fix: map nested device telemetry in console

This commit is contained in:
Manus AI
2026-08-10 05:39:59 +08:00
parent 552d0a08a9
commit e6c351efc2
4 changed files with 355 additions and 8 deletions

View File

@@ -0,0 +1,323 @@
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 };

View File

@@ -6,7 +6,7 @@
<meta name="theme-color" content="#f4f7ff" />
<meta name="color-scheme" content="light" />
<title>工作手机总控平台</title>
<script type="module" crossorigin src="/static/console/assets/index-BzwMJihg.js"></script>
<script type="module" crossorigin src="/static/console/assets/index-_B4qz-iN.js"></script>
<link rel="stylesheet" crossorigin href="/static/console/assets/style-Dxgu53Kz.css">
</head>
<body>

View File

@@ -0,0 +1,24 @@
# NAS 设备详情字段映射修复
- 日期2026-08-10
- 入口:`http://open.quwanzhi.com:8899/?tab=devices`
- 设备:工作手机 AndroidWSS Agent 在线)
## 已修复
控制台设备详情此前只读取顶层字段,而 NAS 设备上报将注册档案放在 `device_profile`、实时心跳放在 `quick_status`。现已合并读取两层数据,详情页可显示:
- Android 版本、型号、厂商、Agent 版本
- 微信运行状态与微信版本
- 电量、网络、屏幕/前台应用等实时状态
- 设备能力与心跳时间
## 微信号与好友数
这两个字段必须由微信 Frida Hook 只读回读。真机回执显示 Frida 已注入,当前处于短暂附着冷却,采集接口返回 `attach_in_progress`控制台保持“未获取”不会用历史或模拟数据填充。Hook 完成附着后,资料与联系人计数会通过同一 WSS 链路回传。
## 验收
- 新控制台静态资源:`/static/console/assets/index-_B4qz-iN.js`
- NAS 设备列表接口HTTP 200
- NAS SDK健康探针通过后可用