324 lines
19 KiB
TypeScript
324 lines
19 KiB
TypeScript
import {
|
||
EMPTY_CONSOLE_DATA,
|
||
emptyResource,
|
||
type ApiResource,
|
||
type AuthorizationGrant,
|
||
type AIOperationSummary,
|
||
type ConsoleApiData,
|
||
type ConsoleDevice,
|
||
type ConsoleDeviceGroup,
|
||
type ConsoleOverview,
|
||
type ConsoleRelease,
|
||
type EngineSummary,
|
||
type IntegrationApplication,
|
||
type IntegrationUsage,
|
||
type OverviewMetric,
|
||
type SecurityModule,
|
||
type SecurityModuleState,
|
||
} from "../types/console";
|
||
|
||
type RecordValue = Record<string, unknown>;
|
||
|
||
const isRecord = (value: unknown): value is RecordValue =>
|
||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||
|
||
const text = (value: unknown): string | undefined => {
|
||
if (typeof value === "string" && value.trim()) return value;
|
||
if (typeof value === "number") return String(value);
|
||
return undefined;
|
||
};
|
||
|
||
const boolean = (value: unknown): boolean | undefined => {
|
||
if (typeof value === "boolean") return value;
|
||
if (["true", "1", "online", "connected", "running", "ready"].includes(String(value).toLowerCase())) return true;
|
||
if (["false", "0", "offline", "disconnected", "stopped"].includes(String(value).toLowerCase())) return false;
|
||
return undefined;
|
||
};
|
||
|
||
const number = (value: unknown): number | undefined => {
|
||
if (value === null || value === undefined || value === "") return undefined;
|
||
const parsed = typeof value === "number" ? value : Number(value);
|
||
return Number.isFinite(parsed) ? parsed : undefined;
|
||
};
|
||
|
||
const unwrap = (payload: unknown): unknown => {
|
||
if (!isRecord(payload)) return payload;
|
||
return payload.data ?? payload;
|
||
};
|
||
|
||
const errorMessage = (payload: unknown, fallback: string) => {
|
||
if (isRecord(payload)) {
|
||
const detail = payload.detail;
|
||
if (isRecord(detail)) return text(detail.message) || text(payload.message) || fallback;
|
||
return text(payload.message) || text(detail) || fallback;
|
||
}
|
||
return fallback;
|
||
};
|
||
|
||
const sourceTime = (value: unknown): string | undefined => {
|
||
if (!isRecord(value)) return undefined;
|
||
return text(value.sampled_at) || text(value.sampledAt) || text(value.server_time) || text(value.serverTime) || text(value.updated_at) || text(value.updatedAt);
|
||
};
|
||
|
||
async function readJson(path: string): Promise<{ ok: true; payload: unknown; status: number } | { ok: false; status: number; message: string }> {
|
||
try {
|
||
const response = await fetch(path, { method: "GET", credentials: "same-origin", headers: { Accept: "application/json" } });
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) return { ok: false, status: response.status, message: errorMessage(payload, `读取接口失败(${response.status})`) };
|
||
return { ok: true, payload, status: response.status };
|
||
} catch (error) {
|
||
return { ok: false, status: 0, message: error instanceof Error ? error.message : "读取接口失败" };
|
||
}
|
||
}
|
||
|
||
async function writeJson(path: string, method: "POST" | "PUT" | "DELETE", body?: unknown): Promise<unknown> {
|
||
const response = await fetch(path, { method, credentials: "same-origin", headers: { Accept: "application/json", "Content-Type": "application/json", "X-Actor-Id": "console" }, body: body === undefined ? undefined : JSON.stringify(body) });
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(errorMessage(payload, `操作失败(${response.status})`));
|
||
return unwrap(payload);
|
||
}
|
||
|
||
export async function readSecurityModules(): Promise<SecurityModuleState> {
|
||
const result = await readJson("/api/v3/security/modules");
|
||
if (!result.ok) throw new Error(result.message);
|
||
const value = unwrap(result.payload);
|
||
if (!isRecord(value) || !Array.isArray(value.modules)) throw new Error("安全模块接口返回格式错误");
|
||
return value as unknown as SecurityModuleState;
|
||
}
|
||
|
||
export async function setSecurityGlobal(enabled: boolean): Promise<SecurityModuleState> {
|
||
return await writeJson("/api/v3/security/modules/global", "PUT", { enabled }) as SecurityModuleState;
|
||
}
|
||
|
||
export async function updateSecurityModule(id: string, body: { enabled?: boolean; name?: string; description?: string; config?: Record<string, unknown> }): Promise<SecurityModule> {
|
||
return await writeJson(`/api/v3/security/modules/${encodeURIComponent(id)}`, "PUT", body) as SecurityModule;
|
||
}
|
||
|
||
export async function createSecurityModule(body: { id: string; name: string; description: string; runtime_binding: string; config: Record<string, unknown> }): Promise<SecurityModule> {
|
||
return await writeJson("/api/v3/security/modules", "POST", body) as SecurityModule;
|
||
}
|
||
|
||
export async function removeSecurityModule(id: string): Promise<void> {
|
||
await writeJson(`/api/v3/security/modules/${encodeURIComponent(id)}`, "DELETE");
|
||
}
|
||
|
||
const resource = <T>(source: string, result: Awaited<ReturnType<typeof readJson>>, data?: T, sourceAt?: string): ApiResource<T> => {
|
||
if (result.ok) return { state: "ready", data, source, sourceAt, status: result.status };
|
||
return {
|
||
state: result.status === 401 || result.status === 403 ? "forbidden" : "unavailable",
|
||
source,
|
||
status: result.status,
|
||
error: result.message,
|
||
};
|
||
};
|
||
|
||
async function readFirst<T>(paths: string[], source: string, parse: (payload: unknown) => { data?: T; sourceAt?: string }): Promise<ApiResource<T>> {
|
||
let last: Awaited<ReturnType<typeof readJson>> | undefined;
|
||
for (const path of paths) {
|
||
const result = await readJson(path);
|
||
if (result.ok) {
|
||
const parsed = parse(unwrap(result.payload));
|
||
return resource(`GET ${path}`, result, parsed.data, parsed.sourceAt || sourceTime(unwrap(result.payload)));
|
||
}
|
||
last = result;
|
||
if (result.status !== 404) break;
|
||
}
|
||
return resource(source, last || { ok: false, status: 0, message: "读取接口失败" });
|
||
}
|
||
|
||
const asList = (value: unknown, keys: string[]): unknown[] => {
|
||
if (Array.isArray(value)) return value;
|
||
if (!isRecord(value)) return [];
|
||
for (const key of keys) if (Array.isArray(value[key])) return value[key] as unknown[];
|
||
return [];
|
||
};
|
||
|
||
const stringList = (value: unknown): string[] | undefined => {
|
||
if (!Array.isArray(value)) return undefined;
|
||
const values = value.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
|
||
return values.length ? values : undefined;
|
||
};
|
||
|
||
const normalizeDevice = (item: unknown): ConsoleDevice | undefined => {
|
||
if (!isRecord(item)) return undefined;
|
||
const deviceId = text(item.device_id) || text(item.deviceId) || text(item.id);
|
||
if (!deviceId) return undefined;
|
||
const rawWechat = isRecord(item.wechat) ? item.wechat : {};
|
||
const rawAgent = isRecord(item.agent) ? item.agent : {};
|
||
const rawHook = isRecord(item.hook) ? item.hook : {};
|
||
// NAS 设备目录保留注册档案和心跳快照;控制台必须同时读取这两层真实上报。
|
||
const deviceProfile = isRecord(item.device_profile) ? item.device_profile : {};
|
||
const quickStatus = isRecord(item.quick_status) ? item.quick_status : {};
|
||
const lastStatus = isRecord(item.last_status) ? item.last_status : {};
|
||
const runtime = Object.keys(quickStatus).length ? quickStatus : lastStatus;
|
||
return {
|
||
deviceId,
|
||
name: text(item.name) || text(item.device_name) || text(deviceProfile.name),
|
||
model: text(item.model) || text(item.model_name) || text(deviceProfile.model),
|
||
androidVersion: text(item.android_version) || text(item.androidVersion) || text(item.os_version) || text(deviceProfile.android_version) || text(deviceProfile.os_version),
|
||
projectId: text(item.project_id) || text(item.projectId),
|
||
groupId: text(item.group_id) || text(item.groupId) || text(item.project_id),
|
||
status: text(item.status),
|
||
online: boolean(item.online) ?? boolean(item.ws_online) ?? (text(item.status) === "online" ? true : undefined),
|
||
agentVersion: text(item.agent_version) || text(item.agentVersion) || text(item.app_version) || text(deviceProfile.app_version),
|
||
agentRunning: boolean(item.agent_running) ?? boolean(rawAgent.running) ?? boolean(runtime.agent_running),
|
||
hookAvailable: boolean(item.hook_available) ?? boolean(item.supports_hook) ?? boolean(rawHook.available),
|
||
wechatRunning: boolean(item.wechat_running) ?? boolean(rawWechat.running) ?? boolean(runtime.wechat_running) ?? boolean(isRecord(runtime.wechat) ? runtime.wechat.running : undefined),
|
||
wechatId: text(item.wxid) || text(item.wechat_id) || text(rawWechat.id) || text(rawWechat.wechat_id) || text(runtime.wxid) || text(runtime.wechat_id),
|
||
friendCount: number(item.friend_count) ?? number(rawWechat.friend_count) ?? number(runtime.friend_count),
|
||
wechatVersion: text(item.wechat_version) || text(rawWechat.version) || text(runtime.wechat_version) || text(deviceProfile.wechat_version),
|
||
batteryPercent: number(item.battery_percent) ?? number(item.batteryPercent) ?? number(isRecord(item.battery) ? item.battery.percent : undefined) ?? number(runtime.battery_level) ?? number(runtime.battery),
|
||
networkType: text(item.network_type) || text(item.networkType) || text(isRecord(item.network) ? item.network.type : undefined) || text(runtime.network_type),
|
||
healthScore: number(item.health_score) ?? number(item.healthScore) ?? number(isRecord(item.health) ? item.health.score : undefined) ?? number(runtime.health_score),
|
||
tags: stringList(item.tags) || stringList(item.labels) || stringList(item.tag_names),
|
||
lastHeartbeat: text(item.last_heartbeat) || text(item.lastHeartbeat),
|
||
sourceAt: sourceTime(item) || text(item.last_heartbeat),
|
||
capabilities: Array.isArray(item.capabilities) ? item.capabilities.filter((entry): entry is string => typeof entry === "string") : undefined,
|
||
sourceKind: item.source_kind === "history" || item.sourceKind === "history" ? "history" : item.source_kind === "fixture" || item.sourceKind === "fixture" ? "fixture" : "live",
|
||
sourceLabel: text(item.source_label) || text(item.sourceLabel),
|
||
statusSource: text(item.status_source) || text(item.statusSource),
|
||
raw: item,
|
||
};
|
||
};
|
||
|
||
const parseDevices = (value: unknown) => {
|
||
const rows = asList(value, ["devices", "items"]);
|
||
return rows.flatMap((item) => {
|
||
const normalized = normalizeDevice(item);
|
||
return normalized ? [normalized] : [];
|
||
});
|
||
};
|
||
|
||
const readReadonlyDeviceStateFixture = (): { label: string; devices: ConsoleDevice[] } | undefined => {
|
||
const encoded = new URLSearchParams(window.location.search).get("device_state_fixture");
|
||
if (!encoded) return undefined;
|
||
try {
|
||
const payload = JSON.parse(decodeURIComponent(escape(window.atob(encoded)))) as RecordValue;
|
||
if (payload.mode !== "readonly_fixture" || typeof payload.label !== "string") return undefined;
|
||
const devices = parseDevices(payload.devices).map((device) => ({ ...device, sourceKind: device.sourceKind || "fixture" }));
|
||
return { label: payload.label, devices };
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
};
|
||
|
||
const parseGroups = (value: unknown): ConsoleDeviceGroup[] => asList(value, ["items", "groups"]).flatMap((item) => {
|
||
if (!isRecord(item)) return [];
|
||
const groupId = text(item.group_id) || text(item.groupId) || text(item.id);
|
||
if (!groupId) return [];
|
||
return [{
|
||
groupId,
|
||
name: text(item.name) || groupId,
|
||
memberCount: number(item.member_count) ?? number(item.memberCount),
|
||
deviceIds: Array.isArray(item.device_ids) ? item.device_ids.filter((entry): entry is string => typeof entry === "string") : undefined,
|
||
sourceAt: sourceTime(item),
|
||
}];
|
||
});
|
||
|
||
const parseMetric = (value: unknown): unknown => value;
|
||
|
||
export async function readConsoleData(): Promise<ConsoleApiData> {
|
||
const [overview, devices, groups, release, aiStatus, aiTasks, brain, connection, process, integrationHealth, applications, authorizations, usage, manifest, documents, knowledge] = await Promise.all([
|
||
readFirst<ConsoleOverview>(["/api/v3/workbench/overview"], "GET /api/v3/workbench/overview", (value) => ({ data: isRecord(value) ? value as ConsoleOverview : undefined, sourceAt: sourceTime(value) })),
|
||
readFirst<ConsoleDevice[]>(["/api/v3/fleet/devices", "/api/v3/devices"], "GET /api/v3/fleet/devices", (value) => ({ data: parseDevices(value), sourceAt: sourceTime(value) })),
|
||
readFirst<ConsoleDeviceGroup[]>(["/api/v3/device-groups"], "GET /api/v3/device-groups", (value) => ({ data: parseGroups(value), sourceAt: sourceTime(value) })),
|
||
readFirst<ConsoleRelease>(["/api/v3/releases/latest"], "GET /api/v3/releases/latest", (value) => ({ data: isRecord(value) ? value as ConsoleRelease : undefined, sourceAt: sourceTime(value) || (isRecord(value) ? text(value.published_at) : undefined) })),
|
||
readFirst<RecordValue>(["/api/v3/ai/status"], "GET /api/v3/ai/status", (value) => ({ data: isRecord(value) ? value : undefined, sourceAt: sourceTime(value) })),
|
||
readFirst<RecordValue>(["/api/v3/ai/tasks?limit=20"], "GET /api/v3/ai/tasks", (value) => ({ data: isRecord(value) ? value : undefined, sourceAt: sourceTime(value) })),
|
||
readFirst<RecordValue>(["/api/v3/ai/brain/dashboard"], "GET /api/v3/ai/brain/dashboard", (value) => ({ data: isRecord(value) ? value : undefined, sourceAt: sourceTime(value) })),
|
||
readFirst<RecordValue>(["/api/v3/connection/status"], "GET /api/v3/connection/status", (value) => ({ data: isRecord(value) ? value : undefined, sourceAt: sourceTime(value) })),
|
||
readFirst<RecordValue>(["/api/v3/process/status"], "GET /api/v3/process/status", (value) => ({ data: isRecord(value) ? value : undefined, sourceAt: sourceTime(value) })),
|
||
readFirst<RecordValue>(["/api/v3/integration/health"], "GET /api/v3/integration/health", (value) => ({ data: isRecord(value) ? value : undefined, sourceAt: sourceTime(value) })),
|
||
readFirst<IntegrationApplication[]>(["/api/v3/integrations/apps", "/api/v3/integrations/clients"], "GET /api/v3/integrations/apps", (value) => ({ data: asList(value, ["items", "applications"]) as IntegrationApplication[], sourceAt: sourceTime(value) })),
|
||
readFirst<AuthorizationGrant[]>(["/api/v3/integrations/authorizations", "/api/v3/authorization-grants"], "GET /api/v3/integrations/authorizations", (value) => ({ data: asList(value, ["items", "grants"]) as AuthorizationGrant[], sourceAt: sourceTime(value) })),
|
||
readFirst<IntegrationUsage>(["/api/v3/integrations/usage"], "GET /api/v3/integrations/usage", (value) => ({ data: isRecord(value) ? value as IntegrationUsage : undefined, sourceAt: sourceTime(value) })),
|
||
readFirst<RecordValue>(["/api/v3/integration/manifest"], "GET /api/v3/integration/manifest", (value) => ({ data: isRecord(value) ? value : undefined, sourceAt: sourceTime(value) })),
|
||
readFirst<Array<Record<string, unknown>>>(["/api/v3/integrations/docs/catalog", "/api/v3/docs/catalog"], "GET /api/v3/integrations/docs/catalog", (value) => ({ data: asList(value, ["items", "documents"]) as Array<Record<string, unknown>>, sourceAt: sourceTime(value) })),
|
||
readFirst<Array<Record<string, unknown>>>(["/api/v3/kb/search?limit=10"], "GET /api/v3/kb/search", (value) => ({ data: asList(value, ["items", "results", "documents"]) as Array<Record<string, unknown>>, sourceAt: sourceTime(value) })),
|
||
]);
|
||
|
||
const readonlyFixture = readReadonlyDeviceStateFixture();
|
||
const deviceResource: ApiResource<ConsoleDevice[]> = readonlyFixture && devices.state === "ready"
|
||
? {
|
||
...devices,
|
||
source: `${devices.source} + readonly_fixture:${readonlyFixture.label}`,
|
||
data: [...(devices.data || []), ...readonlyFixture.devices],
|
||
}
|
||
: devices;
|
||
|
||
const hookDeviceId = deviceResource.data?.[0]?.deviceId;
|
||
const hookStatus = hookDeviceId
|
||
? await readFirst<RecordValue>(
|
||
[`/api/v3/wechat/hook/status?device_id=${encodeURIComponent(hookDeviceId)}`],
|
||
"GET /api/v3/wechat/hook/status",
|
||
(value) => ({ data: isRecord(value) ? value : undefined, sourceAt: sourceTime(value) }),
|
||
)
|
||
: emptyResource<RecordValue>("GET /api/v3/wechat/hook/status");
|
||
|
||
const taskItems = aiTasks.state === "ready" ? asList(aiTasks.data, ["items", "tasks"]) as Array<Record<string, unknown>> : [];
|
||
const selectedTaskId = taskItems.length && isRecord(taskItems[0]) ? text(taskItems[0].task_id) : undefined;
|
||
const [taskDetail, taskLogs, taskAudit] = selectedTaskId
|
||
? await Promise.all([
|
||
readFirst<RecordValue>([`/api/v3/ai/tasks/${encodeURIComponent(selectedTaskId)}`], "GET /api/v3/ai/tasks/{task_id}", (value) => ({ data: isRecord(value) ? value : undefined, sourceAt: sourceTime(value) })),
|
||
readFirst<RecordValue>([`/api/v3/ai/tasks/${encodeURIComponent(selectedTaskId)}/logs`], "GET /api/v3/ai/tasks/{task_id}/logs", (value) => ({ data: isRecord(value) ? value : undefined, sourceAt: sourceTime(value) })),
|
||
readFirst<RecordValue>([`/api/v3/ai/tasks/${encodeURIComponent(selectedTaskId)}/audit`], "GET /api/v3/ai/tasks/{task_id}/audit", (value) => ({ data: isRecord(value) ? value : undefined, sourceAt: sourceTime(value) })),
|
||
])
|
||
: [emptyResource<RecordValue>("GET /api/v3/ai/tasks/{task_id}"), emptyResource<RecordValue>("GET /api/v3/ai/tasks/{task_id}/logs"), emptyResource<RecordValue>("GET /api/v3/ai/tasks/{task_id}/audit")];
|
||
const aiOperations: ApiResource<AIOperationSummary> = aiTasks.state === "ready"
|
||
? {
|
||
state: "ready",
|
||
source: "GET /api/v3/ai/tasks + /logs + /audit",
|
||
sourceAt: [aiTasks.sourceAt, taskDetail.sourceAt, taskLogs.sourceAt, taskAudit.sourceAt].filter(Boolean).sort().at(-1),
|
||
data: {
|
||
items: taskItems,
|
||
selected: taskDetail.data,
|
||
logs: asList(taskLogs.data, ["items", "logs"]) as Array<Record<string, unknown>>,
|
||
audit: asList(taskAudit.data, ["items", "audit"]) as Array<Record<string, unknown>>,
|
||
},
|
||
}
|
||
: { state: aiTasks.state, source: aiTasks.source, status: aiTasks.status, error: aiTasks.error };
|
||
|
||
const engineResource: ApiResource<EngineSummary> =
|
||
aiStatus.state === "ready" || brain.state === "ready" || connection.state === "ready" || process.state === "ready" || integrationHealth.state === "ready"
|
||
? {
|
||
state: "ready",
|
||
source: "GET /api/v3/ai/status + /api/v3/ai/brain/dashboard + /api/v3/connection/status + /api/v3/process/status",
|
||
sourceAt: [aiStatus.sourceAt, brain.sourceAt, connection.sourceAt, process.sourceAt, integrationHealth.sourceAt].filter(Boolean).sort().at(-1),
|
||
data: { aiStatus: aiStatus.data, brain: brain.data, connection: connection.data, process: process.data, integrationHealth: integrationHealth.data },
|
||
}
|
||
: { state: aiStatus.state === "forbidden" ? "forbidden" : "unavailable", source: "GET /api/v3/ai/status + /api/v3/ai/brain/dashboard", error: aiStatus.error || brain.error, status: aiStatus.status || brain.status };
|
||
|
||
return {
|
||
overview,
|
||
devices: deviceResource,
|
||
hookStatus,
|
||
groups,
|
||
release,
|
||
engine: engineResource,
|
||
aiOperations,
|
||
integrations: { applications, authorizations, usage, manifest, documents, knowledge },
|
||
readAt: new Date().toISOString(),
|
||
};
|
||
}
|
||
|
||
export const apiMetric = (metric: unknown): OverviewMetric => {
|
||
if (!isRecord(metric)) return {};
|
||
return {
|
||
value: metric.value as string | number | null | undefined,
|
||
display: text(metric.display),
|
||
status: text(metric.status),
|
||
source: text(metric.source),
|
||
sampled_at: text(metric.sampled_at),
|
||
};
|
||
};
|
||
|
||
export const stale = (sourceAt?: string, maxAgeMinutes = 15) => {
|
||
if (!sourceAt) return false;
|
||
const timestamp = Date.parse(sourceAt);
|
||
return Number.isFinite(timestamp) && Date.now() - timestamp > maxAgeMinutes * 60 * 1000;
|
||
};
|
||
|
||
export const readFailureReason = (item: ApiResource<unknown>) => item.error || (item.status === 403 ? "当前账号没有读取权限" : item.status === 401 ? "登录状态已失效" : "接口未接通或暂无数据");
|
||
|
||
export { EMPTY_CONSOLE_DATA };
|