fix: address CodeRabbit review for per-site probe

- Fix SSE abort: propagate AbortSignal to worker pool; close connection
  immediately on client disconnect without sending 'complete' event
- Fix Boolean coercion: use strict === true/=== 1 check instead of Boolean()
  to avoid Boolean('false') === true foot-gun
- Fix probe-now: parse and forward latencyThresholdMs from request body
- Fix api.ts probeSiteNow: add latencyThresholdMs to options type
- Fix backup import: restore postRefreshProbe* fields when inserting sites
  so that backup/restore round-trips preserve per-site probe config
- Fix latency threshold persistence: add postRefreshProbeLatencyThresholdMs
  column (migration 0026), save/load it in site editor, pass to
  runPostRefreshProbeIfEnabled so auto-probe also respects the threshold
- Fix state management: setProbeCompleted(false) at probe start; error
  branch also refreshes model lists and sets probeCompleted to show results
- Regenerate schema artifacts (mysql/postgres bootstrap+upgrade, contract)
This commit is contained in:
Xiang Li
2026-04-23 23:08:50 +08:00
parent 37924751f6
commit cb5078468c
13 changed files with 68 additions and 17 deletions
@@ -0,0 +1 @@
ALTER TABLE `sites` ADD `post_refresh_probe_latency_threshold_ms` integer DEFAULT 0;
+7
View File
@@ -176,6 +176,13 @@
"when": 1776943800000, "when": 1776943800000,
"tag": "0025_site_post_refresh_probe", "tag": "0025_site_post_refresh_probe",
"breakpoints": true "breakpoints": true
},
{
"idx": 25,
"version": "6",
"when": 1776944000000,
"tag": "0026_site_probe_latency_threshold",
"breakpoints": true
} }
] ]
} }
+1 -1
View File
@@ -1,4 +1,4 @@
CREATE TABLE IF NOT EXISTS `sites` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `platform` TEXT NOT NULL, `status` VARCHAR(191) NOT NULL DEFAULT 'active', `api_key` TEXT, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `is_pinned` BOOLEAN DEFAULT false, `sort_order` INT DEFAULT 0, `proxy_url` TEXT, `use_system_proxy` BOOLEAN DEFAULT false, `custom_headers` JSON, `external_checkin_url` TEXT, `global_weight` DOUBLE DEFAULT 1, `post_refresh_probe_enabled` BOOLEAN DEFAULT false, `post_refresh_probe_model` VARCHAR(191) DEFAULT '', `post_refresh_probe_scope` VARCHAR(191) DEFAULT 'single'); CREATE TABLE IF NOT EXISTS `sites` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `platform` TEXT NOT NULL, `status` VARCHAR(191) NOT NULL DEFAULT 'active', `api_key` TEXT, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `is_pinned` BOOLEAN DEFAULT false, `sort_order` INT DEFAULT 0, `proxy_url` TEXT, `use_system_proxy` BOOLEAN DEFAULT false, `custom_headers` JSON, `external_checkin_url` TEXT, `global_weight` DOUBLE DEFAULT 1, `post_refresh_probe_enabled` BOOLEAN DEFAULT false, `post_refresh_probe_model` VARCHAR(191) DEFAULT '', `post_refresh_probe_scope` VARCHAR(191) DEFAULT 'single', `post_refresh_probe_latency_threshold_ms` INT DEFAULT 0);
CREATE TABLE IF NOT EXISTS `accounts` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `username` TEXT, `access_token` TEXT NOT NULL, `api_token` TEXT, `balance` DOUBLE DEFAULT 0, `balance_used` DOUBLE DEFAULT 0, `quota` DOUBLE DEFAULT 0, `unit_cost` DOUBLE, `value_score` DOUBLE DEFAULT 0, `status` VARCHAR(191) DEFAULT 'active', `checkin_enabled` BOOLEAN DEFAULT true, `last_checkin_at` VARCHAR(191), `last_balance_refresh` VARCHAR(191), `extra_config` JSON, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `is_pinned` BOOLEAN DEFAULT false, `sort_order` INT DEFAULT 0, `oauth_provider` TEXT, `oauth_account_key` TEXT, `oauth_project_id` TEXT, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS `accounts` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `username` TEXT, `access_token` TEXT NOT NULL, `api_token` TEXT, `balance` DOUBLE DEFAULT 0, `balance_used` DOUBLE DEFAULT 0, `quota` DOUBLE DEFAULT 0, `unit_cost` DOUBLE, `value_score` DOUBLE DEFAULT 0, `status` VARCHAR(191) DEFAULT 'active', `checkin_enabled` BOOLEAN DEFAULT true, `last_checkin_at` VARCHAR(191), `last_balance_refresh` VARCHAR(191), `extra_config` JSON, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `is_pinned` BOOLEAN DEFAULT false, `sort_order` INT DEFAULT 0, `oauth_provider` TEXT, `oauth_account_key` TEXT, `oauth_project_id` TEXT, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS `account_tokens` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `name` TEXT NOT NULL, `token` TEXT NOT NULL, `source` VARCHAR(191) DEFAULT 'manual', `enabled` BOOLEAN DEFAULT true, `is_default` BOOLEAN DEFAULT false, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `token_group` TEXT, `value_status` VARCHAR(191) NOT NULL DEFAULT 'ready', FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS `account_tokens` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `name` TEXT NOT NULL, `token` TEXT NOT NULL, `source` VARCHAR(191) DEFAULT 'manual', `enabled` BOOLEAN DEFAULT true, `is_default` BOOLEAN DEFAULT false, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `token_group` TEXT, `value_status` VARCHAR(191) NOT NULL DEFAULT 'ready', FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS `admin_snapshots` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `namespace` TEXT NOT NULL, `snapshot_key` TEXT NOT NULL, `payload` TEXT NOT NULL, `generated_at` VARCHAR(191) NOT NULL, `expires_at` VARCHAR(191) NOT NULL, `stale_until` VARCHAR(191) NOT NULL, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s'))); CREATE TABLE IF NOT EXISTS `admin_snapshots` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `namespace` TEXT NOT NULL, `snapshot_key` TEXT NOT NULL, `payload` TEXT NOT NULL, `generated_at` VARCHAR(191) NOT NULL, `expires_at` VARCHAR(191) NOT NULL, `stale_until` VARCHAR(191) NOT NULL, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')));
+1 -3
View File
@@ -1,3 +1 @@
ALTER TABLE `sites` ADD COLUMN `post_refresh_probe_enabled` BOOLEAN DEFAULT false; ALTER TABLE `sites` ADD COLUMN `post_refresh_probe_latency_threshold_ms` INT DEFAULT 0;
ALTER TABLE `sites` ADD COLUMN `post_refresh_probe_model` VARCHAR(191) DEFAULT '';
ALTER TABLE `sites` ADD COLUMN `post_refresh_probe_scope` VARCHAR(191) DEFAULT 'single';
@@ -1,4 +1,4 @@
CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "platform" TEXT NOT NULL, "status" TEXT NOT NULL DEFAULT 'active', "api_key" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "is_pinned" BOOLEAN DEFAULT false, "sort_order" INTEGER DEFAULT 0, "proxy_url" TEXT, "use_system_proxy" BOOLEAN DEFAULT false, "custom_headers" JSONB, "external_checkin_url" TEXT, "global_weight" DOUBLE PRECISION DEFAULT 1, "post_refresh_probe_enabled" BOOLEAN DEFAULT false, "post_refresh_probe_model" TEXT DEFAULT '', "post_refresh_probe_scope" TEXT DEFAULT 'single'); CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "platform" TEXT NOT NULL, "status" TEXT NOT NULL DEFAULT 'active', "api_key" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "is_pinned" BOOLEAN DEFAULT false, "sort_order" INTEGER DEFAULT 0, "proxy_url" TEXT, "use_system_proxy" BOOLEAN DEFAULT false, "custom_headers" JSONB, "external_checkin_url" TEXT, "global_weight" DOUBLE PRECISION DEFAULT 1, "post_refresh_probe_enabled" BOOLEAN DEFAULT false, "post_refresh_probe_model" TEXT DEFAULT '', "post_refresh_probe_scope" TEXT DEFAULT 'single', "post_refresh_probe_latency_threshold_ms" INTEGER DEFAULT 0);
CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" DOUBLE PRECISION DEFAULT 0, "balance_used" DOUBLE PRECISION DEFAULT 0, "quota" DOUBLE PRECISION DEFAULT 0, "unit_cost" DOUBLE PRECISION, "value_score" DOUBLE PRECISION DEFAULT 0, "status" TEXT DEFAULT 'active', "checkin_enabled" BOOLEAN DEFAULT true, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" JSONB, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "is_pinned" BOOLEAN DEFAULT false, "sort_order" INTEGER DEFAULT 0, "oauth_provider" TEXT, "oauth_account_key" TEXT, "oauth_project_id" TEXT, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" DOUBLE PRECISION DEFAULT 0, "balance_used" DOUBLE PRECISION DEFAULT 0, "quota" DOUBLE PRECISION DEFAULT 0, "unit_cost" DOUBLE PRECISION, "value_score" DOUBLE PRECISION DEFAULT 0, "status" TEXT DEFAULT 'active', "checkin_enabled" BOOLEAN DEFAULT true, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" JSONB, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "is_pinned" BOOLEAN DEFAULT false, "sort_order" INTEGER DEFAULT 0, "oauth_provider" TEXT, "oauth_account_key" TEXT, "oauth_project_id" TEXT, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "source" TEXT DEFAULT 'manual', "enabled" BOOLEAN DEFAULT true, "is_default" BOOLEAN DEFAULT false, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "token_group" TEXT, "value_status" TEXT NOT NULL DEFAULT 'ready', FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "source" TEXT DEFAULT 'manual', "enabled" BOOLEAN DEFAULT true, "is_default" BOOLEAN DEFAULT false, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "token_group" TEXT, "value_status" TEXT NOT NULL DEFAULT 'ready', FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS "admin_snapshots" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "namespace" TEXT NOT NULL, "snapshot_key" TEXT NOT NULL, "payload" TEXT NOT NULL, "generated_at" TEXT NOT NULL, "expires_at" TEXT NOT NULL, "stale_until" TEXT NOT NULL, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS')); CREATE TABLE IF NOT EXISTS "admin_snapshots" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "namespace" TEXT NOT NULL, "snapshot_key" TEXT NOT NULL, "payload" TEXT NOT NULL, "generated_at" TEXT NOT NULL, "expires_at" TEXT NOT NULL, "stale_until" TEXT NOT NULL, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'));
+1 -3
View File
@@ -1,3 +1 @@
ALTER TABLE "sites" ADD COLUMN "post_refresh_probe_enabled" BOOLEAN DEFAULT false; ALTER TABLE "sites" ADD COLUMN "post_refresh_probe_latency_threshold_ms" INTEGER DEFAULT 0;
ALTER TABLE "sites" ADD COLUMN "post_refresh_probe_model" TEXT DEFAULT '';
ALTER TABLE "sites" ADD COLUMN "post_refresh_probe_scope" TEXT DEFAULT 'single';
@@ -2107,6 +2107,12 @@
"notNull": false, "notNull": false,
"defaultValue": "'single'", "defaultValue": "'single'",
"primaryKey": false "primaryKey": false
},
"post_refresh_probe_latency_threshold_ms": {
"logicalType": "integer",
"notNull": false,
"defaultValue": "0",
"primaryKey": false
} }
} }
}, },
+1
View File
@@ -18,6 +18,7 @@ export const sites = sqliteTable('sites', {
postRefreshProbeEnabled: integer('post_refresh_probe_enabled', { mode: 'boolean' }).default(false), postRefreshProbeEnabled: integer('post_refresh_probe_enabled', { mode: 'boolean' }).default(false),
postRefreshProbeModel: text('post_refresh_probe_model').default(''), postRefreshProbeModel: text('post_refresh_probe_model').default(''),
postRefreshProbeScope: text('post_refresh_probe_scope').default('single'), postRefreshProbeScope: text('post_refresh_probe_scope').default('single'),
postRefreshProbeLatencyThresholdMs: integer('post_refresh_probe_latency_threshold_ms').default(0),
createdAt: text('created_at').default(sql`(datetime('now'))`), createdAt: text('created_at').default(sql`(datetime('now'))`),
updatedAt: text('updated_at').default(sql`(datetime('now'))`), updatedAt: text('updated_at').default(sql`(datetime('now'))`),
}, (table) => ({ }, (table) => ({
+15 -3
View File
@@ -689,9 +689,13 @@ export async function sitesRoutes(app: FastifyInstance) {
if (body.sortOrder !== undefined) updates.sortOrder = normalizedSortOrder; if (body.sortOrder !== undefined) updates.sortOrder = normalizedSortOrder;
if (body.globalWeight !== undefined) updates.globalWeight = normalizedGlobalWeight; if (body.globalWeight !== undefined) updates.globalWeight = normalizedGlobalWeight;
const anyBody = body as Record<string, unknown>; const anyBody = body as Record<string, unknown>;
if (anyBody.postRefreshProbeEnabled !== undefined) updates.postRefreshProbeEnabled = Boolean(anyBody.postRefreshProbeEnabled); if (anyBody.postRefreshProbeEnabled !== undefined) updates.postRefreshProbeEnabled = anyBody.postRefreshProbeEnabled === true || anyBody.postRefreshProbeEnabled === 1;
if (anyBody.postRefreshProbeModel !== undefined) updates.postRefreshProbeModel = String(anyBody.postRefreshProbeModel || '').trim(); if (anyBody.postRefreshProbeModel !== undefined) updates.postRefreshProbeModel = String(anyBody.postRefreshProbeModel || '').trim();
if (anyBody.postRefreshProbeScope !== undefined) updates.postRefreshProbeScope = anyBody.postRefreshProbeScope === 'all' ? 'all' : 'single'; if (anyBody.postRefreshProbeScope !== undefined) updates.postRefreshProbeScope = anyBody.postRefreshProbeScope === 'all' ? 'all' : 'single';
if (anyBody.postRefreshProbeLatencyThresholdMs !== undefined) {
const ms = Number(anyBody.postRefreshProbeLatencyThresholdMs);
updates.postRefreshProbeLatencyThresholdMs = Number.isFinite(ms) && ms >= 0 ? Math.trunc(ms) : 0;
}
updates.updatedAt = new Date().toISOString(); updates.updatedAt = new Date().toISOString();
try { try {
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
@@ -905,7 +909,9 @@ export async function sitesRoutes(app: FastifyInstance) {
const body = request.body as Record<string, unknown> | null; const body = request.body as Record<string, unknown> | null;
const scope = body?.scope === 'all' ? 'all' : body?.scope === 'single' ? 'single' : undefined; const scope = body?.scope === 'all' ? 'all' : body?.scope === 'single' ? 'single' : undefined;
const modelName = typeof body?.modelName === 'string' ? body.modelName.trim() : undefined; const modelName = typeof body?.modelName === 'string' ? body.modelName.trim() : undefined;
const result = await probeSiteModels(id, { scope, modelName }); const parsedThresholdBody = Number(body?.latencyThresholdMs ?? 0);
const latencyThresholdMsBody = Number.isFinite(parsedThresholdBody) && parsedThresholdBody > 0 ? Math.trunc(parsedThresholdBody) : undefined;
const result = await probeSiteModels(id, { scope, modelName, latencyThresholdMs: latencyThresholdMsBody });
if (!result.success) { if (!result.success) {
return reply.code(422).send({ error: result.error }); return reply.code(422).send({ error: result.error });
} }
@@ -936,11 +942,17 @@ export async function sitesRoutes(app: FastifyInstance) {
const parsedThreshold = parseInt(q.latencyThresholdMs ?? '', 10); const parsedThreshold = parseInt(q.latencyThresholdMs ?? '', 10);
const latencyThresholdMs = Number.isFinite(parsedThreshold) && parsedThreshold > 0 ? parsedThreshold : undefined; const latencyThresholdMs = Number.isFinite(parsedThreshold) && parsedThreshold > 0 ? parsedThreshold : undefined;
// Propagate client disconnect to the probe worker pool
const probeAbort = new AbortController();
reply.raw.on('close', () => probeAbort.abort());
try { try {
const result = await probeSiteModels(id, { scope, modelName, latencyThresholdMs }, (ev) => { const result = await probeSiteModels(id, { scope, modelName, latencyThresholdMs, signal: probeAbort.signal }, (ev) => {
sseWrite(reply.raw, ev.type, ev); sseWrite(reply.raw, ev.type, ev);
}); });
if (!probeAbort.signal.aborted) {
sseWrite(reply.raw, 'complete', result); sseWrite(reply.raw, 'complete', result);
}
} catch (err: any) { } catch (err: any) {
sseWrite(reply.raw, 'error', { message: err?.message || '探测失败' }); sseWrite(reply.raw, 'error', { message: err?.message || '探测失败' });
} }
+6
View File
@@ -761,6 +761,7 @@ function buildAllApiHubV2AccountsSection(data: RawBackupData): {
postRefreshProbeEnabled: false, postRefreshProbeEnabled: false,
postRefreshProbeModel: '', postRefreshProbeModel: '',
postRefreshProbeScope: 'single', postRefreshProbeScope: 'single',
postRefreshProbeLatencyThresholdMs: 0,
createdAt: input.createdAt, createdAt: input.createdAt,
updatedAt: input.updatedAt, updatedAt: input.updatedAt,
}); });
@@ -1007,6 +1008,7 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
postRefreshProbeEnabled: false, postRefreshProbeEnabled: false,
postRefreshProbeModel: '', postRefreshProbeModel: '',
postRefreshProbeScope: 'single', postRefreshProbeScope: 'single',
postRefreshProbeLatencyThresholdMs: 0,
createdAt: toIsoString(item.created_at), createdAt: toIsoString(item.created_at),
updatedAt: toIsoString(item.updated_at), updatedAt: toIsoString(item.updated_at),
}); });
@@ -1552,6 +1554,10 @@ async function importAccountsSection(section: AccountsBackupSection): Promise<vo
sortOrder: row.sortOrder ?? 0, sortOrder: row.sortOrder ?? 0,
globalWeight: row.globalWeight ?? 1, globalWeight: row.globalWeight ?? 1,
apiKey: row.apiKey, apiKey: row.apiKey,
postRefreshProbeEnabled: row.postRefreshProbeEnabled ?? false,
postRefreshProbeModel: row.postRefreshProbeModel ?? '',
postRefreshProbeScope: (row.postRefreshProbeScope === 'all' ? 'all' : 'single') as 'single' | 'all',
postRefreshProbeLatencyThresholdMs: row.postRefreshProbeLatencyThresholdMs ?? 0,
createdAt: row.createdAt, createdAt: row.createdAt,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
}).run(); }).run();
+13 -3
View File
@@ -397,7 +397,7 @@ export type ProbeSiteModelsProgress =
export async function probeSiteModels( export async function probeSiteModels(
siteId: number, siteId: number,
options?: { scope?: 'single' | 'all'; modelName?: string; concurrency?: number; latencyThresholdMs?: number }, options?: { scope?: 'single' | 'all'; modelName?: string; concurrency?: number; latencyThresholdMs?: number; signal?: AbortSignal },
onProgress?: (event: ProbeSiteModelsProgress) => void, onProgress?: (event: ProbeSiteModelsProgress) => void,
): Promise<ProbeSiteModelsResult> { ): Promise<ProbeSiteModelsResult> {
const empty = (scope: 'single' | 'all', error: string): ProbeSiteModelsResult => const empty = (scope: 'single' | 'all', error: string): ProbeSiteModelsResult =>
@@ -440,11 +440,12 @@ export async function probeSiteModels(
// Probe models concurrently, limited by modelAvailabilityProbeConcurrency // Probe models concurrently, limited by modelAvailabilityProbeConcurrency
const concurrency = Math.max(1, options?.concurrency ?? 10); const concurrency = Math.max(1, options?.concurrency ?? 10);
const detailsMap = new Map<string, { modelName: string; status: RuntimeModelProbeStatus; latencyMs: number | null }>(); const detailsMap = new Map<string, { modelName: string; status: RuntimeModelProbeStatus; latencyMs: number | null; reason?: string }>();
let cursor = 0; let cursor = 0;
async function worker() { async function worker() {
while (cursor < modelsToProbe.length) { while (cursor < modelsToProbe.length) {
if (options?.signal?.aborted) break;
const modelName = modelsToProbe[cursor++]; const modelName = modelsToProbe[cursor++];
try { try {
const result = await probeRuntimeModel({ const result = await probeRuntimeModel({
@@ -530,6 +531,8 @@ async function runPostRefreshProbeIfEnabled(params: {
modelsToProbe = [found]; modelsToProbe = [found];
} }
// runPostRefreshProbeIfEnabled: apply latency threshold from site config
const threshold = params.site.postRefreshProbeLatencyThresholdMs ?? 0;
// Probe each model sequentially // Probe each model sequentially
const details: Array<{ modelName: string; status: RuntimeModelProbeStatus; latencyMs: number | null }> = []; const details: Array<{ modelName: string; status: RuntimeModelProbeStatus; latencyMs: number | null }> = [];
for (const modelName of modelsToProbe) { for (const modelName of modelsToProbe) {
@@ -540,7 +543,14 @@ async function runPostRefreshProbeIfEnabled(params: {
modelName, modelName,
timeoutMs: config.modelAvailabilityProbeTimeoutMs, timeoutMs: config.modelAvailabilityProbeTimeoutMs,
}); });
details.push({ modelName, status: result.status, latencyMs: result.latencyMs }); const latencyExceeded = (
result.status === 'supported'
&& threshold > 0
&& result.latencyMs != null
&& result.latencyMs > threshold
);
const effectiveStatus: RuntimeModelProbeStatus = latencyExceeded ? 'unsupported' : result.status;
details.push({ modelName, status: effectiveStatus, latencyMs: result.latencyMs });
} catch (err) { } catch (err) {
console.warn(`[post-refresh-probe] probe failed for account ${params.account.id} model ${modelName}`, err); console.warn(`[post-refresh-probe] probe failed for account ${params.account.id} model ${modelName}`, err);
details.push({ modelName, status: 'inconclusive', latencyMs: null }); details.push({ modelName, status: 'inconclusive', latencyMs: null });
+1 -1
View File
@@ -786,7 +786,7 @@ export const api = {
}), }),
getSiteAvailableModels: (siteId: number) => getSiteAvailableModels: (siteId: number) =>
request(`/api/sites/${siteId}/available-models`), request(`/api/sites/${siteId}/available-models`),
probeSiteNow: (siteId: number, options?: { scope?: 'single' | 'all'; modelName?: string }) => probeSiteNow: (siteId: number, options?: { scope?: 'single' | 'all'; modelName?: string; latencyThresholdMs?: number }) =>
request(`/api/sites/${siteId}/probe-now`, { method: 'POST', body: JSON.stringify(options || {}) }), request(`/api/sites/${siteId}/probe-now`, { method: 'POST', body: JSON.stringify(options || {}) }),
// Accounts // Accounts
+13 -1
View File
@@ -492,6 +492,7 @@ export default function Sites() {
setProbeEnabled(!!site.postRefreshProbeEnabled); setProbeEnabled(!!site.postRefreshProbeEnabled);
setProbeModel(typeof site.postRefreshProbeModel === 'string' ? site.postRefreshProbeModel : ''); setProbeModel(typeof site.postRefreshProbeModel === 'string' ? site.postRefreshProbeModel : '');
setProbeScope(site.postRefreshProbeScope === 'all' ? 'all' : 'single'); setProbeScope(site.postRefreshProbeScope === 'all' ? 'all' : 'single');
setProbeLatencyThreshold(String((site as any).postRefreshProbeLatencyThresholdMs ?? 0));
setProbeLog([]); setProbeLog([]);
setProbeCompleted(false); setProbeCompleted(false);
probeAbortRef.current?.abort(); probeAbortRef.current?.abort();
@@ -563,9 +564,10 @@ export default function Sites() {
postRefreshProbeEnabled: probeEnabled, postRefreshProbeEnabled: probeEnabled,
postRefreshProbeModel: probeModel.trim(), postRefreshProbeModel: probeModel.trim(),
postRefreshProbeScope: probeScope, postRefreshProbeScope: probeScope,
postRefreshProbeLatencyThresholdMs: Math.max(0, parseInt(probeLatencyThreshold, 10) || 0),
}); });
setSites((prev) => prev.map((s) => s.id === editor.editingSiteId setSites((prev) => prev.map((s) => s.id === editor.editingSiteId
? { ...s, postRefreshProbeEnabled: probeEnabled, postRefreshProbeModel: probeModel.trim(), postRefreshProbeScope: probeScope } ? { ...s, postRefreshProbeEnabled: probeEnabled, postRefreshProbeModel: probeModel.trim(), postRefreshProbeScope: probeScope, postRefreshProbeLatencyThresholdMs: Math.max(0, parseInt(probeLatencyThreshold, 10) || 0) }
: s, : s,
)); ));
toast.success('刷新后探测设置已保存'); toast.success('刷新后探测设置已保存');
@@ -588,6 +590,7 @@ export default function Sites() {
probeAbortRef.current = controller; probeAbortRef.current = controller;
setProbing(true); setProbing(true);
setProbeLog([]); setProbeLog([]);
setProbeCompleted(false);
try { try {
const token = getAuthToken(localStorage); const token = getAuthToken(localStorage);
@@ -666,6 +669,15 @@ export default function Sites() {
} else if (type === 'error') { } else if (type === 'error') {
addLog(d.message || '探测失败', 'var(--color-error, #ef4444)'); addLog(d.message || '探测失败', 'var(--color-error, #ef4444)');
toast.error(d.message || '探测失败'); toast.error(d.message || '探测失败');
// Refresh model state even on error
Promise.all([
api.getSiteAvailableModels(siteId).then((res: any) => {
setAvailableModels(Array.isArray(res?.models) ? res.models : []);
}),
api.getSiteDisabledModels(siteId).then((res: any) => {
setDisabledModels(Array.isArray(res?.models) ? res.models : []);
}),
]).catch(() => {}).finally(() => setProbeCompleted(true));
} }
} catch { /* ignore parse errors */ } } catch { /* ignore parse errors */ }
}; };