feat(api): integrate identity, notification scope and connection settings into control plane

- Wire identity, metrics and update services into the assembled API.

- Add connection authorization modes and scoped console authorization.

- Preserve notification group IDs for filtered delivery.

- Extend maintenance, backup, resource and module flows with runtime metadata and upstream safety.
This commit is contained in:
chick
2026-09-07 00:45:18 +08:00
parent 7168e9dba2
commit 635527dd26
22 changed files with 754 additions and 46 deletions
+129
View File
@@ -397,6 +397,7 @@ describe('buildControlPlaneApp', () => {
checkedAt: null,
},
resources: {},
identity: { tracked: false, status: 'confirmed', reasons: [] },
},
],
});
@@ -1296,4 +1297,132 @@ describe('buildControlPlaneApp', () => {
expect(unbinds).toEqual([`http://192.168.1.20:8080/api/hub/unbind`]);
await app.close();
});
it('holds controls for two nodes that claim the same device identity', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const imei = '860000000000007';
const app = buildControlPlaneApp({
db,
store: new Store(),
upstream: {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async ({ url, method }) => {
if (url.endsWith('/api/device'))
return {
status: 200,
headers: {},
body: JSON.stringify({ imei, model: 'RM500Q', manufacturer: 'Quectel' }),
};
return {
status: method === 'POST' ? 200 : 404,
headers: {},
body: '{"status":"success"}',
};
},
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postBasebandRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
});
const created: string[] = [];
for (const host of ['192.168.1.30', '192.168.1.31']) {
const response = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: { name: `节点 ${host}`, origin: `http://${host}:8080` },
});
created.push(response.json().id as string);
}
const [first, second] = created;
// Reading the overview is enough: the device reports its own hardware and the guard notices
// that two records now answer with one IMEI.
for (const id of created) {
const snapshot = await app.inject(`/api/v1/instances/${id}/modules/overview`);
expect(snapshot.statusCode).toBe(200);
}
const listed = await app.inject('/api/v1/fleet/identities');
expect(listed.json()).toMatchObject({
summary: { tracked: 2, pending: 2 },
items: [
{ instanceId: first, reasons: ['imei_claimed'] },
{ instanceId: second, reasons: ['imei_claimed'] },
],
});
const blocked = await app.inject({
method: 'POST',
url: `/api/v1/instances/${first}/device-actions/call.waiting`,
payload: { confirm: true, params: { value: 'enabled' } },
});
expect(blocked.statusCode).toBe(409);
expect(blocked.json()).toMatchObject({ code: 'IDENTITY_UNCONFIRMED' });
const overview = await app.inject('/api/v1/fleet/overview');
expect(overview.json().items[0].identity).toEqual({
tracked: true,
status: 'pending',
reasons: ['imei_claimed'],
});
// The operator vouches for one node; the other keeps waiting.
const confirmed = await app.inject({
method: 'POST',
url: `/api/v1/fleet/identities/${first}/confirm`,
});
expect(confirmed.json()).toMatchObject({
identity: { instanceId: first, status: 'confirmed', reasons: [] },
});
const allowed = await app.inject({
method: 'POST',
url: `/api/v1/instances/${first}/device-actions/call.waiting`,
payload: { confirm: true, params: { value: 'enabled' } },
});
expect(allowed.statusCode).toBe(200);
await app.close();
});
it('scrapes the fused fleet through the metrics endpoint', async () => {
const db = new Database(':memory:');
db.pragma('foreign_keys=ON');
migrateDatabase(db);
dbs.push(db);
const app = buildControlPlaneApp({
db,
store: new Store(),
runtimeVersion: '9.9.9',
upstream: {
get: async () => ({ status: 200, headers: {}, body: '' }),
request: async ({ method }) => ({
status: method === 'POST' ? 200 : 404,
headers: {},
body: '{"status":"success"}',
}),
postNetworkRegisterAuto: async () => ({ status: 200 }),
postServiceRestart: async () => ({ status: 200 }),
postBasebandRestart: async () => ({ status: 200 }),
postSystemReboot: async () => ({ status: 200 }),
},
});
const created = await app.inject({
method: 'POST',
url: '/api/v1/instances',
payload: { name: '节点 A', origin: 'http://192.168.1.40:8080' },
});
expect(created.statusCode).toBe(201);
const response = await app.inject({ method: 'GET', url: '/api/v1/metrics' });
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toContain('text/plain');
expect(response.body).toContain('multi_simadmin_build_info{version="9.9.9"} 1');
expect(response.body).toContain('multi_simadmin_nodes_total 1');
expect(response.body).toContain('multi_simadmin_notification_queue{status="pending"} 0');
await app.close();
});
});