c3feb7477a
Replace serial probe-on-lock with a bounded probe queue (concurrency 3) that adds a "queued" probe state, manual cooldown, and retry backoff. After each probe the runtime debounces a batched POST to the new /desktop/accounts/health-reports endpoint so the server learns about live, expired, and risk transitions without waiting for a re-bind. Adds an account-scoped IPC (probeRuntimeAccount + runtimeAccountSnapshot) so the renderer can refresh a single row instead of replaying the full snapshot, and exposes the resulting "重新校验" action in AccountsView/AiPlatformsView. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
227 lines
8.4 KiB
TypeScript
227 lines
8.4 KiB
TypeScript
import { app } from "electron/main";
|
|
|
|
import { getProjectedAccountHealth } from "./account-health";
|
|
import { getProcessMetricsSnapshot } from "./process-metrics";
|
|
import { getCircuitBreakerState } from "./circuit-breaker";
|
|
import { collectRecoverySnapshot } from "./crash-recovery";
|
|
import { getKeepAlivePlan } from "./keep-alive";
|
|
import { getLeaseManagerSnapshot } from "./lease-manager";
|
|
import { getMonitorSchedulerSnapshot } from "./monitor-scheduler";
|
|
import { getPublishSchedulerSnapshot } from "./publish-scheduler";
|
|
import { getObservedRequestSnapshot } from "./network-observer";
|
|
import { getHiddenPlaywrightSnapshot } from "./playwright-cdp";
|
|
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 { getHotViewPoolPolicy, listHotViews } from "./view-pool";
|
|
import { normalizeAccountPlatformUid } from "../shared/account-identity";
|
|
|
|
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;
|
|
}
|
|
|
|
type RuntimeControllerSnapshot = ReturnType<typeof getRuntimeControllerSnapshot>;
|
|
type RuntimeControllerAccount = RuntimeControllerSnapshot["accounts"][number];
|
|
|
|
function createRuntimeAccountView(
|
|
controller: RuntimeControllerSnapshot,
|
|
account: RuntimeControllerAccount,
|
|
context: {
|
|
hotViews: ReturnType<typeof listHotViews>;
|
|
primaryClientId: string;
|
|
clientOnline: boolean;
|
|
lastAccountsSyncAt: number;
|
|
now: number;
|
|
},
|
|
) {
|
|
const sessionHandle = getSessionHandle(account.id);
|
|
const isHot = context.hotViews.some((item) => item.accountId === account.id);
|
|
const accountQueueDepth = controller.tasks.filter((task) =>
|
|
task.accountId === account.id && ["queued", "in_progress"].includes(task.status),
|
|
).length;
|
|
const projected = getProjectedAccountHealth({
|
|
accountId: account.id,
|
|
platform: account.platform,
|
|
health: account.health,
|
|
verifiedAt: account.verified_at,
|
|
});
|
|
|
|
return {
|
|
id: account.id,
|
|
platform: account.platform,
|
|
displayName: projected.profile?.displayName ?? account.display_name,
|
|
platformUid: normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid,
|
|
avatarUrl: projected.profile?.avatarUrl ?? account.avatar_url ?? null,
|
|
health: projected.health,
|
|
authState: projected.authState,
|
|
probeState: projected.probeState,
|
|
authReason: projected.authReason,
|
|
tags: account.tags,
|
|
syncVersion: account.sync_version,
|
|
clientId: account.client_id ?? context.primaryClientId,
|
|
partition: projected.partition || sessionHandle?.partition || `persist:acc-${account.id}`,
|
|
sessionState: isHot ? "hot" : sessionHandle ? "warm" : "cold",
|
|
lastSyncAt:
|
|
context.lastAccountsSyncAt
|
|
|| parseTimestamp(account.verified_at)
|
|
|| context.now,
|
|
lastVerifiedAt: projected.lastVerifiedAt,
|
|
lastProbeAt: projected.lastProbeAt,
|
|
nextProbeAt: projected.nextProbeAt,
|
|
queueDepth: accountQueueDepth,
|
|
online: context.clientOnline,
|
|
};
|
|
}
|
|
|
|
export function createRuntimeSnapshot() {
|
|
return createLiveRuntimeSnapshot(getRuntimeControllerSnapshot());
|
|
}
|
|
|
|
export function createRuntimeAccountSnapshot(accountId: string) {
|
|
const controller = getRuntimeControllerSnapshot();
|
|
const now = Date.now();
|
|
const primaryClientId = controller.client?.id ?? controller.session?.desktop_client?.id ?? "desktop-self";
|
|
const clientOnline = controller.running && controller.lastHeartbeatStatus !== "failed";
|
|
const account = controller.accounts.find((item) => item.id === accountId);
|
|
if (!account) {
|
|
return null;
|
|
}
|
|
|
|
return createRuntimeAccountView(controller, account, {
|
|
hotViews: listHotViews(),
|
|
primaryClientId,
|
|
clientOnline,
|
|
lastAccountsSyncAt: controller.lastAccountsSyncAt,
|
|
now,
|
|
});
|
|
}
|
|
|
|
function createLiveRuntimeSnapshot(controller: RuntimeControllerSnapshot) {
|
|
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"].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) =>
|
|
createRuntimeAccountView(controller, account, {
|
|
hotViews,
|
|
primaryClientId,
|
|
clientOnline,
|
|
lastAccountsSyncAt: controller.lastAccountsSyncAt,
|
|
now,
|
|
}),
|
|
);
|
|
|
|
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;
|
|
}, {});
|
|
const blockingAccountCount = accounts.filter((item) =>
|
|
["expired", "revoked", "challenge_required"].includes(item.authState),
|
|
).length;
|
|
|
|
return {
|
|
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.dispatchConnected ? "websocket-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) => ["unknown", "failed"].includes(item.status)).length
|
|
+ blockingAccountCount,
|
|
healthCounts,
|
|
},
|
|
clients,
|
|
accounts,
|
|
tasks,
|
|
activity: controller.activity,
|
|
subsystems: {
|
|
transport,
|
|
circuitBreaker: getCircuitBreakerState(),
|
|
keepAlive: getKeepAlivePlan(),
|
|
leaseManager,
|
|
rateLimiter,
|
|
recovery: collectRecoverySnapshot(),
|
|
runtimeController: {
|
|
running: controller.running,
|
|
dispatchConnected: controller.dispatchConnected,
|
|
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,
|
|
publishScheduler: getPublishSchedulerSnapshot(),
|
|
monitorScheduler: getMonitorSchedulerSnapshot(),
|
|
sessions,
|
|
hotViews,
|
|
hotViewPool: getHotViewPoolPolicy(),
|
|
networkDebug: getObservedRequestSnapshot(),
|
|
playwright: getHiddenPlaywrightSnapshot(),
|
|
processMetrics: getProcessMetricsSnapshot(),
|
|
vault: describeVaultBackend(),
|
|
},
|
|
};
|
|
}
|