433 lines
14 KiB
TypeScript
433 lines
14 KiB
TypeScript
|
|
import { app } from "electron/main";
|
|||
|
|
|
|||
|
|
import { getCircuitBreakerState } from "./circuit-breaker";
|
|||
|
|
import { collectRecoverySnapshot } from "./crash-recovery";
|
|||
|
|
import { getKeepAlivePlan } from "./keep-alive";
|
|||
|
|
import { getLeaseManagerSnapshot } from "./lease-manager";
|
|||
|
|
import { getRateLimiterSnapshot } from "./rate-limiter";
|
|||
|
|
import { getRuntimeControllerSnapshot } from "./runtime-controller";
|
|||
|
|
import { getSchedulerState } from "./scheduler";
|
|||
|
|
import { getSessionHandle, listSessionHandleSnapshots } from "./session-registry";
|
|||
|
|
import { getTransportSnapshot } from "./transport/api-client";
|
|||
|
|
import { describeVaultBackend } from "./vault";
|
|||
|
|
import { listHotViews } from "./view-pool";
|
|||
|
|
|
|||
|
|
function minutesAgo(now: number, minutes: number): number {
|
|||
|
|
return now - minutes * 60_000;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function minutesAhead(now: number, minutes: number): number {
|
|||
|
|
return now + minutes * 60_000;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function parseTimestamp(value: string | null | undefined): number | null {
|
|||
|
|
if (!value) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const timestamp = Date.parse(value);
|
|||
|
|
return Number.isNaN(timestamp) ? null : timestamp;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function createRuntimeSnapshot() {
|
|||
|
|
const controller = getRuntimeControllerSnapshot();
|
|||
|
|
if (!controller.session || controller.session.mode === "preview") {
|
|||
|
|
return createPreviewRuntimeSnapshot();
|
|||
|
|
}
|
|||
|
|
return createLiveRuntimeSnapshot(controller);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function createLiveRuntimeSnapshot(controller: ReturnType<typeof getRuntimeControllerSnapshot>) {
|
|||
|
|
const now = Date.now();
|
|||
|
|
// Electron Session instances are not safe to ship across IPC to the renderer.
|
|||
|
|
const sessions = listSessionHandleSnapshots();
|
|||
|
|
const hotViews = listHotViews();
|
|||
|
|
const leaseManager = getLeaseManagerSnapshot();
|
|||
|
|
const rateLimiter = getRateLimiterSnapshot();
|
|||
|
|
const scheduler = getSchedulerState();
|
|||
|
|
const transport = getTransportSnapshot();
|
|||
|
|
|
|||
|
|
const primaryClientId = controller.client?.id ?? controller.session?.desktop_client?.id ?? "desktop-self";
|
|||
|
|
const lastSeenAt =
|
|||
|
|
controller.lastHeartbeatAt
|
|||
|
|
|| parseTimestamp(controller.client?.last_seen_at)
|
|||
|
|
|| now;
|
|||
|
|
const clientOnline = controller.running && controller.lastHeartbeatStatus !== "failed";
|
|||
|
|
const queueDepth = controller.tasks.filter((task) =>
|
|||
|
|
["queued", "in_progress", "waiting_user"].includes(task.status),
|
|||
|
|
).length;
|
|||
|
|
|
|||
|
|
const clients = [
|
|||
|
|
{
|
|||
|
|
id: primaryClientId,
|
|||
|
|
label: "This device",
|
|||
|
|
deviceName: controller.client?.device_name ?? controller.session?.desktop_client?.device_name ?? "Current Desktop",
|
|||
|
|
os: controller.client?.os ?? process.platform,
|
|||
|
|
status: clientOnline ? "online" : "offline",
|
|||
|
|
lastSeenAt,
|
|||
|
|
queueDepth,
|
|||
|
|
heartbeat: clientOnline ? "healthy" : "stale",
|
|||
|
|
channel: controller.client?.channel ?? "dev",
|
|||
|
|
},
|
|||
|
|
] as const;
|
|||
|
|
|
|||
|
|
const accounts = controller.accounts.map((account) => {
|
|||
|
|
const profile = controller.accountProfiles[account.id] ?? null;
|
|||
|
|
const sessionHandle = getSessionHandle(account.id);
|
|||
|
|
const isHot = hotViews.some((item) => item.accountId === account.id);
|
|||
|
|
const accountQueueDepth = controller.tasks.filter((task) =>
|
|||
|
|
task.accountId === account.id && ["queued", "in_progress", "waiting_user"].includes(task.status),
|
|||
|
|
).length;
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
id: account.id,
|
|||
|
|
platform: account.platform,
|
|||
|
|
displayName: profile?.displayName ?? account.display_name,
|
|||
|
|
platformUid: profile?.platformUid ?? account.platform_uid,
|
|||
|
|
avatarUrl: profile?.avatarUrl ?? null,
|
|||
|
|
health: account.health,
|
|||
|
|
tags: account.tags,
|
|||
|
|
clientId: account.client_id ?? primaryClientId,
|
|||
|
|
partition: sessionHandle?.partition ?? `persist:acc-${account.id}`,
|
|||
|
|
sessionState: isHot ? "hot" : sessionHandle ? "warm" : "cold",
|
|||
|
|
lastSyncAt:
|
|||
|
|
controller.lastAccountsSyncAt
|
|||
|
|
|| parseTimestamp(account.verified_at)
|
|||
|
|
|| now,
|
|||
|
|
queueDepth: accountQueueDepth,
|
|||
|
|
online: clientOnline && account.client_id === primaryClientId,
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const tasks = controller.tasks.map((task) => ({ ...task }));
|
|||
|
|
|
|||
|
|
const healthCounts = accounts.reduce<Record<string, number>>((acc, item) => {
|
|||
|
|
acc[item.health] = (acc[item.health] ?? 0) + 1;
|
|||
|
|
return acc;
|
|||
|
|
}, {});
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
previewMode: false,
|
|||
|
|
generatedAt: now,
|
|||
|
|
app: {
|
|||
|
|
name: "GEO Rankly Desktop",
|
|||
|
|
version: app.getVersion(),
|
|||
|
|
channel: process.env.NODE_ENV === "development" ? "dev" : "release",
|
|||
|
|
platform: process.platform,
|
|||
|
|
sessionCount: sessions.length,
|
|||
|
|
hotViewCount: hotViews.length,
|
|||
|
|
},
|
|||
|
|
workspace: {
|
|||
|
|
id: `ws-${controller.client?.workspace_id ?? controller.session?.desktop_client?.workspace_id ?? "current"}`,
|
|||
|
|
name: `Workspace #${controller.client?.workspace_id ?? controller.session?.desktop_client?.workspace_id ?? "current"}`,
|
|||
|
|
strategy: controller.sseConnected ? "rabbitmq-first / db-fallback" : "db-fallback / heartbeat recovery",
|
|||
|
|
nextSweepAt: scheduler.nextPullAt ?? minutesAhead(now, 1),
|
|||
|
|
lastFullSyncAt: controller.lastAccountsSyncAt || now,
|
|||
|
|
},
|
|||
|
|
summary: {
|
|||
|
|
onlineClients: clients.filter((item) => item.status === "online").length,
|
|||
|
|
accountsBound: accounts.length,
|
|||
|
|
queuedTasks: tasks.filter((item) => item.status === "queued").length,
|
|||
|
|
issuesOpen:
|
|||
|
|
tasks.filter((item) => ["waiting_user", "unknown", "failed"].includes(item.status)).length
|
|||
|
|
+ accounts.filter((item) => item.health !== "live").length,
|
|||
|
|
healthCounts,
|
|||
|
|
},
|
|||
|
|
clients,
|
|||
|
|
accounts,
|
|||
|
|
tasks,
|
|||
|
|
activity: controller.activity,
|
|||
|
|
subsystems: {
|
|||
|
|
transport,
|
|||
|
|
circuitBreaker: getCircuitBreakerState(),
|
|||
|
|
keepAlive: getKeepAlivePlan(),
|
|||
|
|
leaseManager,
|
|||
|
|
rateLimiter,
|
|||
|
|
recovery: collectRecoverySnapshot(),
|
|||
|
|
runtimeController: {
|
|||
|
|
running: controller.running,
|
|||
|
|
sseConnected: controller.sseConnected,
|
|||
|
|
queueDepth: controller.queueDepth,
|
|||
|
|
lastHeartbeatAt: controller.lastHeartbeatAt,
|
|||
|
|
lastHeartbeatStatus: controller.lastHeartbeatStatus,
|
|||
|
|
lastPullAt: controller.lastPullAt,
|
|||
|
|
lastPullStatus: controller.lastPullStatus,
|
|||
|
|
lastAccountsSyncAt: controller.lastAccountsSyncAt,
|
|||
|
|
lastServerTime: controller.lastServerTime,
|
|||
|
|
lastError: controller.lastError,
|
|||
|
|
},
|
|||
|
|
scheduler,
|
|||
|
|
sessions,
|
|||
|
|
hotViews,
|
|||
|
|
vault: describeVaultBackend(),
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function createPreviewRuntimeSnapshot() {
|
|||
|
|
const now = Date.now();
|
|||
|
|
const sessions = listSessionHandleSnapshots();
|
|||
|
|
const hotViews = listHotViews();
|
|||
|
|
const leaseManager = getLeaseManagerSnapshot();
|
|||
|
|
const rateLimiter = getRateLimiterSnapshot();
|
|||
|
|
|
|||
|
|
const clients = [
|
|||
|
|
{
|
|||
|
|
id: "cli-self-macbook",
|
|||
|
|
label: "This device",
|
|||
|
|
deviceName: "LiangXu MacBook Pro",
|
|||
|
|
os: process.platform,
|
|||
|
|
status: "online",
|
|||
|
|
lastSeenAt: minutesAgo(now, 1),
|
|||
|
|
queueDepth: 3,
|
|||
|
|
heartbeat: "healthy",
|
|||
|
|
channel: "stable",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: "cli-studio-macmini",
|
|||
|
|
label: "Remote node",
|
|||
|
|
deviceName: "Studio Mac mini",
|
|||
|
|
os: "darwin",
|
|||
|
|
status: "offline",
|
|||
|
|
lastSeenAt: minutesAgo(now, 38),
|
|||
|
|
queueDepth: 1,
|
|||
|
|
heartbeat: "stale",
|
|||
|
|
channel: "stable",
|
|||
|
|
},
|
|||
|
|
] as const;
|
|||
|
|
|
|||
|
|
const accounts = [
|
|||
|
|
{
|
|||
|
|
id: "acc-doubao-core",
|
|||
|
|
platform: "doubao",
|
|||
|
|
displayName: "Doubao Monitor Core",
|
|||
|
|
platformUid: "db-core-01",
|
|||
|
|
avatarUrl: null,
|
|||
|
|
health: "live",
|
|||
|
|
tags: ["AI监控", "核心"],
|
|||
|
|
clientId: clients[0].id,
|
|||
|
|
partition: "persist:acc-doubao-core",
|
|||
|
|
sessionState: hotViews.length > 0 ? "hot" : "warm",
|
|||
|
|
lastSyncAt: minutesAgo(now, 3),
|
|||
|
|
queueDepth: 1,
|
|||
|
|
online: true,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: "acc-toutiao-brand",
|
|||
|
|
platform: "toutiaohao",
|
|||
|
|
displayName: "Toutiao Brand Primary",
|
|||
|
|
platformUid: "tt-brand-01",
|
|||
|
|
avatarUrl: null,
|
|||
|
|
health: "live",
|
|||
|
|
tags: ["发布", "品牌"],
|
|||
|
|
clientId: clients[0].id,
|
|||
|
|
partition: "persist:acc-toutiao-brand",
|
|||
|
|
sessionState: "warm",
|
|||
|
|
lastSyncAt: minutesAgo(now, 8),
|
|||
|
|
queueDepth: 2,
|
|||
|
|
online: true,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: "acc-toutiao-matrix",
|
|||
|
|
platform: "toutiaohao",
|
|||
|
|
displayName: "Toutiao Matrix B",
|
|||
|
|
platformUid: "tt-matrix-b",
|
|||
|
|
avatarUrl: null,
|
|||
|
|
health: "captcha",
|
|||
|
|
tags: ["发布", "待修复"],
|
|||
|
|
clientId: clients[0].id,
|
|||
|
|
partition: "persist:acc-toutiao-matrix",
|
|||
|
|
sessionState: "cold",
|
|||
|
|
lastSyncAt: minutesAgo(now, 42),
|
|||
|
|
queueDepth: 1,
|
|||
|
|
online: true,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: "acc-qwen-research",
|
|||
|
|
platform: "qwen",
|
|||
|
|
displayName: "Qwen Research Backup",
|
|||
|
|
platformUid: "qwen-research-02",
|
|||
|
|
avatarUrl: null,
|
|||
|
|
health: "risk",
|
|||
|
|
tags: ["AI监控", "备用"],
|
|||
|
|
clientId: clients[1].id,
|
|||
|
|
partition: "persist:acc-qwen-research",
|
|||
|
|
sessionState: "cold",
|
|||
|
|
lastSyncAt: minutesAgo(now, 96),
|
|||
|
|
queueDepth: 1,
|
|||
|
|
online: false,
|
|||
|
|
},
|
|||
|
|
] as const;
|
|||
|
|
|
|||
|
|
const tasks = [
|
|||
|
|
{
|
|||
|
|
id: "task-monitor-doubao-001",
|
|||
|
|
jobId: "job-monitor-daily-001",
|
|||
|
|
title: "品牌提及采样 · Doubao",
|
|||
|
|
kind: "monitor",
|
|||
|
|
platform: "doubao",
|
|||
|
|
accountId: accounts[0].id,
|
|||
|
|
accountName: accounts[0].displayName,
|
|||
|
|
clientId: accounts[0].clientId,
|
|||
|
|
status: "in_progress",
|
|||
|
|
mode: "auto",
|
|||
|
|
routing: "rabbitmq-primary",
|
|||
|
|
leaseExpiresAt: leaseManager.startedAt ? minutesAhead(leaseManager.startedAt, 10) : minutesAhead(now, 8),
|
|||
|
|
updatedAt: minutesAgo(now, 2),
|
|||
|
|
summary: "当前由本机执行,租约续期正常。",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: "task-publish-toutiao-021",
|
|||
|
|
jobId: "job-launch-q2-021",
|
|||
|
|
title: "新品发布 · 头条号主号",
|
|||
|
|
kind: "publish",
|
|||
|
|
platform: "toutiaohao",
|
|||
|
|
accountId: accounts[1].id,
|
|||
|
|
accountName: accounts[1].displayName,
|
|||
|
|
clientId: accounts[1].clientId,
|
|||
|
|
status: "waiting_user",
|
|||
|
|
mode: "manual",
|
|||
|
|
routing: "rabbitmq-primary",
|
|||
|
|
leaseExpiresAt: null,
|
|||
|
|
updatedAt: minutesAgo(now, 6),
|
|||
|
|
summary: "审核页已就绪,等待桌面端人工确认。",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: "task-publish-toutiao-022",
|
|||
|
|
jobId: "job-launch-q2-021",
|
|||
|
|
title: "新品发布 · 矩阵 B",
|
|||
|
|
kind: "publish",
|
|||
|
|
platform: "toutiaohao",
|
|||
|
|
accountId: accounts[2].id,
|
|||
|
|
accountName: accounts[2].displayName,
|
|||
|
|
clientId: accounts[2].clientId,
|
|||
|
|
status: "queued",
|
|||
|
|
mode: "auto",
|
|||
|
|
routing: "rabbitmq-primary",
|
|||
|
|
leaseExpiresAt: null,
|
|||
|
|
updatedAt: minutesAgo(now, 1),
|
|||
|
|
summary: "已入 MQ 队列,等待目标客户端拉取租约。",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: "task-monitor-qwen-003",
|
|||
|
|
jobId: "job-monitor-daily-003",
|
|||
|
|
title: "竞品问答补采样 · Qwen",
|
|||
|
|
kind: "monitor",
|
|||
|
|
platform: "qwen",
|
|||
|
|
accountId: accounts[3].id,
|
|||
|
|
accountName: accounts[3].displayName,
|
|||
|
|
clientId: accounts[3].clientId,
|
|||
|
|
status: "unknown",
|
|||
|
|
mode: "auto",
|
|||
|
|
routing: "db-recovery",
|
|||
|
|
leaseExpiresAt: null,
|
|||
|
|
updatedAt: minutesAgo(now, 49),
|
|||
|
|
summary: "原客户端离线,已切到 DB 补偿队列等待 reconcile。",
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: "task-publish-archive-007",
|
|||
|
|
jobId: "job-content-rollout-007",
|
|||
|
|
title: "专题文章回填 · 归档批次",
|
|||
|
|
kind: "publish",
|
|||
|
|
platform: "toutiaohao",
|
|||
|
|
accountId: accounts[1].id,
|
|||
|
|
accountName: accounts[1].displayName,
|
|||
|
|
clientId: accounts[1].clientId,
|
|||
|
|
status: "succeeded",
|
|||
|
|
mode: "auto",
|
|||
|
|
routing: "rabbitmq-primary",
|
|||
|
|
leaseExpiresAt: null,
|
|||
|
|
updatedAt: minutesAgo(now, 34),
|
|||
|
|
summary: "发布完成,回执已写回服务端。",
|
|||
|
|
},
|
|||
|
|
] as const;
|
|||
|
|
|
|||
|
|
const activity = [
|
|||
|
|
{
|
|||
|
|
id: "evt-01",
|
|||
|
|
severity: "info",
|
|||
|
|
title: "MQ fanout ready",
|
|||
|
|
detail: "desktop.task.event 已完成广播初始化,客户端优先走消息唤醒。",
|
|||
|
|
at: minutesAgo(now, 2),
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: "evt-02",
|
|||
|
|
severity: "warn",
|
|||
|
|
title: "Captcha risk detected",
|
|||
|
|
detail: "Toutiao Matrix B 在最近一次 resync 中被标记为 captcha。",
|
|||
|
|
at: minutesAgo(now, 11),
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: "evt-03",
|
|||
|
|
severity: "danger",
|
|||
|
|
title: "Offline recovery armed",
|
|||
|
|
detail: "Qwen Research Backup 目标客户端离线,任务已进入 DB fallback 观察态。",
|
|||
|
|
at: minutesAgo(now, 49),
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: "evt-04",
|
|||
|
|
severity: "success",
|
|||
|
|
title: "Publish receipt committed",
|
|||
|
|
detail: "归档批次回执已完成 result callback,Web 端可见成功 URL。",
|
|||
|
|
at: minutesAgo(now, 34),
|
|||
|
|
},
|
|||
|
|
] as const;
|
|||
|
|
|
|||
|
|
const healthCounts = accounts.reduce<Record<string, number>>((acc, item) => {
|
|||
|
|
acc[item.health] = (acc[item.health] ?? 0) + 1;
|
|||
|
|
return acc;
|
|||
|
|
}, {});
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
previewMode: true,
|
|||
|
|
generatedAt: now,
|
|||
|
|
app: {
|
|||
|
|
name: "GEO Rankly Desktop",
|
|||
|
|
version: app.getVersion(),
|
|||
|
|
channel: process.env.NODE_ENV === "development" ? "dev" : "release",
|
|||
|
|
platform: process.platform,
|
|||
|
|
sessionCount: sessions.length,
|
|||
|
|
hotViewCount: hotViews.length,
|
|||
|
|
},
|
|||
|
|
workspace: {
|
|||
|
|
id: "ws-default",
|
|||
|
|
name: "Default Workspace",
|
|||
|
|
strategy: "rabbitmq-first / db-fallback",
|
|||
|
|
nextSweepAt: minutesAhead(now, 5),
|
|||
|
|
lastFullSyncAt: minutesAgo(now, 4),
|
|||
|
|
},
|
|||
|
|
summary: {
|
|||
|
|
onlineClients: clients.filter((item) => item.status === "online").length,
|
|||
|
|
accountsBound: accounts.length,
|
|||
|
|
queuedTasks: tasks.filter((item) => item.status === "queued").length,
|
|||
|
|
issuesOpen:
|
|||
|
|
tasks.filter((item) => ["waiting_user", "unknown"].includes(item.status)).length
|
|||
|
|
+ accounts.filter((item) => item.health !== "live").length,
|
|||
|
|
healthCounts,
|
|||
|
|
},
|
|||
|
|
clients,
|
|||
|
|
accounts,
|
|||
|
|
tasks,
|
|||
|
|
activity,
|
|||
|
|
subsystems: {
|
|||
|
|
transport: getTransportSnapshot(),
|
|||
|
|
circuitBreaker: getCircuitBreakerState(),
|
|||
|
|
keepAlive: getKeepAlivePlan(),
|
|||
|
|
leaseManager: {
|
|||
|
|
...leaseManager,
|
|||
|
|
activeLeaseId: leaseManager.activeLeaseId ?? tasks[0].id,
|
|||
|
|
},
|
|||
|
|
rateLimiter: {
|
|||
|
|
...rateLimiter,
|
|||
|
|
refreshedAt: rateLimiter.refreshedAt || minutesAgo(now, 7),
|
|||
|
|
bucketCount: rateLimiter.bucketCount || 6,
|
|||
|
|
},
|
|||
|
|
recovery: collectRecoverySnapshot(),
|
|||
|
|
scheduler: getSchedulerState(),
|
|||
|
|
sessions,
|
|||
|
|
hotViews,
|
|||
|
|
vault: describeVaultBackend(),
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
}
|