device-center 0.2.0-beta.1 → 0.2.0-beta.2

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.
@@ -2,6 +2,7 @@ import { randomBytes, randomUUID, createHash } from 'node:crypto';
2
2
  import { fail, object, text, publicIdentity, verifyRequest, normalizeReport } from './driverProtocol.mjs';
3
3
  import { validateCertificate } from './certificates.mjs';
4
4
  import { createRelay } from './relay.mjs';
5
+ import { createDeviceProfiles } from './deviceProfiles.mjs';
5
6
 
6
7
  const PAIR_AGE = 5 * 60_000;
7
8
  export function createCloudRegistry({ db, origin, now = Date.now, multiUser = false, legacyOwnerMapped = !multiUser }) {
@@ -19,6 +20,10 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
19
20
  }
20
21
  db.exec('CREATE INDEX IF NOT EXISTS cloud_drivers_tenant_created ON cloud_drivers(tenant_id, created_at)');
21
22
  for (const table of ['driver_pairings', 'cloud_drivers']) if (!db.prepare(`PRAGMA table_info(${table})`).all().some(c => c.name === 'certificate')) db.exec(`ALTER TABLE ${table} ADD COLUMN certificate TEXT`);
23
+ if (!db.prepare('PRAGMA table_info(cloud_drivers)').all().some(c => c.name === 'control_seen_at')) db.exec('ALTER TABLE cloud_drivers ADD COLUMN control_seen_at INTEGER');
24
+ db.exec(`CREATE TABLE IF NOT EXISTS driver_collection_requests (driver_id TEXT PRIMARY KEY, request_id TEXT NOT NULL,
25
+ requested_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, completed_at INTEGER)`);
26
+ const profiles = createDeviceProfiles({ db, now, initialize: true });
22
27
  if (multiUser && !legacyOwnerMapped && db.prepare("SELECT 1 FROM cloud_drivers WHERE tenant_id='cloud-owner' LIMIT 1").get()) throw new Error('cloud_legacy_owner_mapping_required');
23
28
  let starts = [], globalLookups = [], globalApprovals = [];
24
29
  const lookups = new Map(), approvals = new Map();
@@ -54,7 +59,18 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
54
59
  if (row.revoked_at != null) fail('driver_revoked', 403);
55
60
  return row;
56
61
  }
57
- const descriptor = row => ({ id: row.id, name: row.name, platform: row.platform, fingerprint: row.fingerprint, shareSsh: Boolean(row.share_ssh), createdAt: row.created_at, revokedAt: row.revoked_at, lastSeen: row.last_seen });
62
+ const descriptor = row => {
63
+ const profile = profiles.get(row.tenant_id, `${row.id}:host`);
64
+ return { id: row.id, name: profile.displayName || row.name, reportedName: row.name, profile, platform: row.platform, fingerprint: row.fingerprint, shareSsh: Boolean(row.share_ssh), createdAt: row.created_at, revokedAt: row.revoked_at, lastSeen: row.last_seen };
65
+ };
66
+ const collection = row => {
67
+ const request = db.prepare('SELECT * FROM driver_collection_requests WHERE driver_id=?').get(row.id);
68
+ const connected = row.last_seen != null && now() - row.last_seen <= 90_000;
69
+ const supported = connected && row.control_seen_at != null && now() - row.control_seen_at <= 30_000;
70
+ return { supported, reason: !connected ? 'driver_not_connected' : row.control_seen_at == null ? 'driver_upgrade_required' : !supported ? 'driver_control_unavailable' : null,
71
+ status: !request ? 'idle' : request.completed_at != null ? 'completed' : request.expires_at <= now() ? 'timeout' : 'pending',
72
+ requestedAt: request?.requested_at ?? null, completedAt: request?.completed_at ?? null, expiresAt: request?.expires_at ?? null };
73
+ };
58
74
  const peers = row => db.prepare('SELECT * FROM cloud_drivers WHERE tenant_id=? AND revoked_at IS NULL AND certificate IS NOT NULL').all(row.tenant_id).map(peer => ({ ...descriptor(peer), certificate: peer.certificate }));
59
75
  const relay = createRelay({ db, now, getDriver: id => db.prepare('SELECT * FROM cloud_drivers WHERE id=?').get(id) });
60
76
  const registry = {
@@ -118,6 +134,7 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
118
134
  const report = normalizeReport(input.payload, Boolean(row.share_ssh), now());
119
135
  if (row.last_seen != null && now() - row.last_seen < 15_000) fail('driver_report_too_frequent', 429);
120
136
  db.prepare('UPDATE cloud_drivers SET last_seen=?, report_json=? WHERE id=?').run(now(), JSON.stringify(report), row.id);
137
+ if (report.collectionRequestId && report.resources[0].observedAt != null) db.prepare('UPDATE driver_collection_requests SET completed_at=? WHERE driver_id=? AND request_id=? AND completed_at IS NULL AND expires_at>?').run(now(), row.id, report.collectionRequestId, now());
121
138
  return { ok: true, receivedAt: now() };
122
139
  },
123
140
  disconnect(input) {
@@ -133,6 +150,24 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
133
150
  return { ok: true };
134
151
  },
135
152
  bindings: tenantId => db.prepare('SELECT * FROM cloud_drivers WHERE tenant_id=? ORDER BY created_at').all(tenantId).map(descriptor),
153
+ saveProfile(input, tenantId) {
154
+ if (!registry.devices(tenantId).some(d => d.id === input?.deviceId)) fail('device_not_found', 404);
155
+ return { profile: profiles.save(tenantId, input) };
156
+ },
157
+ collect(input, tenantId) {
158
+ object(input, ['deviceId']);
159
+ if (typeof input.deviceId !== 'string') fail('invalid_device_id');
160
+ const device = registry.devices(tenantId).find(d => d.id === input.deviceId);
161
+ if (!device) fail('device_not_found', 404);
162
+ if (device.kind !== 'management') fail('collection_requires_local_ssh_check', 409);
163
+ const row = db.prepare('SELECT * FROM cloud_drivers WHERE id=? AND tenant_id=? AND revoked_at IS NULL').get(device.managementDriver.id, tenantId);
164
+ const current = collection(row);
165
+ if (!current.supported) fail(current.reason, 409);
166
+ if (current.status === 'pending') return { status: 'pending', collection: current };
167
+ if (current.requestedAt != null && now() - current.requestedAt < 10_000) fail('collection_rate_limited', 429);
168
+ db.prepare('INSERT INTO driver_collection_requests VALUES (?,?,?,?,NULL) ON CONFLICT(driver_id) DO UPDATE SET request_id=excluded.request_id,requested_at=excluded.requested_at,expires_at=excluded.expires_at,completed_at=NULL').run(row.id, randomUUID(), now(), now() + 75_000);
169
+ return { status: 'pending', collection: collection(row) };
170
+ },
136
171
  devices(tenantId) {
137
172
  return db.prepare('SELECT * FROM cloud_drivers WHERE tenant_id=? AND revoked_at IS NULL ORDER BY created_at').all(tenantId).flatMap(row => {
138
173
  const report = row.report_json ? JSON.parse(row.report_json).resources : [{ id: 'host', name: row.name, platform: row.platform, kind: 'management', observedAt: null, memory: { status: 'unobserved', usedMb: null, totalMb: null }, disk: { status: 'unobserved', usedGb: null, totalGb: null } }];
@@ -140,7 +175,7 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
140
175
  const latest = resource.observedAt == null || row.last_seen == null ? null : Math.min(resource.observedAt, row.last_seen);
141
176
  const age = latest == null ? null : Math.max(0, now() - latest);
142
177
  const connected = row.last_seen != null && now() - row.last_seen <= 90_000;
143
- return { ...resource, id: `${row.id}:${resource.id}`, role: resource.kind === 'management' ? 'driver' : 'resource', source: 'driver-report', transport: 'driver', scope: 'host', state: age == null ? 'unknown' : !connected || age > (resource.kind === 'management' ? 90_000 : 300_000) ? 'expired' : 'online', lastHeartbeat: latest, heartbeatAgeMs: age, memory: resource.memory, disk: resource.disk, synthetic: false, managementDriver: { ...descriptor(row), origin, connected }, capabilities: { observation: true, remoteExecution: false } };
178
+ return profiles.apply(tenantId, { ...resource, id: `${row.id}:${resource.id}`, role: resource.kind === 'management' ? 'driver' : 'resource', source: 'driver-report', transport: 'driver', scope: 'host', state: age == null ? 'unknown' : !connected || age > (resource.kind === 'management' ? 90_000 : 300_000) ? 'expired' : 'online', lastHeartbeat: latest, heartbeatAgeMs: age, memory: resource.memory, disk: resource.disk, synthetic: false, managementDriver: { ...descriptor(row), origin, connected }, collection: resource.kind === 'management' ? collection(row) : { supported: false, reason: 'collection_requires_local_ssh_check', status: 'idle' }, capabilities: { observation: true, remoteExecution: false } });
144
179
  });
145
180
  });
146
181
  },
@@ -149,6 +184,13 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
149
184
  start: registry.start, poll: registry.poll, report: registry.report, disconnect: registry.disconnect,
150
185
  machine(action, input) {
151
186
  const row = driver(input, `/api/driver/${action}`);
187
+ if (action === 'control') {
188
+ object(input.payload, []);
189
+ if (row.control_seen_at != null && now() - row.control_seen_at < 3000) fail('driver_control_too_frequent', 429);
190
+ db.prepare('UPDATE cloud_drivers SET control_seen_at=? WHERE id=?').run(now(), row.id);
191
+ const request = db.prepare('SELECT * FROM driver_collection_requests WHERE driver_id=? AND completed_at IS NULL AND expires_at>?').get(row.id, now());
192
+ return { request: request ? { id: request.request_id, action: 'collect-resources', expiresAt: request.expires_at } : null };
193
+ }
152
194
  if (action === 'certificate') { object(input.payload, ['certificate']); const cert = validateCertificate(input.payload.certificate, row.fingerprint, now()); db.prepare('UPDATE cloud_drivers SET certificate=? WHERE id=?').run(cert, row.id); return { ok: true }; }
153
195
  if (action === 'peers') { object(input.payload, []); return { peers: peers(row) }; }
154
196
  if (!row.certificate) fail('driver_certificate_required', 409);
@@ -160,6 +202,7 @@ export function createCloudRegistry({ db, origin, now = Date.now, multiUser = fa
160
202
  return {
161
203
  lookup: input => registry.lookup(input, tenantId), approve: input => registry.approve(input, tenantId),
162
204
  revoke: input => registry.revoke(input, tenantId), bindings: () => registry.bindings(tenantId), devices: () => registry.devices(tenantId), relayUsage: () => relay.usage(tenantId),
205
+ saveProfile: input => registry.saveProfile(input, tenantId), collect: input => registry.collect(input, tenantId),
163
206
  };
164
207
  },
165
208
  };
@@ -0,0 +1,47 @@
1
+ // Descriptive owner metadata. It never changes device identity or grants access.
2
+ export const DEVICE_TYPES = ['unspecified', 'personal', 'server', 'workstation', 'other'];
3
+ const fail = (message, statusCode = 400) => { throw Object.assign(new Error(message), { statusCode }); };
4
+ function string(value, max, multiline = false) {
5
+ if (typeof value !== 'string' || value.length > max || (multiline ? /[\x00-\x09\x0b-\x1f\x7f]/ : /[\x00-\x1f\x7f]/).test(value)) fail('invalid_device_profile');
6
+ return value.trim();
7
+ }
8
+ export function normalizeDeviceProfile(value) {
9
+ if (!value || Array.isArray(value) || Object.keys(value).some(k => !['displayName', 'type', 'purpose', 'tags', 'services'].includes(k)) || !DEVICE_TYPES.includes(value.type)) fail('invalid_device_profile');
10
+ const list = (items, max, length) => {
11
+ if (!Array.isArray(items) || items.length > max) fail('invalid_device_profile');
12
+ const result = items.map(v => string(v, length));
13
+ if (result.some(v => !v)) fail('invalid_device_profile');
14
+ return [...new Set(result)];
15
+ };
16
+ return { displayName: string(value.displayName, 80), type: value.type, purpose: string(value.purpose, 240, true), tags: list(value.tags, 8, 24), services: list(value.services, 12, 40) };
17
+ }
18
+ const EMPTY = { displayName: '', type: 'unspecified', purpose: '', tags: [], services: [], revision: 0, updatedAt: null, servicesSource: 'user-declared' };
19
+ export function createDeviceProfiles({ db, now = Date.now, initialize = false }) {
20
+ const ensure = () => db.exec(`CREATE TABLE IF NOT EXISTS device_profiles (
21
+ tenant_id TEXT NOT NULL, device_id TEXT NOT NULL, profile_json TEXT NOT NULL,
22
+ revision INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY(tenant_id, device_id))`);
23
+ if (initialize) ensure();
24
+ const get = (tenantId, id) => {
25
+ if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='device_profiles'").get()) return { ...EMPTY, tags: [], services: [] };
26
+ const row = db.prepare('SELECT * FROM device_profiles WHERE tenant_id=? AND device_id=?').get(tenantId, id);
27
+ return row ? { ...normalizeDeviceProfile(JSON.parse(row.profile_json)), revision: row.revision, updatedAt: row.updated_at, servicesSource: 'user-declared' } : { ...EMPTY, tags: [], services: [] };
28
+ };
29
+ return {
30
+ get,
31
+ apply(tenantId, device) {
32
+ const profile = get(tenantId, device.id);
33
+ return { ...device, reportedName: device.reportedName ?? device.name, name: profile.displayName || device.name, profile };
34
+ },
35
+ save(tenantId, input) {
36
+ if (!input || Array.isArray(input) || Object.keys(input).some(k => !['deviceId', 'profile', 'revision'].includes(k)) || typeof input.deviceId !== 'string' || !input.deviceId || input.deviceId.length > 200 || !Number.isSafeInteger(input.revision) || input.revision < 0) fail('invalid_device_profile');
37
+ const profile = normalizeDeviceProfile(input.profile); ensure();
38
+ db.exec('BEGIN IMMEDIATE');
39
+ try {
40
+ if (get(tenantId, input.deviceId).revision !== input.revision) fail('device_profile_conflict', 409);
41
+ db.prepare('INSERT INTO device_profiles VALUES (?,?,?,?,?) ON CONFLICT(tenant_id,device_id) DO UPDATE SET profile_json=excluded.profile_json,revision=excluded.revision,updated_at=excluded.updated_at').run(tenantId, input.deviceId, JSON.stringify(profile), input.revision + 1, now());
42
+ db.exec('COMMIT');
43
+ return get(tenantId, input.deviceId);
44
+ } catch (error) { db.exec('ROLLBACK'); throw error; }
45
+ },
46
+ };
47
+ }
@@ -50,7 +50,8 @@ function metric(input, kind) {
50
50
  return { status: input.status, [used]: input.status === 'ok' ? input[used] : null, [total]: input.status === 'ok' ? input[total] : null };
51
51
  }
52
52
  export function normalizeReport(payload, shareSsh, now) {
53
- object(payload, ['resources']);
53
+ object(payload, ['resources', 'collectionRequestId']);
54
+ if (payload.collectionRequestId !== undefined && (typeof payload.collectionRequestId !== 'string' || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(payload.collectionRequestId))) fail('invalid_collection_request');
54
55
  if (!Array.isArray(payload.resources) || !payload.resources.length || payload.resources.length > 16) fail('invalid_driver_resources');
55
56
  const ids = new Set();
56
57
  const resources = payload.resources.map((value, index) => {
@@ -62,5 +63,5 @@ export function normalizeReport(payload, shareSsh, now) {
62
63
  if (observedAt !== null && (!Number.isSafeInteger(observedAt) || observedAt < 0 || observedAt > now + 90_000)) fail('invalid_driver_observation_time');
63
64
  return { id: value.id, name: text(value.name), platform: text(value.platform, 64), kind: value.kind, observedAt: observedAt == null ? null : Math.min(observedAt, now), memory: metric(value.memory, 'memory'), disk: metric(value.disk, 'disk') };
64
65
  });
65
- return { resources };
66
+ return { resources, ...(payload.collectionRequestId ? { collectionRequestId: payload.collectionRequestId } : {}) };
66
67
  }
@@ -4,12 +4,14 @@ import { discoverSsh } from './sshConfig.mjs';
4
4
  import { prepareSshProfile, probeSsh } from './sshProbe.mjs';
5
5
  import { normalizeConnectionPlan, readConnectionPlan, writeConnectionPlan } from './connectionPlans.mjs';
6
6
  import { inspectSshImport } from './sshInitialization.mjs';
7
+ import { createDeviceProfiles } from './deviceProfiles.mjs';
7
8
 
8
9
  function associations(db, tenantId) {
9
10
  if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='ssh_associations'").get()) return [];
10
11
  return db.prepare('SELECT alias, created_at AS createdAt FROM ssh_associations WHERE tenant_id = ? ORDER BY alias COLLATE NOCASE').all(tenantId);
11
12
  }
12
13
  export function createInventory({ db, tenantId, runtime = 'native', sshHome, sshProbe = probeSsh }) {
14
+ const profiles = createDeviceProfiles({ db });
13
15
  const activeChecks = new Set();
14
16
  const pendingChecks = new Set();
15
17
  const exclusions = () => db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='ssh_exclusions'").get()
@@ -48,7 +50,7 @@ export function createInventory({ db, tenantId, runtime = 'native', sshHome, ssh
48
50
  capabilities: { observation: Boolean(profile), remoteExecution: false },
49
51
  };
50
52
  });
51
- return [observeLocalDriver({ runtime }), ...remote, ...listDevices(db, tenantId)];
53
+ return [observeLocalDriver({ runtime }), ...remote, ...listDevices(db, tenantId)].map(device => profiles.apply(tenantId, device));
52
54
  };
53
55
  const check = async (input) => {
54
56
  if (!input || Array.isArray(input) || Object.keys(input).some((key) => key !== 'alias') || typeof input.alias !== 'string') throw Object.assign(new Error('invalid_ssh_check'), { statusCode: 400 });
@@ -149,5 +151,22 @@ export function createInventory({ db, tenantId, runtime = 'native', sshHome, ssh
149
151
  return { removed, excluded: chosen.length, systemConfigurationChanged: false };
150
152
  } catch (cause) { db.exec('ROLLBACK'); throw cause; }
151
153
  };
152
- return { list, discovery, associate, check, checkBatch, remove, associateAndCheck: (input) => configure(input, false), initializeAndCheck: (input) => configure(input, true), saveConnections, initialization, initialize };
154
+ const find = input => {
155
+ if (!input || Array.isArray(input) || typeof input.deviceId !== 'string') throw Object.assign(new Error('invalid_device_id'), { statusCode: 400 });
156
+ const device = list().find(d => d.id === input.deviceId);
157
+ if (!device) throw Object.assign(new Error('device_not_found'), { statusCode: 404 });
158
+ return device;
159
+ };
160
+ const saveProfile = input => { find(input); return { profile: profiles.save(tenantId, input) }; };
161
+ const collect = async input => {
162
+ if (Object.keys(input ?? {}).some(k => k !== 'deviceId')) throw Object.assign(new Error('invalid_collection_request'), { statusCode: 400 });
163
+ const device = find(input);
164
+ if (device.transport === 'ssh') {
165
+ const result = await check({ alias: device.ssh.alias });
166
+ if (!result.ok) return { status: 'failed', error: result.error, device: list().find(d => d.id === device.id) };
167
+ }
168
+ else if (device.source !== 'local-runtime' || runtime !== 'native') throw Object.assign(new Error('collection_unavailable'), { statusCode: 409 });
169
+ return { status: 'completed', device: list().find(d => d.id === device.id) };
170
+ };
171
+ return { list, discovery, associate, check, checkBatch, remove, saveProfile, collect, associateAndCheck: (input) => configure(input, false), initializeAndCheck: (input) => configure(input, true), saveConnections, initialization, initialize };
153
172
  }
package/server/index.mjs CHANGED
@@ -13,6 +13,7 @@ import { createRemoteAgent, createRemoteClient } from './drivers/remote.mjs';
13
13
 
14
14
  const PROJECT_ROOT = resolve(fileURLToPath(new URL('..', import.meta.url)));
15
15
  const MIME_TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png' };
16
+ const PUBLIC_GUIDES = Object.freeze({ '/install/agent.md': 'docs/agent-install.md', '/install/trial.md': 'docs/friend-trial.md', '/install/remote-access.md': 'docs/remote-access.md', '/install/device-profiles.md': 'docs/device-profiles.md', '/install/troubleshooting.md': 'docs/troubleshooting.md' });
16
17
  function sendJson(response, status, payload, headers = {}) {
17
18
  response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', ...headers });
18
19
  response.end(JSON.stringify(payload));
@@ -42,6 +43,7 @@ async function serveStatic(pathname, response) {
42
43
  let filePath;
43
44
  if (pathname === '/' || pathname === '/index.html') filePath = resolve(PROJECT_ROOT, 'index.html');
44
45
  else if (pathname === '/login') filePath = resolve(PROJECT_ROOT, 'login.html');
46
+ else if (pathname === '/start' || pathname === '/start.html') filePath = resolve(PROJECT_ROOT, 'start.html');
45
47
  else if (pathname.startsWith('/src/')) {
46
48
  filePath = resolve(PROJECT_ROOT, `.${pathname}`);
47
49
  if (!filePath.startsWith(resolve(PROJECT_ROOT, 'src') + '/')) return sendJson(response, 404, { error: 'not_found' });
@@ -79,11 +81,18 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
79
81
  const origin = cloudAccess ? cloudAccess.validate(request) : validateLocalRequest(request);
80
82
  const url = new URL(request.url ?? '/', origin);
81
83
  const path = url.pathname;
84
+ // Public static installation reference only. No identity, configuration or owner API.
85
+ if (['/start', '/start.html', '/src/start.js', '/src/start.css', '/src/onboarding.js'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
86
+ if (Object.hasOwn(PUBLIC_GUIDES, path) && request.method === 'GET') {
87
+ const body = await readFile(resolve(PROJECT_ROOT, PUBLIC_GUIDES[path]));
88
+ response.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'content-security-policy': "default-src 'none'; frame-ancestors 'none'", 'referrer-policy': 'no-referrer' });
89
+ return response.end(body);
90
+ }
82
91
  let accountRegistry;
83
92
  if (cloud) {
84
93
  const machineActions = { '/api/driver/pair/start': 'start', '/api/driver/pair/poll': 'poll', '/api/driver/report': 'report', '/api/driver/disconnect': 'disconnect' };
85
94
  if (Object.hasOwn(machineActions, path) && request.method === 'POST') return sendJson(response, 200, registry[machineActions[path]](await readJson(request)));
86
- if (['certificate','peers','relay/open','relay/accept','relay/exchange','relay/close'].some(action => path === `/api/driver/${action}`) && request.method === 'POST') return sendJson(response, 200, registry.machine(path.slice('/api/driver/'.length), await readJson(request)));
95
+ if (['control','certificate','peers','relay/open','relay/accept','relay/exchange','relay/close'].some(action => path === `/api/driver/${action}`) && request.method === 'POST') return sendJson(response, 200, registry.machine(path.slice('/api/driver/'.length), await readJson(request)));
87
96
  if (['/login', '/src/login.js', '/src/login.css'].includes(path) && request.method === 'GET') return await serveStatic(path, response);
88
97
  if (path === '/api/auth/options' && request.method === 'GET') return sendJson(response, 200, cloudAccess.options(request));
89
98
  if (path === '/api/auth/continue' && request.method === 'POST') {
@@ -111,7 +120,7 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
111
120
  }
112
121
  accountRegistry = registry.forTenant(principal.tenantId);
113
122
  if (path === '/api/drivers' && request.method === 'GET') return sendJson(response, 200, { drivers: accountRegistry.bindings() });
114
- const ownerActions = { '/api/drivers/pairing/lookup': 'lookup', '/api/drivers/pairing/approve': 'approve', '/api/drivers/revoke': 'revoke' };
123
+ const ownerActions = { '/api/devices/profile': 'saveProfile', '/api/devices/collect': 'collect', '/api/drivers/pairing/lookup': 'lookup', '/api/drivers/pairing/approve': 'approve', '/api/drivers/revoke': 'revoke' };
115
124
  if (Object.hasOwn(ownerActions, path) && request.method === 'POST') {
116
125
  if (request.headers.origin !== origin) return sendJson(response, 403, { error: 'same_origin_required' });
117
126
  return sendJson(response, 200, accountRegistry[ownerActions[path]](await readJson(request)));
@@ -144,6 +153,14 @@ export function createDeviceCenterServer({ dbPath = './data/device-center.sqlite
144
153
  if (request.method !== 'GET' || !web) return sendJson(response, 404, { error: 'not_found' });
145
154
  return await serveStatic(path, response);
146
155
  }
156
+ if (!cloud && ['/api/devices/profile', '/api/devices/collect'].includes(path) && request.method === 'POST') {
157
+ if (request.headers.origin !== origin) return sendJson(response, 403, { error: 'same_origin_required' });
158
+ const input = await readJson(request);
159
+ if (path === '/api/devices/profile') return sendJson(response, 200, inventory.saveProfile(input));
160
+ const result = await inventory.collect(input);
161
+ if (result.device.source === 'local-runtime') publisher?.requestCollection();
162
+ return sendJson(response, 200, result);
163
+ }
147
164
  if (path === '/api/devices/ssh' && request.method === 'POST') {
148
165
  if (request.headers.origin !== origin) return sendJson(response, 403, { error: 'same_origin_required' });
149
166
  return sendJson(response, 200, inventory.associate(await readJson(request)));
package/src/api.js CHANGED
@@ -6,6 +6,8 @@ async function request(path, { timeoutMs = 12000, ...options } = {}) {
6
6
  return payload;
7
7
  }
8
8
  export const api = {
9
+ saveDeviceProfile: (profile) => request('/api/devices/profile', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(profile) }),
10
+ collectDevice: (deviceId) => request('/api/devices/collect', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ deviceId }), timeoutMs: 30000 }),
9
11
  remotePeers: () => request('/api/remote/peers'),
10
12
  lookupPairing: (code) => request('/api/drivers/pairing/lookup', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ code }), timeoutMs: 12000 }),
11
13
  approvePairing: (id, shareSsh, code, fingerprint) => request('/api/drivers/pairing/approve', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id, shareSsh, code, fingerprint }), timeoutMs: 12000 }),
@@ -7,3 +7,22 @@ export function expireSshSnapshots(overview, now = Date.now()) {
7
7
  const stats = devices.reduce((totals, device) => { totals.total++; totals[device.state]++; return totals; }, { total: 0, online: 0, unknown: 0, expired: 0 });
8
8
  return { ...overview, devices, stats };
9
9
  }
10
+
11
+ export const DEVICE_TYPE_LABELS = { unspecified: '未分类', personal: '个人电脑', server: '服务器', workstation: '工作站', other: '其他' };
12
+ export function deviceStatus(device, now = Date.now()) {
13
+ if (device.state === 'unknown') return { label: '未知', title: '尚未取得资源数据;未把设备视为已连接。' };
14
+ if (device.source === 'driver-report' && (device.managementDriver.lastSeen == null || now - device.managementDriver.lastSeen > 90_000)) return { label: '未回报', title: '超过 90 秒未收到 Driver 回报。请确认设备已唤醒、Driver 正在运行且能连接云端。旧数据保留,不表示密钥过期。' };
15
+ if (device.state === 'expired') return { label: '待采集', title: '上次资源快照较早,点击采集一次取得新数据;不表示设备断线或密钥过期。' };
16
+ return { label: '在线', title: device.transport === 'ssh' ? '最近一次 SSH 检查成功,不表示持续连接。' : '最近收到真实资源数据。' };
17
+ }
18
+ export function collectionAvailability(device, now = Date.now()) {
19
+ if (device.source === 'driver-report') {
20
+ if (device.collection?.status === 'pending' && device.collection.expiresAt > now) return { available: false, pending: true, hint: '等待设备采集…' };
21
+ if (device.kind !== 'management') return { available: false, hint: '请在管理电脑检查 SSH' };
22
+ if (device.managementDriver.lastSeen == null || now - device.managementDriver.lastSeen > 90_000) return { available: false, hint: '请启动设备上的 Driver' };
23
+ if (!device.collection?.supported) return { available: false, hint: device.collection?.reason === 'driver_control_unavailable' ? '采集通道暂不可用' : '需更新设备 Driver' };
24
+ return { available: true, hint: '通知 Driver 重新采集内存与磁盘;不执行远程命令。' };
25
+ }
26
+ if (device.transport === 'ssh') return { available: Boolean(device.ssh?.checkAvailable && !device.ssh.checking && !device.ssh.queued), hint: '使用当前 SSH 配置进行一次只读检查并采集。' };
27
+ return { available: device.source === 'local-runtime' && device.scope === 'host', hint: '从当前本机进程重新读取内存与磁盘。' };
28
+ }
package/src/login.css CHANGED
@@ -14,3 +14,7 @@ button:disabled { opacity: .6; }
14
14
  .login-link { display: grid; place-items: center; min-height: 44px; margin-top: 24px; border-radius: 8px; color: white; background: #3c622a; text-decoration: none; font-size: 14px; }
15
15
  .help { line-height: 1.7; margin: 20px 0; }
16
16
  small { color: #788473; }
17
+
18
+ .install-help { margin-top: 24px; padding-top: 18px; border-top: 1px solid #e3e7e0; line-height: 1.8; }
19
+ .install-help a { color: #3c622a; text-underline-offset: 3px; }
20
+ .install-help span { font-size: 12px; }