@axium/sysadmin 0.2.5 → 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.
@@ -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 drive of storage)
31
- console.log(tab, styleText('yellow', drive.model), usage(drive));
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)');
@@ -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
- /** Map mounted block devices to the filesystem usage (bytes) of their mount point. */
68
- function mountUsage() {
69
- const usage = new Map();
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 usage;
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, mountPoint] = line.split(' ');
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
- const total = stat.blocks * stat.bsize;
85
- const used = (stat.blocks - stat.bfree) * stat.bsize;
86
- usage.set(source.slice('/dev/'.length), { total, used });
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 usage;
232
+ return [...byFilesystem.values()].sort((a, b) => a.mountPoints[0].localeCompare(b.mountPoints[0]));
89
233
  }
90
234
  function storage() {
91
- const usage = mountUsage();
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
@@ -31,6 +31,7 @@ export declare const System: z.ZodObject<{
31
31
  emailVerified: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodCoercedDate<unknown>>>>;
32
32
  preferences: z.ZodOptional<z.ZodLazy<z.ZodObject<{
33
33
  debug: z.ZodDefault<z.ZodBoolean>;
34
+ theme: z.ZodDefault<z.ZodLiteral<"default" | "light" | "forest" | "midnight" | "beach" | "cherry" | "volcano">>;
34
35
  }, z.core.$strip>>>;
35
36
  roles: z.ZodArray<z.ZodString>;
36
37
  tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -86,6 +87,7 @@ declare const SysadminAPI: {
86
87
  emailVerified: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodCoercedDate<unknown>>>>;
87
88
  preferences: z.ZodOptional<z.ZodLazy<z.ZodObject<{
88
89
  debug: z.ZodDefault<z.ZodBoolean>;
90
+ theme: z.ZodDefault<z.ZodLiteral<"default" | "light" | "forest" | "midnight" | "beach" | "cherry" | "volcano">>;
89
91
  }, z.core.$strip>>>;
90
92
  roles: z.ZodArray<z.ZodString>;
91
93
  tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -122,6 +124,7 @@ declare const SysadminAPI: {
122
124
  emailVerified: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodCoercedDate<unknown>>>>;
123
125
  preferences: z.ZodOptional<z.ZodLazy<z.ZodObject<{
124
126
  debug: z.ZodDefault<z.ZodBoolean>;
127
+ theme: z.ZodDefault<z.ZodLiteral<"default" | "light" | "forest" | "midnight" | "beach" | "cherry" | "volcano">>;
125
128
  }, z.core.$strip>>>;
126
129
  roles: z.ZodArray<z.ZodString>;
127
130
  tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -172,6 +175,7 @@ declare const SysadminAPI: {
172
175
  emailVerified: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodCoercedDate<unknown>>>>;
173
176
  preferences: z.ZodOptional<z.ZodLazy<z.ZodObject<{
174
177
  debug: z.ZodDefault<z.ZodBoolean>;
178
+ theme: z.ZodDefault<z.ZodLiteral<"default" | "light" | "forest" | "midnight" | "beach" | "cherry" | "volcano">>;
175
179
  }, z.core.$strip>>>;
176
180
  roles: z.ZodArray<z.ZodString>;
177
181
  tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -208,6 +212,7 @@ declare const SysadminAPI: {
208
212
  emailVerified: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodCoercedDate<unknown>>>>;
209
213
  preferences: z.ZodOptional<z.ZodLazy<z.ZodObject<{
210
214
  debug: z.ZodDefault<z.ZodBoolean>;
215
+ theme: z.ZodDefault<z.ZodLiteral<"default" | "light" | "forest" | "midnight" | "beach" | "cherry" | "volcano">>;
211
216
  }, z.core.$strip>>>;
212
217
  roles: z.ZodArray<z.ZodString>;
213
218
  tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -239,6 +244,7 @@ declare const SysadminAPI: {
239
244
  emailVerified: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodCoercedDate<unknown>>>>;
240
245
  preferences: z.ZodOptional<z.ZodLazy<z.ZodObject<{
241
246
  debug: z.ZodDefault<z.ZodBoolean>;
247
+ theme: z.ZodDefault<z.ZodLiteral<"default" | "light" | "forest" | "midnight" | "beach" | "cherry" | "volcano">>;
242
248
  }, z.core.$strip>>>;
243
249
  roles: z.ZodArray<z.ZodString>;
244
250
  tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -295,6 +301,7 @@ declare const SysadminAPI: {
295
301
  emailVerified: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodCoercedDate<unknown>>>>;
296
302
  preferences: z.ZodOptional<z.ZodLazy<z.ZodObject<{
297
303
  debug: z.ZodDefault<z.ZodBoolean>;
304
+ theme: z.ZodDefault<z.ZodLiteral<"default" | "light" | "forest" | "midnight" | "beach" | "cherry" | "volcano">>;
298
305
  }, z.core.$strip>>>;
299
306
  roles: z.ZodArray<z.ZodString>;
300
307
  tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -336,11 +343,24 @@ declare const SysadminClientToServer: {
336
343
  total: z.ZodCoercedBigInt<unknown>;
337
344
  used: z.ZodCoercedBigInt<unknown>;
338
345
  }, z.core.$strip>;
339
- storage: z.ZodArray<z.ZodObject<{
340
- model: z.ZodString;
341
- total: z.ZodCoercedBigInt<unknown>;
342
- used: z.ZodCoercedBigInt<unknown>;
343
- }, z.core.$strip>>;
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>;
344
364
  networkInterfaces: z.ZodArray<z.ZodObject<{
345
365
  name: z.ZodString;
346
366
  model: z.ZodString;
@@ -394,11 +414,24 @@ declare const SysadminServerToClient: {
394
414
  total: z.ZodCoercedBigInt<unknown>;
395
415
  used: z.ZodCoercedBigInt<unknown>;
396
416
  }, z.core.$strip>;
397
- storage: z.ZodArray<z.ZodObject<{
398
- model: z.ZodString;
399
- total: z.ZodCoercedBigInt<unknown>;
400
- used: z.ZodCoercedBigInt<unknown>;
401
- }, z.core.$strip>>;
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>;
402
435
  networkInterfaces: z.ZodArray<z.ZodObject<{
403
436
  name: z.ZodString;
404
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 Storage: z.ZodObject<{
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.ZodArray<z.ZodObject<{
87
- model: z.ZodString;
88
- total: z.ZodCoercedBigInt<unknown>;
89
- used: z.ZodCoercedBigInt<unknown>;
90
- }, z.core.$strip>>;
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 Storage = z.object({
28
- ...TotalUsed.shape,
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.array(),
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(),
@@ -2,7 +2,7 @@
2
2
  import { fetchAPI, text } from '@axium/client';
3
3
  import { contextMenu, type ContextMenuItem } from '@axium/client/attachments';
4
4
  import { Icon, Popover } from '@axium/client/components';
5
- import { copy } from '@axium/client/gui';
5
+ import { copy } from '@axium/client/web';
6
6
  import { toastStatus } from '@axium/client/toast';
7
7
  import { systemTypeIcons, type System } from '@axium/sysadmin';
8
8
  import SystemInitDialog from './SystemInitDialog.svelte';
@@ -2,7 +2,7 @@
2
2
  import { fetchAPI, text } from '@axium/client';
3
3
  import { contextMenu, type ContextMenuItem } from '@axium/client/attachments';
4
4
  import { Icon, Popover } from '@axium/client/components';
5
- import { copy } from '@axium/client/gui';
5
+ import { copy } from '@axium/client/web';
6
6
  import { toastStatus } from '@axium/client/toast';
7
7
  import type { SystemUser } from '@axium/sysadmin';
8
8
  import UserInitDialog from './UserInitDialog.svelte';
package/lib/tsconfig.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "module": "preserve",
7
7
  "moduleResolution": "Bundler"
8
8
  },
9
- "include": ["**/*.svelte", "**/*.ts"],
9
+ "include": ["**/*.svelte", "**/*.ts", "../src/.*.ts", "../../client/dist/.*.ts"],
10
10
  "exclude": [],
11
11
  "references": [{ "path": ".." }]
12
12
  }
package/locales/en.json CHANGED
@@ -63,6 +63,8 @@
63
63
  "memory_speed": "{speed} MT/s",
64
64
  "swap": "Swap",
65
65
  "storage": "Storage",
66
+ "storage_unused": "Unused",
67
+ "storage_array": "{count} drives",
66
68
  "network": "Network",
67
69
  "wireless": "Wireless",
68
70
  "wired": "Wired",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axium/sysadmin",
3
- "version": "0.2.5",
3
+ "version": "0.3.0",
4
4
  "author": "James Prevett <axium@jamespre.dev>",
5
5
  "description": "System administration for Axium",
6
6
  "funding": {
@@ -36,8 +36,8 @@
36
36
  "build": "tsc"
37
37
  },
38
38
  "peerDependencies": {
39
- "@axium/client": ">=0.27.0",
40
- "@axium/core": ">=0.33.0",
39
+ "@axium/client": ">=0.37.0",
40
+ "@axium/core": ">=0.39.0",
41
41
  "@axium/server": ">=0.47.0",
42
42
  "@sveltejs/kit": "^2.27.3",
43
43
  "kysely": "^0.29.0",
@@ -49,11 +49,12 @@
49
49
  "zod": "^4.0.5"
50
50
  },
51
51
  "axium": {
52
+ "locales": "locales",
52
53
  "server": {
53
54
  "routes": "routes",
54
55
  "hooks": "./dist/server/hooks.js",
55
56
  "db": "./db.json",
56
- "web_client_hooks": "./dist/web_hook.js"
57
+ "web_client_hooks": "./dist/common.js"
57
58
  },
58
59
  "client": {
59
60
  "hooks": "./dist/client/hooks.js",
@@ -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 disk}
154
- <div class="line">
155
- <span>{disk.model}</span>
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;
@@ -7,6 +7,6 @@
7
7
  "target": "esnext",
8
8
  "rootDir": ".."
9
9
  },
10
- "include": ["**/*", "../lib/*"],
10
+ "include": ["**/*", "../lib/*", "../src/.*.ts", "../../client/dist/.*.ts"],
11
11
  "references": [{ "path": ".." }]
12
12
  }
@@ -1,8 +0,0 @@
1
- import en from '../locales/en.json';
2
- import './common.js';
3
- type en = typeof en;
4
- declare module '@axium/client/locales' {
5
- interface Locale extends en {
6
- }
7
- }
8
- export {};
package/dist/web_hook.js DELETED
@@ -1,4 +0,0 @@
1
- import { extendLocale } from '@axium/client';
2
- import en from '../locales/en.json' with { type: 'json' };
3
- import './common.js';
4
- extendLocale('en', en);