feat(web): rebuild the console around the fused control plane

Render the Hub feature set in the existing Animal Island styling: fleet
messages, notification centre, log centre, organisation panel, per-instance
device module panels and the settings backup, connection and maintenance
surfaces.
This commit is contained in:
chick
2026-09-05 18:53:04 +08:00
parent f9186bd851
commit a75badeb58
77 changed files with 20578 additions and 435 deletions
@@ -0,0 +1,290 @@
import {
sanitizeNotificationChannel,
sanitizeNotificationLogEntry,
sanitizeNotificationQueueEntry,
sanitizeNotificationRule,
sanitizeNotificationCenterSnapshot,
} from './notification-center-sanitize.js';
import type {
NotificationChannel,
NotificationChannelDraft,
NotificationCenterDataSource,
NotificationCenterSnapshot,
NotificationLogEntry,
NotificationLogCleanup,
NotificationLogFilter,
NotificationPage,
NotificationQueueEntry,
NotificationRule,
NotificationRuleDraft,
} from './notification-center-types.js';
import { logFilterQuery, sanitizeLogCleanup } from './notification-center-types.js';
const BASE = '/api/v1/notifications';
export const NOTIFICATION_PAGE_SIZE = 20;
interface CallInit {
readonly method: string;
readonly body?: string | undefined;
readonly headers?: Readonly<Record<string, string>> | undefined;
readonly signal?: AbortSignal | undefined;
}
async function requestJson(
fetcher: typeof fetch,
path: string,
init: CallInit = { method: 'GET' },
): Promise<unknown> {
const response = await fetcher(path, {
method: init.method,
...(init.body !== undefined ? { body: init.body } : {}),
...(init.signal !== undefined ? { signal: init.signal } : {}),
credentials: 'same-origin',
headers: { accept: 'application/json', ...(init.headers ?? {}) },
});
if (!response.ok) throw new Error(`请求失败 (${response.status})`);
if (response.status === 204) return undefined;
try {
return (await response.json()) as unknown;
} catch {
return undefined;
}
}
function items(value: unknown): readonly unknown[] {
const raw =
value !== null && typeof value === 'object'
? (value as Record<string, unknown>).items
: undefined;
return Array.isArray(raw) ? raw.slice(0, 500) : [];
}
function total(value: unknown): number {
if (value === null || typeof value !== 'object') return 0;
const page = (value as Record<string, unknown>).page;
if (page === null || typeof page !== 'object') return 0;
const count = (page as Record<string, unknown>).total;
return typeof count === 'number' && Number.isSafeInteger(count) && count >= 0 ? count : 0;
}
function affected(value: unknown): number {
if (value === null || typeof value !== 'object') return 0;
const count = (value as Record<string, unknown>).affected;
return typeof count === 'number' && Number.isSafeInteger(count) && count >= 0 ? count : 0;
}
const jsonHeaders = { 'content-type': 'application/json' };
export function createNotificationCenterApiDataSource(
fetcher: typeof fetch = fetch,
): NotificationCenterDataSource {
return {
async loadSnapshot(signal?: AbortSignal): Promise<NotificationCenterSnapshot> {
const payload = await requestJson(fetcher, `${BASE}/overview`, { method: 'GET', signal });
const snapshot = sanitizeNotificationCenterSnapshot(payload);
if (!snapshot) throw new Error('通知总览响应无效。');
return snapshot;
},
async listChannels(signal?: AbortSignal): Promise<readonly NotificationChannel[]> {
const payload = await requestJson(fetcher, `${BASE}/channels`, { method: 'GET', signal });
return items(payload)
.map(sanitizeNotificationChannel)
.filter((item): item is NotificationChannel => item !== undefined);
},
async saveChannel(draft: NotificationChannelDraft): Promise<NotificationChannel> {
const body = JSON.stringify({
name: draft.name,
type: draft.type,
enabled: draft.enabled,
config: draft.config,
});
const payload = draft.id
? await requestJson(fetcher, `${BASE}/channels/${encodeURIComponent(draft.id)}`, {
method: 'PUT',
body,
headers: jsonHeaders,
})
: await requestJson(fetcher, `${BASE}/channels`, {
method: 'POST',
body,
headers: jsonHeaders,
});
const channel = sanitizeNotificationChannel(payload);
if (!channel) throw new Error('通道响应无效。');
return channel;
},
async deleteChannel(id: string): Promise<void> {
await requestJson(fetcher, `${BASE}/channels/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
},
async testChannel(id: string): Promise<{ readonly ok: boolean; readonly detail?: string }> {
const payload = await requestJson(
fetcher,
`${BASE}/channels/${encodeURIComponent(id)}/test`,
{
method: 'POST',
body: '{}',
headers: jsonHeaders,
},
);
const source =
payload !== null && typeof payload === 'object' ? (payload as Record<string, unknown>) : {};
const detail = typeof source.detail === 'string' ? source.detail.slice(0, 500) : '';
return Object.freeze({ ok: source.ok === true, ...(detail ? { detail } : {}) });
},
async listRules(signal?: AbortSignal): Promise<readonly NotificationRule[]> {
const payload = await requestJson(fetcher, `${BASE}/rules?pageSize=100`, {
method: 'GET',
signal,
});
return items(payload)
.map(sanitizeNotificationRule)
.filter((item): item is NotificationRule => item !== undefined);
},
async saveRule(draft: NotificationRuleDraft): Promise<NotificationRule> {
const body = JSON.stringify({
name: draft.name,
eventType: draft.eventType,
enabled: draft.enabled,
condition: {
field: draft.condition.field,
mode: draft.condition.mode,
...(draft.condition.mode === 'all' ? {} : { value: draft.condition.value }),
},
scope:
draft.scope.mode === 'tags'
? { mode: 'tags', tags: [...draft.scope.tags], match: draft.scope.match }
: draft.scope.mode === 'devices'
? { mode: 'devices', instanceIds: [...draft.scope.instanceIds] }
: { mode: 'all' },
channelIds: [...draft.channelIds],
templates: { title: draft.templates.title, body: draft.templates.body },
...(draft.rateLimit === undefined ? {} : { rateLimit: draft.rateLimit }),
...(draft.quietHours === undefined ? {} : { quietHours: [...draft.quietHours] }),
});
const payload = draft.id
? await requestJson(fetcher, `${BASE}/rules/${encodeURIComponent(draft.id)}`, {
method: 'PATCH',
body,
headers: jsonHeaders,
})
: await requestJson(fetcher, `${BASE}/rules`, {
method: 'POST',
body,
headers: jsonHeaders,
});
const rule = sanitizeNotificationRule(payload);
if (!rule) throw new Error('规则响应无效。');
return rule;
},
async deleteRule(id: string): Promise<void> {
await requestJson(fetcher, `${BASE}/rules/${encodeURIComponent(id)}`, { method: 'DELETE' });
},
async listLogs(
page: number,
filter?: NotificationLogFilter,
signal?: AbortSignal,
): Promise<NotificationPage<NotificationLogEntry>> {
const payload = await requestJson(
fetcher,
`${BASE}/logs?page=${page}&pageSize=${NOTIFICATION_PAGE_SIZE}${logFilterQuery(filter)}`,
{ method: 'GET', signal },
);
return Object.freeze({
items: Object.freeze(
items(payload)
.map(sanitizeNotificationLogEntry)
.filter((item): item is NotificationLogEntry => item !== undefined),
),
total: total(payload),
});
},
async clearLogs(filter?: NotificationLogFilter): Promise<number> {
return affected(
await requestJson(fetcher, `${BASE}/logs/clear`, {
method: 'POST',
body: JSON.stringify(filter ?? {}),
headers: jsonHeaders,
}),
);
},
async getLogCleanup(signal?: AbortSignal): Promise<NotificationLogCleanup> {
const payload = await requestJson(fetcher, `${BASE}/logs/cleanup-settings`, {
method: 'GET',
signal,
});
return sanitizeLogCleanup(payload);
},
async saveLogCleanup(cleanup: NotificationLogCleanup): Promise<NotificationLogCleanup> {
const payload = await requestJson(fetcher, `${BASE}/logs/cleanup-settings`, {
method: 'PUT',
body: JSON.stringify({
retentionDaysEnabled: cleanup.retentionDaysEnabled,
retentionDays: cleanup.retentionDays,
maxEntriesEnabled: cleanup.maxEntriesEnabled,
maxEntries: cleanup.maxEntries,
}),
headers: jsonHeaders,
});
return sanitizeLogCleanup(payload);
},
async pruneLogs(): Promise<number> {
return affected(
await requestJson(fetcher, `${BASE}/logs/prune`, {
method: 'POST',
body: '{}',
headers: jsonHeaders,
}),
);
},
async listQueue(
page: number,
status?: string,
signal?: AbortSignal,
): Promise<NotificationPage<NotificationQueueEntry>> {
const query = status ? `&status=${encodeURIComponent(status)}` : '';
const payload = await requestJson(
fetcher,
`${BASE}/queue?page=${page}&pageSize=${NOTIFICATION_PAGE_SIZE}${query}`,
{ method: 'GET', signal },
);
return Object.freeze({
items: Object.freeze(
items(payload)
.map(sanitizeNotificationQueueEntry)
.filter((item): item is NotificationQueueEntry => item !== undefined),
),
total: total(payload),
});
},
async retryQueueItem(id: string): Promise<void> {
await requestJson(fetcher, `${BASE}/queue/${encodeURIComponent(id)}/retry`, {
method: 'POST',
});
},
async deleteQueueItem(id: string): Promise<void> {
await requestJson(fetcher, `${BASE}/queue/${encodeURIComponent(id)}`, { method: 'DELETE' });
},
async clearQueue(status?: string): Promise<number> {
const query = status ? `?status=${encodeURIComponent(status)}` : '';
return affected(await requestJson(fetcher, `${BASE}/queue${query}`, { method: 'DELETE' }));
},
async processQueue(): Promise<{
readonly processed: number;
readonly succeeded: number;
readonly failed: number;
}> {
const payload = await requestJson(fetcher, `${BASE}/queue/process`, { method: 'POST' });
const source =
payload !== null && typeof payload === 'object' ? (payload as Record<string, unknown>) : {};
const number = (value: unknown): number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : 0;
return Object.freeze({
processed: number(source.processed),
succeeded: number(source.succeeded),
failed: number(source.failed),
});
},
};
}