@axium/sysadmin 0.1.3 → 0.2.1

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/db.json CHANGED
@@ -68,6 +68,19 @@
68
68
  }
69
69
  }
70
70
  }
71
+ },
72
+ {
73
+ "delta": true,
74
+ "alter_tables": {
75
+ "systems": {
76
+ "add_columns": {
77
+ "connectedUserId": { "type": "uuid", "references": "system_users.id", "onDelete": "set null" }
78
+ }
79
+ }
80
+ },
81
+ "add_indexes": {
82
+ "systems_connectedUserId": { "on": "systems", "columns": ["connectedUserId"] }
83
+ }
71
84
  }
72
85
  ],
73
86
  "wipe": ["systems", "system_users", "acl.systems"],
@@ -129,6 +129,27 @@ function memory() {
129
129
  total,
130
130
  used: total - available,
131
131
  };
132
+ try {
133
+ const dmiMemoryInfo = execFileSync('sudo', ['-n', 'dmidecode', '-t', '17'], {
134
+ encoding: 'utf8',
135
+ stdio: ['ignore', 'pipe', 'ignore'],
136
+ });
137
+ for (const match of dmiMemoryInfo.matchAll(/^\s*(?:Configured Memory|Configured Clock)?\s*Speed:\s*(\d+)\s*MT\/s/gim)) {
138
+ memory.speed = Math.max(memory.speed, Number(match[1]));
139
+ }
140
+ const populated = (field) => {
141
+ for (const [, match] of dmiMemoryInfo.matchAll(new RegExp(`^[ \\t]*${field}:[ \\t]*(.+)$`, 'gim'))) {
142
+ const value = match.trim();
143
+ if (value && value !== 'Unknown')
144
+ return value;
145
+ }
146
+ };
147
+ memory.formFactor = populated('Form Factor');
148
+ memory.type = populated('Type');
149
+ }
150
+ catch {
151
+ // dmidecode missing, not root, or sudo needs a password
152
+ }
132
153
  const swapTotal = info.SwapTotal ?? 0n;
133
154
  if (swapTotal > 0n)
134
155
  memory.swap = { total: swapTotal, used: swapTotal - (info.SwapFree ?? 0n) };
@@ -179,5 +200,6 @@ export function systemInfo() {
179
200
  uptime: os.uptime(),
180
201
  version: os.version(),
181
202
  hostname: os.hostname(),
203
+ user: os.userInfo(),
182
204
  };
183
205
  }
package/dist/common.d.ts CHANGED
@@ -6,6 +6,7 @@ export declare const SystemInit: z.ZodObject<{
6
6
  name: z.ZodString;
7
7
  hostname: z.ZodString;
8
8
  type: z.ZodDefault<z.ZodLiteral<"server" | "pc" | "laptop">>;
9
+ connectedUserId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
9
10
  }, z.core.$strip>;
10
11
  export interface SystemInit extends z.infer<typeof SystemInit> {
11
12
  }
@@ -15,6 +16,7 @@ export declare const System: z.ZodObject<{
15
16
  type: z.ZodDefault<z.ZodLiteral<"server" | "pc" | "laptop">>;
16
17
  id: z.ZodUUID;
17
18
  userId: z.ZodUUID;
19
+ connectedUserId: z.ZodNullable<z.ZodUUID>;
18
20
  isShared: z.ZodBoolean;
19
21
  acl: z.ZodArray<z.ZodObject<{
20
22
  itemId: z.ZodUUID;
@@ -68,6 +70,7 @@ declare const SysadminAPI: {
68
70
  type: z.ZodDefault<z.ZodLiteral<"server" | "pc" | "laptop">>;
69
71
  id: z.ZodUUID;
70
72
  userId: z.ZodUUID;
73
+ connectedUserId: z.ZodNullable<z.ZodUUID>;
71
74
  isShared: z.ZodBoolean;
72
75
  acl: z.ZodArray<z.ZodObject<{
73
76
  itemId: z.ZodUUID;
@@ -95,12 +98,14 @@ declare const SysadminAPI: {
95
98
  name: z.ZodString;
96
99
  hostname: z.ZodString;
97
100
  type: z.ZodDefault<z.ZodLiteral<"server" | "pc" | "laptop">>;
101
+ connectedUserId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
98
102
  }, z.core.$strip>, z.ZodObject<{
99
103
  name: z.ZodString;
100
104
  hostname: z.ZodString;
101
105
  type: z.ZodDefault<z.ZodLiteral<"server" | "pc" | "laptop">>;
102
106
  id: z.ZodUUID;
103
107
  userId: z.ZodUUID;
108
+ connectedUserId: z.ZodNullable<z.ZodUUID>;
104
109
  isShared: z.ZodBoolean;
105
110
  acl: z.ZodArray<z.ZodObject<{
106
111
  itemId: z.ZodUUID;
@@ -149,6 +154,7 @@ declare const SysadminAPI: {
149
154
  type: z.ZodDefault<z.ZodLiteral<"server" | "pc" | "laptop">>;
150
155
  id: z.ZodUUID;
151
156
  userId: z.ZodUUID;
157
+ connectedUserId: z.ZodNullable<z.ZodUUID>;
152
158
  isShared: z.ZodBoolean;
153
159
  acl: z.ZodArray<z.ZodObject<{
154
160
  itemId: z.ZodUUID;
@@ -176,12 +182,14 @@ declare const SysadminAPI: {
176
182
  name: z.ZodString;
177
183
  hostname: z.ZodString;
178
184
  type: z.ZodDefault<z.ZodLiteral<"server" | "pc" | "laptop">>;
185
+ connectedUserId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
179
186
  }, z.core.$strip>, z.ZodObject<{
180
187
  name: z.ZodString;
181
188
  hostname: z.ZodString;
182
189
  type: z.ZodDefault<z.ZodLiteral<"server" | "pc" | "laptop">>;
183
190
  id: z.ZodUUID;
184
191
  userId: z.ZodUUID;
192
+ connectedUserId: z.ZodNullable<z.ZodUUID>;
185
193
  isShared: z.ZodBoolean;
186
194
  acl: z.ZodArray<z.ZodObject<{
187
195
  itemId: z.ZodUUID;
@@ -211,6 +219,7 @@ declare const SysadminAPI: {
211
219
  type: z.ZodDefault<z.ZodLiteral<"server" | "pc" | "laptop">>;
212
220
  id: z.ZodUUID;
213
221
  userId: z.ZodUUID;
222
+ connectedUserId: z.ZodNullable<z.ZodUUID>;
214
223
  isShared: z.ZodBoolean;
215
224
  acl: z.ZodArray<z.ZodObject<{
216
225
  itemId: z.ZodUUID;
@@ -258,6 +267,38 @@ declare const SysadminAPI: {
258
267
  userId: z.ZodUUID;
259
268
  }, z.core.$strip>;
260
269
  };
270
+ readonly 'sysadmin/users/:id/systems': {
271
+ readonly GET: z.ZodArray<z.ZodObject<{
272
+ name: z.ZodString;
273
+ hostname: z.ZodString;
274
+ type: z.ZodDefault<z.ZodLiteral<"server" | "pc" | "laptop">>;
275
+ id: z.ZodUUID;
276
+ userId: z.ZodUUID;
277
+ connectedUserId: z.ZodNullable<z.ZodUUID>;
278
+ isShared: z.ZodBoolean;
279
+ acl: z.ZodArray<z.ZodObject<{
280
+ itemId: z.ZodUUID;
281
+ userId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
282
+ role: z.ZodOptional<z.ZodNullable<z.ZodString>>;
283
+ tag: z.ZodOptional<z.ZodNullable<z.ZodString>>;
284
+ user: z.ZodOptional<z.ZodNullable<z.ZodObject<{
285
+ id: z.ZodUUID;
286
+ name: z.ZodString;
287
+ email: z.ZodOptional<z.ZodEmail>;
288
+ emailVerified: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodCoercedDate<unknown>>>>;
289
+ preferences: z.ZodOptional<z.ZodLazy<z.ZodObject<{
290
+ debug: z.ZodDefault<z.ZodBoolean>;
291
+ }, z.core.$strip>>>;
292
+ roles: z.ZodArray<z.ZodString>;
293
+ tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
294
+ registeredAt: z.ZodCoercedDate<unknown>;
295
+ isAdmin: z.ZodOptional<z.ZodBoolean>;
296
+ isSuspended: z.ZodOptional<z.ZodBoolean>;
297
+ }, z.core.$strip>>>;
298
+ createdAt: z.ZodCoercedDate<unknown>;
299
+ }, z.core.$catchall<z.ZodBoolean>>>;
300
+ }, z.core.$strip>>;
301
+ };
261
302
  };
262
303
  type SysadminAPI = typeof SysadminAPI;
263
304
  declare module '@axium/core/api' {
@@ -279,6 +320,8 @@ declare const SysadminClientToServer: {
279
320
  }, z.core.$strip>>;
280
321
  memory: z.ZodObject<{
281
322
  speed: z.ZodInt;
323
+ formFactor: z.ZodOptional<z.ZodString>;
324
+ type: z.ZodOptional<z.ZodString>;
282
325
  swap: z.ZodOptional<z.ZodObject<{
283
326
  total: z.ZodCoercedBigInt<unknown>;
284
327
  used: z.ZodCoercedBigInt<unknown>;
@@ -307,6 +350,13 @@ declare const SysadminClientToServer: {
307
350
  uptime: z.ZodNumber;
308
351
  version: z.ZodString;
309
352
  hostname: z.ZodString;
353
+ user: z.ZodObject<{
354
+ username: z.ZodString;
355
+ uid: z.ZodInt;
356
+ gid: z.ZodInt;
357
+ shell: z.ZodNullable<z.ZodString>;
358
+ homedir: z.ZodString;
359
+ }, z.core.$strip>;
310
360
  }, z.core.$strip>>]];
311
361
  readonly 'sysadmin:ping': readonly [readonly [z.ZodArray<z.ZodObject<{
312
362
  hostname: z.ZodString;
@@ -328,6 +378,8 @@ declare const SysadminServerToClient: {
328
378
  }, z.core.$strip>>;
329
379
  memory: z.ZodObject<{
330
380
  speed: z.ZodInt;
381
+ formFactor: z.ZodOptional<z.ZodString>;
382
+ type: z.ZodOptional<z.ZodString>;
331
383
  swap: z.ZodOptional<z.ZodObject<{
332
384
  total: z.ZodCoercedBigInt<unknown>;
333
385
  used: z.ZodCoercedBigInt<unknown>;
@@ -356,6 +408,13 @@ declare const SysadminServerToClient: {
356
408
  uptime: z.ZodNumber;
357
409
  version: z.ZodString;
358
410
  hostname: z.ZodString;
411
+ user: z.ZodObject<{
412
+ username: z.ZodString;
413
+ uid: z.ZodInt;
414
+ gid: z.ZodInt;
415
+ shell: z.ZodNullable<z.ZodString>;
416
+ homedir: z.ZodString;
417
+ }, z.core.$strip>;
359
418
  }, z.core.$strip>]];
360
419
  readonly 'sysadmin:ping': readonly [readonly [z.ZodObject<{
361
420
  hostname: z.ZodString;
package/dist/common.js CHANGED
@@ -13,10 +13,13 @@ export const SystemInit = z.object({
13
13
  name: z.string().nonempty().max(250),
14
14
  hostname: z.string().nonempty().max(250),
15
15
  type: SystemType.default('server'),
16
+ /** The connected system user, if any. `null` disconnects. */
17
+ connectedUserId: z.uuid().nullish(),
16
18
  });
17
19
  export const System = SystemInit.extend({
18
20
  id: z.uuid(),
19
21
  userId: z.uuid(),
22
+ connectedUserId: z.uuid().nullable(),
20
23
  isShared: z.boolean(),
21
24
  acl: AccessControl.array(),
22
25
  });
@@ -51,6 +54,9 @@ const SysadminAPI = {
51
54
  PATCH: [SystemUserInit, SystemUser],
52
55
  DELETE: SystemUser,
53
56
  },
57
+ 'sysadmin/users/:id/systems': {
58
+ GET: System.array(),
59
+ },
54
60
  };
55
61
  Object.assign($API, SysadminAPI);
56
62
  const SysadminClientToServer = {
package/dist/info.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { UserInfo } from 'node:os';
1
2
  import * as z from 'zod';
2
3
  export declare const TotalUsed: z.ZodObject<{
3
4
  total: z.ZodCoercedBigInt<unknown>;
@@ -22,6 +23,8 @@ export interface GPU extends z.infer<typeof GPU> {
22
23
  }
23
24
  export declare const Memory: z.ZodObject<{
24
25
  speed: z.ZodInt;
26
+ formFactor: z.ZodOptional<z.ZodString>;
27
+ type: z.ZodOptional<z.ZodString>;
25
28
  swap: z.ZodOptional<z.ZodObject<{
26
29
  total: z.ZodCoercedBigInt<unknown>;
27
30
  used: z.ZodCoercedBigInt<unknown>;
@@ -48,6 +51,15 @@ export declare const NetworkInterface: z.ZodObject<{
48
51
  }, z.core.$strip>;
49
52
  export interface NetworkInterface extends z.infer<typeof NetworkInterface> {
50
53
  }
54
+ export declare const SystemInfoUser: z.ZodObject<{
55
+ username: z.ZodString;
56
+ uid: z.ZodInt;
57
+ gid: z.ZodInt;
58
+ shell: z.ZodNullable<z.ZodString>;
59
+ homedir: z.ZodString;
60
+ }, z.core.$strip>;
61
+ export interface SystemInfoUser extends z.infer<typeof SystemInfoUser>, UserInfo<string> {
62
+ }
51
63
  export declare const SystemInfo: z.ZodObject<{
52
64
  cpus: z.ZodArray<z.ZodObject<{
53
65
  model: z.ZodString;
@@ -62,6 +74,8 @@ export declare const SystemInfo: z.ZodObject<{
62
74
  }, z.core.$strip>>;
63
75
  memory: z.ZodObject<{
64
76
  speed: z.ZodInt;
77
+ formFactor: z.ZodOptional<z.ZodString>;
78
+ type: z.ZodOptional<z.ZodString>;
65
79
  swap: z.ZodOptional<z.ZodObject<{
66
80
  total: z.ZodCoercedBigInt<unknown>;
67
81
  used: z.ZodCoercedBigInt<unknown>;
@@ -90,6 +104,13 @@ export declare const SystemInfo: z.ZodObject<{
90
104
  uptime: z.ZodNumber;
91
105
  version: z.ZodString;
92
106
  hostname: z.ZodString;
107
+ user: z.ZodObject<{
108
+ username: z.ZodString;
109
+ uid: z.ZodInt;
110
+ gid: z.ZodInt;
111
+ shell: z.ZodNullable<z.ZodString>;
112
+ homedir: z.ZodString;
113
+ }, z.core.$strip>;
93
114
  }, z.core.$strip>;
94
115
  export interface SystemInfo extends z.infer<typeof SystemInfo> {
95
116
  }
package/dist/info.js CHANGED
@@ -17,6 +17,10 @@ export const Memory = z.object({
17
17
  ...TotalUsed.shape,
18
18
  /** Memory speed in MT/s */
19
19
  speed: z.int().nonnegative(),
20
+ /** Form factor, e.g. 'DIMM' or 'SODIMM' */
21
+ formFactor: z.string().optional(),
22
+ /** Memory type, e.g. 'DDR4' or 'DDR5' */
23
+ type: z.string().optional(),
20
24
  /** Only available when swap is in use */
21
25
  swap: TotalUsed.optional(),
22
26
  });
@@ -32,6 +36,13 @@ export const NetworkInterface = z.object({
32
36
  connection: z.string().optional(),
33
37
  speed: z.number().nonnegative().optional(),
34
38
  });
39
+ export const SystemInfoUser = z.object({
40
+ username: z.string(),
41
+ uid: z.int(),
42
+ gid: z.int(),
43
+ shell: z.string().nullable(),
44
+ homedir: z.string(),
45
+ });
35
46
  export const SystemInfo = z.object({
36
47
  cpus: CPU.array(),
37
48
  gpus: GPU.array(),
@@ -53,6 +64,7 @@ export const SystemInfo = z.object({
53
64
  /** Kernel version, e.g. `#1 SMP PREEMPT_DYNAMIC ...` */
54
65
  version: z.string(),
55
66
  hostname: z.string(),
67
+ user: SystemInfoUser,
56
68
  });
57
69
  // OS //
58
70
  // User //
@@ -8,6 +8,21 @@ import { SystemInit, SystemUserInit } from '../common.js';
8
8
  function parseSystem(system, isShared) {
9
9
  return Object.assign({ acl: [] }, system, { isShared });
10
10
  }
11
+ /**
12
+ * Ensure the requester owns the system user they are connecting a system to.
13
+ * No-op when `connectedUserId` is unset (`undefined` leaves it unchanged, `null` disconnects).
14
+ */
15
+ async function checkConnectedUser(request, connectedUserId) {
16
+ if (!connectedUserId)
17
+ return;
18
+ const systemUser = await database
19
+ .selectFrom('system_users')
20
+ .select('userId')
21
+ .where('id', '=', connectedUserId)
22
+ .executeTakeFirstOrThrow()
23
+ .catch(withError('Could not get system user', 404));
24
+ await checkAuthForUser(request, systemUser.userId);
25
+ }
11
26
  addRoute({
12
27
  path: '/api/users/:id/sysadmin/systems',
13
28
  params: { id: z.uuid() },
@@ -25,6 +40,7 @@ addRoute({
25
40
  async PUT(request, { id: userId }) {
26
41
  const init = await parseBody(request, SystemInit);
27
42
  await checkAuthForUser(request, userId);
43
+ await checkConnectedUser(request, init.connectedUserId);
28
44
  return parseSystem(await database
29
45
  .insertInto('systems')
30
46
  .values({ ...init, userId })
@@ -43,6 +59,7 @@ addRoute({
43
59
  async PATCH(request, { id }) {
44
60
  const init = await parseBody(request, SystemInit);
45
61
  const { fromACL } = await authRequestForItem(request, 'systems', id, { edit: true });
62
+ await checkConnectedUser(request, init.connectedUserId);
46
63
  const system = await database
47
64
  .updateTable('systems')
48
65
  .set(init)
@@ -134,3 +151,24 @@ addRoute({
134
151
  .catch(withError('Could not delete system user'));
135
152
  },
136
153
  });
154
+ addRoute({
155
+ path: '/api/sysadmin/users/:id/systems',
156
+ params: { id: z.uuid() },
157
+ async GET(request, { id }) {
158
+ const user = await database
159
+ .selectFrom('system_users')
160
+ .select('userId')
161
+ .where('id', '=', id)
162
+ .executeTakeFirstOrThrow()
163
+ .catch(withError('Could not get system user', 404));
164
+ await checkAuthForUser(request, user.userId);
165
+ const systems = await database
166
+ .selectFrom('systems')
167
+ .selectAll()
168
+ .select(acl.from('systems'))
169
+ .where('connectedUserId', '=', id)
170
+ .execute()
171
+ .catch(withError('Could not get systems'));
172
+ return systems.map(system => parseSystem(system, false));
173
+ },
174
+ });
package/locales/en.json CHANGED
@@ -14,8 +14,6 @@
14
14
  "online": "Online",
15
15
  "offline": "Offline",
16
16
  "back_to_main": "Back to System Administration",
17
- "back_to_systems": "Back to Systems",
18
- "back_to_users": "Back to System Users",
19
17
  "SystemInitDialog": {
20
18
  "submit": "Add System",
21
19
  "save": "Save",
@@ -42,7 +40,9 @@
42
40
  "toast_deleted": "User deleted"
43
41
  },
44
42
  "user": {
45
- "page_title": "{name} — System Users"
43
+ "page_title": "{name} — System Users",
44
+ "systems": "Connected Systems",
45
+ "no_systems": "No systems are connected to this user."
46
46
  },
47
47
  "system_type": {
48
48
  "server": "Server",
@@ -60,6 +60,7 @@
60
60
  "cpu_cores": "{count} cores",
61
61
  "gpu": "Graphics",
62
62
  "memory": "Memory",
63
+ "memory_speed": "{speed} MT/s",
63
64
  "swap": "Swap",
64
65
  "storage": "Storage",
65
66
  "network": "Network",
@@ -74,7 +75,21 @@
74
75
  "kernel": "Kernel",
75
76
  "arch": "Architecture",
76
77
  "uptime": "Uptime",
77
- "usage": "{used} / {total}"
78
+ "usage": "{used} / {total}",
79
+ "user": {
80
+ "title": "User",
81
+ "username": "Username",
82
+ "uid": "UID / GID",
83
+ "home": "Home Directory",
84
+ "shell": "Shell",
85
+ "match_found": "This user matches '{name}'.",
86
+ "no_match": "No matching system user. Add one with the live username to connect it.",
87
+ "connect": "Connect User",
88
+ "connected_to": "Connected to {name}",
89
+ "disconnect": "Disconnect",
90
+ "disconnected_from": "Disconnected from {name}",
91
+ "username_mismatch": "The connected user “{connected}” no longer matches the live username “{live}”."
92
+ }
78
93
  }
79
94
  }
80
95
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axium/sysadmin",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
4
4
  "author": "James Prevett <axium@jamespre.dev>",
5
5
  "description": "System administration for Axium",
6
6
  "funding": {
@@ -1,18 +1,29 @@
1
1
  <script lang="ts">
2
- import { text } from '@axium/client';
3
- import { formatBytes, formatDuration } from '@axium/core';
2
+ import { fetchAPI, text } from '@axium/client';
4
3
  import { Icon, NumberBar } from '@axium/client/components';
5
4
  import { connect } from '@axium/client/socket';
5
+ import { toastStatus } from '@axium/client/toast';
6
+ import { formatBytes, formatDuration } from '@axium/core';
6
7
  import { systemTypeIcons, type SystemInfo } from '@axium/sysadmin';
7
8
  import '@axium/sysadmin/common';
8
9
 
9
10
  const { data } = $props();
10
- const system = data.system;
11
+
12
+ let system = $state(data.system);
13
+ const systemUsers = data.systemUsers;
11
14
 
12
15
  let info = $state<SystemInfo>();
13
16
  let error = $state(false);
14
17
  let loading = $state(true);
15
18
 
19
+ const matchingUser = $derived(info && systemUsers.find(u => u.username === info!.user.username));
20
+ const connectedUser = $derived(system.connectedUserId ? systemUsers.find(u => u.id === system.connectedUserId) : undefined);
21
+
22
+ async function setConnectedUser(connectedUserId: string | null) {
23
+ const updated = await fetchAPI('PATCH', 'sysadmin/systems/:id', { ...system, connectedUserId }, system.id);
24
+ system = updated;
25
+ }
26
+
16
27
  /** The fraction used, for a NumberBar, clamped to [0, 1]. */
17
28
  function fraction(used: bigint, total: bigint): number {
18
29
  if (total <= 0n) return 0;
@@ -20,6 +31,7 @@
20
31
  }
21
32
 
22
33
  function usageText(used: bigint, total: bigint): string {
34
+ if (used <= 0n) return formatBytes(total);
23
35
  return text('sysadmin.system.usage', { used: formatBytes(used), total: formatBytes(total) });
24
36
  }
25
37
 
@@ -35,7 +47,7 @@
35
47
  try {
36
48
  const all = await socket.emitWithAck('sysadmin:getSystemInfo');
37
49
  info = all.find(i => i.hostname === system.hostname);
38
- if (!info) error = true;
50
+ error = !info;
39
51
  } catch {
40
52
  error = true;
41
53
  } finally {
@@ -53,9 +65,9 @@
53
65
  </svelte:head>
54
66
 
55
67
  <div class="system-page">
56
- <a class="subtle icon-text back" href="/sysadmin/systems">
68
+ <a class="subtle icon-text back" href="/sysadmin">
57
69
  <Icon i="arrow-left" />
58
- <span>{text('sysadmin.back_to_systems')}</span>
70
+ <span>{text('sysadmin.back_to_main')}</span>
59
71
  </a>
60
72
 
61
73
  <div class="system-header">
@@ -113,7 +125,19 @@
113
125
  {/if}
114
126
 
115
127
  <div class="component">
116
- <h3><Icon i="memory" /> {text('sysadmin.system.memory')}</h3>
128
+ <h3>
129
+ <Icon i="memory" />
130
+ {text('sysadmin.system.memory')}
131
+ {#if info.memory.type}
132
+ <span class="subtle">{info.memory.type}</span>
133
+ {/if}
134
+ {#if info.memory.formFactor}
135
+ <span class="subtle">{info.memory.formFactor}</span>
136
+ {/if}
137
+ {#if info.memory.speed}
138
+ <span class="subtle">{text('sysadmin.system.memory_speed', { speed: info.memory.speed })}</span>
139
+ {/if}
140
+ </h3>
117
141
  <NumberBar
118
142
  value={fraction(info.memory.used, info.memory.total)}
119
143
  max={1}
@@ -182,6 +206,68 @@
182
206
  </dl>
183
207
  </section>
184
208
  {/if}
209
+
210
+ {#if info || connectedUser}
211
+ <section>
212
+ {const userIcon = !info ? 'user' : info.user.uid === 0 ? 'user-crown' : info.user.uid < 1000 ? 'user-gear' : 'user'}
213
+ <h2><Icon i={userIcon} /> {text('sysadmin.system.user.title')}</h2>
214
+
215
+ {#if info && connectedUser && connectedUser.username !== info.user.username}
216
+ <div class="warning icon-text">
217
+ <Icon i="triangle-exclamation" />
218
+ <span>
219
+ {text('sysadmin.system.user.username_mismatch', {
220
+ connected: connectedUser.username,
221
+ live: info.user.username,
222
+ })}
223
+ </span>
224
+ </div>
225
+ {/if}
226
+
227
+ {#if info}
228
+ <dl>
229
+ <dt>{text('sysadmin.system.user.username')}</dt>
230
+ <dd>{info.user.username}</dd>
231
+ <dt>{text('sysadmin.system.user.uid')}</dt>
232
+ <dd>{info.user.uid} <span class="subtle">/ {info.user.gid}</span></dd>
233
+ <dt>{text('sysadmin.system.user.home')}</dt>
234
+ <dd>{info.user.homedir}</dd>
235
+ {#if info.user.shell}
236
+ <dt>{text('sysadmin.system.user.shell')}</dt>
237
+ <dd>{info.user.shell}</dd>
238
+ {/if}
239
+ </dl>
240
+ {/if}
241
+
242
+ <div class="user-connection">
243
+ {#if connectedUser}
244
+ <a class="icon-text subtle" href="/sysadmin/users/{connectedUser.id}">
245
+ <Icon i="link" />
246
+ <span>{text('sysadmin.system.user.connected_to', connectedUser)}</span>
247
+ </a>
248
+ <button
249
+ class="icon-text"
250
+ onclick={() => toastStatus(setConnectedUser(null), text('sysadmin.system.user.disconnected_from', connectedUser))}
251
+ >
252
+ <Icon i="link-slash" />
253
+ <span>{text('sysadmin.system.user.disconnect')}</span>
254
+ </button>
255
+ {:else if matchingUser}
256
+ <span class="subtle">{text('sysadmin.system.user.match_found', matchingUser)}</span>
257
+ <button
258
+ class="icon-text"
259
+ onclick={() =>
260
+ toastStatus(setConnectedUser(matchingUser.id), text('sysadmin.system.user.connected_to', matchingUser))}
261
+ >
262
+ <Icon i="link" />
263
+ <span>{text('sysadmin.system.user.connect')}</span>
264
+ </button>
265
+ {:else}
266
+ <span class="subtle">{text('sysadmin.system.user.no_match')}</span>
267
+ {/if}
268
+ </div>
269
+ </section>
270
+ {/if}
185
271
  </div>
186
272
 
187
273
  <style>
@@ -315,6 +401,17 @@
315
401
  margin: 0;
316
402
  }
317
403
 
404
+ .user-connection {
405
+ display: flex;
406
+ align-items: center;
407
+ gap: 1em;
408
+ flex-wrap: wrap;
409
+
410
+ button {
411
+ margin-left: auto;
412
+ }
413
+ }
414
+
318
415
  @media (width < 700px) {
319
416
  .system-page {
320
417
  padding: 1em;
@@ -5,7 +5,10 @@ export const ssr = false;
5
5
  export async function load({ parent, params }) {
6
6
  const { session } = await parent();
7
7
 
8
- const system = await fetchAPI('GET', 'sysadmin/systems/:id', {}, params.id);
8
+ const [system, systemUsers] = await Promise.all([
9
+ fetchAPI('GET', 'sysadmin/systems/:id', {}, params.id),
10
+ fetchAPI('GET', 'users/:id/sysadmin/users', {}, session.userId),
11
+ ]);
9
12
 
10
- return { session, system };
13
+ return { session, system, systemUsers };
11
14
  }
@@ -1,15 +1,19 @@
1
1
  <script lang="ts">
2
+ import { goto } from '$app/navigation';
2
3
  import { fetchAPI, text } from '@axium/client';
3
4
  import { Icon } from '@axium/client/components';
4
- import { UserInitDialog } from '@axium/sysadmin/components';
5
+ import { socket } from '@axium/client/socket';
5
6
  import { toastStatus } from '@axium/client/toast';
6
- import { goto } from '$app/navigation';
7
+ import { SystemCard, UserInitDialog } from '@axium/sysadmin/components';
7
8
 
8
9
  const { data } = $props();
9
10
 
10
11
  let user = $state(data.user);
12
+ let systems = $state(data.systems);
11
13
  let editDialog = $state<HTMLDialogElement>();
12
14
 
15
+ const onlineHosts = $derived(await socket?.emitWithAck('sysadmin:ping').then(systems => systems.map(s => s.hostname)));
16
+
13
17
  function remove() {
14
18
  toastStatus(
15
19
  fetchAPI('DELETE', 'sysadmin/users/:id', {}, user.id).then(() => goto('/sysadmin/users')),
@@ -23,9 +27,9 @@
23
27
  </svelte:head>
24
28
 
25
29
  <div class="user-page">
26
- <a class="subtle icon-text back" href="/sysadmin/users">
30
+ <a class="subtle icon-text back" href="/sysadmin">
27
31
  <Icon i="arrow-left" />
28
- <span>{text('sysadmin.back_to_users')}</span>
32
+ <span>{text('sysadmin.back_to_main')}</span>
29
33
  </a>
30
34
 
31
35
  <div class="user-header">
@@ -43,6 +47,23 @@
43
47
  <span>{text('generic.delete')}</span>
44
48
  </button>
45
49
  </div>
50
+
51
+ <section>
52
+ <h2><Icon i="server" /> {text('sysadmin.user.systems')}</h2>
53
+ {#if systems.length}
54
+ <div class="cards">
55
+ {#each systems as system, i (system.id)}
56
+ <SystemCard
57
+ bind:system={systems[i]}
58
+ online={!!onlineHosts?.includes(system.hostname)}
59
+ deleted={() => systems.splice(i, 1)}
60
+ />
61
+ {/each}
62
+ </div>
63
+ {:else}
64
+ <p class="subtle">{text('sysadmin.user.no_systems')}</p>
65
+ {/if}
66
+ </section>
46
67
  </div>
47
68
 
48
69
  <UserInitDialog bind:dialog={editDialog} {user} edited={updated => (user = updated)} />
@@ -73,10 +94,33 @@
73
94
  }
74
95
  }
75
96
 
97
+ section {
98
+ display: flex;
99
+ flex-direction: column;
100
+ gap: 1em;
101
+
102
+ h2 {
103
+ margin: 0;
104
+ display: flex;
105
+ align-items: center;
106
+ gap: 0.5em;
107
+ }
108
+ }
109
+
110
+ .cards {
111
+ display: grid;
112
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
113
+ gap: 1em;
114
+ }
115
+
76
116
  @media (width < 700px) {
77
117
  .user-page {
78
118
  padding: 1em;
79
119
  padding-bottom: 5em;
80
120
  }
121
+
122
+ .cards {
123
+ grid-template-columns: 1fr;
124
+ }
81
125
  }
82
126
  </style>
@@ -5,7 +5,10 @@ export const ssr = false;
5
5
  export async function load({ parent, params }) {
6
6
  const { session } = await parent();
7
7
 
8
- const user = await fetchAPI('GET', 'sysadmin/users/:id', {}, params.id);
8
+ const [user, systems] = await Promise.all([
9
+ fetchAPI('GET', 'sysadmin/users/:id', {}, params.id),
10
+ fetchAPI('GET', 'sysadmin/users/:id/systems', {}, params.id),
11
+ ]);
9
12
 
10
- return { session, user };
13
+ return { session, user, systems };
11
14
  }