Migrate to Python 3
[collecty.git] / src / collecty / plugins / disk.py
1 #!/usr/bin/python3
2 ###############################################################################
3 #                                                                             #
4 # collecty - A system statistics collection daemon for IPFire                 #
5 # Copyright (C) 2012 IPFire development team                                  #
6 #                                                                             #
7 # This program is free software: you can redistribute it and/or modify        #
8 # it under the terms of the GNU General Public License as published by        #
9 # the Free Software Foundation, either version 3 of the License, or           #
10 # (at your option) any later version.                                         #
11 #                                                                             #
12 # This program is distributed in the hope that it will be useful,             #
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of              #
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the               #
15 # GNU General Public License for more details.                                #
16 #                                                                             #
17 # You should have received a copy of the GNU General Public License           #
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.       #
19 #                                                                             #
20 ###############################################################################
21
22 from collecty import _collecty
23 import os
24 import re
25
26 from . import base
27
28 from ..i18n import _
29
30 class GraphTemplateDiskBadSectors(base.GraphTemplate):
31         name = "disk-bad-sectors"
32
33         rrd_graph = [
34                 "DEF:bad_sectors=%(file)s:bad_sectors:AVERAGE",
35
36                 "AREA:bad_sectors#ff0000:%s" % _("Bad Sectors"),
37
38                 "VDEF:bad_sectors_cur=bad_sectors,LAST",
39                 "VDEF:bad_sectors_max=bad_sectors,MAXIMUM",
40                 "GPRINT:bad_sectors_cur:%12s\:" % _("Current") + " %9.2lf",
41                 "GPRINT:bad_sectors_max:%12s\:" % _("Maximum") + " %9.2lf\\n",
42         ]
43
44         @property
45         def graph_title(self):
46                 return _("Bad Sectors of %s") % self.object.device_string
47
48         @property
49         def graph_vertical_label(self):
50                 return _("Pending/Relocated Sectors")
51
52
53 class GraphTemplateDiskBytes(base.GraphTemplate):
54         name = "disk-bytes"
55
56         rrd_graph = [
57                 "DEF:read_sectors=%(file)s:read_sectors:AVERAGE",
58                 "DEF:write_sectors=%(file)s:write_sectors:AVERAGE",
59
60                 "CDEF:read_bytes=read_sectors,512,*",
61                 "CDEF:write_bytes=write_sectors,512,*",
62
63                 "LINE1:read_bytes#ff0000:%-15s" % _("Read"),
64                 "VDEF:read_cur=read_bytes,LAST",
65                 "VDEF:read_min=read_bytes,MINIMUM",
66                 "VDEF:read_max=read_bytes,MAXIMUM",
67                 "VDEF:read_avg=read_bytes,AVERAGE",
68                 "GPRINT:read_cur:%12s\:" % _("Current") + " %9.2lf",
69                 "GPRINT:read_max:%12s\:" % _("Maximum") + " %9.2lf",
70                 "GPRINT:read_min:%12s\:" % _("Minimum") + " %9.2lf",
71                 "GPRINT:read_avg:%12s\:" % _("Average") + " %9.2lf\\n",
72
73                 "LINE1:write_bytes#00ff00:%-15s" % _("Written"),
74                 "VDEF:write_cur=write_bytes,LAST",
75                 "VDEF:write_min=write_bytes,MINIMUM",
76                 "VDEF:write_max=write_bytes,MAXIMUM",
77                 "VDEF:write_avg=write_bytes,AVERAGE",
78                 "GPRINT:write_cur:%12s\:" % _("Current") + " %9.2lf",
79                 "GPRINT:write_max:%12s\:" % _("Maximum") + " %9.2lf",
80                 "GPRINT:write_min:%12s\:" % _("Minimum") + " %9.2lf",
81                 "GPRINT:write_avg:%12s\:" % _("Average") + " %9.2lf\\n",
82         ]
83
84         lower_limit = 0
85
86         @property
87         def graph_title(self):
88                 return _("Disk Utilisation of %s") % self.object.device_string
89
90         @property
91         def graph_vertical_label(self):
92                 return _("Byte per Second")
93
94
95 class GraphTemplateDiskIoOps(base.GraphTemplate):
96         name = "disk-io-ops"
97
98         rrd_graph = [
99                 "DEF:read_ios=%(file)s:read_ios:AVERAGE",
100                 "DEF:write_ios=%(file)s:write_ios:AVERAGE",
101
102                 "LINE1:read_ios#ff0000:%-15s" % _("Read"),
103                 "VDEF:read_cur=read_ios,LAST",
104                 "VDEF:read_min=read_ios,MINIMUM",
105                 "VDEF:read_max=read_ios,MAXIMUM",
106                 "VDEF:read_avg=read_ios,AVERAGE",
107                 "GPRINT:read_cur:%12s\:" % _("Current") + " %6.2lf",
108                 "GPRINT:read_max:%12s\:" % _("Maximum") + " %6.2lf",
109                 "GPRINT:read_min:%12s\:" % _("Minimum") + " %6.2lf",
110                 "GPRINT:read_avg:%12s\:" % _("Average") + " %6.2lf\\n",
111
112                 "LINE1:write_ios#00ff00:%-15s" % _("Written"),
113                 "VDEF:write_cur=write_ios,LAST",
114                 "VDEF:write_min=write_ios,MINIMUM",
115                 "VDEF:write_max=write_ios,MAXIMUM",
116                 "VDEF:write_avg=write_ios,AVERAGE",
117                 "GPRINT:write_cur:%12s\:" % _("Current") + " %6.2lf",
118                 "GPRINT:write_max:%12s\:" % _("Maximum") + " %6.2lf",
119                 "GPRINT:write_min:%12s\:" % _("Minimum") + " %6.2lf",
120                 "GPRINT:write_avg:%12s\:" % _("Average") + " %6.2lf\\n",
121         ]
122
123         lower_limit = 0
124
125         @property
126         def graph_title(self):
127                 return _("Disk IO Operations of %s") % self.object.device_string
128
129         @property
130         def graph_vertical_label(self):
131                 return _("Operations per Second")
132
133
134 class GraphTemplateDiskTemperature(base.GraphTemplate):
135         name = "disk-temperature"
136
137         rrd_graph = [
138                 "DEF:kelvin=%(file)s:temperature:AVERAGE",
139                 "CDEF:celsius=kelvin,273.15,-",
140
141                 "LINE2:celsius#ff0000:%s" % _("Temperature"),
142                 "VDEF:temp_cur=celsius,LAST",
143                 "VDEF:temp_min=celsius,MINIMUM",
144                 "VDEF:temp_max=celsius,MAXIMUM",
145                 "VDEF:temp_avg=celsius,AVERAGE",
146                 "GPRINT:temp_cur:%12s\:" % _("Current") + " %3.2lf",
147                 "GPRINT:temp_max:%12s\:" % _("Maximum") + " %3.2lf",
148                 "GPRINT:temp_min:%12s\:" % _("Minimum") + " %3.2lf",
149                 "GPRINT:temp_avg:%12s\:" % _("Average") + " %3.2lf\\n",
150         ]
151
152         @property
153         def graph_title(self):
154                 return _("Disk Temperature of %s") % self.object.device_string
155
156         @property
157         def graph_vertical_label(self):
158                 return _("° Celsius")
159
160         @property
161         def rrd_graph_args(self):
162                 return [
163                         # Make the y-axis have a decimal
164                         "--left-axis-format", "%3.1lf",
165                 ]
166
167
168 class DiskObject(base.Object):
169         rrd_schema = [
170                 "DS:awake:GAUGE:0:1",
171                 "DS:read_ios:DERIVE:0:U",
172                 "DS:read_sectors:DERIVE:0:U",
173                 "DS:write_ios:DERIVE:0:U",
174                 "DS:write_sectors:DERIVE:0:U",
175                 "DS:bad_sectors:GAUGE:0:U",
176                 "DS:temperature:GAUGE:U:U",
177         ]
178
179         def __repr__(self):
180                 return "<%s %s (%s)>" % (self.__class__.__name__, self.sys_path, self.id)
181
182         def init(self, device):
183                 self.dev_path = os.path.join("/dev", device)
184                 self.sys_path = os.path.join("/sys/block", device)
185
186                 self.device = _collecty.BlockDevice(self.dev_path)
187
188         @property
189         def id(self):
190                 return "-".join((self.device.model, self.device.serial))
191
192         @property
193         def device_string(self):
194                 return "%s (%s)" % (self.device.model, self.dev_path)
195
196         def collect(self):
197                 stats = self.parse_stats()
198
199                 return (
200                         self.is_awake(),
201                         stats.get("read_ios"),
202                         stats.get("read_sectors"),
203                         stats.get("write_ios"),
204                         stats.get("write_sectors"),
205                         self.get_bad_sectors(),
206                         self.get_temperature(),
207                 )
208
209         def parse_stats(self):
210                 """
211                         https://www.kernel.org/doc/Documentation/block/stat.txt
212
213                         Name            units         description
214                         ----            -----         -----------
215                         read I/Os       requests      number of read I/Os processed
216                         read merges     requests      number of read I/Os merged with in-queue I/O
217                         read sectors    sectors       number of sectors read
218                         read ticks      milliseconds  total wait time for read requests
219                         write I/Os      requests      number of write I/Os processed
220                         write merges    requests      number of write I/Os merged with in-queue I/O
221                         write sectors   sectors       number of sectors written
222                         write ticks     milliseconds  total wait time for write requests
223                         in_flight       requests      number of I/Os currently in flight
224                         io_ticks        milliseconds  total time this block device has been active
225                         time_in_queue   milliseconds  total wait time for all requests
226                 """
227                 stats_file = os.path.join(self.sys_path, "stat")
228
229                 with open(stats_file) as f:
230                         stats = f.read().split()
231
232                         return {
233                                 "read_ios"      : stats[0],
234                                 "read_merges"   : stats[1],
235                                 "read_sectors"  : stats[2],
236                                 "read_ticks"    : stats[3],
237                                 "write_ios"     : stats[4],
238                                 "write_merges"  : stats[5],
239                                 "write_sectors" : stats[6],
240                                 "write_ticks"   : stats[7],
241                                 "in_flight"     : stats[8],
242                                 "io_ticks"      : stats[9],
243                                 "time_in_queue" : stats[10],
244                         }
245
246         def is_smart_supported(self):
247                 """
248                         We can only query SMART data if SMART is supported by the disk
249                         and when the disk is awake.
250                 """
251                 return self.device.is_smart_supported() and self.device.is_awake()
252
253         def is_awake(self):
254                 # If SMART is supported we can get the data from the disk
255                 if self.device.is_smart_supported():
256                         if self.device.is_awake():
257                                 return 1
258                         else:
259                                 return 0
260
261                 # Otherwise we just assume that the disk is awake
262                 return 1
263
264         def get_temperature(self):
265                 if not self.is_smart_supported():
266                         return "NaN"
267
268                 return self.device.get_temperature()
269
270         def get_bad_sectors(self):
271                 if not self.is_smart_supported():
272                         return "NaN"
273
274                 return self.device.get_bad_sectors()
275
276
277 class DiskPlugin(base.Plugin):
278         name = "disk"
279         description = "Disk Plugin"
280
281         templates = [
282                 GraphTemplateDiskBadSectors,
283                 GraphTemplateDiskBytes,
284                 GraphTemplateDiskIoOps,
285                 GraphTemplateDiskTemperature,
286         ]
287
288         block_device_patterns = [
289                 re.compile(r"(x?v|s)d[a-z]+"),
290                 re.compile(r"mmcblk[0-9]+"),
291         ]
292
293         @property
294         def objects(self):
295                 for dev in self.find_block_devices():
296                         try:
297                                 yield DiskObject(self, dev)
298                         except OSError:
299                                 pass
300
301         def find_block_devices(self):
302                 for device in os.listdir("/sys/block"):
303                         # Skip invalid device names
304                         if not self._valid_block_device_name(device):
305                                 continue
306
307                         yield device
308
309         def _valid_block_device_name(self, name):
310                 # Check if the given name matches any of the valid patterns.
311                 for pattern in self.block_device_patterns:
312                         if pattern.match(name):
313                                 return True
314
315                 return False