feat(desktop-client): queue account probes and report health to server
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>
This commit is contained in:
@@ -30,6 +30,12 @@ const CONFIRM_BACKOFF_STEPS_MS = [30_000, 120_000] as const;
|
|||||||
const NETWORK_RETRY_MS = 2 * 60_000;
|
const NETWORK_RETRY_MS = 2 * 60_000;
|
||||||
const STALE_AFTER_MS = 45 * 60_000;
|
const STALE_AFTER_MS = 45 * 60_000;
|
||||||
const PREFLIGHT_MAX_AGE_MS = 15 * 60_000;
|
const PREFLIGHT_MAX_AGE_MS = 15 * 60_000;
|
||||||
|
const MAX_CONCURRENT_ACCOUNT_PROBES = 3;
|
||||||
|
const MANUAL_PROBE_CACHE_TTL_MS = 5 * 60_000;
|
||||||
|
const MANUAL_PROBE_MIN_INTERVAL_MS = 15_000;
|
||||||
|
const ACCOUNT_PROBE_RETRY_BACKOFF_MS = [1_000, 3_000, 10_000] as const;
|
||||||
|
|
||||||
|
type ProbeTrigger = "scheduled" | "manual" | "preflight" | "confirm";
|
||||||
|
|
||||||
interface PersistedAccountHealthRecord {
|
interface PersistedAccountHealthRecord {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
@@ -56,11 +62,27 @@ interface AccountHealthRecord {
|
|||||||
confirmFailureCount: number;
|
confirmFailureCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AccountProbeJob {
|
||||||
|
account: PublishAccountIdentity;
|
||||||
|
trigger: ProbeTrigger;
|
||||||
|
allowSilentRefresh: boolean;
|
||||||
|
force: boolean;
|
||||||
|
status: "queued" | "running";
|
||||||
|
attempt: number;
|
||||||
|
maxAttempts: number;
|
||||||
|
resolve: (snapshot: AccountHealthSnapshot) => void;
|
||||||
|
reject: (error: unknown) => void;
|
||||||
|
promise: Promise<AccountHealthSnapshot>;
|
||||||
|
}
|
||||||
|
|
||||||
const trackedAccounts = new Map<string, PublishAccountIdentity>();
|
const trackedAccounts = new Map<string, PublishAccountIdentity>();
|
||||||
const records = new Map<string, AccountHealthRecord>();
|
const records = new Map<string, AccountHealthRecord>();
|
||||||
const accountLocks = new Map<string, Promise<void>>();
|
const accountLocks = new Map<string, Promise<void>>();
|
||||||
|
const probeJobs = new Map<string, AccountProbeJob>();
|
||||||
|
const probeQueue: string[] = [];
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
let probeTimer: ReturnType<typeof setInterval> | null = null;
|
let probeTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let activeProbeCount = 0;
|
||||||
|
|
||||||
function randomInt(min: number, max: number): number {
|
function randomInt(min: number, max: number): number {
|
||||||
if (max <= min) {
|
if (max <= min) {
|
||||||
@@ -193,7 +215,7 @@ function commitRecord(record: AccountHealthRecord, previousSignature: string | n
|
|||||||
|
|
||||||
const nextSignature = snapshotSignature(record);
|
const nextSignature = snapshotSignature(record);
|
||||||
if (previousSignature !== nextSignature) {
|
if (previousSignature !== nextSignature) {
|
||||||
emitRuntimeInvalidated("account-health");
|
emitRuntimeInvalidated("account-health", { accountId: record.accountId });
|
||||||
}
|
}
|
||||||
|
|
||||||
return toPublicSnapshot(record);
|
return toPublicSnapshot(record);
|
||||||
@@ -342,7 +364,7 @@ function applyNetworkResult(
|
|||||||
async function performProbeLocked(
|
async function performProbeLocked(
|
||||||
account: PublishAccountIdentity,
|
account: PublishAccountIdentity,
|
||||||
options: {
|
options: {
|
||||||
trigger: "scheduled" | "manual" | "preflight" | "confirm";
|
trigger: ProbeTrigger;
|
||||||
allowSilentRefresh: boolean;
|
allowSilentRefresh: boolean;
|
||||||
},
|
},
|
||||||
): Promise<AccountHealthSnapshot> {
|
): Promise<AccountHealthSnapshot> {
|
||||||
@@ -384,6 +406,165 @@ async function performProbeLocked(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canUseCachedProbe(
|
||||||
|
record: AccountHealthRecord,
|
||||||
|
options: { trigger: ProbeTrigger; force: boolean },
|
||||||
|
now = Date.now(),
|
||||||
|
): boolean {
|
||||||
|
if (options.force) {
|
||||||
|
return Boolean(record.lastProbeAt && now - record.lastProbeAt < MANUAL_PROBE_MIN_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
options.trigger === "manual"
|
||||||
|
&& record.lastProbeAt
|
||||||
|
&& now - record.lastProbeAt < MANUAL_PROBE_MIN_INTERVAL_MS
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(options.trigger === "manual" || options.trigger === "scheduled")
|
||||||
|
&& record.authState === "active"
|
||||||
|
&& record.lastVerifiedAt
|
||||||
|
&& now - record.lastVerifiedAt < MANUAL_PROBE_CACHE_TTL_MS
|
||||||
|
&& record.probeState !== "network_error"
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markProbeQueued(record: AccountHealthRecord): AccountHealthSnapshot {
|
||||||
|
const previousSignature = snapshotSignature(record);
|
||||||
|
record.probeState = "queued";
|
||||||
|
return commitRecord(record, previousSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maxAttemptsForTrigger(trigger: ProbeTrigger): number {
|
||||||
|
return trigger === "preflight" ? 1 : ACCOUNT_PROBE_RETRY_BACKOFF_MS.length + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueProbeJob(job: AccountProbeJob): void {
|
||||||
|
if (!probeQueue.includes(job.account.id)) {
|
||||||
|
probeQueue.push(job.account.id);
|
||||||
|
}
|
||||||
|
drainProbeQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleProbeRetry(job: AccountProbeJob): void {
|
||||||
|
const retryIndex = Math.min(Math.max(job.attempt - 2, 0), ACCOUNT_PROBE_RETRY_BACKOFF_MS.length - 1);
|
||||||
|
const backoff = ACCOUNT_PROBE_RETRY_BACKOFF_MS[retryIndex]
|
||||||
|
?? ACCOUNT_PROBE_RETRY_BACKOFF_MS[ACCOUNT_PROBE_RETRY_BACKOFF_MS.length - 1];
|
||||||
|
job.status = "queued";
|
||||||
|
setTimeout(() => enqueueProbeJob(job), backoff);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drainProbeQueue(): void {
|
||||||
|
while (activeProbeCount < MAX_CONCURRENT_ACCOUNT_PROBES && probeQueue.length > 0) {
|
||||||
|
const accountId = probeQueue.shift();
|
||||||
|
if (!accountId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = probeJobs.get(accountId);
|
||||||
|
if (!job || job.status !== "queued") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
job.status = "running";
|
||||||
|
activeProbeCount += 1;
|
||||||
|
void runProbeJob(job);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runProbeJob(job: AccountProbeJob): Promise<void> {
|
||||||
|
try {
|
||||||
|
const snapshot = await withAccountLock(job.account.id, async () => {
|
||||||
|
const record = ensureRecord(job.account);
|
||||||
|
if (canUseCachedProbe(record, job)) {
|
||||||
|
return toPublicSnapshot(record);
|
||||||
|
}
|
||||||
|
return await performProbeLocked(job.account, {
|
||||||
|
trigger: job.trigger,
|
||||||
|
allowSilentRefresh: job.allowSilentRefresh,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (snapshot.probeState === "network_error" && job.attempt < job.maxAttempts) {
|
||||||
|
job.attempt += 1;
|
||||||
|
scheduleProbeRetry(job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
probeJobs.delete(job.account.id);
|
||||||
|
job.resolve(snapshot);
|
||||||
|
} catch (error) {
|
||||||
|
probeJobs.delete(job.account.id);
|
||||||
|
job.reject(error);
|
||||||
|
} finally {
|
||||||
|
activeProbeCount = Math.max(0, activeProbeCount - 1);
|
||||||
|
drainProbeQueue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueAccountProbe(
|
||||||
|
account: PublishAccountIdentity,
|
||||||
|
options: {
|
||||||
|
trigger?: ProbeTrigger;
|
||||||
|
allowSilentRefresh?: boolean;
|
||||||
|
force?: boolean;
|
||||||
|
} = {},
|
||||||
|
): Promise<AccountHealthSnapshot> {
|
||||||
|
syncTrackedAccountsInternal([account], false);
|
||||||
|
|
||||||
|
const trigger = options.trigger ?? "manual";
|
||||||
|
const force = Boolean(options.force);
|
||||||
|
const record = ensureRecord(account);
|
||||||
|
if (canUseCachedProbe(record, { trigger, force })) {
|
||||||
|
return Promise.resolve(toPublicSnapshot(record));
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = probeJobs.get(account.id);
|
||||||
|
if (existing) {
|
||||||
|
if (force) {
|
||||||
|
existing.force = true;
|
||||||
|
}
|
||||||
|
existing.allowSilentRefresh = existing.allowSilentRefresh || Boolean(options.allowSilentRefresh);
|
||||||
|
if (trigger === "manual") {
|
||||||
|
existing.trigger = "manual";
|
||||||
|
}
|
||||||
|
return existing.promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
markProbeQueued(record);
|
||||||
|
|
||||||
|
let resolve!: (snapshot: AccountHealthSnapshot) => void;
|
||||||
|
let reject!: (error: unknown) => void;
|
||||||
|
const promise = new Promise<AccountHealthSnapshot>((resolvePromise, rejectPromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
reject = rejectPromise;
|
||||||
|
});
|
||||||
|
|
||||||
|
const job: AccountProbeJob = {
|
||||||
|
account,
|
||||||
|
trigger,
|
||||||
|
allowSilentRefresh: Boolean(options.allowSilentRefresh),
|
||||||
|
force,
|
||||||
|
status: "queued",
|
||||||
|
attempt: 1,
|
||||||
|
maxAttempts: maxAttemptsForTrigger(trigger),
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
promise,
|
||||||
|
};
|
||||||
|
|
||||||
|
probeJobs.set(account.id, job);
|
||||||
|
enqueueProbeJob(job);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
async function runDueProbes(): Promise<void> {
|
async function runDueProbes(): Promise<void> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const dueAccounts = [...trackedAccounts.values()].filter((account) => {
|
const dueAccounts = [...trackedAccounts.values()].filter((account) => {
|
||||||
@@ -398,10 +579,14 @@ async function runDueProbes(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const trigger = record.suspectedExpiredAt ? "confirm" : "scheduled";
|
const trigger = record.suspectedExpiredAt ? "confirm" : "scheduled";
|
||||||
void withAccountLock(account.id, async () => {
|
void enqueueAccountProbe(account, {
|
||||||
await performProbeLocked(account, {
|
trigger,
|
||||||
trigger,
|
allowSilentRefresh: false,
|
||||||
allowSilentRefresh: false,
|
}).catch((error) => {
|
||||||
|
console.warn("[desktop-account-health] scheduled probe failed", {
|
||||||
|
accountId: account.id,
|
||||||
|
platform: account.platform,
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -454,6 +639,7 @@ function syncTrackedAccountsInternal(
|
|||||||
trackedAccounts.delete(accountId);
|
trackedAccounts.delete(accountId);
|
||||||
records.delete(accountId);
|
records.delete(accountId);
|
||||||
removed = true;
|
removed = true;
|
||||||
|
emitRuntimeInvalidated("account-health", { accountId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -475,7 +661,7 @@ export function forgetTrackedAccountHealth(
|
|||||||
trackedAccounts.delete(accountId);
|
trackedAccounts.delete(accountId);
|
||||||
if (records.delete(accountId)) {
|
if (records.delete(accountId)) {
|
||||||
persistRecords();
|
persistRecords();
|
||||||
emitRuntimeInvalidated("account-health");
|
emitRuntimeInvalidated("account-health", { accountId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,16 +712,15 @@ export function getProjectedAccountHealth(input: {
|
|||||||
export async function probeTrackedAccount(
|
export async function probeTrackedAccount(
|
||||||
account: PublishAccountIdentity,
|
account: PublishAccountIdentity,
|
||||||
options: {
|
options: {
|
||||||
trigger?: "scheduled" | "manual" | "preflight" | "confirm";
|
trigger?: ProbeTrigger;
|
||||||
allowSilentRefresh?: boolean;
|
allowSilentRefresh?: boolean;
|
||||||
|
force?: boolean;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<AccountHealthSnapshot> {
|
): Promise<AccountHealthSnapshot> {
|
||||||
syncTrackedAccountsInternal([account], false);
|
return await enqueueAccountProbe(account, {
|
||||||
return await withAccountLock(account.id, async () => {
|
trigger: options.trigger ?? "manual",
|
||||||
return await performProbeLocked(account, {
|
allowSilentRefresh: Boolean(options.allowSilentRefresh),
|
||||||
trigger: options.trigger ?? "manual",
|
force: Boolean(options.force),
|
||||||
allowSilentRefresh: Boolean(options.allowSilentRefresh),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type AccountAuthState =
|
|||||||
| "revoked";
|
| "revoked";
|
||||||
|
|
||||||
export type AccountProbeState =
|
export type AccountProbeState =
|
||||||
|
| "queued"
|
||||||
| "idle"
|
| "idle"
|
||||||
| "probing"
|
| "probing"
|
||||||
| "network_error";
|
| "network_error";
|
||||||
|
|||||||
@@ -24,12 +24,13 @@ import { registerRendererDevtoolsProxyTarget } from "./renderer-devtools-proxy";
|
|||||||
import { onRuntimeInvalidated } from "./runtime-events";
|
import { onRuntimeInvalidated } from "./runtime-events";
|
||||||
import {
|
import {
|
||||||
noteRuntimeAccountBound,
|
noteRuntimeAccountBound,
|
||||||
|
requestRuntimeAccountProbe,
|
||||||
refreshRuntimeAccounts,
|
refreshRuntimeAccounts,
|
||||||
releaseRuntimeSession,
|
releaseRuntimeSession,
|
||||||
syncRuntimeSession,
|
syncRuntimeSession,
|
||||||
unbindRuntimeAccount,
|
unbindRuntimeAccount,
|
||||||
} from "./runtime-controller";
|
} from "./runtime-controller";
|
||||||
import { createRuntimeSnapshot } from "./runtime-snapshot";
|
import { createRuntimeAccountSnapshot, createRuntimeSnapshot } from "./runtime-snapshot";
|
||||||
import { resolveDesktopClientID, resolveDesktopDeviceInfo } from "./device-id";
|
import { resolveDesktopClientID, resolveDesktopDeviceInfo } from "./device-id";
|
||||||
import { initScheduler } from "./scheduler";
|
import { initScheduler } from "./scheduler";
|
||||||
import { initSessionRegistry } from "./session-registry";
|
import { initSessionRegistry } from "./session-registry";
|
||||||
@@ -154,6 +155,10 @@ function captureRuntimeSnapshot() {
|
|||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function captureRuntimeAccountSnapshot(accountId: string) {
|
||||||
|
return createRuntimeAccountSnapshot(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureMainWindow(): Promise<ElectronBrowserWindow> {
|
async function ensureMainWindow(): Promise<ElectronBrowserWindow> {
|
||||||
const existing = currentMainWindow();
|
const existing = currentMainWindow();
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -314,6 +319,9 @@ function registerBridgeHandlers(): void {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
ipcMain.handle("desktop:runtime-snapshot", () => captureRuntimeSnapshot());
|
ipcMain.handle("desktop:runtime-snapshot", () => captureRuntimeSnapshot());
|
||||||
|
ipcMain.handle("desktop:runtime-account-snapshot", (_event, accountId: string) =>
|
||||||
|
captureRuntimeAccountSnapshot(accountId),
|
||||||
|
);
|
||||||
safeHandle("desktop:bind-publish-account", async (_event, platformId: string) => {
|
safeHandle("desktop:bind-publish-account", async (_event, platformId: string) => {
|
||||||
const account = (await bindPublishAccount(platformId)) as DesktopAccountInfo;
|
const account = (await bindPublishAccount(platformId)) as DesktopAccountInfo;
|
||||||
noteRuntimeAccountBound(account);
|
noteRuntimeAccountBound(account);
|
||||||
@@ -328,6 +336,10 @@ function registerBridgeHandlers(): void {
|
|||||||
await refreshRuntimeAccounts();
|
await refreshRuntimeAccounts();
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
safeHandle("desktop:probe-runtime-account", async (_event, accountId: string) => {
|
||||||
|
requestRuntimeAccountProbe(accountId);
|
||||||
|
return captureRuntimeAccountSnapshot(accountId);
|
||||||
|
});
|
||||||
safeHandle(
|
safeHandle(
|
||||||
"desktop:open-publish-account-console",
|
"desktop:open-publish-account-console",
|
||||||
async (
|
async (
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ApiClientError } from "@geo/http-client";
|
|||||||
import type {
|
import type {
|
||||||
DesktopArticleContent,
|
DesktopArticleContent,
|
||||||
DesktopAccountInfo,
|
DesktopAccountInfo,
|
||||||
|
DesktopAccountHealthReport,
|
||||||
DesktopClientInfo,
|
DesktopClientInfo,
|
||||||
MonitoringLeaseTask,
|
MonitoringLeaseTask,
|
||||||
MonitoringSourceItem,
|
MonitoringSourceItem,
|
||||||
@@ -112,6 +113,7 @@ import {
|
|||||||
noteTransportHeartbeat,
|
noteTransportHeartbeat,
|
||||||
noteTransportPull,
|
noteTransportPull,
|
||||||
offlineDesktopClient,
|
offlineDesktopClient,
|
||||||
|
reportDesktopAccountHealth,
|
||||||
resumeMonitoringTasks,
|
resumeMonitoringTasks,
|
||||||
revokeDesktopClient,
|
revokeDesktopClient,
|
||||||
setTransportAuthState,
|
setTransportAuthState,
|
||||||
@@ -151,6 +153,8 @@ const publishReservedSlots = 1;
|
|||||||
const minimumForegroundTotalConcurrency = 2;
|
const minimumForegroundTotalConcurrency = 2;
|
||||||
const maximumRuntimeTotalConcurrency = 4;
|
const maximumRuntimeTotalConcurrency = 4;
|
||||||
const BAIJIAHAO_RISK_CONTROL_PROMPT = "您触发平台风控,请手动发稿一篇并完成验证,方可继续使用";
|
const BAIJIAHAO_RISK_CONTROL_PROMPT = "您触发平台风控,请手动发稿一篇并完成验证,方可继续使用";
|
||||||
|
const accountHealthReportDebounceMs = 1_000;
|
||||||
|
const accountHealthReportMaxBatchSize = 100;
|
||||||
|
|
||||||
interface RuntimeTaskRecord {
|
interface RuntimeTaskRecord {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -233,6 +237,8 @@ interface RuntimeState {
|
|||||||
publishFallbackBackoffUntil: number;
|
publishFallbackBackoffUntil: number;
|
||||||
heartbeatTimer: ReturnType<typeof setInterval> | null;
|
heartbeatTimer: ReturnType<typeof setInterval> | null;
|
||||||
pullTimer: ReturnType<typeof setInterval> | null;
|
pullTimer: ReturnType<typeof setInterval> | null;
|
||||||
|
accountHealthReportTimer: ReturnType<typeof setTimeout> | null;
|
||||||
|
accountHealthReportQueue: Map<string, DesktopAccountHealthReport>;
|
||||||
dispatchWsClient: DispatchWsClient | null;
|
dispatchWsClient: DispatchWsClient | null;
|
||||||
dispatchWsConnected: boolean;
|
dispatchWsConnected: boolean;
|
||||||
lastHeartbeatAt: number;
|
lastHeartbeatAt: number;
|
||||||
@@ -258,6 +264,8 @@ const state: RuntimeState = {
|
|||||||
publishFallbackBackoffUntil: 0,
|
publishFallbackBackoffUntil: 0,
|
||||||
heartbeatTimer: null,
|
heartbeatTimer: null,
|
||||||
pullTimer: null,
|
pullTimer: null,
|
||||||
|
accountHealthReportTimer: null,
|
||||||
|
accountHealthReportQueue: new Map<string, DesktopAccountHealthReport>(),
|
||||||
dispatchWsClient: null,
|
dispatchWsClient: null,
|
||||||
dispatchWsConnected: false,
|
dispatchWsConnected: false,
|
||||||
lastHeartbeatAt: 0,
|
lastHeartbeatAt: 0,
|
||||||
@@ -496,6 +504,10 @@ function stopRuntime(): void {
|
|||||||
clearInterval(state.pullTimer);
|
clearInterval(state.pullTimer);
|
||||||
state.pullTimer = null;
|
state.pullTimer = null;
|
||||||
}
|
}
|
||||||
|
if (state.accountHealthReportTimer) {
|
||||||
|
clearTimeout(state.accountHealthReportTimer);
|
||||||
|
state.accountHealthReportTimer = null;
|
||||||
|
}
|
||||||
if (state.dispatchWsClient) {
|
if (state.dispatchWsClient) {
|
||||||
state.dispatchWsClient.stop();
|
state.dispatchWsClient.stop();
|
||||||
state.dispatchWsClient = null;
|
state.dispatchWsClient = null;
|
||||||
@@ -519,6 +531,7 @@ function clearRuntimeState(): void {
|
|||||||
state.tasks.clear();
|
state.tasks.clear();
|
||||||
state.accounts = [];
|
state.accounts = [];
|
||||||
state.accountProfiles.clear();
|
state.accountProfiles.clear();
|
||||||
|
state.accountHealthReportQueue.clear();
|
||||||
state.activeExecutions.clear();
|
state.activeExecutions.clear();
|
||||||
state.lastHeartbeatAt = 0;
|
state.lastHeartbeatAt = 0;
|
||||||
state.lastHeartbeatStatus = "idle";
|
state.lastHeartbeatStatus = "idle";
|
||||||
@@ -897,7 +910,6 @@ async function syncAccounts(source: "startup" | "manual"): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const accounts = await listDesktopAccounts();
|
const accounts = await listDesktopAccounts();
|
||||||
const currentClientId = state.client?.id ?? state.session?.desktop_client?.id ?? null;
|
|
||||||
state.lastAccountsSyncAt = Date.now();
|
state.lastAccountsSyncAt = Date.now();
|
||||||
state.lastError = null;
|
state.lastError = null;
|
||||||
|
|
||||||
@@ -906,22 +918,11 @@ async function syncAccounts(source: "startup" | "manual"): Promise<void> {
|
|||||||
|
|
||||||
const identities = accounts.map((account) => accountIdentityFromDesktopAccount(account));
|
const identities = accounts.map((account) => accountIdentityFromDesktopAccount(account));
|
||||||
syncTrackedAccounts(identities);
|
syncTrackedAccounts(identities);
|
||||||
if (source === "manual") {
|
|
||||||
await Promise.allSettled(
|
|
||||||
identities.map((account) =>
|
|
||||||
probeTrackedAccount(account, {
|
|
||||||
trigger: "manual",
|
|
||||||
allowSilentRefresh: true,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
state.accountProfiles.clear();
|
state.accountProfiles.clear();
|
||||||
const syncedAccounts: Array<DesktopAccountInfo | null> = await Promise.all(accounts.map(async (account) => {
|
const syncedAccounts: Array<DesktopAccountInfo | null> = accounts.map((account) => {
|
||||||
const normalizedPlatformUid = normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid;
|
const normalizedPlatformUid = normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid;
|
||||||
const isAIPlatform = isAIPlatformId(account.platform);
|
const isAIPlatform = isAIPlatformId(account.platform);
|
||||||
const identity = accountIdentityFromDesktopAccount(account);
|
|
||||||
const projected = getProjectedAccountHealth({
|
const projected = getProjectedAccountHealth({
|
||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
platform: account.platform,
|
platform: account.platform,
|
||||||
@@ -961,41 +962,8 @@ async function syncAccounts(source: "startup" | "manual"): Promise<void> {
|
|||||||
verified_at: isoFromTimestamp(projected.lastVerifiedAt) ?? account.verified_at,
|
verified_at: isoFromTimestamp(projected.lastVerifiedAt) ?? account.verified_at,
|
||||||
};
|
};
|
||||||
|
|
||||||
const shouldRelinkClient = Boolean(currentClientId && resolvedAccount.client_id !== currentClientId);
|
|
||||||
const shouldMirrorHealth =
|
|
||||||
resolvedAccount.health !== account.health
|
|
||||||
&& isDefinitiveAuthState(projected.authState);
|
|
||||||
|
|
||||||
if (shouldRelinkClient || shouldMirrorHealth) {
|
|
||||||
try {
|
|
||||||
const updated = await upsertDesktopAccount({
|
|
||||||
platform: resolvedAccount.platform,
|
|
||||||
platform_uid: normalizedPlatformUid,
|
|
||||||
display_name: projected.profile?.displayName ?? resolvedAccount.display_name,
|
|
||||||
avatar_url: projected.profile?.avatarUrl ?? resolvedAccount.avatar_url,
|
|
||||||
account_fingerprint: resolvedAccount.account_fingerprint ?? normalizedPlatformUid,
|
|
||||||
health: resolvedAccount.health,
|
|
||||||
verified_at: isoFromTimestamp(projected.lastVerifiedAt) ?? resolvedAccount.verified_at,
|
|
||||||
tags: resolvedAccount.tags,
|
|
||||||
});
|
|
||||||
|
|
||||||
resolvedAccount = {
|
|
||||||
...updated,
|
|
||||||
platform_uid: normalizeAccountPlatformUid(updated.platform_uid) || updated.platform_uid,
|
|
||||||
health: resolvedAccount.health,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("[desktop-runtime] account relink failed", {
|
|
||||||
accountId: resolvedAccount.id,
|
|
||||||
platform: resolvedAccount.platform,
|
|
||||||
platformUid: resolvedAccount.platform_uid,
|
|
||||||
message: errorMessage(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return resolvedAccount;
|
return resolvedAccount;
|
||||||
}));
|
});
|
||||||
const duplicateAccounts: DesktopAccountInfo[] = [];
|
const duplicateAccounts: DesktopAccountInfo[] = [];
|
||||||
const dedupedAccounts: DesktopAccountInfo[] = [];
|
const dedupedAccounts: DesktopAccountInfo[] = [];
|
||||||
const seenAccountKeys = new Set<string>();
|
const seenAccountKeys = new Set<string>();
|
||||||
@@ -1061,10 +1029,161 @@ async function cleanupDuplicateDesktopAccounts(accounts: DesktopAccountInfo[]):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildAccountHealthReport(account: DesktopAccountInfo): DesktopAccountHealthReport {
|
||||||
|
const projected = getProjectedAccountHealth({
|
||||||
|
accountId: account.id,
|
||||||
|
platform: account.platform,
|
||||||
|
health: account.health,
|
||||||
|
verifiedAt: account.verified_at,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
account_id: account.id,
|
||||||
|
platform: account.platform,
|
||||||
|
platform_uid: normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid,
|
||||||
|
health: projected.health,
|
||||||
|
auth_state: projected.authState,
|
||||||
|
probe_state: projected.probeState,
|
||||||
|
auth_reason: projected.authReason,
|
||||||
|
display_name: projected.profile?.displayName ?? account.display_name,
|
||||||
|
avatar_url: projected.profile?.avatarUrl ?? account.avatar_url,
|
||||||
|
verified_at: isoFromTimestamp(projected.lastVerifiedAt) ?? account.verified_at,
|
||||||
|
checked_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueAccountHealthReport(accountId: string): void {
|
||||||
|
const account = state.accounts.find((item) => item.id === accountId) ?? null;
|
||||||
|
if (!account) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.accountHealthReportQueue.set(account.id, buildAccountHealthReport(account));
|
||||||
|
if (state.accountHealthReportTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.accountHealthReportTimer = setTimeout(() => {
|
||||||
|
state.accountHealthReportTimer = null;
|
||||||
|
void flushAccountHealthReports();
|
||||||
|
}, accountHealthReportDebounceMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushAccountHealthReports(): Promise<void> {
|
||||||
|
if (state.accountHealthReportQueue.size === 0 || !canRunLive(state.session)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reports = [...state.accountHealthReportQueue.values()].slice(0, accountHealthReportMaxBatchSize);
|
||||||
|
for (const report of reports) {
|
||||||
|
state.accountHealthReportQueue.delete(report.account_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await reportDesktopAccountHealth({ reports });
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[desktop-runtime] account health report failed", {
|
||||||
|
count: reports.length,
|
||||||
|
message: errorMessage(error),
|
||||||
|
});
|
||||||
|
for (const report of reports) {
|
||||||
|
state.accountHealthReportQueue.set(report.account_id, report);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (state.accountHealthReportQueue.size > 0 && !state.accountHealthReportTimer) {
|
||||||
|
state.accountHealthReportTimer = setTimeout(() => {
|
||||||
|
state.accountHealthReportTimer = null;
|
||||||
|
void flushAccountHealthReports();
|
||||||
|
}, accountHealthReportDebounceMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mirrorProjectedAccountProfile(accountId: string): Promise<void> {
|
||||||
|
const account = state.accounts.find((item) => item.id === accountId) ?? null;
|
||||||
|
if (!account) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projected = getProjectedAccountHealth({
|
||||||
|
accountId: account.id,
|
||||||
|
platform: account.platform,
|
||||||
|
health: account.health,
|
||||||
|
verifiedAt: account.verified_at,
|
||||||
|
});
|
||||||
|
if (!isDefinitiveAuthState(projected.authState)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextDisplayName = projected.profile?.displayName ?? account.display_name;
|
||||||
|
const nextAvatarUrl = projected.profile?.avatarUrl ?? account.avatar_url;
|
||||||
|
const shouldMirrorProfile =
|
||||||
|
account.display_name !== nextDisplayName
|
||||||
|
|| account.avatar_url !== nextAvatarUrl;
|
||||||
|
if (!shouldMirrorProfile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedPlatformUid = normalizeAccountPlatformUid(account.platform_uid) || account.platform_uid;
|
||||||
|
const updated = await upsertDesktopAccount({
|
||||||
|
platform: account.platform,
|
||||||
|
platform_uid: normalizedPlatformUid,
|
||||||
|
display_name: nextDisplayName,
|
||||||
|
avatar_url: nextAvatarUrl,
|
||||||
|
account_fingerprint: account.account_fingerprint ?? normalizedPlatformUid,
|
||||||
|
health: account.health,
|
||||||
|
verified_at: account.verified_at,
|
||||||
|
tags: account.tags,
|
||||||
|
});
|
||||||
|
|
||||||
|
state.accounts = state.accounts.map((item) =>
|
||||||
|
item.id === account.id
|
||||||
|
? {
|
||||||
|
...updated,
|
||||||
|
platform_uid: normalizeAccountPlatformUid(updated.platform_uid) || updated.platform_uid,
|
||||||
|
health: account.health,
|
||||||
|
}
|
||||||
|
: item,
|
||||||
|
);
|
||||||
|
state.accountProfiles.set(account.id, {
|
||||||
|
platformUid: normalizedPlatformUid,
|
||||||
|
displayName: nextDisplayName,
|
||||||
|
avatarUrl: nextAvatarUrl,
|
||||||
|
});
|
||||||
|
refreshAccountNames();
|
||||||
|
}
|
||||||
|
|
||||||
export async function refreshRuntimeAccounts(): Promise<void> {
|
export async function refreshRuntimeAccounts(): Promise<void> {
|
||||||
await syncAccounts("manual");
|
await syncAccounts("manual");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function requestRuntimeAccountProbe(
|
||||||
|
accountId: string,
|
||||||
|
options: { account?: PublishAccountIdentity; force?: boolean } = {},
|
||||||
|
): void {
|
||||||
|
const existingAccount = state.accounts.find((item) => item.id === accountId) ?? null;
|
||||||
|
const account = options.account ?? (existingAccount ? accountIdentityFromDesktopAccount(existingAccount) : null);
|
||||||
|
if (!account) {
|
||||||
|
throw new Error("runtime_account_not_found");
|
||||||
|
}
|
||||||
|
|
||||||
|
void probeTrackedAccount(account, {
|
||||||
|
trigger: "manual",
|
||||||
|
allowSilentRefresh: true,
|
||||||
|
force: options.force ?? true,
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
enqueueAccountHealthReport(account.id);
|
||||||
|
await mirrorProjectedAccountProfile(account.id);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.warn("[desktop-runtime] account probe failed", {
|
||||||
|
accountId: account.id,
|
||||||
|
platform: account.platform,
|
||||||
|
message: errorMessage(error),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function noteRuntimeAccountBound(account: DesktopAccountInfo): void {
|
export function noteRuntimeAccountBound(account: DesktopAccountInfo): void {
|
||||||
const normalizedAccount: DesktopAccountInfo = {
|
const normalizedAccount: DesktopAccountInfo = {
|
||||||
...account,
|
...account,
|
||||||
|
|||||||
@@ -3,14 +3,19 @@ type RuntimeInvalidationReason = "account-health";
|
|||||||
type RuntimeInvalidationListener = (event: {
|
type RuntimeInvalidationListener = (event: {
|
||||||
reason: RuntimeInvalidationReason;
|
reason: RuntimeInvalidationReason;
|
||||||
at: number;
|
at: number;
|
||||||
|
accountId?: string;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
|
||||||
const listeners = new Set<RuntimeInvalidationListener>();
|
const listeners = new Set<RuntimeInvalidationListener>();
|
||||||
|
|
||||||
export function emitRuntimeInvalidated(reason: RuntimeInvalidationReason): void {
|
export function emitRuntimeInvalidated(
|
||||||
|
reason: RuntimeInvalidationReason,
|
||||||
|
details: { accountId?: string } = {},
|
||||||
|
): void {
|
||||||
const event = {
|
const event = {
|
||||||
reason,
|
reason,
|
||||||
at: Date.now(),
|
at: Date.now(),
|
||||||
|
...details,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
for (const listener of listeners) {
|
for (const listener of listeners) {
|
||||||
@@ -31,4 +36,3 @@ export function onRuntimeInvalidated(listener: RuntimeInvalidationListener): ()
|
|||||||
listeners.delete(listener);
|
listeners.delete(listener);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,11 +32,83 @@ function parseTimestamp(value: string | null | undefined): number | null {
|
|||||||
return Number.isNaN(timestamp) ? null : timestamp;
|
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() {
|
export function createRuntimeSnapshot() {
|
||||||
return createLiveRuntimeSnapshot(getRuntimeControllerSnapshot());
|
return createLiveRuntimeSnapshot(getRuntimeControllerSnapshot());
|
||||||
}
|
}
|
||||||
|
|
||||||
function createLiveRuntimeSnapshot(controller: ReturnType<typeof 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();
|
const now = Date.now();
|
||||||
// Electron Session instances are not safe to ship across IPC to the renderer.
|
// Electron Session instances are not safe to ship across IPC to the renderer.
|
||||||
const sessions = listSessionHandleSnapshots();
|
const sessions = listSessionHandleSnapshots();
|
||||||
@@ -70,45 +142,15 @@ function createLiveRuntimeSnapshot(controller: ReturnType<typeof getRuntimeContr
|
|||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const accounts = controller.accounts.map((account) => {
|
const accounts = controller.accounts.map((account) =>
|
||||||
const sessionHandle = getSessionHandle(account.id);
|
createRuntimeAccountView(controller, account, {
|
||||||
const isHot = hotViews.some((item) => item.accountId === account.id);
|
hotViews,
|
||||||
const accountQueueDepth = controller.tasks.filter((task) =>
|
primaryClientId,
|
||||||
task.accountId === account.id && ["queued", "in_progress"].includes(task.status),
|
clientOnline,
|
||||||
).length;
|
lastAccountsSyncAt: controller.lastAccountsSyncAt,
|
||||||
const projected = getProjectedAccountHealth({
|
now,
|
||||||
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 ?? primaryClientId,
|
|
||||||
partition: projected.partition || sessionHandle?.partition || `persist:acc-${account.id}`,
|
|
||||||
sessionState: isHot ? "hot" : sessionHandle ? "warm" : "cold",
|
|
||||||
lastSyncAt:
|
|
||||||
controller.lastAccountsSyncAt
|
|
||||||
|| parseTimestamp(account.verified_at)
|
|
||||||
|| now,
|
|
||||||
lastVerifiedAt: projected.lastVerifiedAt,
|
|
||||||
lastProbeAt: projected.lastProbeAt,
|
|
||||||
nextProbeAt: projected.nextProbeAt,
|
|
||||||
queueDepth: accountQueueDepth,
|
|
||||||
online: clientOnline,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const tasks = controller.tasks.map((task) => ({ ...task }));
|
const tasks = controller.tasks.map((task) => ({ ...task }));
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import type {
|
|||||||
LeaseDesktopTaskRequest,
|
LeaseDesktopTaskRequest,
|
||||||
LeaseDesktopTaskResponse,
|
LeaseDesktopTaskResponse,
|
||||||
ListDesktopPublishTasksParams,
|
ListDesktopPublishTasksParams,
|
||||||
|
ReportDesktopAccountHealthRequest,
|
||||||
|
ReportDesktopAccountHealthResponse,
|
||||||
MonitoringLeaseTasksPayload,
|
MonitoringLeaseTasksPayload,
|
||||||
MonitoringLeaseTasksResponse,
|
MonitoringLeaseTasksResponse,
|
||||||
MonitoringResumeTasksPayload,
|
MonitoringResumeTasksPayload,
|
||||||
@@ -65,6 +67,16 @@ let transportSession: DesktopTransportSession = {
|
|||||||
baseURL: process.env.DESKTOP_API_BASE_URL ?? "http://localhost:8080",
|
baseURL: process.env.DESKTOP_API_BASE_URL ?? "http://localhost:8080",
|
||||||
clientToken: null,
|
clientToken: null,
|
||||||
};
|
};
|
||||||
|
const ACCOUNT_UPSERT_CACHE_TTL_MS = 5 * 60_000;
|
||||||
|
|
||||||
|
interface AccountUpsertCacheEntry {
|
||||||
|
signature: string;
|
||||||
|
expiresAt: number;
|
||||||
|
response: DesktopAccountInfo;
|
||||||
|
inFlight: Promise<DesktopAccountInfo> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountUpsertCache = new Map<string, AccountUpsertCacheEntry>();
|
||||||
|
|
||||||
const transportState = {
|
const transportState = {
|
||||||
baseURL: transportSession.baseURL,
|
baseURL: transportSession.baseURL,
|
||||||
@@ -135,6 +147,43 @@ function createDesktopClient(baseURL: string): ApiClient {
|
|||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stableJson(value: unknown): string {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return `[${value.map((item) => stableJson(item)).join(",")}]`;
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
return `{${Object.entries(value as Record<string, unknown>)
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`)
|
||||||
|
.join(",")}}`;
|
||||||
|
}
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function accountUpsertCacheKey(payload: UpsertDesktopAccountRequest): string {
|
||||||
|
return [
|
||||||
|
payload.platform.trim(),
|
||||||
|
payload.platform_uid.trim(),
|
||||||
|
].join("::");
|
||||||
|
}
|
||||||
|
|
||||||
|
function accountUpsertSignature(payload: UpsertDesktopAccountRequest): string {
|
||||||
|
return stableJson({
|
||||||
|
platform: payload.platform.trim(),
|
||||||
|
platform_uid: payload.platform_uid.trim(),
|
||||||
|
account_fingerprint: payload.account_fingerprint ?? null,
|
||||||
|
display_name: payload.display_name.trim(),
|
||||||
|
avatar_url: payload.avatar_url ?? null,
|
||||||
|
health: payload.health,
|
||||||
|
tags: payload.tags ?? [],
|
||||||
|
if_sync_version: payload.if_sync_version ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAccountUpsertCache(): void {
|
||||||
|
accountUpsertCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
function currentRequestDebugMode(): "main-process" | "renderer-devtools" {
|
function currentRequestDebugMode(): "main-process" | "renderer-devtools" {
|
||||||
return canUseRendererDevtoolsProxy() ? "renderer-devtools" : "main-process";
|
return canUseRendererDevtoolsProxy() ? "renderer-devtools" : "main-process";
|
||||||
}
|
}
|
||||||
@@ -319,6 +368,7 @@ export function configureTransport(session: DesktopRuntimeSessionSyncRequest | n
|
|||||||
transportState.auth = "pending";
|
transportState.auth = "pending";
|
||||||
transportState.dispatchState = "idle";
|
transportState.dispatchState = "idle";
|
||||||
desktopApiClient = null;
|
desktopApiClient = null;
|
||||||
|
clearAccountUpsertCache();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,6 +382,7 @@ export function configureTransport(session: DesktopRuntimeSessionSyncRequest | n
|
|||||||
transportState.dispatchState = session.client_token ? "connecting" : "idle";
|
transportState.dispatchState = session.client_token ? "connecting" : "idle";
|
||||||
transportState.initializedAt = Date.now();
|
transportState.initializedAt = Date.now();
|
||||||
desktopApiClient = createDesktopClient(transportSession.baseURL);
|
desktopApiClient = createDesktopClient(transportSession.baseURL);
|
||||||
|
clearAccountUpsertCache();
|
||||||
return desktopApiClient;
|
return desktopApiClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,10 +476,52 @@ export async function retryDesktopPublishTask(taskId: string): Promise<CreatePub
|
|||||||
export async function upsertDesktopAccount(
|
export async function upsertDesktopAccount(
|
||||||
payload: UpsertDesktopAccountRequest,
|
payload: UpsertDesktopAccountRequest,
|
||||||
): Promise<DesktopAccountInfo> {
|
): Promise<DesktopAccountInfo> {
|
||||||
return desktopPost<DesktopAccountInfo, UpsertDesktopAccountRequest>(
|
const now = Date.now();
|
||||||
|
const cacheKey = accountUpsertCacheKey(payload);
|
||||||
|
const signature = accountUpsertSignature(payload);
|
||||||
|
const cached = accountUpsertCache.get(cacheKey);
|
||||||
|
if (cached && cached.signature === signature && cached.expiresAt > now) {
|
||||||
|
if (cached.inFlight) {
|
||||||
|
return cached.inFlight;
|
||||||
|
}
|
||||||
|
return cached.response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = desktopPost<DesktopAccountInfo, UpsertDesktopAccountRequest>(
|
||||||
"/api/desktop/accounts",
|
"/api/desktop/accounts",
|
||||||
payload,
|
payload,
|
||||||
);
|
);
|
||||||
|
accountUpsertCache.set(cacheKey, {
|
||||||
|
signature,
|
||||||
|
expiresAt: now + ACCOUNT_UPSERT_CACHE_TTL_MS,
|
||||||
|
response: cached?.response ?? ({} as DesktopAccountInfo),
|
||||||
|
inFlight: request,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request;
|
||||||
|
accountUpsertCache.set(cacheKey, {
|
||||||
|
signature,
|
||||||
|
expiresAt: Date.now() + ACCOUNT_UPSERT_CACHE_TTL_MS,
|
||||||
|
response,
|
||||||
|
inFlight: null,
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
if (accountUpsertCache.get(cacheKey)?.inFlight === request) {
|
||||||
|
accountUpsertCache.delete(cacheKey);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reportDesktopAccountHealth(
|
||||||
|
payload: ReportDesktopAccountHealthRequest,
|
||||||
|
): Promise<ReportDesktopAccountHealthResponse> {
|
||||||
|
return desktopPost<ReportDesktopAccountHealthResponse, ReportDesktopAccountHealthRequest>(
|
||||||
|
"/api/desktop/accounts/health-reports",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteDesktopAccount(
|
export async function deleteDesktopAccount(
|
||||||
@@ -436,9 +529,11 @@ export async function deleteDesktopAccount(
|
|||||||
ifSyncVersion: number,
|
ifSyncVersion: number,
|
||||||
): Promise<DesktopAccountInfo> {
|
): Promise<DesktopAccountInfo> {
|
||||||
const params = new URLSearchParams({ if_sync_version: String(ifSyncVersion) });
|
const params = new URLSearchParams({ if_sync_version: String(ifSyncVersion) });
|
||||||
return desktopDelete<DesktopAccountInfo>(
|
const response = await desktopDelete<DesktopAccountInfo>(
|
||||||
`/api/desktop/accounts/${encodeURIComponent(desktopId)}?${params.toString()}`,
|
`/api/desktop/accounts/${encodeURIComponent(desktopId)}?${params.toString()}`,
|
||||||
);
|
);
|
||||||
|
clearAccountUpsertCache();
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function leaseDesktopTask(
|
export async function leaseDesktopTask(
|
||||||
|
|||||||
@@ -92,10 +92,14 @@ const desktopBridge = {
|
|||||||
ipcRenderer.invoke("desktop:client-id", scope) as Promise<string>,
|
ipcRenderer.invoke("desktop:client-id", scope) as Promise<string>,
|
||||||
runtimeSnapshot: () =>
|
runtimeSnapshot: () =>
|
||||||
ipcRenderer.invoke("desktop:runtime-snapshot") as Promise<DesktopRuntimeSnapshot>,
|
ipcRenderer.invoke("desktop:runtime-snapshot") as Promise<DesktopRuntimeSnapshot>,
|
||||||
|
runtimeAccountSnapshot: (accountId: string) =>
|
||||||
|
ipcRenderer.invoke("desktop:runtime-account-snapshot", accountId) as Promise<DesktopRuntimeSnapshot["accounts"][number] | null>,
|
||||||
bindPublishAccount: (platformId: string) =>
|
bindPublishAccount: (platformId: string) =>
|
||||||
ipcRenderer.invoke("desktop:bind-publish-account", platformId) as Promise<DesktopAccountInfo>,
|
ipcRenderer.invoke("desktop:bind-publish-account", platformId) as Promise<DesktopAccountInfo>,
|
||||||
refreshRuntimeAccounts: () =>
|
refreshRuntimeAccounts: () =>
|
||||||
ipcRenderer.invoke("desktop:refresh-runtime-accounts") as Promise<null>,
|
ipcRenderer.invoke("desktop:refresh-runtime-accounts") as Promise<null>,
|
||||||
|
probeRuntimeAccount: (accountId: string) =>
|
||||||
|
ipcRenderer.invoke("desktop:probe-runtime-account", accountId) as Promise<DesktopRuntimeSnapshot["accounts"][number] | null>,
|
||||||
openPublishAccountConsole: (account: {
|
openPublishAccountConsole: (account: {
|
||||||
id: string;
|
id: string;
|
||||||
platform: string;
|
platform: string;
|
||||||
@@ -118,9 +122,9 @@ const desktopBridge = {
|
|||||||
setWindowMode: (mode: "login" | "main") =>
|
setWindowMode: (mode: "login" | "main") =>
|
||||||
ipcRenderer.invoke("desktop:set-window-mode", mode) as Promise<null>,
|
ipcRenderer.invoke("desktop:set-window-mode", mode) as Promise<null>,
|
||||||
onRuntimeInvalidated: (
|
onRuntimeInvalidated: (
|
||||||
listener: (event: { reason: "account-health"; at: number }) => void,
|
listener: (event: { reason: "account-health"; at: number; accountId?: string }) => void,
|
||||||
) => {
|
) => {
|
||||||
const wrapped = (_event: IpcRendererEvent, payload: { reason: "account-health"; at: number }) => {
|
const wrapped = (_event: IpcRendererEvent, payload: { reason: "account-health"; at: number; accountId?: string }) => {
|
||||||
listener(payload);
|
listener(payload);
|
||||||
};
|
};
|
||||||
ipcRenderer.on("desktop:runtime-invalidated", wrapped);
|
ipcRenderer.on("desktop:runtime-invalidated", wrapped);
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { computed, onMounted, onUnmounted, readonly, shallowRef } from "vue";
|
import { computed, onMounted, onUnmounted, readonly, shallowRef } from "vue";
|
||||||
|
|
||||||
import type { DesktopRuntimeSnapshot } from "../types";
|
import type { DesktopRuntimeSnapshot, RuntimeAccount } from "../types";
|
||||||
|
|
||||||
const RUNTIME_POLL_INTERVAL_MS = 15_000;
|
const RUNTIME_POLL_INTERVAL_MS = 15_000;
|
||||||
|
const INVALIDATION_DEBOUNCE_MS = 250;
|
||||||
|
|
||||||
const snapshot = shallowRef<DesktopRuntimeSnapshot | null>(null);
|
const snapshot = shallowRef<DesktopRuntimeSnapshot | null>(null);
|
||||||
const loading = shallowRef(false);
|
const loading = shallowRef(false);
|
||||||
const error = shallowRef<string | null>(null);
|
const error = shallowRef<string | null>(null);
|
||||||
|
|
||||||
let intervalHandle: ReturnType<typeof setInterval> | null = null;
|
let intervalHandle: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let invalidationRefreshHandle: ReturnType<typeof setTimeout> | null = null;
|
||||||
let subscribers = 0;
|
let subscribers = 0;
|
||||||
let visibilityListenerBound = false;
|
let visibilityListenerBound = false;
|
||||||
let runtimeInvalidationUnsubscribe: (() => void) | null = null;
|
let runtimeInvalidationUnsubscribe: (() => void) | null = null;
|
||||||
@@ -17,8 +19,54 @@ function isPageVisible(): boolean {
|
|||||||
return typeof document === "undefined" || document.visibilityState === "visible";
|
return typeof document === "undefined" || document.visibilityState === "visible";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshRuntimeSnapshot() {
|
function accountSummaryPatch(
|
||||||
loading.value = true;
|
current: DesktopRuntimeSnapshot,
|
||||||
|
accounts: RuntimeAccount[],
|
||||||
|
): DesktopRuntimeSnapshot["summary"] {
|
||||||
|
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 {
|
||||||
|
...current.summary,
|
||||||
|
accountsBound: accounts.length,
|
||||||
|
healthCounts,
|
||||||
|
issuesOpen:
|
||||||
|
current.tasks.filter((item) => ["unknown", "failed"].includes(item.status)).length
|
||||||
|
+ blockingAccountCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchRuntimeAccount(accountId: string, account: RuntimeAccount | null): void {
|
||||||
|
const current = snapshot.value;
|
||||||
|
if (!current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingIndex = current.accounts.findIndex((item) => item.id === accountId);
|
||||||
|
const accounts =
|
||||||
|
account && existingIndex >= 0
|
||||||
|
? current.accounts.map((item, index) => (index === existingIndex ? account : item))
|
||||||
|
: account
|
||||||
|
? [account, ...current.accounts]
|
||||||
|
: current.accounts.filter((item) => item.id !== accountId);
|
||||||
|
|
||||||
|
snapshot.value = {
|
||||||
|
...current,
|
||||||
|
generatedAt: Date.now(),
|
||||||
|
summary: accountSummaryPatch(current, accounts),
|
||||||
|
accounts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshRuntimeSnapshot(options: { silent?: boolean } = {}) {
|
||||||
|
if (!options.silent) {
|
||||||
|
loading.value = true;
|
||||||
|
}
|
||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -32,7 +80,9 @@ async function refreshRuntimeSnapshot() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : "runtime snapshot unavailable";
|
error.value = err instanceof Error ? err.message : "runtime snapshot unavailable";
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
if (!options.silent) {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +105,48 @@ async function refreshAccounts() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshRuntimeAccount(accountId: string) {
|
||||||
|
if (!window.desktopBridge?.app?.runtimeAccountSnapshot) {
|
||||||
|
await refreshRuntimeSnapshot({ silent: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const account = await window.desktopBridge.app.runtimeAccountSnapshot(accountId);
|
||||||
|
patchRuntimeAccount(accountId, account);
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : "runtime account snapshot unavailable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeAccount(accountId: string) {
|
||||||
|
if (!window.desktopBridge?.app?.probeRuntimeAccount) {
|
||||||
|
await refreshAccounts();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const account = await window.desktopBridge.app.probeRuntimeAccount(accountId);
|
||||||
|
patchRuntimeAccount(accountId, account);
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : "probe account failed";
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSilentSnapshotRefresh() {
|
||||||
|
if (invalidationRefreshHandle) {
|
||||||
|
clearTimeout(invalidationRefreshHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidationRefreshHandle = setTimeout(() => {
|
||||||
|
invalidationRefreshHandle = null;
|
||||||
|
if (isPageVisible()) {
|
||||||
|
void refreshRuntimeSnapshot({ silent: true });
|
||||||
|
}
|
||||||
|
}, INVALIDATION_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
function stopPolling() {
|
function stopPolling() {
|
||||||
if (!intervalHandle) {
|
if (!intervalHandle) {
|
||||||
return;
|
return;
|
||||||
@@ -75,13 +167,13 @@ function syncPollingState() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
intervalHandle = setInterval(() => {
|
intervalHandle = setInterval(() => {
|
||||||
void refreshRuntimeSnapshot();
|
void refreshRuntimeSnapshot({ silent: true });
|
||||||
}, RUNTIME_POLL_INTERVAL_MS);
|
}, RUNTIME_POLL_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleVisibilityChange() {
|
function handleVisibilityChange() {
|
||||||
if (isPageVisible()) {
|
if (isPageVisible()) {
|
||||||
void refreshRuntimeSnapshot();
|
void refreshRuntimeSnapshot({ silent: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
syncPollingState();
|
syncPollingState();
|
||||||
@@ -101,10 +193,17 @@ function bindRuntimeInvalidationListener() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
runtimeInvalidationUnsubscribe = window.desktopBridge.app.onRuntimeInvalidated(() => {
|
runtimeInvalidationUnsubscribe = window.desktopBridge.app.onRuntimeInvalidated((event) => {
|
||||||
if (isPageVisible()) {
|
if (!isPageVisible()) {
|
||||||
void refreshRuntimeSnapshot();
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.accountId) {
|
||||||
|
void refreshRuntimeAccount(event.accountId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleSilentSnapshotRefresh();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +219,10 @@ function unbindVisibilityListener() {
|
|||||||
function unbindRuntimeInvalidationListener() {
|
function unbindRuntimeInvalidationListener() {
|
||||||
runtimeInvalidationUnsubscribe?.();
|
runtimeInvalidationUnsubscribe?.();
|
||||||
runtimeInvalidationUnsubscribe = null;
|
runtimeInvalidationUnsubscribe = null;
|
||||||
|
if (invalidationRefreshHandle) {
|
||||||
|
clearTimeout(invalidationRefreshHandle);
|
||||||
|
invalidationRefreshHandle = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDesktopRuntime() {
|
export function useDesktopRuntime() {
|
||||||
@@ -151,6 +254,7 @@ export function useDesktopRuntime() {
|
|||||||
error: readonly(error),
|
error: readonly(error),
|
||||||
refresh: refreshRuntimeSnapshot,
|
refresh: refreshRuntimeSnapshot,
|
||||||
refreshAccounts,
|
refreshAccounts,
|
||||||
|
probeAccount,
|
||||||
hasSnapshot: computed(() => snapshot.value !== null),
|
hasSnapshot: computed(() => snapshot.value !== null),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -26,8 +26,10 @@ declare global {
|
|||||||
user_id: number;
|
user_id: number;
|
||||||
}): Promise<string>;
|
}): Promise<string>;
|
||||||
runtimeSnapshot(): Promise<DesktopRuntimeSnapshot>;
|
runtimeSnapshot(): Promise<DesktopRuntimeSnapshot>;
|
||||||
|
runtimeAccountSnapshot(accountId: string): Promise<DesktopRuntimeSnapshot["accounts"][number] | null>;
|
||||||
bindPublishAccount(platformId: string): Promise<DesktopAccountInfo>;
|
bindPublishAccount(platformId: string): Promise<DesktopAccountInfo>;
|
||||||
refreshRuntimeAccounts(): Promise<null>;
|
refreshRuntimeAccounts(): Promise<null>;
|
||||||
|
probeRuntimeAccount(accountId: string): Promise<DesktopRuntimeSnapshot["accounts"][number] | null>;
|
||||||
openPublishAccountConsole(account: {
|
openPublishAccountConsole(account: {
|
||||||
id: string;
|
id: string;
|
||||||
platform: string;
|
platform: string;
|
||||||
@@ -42,7 +44,7 @@ declare global {
|
|||||||
releaseRuntimeSession(revoke?: boolean): Promise<null>;
|
releaseRuntimeSession(revoke?: boolean): Promise<null>;
|
||||||
setWindowMode(mode: "login" | "main"): Promise<null>;
|
setWindowMode(mode: "login" | "main"): Promise<null>;
|
||||||
onRuntimeInvalidated(
|
onRuntimeInvalidated(
|
||||||
listener: (event: { reason: "account-health"; at: number }) => void,
|
listener: (event: { reason: "account-health"; at: number; accountId?: string }) => void,
|
||||||
): () => void;
|
): () => void;
|
||||||
onNetworkObserved(listener: (event: DesktopObservedRequest) => void): () => void;
|
onNetworkObserved(listener: (event: DesktopObservedRequest) => void): () => void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
type ClientErrorTone = "error" | "warning";
|
type ClientErrorTone = "error" | "warning";
|
||||||
type ClientActionKind = "bind-account" | "open-console" | "unbind-account";
|
type ClientActionKind = "bind-account" | "open-console" | "probe-account" | "unbind-account";
|
||||||
|
|
||||||
interface ClientErrorPresentation {
|
interface ClientErrorPresentation {
|
||||||
tone: ClientErrorTone;
|
tone: ClientErrorTone;
|
||||||
@@ -42,7 +42,9 @@ function presentClientError(kind: ClientActionKind, error: unknown): ClientError
|
|||||||
? "账号绑定失败"
|
? "账号绑定失败"
|
||||||
: kind === "unbind-account"
|
: kind === "unbind-account"
|
||||||
? "账号解绑失败"
|
? "账号解绑失败"
|
||||||
: "打开平台失败",
|
: kind === "probe-account"
|
||||||
|
? "账号校验失败"
|
||||||
|
: "打开平台失败",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (message === "desktop_account_bind_window_closed") {
|
if (message === "desktop_account_bind_window_closed") {
|
||||||
@@ -110,6 +112,14 @@ function presentClientError(kind: ClientActionKind, error: unknown): ClientError
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (kind === "probe-account") {
|
||||||
|
return {
|
||||||
|
tone: "error",
|
||||||
|
title: "账号校验失败",
|
||||||
|
content: message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (kind === "unbind-account") {
|
if (kind === "unbind-account") {
|
||||||
return {
|
return {
|
||||||
tone: "error",
|
tone: "error",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export interface RuntimeAccount {
|
|||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
health: "live" | "expired" | "captcha" | "risk";
|
health: "live" | "expired" | "captcha" | "risk";
|
||||||
authState: "unknown" | "active" | "expiring_soon" | "challenge_required" | "expired" | "revoked";
|
authState: "unknown" | "active" | "expiring_soon" | "challenge_required" | "expired" | "revoked";
|
||||||
probeState: "idle" | "probing" | "network_error";
|
probeState: "queued" | "idle" | "probing" | "network_error";
|
||||||
authReason:
|
authReason:
|
||||||
| "bind_success"
|
| "bind_success"
|
||||||
| "probe_success"
|
| "probe_success"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { formatDateTime, formatRelativeTime, formatVerifiedAtLabel, titleCaseTok
|
|||||||
import { desktopPublishMediaCatalog } from "../lib/media-catalog";
|
import { desktopPublishMediaCatalog } from "../lib/media-catalog";
|
||||||
import type { RuntimeAccount } from "../types";
|
import type { RuntimeAccount } from "../types";
|
||||||
|
|
||||||
const { snapshot, refresh, refreshAccounts, loading } = useDesktopRuntime();
|
const { snapshot, refresh, refreshAccounts, probeAccount, loading } = useDesktopRuntime();
|
||||||
|
|
||||||
const selectedPlatform = ref("all");
|
const selectedPlatform = ref("all");
|
||||||
const selectedStatus = ref("all");
|
const selectedStatus = ref("all");
|
||||||
@@ -18,6 +18,7 @@ const searchQuery = ref("");
|
|||||||
const bindPendingPlatformId = ref<string | null>(null);
|
const bindPendingPlatformId = ref<string | null>(null);
|
||||||
const consolePendingAccountId = ref<string | null>(null);
|
const consolePendingAccountId = ref<string | null>(null);
|
||||||
const unbindPendingAccountId = ref<string | null>(null);
|
const unbindPendingAccountId = ref<string | null>(null);
|
||||||
|
const probePendingAccountIds = ref(new Set<string>());
|
||||||
const actionSuccess = ref<string | null>(null);
|
const actionSuccess = ref<string | null>(null);
|
||||||
|
|
||||||
type AccountAuthState = "authorized" | "expired" | "attention" | "risk";
|
type AccountAuthState = "authorized" | "expired" | "attention" | "risk";
|
||||||
@@ -84,6 +85,13 @@ function authState(account: AccountRow): AccountAuthState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function authStateLabel(account: AccountRow): string {
|
function authStateLabel(account: AccountRow): string {
|
||||||
|
if (account.probeState === "queued") {
|
||||||
|
return "等待校验";
|
||||||
|
}
|
||||||
|
if (account.probeState === "probing") {
|
||||||
|
return "校验中";
|
||||||
|
}
|
||||||
|
|
||||||
switch (authState(account)) {
|
switch (authState(account)) {
|
||||||
case "authorized":
|
case "authorized":
|
||||||
return "已授权";
|
return "已授权";
|
||||||
@@ -96,6 +104,23 @@ function authStateLabel(account: AccountRow): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function authTagColor(account: AccountRow): string {
|
||||||
|
if (account.probeState === "queued" || account.probeState === "probing") {
|
||||||
|
return "processing";
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (authState(account)) {
|
||||||
|
case "authorized":
|
||||||
|
return "success";
|
||||||
|
case "expired":
|
||||||
|
return "error";
|
||||||
|
case "attention":
|
||||||
|
return "warning";
|
||||||
|
default:
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function authStateTone(account: AccountRow) {
|
function authStateTone(account: AccountRow) {
|
||||||
switch (authState(account)) {
|
switch (authState(account)) {
|
||||||
case "authorized":
|
case "authorized":
|
||||||
@@ -128,6 +153,9 @@ function verificationTimeLabel(account: AccountRow): string {
|
|||||||
if (account.lastVerifiedAt) {
|
if (account.lastVerifiedAt) {
|
||||||
return formatVerifiedAtLabel(account.lastVerifiedAt);
|
return formatVerifiedAtLabel(account.lastVerifiedAt);
|
||||||
}
|
}
|
||||||
|
if (account.probeState === "queued") {
|
||||||
|
return "等待校验";
|
||||||
|
}
|
||||||
if (account.probeState === "probing") {
|
if (account.probeState === "probing") {
|
||||||
return "正在校验";
|
return "正在校验";
|
||||||
}
|
}
|
||||||
@@ -221,6 +249,32 @@ async function unbindAccount(account: AccountRow) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function verifyAccount(account: AccountRow) {
|
||||||
|
probePendingAccountIds.value = new Set([...probePendingAccountIds.value, account.id]);
|
||||||
|
actionSuccess.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await probeAccount(account.id);
|
||||||
|
} catch (error) {
|
||||||
|
showClientActionError("probe-account", error);
|
||||||
|
} finally {
|
||||||
|
const next = new Set(probePendingAccountIds.value);
|
||||||
|
next.delete(account.id);
|
||||||
|
probePendingAccountIds.value = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProbePending(account: AccountRow): boolean {
|
||||||
|
return (
|
||||||
|
probePendingAccountIds.value.has(account.id)
|
||||||
|
|| account.probeState === "queued"
|
||||||
|
|| account.probeState === "probing"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableVirtual = computed(() => filteredAccounts.value.length > 20);
|
||||||
|
const tableScroll = computed(() => (tableVirtual.value ? { x: 960, y: 640 } : { x: 960 }));
|
||||||
|
|
||||||
const tableColumns = [
|
const tableColumns = [
|
||||||
{ title: "昵称 / UID", key: "name", dataIndex: "name" },
|
{ title: "昵称 / UID", key: "name", dataIndex: "name" },
|
||||||
{ title: "平台", key: "platform", dataIndex: "platform", width: 140 },
|
{ title: "平台", key: "platform", dataIndex: "platform", width: 140 },
|
||||||
@@ -359,7 +413,15 @@ const tableColumns = [
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a-table class="modern-table" :columns="tableColumns" :data-source="filteredAccounts" :pagination="false">
|
<a-table
|
||||||
|
class="modern-table"
|
||||||
|
row-key="id"
|
||||||
|
:columns="tableColumns"
|
||||||
|
:data-source="filteredAccounts"
|
||||||
|
:pagination="false"
|
||||||
|
:scroll="tableScroll"
|
||||||
|
:virtual="tableVirtual"
|
||||||
|
>
|
||||||
<template #emptyText>
|
<template #emptyText>
|
||||||
<div style="padding: 60px; color: #94a3b8;"><a-empty description="暂无匹配账号,请重置过滤或绑定新账号" /></div>
|
<div style="padding: 60px; color: #94a3b8;"><a-empty description="暂无匹配账号,请重置过滤或绑定新账号" /></div>
|
||||||
</template>
|
</template>
|
||||||
@@ -393,7 +455,7 @@ const tableColumns = [
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="column.key === 'status'">
|
<template v-else-if="column.key === 'status'">
|
||||||
<a-tag :color="authState(record) === 'authorized' ? 'success' : authState(record) === 'expired' ? 'error' : authState(record) === 'attention' ? 'warning' : 'default'" style="border-radius: 999px; font-weight: 600; padding: 2px 10px;">
|
<a-tag :color="authTagColor(record)" style="border-radius: 999px; font-weight: 600; padding: 2px 10px;">
|
||||||
{{ authStateLabel(record) }}
|
{{ authStateLabel(record) }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
</template>
|
</template>
|
||||||
@@ -411,7 +473,9 @@ const tableColumns = [
|
|||||||
<div class="cell-primary-secondary">
|
<div class="cell-primary-secondary">
|
||||||
<div class="info-stack">
|
<div class="info-stack">
|
||||||
<span class="title">{{ record.lastVerifiedAt ? formatDateTime(record.lastVerifiedAt) : formatDateTime(record.lastSyncAt) }}</span>
|
<span class="title">{{ record.lastVerifiedAt ? formatDateTime(record.lastVerifiedAt) : formatDateTime(record.lastSyncAt) }}</span>
|
||||||
<span class="subtitle">{{ verificationTimeLabel(record) }} · {{ record.partition }}</span>
|
<span class="subtitle subtitle--partition" :title="`${verificationTimeLabel(record)} · ${record.partition}`">
|
||||||
|
{{ verificationTimeLabel(record) }} · {{ record.partition }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -423,8 +487,8 @@ const tableColumns = [
|
|||||||
<template #icon><LinkOutlined /></template>
|
<template #icon><LinkOutlined /></template>
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
<a-tooltip title="重新授权" placement="top">
|
<a-tooltip title="重新校验" placement="top">
|
||||||
<a-button type="text" class="action-btn" :loading="bindPendingPlatformId === record.platform" @click="bindPlatform(record.platform)">
|
<a-button type="text" class="action-btn" :loading="isProbePending(record)" @click="verifyAccount(record)">
|
||||||
<template #icon><SyncOutlined /></template>
|
<template #icon><SyncOutlined /></template>
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
@@ -712,6 +776,13 @@ const tableColumns = [
|
|||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info-stack .subtitle--partition {
|
||||||
|
max-width: 260px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.user-avatar-wrap {
|
.user-avatar-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -64,7 +64,11 @@ function authLabel(account: AccountRow | null) {
|
|||||||
case "revoked":
|
case "revoked":
|
||||||
return "已停用";
|
return "已停用";
|
||||||
default:
|
default:
|
||||||
return account.probeState === "probing" ? "校验中" : "待校验";
|
return account.probeState === "queued"
|
||||||
|
? "等待校验"
|
||||||
|
: account.probeState === "probing"
|
||||||
|
? "校验中"
|
||||||
|
: "待校验";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +88,9 @@ function authColor(account: AccountRow | null) {
|
|||||||
case "expiring_soon":
|
case "expiring_soon":
|
||||||
return account.probeState === "network_error" ? "warning" : "default";
|
return account.probeState === "network_error" ? "warning" : "default";
|
||||||
default:
|
default:
|
||||||
return "default";
|
return account.probeState === "queued" || account.probeState === "probing"
|
||||||
|
? "processing"
|
||||||
|
: "default";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +114,9 @@ function verificationLabel(account: AccountRow): string {
|
|||||||
if (account.lastVerifiedAt) {
|
if (account.lastVerifiedAt) {
|
||||||
return formatVerifiedAtLabel(account.lastVerifiedAt);
|
return formatVerifiedAtLabel(account.lastVerifiedAt);
|
||||||
}
|
}
|
||||||
|
if (account.probeState === "queued") {
|
||||||
|
return "等待首轮校验";
|
||||||
|
}
|
||||||
if (account.probeState === "probing") {
|
if (account.probeState === "probing") {
|
||||||
return "正在执行首轮校验";
|
return "正在执行首轮校验";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,13 @@ export interface DesktopAccountInfo {
|
|||||||
display_name: string;
|
display_name: string;
|
||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
health: "live" | "expired" | "captcha" | "risk";
|
health: "live" | "expired" | "captcha" | "risk";
|
||||||
|
runtime_health?: "live" | "expired" | "captcha" | "risk" | null;
|
||||||
|
runtime_verified_at?: string | null;
|
||||||
|
runtime_checked_at?: string | null;
|
||||||
|
runtime_auth_state?: "unknown" | "active" | "expiring_soon" | "challenge_required" | "expired" | "revoked" | null;
|
||||||
|
runtime_probe_state?: "queued" | "idle" | "probing" | "network_error" | null;
|
||||||
|
runtime_auth_reason?: string | null;
|
||||||
|
health_source?: "database" | "runtime";
|
||||||
client_id: string | null;
|
client_id: string | null;
|
||||||
account_fingerprint: string | null;
|
account_fingerprint: string | null;
|
||||||
verified_at: string | null;
|
verified_at: string | null;
|
||||||
@@ -158,6 +165,29 @@ export interface PatchDesktopAccountRequest {
|
|||||||
if_sync_version: number;
|
if_sync_version: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DesktopAccountHealthReport {
|
||||||
|
account_id: string;
|
||||||
|
platform: string;
|
||||||
|
platform_uid: string;
|
||||||
|
health: "live" | "expired" | "captcha" | "risk";
|
||||||
|
auth_state: "unknown" | "active" | "expiring_soon" | "challenge_required" | "expired" | "revoked";
|
||||||
|
probe_state: "queued" | "idle" | "probing" | "network_error";
|
||||||
|
auth_reason?: string | null;
|
||||||
|
display_name?: string | null;
|
||||||
|
avatar_url?: string | null;
|
||||||
|
verified_at?: string | null;
|
||||||
|
checked_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportDesktopAccountHealthRequest {
|
||||||
|
reports: DesktopAccountHealthReport[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportDesktopAccountHealthResponse {
|
||||||
|
accepted_count: number;
|
||||||
|
buffered_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DesktopTaskInfo {
|
export interface DesktopTaskInfo {
|
||||||
id: string;
|
id: string;
|
||||||
job_id: string;
|
job_id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user