@axium/sysadmin 0.2.6 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/cli.js +15 -2
- package/dist/client/info.js +154 -31
- package/dist/common.d.ts +36 -10
- package/dist/info.d.ts +52 -6
- package/dist/info.js +32 -3
- package/locales/en.json +2 -0
- package/package.json +1 -1
- package/routes/sysadmin/systems/[id]/+page.svelte +109 -5
package/dist/client/cli.js
CHANGED
|
@@ -9,6 +9,9 @@ import './socket.js';
|
|
|
9
9
|
function usage(info) {
|
|
10
10
|
return styleText('blueBright', formatBytes(info.used)) + '/' + styleText('blueBright', formatBytes(info.total));
|
|
11
11
|
}
|
|
12
|
+
function drive(device) {
|
|
13
|
+
return styleText('blueBright', formatBytes(device.size)) + (device.interface ? ' ' + styleText('dim', device.interface) : '');
|
|
14
|
+
}
|
|
12
15
|
const num = (value) => value === undefined ? styleText('red', '<unknown>') : styleText('blueBright', value.toString());
|
|
13
16
|
const tab = ' ', tab2 = ' ';
|
|
14
17
|
function dumpInfo(system, info) {
|
|
@@ -26,9 +29,19 @@ function dumpInfo(system, info) {
|
|
|
26
29
|
console.log('Memory:', usage(memory));
|
|
27
30
|
if (memory.swap)
|
|
28
31
|
console.log(tab, 'Swap:', usage(memory.swap));
|
|
32
|
+
const models = new Map(storage.devices.map(device => [device.name, device]));
|
|
29
33
|
console.log('Storage:');
|
|
30
|
-
for (const
|
|
31
|
-
|
|
34
|
+
for (const volume of storage.volumes) {
|
|
35
|
+
const labels = [volume.filesystem, ...(volume.profile ? [volume.profile.toUpperCase()] : [])];
|
|
36
|
+
console.log(tab, styleText('cyanBright', volume.mountPoints.join(' ')), styleText('dim', labels.join(' ')), usage(volume));
|
|
37
|
+
for (const name of volume.devices) {
|
|
38
|
+
const device = models.get(name);
|
|
39
|
+
console.log(tab2, styleText('yellow', device?.model ?? name), device ? drive(device) : '');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
for (const device of storage.devices.filter(d => storage.volumes.every(v => !v.devices.includes(d.name)))) {
|
|
43
|
+
console.log(tab, styleText('yellow', device.model), drive(device), styleText('dim', '(unused)'));
|
|
44
|
+
}
|
|
32
45
|
console.log('Network:');
|
|
33
46
|
for (const iface of networkInterfaces) {
|
|
34
47
|
console.log(tab, styleText('cyanBright', iface.name), styleText('yellow', iface.model), iface.wireless ? '(wireless)' : '(wired)');
|
package/dist/client/info.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import * as fs from 'node:fs';
|
|
3
3
|
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
4
5
|
/** Read a sysfs/procfs file, returning the trimmed contents or undefined if unreadable. */
|
|
5
6
|
function read(path) {
|
|
6
7
|
try {
|
|
@@ -64,16 +65,148 @@ function gpus() {
|
|
|
64
65
|
}
|
|
65
66
|
return result;
|
|
66
67
|
}
|
|
67
|
-
/**
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
/** PCIe generations by their per-lane transfer rate in GT/s, as reported by `current_link_speed`. */
|
|
69
|
+
const pcieGenerations = {
|
|
70
|
+
'2.5': '1.0',
|
|
71
|
+
'5.0': '2.0',
|
|
72
|
+
'8.0': '3.0',
|
|
73
|
+
'16.0': '4.0',
|
|
74
|
+
'32.0': '5.0',
|
|
75
|
+
'64.0': '6.0',
|
|
76
|
+
'128.0': '7.0',
|
|
77
|
+
};
|
|
78
|
+
/** Format a link rate given in Mbit/s, e.g. 10000 -> '10 Gbps'. */
|
|
79
|
+
function formatLinkRate(mbps) {
|
|
80
|
+
return mbps >= 1000 ? `${(mbps / 1000).toFixed(1).replace(/\.0$/, '')} Gbps` : `${mbps} Mbps`;
|
|
81
|
+
}
|
|
82
|
+
/** The SATA link speed of an `ataN` device directory, e.g. '6.0 Gbps'. */
|
|
83
|
+
function sataSpeed(ata) {
|
|
84
|
+
for (const link of list(ata)) {
|
|
85
|
+
if (!link.startsWith('link'))
|
|
86
|
+
continue;
|
|
87
|
+
for (const classLink of list(`${ata}/${link}/ata_link`)) {
|
|
88
|
+
const speed = read(`${ata}/${link}/ata_link/${classLink}/sata_spd`);
|
|
89
|
+
// Reported as `<unknown>` for PATA links and for ports with nothing negotiated.
|
|
90
|
+
if (speed && !speed.startsWith('<'))
|
|
91
|
+
return speed;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** How a disk is attached, e.g. 'PCIe 4.0 x4', 'SATA 6.0 Gbps' or 'USB 10 Gbps'. */
|
|
96
|
+
function diskInterface(dev) {
|
|
97
|
+
let dir;
|
|
98
|
+
try {
|
|
99
|
+
dir = fs.realpathSync(`/sys/block/${dev}/device`);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
// Walk toward the root of the device tree; the nearest bus we recognize is the one the disk hangs off.
|
|
105
|
+
for (; dir.startsWith('/sys/devices/'); dir = path.dirname(dir)) {
|
|
106
|
+
const name = path.basename(dir);
|
|
107
|
+
if (/^ata\d+$/.test(name)) {
|
|
108
|
+
const speed = sataSpeed(dir);
|
|
109
|
+
return speed ? `SATA ${speed}` : 'SATA';
|
|
110
|
+
}
|
|
111
|
+
// PCI(e) endpoints expose the negotiated link; `current_link_speed` is like `16.0 GT/s PCIe`.
|
|
112
|
+
const link = read(`${dir}/current_link_speed`);
|
|
113
|
+
if (link) {
|
|
114
|
+
const rate = link.split(' ')[0];
|
|
115
|
+
const generation = pcieGenerations[Number(rate).toFixed(1)];
|
|
116
|
+
const width = read(`${dir}/current_link_width`);
|
|
117
|
+
return `PCIe ${generation ?? `${rate} GT/s`}${width && width !== '0' ? ` x${width}` : ''}`;
|
|
118
|
+
}
|
|
119
|
+
// USB devices (as opposed to their interfaces) carry the descriptor fields; `speed` is in Mbit/s.
|
|
120
|
+
if (fs.existsSync(`${dir}/idVendor`)) {
|
|
121
|
+
const speed = Number(read(`${dir}/speed`));
|
|
122
|
+
return speed > 0 ? `USB ${formatLinkRate(speed)}` : 'USB';
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function devices() {
|
|
127
|
+
const result = [];
|
|
128
|
+
for (const name of list('/sys/block')) {
|
|
129
|
+
// Skip virtual devices (zram, loop, device-mapper, MD) which have no backing `device`.
|
|
130
|
+
if (!fs.existsSync(`/sys/block/${name}/device`))
|
|
131
|
+
continue;
|
|
132
|
+
const sectors = read(`/sys/block/${name}/size`);
|
|
133
|
+
if (!sectors)
|
|
134
|
+
continue;
|
|
135
|
+
result.push({
|
|
136
|
+
name,
|
|
137
|
+
model: read(`/sys/block/${name}/device/model`)?.trim() || name,
|
|
138
|
+
size: BigInt(sectors) * 512n,
|
|
139
|
+
interface: diskInterface(name),
|
|
140
|
+
rotational: read(`/sys/block/${name}/queue/rotational`) === '1',
|
|
141
|
+
removable: read(`/sys/block/${name}/removable`) === '1',
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return result.sort((a, b) => a.name.localeCompare(b.name));
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Resolve a block device to the physical disks backing it, recursing through `slaves` so
|
|
148
|
+
* device-mapper (LVM, LUKS) and MD stacks resolve to real hardware rather than virtual devices.
|
|
149
|
+
*/
|
|
150
|
+
function physicalDisks(dev, into = new Set()) {
|
|
151
|
+
if (fs.existsSync(`/sys/class/block/${dev}/partition`)) {
|
|
152
|
+
// Partitions live inside their disk's directory: `.../nvme0n1/nvme0n1p3`.
|
|
153
|
+
try {
|
|
154
|
+
return physicalDisks(path.basename(path.dirname(fs.realpathSync(`/sys/class/block/${dev}`))), into);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return into;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (fs.existsSync(`/sys/block/${dev}/device`))
|
|
161
|
+
into.add(dev);
|
|
162
|
+
else
|
|
163
|
+
for (const slave of list(`/sys/block/${dev}/slaves`))
|
|
164
|
+
physicalDisks(slave, into);
|
|
165
|
+
return into;
|
|
166
|
+
}
|
|
167
|
+
/** BTRFS filesystems keyed by each of their member devices. The kernel is the only source that knows all members. */
|
|
168
|
+
function btrfsFilesystems() {
|
|
169
|
+
const result = new Map();
|
|
170
|
+
for (const fsid of list('/sys/fs/btrfs')) {
|
|
171
|
+
const devices = list(`/sys/fs/btrfs/${fsid}/devices`);
|
|
172
|
+
if (!devices.length)
|
|
173
|
+
continue;
|
|
174
|
+
// `allocation/data` holds a directory per chunk profile in use alongside its counter files.
|
|
175
|
+
const profile = list(`/sys/fs/btrfs/${fsid}/allocation/data`).find(entry => /^(single|dup|raid\d+(c\d+)?)$/.test(entry));
|
|
176
|
+
const info = { fsid, devices, profile: profile === 'single' ? undefined : profile };
|
|
177
|
+
for (const dev of devices)
|
|
178
|
+
result.set(dev, info);
|
|
179
|
+
}
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
function volumes() {
|
|
70
183
|
const mounts = read('/proc/mounts');
|
|
71
184
|
if (!mounts)
|
|
72
|
-
return
|
|
185
|
+
return [];
|
|
186
|
+
const btrfs = btrfsFilesystems();
|
|
187
|
+
// Several mount points can share one filesystem (BTRFS subvolumes, bind mounts); they are one volume.
|
|
188
|
+
const byFilesystem = new Map();
|
|
73
189
|
for (const line of mounts.split('\n')) {
|
|
74
|
-
const [source,
|
|
190
|
+
const [source, rawMountPoint, filesystem] = line.split(' ');
|
|
75
191
|
if (!source?.startsWith('/dev/'))
|
|
76
192
|
continue;
|
|
193
|
+
// /proc/mounts octal-escapes characters that would otherwise break the field separators.
|
|
194
|
+
const mountPoint = rawMountPoint.replace(/\\(\d{3})/g, (_, code) => String.fromCharCode(parseInt(code, 8)));
|
|
195
|
+
let dev;
|
|
196
|
+
try {
|
|
197
|
+
// The source may be a symlink, e.g. /dev/mapper/foo -> /dev/dm-0.
|
|
198
|
+
dev = fs.realpathSync(source).slice('/dev/'.length);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const btrfsInfo = btrfs.get(dev);
|
|
204
|
+
const existing = byFilesystem.get(btrfsInfo?.fsid ?? dev);
|
|
205
|
+
if (existing) {
|
|
206
|
+
if (!existing.mountPoints.includes(mountPoint))
|
|
207
|
+
existing.mountPoints.push(mountPoint);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
77
210
|
let stat;
|
|
78
211
|
try {
|
|
79
212
|
stat = fs.statfsSync(mountPoint, { bigint: true });
|
|
@@ -81,35 +214,25 @@ function mountUsage() {
|
|
|
81
214
|
catch {
|
|
82
215
|
continue;
|
|
83
216
|
}
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
217
|
+
// BTRFS tracks its own members; anything else spanning devices does so through MD or device-mapper.
|
|
218
|
+
const devices = new Set();
|
|
219
|
+
for (const member of btrfsInfo?.devices ?? [dev])
|
|
220
|
+
physicalDisks(member, devices);
|
|
221
|
+
if (!devices.size)
|
|
222
|
+
continue;
|
|
223
|
+
byFilesystem.set(btrfsInfo?.fsid ?? dev, {
|
|
224
|
+
mountPoints: [mountPoint],
|
|
225
|
+
filesystem,
|
|
226
|
+
devices: [...devices].sort((a, b) => a.localeCompare(b)),
|
|
227
|
+
profile: btrfsInfo?.profile ?? read(`/sys/block/${dev}/md/level`),
|
|
228
|
+
total: stat.blocks * stat.bsize,
|
|
229
|
+
used: (stat.blocks - stat.bfree) * stat.bsize,
|
|
230
|
+
});
|
|
87
231
|
}
|
|
88
|
-
return
|
|
232
|
+
return [...byFilesystem.values()].sort((a, b) => a.mountPoints[0].localeCompare(b.mountPoints[0]));
|
|
89
233
|
}
|
|
90
234
|
function storage() {
|
|
91
|
-
|
|
92
|
-
const result = [];
|
|
93
|
-
for (const dev of list('/sys/block')) {
|
|
94
|
-
// Skip virtual devices (zram, loop, device-mapper) which have no backing `device`.
|
|
95
|
-
if (!fs.existsSync(`/sys/block/${dev}/device`))
|
|
96
|
-
continue;
|
|
97
|
-
const sectors = read(`/sys/block/${dev}/size`);
|
|
98
|
-
if (!sectors)
|
|
99
|
-
continue;
|
|
100
|
-
// `size` is always in 512-byte sectors regardless of logical block size.
|
|
101
|
-
const total = BigInt(sectors) * 512n;
|
|
102
|
-
// Sum filesystem usage across mounted partitions of this disk.
|
|
103
|
-
let used = 0n;
|
|
104
|
-
for (const [mounted, info] of usage) {
|
|
105
|
-
if (mounted === dev || mounted.startsWith(dev + 'p') || (mounted.startsWith(dev) && /\d$/.test(mounted))) {
|
|
106
|
-
used += info.used;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
const model = read(`/sys/block/${dev}/device/model`)?.trim() || dev;
|
|
110
|
-
result.push({ model, total, used });
|
|
111
|
-
}
|
|
112
|
-
return result;
|
|
235
|
+
return { devices: devices(), volumes: volumes() };
|
|
113
236
|
}
|
|
114
237
|
let dmiMemory;
|
|
115
238
|
/** Static memory hardware details from DMI. */
|
package/dist/common.d.ts
CHANGED
|
@@ -343,11 +343,24 @@ declare const SysadminClientToServer: {
|
|
|
343
343
|
total: z.ZodCoercedBigInt<unknown>;
|
|
344
344
|
used: z.ZodCoercedBigInt<unknown>;
|
|
345
345
|
}, z.core.$strip>;
|
|
346
|
-
storage: z.
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
346
|
+
storage: z.ZodObject<{
|
|
347
|
+
devices: z.ZodArray<z.ZodObject<{
|
|
348
|
+
name: z.ZodString;
|
|
349
|
+
model: z.ZodString;
|
|
350
|
+
size: z.ZodCoercedBigInt<unknown>;
|
|
351
|
+
interface: z.ZodOptional<z.ZodString>;
|
|
352
|
+
rotational: z.ZodBoolean;
|
|
353
|
+
removable: z.ZodBoolean;
|
|
354
|
+
}, z.core.$strip>>;
|
|
355
|
+
volumes: z.ZodArray<z.ZodObject<{
|
|
356
|
+
mountPoints: z.ZodArray<z.ZodString>;
|
|
357
|
+
filesystem: z.ZodString;
|
|
358
|
+
devices: z.ZodArray<z.ZodString>;
|
|
359
|
+
profile: z.ZodOptional<z.ZodString>;
|
|
360
|
+
total: z.ZodCoercedBigInt<unknown>;
|
|
361
|
+
used: z.ZodCoercedBigInt<unknown>;
|
|
362
|
+
}, z.core.$strip>>;
|
|
363
|
+
}, z.core.$strip>;
|
|
351
364
|
networkInterfaces: z.ZodArray<z.ZodObject<{
|
|
352
365
|
name: z.ZodString;
|
|
353
366
|
model: z.ZodString;
|
|
@@ -401,11 +414,24 @@ declare const SysadminServerToClient: {
|
|
|
401
414
|
total: z.ZodCoercedBigInt<unknown>;
|
|
402
415
|
used: z.ZodCoercedBigInt<unknown>;
|
|
403
416
|
}, z.core.$strip>;
|
|
404
|
-
storage: z.
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
417
|
+
storage: z.ZodObject<{
|
|
418
|
+
devices: z.ZodArray<z.ZodObject<{
|
|
419
|
+
name: z.ZodString;
|
|
420
|
+
model: z.ZodString;
|
|
421
|
+
size: z.ZodCoercedBigInt<unknown>;
|
|
422
|
+
interface: z.ZodOptional<z.ZodString>;
|
|
423
|
+
rotational: z.ZodBoolean;
|
|
424
|
+
removable: z.ZodBoolean;
|
|
425
|
+
}, z.core.$strip>>;
|
|
426
|
+
volumes: z.ZodArray<z.ZodObject<{
|
|
427
|
+
mountPoints: z.ZodArray<z.ZodString>;
|
|
428
|
+
filesystem: z.ZodString;
|
|
429
|
+
devices: z.ZodArray<z.ZodString>;
|
|
430
|
+
profile: z.ZodOptional<z.ZodString>;
|
|
431
|
+
total: z.ZodCoercedBigInt<unknown>;
|
|
432
|
+
used: z.ZodCoercedBigInt<unknown>;
|
|
433
|
+
}, z.core.$strip>>;
|
|
434
|
+
}, z.core.$strip>;
|
|
409
435
|
networkInterfaces: z.ZodArray<z.ZodObject<{
|
|
410
436
|
name: z.ZodString;
|
|
411
437
|
model: z.ZodString;
|
package/dist/info.d.ts
CHANGED
|
@@ -34,11 +34,44 @@ export declare const Memory: z.ZodObject<{
|
|
|
34
34
|
}, z.core.$strip>;
|
|
35
35
|
export interface Memory extends z.infer<typeof Memory> {
|
|
36
36
|
}
|
|
37
|
-
export declare const
|
|
37
|
+
export declare const StorageDevice: z.ZodObject<{
|
|
38
|
+
name: z.ZodString;
|
|
38
39
|
model: z.ZodString;
|
|
40
|
+
size: z.ZodCoercedBigInt<unknown>;
|
|
41
|
+
interface: z.ZodOptional<z.ZodString>;
|
|
42
|
+
rotational: z.ZodBoolean;
|
|
43
|
+
removable: z.ZodBoolean;
|
|
44
|
+
}, z.core.$strip>;
|
|
45
|
+
export interface StorageDevice extends z.infer<typeof StorageDevice> {
|
|
46
|
+
}
|
|
47
|
+
export declare const StorageVolume: z.ZodObject<{
|
|
48
|
+
mountPoints: z.ZodArray<z.ZodString>;
|
|
49
|
+
filesystem: z.ZodString;
|
|
50
|
+
devices: z.ZodArray<z.ZodString>;
|
|
51
|
+
profile: z.ZodOptional<z.ZodString>;
|
|
39
52
|
total: z.ZodCoercedBigInt<unknown>;
|
|
40
53
|
used: z.ZodCoercedBigInt<unknown>;
|
|
41
54
|
}, z.core.$strip>;
|
|
55
|
+
export interface StorageVolume extends z.infer<typeof StorageVolume> {
|
|
56
|
+
}
|
|
57
|
+
export declare const Storage: z.ZodObject<{
|
|
58
|
+
devices: z.ZodArray<z.ZodObject<{
|
|
59
|
+
name: z.ZodString;
|
|
60
|
+
model: z.ZodString;
|
|
61
|
+
size: z.ZodCoercedBigInt<unknown>;
|
|
62
|
+
interface: z.ZodOptional<z.ZodString>;
|
|
63
|
+
rotational: z.ZodBoolean;
|
|
64
|
+
removable: z.ZodBoolean;
|
|
65
|
+
}, z.core.$strip>>;
|
|
66
|
+
volumes: z.ZodArray<z.ZodObject<{
|
|
67
|
+
mountPoints: z.ZodArray<z.ZodString>;
|
|
68
|
+
filesystem: z.ZodString;
|
|
69
|
+
devices: z.ZodArray<z.ZodString>;
|
|
70
|
+
profile: z.ZodOptional<z.ZodString>;
|
|
71
|
+
total: z.ZodCoercedBigInt<unknown>;
|
|
72
|
+
used: z.ZodCoercedBigInt<unknown>;
|
|
73
|
+
}, z.core.$strip>>;
|
|
74
|
+
}, z.core.$strip>;
|
|
42
75
|
export interface Storage extends z.infer<typeof Storage> {
|
|
43
76
|
}
|
|
44
77
|
export declare const NetworkInterface: z.ZodObject<{
|
|
@@ -83,11 +116,24 @@ export declare const SystemInfo: z.ZodObject<{
|
|
|
83
116
|
total: z.ZodCoercedBigInt<unknown>;
|
|
84
117
|
used: z.ZodCoercedBigInt<unknown>;
|
|
85
118
|
}, z.core.$strip>;
|
|
86
|
-
storage: z.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
119
|
+
storage: z.ZodObject<{
|
|
120
|
+
devices: z.ZodArray<z.ZodObject<{
|
|
121
|
+
name: z.ZodString;
|
|
122
|
+
model: z.ZodString;
|
|
123
|
+
size: z.ZodCoercedBigInt<unknown>;
|
|
124
|
+
interface: z.ZodOptional<z.ZodString>;
|
|
125
|
+
rotational: z.ZodBoolean;
|
|
126
|
+
removable: z.ZodBoolean;
|
|
127
|
+
}, z.core.$strip>>;
|
|
128
|
+
volumes: z.ZodArray<z.ZodObject<{
|
|
129
|
+
mountPoints: z.ZodArray<z.ZodString>;
|
|
130
|
+
filesystem: z.ZodString;
|
|
131
|
+
devices: z.ZodArray<z.ZodString>;
|
|
132
|
+
profile: z.ZodOptional<z.ZodString>;
|
|
133
|
+
total: z.ZodCoercedBigInt<unknown>;
|
|
134
|
+
used: z.ZodCoercedBigInt<unknown>;
|
|
135
|
+
}, z.core.$strip>>;
|
|
136
|
+
}, z.core.$strip>;
|
|
91
137
|
networkInterfaces: z.ZodArray<z.ZodObject<{
|
|
92
138
|
name: z.ZodString;
|
|
93
139
|
model: z.ZodString;
|
package/dist/info.js
CHANGED
|
@@ -24,9 +24,38 @@ export const Memory = z.object({
|
|
|
24
24
|
/** Only available when swap is in use */
|
|
25
25
|
swap: TotalUsed.optional(),
|
|
26
26
|
});
|
|
27
|
-
export const
|
|
28
|
-
|
|
27
|
+
export const StorageDevice = z.object({
|
|
28
|
+
/** Kernel name of the block device, e.g. 'nvme0n1' or 'sda' */
|
|
29
|
+
name: z.string(),
|
|
29
30
|
model: z.string(),
|
|
31
|
+
/** Capacity in bytes */
|
|
32
|
+
size: z.coerce.bigint(),
|
|
33
|
+
/** How the device is attached, e.g. 'PCIe 4.0 x4' or 'SATA 6.0 Gbps' */
|
|
34
|
+
interface: z.string().optional(),
|
|
35
|
+
/** Whether the device is a spinning disk rather than solid state */
|
|
36
|
+
rotational: z.boolean(),
|
|
37
|
+
/** Whether the device's media can be removed, e.g. a card reader or optical drive */
|
|
38
|
+
removable: z.boolean(),
|
|
39
|
+
});
|
|
40
|
+
export const StorageVolume = z.object({
|
|
41
|
+
...TotalUsed.shape,
|
|
42
|
+
/** Every mount point of the filesystem; more than one for e.g. BTRFS subvolumes or bind mounts */
|
|
43
|
+
mountPoints: z.string().array(),
|
|
44
|
+
/** Filesystem type, e.g. 'btrfs' or 'ext4' */
|
|
45
|
+
filesystem: z.string(),
|
|
46
|
+
/** Names of the devices backing this volume, matching `StorageDevice.name` */
|
|
47
|
+
devices: z.string().array(),
|
|
48
|
+
/**
|
|
49
|
+
* The RAID or allocation profile the volume is stored with, e.g. 'raid1'.
|
|
50
|
+
* Only known for filesystems that manage their own devices (BTRFS) and MD arrays; unset when it is plain 'single'.
|
|
51
|
+
*/
|
|
52
|
+
profile: z.string().optional(),
|
|
53
|
+
});
|
|
54
|
+
export const Storage = z.object({
|
|
55
|
+
/** Physical devices, including ones not backing any volume */
|
|
56
|
+
devices: StorageDevice.array(),
|
|
57
|
+
/** Mounted filesystems, each of which may span several devices */
|
|
58
|
+
volumes: StorageVolume.array(),
|
|
30
59
|
});
|
|
31
60
|
export const NetworkInterface = z.object({
|
|
32
61
|
name: z.string(),
|
|
@@ -47,7 +76,7 @@ export const SystemInfo = z.object({
|
|
|
47
76
|
cpus: CPU.array(),
|
|
48
77
|
gpus: GPU.array(),
|
|
49
78
|
memory: Memory,
|
|
50
|
-
storage: Storage
|
|
79
|
+
storage: Storage,
|
|
51
80
|
networkInterfaces: NetworkInterface.array(),
|
|
52
81
|
/** e.g. 'arm', 'arm64', 'ia32', 'loong64', 'mips', 'mipsel', 'ppc64', 'riscv64', 's390x', and 'x64' */
|
|
53
82
|
arch: z.string(),
|
package/locales/en.json
CHANGED
package/package.json
CHANGED
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
let loading = $state(true);
|
|
18
18
|
|
|
19
19
|
const matchingUser = $derived(info && systemUsers.find(u => u.username === info!.user.username));
|
|
20
|
+
|
|
21
|
+
const storageDevices = $derived(new Map((info?.storage.devices ?? []).map(device => [device.name, device])));
|
|
22
|
+
const unusedDevices = $derived(
|
|
23
|
+
(info?.storage.devices ?? []).filter(device => info!.storage.volumes.every(volume => !volume.devices.includes(device.name)))
|
|
24
|
+
);
|
|
20
25
|
const connectedUser = $derived(system.connectedUserId ? systemUsers.find(u => u.id === system.connectedUserId) : undefined);
|
|
21
26
|
|
|
22
27
|
async function setConnectedUser(connectedUserId: string | null) {
|
|
@@ -147,14 +152,57 @@
|
|
|
147
152
|
{/if}
|
|
148
153
|
</div>
|
|
149
154
|
|
|
150
|
-
{#if info.storage.length}
|
|
155
|
+
{#if info.storage.devices.length || info.storage.volumes.length}
|
|
151
156
|
<div class="component">
|
|
152
157
|
<h3><Icon i="hard-drive" /> {text('sysadmin.system.storage')}</h3>
|
|
153
|
-
{#each info.storage as
|
|
154
|
-
|
|
155
|
-
|
|
158
|
+
{#each info.storage.volumes as volume}
|
|
159
|
+
{const disks = $derived(volume.devices.map(name => storageDevices.get(name)).filter(disk => !!disk))}
|
|
160
|
+
<div class="storage-line">
|
|
161
|
+
<span class="mount">{volume.mountPoints.join(' ')}</span>
|
|
162
|
+
{#if disks.length === 1}
|
|
163
|
+
<span class="subtle">{disks[0].model}</span>
|
|
164
|
+
{/if}
|
|
165
|
+
<span class="tags">
|
|
166
|
+
<span class="tag">{volume.filesystem}</span>
|
|
167
|
+
{#if volume.profile}
|
|
168
|
+
<span class="tag raid">{volume.profile.toUpperCase()}</span>
|
|
169
|
+
{/if}
|
|
170
|
+
{#if disks.length > 1}
|
|
171
|
+
<span class="subtle">{text('sysadmin.system.storage_array', { count: disks.length })}</span>
|
|
172
|
+
{:else if disks[0]?.interface}
|
|
173
|
+
<span class="subtle">{disks[0].interface}</span>
|
|
174
|
+
{/if}
|
|
175
|
+
</span>
|
|
176
|
+
</div>
|
|
177
|
+
<NumberBar value={fraction(volume.used, volume.total)} max={1} text={usageText(volume.used, volume.total)} />
|
|
178
|
+
{#if disks.length > 1}
|
|
179
|
+
<div class="disks">
|
|
180
|
+
{#each disks as disk}
|
|
181
|
+
<div class="disk">
|
|
182
|
+
<span class="icon-text">
|
|
183
|
+
<Icon i={disk.rotational ? 'hard-drive' : 'memory'} />
|
|
184
|
+
{disk.model}
|
|
185
|
+
</span>
|
|
186
|
+
<span class="subtle">
|
|
187
|
+
{formatBytes(disk.size)}
|
|
188
|
+
{#if disk.interface}<span class="dot">·</span>{disk.interface}{/if}
|
|
189
|
+
</span>
|
|
190
|
+
</div>
|
|
191
|
+
{/each}
|
|
192
|
+
</div>
|
|
193
|
+
{/if}
|
|
194
|
+
{/each}
|
|
195
|
+
{#each unusedDevices as disk}
|
|
196
|
+
<div class="storage-line">
|
|
197
|
+
<span class="mount">{disk.model}</span>
|
|
198
|
+
<span class="tags">
|
|
199
|
+
<span class="subtle">
|
|
200
|
+
{formatBytes(disk.size)}
|
|
201
|
+
{#if disk.interface}<span class="dot">·</span>{disk.interface}{/if}
|
|
202
|
+
</span>
|
|
203
|
+
<span class="tag unused">{text('sysadmin.system.storage_unused')}</span>
|
|
204
|
+
</span>
|
|
156
205
|
</div>
|
|
157
|
-
<NumberBar value={fraction(disk.used, disk.total)} max={1} text={usageText(disk.used, disk.total)} />
|
|
158
206
|
{/each}
|
|
159
207
|
</div>
|
|
160
208
|
{/if}
|
|
@@ -361,6 +409,62 @@
|
|
|
361
409
|
}
|
|
362
410
|
}
|
|
363
411
|
|
|
412
|
+
.storage-line {
|
|
413
|
+
display: flex;
|
|
414
|
+
align-items: center;
|
|
415
|
+
gap: 0.35em 1em;
|
|
416
|
+
flex-wrap: wrap;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
.mount {
|
|
420
|
+
font-weight: bold;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
.tags {
|
|
424
|
+
margin-left: auto;
|
|
425
|
+
display: inline-flex;
|
|
426
|
+
align-items: center;
|
|
427
|
+
gap: 0.5em;
|
|
428
|
+
flex-wrap: wrap;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
.tag {
|
|
432
|
+
padding: 0.1em 0.6em;
|
|
433
|
+
border-radius: 1em;
|
|
434
|
+
font-size: 0.85em;
|
|
435
|
+
line-height: 1.5;
|
|
436
|
+
background-color: hsl(0 0 calc(var(--bg-light) + (var(--light-step) * 2)));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
.raid {
|
|
440
|
+
background-color: var(--bg-strong);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
.unused {
|
|
444
|
+
color: hsl(0 0 var(--fg-light));
|
|
445
|
+
background-color: transparent;
|
|
446
|
+
border: 1px solid hsl(0 0 calc(var(--bg-light) + (var(--light-step) * 3)));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
.disks {
|
|
450
|
+
display: grid;
|
|
451
|
+
grid-template-columns: repeat(auto-fit, minmax(15em, 1fr));
|
|
452
|
+
gap: 0.5em;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
.disk {
|
|
456
|
+
display: flex;
|
|
457
|
+
flex-direction: column;
|
|
458
|
+
gap: 0.15em;
|
|
459
|
+
padding: 0.5em 0.75em;
|
|
460
|
+
border-radius: 0.5em;
|
|
461
|
+
background-color: hsl(0 0 calc(var(--bg-light) + var(--light-step)));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
.dot {
|
|
465
|
+
margin: 0 0.15em;
|
|
466
|
+
}
|
|
467
|
+
|
|
364
468
|
.net-line {
|
|
365
469
|
display: flex;
|
|
366
470
|
align-items: center;
|