Loading CMakeLists.txt +3 −1 Changes for CMakeLists.txt: 3 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -92,11 +92,13 @@ webctl_module(updates modules/updates/updates.cpp modules/common/jobs.cpp) webctl_module(shares modules/shares/shares.cpp) target_link_libraries(webctl_mod_shares PRIVATE sysbus::sysbus) # storage modules: clients of storaged (modules/common/storaged.cpp), # which does RAID and LVM; needs storaged/api.h (storaged-dev) # which does RAID, LVM and SMART; needs storaged/api.h (storaged-dev) webctl_module(raid modules/raid/raid.cpp modules/common/storaged.cpp) target_link_libraries(webctl_mod_raid PRIVATE sysbus::sysbus) webctl_module(lvm modules/lvm/lvm.cpp modules/common/storaged.cpp) target_link_libraries(webctl_mod_lvm PRIVATE sysbus::sysbus) webctl_module(disks modules/disks/disks.cpp modules/common/storaged.cpp) target_link_libraries(webctl_mod_disks PRIVATE sysbus::sysbus) # mounts are syslite .mount units (sysbus CreateMount); mke2fs/mkfs.fat # format; modules/common/blockdev.cpp: free devices, guarded probing webctl_module(filesystems modules/filesystems/filesystems.cpp modules/common/blockdev.cpp) Loading modules/disks/disks.cpp 0 → 100644 +139 −0 Changes for modules/disks/disks.cpp: 139 added lines, 0 removed lines. Original line number Diff line number Diff line /******************************************************************************* Copyright (c) 2026, Jan Koester jan.koester@gmx.net All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the <organization> nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. *******************************************************************************/ /* webctl module "disks": the drives' SMART data, read by storaged over its * sysbus API (include/storaged/api.h). * * list -> {disks: [...]} (no attributes) * get {disk, wake?} -> {disk: {..., attributes: [...]}} * selfTest {disk, type: short|long} * abortSelfTest {disk} * * A disk: name, size, model, serial, firmware, transport (ata|nvme|none), * smart, enabled, rotation, power, health (passed|failing|unknown), * temperature, powerOnHours, ... (camelCase of storaged's fields; numbers * as numbers), warnings [...], readAt, problem. */ #include <cstdlib> #include <set> #include <string> #include <storaged/api.h> #include <sysbus/message.h> #include <webctl/module.h> #include "storaged.h" using webctl::error; using webctl::jstr; using webctl::tr; namespace storage = webctl::storage; namespace { const std::set<std::string> Flags = {"smart", "enabled", "self_test_supported", "prefail"}; const std::set<std::string> Numbers = { "size", "temperature", "power_on_hours", "power_cycles", "reallocated", "pending", "uncorrectable", "crc_errors", "critical_warning", "available_spare", "spare_threshold", "percentage_used", "data_read", "data_written", "unsafe_shutdowns", "media_errors", "error_log_entries", "self_test_remaining", "short_test_minutes", "long_test_minutes", "read_at", "value", "worst", "threshold", "raw", "attr"}; std::string camel(const std::string &k) { std::string out; for (size_t i = 0; i < k.size(); ++i) { if (k[i] == '_' && i + 1 < k.size()) out += (char)toupper((unsigned char)k[++i]); else out += k[i]; } return out; } void put(json_object *o, const std::string &k, const std::string &v) { std::string name = k == "attr" ? "id" : k == "disk" ? "name" : camel(k); if (Flags.count(k)) json_object_object_add(o, name.c_str(), json_object_new_boolean(v == "yes")); else if (Numbers.count(k)) json_object_object_add(o, name.c_str(), json_object_new_int64(std::strtoll(v.c_str(), nullptr, 10))); else json_object_object_add(o, name.c_str(), jstr(v)); } // the flat reply as disks (with attributes and warnings as lists) json_object *disks(const sysbus::Message &r) { json_object *list = json_object_new_array(); json_object *disk = nullptr, *attr = nullptr; for (auto &f : r.fields) { if (f.first == "disk") { disk = json_object_new_object(); json_object_object_add(disk, "warnings", json_object_new_array()); json_object_object_add(disk, "attributes", json_object_new_array()); json_object_array_add(list, disk); attr = nullptr; } else if (!disk) { continue; } else if (f.first == "attr") { attr = json_object_new_object(); json_object_array_add(json_object_object_get(disk, "attributes"), attr); } else if (f.first == "warning") { json_object_array_add(json_object_object_get(disk, "warnings"), jstr(f.second)); continue; } put(attr ? attr : disk, f.first, f.second); } return list; } json_object *call(const webctl_request *req) { webctl::LanguageScope lang(req->lang); std::string action = req->action; json_object *a = req->args; namespace m = storaged::method; std::string disk = webctl::str(a, "disk"); if (action == "list" || action == "get") { sysbus::Message r; storage::Fields f; if (action == "get") { if (disk.empty()) return error(tr("no disk", "keine Festplatte angegeben")); f = {{"disk", disk}, {"wake", webctl::boolean(a, "wake") ? "yes" : "no"}}; } if (json_object *e = storage::call(action == "list" ? m::ListSmart : m::GetSmart, f, r)) return e; json_object *list = disks(r); json_object *out = json_object_new_object(); if (action == "list") { json_object_object_add(out, "disks", list); } else { json_object_object_add(out, "disk", json_object_get(json_object_array_get_idx(list, 0))); json_object_put(list); } return out; } if (disk.empty()) return error(tr("no disk", "keine Festplatte angegeben")); if (action == "selfTest") return storage::change(m::StartSelfTest, {{"disk", disk}, {"type", webctl::str(a, "type", "short")}}); if (action == "abortSelfTest") return storage::change(m::AbortSelfTest, {{"disk", disk}}); return error(tr("unknown action %1", "unbekannte Aktion %1", {action}), 404); } } // namespace WEBCTL_MODULE({WEBCTL_MODULE_ABI, "disks", "Disks", 26, nullptr, nullptr, call}); www/app.css +4 −0 Changes for www/app.css: 4 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -176,6 +176,10 @@ table.perm-grid th, table.perm-grid td { text-align: center; padding: .3em .8em; table.perm-grid tbody th { text-align: left; } /* ---- raid: warnings, sync progress ---- */ /* key/value lists (details) */ .kv { display: grid; grid-template-columns: max-content 1fr; gap: .25em 1em; margin: 0 0 1em; } .kv dt { color: var(--muted); } .kv dd { margin: 0; overflow-wrap: anywhere; } .callout { border: 1px solid var(--line); border-left-width: 4px; border-radius: var(--radius); padding: .6em .8em; margin: 0 0 .9em; background: var(--surface); } .callout p { margin: .3em 0 0; } .callout.warn { border-color: #e1d5b6; border-left-color: var(--warn); background: #f6f1e4; } Loading www/modules/disks.js 0 → 100644 +202 −0 Changes for www/modules/disks.js: 202 added lines, 0 removed lines. Original line number Diff line number Diff line 'use strict'; // Disks: SMART health (backend module "disks", read by storaged) (() => { const {h, t, table, dialog, confirmAction, call, run, toast} = webctl; webctl.translations({de: { 'Disks': 'Laufwerke', 'Disk': 'Laufwerk', 'Size': 'Größe', 'Health': 'Zustand', 'Temperature': 'Temperatur', 'Powered on': 'Betriebszeit', 'Self-test': 'Selbsttest', 'Warnings': 'Warnungen', 'No disks found.': 'Keine Laufwerke gefunden.', 'OK': 'OK', 'Failing': 'Fällt aus', 'Warning': 'Warnung', 'No SMART': 'Kein SMART', 'unknown': 'unbekannt', 'standby': 'Ruhezustand', 'values from {0}': 'Werte vom {0}', 'The disk sleeps; it isn\'t woken up to be read.': 'Das Laufwerk ruht; es wird zum Lesen nicht geweckt.', 'Details': 'Details', 'Short test': 'Kurztest', 'Long test': 'Langtest', 'Abort test': 'Test abbrechen', 'passed': 'bestanden', 'failed': 'fehlgeschlagen', 'aborted': 'abgebrochen', 'interrupted': 'unterbrochen', 'running': 'läuft', 'none': 'keiner', 'running, {0} % left': 'läuft, noch {0} %', '{0} h': '{0} h', '{0} days': '{0} Tage', '{0} years': '{0} Jahre', 'Start a short self-test on {0}?': 'Kurzen Selbsttest auf {0} starten?', 'Start a long self-test on {0}?': 'Langen Selbsttest auf {0} starten?', 'The drive tests itself while it keeps working; it may be slower meanwhile. Takes about {0} minutes.': 'Das Laufwerk prüft sich selbst und arbeitet dabei weiter, eventuell etwas langsamer. Dauer etwa {0} Minuten.', 'Start': 'Starten', 'Self-test started on {0}.': 'Selbsttest auf {0} gestartet.', 'Self-test on {0} aborted.': 'Selbsttest auf {0} abgebrochen.', 'SMART of {0}': 'SMART von {0}', 'Close': 'Schließen', 'Read now (wakes the disk)': 'Jetzt lesen (weckt das Laufwerk)', 'Model': 'Modell', 'Serial number': 'Seriennummer', 'Firmware': 'Firmware', 'Connection': 'Anschluss', 'Type': 'Typ', 'SSD': 'SSD', '{0} rpm': '{0} U/min', 'Power cycles': 'Einschaltvorgänge', 'Reallocated sectors': 'Ersetzte Sektoren', 'Pending sectors': 'Wartende Sektoren', 'Uncorrectable sectors': 'Nicht korrigierbare Sektoren', 'Interface CRC errors': 'Schnittstellen-CRC-Fehler', 'Wear': 'Verschleiß', 'Spare': 'Reserve', '{0} % (threshold {1} %)': '{0} % (Grenze {1} %)', 'Read': 'Gelesen', 'Written': 'Geschrieben', 'Unsafe shutdowns': 'Unsaubere Abschaltungen', 'Media errors': 'Medienfehler', 'Error log entries': 'Fehlerprotokoll-Einträge', 'Attributes': 'Attribute', 'ID': 'ID', 'Attribute': 'Attribut', 'Value': 'Wert', 'Worst': 'Schlechtester', 'Threshold': 'Grenze', 'Raw': 'Rohwert', 'failing now': 'unter Grenzwert', 'failed in the past': 'früher unter Grenzwert', 'pre-failure': 'Ausfallvorhersage', 'Problem': 'Problem', 'SMART is switched off on the drive': 'SMART ist auf dem Laufwerk abgeschaltet', 'the drive has no SMART': 'das Laufwerk hat kein SMART', 'no SMART on this kind of device': 'kein SMART bei dieser Art Gerät', 'not responding': 'antwortet nicht', 'storaged checks all disks every half hour and logs new warnings in the journal.': 'storaged prüft alle Laufwerke jede halbe Stunde und schreibt neue Warnungen ins Journal.', }}); let root, timer; let disks = []; function size(n) { const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; let i = 0; while (n >= 1000 && i < units.length - 1) { n /= 1000; i++; } return (i ? n.toFixed(n < 10 ? 1 : 0) : n) + ' ' + units[i]; } function hours(n) { if (n === undefined || n < 0) return '–'; if (n < 48) return t('{0} h', n); if (n < 24 * 730) return t('{0} days', Math.round(n / 24)); return t('{0} years', (n / 24 / 365).toFixed(1)); } const when = s => new Date(s * 1000).toLocaleString(); const smart = d => d.smart && d.transport !== 'none'; function health(d) { if (!smart(d)) return h('span', {class: 'badge', title: d.problem ? t(d.problem) : ''}, t('No SMART')); if (d.health === 'failing') return h('span', {class: 'badge danger'}, t('Failing')); if (d.warnings.length) return h('span', {class: 'badge warn'}, t('Warning')); if (d.health === 'passed') return h('span', {class: 'badge ok'}, t('OK')); return h('span', {class: 'badge'}, t('unknown')); } function testText(d) { if (!d.selfTestSupported || !d.selfTest) return '–'; if (d.selfTest === 'running') return d.selfTestRemaining !== undefined ? t('running, {0} % left', d.selfTestRemaining) : t('running'); return t(d.selfTest); } // asleep, or a reading that isn't fresh function freshness(d) { const out = []; if (d.power === 'standby') out.push(h('span', {class: 'badge', title: t('The disk sleeps; it isn\'t woken up to be read.')}, t('standby'))); if (d.problem && smart(d)) out.push(h('span', {class: 'badge warn'}, t(d.problem))); if (d.readAt && Date.now() / 1000 - d.readAt > 120) out.push(h('div', {class: 'muted'}, t('values from {0}', when(d.readAt)))); return out; } async function selfTest(d, type) { const minutes = type === 'long' ? d.longTestMinutes : d.shortTestMinutes; const ok = await confirmAction(t(type === 'long' ? 'Start a long self-test on {0}?' : 'Start a short self-test on {0}?', d.name), t('The drive tests itself while it keeps working; it may be slower meanwhile. Takes about {0} minutes.', minutes || (type === 'long' ? '60+' : 2)), t('Start')); if (!ok) return; if (await run(() => call('disks', 'selfTest', {disk: d.name, type}), t('Self-test started on {0}.', d.name)) !== undefined) load(); } async function abortTest(d) { if (await run(() => call('disks', 'abortSelfTest', {disk: d.name}), t('Self-test on {0} aborted.', d.name)) !== undefined) load(); } function infoRows(d) { const rows = []; const add = (label, value) => { if (value !== undefined && value !== '' && value !== null) rows.push([label, value]); }; add(t('Model'), d.model); add(t('Serial number'), d.serial); add(t('Firmware'), d.firmware); add(t('Connection'), d.transport === 'nvme' ? 'NVMe' : d.transport === 'ata' ? 'ATA' : undefined); add(t('Type'), d.rotation === 'ssd' ? t('SSD') : d.rotation ? t('{0} rpm', d.rotation) : undefined); add(t('Size'), d.size ? size(d.size) : undefined); add(t('Temperature'), d.temperature !== undefined ? d.temperature + ' °C' : undefined); add(t('Powered on'), d.powerOnHours !== undefined ? hours(d.powerOnHours) : undefined); add(t('Power cycles'), d.powerCycles); add(t('Reallocated sectors'), d.reallocated); add(t('Pending sectors'), d.pending); add(t('Uncorrectable sectors'), d.uncorrectable); add(t('Interface CRC errors'), d.crcErrors); add(t('Wear'), d.percentageUsed !== undefined ? d.percentageUsed + ' %' : undefined); add(t('Spare'), d.availableSpare !== undefined ? t('{0} % (threshold {1} %)', d.availableSpare, d.spareThreshold) : undefined); add(t('Read'), d.dataRead !== undefined ? size(d.dataRead) : undefined); add(t('Written'), d.dataWritten !== undefined ? size(d.dataWritten) : undefined); add(t('Unsafe shutdowns'), d.unsafeShutdowns); add(t('Media errors'), d.mediaErrors); add(t('Error log entries'), d.errorLogEntries); add(t('Self-test'), testText(d)); add(t('Problem'), d.problem ? t(d.problem) : undefined); return rows; } async function details(d, wake) { const r = await run(() => call('disks', 'get', {disk: d.name, wake: !!wake})); if (!r || !r.disk) return; const x = r.disk; dialog({ title: t('SMART of {0}', '/dev/' + x.name), submit: t('Close'), fields: () => [ h('div', {class: 'toolbar'}, health(x), ...freshness(x), x.power === 'standby' ? h('button', {type: 'button', class: 'small', onclick: e => { e.target.closest('dialog').close(); e.target.closest('dialog').remove(); details(x, true); }}, t('Read now (wakes the disk)')) : null), x.warnings.length ? h('div', {class: 'callout warn'}, x.warnings.map(w => h('div', {}, w))) : null, h('dl', {class: 'kv'}, infoRows(x).map(([k, v]) => [h('dt', {}, k), h('dd', {}, String(v))])), x.attributes.length ? h('h3', {}, t('Attributes')) : null, x.attributes.length ? table([ {title: t('ID'), render: a => a.id}, {title: t('Attribute'), render: a => [a.name, a.prefail ? h('div', {class: 'muted'}, t('pre-failure')) : null]}, {title: t('Value'), render: a => a.value}, {title: t('Worst'), render: a => a.worst}, {title: t('Threshold'), render: a => a.threshold}, {title: t('Raw'), class: 'mono', render: a => a.raw}, {title: '', render: a => a.failing === 'now' ? h('span', {class: 'badge danger'}, t('failing now')) : a.failing === 'past' ? h('span', {class: 'badge warn'}, t('failed in the past')) : null}, ], x.attributes) : null, ], onSubmit: () => {}, }); } function actions(d) { if (!smart(d)) return null; return [ h('button', {class: 'small', onclick: () => details(d)}, t('Details')), d.selfTestSupported && d.selfTest !== 'running' ? [h('button', {class: 'small', onclick: () => selfTest(d, 'short')}, t('Short test')), h('button', {class: 'small', onclick: () => selfTest(d, 'long')}, t('Long test'))] : null, d.selfTest === 'running' ? h('button', {class: 'small danger', onclick: () => abortTest(d)}, t('Abort test')) : null, ]; } function render() { root.replaceChildren(h('section', {class: 'panel'}, h('div', {class: 'toolbar'}, h('h2', {}, t('Disks')), h('button', {onclick: () => load()}, t('Refresh'))), table([ {title: t('Disk'), render: d => [h('strong', {class: 'mono'}, '/dev/' + d.name), h('div', {class: 'muted'}, [d.model, d.serial].filter(Boolean).join(' · '))]}, {title: t('Size'), render: d => d.size ? size(d.size) : '–'}, {title: t('Health'), render: d => [health(d), ...freshness(d)]}, {title: t('Temperature'), render: d => d.temperature !== undefined ? d.temperature + ' °C' : '–'}, {title: t('Powered on'), render: d => hours(d.powerOnHours)}, {title: t('Self-test'), render: testText}, {title: t('Warnings'), render: d => d.warnings.length ? d.warnings.map(w => h('div', {}, w)) : '–'}, {title: '', class: 'actions', render: actions}, ], disks, t('No disks found.')), h('p', {class: 'hint'}, t('storaged checks all disks every half hour and logs new warnings in the journal.')))); } async function load() { clearTimeout(timer); const r = await run(() => call('disks', 'list')); if (r) { disks = r.disks || []; if (!document.querySelector('dialog')) render(); } // a running self-test: follow it more closely timer = setTimeout(load, disks.some(d => d.selfTest === 'running') ? 15000 : 60000); } webctl.register('disks', { render(container) { root = container; root.append(h('p', {class: 'empty'}, t('Loading …'))); load(); return () => clearTimeout(timer); }, }); })(); Loading
CMakeLists.txt +3 −1 Changes for CMakeLists.txt: 3 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -92,11 +92,13 @@ webctl_module(updates modules/updates/updates.cpp modules/common/jobs.cpp) webctl_module(shares modules/shares/shares.cpp) target_link_libraries(webctl_mod_shares PRIVATE sysbus::sysbus) # storage modules: clients of storaged (modules/common/storaged.cpp), # which does RAID and LVM; needs storaged/api.h (storaged-dev) # which does RAID, LVM and SMART; needs storaged/api.h (storaged-dev) webctl_module(raid modules/raid/raid.cpp modules/common/storaged.cpp) target_link_libraries(webctl_mod_raid PRIVATE sysbus::sysbus) webctl_module(lvm modules/lvm/lvm.cpp modules/common/storaged.cpp) target_link_libraries(webctl_mod_lvm PRIVATE sysbus::sysbus) webctl_module(disks modules/disks/disks.cpp modules/common/storaged.cpp) target_link_libraries(webctl_mod_disks PRIVATE sysbus::sysbus) # mounts are syslite .mount units (sysbus CreateMount); mke2fs/mkfs.fat # format; modules/common/blockdev.cpp: free devices, guarded probing webctl_module(filesystems modules/filesystems/filesystems.cpp modules/common/blockdev.cpp) Loading
modules/disks/disks.cpp 0 → 100644 +139 −0 Changes for modules/disks/disks.cpp: 139 added lines, 0 removed lines. Original line number Diff line number Diff line /******************************************************************************* Copyright (c) 2026, Jan Koester jan.koester@gmx.net All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the <organization> nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. *******************************************************************************/ /* webctl module "disks": the drives' SMART data, read by storaged over its * sysbus API (include/storaged/api.h). * * list -> {disks: [...]} (no attributes) * get {disk, wake?} -> {disk: {..., attributes: [...]}} * selfTest {disk, type: short|long} * abortSelfTest {disk} * * A disk: name, size, model, serial, firmware, transport (ata|nvme|none), * smart, enabled, rotation, power, health (passed|failing|unknown), * temperature, powerOnHours, ... (camelCase of storaged's fields; numbers * as numbers), warnings [...], readAt, problem. */ #include <cstdlib> #include <set> #include <string> #include <storaged/api.h> #include <sysbus/message.h> #include <webctl/module.h> #include "storaged.h" using webctl::error; using webctl::jstr; using webctl::tr; namespace storage = webctl::storage; namespace { const std::set<std::string> Flags = {"smart", "enabled", "self_test_supported", "prefail"}; const std::set<std::string> Numbers = { "size", "temperature", "power_on_hours", "power_cycles", "reallocated", "pending", "uncorrectable", "crc_errors", "critical_warning", "available_spare", "spare_threshold", "percentage_used", "data_read", "data_written", "unsafe_shutdowns", "media_errors", "error_log_entries", "self_test_remaining", "short_test_minutes", "long_test_minutes", "read_at", "value", "worst", "threshold", "raw", "attr"}; std::string camel(const std::string &k) { std::string out; for (size_t i = 0; i < k.size(); ++i) { if (k[i] == '_' && i + 1 < k.size()) out += (char)toupper((unsigned char)k[++i]); else out += k[i]; } return out; } void put(json_object *o, const std::string &k, const std::string &v) { std::string name = k == "attr" ? "id" : k == "disk" ? "name" : camel(k); if (Flags.count(k)) json_object_object_add(o, name.c_str(), json_object_new_boolean(v == "yes")); else if (Numbers.count(k)) json_object_object_add(o, name.c_str(), json_object_new_int64(std::strtoll(v.c_str(), nullptr, 10))); else json_object_object_add(o, name.c_str(), jstr(v)); } // the flat reply as disks (with attributes and warnings as lists) json_object *disks(const sysbus::Message &r) { json_object *list = json_object_new_array(); json_object *disk = nullptr, *attr = nullptr; for (auto &f : r.fields) { if (f.first == "disk") { disk = json_object_new_object(); json_object_object_add(disk, "warnings", json_object_new_array()); json_object_object_add(disk, "attributes", json_object_new_array()); json_object_array_add(list, disk); attr = nullptr; } else if (!disk) { continue; } else if (f.first == "attr") { attr = json_object_new_object(); json_object_array_add(json_object_object_get(disk, "attributes"), attr); } else if (f.first == "warning") { json_object_array_add(json_object_object_get(disk, "warnings"), jstr(f.second)); continue; } put(attr ? attr : disk, f.first, f.second); } return list; } json_object *call(const webctl_request *req) { webctl::LanguageScope lang(req->lang); std::string action = req->action; json_object *a = req->args; namespace m = storaged::method; std::string disk = webctl::str(a, "disk"); if (action == "list" || action == "get") { sysbus::Message r; storage::Fields f; if (action == "get") { if (disk.empty()) return error(tr("no disk", "keine Festplatte angegeben")); f = {{"disk", disk}, {"wake", webctl::boolean(a, "wake") ? "yes" : "no"}}; } if (json_object *e = storage::call(action == "list" ? m::ListSmart : m::GetSmart, f, r)) return e; json_object *list = disks(r); json_object *out = json_object_new_object(); if (action == "list") { json_object_object_add(out, "disks", list); } else { json_object_object_add(out, "disk", json_object_get(json_object_array_get_idx(list, 0))); json_object_put(list); } return out; } if (disk.empty()) return error(tr("no disk", "keine Festplatte angegeben")); if (action == "selfTest") return storage::change(m::StartSelfTest, {{"disk", disk}, {"type", webctl::str(a, "type", "short")}}); if (action == "abortSelfTest") return storage::change(m::AbortSelfTest, {{"disk", disk}}); return error(tr("unknown action %1", "unbekannte Aktion %1", {action}), 404); } } // namespace WEBCTL_MODULE({WEBCTL_MODULE_ABI, "disks", "Disks", 26, nullptr, nullptr, call});
www/app.css +4 −0 Changes for www/app.css: 4 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -176,6 +176,10 @@ table.perm-grid th, table.perm-grid td { text-align: center; padding: .3em .8em; table.perm-grid tbody th { text-align: left; } /* ---- raid: warnings, sync progress ---- */ /* key/value lists (details) */ .kv { display: grid; grid-template-columns: max-content 1fr; gap: .25em 1em; margin: 0 0 1em; } .kv dt { color: var(--muted); } .kv dd { margin: 0; overflow-wrap: anywhere; } .callout { border: 1px solid var(--line); border-left-width: 4px; border-radius: var(--radius); padding: .6em .8em; margin: 0 0 .9em; background: var(--surface); } .callout p { margin: .3em 0 0; } .callout.warn { border-color: #e1d5b6; border-left-color: var(--warn); background: #f6f1e4; } Loading
www/modules/disks.js 0 → 100644 +202 −0 Changes for www/modules/disks.js: 202 added lines, 0 removed lines. Original line number Diff line number Diff line 'use strict'; // Disks: SMART health (backend module "disks", read by storaged) (() => { const {h, t, table, dialog, confirmAction, call, run, toast} = webctl; webctl.translations({de: { 'Disks': 'Laufwerke', 'Disk': 'Laufwerk', 'Size': 'Größe', 'Health': 'Zustand', 'Temperature': 'Temperatur', 'Powered on': 'Betriebszeit', 'Self-test': 'Selbsttest', 'Warnings': 'Warnungen', 'No disks found.': 'Keine Laufwerke gefunden.', 'OK': 'OK', 'Failing': 'Fällt aus', 'Warning': 'Warnung', 'No SMART': 'Kein SMART', 'unknown': 'unbekannt', 'standby': 'Ruhezustand', 'values from {0}': 'Werte vom {0}', 'The disk sleeps; it isn\'t woken up to be read.': 'Das Laufwerk ruht; es wird zum Lesen nicht geweckt.', 'Details': 'Details', 'Short test': 'Kurztest', 'Long test': 'Langtest', 'Abort test': 'Test abbrechen', 'passed': 'bestanden', 'failed': 'fehlgeschlagen', 'aborted': 'abgebrochen', 'interrupted': 'unterbrochen', 'running': 'läuft', 'none': 'keiner', 'running, {0} % left': 'läuft, noch {0} %', '{0} h': '{0} h', '{0} days': '{0} Tage', '{0} years': '{0} Jahre', 'Start a short self-test on {0}?': 'Kurzen Selbsttest auf {0} starten?', 'Start a long self-test on {0}?': 'Langen Selbsttest auf {0} starten?', 'The drive tests itself while it keeps working; it may be slower meanwhile. Takes about {0} minutes.': 'Das Laufwerk prüft sich selbst und arbeitet dabei weiter, eventuell etwas langsamer. Dauer etwa {0} Minuten.', 'Start': 'Starten', 'Self-test started on {0}.': 'Selbsttest auf {0} gestartet.', 'Self-test on {0} aborted.': 'Selbsttest auf {0} abgebrochen.', 'SMART of {0}': 'SMART von {0}', 'Close': 'Schließen', 'Read now (wakes the disk)': 'Jetzt lesen (weckt das Laufwerk)', 'Model': 'Modell', 'Serial number': 'Seriennummer', 'Firmware': 'Firmware', 'Connection': 'Anschluss', 'Type': 'Typ', 'SSD': 'SSD', '{0} rpm': '{0} U/min', 'Power cycles': 'Einschaltvorgänge', 'Reallocated sectors': 'Ersetzte Sektoren', 'Pending sectors': 'Wartende Sektoren', 'Uncorrectable sectors': 'Nicht korrigierbare Sektoren', 'Interface CRC errors': 'Schnittstellen-CRC-Fehler', 'Wear': 'Verschleiß', 'Spare': 'Reserve', '{0} % (threshold {1} %)': '{0} % (Grenze {1} %)', 'Read': 'Gelesen', 'Written': 'Geschrieben', 'Unsafe shutdowns': 'Unsaubere Abschaltungen', 'Media errors': 'Medienfehler', 'Error log entries': 'Fehlerprotokoll-Einträge', 'Attributes': 'Attribute', 'ID': 'ID', 'Attribute': 'Attribut', 'Value': 'Wert', 'Worst': 'Schlechtester', 'Threshold': 'Grenze', 'Raw': 'Rohwert', 'failing now': 'unter Grenzwert', 'failed in the past': 'früher unter Grenzwert', 'pre-failure': 'Ausfallvorhersage', 'Problem': 'Problem', 'SMART is switched off on the drive': 'SMART ist auf dem Laufwerk abgeschaltet', 'the drive has no SMART': 'das Laufwerk hat kein SMART', 'no SMART on this kind of device': 'kein SMART bei dieser Art Gerät', 'not responding': 'antwortet nicht', 'storaged checks all disks every half hour and logs new warnings in the journal.': 'storaged prüft alle Laufwerke jede halbe Stunde und schreibt neue Warnungen ins Journal.', }}); let root, timer; let disks = []; function size(n) { const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; let i = 0; while (n >= 1000 && i < units.length - 1) { n /= 1000; i++; } return (i ? n.toFixed(n < 10 ? 1 : 0) : n) + ' ' + units[i]; } function hours(n) { if (n === undefined || n < 0) return '–'; if (n < 48) return t('{0} h', n); if (n < 24 * 730) return t('{0} days', Math.round(n / 24)); return t('{0} years', (n / 24 / 365).toFixed(1)); } const when = s => new Date(s * 1000).toLocaleString(); const smart = d => d.smart && d.transport !== 'none'; function health(d) { if (!smart(d)) return h('span', {class: 'badge', title: d.problem ? t(d.problem) : ''}, t('No SMART')); if (d.health === 'failing') return h('span', {class: 'badge danger'}, t('Failing')); if (d.warnings.length) return h('span', {class: 'badge warn'}, t('Warning')); if (d.health === 'passed') return h('span', {class: 'badge ok'}, t('OK')); return h('span', {class: 'badge'}, t('unknown')); } function testText(d) { if (!d.selfTestSupported || !d.selfTest) return '–'; if (d.selfTest === 'running') return d.selfTestRemaining !== undefined ? t('running, {0} % left', d.selfTestRemaining) : t('running'); return t(d.selfTest); } // asleep, or a reading that isn't fresh function freshness(d) { const out = []; if (d.power === 'standby') out.push(h('span', {class: 'badge', title: t('The disk sleeps; it isn\'t woken up to be read.')}, t('standby'))); if (d.problem && smart(d)) out.push(h('span', {class: 'badge warn'}, t(d.problem))); if (d.readAt && Date.now() / 1000 - d.readAt > 120) out.push(h('div', {class: 'muted'}, t('values from {0}', when(d.readAt)))); return out; } async function selfTest(d, type) { const minutes = type === 'long' ? d.longTestMinutes : d.shortTestMinutes; const ok = await confirmAction(t(type === 'long' ? 'Start a long self-test on {0}?' : 'Start a short self-test on {0}?', d.name), t('The drive tests itself while it keeps working; it may be slower meanwhile. Takes about {0} minutes.', minutes || (type === 'long' ? '60+' : 2)), t('Start')); if (!ok) return; if (await run(() => call('disks', 'selfTest', {disk: d.name, type}), t('Self-test started on {0}.', d.name)) !== undefined) load(); } async function abortTest(d) { if (await run(() => call('disks', 'abortSelfTest', {disk: d.name}), t('Self-test on {0} aborted.', d.name)) !== undefined) load(); } function infoRows(d) { const rows = []; const add = (label, value) => { if (value !== undefined && value !== '' && value !== null) rows.push([label, value]); }; add(t('Model'), d.model); add(t('Serial number'), d.serial); add(t('Firmware'), d.firmware); add(t('Connection'), d.transport === 'nvme' ? 'NVMe' : d.transport === 'ata' ? 'ATA' : undefined); add(t('Type'), d.rotation === 'ssd' ? t('SSD') : d.rotation ? t('{0} rpm', d.rotation) : undefined); add(t('Size'), d.size ? size(d.size) : undefined); add(t('Temperature'), d.temperature !== undefined ? d.temperature + ' °C' : undefined); add(t('Powered on'), d.powerOnHours !== undefined ? hours(d.powerOnHours) : undefined); add(t('Power cycles'), d.powerCycles); add(t('Reallocated sectors'), d.reallocated); add(t('Pending sectors'), d.pending); add(t('Uncorrectable sectors'), d.uncorrectable); add(t('Interface CRC errors'), d.crcErrors); add(t('Wear'), d.percentageUsed !== undefined ? d.percentageUsed + ' %' : undefined); add(t('Spare'), d.availableSpare !== undefined ? t('{0} % (threshold {1} %)', d.availableSpare, d.spareThreshold) : undefined); add(t('Read'), d.dataRead !== undefined ? size(d.dataRead) : undefined); add(t('Written'), d.dataWritten !== undefined ? size(d.dataWritten) : undefined); add(t('Unsafe shutdowns'), d.unsafeShutdowns); add(t('Media errors'), d.mediaErrors); add(t('Error log entries'), d.errorLogEntries); add(t('Self-test'), testText(d)); add(t('Problem'), d.problem ? t(d.problem) : undefined); return rows; } async function details(d, wake) { const r = await run(() => call('disks', 'get', {disk: d.name, wake: !!wake})); if (!r || !r.disk) return; const x = r.disk; dialog({ title: t('SMART of {0}', '/dev/' + x.name), submit: t('Close'), fields: () => [ h('div', {class: 'toolbar'}, health(x), ...freshness(x), x.power === 'standby' ? h('button', {type: 'button', class: 'small', onclick: e => { e.target.closest('dialog').close(); e.target.closest('dialog').remove(); details(x, true); }}, t('Read now (wakes the disk)')) : null), x.warnings.length ? h('div', {class: 'callout warn'}, x.warnings.map(w => h('div', {}, w))) : null, h('dl', {class: 'kv'}, infoRows(x).map(([k, v]) => [h('dt', {}, k), h('dd', {}, String(v))])), x.attributes.length ? h('h3', {}, t('Attributes')) : null, x.attributes.length ? table([ {title: t('ID'), render: a => a.id}, {title: t('Attribute'), render: a => [a.name, a.prefail ? h('div', {class: 'muted'}, t('pre-failure')) : null]}, {title: t('Value'), render: a => a.value}, {title: t('Worst'), render: a => a.worst}, {title: t('Threshold'), render: a => a.threshold}, {title: t('Raw'), class: 'mono', render: a => a.raw}, {title: '', render: a => a.failing === 'now' ? h('span', {class: 'badge danger'}, t('failing now')) : a.failing === 'past' ? h('span', {class: 'badge warn'}, t('failed in the past')) : null}, ], x.attributes) : null, ], onSubmit: () => {}, }); } function actions(d) { if (!smart(d)) return null; return [ h('button', {class: 'small', onclick: () => details(d)}, t('Details')), d.selfTestSupported && d.selfTest !== 'running' ? [h('button', {class: 'small', onclick: () => selfTest(d, 'short')}, t('Short test')), h('button', {class: 'small', onclick: () => selfTest(d, 'long')}, t('Long test'))] : null, d.selfTest === 'running' ? h('button', {class: 'small danger', onclick: () => abortTest(d)}, t('Abort test')) : null, ]; } function render() { root.replaceChildren(h('section', {class: 'panel'}, h('div', {class: 'toolbar'}, h('h2', {}, t('Disks')), h('button', {onclick: () => load()}, t('Refresh'))), table([ {title: t('Disk'), render: d => [h('strong', {class: 'mono'}, '/dev/' + d.name), h('div', {class: 'muted'}, [d.model, d.serial].filter(Boolean).join(' · '))]}, {title: t('Size'), render: d => d.size ? size(d.size) : '–'}, {title: t('Health'), render: d => [health(d), ...freshness(d)]}, {title: t('Temperature'), render: d => d.temperature !== undefined ? d.temperature + ' °C' : '–'}, {title: t('Powered on'), render: d => hours(d.powerOnHours)}, {title: t('Self-test'), render: testText}, {title: t('Warnings'), render: d => d.warnings.length ? d.warnings.map(w => h('div', {}, w)) : '–'}, {title: '', class: 'actions', render: actions}, ], disks, t('No disks found.')), h('p', {class: 'hint'}, t('storaged checks all disks every half hour and logs new warnings in the journal.')))); } async function load() { clearTimeout(timer); const r = await run(() => call('disks', 'list')); if (r) { disks = r.disks || []; if (!document.querySelector('dialog')) render(); } // a running self-test: follow it more closely timer = setTimeout(load, disks.some(d => d.selfTest === 'running') ? 15000 : 60000); } webctl.register('disks', { render(container) { root = container; root.append(h('p', {class: 'empty'}, t('Loading …'))); load(); return () => clearTimeout(timer); }, }); })();