- Surface device identity state, carrier details and upstream link health. - Add fleet-level identity monitoring and restart/baseband context actions. - Apply consistent notification scoping across fleet views.
293 lines
11 KiB
TypeScript
293 lines
11 KiB
TypeScript
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 === 'groups'
|
|
? { mode: 'groups', groups: [...draft.scope.groups] }
|
|
: 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),
|
|
});
|
|
},
|
|
};
|
|
}
|