Merge pull request #59 from bnvnvnv/feat/round-robin-routing-ui-polish
feat: polish routing UI and add site header/log cleanup tooling
This commit is contained in:
@@ -49,6 +49,11 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
|
|||||||
accountCredentialSecret: env.ACCOUNT_CREDENTIAL_SECRET || env.AUTH_TOKEN || 'change-me-admin-token',
|
accountCredentialSecret: env.ACCOUNT_CREDENTIAL_SECRET || env.AUTH_TOKEN || 'change-me-admin-token',
|
||||||
checkinCron: env.CHECKIN_CRON || '0 8 * * *',
|
checkinCron: env.CHECKIN_CRON || '0 8 * * *',
|
||||||
balanceRefreshCron: env.BALANCE_REFRESH_CRON || '0 * * * *',
|
balanceRefreshCron: env.BALANCE_REFRESH_CRON || '0 * * * *',
|
||||||
|
logCleanupCron: env.LOG_CLEANUP_CRON || '0 6 * * *',
|
||||||
|
logCleanupConfigured: false,
|
||||||
|
logCleanupUsageLogsEnabled: parseBoolean(env.LOG_CLEANUP_USAGE_LOGS_ENABLED, false),
|
||||||
|
logCleanupProgramLogsEnabled: parseBoolean(env.LOG_CLEANUP_PROGRAM_LOGS_ENABLED, false),
|
||||||
|
logCleanupRetentionDays: Math.max(1, Math.trunc(parseNumber(env.LOG_CLEANUP_RETENTION_DAYS, 30))),
|
||||||
webhookUrl: env.WEBHOOK_URL || '',
|
webhookUrl: env.WEBHOOK_URL || '',
|
||||||
barkUrl: env.BARK_URL || '',
|
barkUrl: env.BARK_URL || '',
|
||||||
webhookEnabled: parseBoolean(env.WEBHOOK_ENABLED, true),
|
webhookEnabled: parseBoolean(env.WEBHOOK_ENABLED, true),
|
||||||
|
|||||||
+14
-2
@@ -270,6 +270,17 @@ function ensureSiteUseSystemProxySchema() {
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureSiteCustomHeadersSchema() {
|
||||||
|
const sqlite = requireSqliteConnection();
|
||||||
|
if (!tableExists('sites')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tableColumnExists('sites', 'custom_headers')) {
|
||||||
|
sqlite.exec(`ALTER TABLE sites ADD COLUMN custom_headers text;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function ensureSiteExternalCheckinUrlSchema() {
|
function ensureSiteExternalCheckinUrlSchema() {
|
||||||
const sqlite = requireSqliteConnection();
|
const sqlite = requireSqliteConnection();
|
||||||
if (!tableExists('sites')) {
|
if (!tableExists('sites')) {
|
||||||
@@ -428,11 +439,11 @@ function ensureRouteGroupingSchema() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!tableColumnExists('route_channels', 'consecutive_fail_count')) {
|
if (!tableColumnExists('route_channels', 'consecutive_fail_count')) {
|
||||||
sqlite.exec(`ALTER TABLE route_channels ADD COLUMN consecutive_fail_count integer DEFAULT 0;`);
|
sqlite.exec(`ALTER TABLE route_channels ADD COLUMN consecutive_fail_count integer NOT NULL DEFAULT 0;`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tableColumnExists('route_channels', 'cooldown_level')) {
|
if (!tableColumnExists('route_channels', 'cooldown_level')) {
|
||||||
sqlite.exec(`ALTER TABLE route_channels ADD COLUMN cooldown_level integer DEFAULT 0;`);
|
sqlite.exec(`ALTER TABLE route_channels ADD COLUMN cooldown_level integer NOT NULL DEFAULT 0;`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -818,6 +829,7 @@ function initSqliteDb() {
|
|||||||
ensureSiteStatusSchema();
|
ensureSiteStatusSchema();
|
||||||
ensureSiteProxySchema();
|
ensureSiteProxySchema();
|
||||||
ensureSiteUseSystemProxySchema();
|
ensureSiteUseSystemProxySchema();
|
||||||
|
ensureSiteCustomHeadersSchema();
|
||||||
ensureSiteExternalCheckinUrlSchema();
|
ensureSiteExternalCheckinUrlSchema();
|
||||||
ensureSiteGlobalWeightSchema();
|
ensureSiteGlobalWeightSchema();
|
||||||
ensureRouteGroupingSchema();
|
ensureRouteGroupingSchema();
|
||||||
|
|||||||
@@ -37,6 +37,20 @@ function createInspector(
|
|||||||
|
|
||||||
describe('ensureRouteGroupingSchemaCompatibility', () => {
|
describe('ensureRouteGroupingSchemaCompatibility', () => {
|
||||||
it.each([
|
it.each([
|
||||||
|
{
|
||||||
|
dialect: 'sqlite' as const,
|
||||||
|
expectedSql: [
|
||||||
|
'ALTER TABLE token_routes ADD COLUMN display_name text;',
|
||||||
|
'ALTER TABLE token_routes ADD COLUMN display_icon text;',
|
||||||
|
'ALTER TABLE token_routes ADD COLUMN decision_snapshot text;',
|
||||||
|
'ALTER TABLE token_routes ADD COLUMN decision_refreshed_at text;',
|
||||||
|
'ALTER TABLE token_routes ADD COLUMN routing_strategy text DEFAULT \'weighted\';',
|
||||||
|
'ALTER TABLE route_channels ADD COLUMN source_model text;',
|
||||||
|
'ALTER TABLE route_channels ADD COLUMN last_selected_at text;',
|
||||||
|
'ALTER TABLE route_channels ADD COLUMN consecutive_fail_count integer NOT NULL DEFAULT 0;',
|
||||||
|
'ALTER TABLE route_channels ADD COLUMN cooldown_level integer NOT NULL DEFAULT 0;',
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
dialect: 'postgres' as const,
|
dialect: 'postgres' as const,
|
||||||
expectedSql: [
|
expectedSql: [
|
||||||
@@ -47,8 +61,8 @@ describe('ensureRouteGroupingSchemaCompatibility', () => {
|
|||||||
'ALTER TABLE "token_routes" ADD COLUMN "routing_strategy" TEXT DEFAULT \'weighted\'',
|
'ALTER TABLE "token_routes" ADD COLUMN "routing_strategy" TEXT DEFAULT \'weighted\'',
|
||||||
'ALTER TABLE "route_channels" ADD COLUMN "source_model" TEXT',
|
'ALTER TABLE "route_channels" ADD COLUMN "source_model" TEXT',
|
||||||
'ALTER TABLE "route_channels" ADD COLUMN "last_selected_at" TEXT',
|
'ALTER TABLE "route_channels" ADD COLUMN "last_selected_at" TEXT',
|
||||||
'ALTER TABLE "route_channels" ADD COLUMN "consecutive_fail_count" INTEGER DEFAULT 0',
|
'ALTER TABLE "route_channels" ADD COLUMN "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0',
|
||||||
'ALTER TABLE "route_channels" ADD COLUMN "cooldown_level" INTEGER DEFAULT 0',
|
'ALTER TABLE "route_channels" ADD COLUMN "cooldown_level" INTEGER NOT NULL DEFAULT 0',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -81,18 +81,18 @@ const ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS: RouteGroupingColumnCompatibilit
|
|||||||
table: 'route_channels',
|
table: 'route_channels',
|
||||||
column: 'consecutive_fail_count',
|
column: 'consecutive_fail_count',
|
||||||
addSql: {
|
addSql: {
|
||||||
sqlite: 'ALTER TABLE route_channels ADD COLUMN consecutive_fail_count integer DEFAULT 0;',
|
sqlite: 'ALTER TABLE route_channels ADD COLUMN consecutive_fail_count integer NOT NULL DEFAULT 0;',
|
||||||
mysql: 'ALTER TABLE `route_channels` ADD COLUMN `consecutive_fail_count` INT NOT NULL DEFAULT 0',
|
mysql: 'ALTER TABLE `route_channels` ADD COLUMN `consecutive_fail_count` INT NOT NULL DEFAULT 0',
|
||||||
postgres: 'ALTER TABLE "route_channels" ADD COLUMN "consecutive_fail_count" INTEGER DEFAULT 0',
|
postgres: 'ALTER TABLE "route_channels" ADD COLUMN "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
table: 'route_channels',
|
table: 'route_channels',
|
||||||
column: 'cooldown_level',
|
column: 'cooldown_level',
|
||||||
addSql: {
|
addSql: {
|
||||||
sqlite: 'ALTER TABLE route_channels ADD COLUMN cooldown_level integer DEFAULT 0;',
|
sqlite: 'ALTER TABLE route_channels ADD COLUMN cooldown_level integer NOT NULL DEFAULT 0;',
|
||||||
mysql: 'ALTER TABLE `route_channels` ADD COLUMN `cooldown_level` INT NOT NULL DEFAULT 0',
|
mysql: 'ALTER TABLE `route_channels` ADD COLUMN `cooldown_level` INT NOT NULL DEFAULT 0',
|
||||||
postgres: 'ALTER TABLE "route_channels" ADD COLUMN "cooldown_level" INTEGER DEFAULT 0',
|
postgres: 'ALTER TABLE "route_channels" ADD COLUMN "cooldown_level" INTEGER NOT NULL DEFAULT 0',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -186,14 +186,14 @@ export async function createRuntimeSchemaClient(input: RuntimeSchemaConnectionIn
|
|||||||
export async function ensureRuntimeDatabaseSchema(client: RuntimeSchemaClient): Promise<void> {
|
export async function ensureRuntimeDatabaseSchema(client: RuntimeSchemaClient): Promise<void> {
|
||||||
const statements = client.dialect === 'postgres'
|
const statements = client.dialect === 'postgres'
|
||||||
? [
|
? [
|
||||||
`CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "external_checkin_url" TEXT, "platform" TEXT NOT NULL, "proxy_url" TEXT, "use_system_proxy" BOOLEAN DEFAULT FALSE, "status" TEXT DEFAULT 'active', "is_pinned" BOOLEAN DEFAULT FALSE, "sort_order" INTEGER DEFAULT 0, "global_weight" DOUBLE PRECISION DEFAULT 1, "api_key" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "external_checkin_url" TEXT, "platform" TEXT NOT NULL, "proxy_url" TEXT, "use_system_proxy" BOOLEAN DEFAULT FALSE, "custom_headers" TEXT, "status" TEXT DEFAULT 'active', "is_pinned" BOOLEAN DEFAULT FALSE, "sort_order" INTEGER DEFAULT 0, "global_weight" DOUBLE PRECISION DEFAULT 1, "api_key" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "site_id" INTEGER NOT NULL REFERENCES "sites"("id") ON DELETE CASCADE, "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', "is_pinned" BOOLEAN DEFAULT FALSE, "sort_order" INTEGER DEFAULT 0, "checkin_enabled" BOOLEAN DEFAULT TRUE, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "site_id" INTEGER NOT NULL REFERENCES "sites"("id") ON DELETE CASCADE, "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', "is_pinned" BOOLEAN DEFAULT FALSE, "sort_order" INTEGER DEFAULT 0, "checkin_enabled" BOOLEAN DEFAULT TRUE, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "token_group" TEXT, "source" TEXT DEFAULT 'manual', "enabled" BOOLEAN DEFAULT TRUE, "is_default" BOOLEAN DEFAULT FALSE, "created_at" TEXT, "updated_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "token_group" TEXT, "source" TEXT DEFAULT 'manual', "enabled" BOOLEAN DEFAULT TRUE, "is_default" BOOLEAN DEFAULT FALSE, "created_at" TEXT, "updated_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "routing_strategy" TEXT DEFAULT 'weighted', "enabled" BOOLEAN DEFAULT TRUE, "created_at" TEXT, "updated_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "routing_strategy" TEXT DEFAULT 'weighted', "enabled" BOOLEAN DEFAULT TRUE, "created_at" TEXT, "updated_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" BOOLEAN DEFAULT TRUE, "manual_override" BOOLEAN DEFAULT FALSE, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TEXT, "last_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER DEFAULT 0, "cooldown_level" INTEGER DEFAULT 0, "cooldown_until" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" BOOLEAN DEFAULT TRUE, "manual_override" BOOLEAN DEFAULT FALSE, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TEXT, "last_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0, "cooldown_level" INTEGER NOT NULL DEFAULT 0, "cooldown_until" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" DOUBLE PRECISION, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" DOUBLE PRECISION, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "public_id" TEXT NOT NULL UNIQUE, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "public_id" TEXT NOT NULL UNIQUE, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "public_id" TEXT NOT NULL UNIQUE, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "public_id" TEXT NOT NULL UNIQUE, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)`,
|
||||||
@@ -203,14 +203,14 @@ export async function ensureRuntimeDatabaseSchema(client: RuntimeSchemaClient):
|
|||||||
]
|
]
|
||||||
: client.dialect === 'mysql'
|
: client.dialect === 'mysql'
|
||||||
? [
|
? [
|
||||||
`CREATE TABLE IF NOT EXISTS \`sites\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`name\` TEXT NOT NULL, \`url\` TEXT NOT NULL, \`external_checkin_url\` TEXT NULL, \`platform\` VARCHAR(64) NOT NULL, \`proxy_url\` TEXT NULL, \`use_system_proxy\` BOOLEAN DEFAULT FALSE, \`status\` VARCHAR(32) DEFAULT 'active', \`is_pinned\` BOOLEAN DEFAULT FALSE, \`sort_order\` INT DEFAULT 0, \`global_weight\` DOUBLE DEFAULT 1, \`api_key\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
|
`CREATE TABLE IF NOT EXISTS \`sites\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`name\` TEXT NOT NULL, \`url\` TEXT NOT NULL, \`external_checkin_url\` TEXT NULL, \`platform\` VARCHAR(64) NOT NULL, \`proxy_url\` TEXT NULL, \`use_system_proxy\` BOOLEAN DEFAULT FALSE, \`custom_headers\` TEXT NULL, \`status\` VARCHAR(32) DEFAULT 'active', \`is_pinned\` BOOLEAN DEFAULT FALSE, \`sort_order\` INT DEFAULT 0, \`global_weight\` DOUBLE DEFAULT 1, \`api_key\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
|
||||||
`CREATE TABLE IF NOT EXISTS \`accounts\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`site_id\` INT NOT NULL, \`username\` TEXT NULL, \`access_token\` TEXT NOT NULL, \`api_token\` TEXT NULL, \`balance\` DOUBLE DEFAULT 0, \`balance_used\` DOUBLE DEFAULT 0, \`quota\` DOUBLE DEFAULT 0, \`unit_cost\` DOUBLE NULL, \`value_score\` DOUBLE DEFAULT 0, \`status\` VARCHAR(32) DEFAULT 'active', \`is_pinned\` BOOLEAN DEFAULT FALSE, \`sort_order\` INT DEFAULT 0, \`checkin_enabled\` BOOLEAN DEFAULT TRUE, \`last_checkin_at\` TEXT NULL, \`last_balance_refresh\` TEXT NULL, \`extra_config\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, CONSTRAINT \`accounts_site_fk\` FOREIGN KEY (\`site_id\`) REFERENCES \`sites\`(\`id\`) ON DELETE CASCADE)`,
|
`CREATE TABLE IF NOT EXISTS \`accounts\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`site_id\` INT NOT NULL, \`username\` TEXT NULL, \`access_token\` TEXT NOT NULL, \`api_token\` TEXT NULL, \`balance\` DOUBLE DEFAULT 0, \`balance_used\` DOUBLE DEFAULT 0, \`quota\` DOUBLE DEFAULT 0, \`unit_cost\` DOUBLE NULL, \`value_score\` DOUBLE DEFAULT 0, \`status\` VARCHAR(32) DEFAULT 'active', \`is_pinned\` BOOLEAN DEFAULT FALSE, \`sort_order\` INT DEFAULT 0, \`checkin_enabled\` BOOLEAN DEFAULT TRUE, \`last_checkin_at\` TEXT NULL, \`last_balance_refresh\` TEXT NULL, \`extra_config\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, CONSTRAINT \`accounts_site_fk\` FOREIGN KEY (\`site_id\`) REFERENCES \`sites\`(\`id\`) ON DELETE CASCADE)`,
|
||||||
`CREATE TABLE IF NOT EXISTS \`account_tokens\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`name\` TEXT NOT NULL, \`token\` TEXT NOT NULL, \`token_group\` TEXT NULL, \`source\` VARCHAR(32) DEFAULT 'manual', \`enabled\` BOOLEAN DEFAULT TRUE, \`is_default\` BOOLEAN DEFAULT FALSE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, CONSTRAINT \`account_tokens_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
|
`CREATE TABLE IF NOT EXISTS \`account_tokens\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`name\` TEXT NOT NULL, \`token\` TEXT NOT NULL, \`token_group\` TEXT NULL, \`source\` VARCHAR(32) DEFAULT 'manual', \`enabled\` BOOLEAN DEFAULT TRUE, \`is_default\` BOOLEAN DEFAULT FALSE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, CONSTRAINT \`account_tokens_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
|
||||||
`CREATE TABLE IF NOT EXISTS \`checkin_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`status\` VARCHAR(32) NOT NULL, \`message\` TEXT NULL, \`reward\` TEXT NULL, \`created_at\` TEXT NULL, CONSTRAINT \`checkin_logs_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
|
`CREATE TABLE IF NOT EXISTS \`checkin_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`status\` VARCHAR(32) NOT NULL, \`message\` TEXT NULL, \`reward\` TEXT NULL, \`created_at\` TEXT NULL, CONSTRAINT \`checkin_logs_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
|
||||||
`CREATE TABLE IF NOT EXISTS \`model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`model_name\` VARCHAR(191) NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`model_availability_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
|
`CREATE TABLE IF NOT EXISTS \`model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`account_id\` INT NOT NULL, \`model_name\` VARCHAR(191) NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`model_availability_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE)`,
|
||||||
`CREATE TABLE IF NOT EXISTS \`token_model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`token_id\` INT NOT NULL, \`model_name\` VARCHAR(191) NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`token_model_availability_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE CASCADE)`,
|
`CREATE TABLE IF NOT EXISTS \`token_model_availability\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`token_id\` INT NOT NULL, \`model_name\` VARCHAR(191) NOT NULL, \`available\` BOOLEAN NULL, \`latency_ms\` INT NULL, \`checked_at\` TEXT NULL, CONSTRAINT \`token_model_availability_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE CASCADE)`,
|
||||||
`CREATE TABLE IF NOT EXISTS \`token_routes\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`model_pattern\` TEXT NOT NULL, \`display_name\` TEXT NULL, \`display_icon\` TEXT NULL, \`model_mapping\` TEXT NULL, \`decision_snapshot\` TEXT NULL, \`decision_refreshed_at\` TEXT NULL, \`routing_strategy\` VARCHAR(32) NULL DEFAULT 'weighted', \`enabled\` BOOLEAN DEFAULT TRUE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
|
`CREATE TABLE IF NOT EXISTS \`token_routes\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`model_pattern\` TEXT NOT NULL, \`display_name\` TEXT NULL, \`display_icon\` TEXT NULL, \`model_mapping\` TEXT NULL, \`decision_snapshot\` TEXT NULL, \`decision_refreshed_at\` TEXT NULL, \`routing_strategy\` VARCHAR(32) NULL DEFAULT 'weighted', \`enabled\` BOOLEAN DEFAULT TRUE, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
|
||||||
`CREATE TABLE IF NOT EXISTS \`route_channels\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NOT NULL, \`account_id\` INT NOT NULL, \`token_id\` INT NULL, \`source_model\` TEXT NULL, \`priority\` INT DEFAULT 0, \`weight\` INT DEFAULT 10, \`enabled\` BOOLEAN DEFAULT TRUE, \`manual_override\` BOOLEAN DEFAULT FALSE, \`success_count\` INT DEFAULT 0, \`fail_count\` INT DEFAULT 0, \`total_latency_ms\` INT DEFAULT 0, \`total_cost\` DOUBLE DEFAULT 0, \`last_used_at\` TEXT NULL, \`last_selected_at\` TEXT NULL, \`last_fail_at\` TEXT NULL, \`consecutive_fail_count\` INT DEFAULT 0, \`cooldown_level\` INT DEFAULT 0, \`cooldown_until\` TEXT NULL, CONSTRAINT \`route_channels_route_fk\` FOREIGN KEY (\`route_id\`) REFERENCES \`token_routes\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE SET NULL)`,
|
`CREATE TABLE IF NOT EXISTS \`route_channels\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NOT NULL, \`account_id\` INT NOT NULL, \`token_id\` INT NULL, \`source_model\` TEXT NULL, \`priority\` INT DEFAULT 0, \`weight\` INT DEFAULT 10, \`enabled\` BOOLEAN DEFAULT TRUE, \`manual_override\` BOOLEAN DEFAULT FALSE, \`success_count\` INT DEFAULT 0, \`fail_count\` INT DEFAULT 0, \`total_latency_ms\` INT DEFAULT 0, \`total_cost\` DOUBLE DEFAULT 0, \`last_used_at\` TEXT NULL, \`last_selected_at\` TEXT NULL, \`last_fail_at\` TEXT NULL, \`consecutive_fail_count\` INT NOT NULL DEFAULT 0, \`cooldown_level\` INT NOT NULL DEFAULT 0, \`cooldown_until\` TEXT NULL, CONSTRAINT \`route_channels_route_fk\` FOREIGN KEY (\`route_id\`) REFERENCES \`token_routes\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_account_fk\` FOREIGN KEY (\`account_id\`) REFERENCES \`accounts\`(\`id\`) ON DELETE CASCADE, CONSTRAINT \`route_channels_token_fk\` FOREIGN KEY (\`token_id\`) REFERENCES \`account_tokens\`(\`id\`) ON DELETE SET NULL)`,
|
||||||
`CREATE TABLE IF NOT EXISTS \`proxy_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`model_requested\` TEXT NULL, \`model_actual\` TEXT NULL, \`status\` VARCHAR(32) NULL, \`http_status\` INT NULL, \`latency_ms\` INT NULL, \`prompt_tokens\` INT NULL, \`completion_tokens\` INT NULL, \`total_tokens\` INT NULL, \`estimated_cost\` DOUBLE NULL, \`billing_details\` TEXT NULL, \`error_message\` TEXT NULL, \`retry_count\` INT DEFAULT 0, \`created_at\` TEXT NULL)`,
|
`CREATE TABLE IF NOT EXISTS \`proxy_logs\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`route_id\` INT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`model_requested\` TEXT NULL, \`model_actual\` TEXT NULL, \`status\` VARCHAR(32) NULL, \`http_status\` INT NULL, \`latency_ms\` INT NULL, \`prompt_tokens\` INT NULL, \`completion_tokens\` INT NULL, \`total_tokens\` INT NULL, \`estimated_cost\` DOUBLE NULL, \`billing_details\` TEXT NULL, \`error_message\` TEXT NULL, \`retry_count\` INT DEFAULT 0, \`created_at\` TEXT NULL)`,
|
||||||
`CREATE TABLE IF NOT EXISTS \`proxy_video_tasks\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`public_id\` VARCHAR(191) NOT NULL UNIQUE, \`upstream_video_id\` TEXT NOT NULL, \`site_url\` TEXT NOT NULL, \`token_value\` TEXT NOT NULL, \`requested_model\` TEXT NULL, \`actual_model\` TEXT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`status_snapshot\` TEXT NULL, \`upstream_response_meta\` TEXT NULL, \`last_upstream_status\` INT NULL, \`last_polled_at\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
|
`CREATE TABLE IF NOT EXISTS \`proxy_video_tasks\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`public_id\` VARCHAR(191) NOT NULL UNIQUE, \`upstream_video_id\` TEXT NOT NULL, \`site_url\` TEXT NOT NULL, \`token_value\` TEXT NOT NULL, \`requested_model\` TEXT NULL, \`actual_model\` TEXT NULL, \`channel_id\` INT NULL, \`account_id\` INT NULL, \`status_snapshot\` TEXT NULL, \`upstream_response_meta\` TEXT NULL, \`last_upstream_status\` INT NULL, \`last_polled_at\` TEXT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL)`,
|
||||||
`CREATE TABLE IF NOT EXISTS \`proxy_files\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`public_id\` VARCHAR(191) NOT NULL UNIQUE, \`owner_type\` VARCHAR(64) NOT NULL, \`owner_id\` VARCHAR(191) NOT NULL, \`filename\` TEXT NOT NULL, \`mime_type\` VARCHAR(191) NOT NULL, \`purpose\` TEXT NULL, \`byte_size\` INT NOT NULL, \`sha256\` VARCHAR(191) NOT NULL, \`content_base64\` LONGTEXT NOT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, \`deleted_at\` TEXT NULL)`,
|
`CREATE TABLE IF NOT EXISTS \`proxy_files\` (\`id\` INT AUTO_INCREMENT PRIMARY KEY, \`public_id\` VARCHAR(191) NOT NULL UNIQUE, \`owner_type\` VARCHAR(64) NOT NULL, \`owner_id\` VARCHAR(191) NOT NULL, \`filename\` TEXT NOT NULL, \`mime_type\` VARCHAR(191) NOT NULL, \`purpose\` TEXT NULL, \`byte_size\` INT NOT NULL, \`sha256\` VARCHAR(191) NOT NULL, \`content_base64\` LONGTEXT NOT NULL, \`created_at\` TEXT NULL, \`updated_at\` TEXT NULL, \`deleted_at\` TEXT NULL)`,
|
||||||
@@ -219,14 +219,14 @@ export async function ensureRuntimeDatabaseSchema(client: RuntimeSchemaClient):
|
|||||||
`CREATE TABLE IF NOT EXISTS \`settings\` (\`key\` VARCHAR(191) PRIMARY KEY, \`value\` TEXT NULL)`,
|
`CREATE TABLE IF NOT EXISTS \`settings\` (\`key\` VARCHAR(191) PRIMARY KEY, \`value\` TEXT NULL)`,
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
`CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "external_checkin_url" TEXT, "platform" TEXT NOT NULL, "proxy_url" TEXT, "use_system_proxy" INTEGER DEFAULT 0, "status" TEXT DEFAULT 'active', "is_pinned" INTEGER DEFAULT 0, "sort_order" INTEGER DEFAULT 0, "global_weight" REAL DEFAULT 1, "api_key" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "external_checkin_url" TEXT, "platform" TEXT NOT NULL, "proxy_url" TEXT, "use_system_proxy" INTEGER DEFAULT 0, "custom_headers" TEXT, "status" TEXT DEFAULT 'active', "is_pinned" INTEGER DEFAULT 0, "sort_order" INTEGER DEFAULT 0, "global_weight" REAL DEFAULT 1, "api_key" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "site_id" INTEGER NOT NULL REFERENCES "sites"("id") ON DELETE CASCADE, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" REAL DEFAULT 0, "balance_used" REAL DEFAULT 0, "quota" REAL DEFAULT 0, "unit_cost" REAL, "value_score" REAL DEFAULT 0, "status" TEXT DEFAULT 'active', "is_pinned" INTEGER DEFAULT 0, "sort_order" INTEGER DEFAULT 0, "checkin_enabled" INTEGER DEFAULT 1, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "site_id" INTEGER NOT NULL REFERENCES "sites"("id") ON DELETE CASCADE, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" REAL DEFAULT 0, "balance_used" REAL DEFAULT 0, "quota" REAL DEFAULT 0, "unit_cost" REAL, "value_score" REAL DEFAULT 0, "status" TEXT DEFAULT 'active', "is_pinned" INTEGER DEFAULT 0, "sort_order" INTEGER DEFAULT 0, "checkin_enabled" INTEGER DEFAULT 1, "last_checkin_at" TEXT, "last_balance_refresh" TEXT, "extra_config" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "token_group" TEXT, "source" TEXT DEFAULT 'manual', "enabled" INTEGER DEFAULT 1, "is_default" INTEGER DEFAULT 0, "created_at" TEXT, "updated_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "token_group" TEXT, "source" TEXT DEFAULT 'manual', "enabled" INTEGER DEFAULT 1, "is_default" INTEGER DEFAULT 0, "created_at" TEXT, "updated_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "token_id" INTEGER NOT NULL REFERENCES "account_tokens"("id") ON DELETE CASCADE, "model_name" TEXT NOT NULL, "available" INTEGER, "latency_ms" INTEGER, "checked_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "routing_strategy" TEXT DEFAULT 'weighted', "enabled" INTEGER DEFAULT 1, "created_at" TEXT, "updated_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "model_pattern" TEXT NOT NULL, "display_name" TEXT, "display_icon" TEXT, "model_mapping" TEXT, "decision_snapshot" TEXT, "decision_refreshed_at" TEXT, "routing_strategy" TEXT DEFAULT 'weighted', "enabled" INTEGER DEFAULT 1, "created_at" TEXT, "updated_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" INTEGER DEFAULT 1, "manual_override" INTEGER DEFAULT 0, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" REAL DEFAULT 0, "last_used_at" TEXT, "last_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER DEFAULT 0, "cooldown_level" INTEGER DEFAULT 0, "cooldown_until" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER NOT NULL REFERENCES "token_routes"("id") ON DELETE CASCADE, "account_id" INTEGER NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, "token_id" INTEGER REFERENCES "account_tokens"("id") ON DELETE SET NULL, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" INTEGER DEFAULT 1, "manual_override" INTEGER DEFAULT 0, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" REAL DEFAULT 0, "last_used_at" TEXT, "last_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0, "cooldown_level" INTEGER NOT NULL DEFAULT 0, "cooldown_until" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" REAL, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" REAL, "billing_details" TEXT, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL UNIQUE, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL UNIQUE, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" TEXT, "upstream_response_meta" TEXT, "last_upstream_status" INTEGER, "last_polled_at" TEXT, "created_at" TEXT, "updated_at" TEXT)`,
|
||||||
`CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL UNIQUE, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)`,
|
`CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "public_id" TEXT NOT NULL UNIQUE, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TEXT, "updated_at" TEXT, "deleted_at" TEXT)`,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const sites = sqliteTable('sites', {
|
|||||||
platform: text('platform').notNull(), // 'new-api' | 'one-api' | 'veloera' | 'one-hub' | 'done-hub' | 'sub2api' | 'openai' | 'claude' | 'gemini'
|
platform: text('platform').notNull(), // 'new-api' | 'one-api' | 'veloera' | 'one-hub' | 'done-hub' | 'sub2api' | 'openai' | 'claude' | 'gemini'
|
||||||
proxyUrl: text('proxy_url'),
|
proxyUrl: text('proxy_url'),
|
||||||
useSystemProxy: integer('use_system_proxy', { mode: 'boolean' }).default(false),
|
useSystemProxy: integer('use_system_proxy', { mode: 'boolean' }).default(false),
|
||||||
|
customHeaders: text('custom_headers'),
|
||||||
status: text('status').notNull().default('active'), // 'active' | 'disabled'
|
status: text('status').notNull().default('active'), // 'active' | 'disabled'
|
||||||
isPinned: integer('is_pinned', { mode: 'boolean' }).default(false),
|
isPinned: integer('is_pinned', { mode: 'boolean' }).default(false),
|
||||||
sortOrder: integer('sort_order').default(0),
|
sortOrder: integer('sort_order').default(0),
|
||||||
@@ -137,8 +138,8 @@ export const routeChannels = sqliteTable('route_channels', {
|
|||||||
lastUsedAt: text('last_used_at'),
|
lastUsedAt: text('last_used_at'),
|
||||||
lastSelectedAt: text('last_selected_at'),
|
lastSelectedAt: text('last_selected_at'),
|
||||||
lastFailAt: text('last_fail_at'),
|
lastFailAt: text('last_fail_at'),
|
||||||
consecutiveFailCount: integer('consecutive_fail_count').default(0),
|
consecutiveFailCount: integer('consecutive_fail_count').notNull().default(0),
|
||||||
cooldownLevel: integer('cooldown_level').default(0),
|
cooldownLevel: integer('cooldown_level').notNull().default(0),
|
||||||
cooldownUntil: text('cooldown_until'),
|
cooldownUntil: text('cooldown_until'),
|
||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
routeIdIdx: index('route_channels_route_id_idx').on(table.routeId),
|
routeIdIdx: index('route_channels_route_id_idx').on(table.routeId),
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ describe('ensureSiteSchemaCompatibility', () => {
|
|||||||
'ALTER TABLE "sites" ADD COLUMN "proxy_url" TEXT',
|
'ALTER TABLE "sites" ADD COLUMN "proxy_url" TEXT',
|
||||||
'ALTER TABLE "sites" ADD COLUMN "use_system_proxy" BOOLEAN DEFAULT FALSE',
|
'ALTER TABLE "sites" ADD COLUMN "use_system_proxy" BOOLEAN DEFAULT FALSE',
|
||||||
'UPDATE "sites" SET "use_system_proxy" = FALSE WHERE "use_system_proxy" IS NULL',
|
'UPDATE "sites" SET "use_system_proxy" = FALSE WHERE "use_system_proxy" IS NULL',
|
||||||
|
'ALTER TABLE "sites" ADD COLUMN "custom_headers" TEXT',
|
||||||
'ALTER TABLE "sites" ADD COLUMN "external_checkin_url" TEXT',
|
'ALTER TABLE "sites" ADD COLUMN "external_checkin_url" TEXT',
|
||||||
'ALTER TABLE "sites" ADD COLUMN "global_weight" DOUBLE PRECISION DEFAULT 1',
|
'ALTER TABLE "sites" ADD COLUMN "global_weight" DOUBLE PRECISION DEFAULT 1',
|
||||||
'UPDATE "sites" SET "global_weight" = 1 WHERE "global_weight" IS NULL OR "global_weight" <= 0',
|
'UPDATE "sites" SET "global_weight" = 1 WHERE "global_weight" IS NULL OR "global_weight" <= 0',
|
||||||
@@ -47,6 +48,7 @@ describe('ensureSiteSchemaCompatibility', () => {
|
|||||||
'ALTER TABLE `sites` ADD COLUMN `proxy_url` TEXT NULL',
|
'ALTER TABLE `sites` ADD COLUMN `proxy_url` TEXT NULL',
|
||||||
'ALTER TABLE `sites` ADD COLUMN `use_system_proxy` BOOLEAN DEFAULT FALSE',
|
'ALTER TABLE `sites` ADD COLUMN `use_system_proxy` BOOLEAN DEFAULT FALSE',
|
||||||
'UPDATE `sites` SET `use_system_proxy` = FALSE WHERE `use_system_proxy` IS NULL',
|
'UPDATE `sites` SET `use_system_proxy` = FALSE WHERE `use_system_proxy` IS NULL',
|
||||||
|
'ALTER TABLE `sites` ADD COLUMN `custom_headers` TEXT NULL',
|
||||||
'ALTER TABLE `sites` ADD COLUMN `external_checkin_url` TEXT NULL',
|
'ALTER TABLE `sites` ADD COLUMN `external_checkin_url` TEXT NULL',
|
||||||
'ALTER TABLE `sites` ADD COLUMN `global_weight` DOUBLE DEFAULT 1',
|
'ALTER TABLE `sites` ADD COLUMN `global_weight` DOUBLE DEFAULT 1',
|
||||||
'UPDATE `sites` SET `global_weight` = 1 WHERE `global_weight` IS NULL OR `global_weight` <= 0',
|
'UPDATE `sites` SET `global_weight` = 1 WHERE `global_weight` IS NULL OR `global_weight` <= 0',
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ const SITE_COLUMN_COMPATIBILITY_SPECS: SiteColumnCompatibilitySpec[] = [
|
|||||||
postgres: 'UPDATE "sites" SET "use_system_proxy" = FALSE WHERE "use_system_proxy" IS NULL',
|
postgres: 'UPDATE "sites" SET "use_system_proxy" = FALSE WHERE "use_system_proxy" IS NULL',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
column: 'custom_headers',
|
||||||
|
addSql: {
|
||||||
|
sqlite: 'ALTER TABLE sites ADD COLUMN custom_headers text;',
|
||||||
|
mysql: 'ALTER TABLE `sites` ADD COLUMN `custom_headers` TEXT NULL',
|
||||||
|
postgres: 'ALTER TABLE "sites" ADD COLUMN "custom_headers" TEXT',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
column: 'external_checkin_url',
|
column: 'external_checkin_url',
|
||||||
addSql: {
|
addSql: {
|
||||||
|
|||||||
+38
-2
@@ -19,7 +19,7 @@ import { monitorRoutes } from './routes/api/monitor.js';
|
|||||||
import { downstreamApiKeysRoutes } from './routes/api/downstreamApiKeys.js';
|
import { downstreamApiKeysRoutes } from './routes/api/downstreamApiKeys.js';
|
||||||
import { proxyRoutes } from './routes/proxy/router.js';
|
import { proxyRoutes } from './routes/proxy/router.js';
|
||||||
import { startScheduler } from './services/checkinScheduler.js';
|
import { startScheduler } from './services/checkinScheduler.js';
|
||||||
import { startProxyLogRetentionService, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js';
|
import { setLegacyProxyLogRetentionFallbackEnabled, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js';
|
||||||
import { buildStartupSummaryLines } from './services/startupInfo.js';
|
import { buildStartupSummaryLines } from './services/startupInfo.js';
|
||||||
import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js';
|
import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js';
|
||||||
import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js';
|
import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js';
|
||||||
@@ -29,6 +29,7 @@ import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js';
|
|||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { dirname, normalize, resolve, sep } from 'path';
|
import { dirname, normalize, resolve, sep } from 'path';
|
||||||
|
import { normalizeLogCleanupRetentionDays } from './services/logCleanupService.js';
|
||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
ensureProxyFileCompatibilityColumns,
|
ensureProxyFileCompatibilityColumns,
|
||||||
@@ -104,6 +105,17 @@ function extractSavedRuntimeDatabaseConfig(settingsMap: Map<string, string>): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LOG_CLEANUP_SETTING_KEYS = [
|
||||||
|
'log_cleanup_cron',
|
||||||
|
'log_cleanup_usage_logs_enabled',
|
||||||
|
'log_cleanup_program_logs_enabled',
|
||||||
|
'log_cleanup_retention_days',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function hasExplicitLogCleanupSettings(settingsMap: Map<string, string>): boolean {
|
||||||
|
return LOG_CLEANUP_SETTING_KEYS.some((key) => settingsMap.has(key));
|
||||||
|
}
|
||||||
|
|
||||||
function applyRuntimeSettings(settingsMap: Map<string, string>) {
|
function applyRuntimeSettings(settingsMap: Map<string, string>) {
|
||||||
const authToken = parseSettingFromMap<string>(settingsMap, 'auth_token');
|
const authToken = parseSettingFromMap<string>(settingsMap, 'auth_token');
|
||||||
if (typeof authToken === 'string' && authToken) config.authToken = authToken;
|
if (typeof authToken === 'string' && authToken) config.authToken = authToken;
|
||||||
@@ -120,6 +132,24 @@ function applyRuntimeSettings(settingsMap: Map<string, string>) {
|
|||||||
const balanceRefreshCron = parseSettingFromMap<string>(settingsMap, 'balance_refresh_cron');
|
const balanceRefreshCron = parseSettingFromMap<string>(settingsMap, 'balance_refresh_cron');
|
||||||
if (typeof balanceRefreshCron === 'string' && balanceRefreshCron) config.balanceRefreshCron = balanceRefreshCron;
|
if (typeof balanceRefreshCron === 'string' && balanceRefreshCron) config.balanceRefreshCron = balanceRefreshCron;
|
||||||
|
|
||||||
|
const logCleanupCron = parseSettingFromMap<string>(settingsMap, 'log_cleanup_cron');
|
||||||
|
if (typeof logCleanupCron === 'string' && logCleanupCron) config.logCleanupCron = logCleanupCron;
|
||||||
|
|
||||||
|
const logCleanupUsageLogsEnabled = parseSettingFromMap<boolean>(settingsMap, 'log_cleanup_usage_logs_enabled');
|
||||||
|
if (typeof logCleanupUsageLogsEnabled === 'boolean') {
|
||||||
|
config.logCleanupUsageLogsEnabled = logCleanupUsageLogsEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
const logCleanupProgramLogsEnabled = parseSettingFromMap<boolean>(settingsMap, 'log_cleanup_program_logs_enabled');
|
||||||
|
if (typeof logCleanupProgramLogsEnabled === 'boolean') {
|
||||||
|
config.logCleanupProgramLogsEnabled = logCleanupProgramLogsEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
const logCleanupRetentionDays = parseSettingFromMap<number>(settingsMap, 'log_cleanup_retention_days');
|
||||||
|
if (typeof logCleanupRetentionDays === 'number' && Number.isFinite(logCleanupRetentionDays) && logCleanupRetentionDays >= 1) {
|
||||||
|
config.logCleanupRetentionDays = normalizeLogCleanupRetentionDays(logCleanupRetentionDays);
|
||||||
|
}
|
||||||
|
|
||||||
const routingWeights = parseSettingFromMap<Partial<typeof config.routingWeights>>(settingsMap, 'routing_weights');
|
const routingWeights = parseSettingFromMap<Partial<typeof config.routingWeights>>(settingsMap, 'routing_weights');
|
||||||
if (routingWeights && typeof routingWeights === 'object') {
|
if (routingWeights && typeof routingWeights === 'object') {
|
||||||
config.routingWeights = {
|
config.routingWeights = {
|
||||||
@@ -232,6 +262,12 @@ try {
|
|||||||
const finalRows = await db.select().from(schema.settings).all();
|
const finalRows = await db.select().from(schema.settings).all();
|
||||||
const finalMap = toSettingsMap(finalRows);
|
const finalMap = toSettingsMap(finalRows);
|
||||||
applyRuntimeSettings(finalMap);
|
applyRuntimeSettings(finalMap);
|
||||||
|
config.logCleanupConfigured = hasExplicitLogCleanupSettings(finalMap);
|
||||||
|
if (!config.logCleanupConfigured && config.proxyLogRetentionDays > 0) {
|
||||||
|
config.logCleanupUsageLogsEnabled = true;
|
||||||
|
config.logCleanupProgramLogsEnabled = false;
|
||||||
|
config.logCleanupRetentionDays = normalizeLogCleanupRetentionDays(config.proxyLogRetentionDays);
|
||||||
|
}
|
||||||
await ensureProxyLogBillingDetailsColumn();
|
await ensureProxyLogBillingDetailsColumn();
|
||||||
await repairStoredCreatedAtValues();
|
await repairStoredCreatedAtValues();
|
||||||
await migrateSiteApiKeysToAccounts();
|
await migrateSiteApiKeysToAccounts();
|
||||||
@@ -302,7 +338,7 @@ if (existsSync(webDir)) {
|
|||||||
|
|
||||||
// Start scheduler
|
// Start scheduler
|
||||||
await startScheduler();
|
await startScheduler();
|
||||||
startProxyLogRetentionService();
|
setLegacyProxyLogRetentionFallbackEnabled(!config.logCleanupConfigured);
|
||||||
app.addHook('onClose', async () => {
|
app.addHook('onClose', async () => {
|
||||||
stopProxyLogRetentionService();
|
stopProxyLogRetentionService();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ describe('settings and auth events', () => {
|
|||||||
config.systemProxyUrl = '';
|
config.systemProxyUrl = '';
|
||||||
config.checkinCron = '0 8 * * *';
|
config.checkinCron = '0 8 * * *';
|
||||||
config.balanceRefreshCron = '0 * * * *';
|
config.balanceRefreshCron = '0 * * * *';
|
||||||
|
config.logCleanupConfigured = false;
|
||||||
|
config.logCleanupCron = '0 6 * * *';
|
||||||
|
config.logCleanupUsageLogsEnabled = false;
|
||||||
|
config.logCleanupProgramLogsEnabled = false;
|
||||||
|
config.logCleanupRetentionDays = 30;
|
||||||
config.routingFallbackUnitCost = 1;
|
config.routingFallbackUnitCost = 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -219,6 +224,80 @@ describe('settings and auth events', () => {
|
|||||||
expect(runtime.systemProxyUrl).toBe('http://127.0.0.1:7890');
|
expect(runtime.systemProxyUrl).toBe('http://127.0.0.1:7890');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('persists and returns log cleanup settings from runtime settings', async () => {
|
||||||
|
const updateResponse = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/settings/runtime',
|
||||||
|
payload: {
|
||||||
|
logCleanupCron: '15 4 * * *',
|
||||||
|
logCleanupUsageLogsEnabled: true,
|
||||||
|
logCleanupProgramLogsEnabled: true,
|
||||||
|
logCleanupRetentionDays: 14,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updateResponse.statusCode).toBe(200);
|
||||||
|
const updated = updateResponse.json() as {
|
||||||
|
logCleanupCron?: string;
|
||||||
|
logCleanupUsageLogsEnabled?: boolean;
|
||||||
|
logCleanupProgramLogsEnabled?: boolean;
|
||||||
|
logCleanupRetentionDays?: number;
|
||||||
|
};
|
||||||
|
expect(updated.logCleanupCron).toBe('15 4 * * *');
|
||||||
|
expect(updated.logCleanupUsageLogsEnabled).toBe(true);
|
||||||
|
expect(updated.logCleanupProgramLogsEnabled).toBe(true);
|
||||||
|
expect(updated.logCleanupRetentionDays).toBe(14);
|
||||||
|
expect(config.logCleanupCron).toBe('15 4 * * *');
|
||||||
|
expect(config.logCleanupUsageLogsEnabled).toBe(true);
|
||||||
|
expect(config.logCleanupProgramLogsEnabled).toBe(true);
|
||||||
|
expect(config.logCleanupRetentionDays).toBe(14);
|
||||||
|
|
||||||
|
const rows = await db.select().from(schema.settings).all();
|
||||||
|
const settingsMap = new Map(rows.map((row) => [row.key, row.value]));
|
||||||
|
expect(settingsMap.get('log_cleanup_cron')).toBe(JSON.stringify('15 4 * * *'));
|
||||||
|
expect(settingsMap.get('log_cleanup_usage_logs_enabled')).toBe(JSON.stringify(true));
|
||||||
|
expect(settingsMap.get('log_cleanup_program_logs_enabled')).toBe(JSON.stringify(true));
|
||||||
|
expect(settingsMap.get('log_cleanup_retention_days')).toBe(JSON.stringify(14));
|
||||||
|
|
||||||
|
const getResponse = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/settings/runtime',
|
||||||
|
});
|
||||||
|
expect(getResponse.statusCode).toBe(200);
|
||||||
|
const runtime = getResponse.json() as {
|
||||||
|
logCleanupCron?: string;
|
||||||
|
logCleanupUsageLogsEnabled?: boolean;
|
||||||
|
logCleanupProgramLogsEnabled?: boolean;
|
||||||
|
logCleanupRetentionDays?: number;
|
||||||
|
};
|
||||||
|
expect(runtime.logCleanupCron).toBe('15 4 * * *');
|
||||||
|
expect(runtime.logCleanupUsageLogsEnabled).toBe(true);
|
||||||
|
expect(runtime.logCleanupProgramLogsEnabled).toBe(true);
|
||||||
|
expect(runtime.logCleanupRetentionDays).toBe(14);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid log cleanup cron and retention days', async () => {
|
||||||
|
const invalidCronResponse = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/settings/runtime',
|
||||||
|
payload: {
|
||||||
|
logCleanupCron: 'invalid cron',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(invalidCronResponse.statusCode).toBe(400);
|
||||||
|
expect((invalidCronResponse.json() as { message?: string }).message).toContain('日志清理 Cron');
|
||||||
|
|
||||||
|
const invalidRetentionResponse = await app.inject({
|
||||||
|
method: 'PUT',
|
||||||
|
url: '/api/settings/runtime',
|
||||||
|
payload: {
|
||||||
|
logCleanupRetentionDays: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(invalidRetentionResponse.statusCode).toBe(400);
|
||||||
|
expect((invalidRetentionResponse.json() as { message?: string }).message).toContain('保留天数');
|
||||||
|
});
|
||||||
|
|
||||||
it('invalidates cached site proxy resolution when system proxy url changes', async () => {
|
it('invalidates cached site proxy resolution when system proxy url changes', async () => {
|
||||||
await db.insert(schema.sites).values({
|
await db.insert(schema.sites).values({
|
||||||
name: 'proxy-site',
|
name: 'proxy-site',
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { config } from '../../config.js';
|
|||||||
import { db, runtimeDbDialect, schema } from '../../db/index.js';
|
import { db, runtimeDbDialect, schema } from '../../db/index.js';
|
||||||
import { upsertSetting } from '../../db/upsertSetting.js';
|
import { upsertSetting } from '../../db/upsertSetting.js';
|
||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||||
import { updateBalanceRefreshCron, updateCheckinCron } from '../../services/checkinScheduler.js';
|
import { updateBalanceRefreshCron, updateCheckinCron, updateLogCleanupSettings } from '../../services/checkinScheduler.js';
|
||||||
import { sendNotification } from '../../services/notifyService.js';
|
import { sendNotification } from '../../services/notifyService.js';
|
||||||
import { exportBackup, importBackup, type BackupExportType } from '../../services/backupService.js';
|
import { exportBackup, importBackup, type BackupExportType } from '../../services/backupService.js';
|
||||||
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
|
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
|
||||||
@@ -19,6 +19,8 @@ import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
|||||||
import { extractClientIp, isIpAllowed } from '../../middleware/auth.js';
|
import { extractClientIp, isIpAllowed } from '../../middleware/auth.js';
|
||||||
import { invalidateSiteProxyCache, normalizeSiteProxyUrl } from '../../services/siteProxy.js';
|
import { invalidateSiteProxyCache, normalizeSiteProxyUrl } from '../../services/siteProxy.js';
|
||||||
import { performFactoryReset } from '../../services/factoryResetService.js';
|
import { performFactoryReset } from '../../services/factoryResetService.js';
|
||||||
|
import { normalizeLogCleanupRetentionDays } from '../../services/logCleanupService.js';
|
||||||
|
import { stopProxyLogRetentionService } from '../../services/proxyLogRetentionService.js';
|
||||||
|
|
||||||
type RoutingWeights = typeof config.routingWeights;
|
type RoutingWeights = typeof config.routingWeights;
|
||||||
|
|
||||||
@@ -27,6 +29,10 @@ interface RuntimeSettingsBody {
|
|||||||
systemProxyUrl?: string;
|
systemProxyUrl?: string;
|
||||||
checkinCron?: string;
|
checkinCron?: string;
|
||||||
balanceRefreshCron?: string;
|
balanceRefreshCron?: string;
|
||||||
|
logCleanupCron?: string;
|
||||||
|
logCleanupUsageLogsEnabled?: boolean;
|
||||||
|
logCleanupProgramLogsEnabled?: boolean;
|
||||||
|
logCleanupRetentionDays?: number;
|
||||||
webhookUrl?: string;
|
webhookUrl?: string;
|
||||||
barkUrl?: string;
|
barkUrl?: string;
|
||||||
webhookEnabled?: boolean;
|
webhookEnabled?: boolean;
|
||||||
@@ -145,6 +151,35 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
|
|||||||
updateBalanceRefreshCron(value);
|
updateBalanceRefreshCron(value);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case 'log_cleanup_cron': {
|
||||||
|
if (typeof value !== 'string' || !value || !cron.validate(value)) return;
|
||||||
|
config.logCleanupConfigured = true;
|
||||||
|
updateLogCleanupSettings({ cronExpr: value });
|
||||||
|
stopProxyLogRetentionService();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'log_cleanup_usage_logs_enabled': {
|
||||||
|
if (typeof value !== 'boolean') return;
|
||||||
|
config.logCleanupConfigured = true;
|
||||||
|
updateLogCleanupSettings({ usageLogsEnabled: value });
|
||||||
|
stopProxyLogRetentionService();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'log_cleanup_program_logs_enabled': {
|
||||||
|
if (typeof value !== 'boolean') return;
|
||||||
|
config.logCleanupConfigured = true;
|
||||||
|
updateLogCleanupSettings({ programLogsEnabled: value });
|
||||||
|
stopProxyLogRetentionService();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'log_cleanup_retention_days': {
|
||||||
|
const retentionDays = Number(value);
|
||||||
|
if (!Number.isFinite(retentionDays) || retentionDays < 1) return;
|
||||||
|
config.logCleanupConfigured = true;
|
||||||
|
updateLogCleanupSettings({ retentionDays: Math.trunc(retentionDays) });
|
||||||
|
stopProxyLogRetentionService();
|
||||||
|
return;
|
||||||
|
}
|
||||||
case 'proxy_token': {
|
case 'proxy_token': {
|
||||||
if (typeof value !== 'string') return;
|
if (typeof value !== 'string') return;
|
||||||
const nextToken = value.trim();
|
const nextToken = value.trim();
|
||||||
@@ -274,6 +309,10 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
|
|||||||
return {
|
return {
|
||||||
checkinCron: config.checkinCron,
|
checkinCron: config.checkinCron,
|
||||||
balanceRefreshCron: config.balanceRefreshCron,
|
balanceRefreshCron: config.balanceRefreshCron,
|
||||||
|
logCleanupCron: config.logCleanupCron,
|
||||||
|
logCleanupUsageLogsEnabled: config.logCleanupUsageLogsEnabled,
|
||||||
|
logCleanupProgramLogsEnabled: config.logCleanupProgramLogsEnabled,
|
||||||
|
logCleanupRetentionDays: config.logCleanupRetentionDays,
|
||||||
routingFallbackUnitCost: config.routingFallbackUnitCost,
|
routingFallbackUnitCost: config.routingFallbackUnitCost,
|
||||||
routingWeights: config.routingWeights,
|
routingWeights: config.routingWeights,
|
||||||
webhookUrl: config.webhookUrl,
|
webhookUrl: config.webhookUrl,
|
||||||
@@ -458,6 +497,61 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
upsertSetting('balance_refresh_cron', body.balanceRefreshCron);
|
upsertSetting('balance_refresh_cron', body.balanceRefreshCron);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const logCleanupTouched =
|
||||||
|
body.logCleanupCron !== undefined
|
||||||
|
|| body.logCleanupUsageLogsEnabled !== undefined
|
||||||
|
|| body.logCleanupProgramLogsEnabled !== undefined
|
||||||
|
|| body.logCleanupRetentionDays !== undefined;
|
||||||
|
|
||||||
|
if (logCleanupTouched) {
|
||||||
|
const nextLogCleanupCron = body.logCleanupCron !== undefined
|
||||||
|
? String(body.logCleanupCron || '').trim()
|
||||||
|
: config.logCleanupCron;
|
||||||
|
if (!cron.validate(nextLogCleanupCron)) {
|
||||||
|
return reply.code(400).send({ success: false, message: '日志清理 Cron 表达式无效' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawRetentionDays = body.logCleanupRetentionDays !== undefined
|
||||||
|
? Number(body.logCleanupRetentionDays)
|
||||||
|
: config.logCleanupRetentionDays;
|
||||||
|
if (!Number.isFinite(rawRetentionDays) || rawRetentionDays < 1) {
|
||||||
|
return reply.code(400).send({ success: false, message: '日志清理保留天数必须是大于等于 1 的整数' });
|
||||||
|
}
|
||||||
|
const nextLogCleanupRetentionDays = normalizeLogCleanupRetentionDays(rawRetentionDays);
|
||||||
|
const nextUsageLogsEnabled = body.logCleanupUsageLogsEnabled !== undefined
|
||||||
|
? !!body.logCleanupUsageLogsEnabled
|
||||||
|
: config.logCleanupUsageLogsEnabled;
|
||||||
|
const nextProgramLogsEnabled = body.logCleanupProgramLogsEnabled !== undefined
|
||||||
|
? !!body.logCleanupProgramLogsEnabled
|
||||||
|
: config.logCleanupProgramLogsEnabled;
|
||||||
|
|
||||||
|
if (nextLogCleanupCron !== config.logCleanupCron) {
|
||||||
|
changedLabels.push(`日志清理 Cron(${config.logCleanupCron} -> ${nextLogCleanupCron})`);
|
||||||
|
}
|
||||||
|
if (nextUsageLogsEnabled !== config.logCleanupUsageLogsEnabled) {
|
||||||
|
changedLabels.push(`自动清理使用日志(${config.logCleanupUsageLogsEnabled ? '开启' : '关闭'} -> ${nextUsageLogsEnabled ? '开启' : '关闭'})`);
|
||||||
|
}
|
||||||
|
if (nextProgramLogsEnabled !== config.logCleanupProgramLogsEnabled) {
|
||||||
|
changedLabels.push(`自动清理程序日志(${config.logCleanupProgramLogsEnabled ? '开启' : '关闭'} -> ${nextProgramLogsEnabled ? '开启' : '关闭'})`);
|
||||||
|
}
|
||||||
|
if (nextLogCleanupRetentionDays !== config.logCleanupRetentionDays) {
|
||||||
|
changedLabels.push(`日志清理保留天数(${config.logCleanupRetentionDays} -> ${nextLogCleanupRetentionDays})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
config.logCleanupConfigured = true;
|
||||||
|
updateLogCleanupSettings({
|
||||||
|
cronExpr: nextLogCleanupCron,
|
||||||
|
usageLogsEnabled: nextUsageLogsEnabled,
|
||||||
|
programLogsEnabled: nextProgramLogsEnabled,
|
||||||
|
retentionDays: nextLogCleanupRetentionDays,
|
||||||
|
});
|
||||||
|
stopProxyLogRetentionService();
|
||||||
|
upsertSetting('log_cleanup_cron', nextLogCleanupCron);
|
||||||
|
upsertSetting('log_cleanup_usage_logs_enabled', nextUsageLogsEnabled);
|
||||||
|
upsertSetting('log_cleanup_program_logs_enabled', nextProgramLogsEnabled);
|
||||||
|
upsertSetting('log_cleanup_retention_days', nextLogCleanupRetentionDays);
|
||||||
|
}
|
||||||
|
|
||||||
if (body.proxyToken !== undefined) {
|
if (body.proxyToken !== undefined) {
|
||||||
const proxyToken = String(body.proxyToken).trim();
|
const proxyToken = String(body.proxyToken).trim();
|
||||||
if (!proxyToken.startsWith(PROXY_TOKEN_PREFIX)) {
|
if (!proxyToken.startsWith(PROXY_TOKEN_PREFIX)) {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ describe('sites system proxy settings', () => {
|
|||||||
delete process.env.DATA_DIR;
|
delete process.env.DATA_DIR;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stores useSystemProxy and external checkin url when creating a site', async () => {
|
it('stores useSystemProxy, external checkin url, and custom headers when creating a site', async () => {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/sites',
|
url: '/api/sites',
|
||||||
@@ -45,6 +45,10 @@ describe('sites system proxy settings', () => {
|
|||||||
url: 'https://proxy-site.example.com',
|
url: 'https://proxy-site.example.com',
|
||||||
platform: 'new-api',
|
platform: 'new-api',
|
||||||
useSystemProxy: true,
|
useSystemProxy: true,
|
||||||
|
customHeaders: JSON.stringify({
|
||||||
|
'cf-access-client-id': 'site-client-id',
|
||||||
|
'x-site-scope': 'internal',
|
||||||
|
}),
|
||||||
externalCheckinUrl: 'https://checkin.example.com/welfare',
|
externalCheckinUrl: 'https://checkin.example.com/welfare',
|
||||||
globalWeight: 1.5,
|
globalWeight: 1.5,
|
||||||
},
|
},
|
||||||
@@ -53,10 +57,12 @@ describe('sites system proxy settings', () => {
|
|||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
const payload = response.json() as {
|
const payload = response.json() as {
|
||||||
useSystemProxy?: boolean;
|
useSystemProxy?: boolean;
|
||||||
|
customHeaders?: string | null;
|
||||||
externalCheckinUrl?: string | null;
|
externalCheckinUrl?: string | null;
|
||||||
globalWeight?: number;
|
globalWeight?: number;
|
||||||
};
|
};
|
||||||
expect(payload.useSystemProxy).toBe(true);
|
expect(payload.useSystemProxy).toBe(true);
|
||||||
|
expect(payload.customHeaders).toBe('{"cf-access-client-id":"site-client-id","x-site-scope":"internal"}');
|
||||||
expect(payload.externalCheckinUrl).toBe('https://checkin.example.com/welfare');
|
expect(payload.externalCheckinUrl).toBe('https://checkin.example.com/welfare');
|
||||||
expect(payload.globalWeight).toBe(1.5);
|
expect(payload.globalWeight).toBe(1.5);
|
||||||
});
|
});
|
||||||
@@ -134,4 +140,38 @@ describe('sites system proxy settings', () => {
|
|||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect((response.json() as { useSystemProxy?: boolean }).useSystemProxy).toBe(true);
|
expect((response.json() as { useSystemProxy?: boolean }).useSystemProxy).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects invalid custom headers json', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sites',
|
||||||
|
payload: {
|
||||||
|
name: 'headers-site',
|
||||||
|
url: 'https://headers-site.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
customHeaders: '{invalid-json}',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect((response.json() as { error?: string }).error).toContain('Invalid customHeaders');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects custom headers with non-string values', async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/sites',
|
||||||
|
payload: {
|
||||||
|
name: 'headers-site',
|
||||||
|
url: 'https://headers-site.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
customHeaders: JSON.stringify({
|
||||||
|
'x-site-scope': true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect((response.json() as { error?: string }).error).toContain('must use a string value');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { detectSite } from '../../services/siteDetector.js';
|
|||||||
import { invalidateSiteProxyCache } from '../../services/siteProxy.js';
|
import { invalidateSiteProxyCache } from '../../services/siteProxy.js';
|
||||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
import { invalidateTokenRouterCache } from '../../services/tokenRouter.js';
|
import { invalidateTokenRouterCache } from '../../services/tokenRouter.js';
|
||||||
|
import { parseSiteCustomHeadersInput } from '../../services/siteCustomHeaders.js';
|
||||||
|
|
||||||
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
|
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
|
||||||
if (input === undefined || input === null) return null;
|
if (input === undefined || input === null) return null;
|
||||||
@@ -160,13 +161,14 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
url: string;
|
url: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
useSystemProxy?: boolean;
|
useSystemProxy?: boolean;
|
||||||
|
customHeaders?: string | null;
|
||||||
externalCheckinUrl?: string | null;
|
externalCheckinUrl?: string | null;
|
||||||
status?: string;
|
status?: string;
|
||||||
isPinned?: boolean;
|
isPinned?: boolean;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
globalWeight?: number;
|
globalWeight?: number;
|
||||||
} }>('/api/sites', async (request, reply) => {
|
} }>('/api/sites', async (request, reply) => {
|
||||||
const { name, url, platform, useSystemProxy, externalCheckinUrl, status, isPinned, sortOrder, globalWeight } = request.body;
|
const { name, url, platform, useSystemProxy, customHeaders, externalCheckinUrl, status, isPinned, sortOrder, globalWeight } = request.body;
|
||||||
const normalizedStatus = normalizeSiteStatus(status);
|
const normalizedStatus = normalizeSiteStatus(status);
|
||||||
if (status !== undefined && !normalizedStatus) {
|
if (status !== undefined && !normalizedStatus) {
|
||||||
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
|
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
|
||||||
@@ -191,6 +193,10 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
if (globalWeight !== undefined && normalizedGlobalWeight === null) {
|
if (globalWeight !== undefined && normalizedGlobalWeight === null) {
|
||||||
return reply.code(400).send({ error: 'Invalid globalWeight value. Expected a positive number.' });
|
return reply.code(400).send({ error: 'Invalid globalWeight value. Expected a positive number.' });
|
||||||
}
|
}
|
||||||
|
const normalizedCustomHeaders = parseSiteCustomHeadersInput(customHeaders);
|
||||||
|
if (!normalizedCustomHeaders.valid) {
|
||||||
|
return reply.code(400).send({ error: normalizedCustomHeaders.error || 'Invalid customHeaders.' });
|
||||||
|
}
|
||||||
|
|
||||||
const existingSites = await db.select().from(schema.sites).all();
|
const existingSites = await db.select().from(schema.sites).all();
|
||||||
const maxSortOrder = existingSites.reduce((max, site) => Math.max(max, site.sortOrder || 0), -1);
|
const maxSortOrder = existingSites.reduce((max, site) => Math.max(max, site.sortOrder || 0), -1);
|
||||||
@@ -208,6 +214,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
url: url.replace(/\/+$/, ''),
|
url: url.replace(/\/+$/, ''),
|
||||||
platform: detectedPlatform,
|
platform: detectedPlatform,
|
||||||
useSystemProxy: normalizedUseSystemProxy ?? false,
|
useSystemProxy: normalizedUseSystemProxy ?? false,
|
||||||
|
customHeaders: normalizedCustomHeaders.customHeaders,
|
||||||
externalCheckinUrl: normalizedExternalCheckinUrl.url,
|
externalCheckinUrl: normalizedExternalCheckinUrl.url,
|
||||||
status: normalizedStatus ?? 'active',
|
status: normalizedStatus ?? 'active',
|
||||||
isPinned: normalizedPinned ?? false,
|
isPinned: normalizedPinned ?? false,
|
||||||
@@ -232,6 +239,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
url?: string;
|
url?: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
useSystemProxy?: boolean;
|
useSystemProxy?: boolean;
|
||||||
|
customHeaders?: string | null;
|
||||||
externalCheckinUrl?: string | null;
|
externalCheckinUrl?: string | null;
|
||||||
status?: string;
|
status?: string;
|
||||||
isPinned?: boolean;
|
isPinned?: boolean;
|
||||||
@@ -274,11 +282,16 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
if (body.globalWeight !== undefined && normalizedGlobalWeight === null) {
|
if (body.globalWeight !== undefined && normalizedGlobalWeight === null) {
|
||||||
return reply.code(400).send({ error: 'Invalid globalWeight value. Expected a positive number.' });
|
return reply.code(400).send({ error: 'Invalid globalWeight value. Expected a positive number.' });
|
||||||
}
|
}
|
||||||
|
const normalizedCustomHeaders = parseSiteCustomHeadersInput(body.customHeaders);
|
||||||
|
if (!normalizedCustomHeaders.valid) {
|
||||||
|
return reply.code(400).send({ error: normalizedCustomHeaders.error || 'Invalid customHeaders.' });
|
||||||
|
}
|
||||||
|
|
||||||
if (body.name !== undefined) updates.name = body.name;
|
if (body.name !== undefined) updates.name = body.name;
|
||||||
if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, '');
|
if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, '');
|
||||||
if (body.platform !== undefined) updates.platform = body.platform;
|
if (body.platform !== undefined) updates.platform = body.platform;
|
||||||
if (body.useSystemProxy !== undefined) updates.useSystemProxy = normalizedUseSystemProxy;
|
if (body.useSystemProxy !== undefined) updates.useSystemProxy = normalizedUseSystemProxy;
|
||||||
|
if (normalizedCustomHeaders.present) updates.customHeaders = normalizedCustomHeaders.customHeaders;
|
||||||
if (normalizedExternalCheckinUrl.present) updates.externalCheckinUrl = normalizedExternalCheckinUrl.url;
|
if (normalizedExternalCheckinUrl.present) updates.externalCheckinUrl = normalizedExternalCheckinUrl.url;
|
||||||
if (body.status !== undefined) updates.status = normalizedStatus;
|
if (body.status !== undefined) updates.status = normalizedStatus;
|
||||||
if (body.isPinned !== undefined) updates.isPinned = normalizedPinned;
|
if (body.isPinned !== undefined) updates.isPinned = normalizedPinned;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { fetch } from 'undici';
|
import { fetch } from 'undici';
|
||||||
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
|
import { withSiteProxyRequestInit } from '../../services/siteProxy.js';
|
||||||
import { summarizeUpstreamError } from './upstreamError.js';
|
import { summarizeUpstreamError } from './upstreamError.js';
|
||||||
import type { UpstreamEndpoint } from './upstreamEndpoint.js';
|
import type { UpstreamEndpoint } from './upstreamEndpoint.js';
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
|
|||||||
const request = input.buildRequest(endpoint, endpointIndex);
|
const request = input.buildRequest(endpoint, endpointIndex);
|
||||||
const targetUrl = `${input.siteUrl}${request.path}`;
|
const targetUrl = `${input.siteUrl}${request.path}`;
|
||||||
|
|
||||||
let response = await fetch(targetUrl, withExplicitProxyRequestInit(input.proxyUrl, {
|
let response = await fetch(targetUrl, await withSiteProxyRequestInit(targetUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: request.headers,
|
headers: request.headers,
|
||||||
body: JSON.stringify(request.body),
|
body: JSON.stringify(request.body),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { isTokenExpiredError } from '../../services/alertRules.js';
|
|||||||
import { estimateProxyCost } from '../../services/modelPricingService.js';
|
import { estimateProxyCost } from '../../services/modelPricingService.js';
|
||||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||||
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
|
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
|
||||||
import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
import { withSiteProxyRequestInit, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
|
||||||
import { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js';
|
import { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js';
|
||||||
import {
|
import {
|
||||||
deleteProxyVideoTaskByPublicId,
|
deleteProxyVideoTaskByPublicId,
|
||||||
@@ -180,12 +180,13 @@ export async function videosProxyRoute(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const upstream = await fetch(`${mapping.siteUrl}/v1/videos/${encodeURIComponent(mapping.upstreamVideoId)}`, {
|
const targetUrl = `${mapping.siteUrl}/v1/videos/${encodeURIComponent(mapping.upstreamVideoId)}`;
|
||||||
|
const upstream = await fetch(targetUrl, await withSiteProxyRequestInit(targetUrl, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${mapping.tokenValue}`,
|
Authorization: `Bearer ${mapping.tokenValue}`,
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
const text = await upstream.text();
|
const text = await upstream.text();
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(text);
|
const data = JSON.parse(text);
|
||||||
@@ -210,12 +211,13 @@ export async function videosProxyRoute(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const upstream = await fetch(`${mapping.siteUrl}/v1/videos/${encodeURIComponent(mapping.upstreamVideoId)}`, {
|
const targetUrl = `${mapping.siteUrl}/v1/videos/${encodeURIComponent(mapping.upstreamVideoId)}`;
|
||||||
|
const upstream = await fetch(targetUrl, await withSiteProxyRequestInit(targetUrl, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${mapping.tokenValue}`,
|
Authorization: `Bearer ${mapping.tokenValue}`,
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
if (upstream.ok) {
|
if (upstream.ok) {
|
||||||
await deleteProxyVideoTaskByPublicId(mapping.publicId);
|
await deleteProxyVideoTaskByPublicId(mapping.publicId);
|
||||||
return reply.code(upstream.status).send();
|
return reply.code(upstream.status).send();
|
||||||
|
|||||||
@@ -49,7 +49,12 @@ describe('backupService', () => {
|
|||||||
name: 'roundtrip-site',
|
name: 'roundtrip-site',
|
||||||
url: 'https://roundtrip.example.com',
|
url: 'https://roundtrip.example.com',
|
||||||
platform: 'new-api',
|
platform: 'new-api',
|
||||||
|
externalCheckinUrl: 'https://checkin.roundtrip.example.com',
|
||||||
proxyUrl: 'http://127.0.0.1:8080',
|
proxyUrl: 'http://127.0.0.1:8080',
|
||||||
|
useSystemProxy: true,
|
||||||
|
customHeaders: JSON.stringify({
|
||||||
|
'cf-access-client-id': 'roundtrip-client',
|
||||||
|
}),
|
||||||
status: 'active',
|
status: 'active',
|
||||||
isPinned: true,
|
isPinned: true,
|
||||||
sortOrder: 9,
|
sortOrder: 9,
|
||||||
@@ -128,6 +133,9 @@ describe('backupService', () => {
|
|||||||
const restoredChannel = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.routeId, route.id)).get();
|
const restoredChannel = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.routeId, route.id)).get();
|
||||||
|
|
||||||
expect(restoredSite?.proxyUrl).toBe('http://127.0.0.1:8080');
|
expect(restoredSite?.proxyUrl).toBe('http://127.0.0.1:8080');
|
||||||
|
expect(restoredSite?.externalCheckinUrl).toBe('https://checkin.roundtrip.example.com');
|
||||||
|
expect(restoredSite?.useSystemProxy).toBe(true);
|
||||||
|
expect(restoredSite?.customHeaders).toBe('{"cf-access-client-id":"roundtrip-client"}');
|
||||||
expect(restoredSite?.isPinned).toBe(true);
|
expect(restoredSite?.isPinned).toBe(true);
|
||||||
expect(restoredSite?.sortOrder).toBe(9);
|
expect(restoredSite?.sortOrder).toBe(9);
|
||||||
|
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS
|
|||||||
platform,
|
platform,
|
||||||
proxyUrl: null,
|
proxyUrl: null,
|
||||||
useSystemProxy: false,
|
useSystemProxy: false,
|
||||||
|
customHeaders: null,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
isPinned: false,
|
isPinned: false,
|
||||||
sortOrder: sites.length,
|
sortOrder: sites.length,
|
||||||
@@ -293,10 +294,18 @@ function isFiniteNumber(value: unknown): value is number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isSettingValueAcceptable(key: string, value: unknown): boolean {
|
function isSettingValueAcceptable(key: string, value: unknown): boolean {
|
||||||
if (key === 'checkin_cron' || key === 'balance_refresh_cron') {
|
if (key === 'checkin_cron' || key === 'balance_refresh_cron' || key === 'log_cleanup_cron') {
|
||||||
return typeof value === 'string' && cron.validate(value);
|
return typeof value === 'string' && cron.validate(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (key === 'log_cleanup_usage_logs_enabled' || key === 'log_cleanup_program_logs_enabled') {
|
||||||
|
return typeof value === 'boolean';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'log_cleanup_retention_days') {
|
||||||
|
return isFiniteNumber(value) && value >= 1;
|
||||||
|
}
|
||||||
|
|
||||||
if (key === 'proxy_token') {
|
if (key === 'proxy_token') {
|
||||||
return typeof value === 'string'
|
return typeof value === 'string'
|
||||||
&& value.trim().length >= 6
|
&& value.trim().length >= 6
|
||||||
@@ -465,8 +474,11 @@ async function importAccountsSection(section: AccountsBackupSection): Promise<vo
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
url: row.url,
|
url: row.url,
|
||||||
|
externalCheckinUrl: row.externalCheckinUrl ?? null,
|
||||||
platform: row.platform,
|
platform: row.platform,
|
||||||
proxyUrl: row.proxyUrl ?? null,
|
proxyUrl: row.proxyUrl ?? null,
|
||||||
|
useSystemProxy: row.useSystemProxy ?? false,
|
||||||
|
customHeaders: row.customHeaders ?? null,
|
||||||
status: row.status || 'active',
|
status: row.status || 'active',
|
||||||
isPinned: row.isPinned ?? false,
|
isPinned: row.isPinned ?? false,
|
||||||
sortOrder: row.sortOrder ?? 0,
|
sortOrder: row.sortOrder ?? 0,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { decryptAccountPassword } from './accountCredentialService.js';
|
|||||||
import { extractRuntimeHealth, setAccountRuntimeHealth } from './accountHealthService.js';
|
import { extractRuntimeHealth, setAccountRuntimeHealth } from './accountHealthService.js';
|
||||||
import { updateTodayIncomeSnapshot } from './todayIncomeRewardService.js';
|
import { updateTodayIncomeSnapshot } from './todayIncomeRewardService.js';
|
||||||
import type { BalanceInfo } from './platforms/base.js';
|
import type { BalanceInfo } from './platforms/base.js';
|
||||||
import { resolveProxyUrlForSite, withExplicitProxyRequestInit, withSiteRecordProxyRequestInit } from './siteProxy.js';
|
import { withSiteProxyRequestInit, withSiteRecordProxyRequestInit } from './siteProxy.js';
|
||||||
|
|
||||||
function isSiteDisabled(status?: string | null): boolean {
|
function isSiteDisabled(status?: string | null): boolean {
|
||||||
return (status || 'active') === 'disabled';
|
return (status || 'active') === 'disabled';
|
||||||
@@ -237,7 +237,6 @@ async function fetchTodayIncomeFromLogs(params: {
|
|||||||
accessToken: string;
|
accessToken: string;
|
||||||
platform?: string | null;
|
platform?: string | null;
|
||||||
platformUserId?: number;
|
platformUserId?: number;
|
||||||
proxyUrl?: string | null;
|
|
||||||
}): Promise<number | null> {
|
}): Promise<number | null> {
|
||||||
const baseUrl = params.baseUrl.trim();
|
const baseUrl = params.baseUrl.trim();
|
||||||
const accessToken = params.accessToken.trim();
|
const accessToken = params.accessToken.trim();
|
||||||
@@ -272,7 +271,8 @@ async function fetchTodayIncomeFromLogs(params: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${baseUrl}/api/log/self?${query.toString()}`, withExplicitProxyRequestInit(params.proxyUrl, {
|
const requestUrl = `${baseUrl}/api/log/self?${query.toString()}`;
|
||||||
|
const response = await fetch(requestUrl, await withSiteProxyRequestInit(requestUrl, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers,
|
headers,
|
||||||
}));
|
}));
|
||||||
@@ -466,7 +466,6 @@ export async function refreshBalance(accountId: number) {
|
|||||||
accessToken: activeAccessToken,
|
accessToken: activeAccessToken,
|
||||||
platform: site.platform,
|
platform: site.platform,
|
||||||
platformUserId,
|
platformUserId,
|
||||||
proxyUrl: resolveProxyUrlForSite(site),
|
|
||||||
});
|
});
|
||||||
if (typeof fallbackIncome === 'number' && Number.isFinite(fallbackIncome)) {
|
if (typeof fallbackIncome === 'number' && Number.isFinite(fallbackIncome)) {
|
||||||
balanceInfo.todayIncome = fallbackIncome;
|
balanceInfo.todayIncome = fallbackIncome;
|
||||||
|
|||||||
@@ -7,19 +7,26 @@ import { checkinAll } from './checkinService.js';
|
|||||||
import { refreshModelsAndRebuildRoutes } from './modelService.js';
|
import { refreshModelsAndRebuildRoutes } from './modelService.js';
|
||||||
import { sendNotification } from './notifyService.js';
|
import { sendNotification } from './notifyService.js';
|
||||||
import { buildDailySummaryNotification, collectDailySummaryMetrics } from './dailySummaryService.js';
|
import { buildDailySummaryNotification, collectDailySummaryMetrics } from './dailySummaryService.js';
|
||||||
|
import { cleanupConfiguredLogs, normalizeLogCleanupRetentionDays } from './logCleanupService.js';
|
||||||
|
|
||||||
let checkinTask: cron.ScheduledTask | null = null;
|
let checkinTask: cron.ScheduledTask | null = null;
|
||||||
let balanceTask: cron.ScheduledTask | null = null;
|
let balanceTask: cron.ScheduledTask | null = null;
|
||||||
let dailySummaryTask: cron.ScheduledTask | null = null;
|
let dailySummaryTask: cron.ScheduledTask | null = null;
|
||||||
|
let logCleanupTask: cron.ScheduledTask | null = null;
|
||||||
|
|
||||||
const DAILY_SUMMARY_DEFAULT_CRON = '58 23 * * *';
|
const DAILY_SUMMARY_DEFAULT_CRON = '58 23 * * *';
|
||||||
|
const LOG_CLEANUP_DEFAULT_CRON = '0 6 * * *';
|
||||||
|
|
||||||
async function resolveCronSetting(settingKey: string, fallback: string): Promise<string> {
|
async function resolveJsonSetting<T>(
|
||||||
|
settingKey: string,
|
||||||
|
isValid: (value: unknown) => value is T,
|
||||||
|
fallback: T,
|
||||||
|
): Promise<T> {
|
||||||
try {
|
try {
|
||||||
const row = await db.select().from(schema.settings).where(eq(schema.settings.key, settingKey)).get();
|
const row = await db.select().from(schema.settings).where(eq(schema.settings.key, settingKey)).get();
|
||||||
if (row?.value) {
|
if (row?.value) {
|
||||||
const parsed = JSON.parse(row.value);
|
const parsed = JSON.parse(row.value);
|
||||||
if (typeof parsed === 'string' && cron.validate(parsed)) {
|
if (isValid(parsed)) {
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,6 +34,22 @@ async function resolveCronSetting(settingKey: string, fallback: string): Promise
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveCronSetting(settingKey: string, fallback: string): Promise<string> {
|
||||||
|
return resolveJsonSetting(settingKey, (value): value is string => typeof value === 'string' && cron.validate(value), fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveBooleanSetting(settingKey: string, fallback: boolean): Promise<boolean> {
|
||||||
|
return resolveJsonSetting(settingKey, (value): value is boolean => typeof value === 'boolean', fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolvePositiveIntegerSetting(settingKey: string, fallback: number): Promise<number> {
|
||||||
|
return resolveJsonSetting(
|
||||||
|
settingKey,
|
||||||
|
(value): value is number => typeof value === 'number' && Number.isFinite(value) && value >= 1,
|
||||||
|
fallback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function createCheckinTask(cronExpr: string) {
|
function createCheckinTask(cronExpr: string) {
|
||||||
return cron.schedule(cronExpr, async () => {
|
return cron.schedule(cronExpr, async () => {
|
||||||
console.log(`[Scheduler] Running check-in at ${new Date().toISOString()}`);
|
console.log(`[Scheduler] Running check-in at ${new Date().toISOString()}`);
|
||||||
@@ -72,20 +95,67 @@ function createDailySummaryTask(cronExpr: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createLogCleanupTask(cronExpr: string) {
|
||||||
|
return cron.schedule(cronExpr, async () => {
|
||||||
|
if (!config.logCleanupConfigured) {
|
||||||
|
console.log('[Scheduler] Log cleanup skipped: legacy fallback mode is active');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`[Scheduler] Running log cleanup at ${new Date().toISOString()}`);
|
||||||
|
try {
|
||||||
|
const result = await cleanupConfiguredLogs();
|
||||||
|
if (!result.enabled) {
|
||||||
|
console.log('[Scheduler] Log cleanup skipped: no log target enabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`[Scheduler] Log cleanup complete: usage=${result.usageLogsDeleted}, program=${result.programLogsDeleted}, cutoff=${result.cutoffUtc}`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Scheduler] Log cleanup error:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function startScheduler() {
|
export async function startScheduler() {
|
||||||
const activeCheckinCron = await resolveCronSetting('checkin_cron', config.checkinCron);
|
const activeCheckinCron = await resolveCronSetting('checkin_cron', config.checkinCron);
|
||||||
const activeBalanceCron = await resolveCronSetting('balance_refresh_cron', config.balanceRefreshCron);
|
const activeBalanceCron = await resolveCronSetting('balance_refresh_cron', config.balanceRefreshCron);
|
||||||
const activeDailySummaryCron = await resolveCronSetting('daily_summary_cron', DAILY_SUMMARY_DEFAULT_CRON);
|
const activeDailySummaryCron = await resolveCronSetting('daily_summary_cron', DAILY_SUMMARY_DEFAULT_CRON);
|
||||||
|
const activeLogCleanupCron = await resolveCronSetting('log_cleanup_cron', config.logCleanupCron || LOG_CLEANUP_DEFAULT_CRON);
|
||||||
|
const activeLogCleanupUsageLogsEnabled = await resolveBooleanSetting(
|
||||||
|
'log_cleanup_usage_logs_enabled',
|
||||||
|
config.logCleanupUsageLogsEnabled,
|
||||||
|
);
|
||||||
|
const activeLogCleanupProgramLogsEnabled = await resolveBooleanSetting(
|
||||||
|
'log_cleanup_program_logs_enabled',
|
||||||
|
config.logCleanupProgramLogsEnabled,
|
||||||
|
);
|
||||||
|
const activeLogCleanupRetentionDays = await resolvePositiveIntegerSetting(
|
||||||
|
'log_cleanup_retention_days',
|
||||||
|
normalizeLogCleanupRetentionDays(config.logCleanupRetentionDays),
|
||||||
|
);
|
||||||
config.checkinCron = activeCheckinCron;
|
config.checkinCron = activeCheckinCron;
|
||||||
config.balanceRefreshCron = activeBalanceCron;
|
config.balanceRefreshCron = activeBalanceCron;
|
||||||
|
config.logCleanupCron = activeLogCleanupCron;
|
||||||
|
config.logCleanupUsageLogsEnabled = activeLogCleanupUsageLogsEnabled;
|
||||||
|
config.logCleanupProgramLogsEnabled = activeLogCleanupProgramLogsEnabled;
|
||||||
|
config.logCleanupRetentionDays = activeLogCleanupRetentionDays;
|
||||||
|
|
||||||
|
checkinTask?.stop();
|
||||||
|
balanceTask?.stop();
|
||||||
|
dailySummaryTask?.stop();
|
||||||
|
logCleanupTask?.stop();
|
||||||
checkinTask = createCheckinTask(activeCheckinCron);
|
checkinTask = createCheckinTask(activeCheckinCron);
|
||||||
balanceTask = createBalanceTask(activeBalanceCron);
|
balanceTask = createBalanceTask(activeBalanceCron);
|
||||||
dailySummaryTask = createDailySummaryTask(activeDailySummaryCron);
|
dailySummaryTask = createDailySummaryTask(activeDailySummaryCron);
|
||||||
|
logCleanupTask = createLogCleanupTask(activeLogCleanupCron);
|
||||||
|
|
||||||
console.log(`[Scheduler] Check-in cron: ${activeCheckinCron}`);
|
console.log(`[Scheduler] Check-in cron: ${activeCheckinCron}`);
|
||||||
console.log(`[Scheduler] Balance refresh cron: ${activeBalanceCron}`);
|
console.log(`[Scheduler] Balance refresh cron: ${activeBalanceCron}`);
|
||||||
console.log(`[Scheduler] Daily summary cron: ${activeDailySummaryCron}`);
|
console.log(`[Scheduler] Daily summary cron: ${activeDailySummaryCron}`);
|
||||||
|
console.log(
|
||||||
|
`[Scheduler] Log cleanup cron: ${activeLogCleanupCron} (configured=${config.logCleanupConfigured}, usage=${activeLogCleanupUsageLogsEnabled}, program=${activeLogCleanupProgramLogsEnabled}, retentionDays=${activeLogCleanupRetentionDays})`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateCheckinCron(cronExpr: string) {
|
export function updateCheckinCron(cronExpr: string) {
|
||||||
@@ -101,3 +171,23 @@ export function updateBalanceRefreshCron(cronExpr: string) {
|
|||||||
balanceTask?.stop();
|
balanceTask?.stop();
|
||||||
balanceTask = createBalanceTask(cronExpr);
|
balanceTask = createBalanceTask(cronExpr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateLogCleanupSettings(input: {
|
||||||
|
cronExpr?: string;
|
||||||
|
usageLogsEnabled?: boolean;
|
||||||
|
programLogsEnabled?: boolean;
|
||||||
|
retentionDays?: number;
|
||||||
|
}) {
|
||||||
|
const cronExpr = input.cronExpr ?? config.logCleanupCron;
|
||||||
|
if (!cron.validate(cronExpr)) throw new Error(`Invalid cron: ${cronExpr}`);
|
||||||
|
|
||||||
|
const retentionDays = normalizeLogCleanupRetentionDays(input.retentionDays ?? config.logCleanupRetentionDays);
|
||||||
|
|
||||||
|
config.logCleanupCron = cronExpr;
|
||||||
|
if (input.usageLogsEnabled !== undefined) config.logCleanupUsageLogsEnabled = !!input.usageLogsEnabled;
|
||||||
|
if (input.programLogsEnabled !== undefined) config.logCleanupProgramLogsEnabled = !!input.programLogsEnabled;
|
||||||
|
config.logCleanupRetentionDays = retentionDays;
|
||||||
|
|
||||||
|
logCleanupTask?.stop();
|
||||||
|
logCleanupTask = createLogCleanupTask(cronExpr);
|
||||||
|
}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ describe('databaseMigrationService', () => {
|
|||||||
expect(normalized.ssl).toBe(false);
|
expect(normalized.ssl).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(['postgres', 'mysql', 'sqlite'] as const)('creates or patches sites schema with use_system_proxy for %s', async (dialect) => {
|
it.each(['postgres', 'mysql', 'sqlite'] as const)('creates or patches sites schema with use_system_proxy and custom_headers for %s', async (dialect) => {
|
||||||
const executedSql: string[] = [];
|
const executedSql: string[] = [];
|
||||||
await __databaseMigrationServiceTestUtils.ensureSchema({
|
await __databaseMigrationServiceTestUtils.ensureSchema({
|
||||||
dialect,
|
dialect,
|
||||||
@@ -118,7 +118,7 @@ describe('databaseMigrationService', () => {
|
|||||||
}
|
}
|
||||||
if (sqlText.includes('pragma_table_info') || sqlText.includes('information_schema.columns')) {
|
if (sqlText.includes('pragma_table_info') || sqlText.includes('information_schema.columns')) {
|
||||||
const columnName = String(params[1] ?? sqlText.match(/name = '([^']+)'/)?.[1] ?? '');
|
const columnName = String(params[1] ?? sqlText.match(/name = '([^']+)'/)?.[1] ?? '');
|
||||||
return columnName === 'use_system_proxy' ? 0 : 1;
|
return columnName === 'use_system_proxy' || columnName === 'custom_headers' ? 0 : 1;
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
},
|
},
|
||||||
@@ -126,8 +126,10 @@ describe('databaseMigrationService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const useSystemProxySql = executedSql.find((sqlText) => sqlText.includes('use_system_proxy'));
|
const useSystemProxySql = executedSql.find((sqlText) => sqlText.includes('use_system_proxy'));
|
||||||
|
const customHeadersSql = executedSql.find((sqlText) => sqlText.includes('custom_headers'));
|
||||||
|
|
||||||
expect(useSystemProxySql).toContain('use_system_proxy');
|
expect(useSystemProxySql).toContain('use_system_proxy');
|
||||||
|
expect(customHeadersSql).toContain('custom_headers');
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(['postgres', 'mysql'] as const)('patches token_routes decision snapshot columns for %s', async (dialect) => {
|
it.each(['postgres', 'mysql'] as const)('patches token_routes decision snapshot columns for %s', async (dialect) => {
|
||||||
@@ -159,7 +161,7 @@ describe('databaseMigrationService', () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('includes useSystemProxy when building site migration statements', () => {
|
it('includes useSystemProxy and customHeaders when building site migration statements', () => {
|
||||||
const statements = __databaseMigrationServiceTestUtils.buildStatements({
|
const statements = __databaseMigrationServiceTestUtils.buildStatements({
|
||||||
version: 'test',
|
version: 'test',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
@@ -170,6 +172,7 @@ describe('databaseMigrationService', () => {
|
|||||||
url: 'https://example.com',
|
url: 'https://example.com',
|
||||||
platform: 'openai',
|
platform: 'openai',
|
||||||
useSystemProxy: true,
|
useSystemProxy: true,
|
||||||
|
customHeaders: '{"x-site-scope":"internal"}',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
}],
|
}],
|
||||||
accounts: [],
|
accounts: [],
|
||||||
@@ -190,8 +193,11 @@ describe('databaseMigrationService', () => {
|
|||||||
|
|
||||||
const siteStatement = statements.find((statement) => statement.table === 'sites');
|
const siteStatement = statements.find((statement) => statement.table === 'sites');
|
||||||
const useSystemProxyIndex = siteStatement?.columns.indexOf('use_system_proxy') ?? -1;
|
const useSystemProxyIndex = siteStatement?.columns.indexOf('use_system_proxy') ?? -1;
|
||||||
|
const customHeadersIndex = siteStatement?.columns.indexOf('custom_headers') ?? -1;
|
||||||
|
|
||||||
expect(useSystemProxyIndex).toBeGreaterThanOrEqual(0);
|
expect(useSystemProxyIndex).toBeGreaterThanOrEqual(0);
|
||||||
expect(siteStatement?.values[useSystemProxyIndex]).toBe(true);
|
expect(siteStatement?.values[useSystemProxyIndex]).toBe(true);
|
||||||
|
expect(customHeadersIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(siteStatement?.values[customHeadersIndex]).toBe('{"x-site-scope":"internal"}');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
|
|||||||
for (const row of snapshot.accounts.sites) {
|
for (const row of snapshot.accounts.sites) {
|
||||||
statements.push({
|
statements.push({
|
||||||
table: 'sites',
|
table: 'sites',
|
||||||
columns: ['id', 'name', 'url', 'external_checkin_url', 'platform', 'proxy_url', 'use_system_proxy', 'status', 'is_pinned', 'sort_order', 'global_weight', 'api_key', 'created_at', 'updated_at'],
|
columns: ['id', 'name', 'url', 'external_checkin_url', 'platform', 'proxy_url', 'use_system_proxy', 'custom_headers', 'status', 'is_pinned', 'sort_order', 'global_weight', 'api_key', 'created_at', 'updated_at'],
|
||||||
values: [
|
values: [
|
||||||
asNumber(row.id, 0),
|
asNumber(row.id, 0),
|
||||||
asNullableString(row.name),
|
asNullableString(row.name),
|
||||||
@@ -268,6 +268,7 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] {
|
|||||||
asNullableString(row.platform),
|
asNullableString(row.platform),
|
||||||
asNullableString(row.proxyUrl),
|
asNullableString(row.proxyUrl),
|
||||||
asBoolean(row.useSystemProxy, false),
|
asBoolean(row.useSystemProxy, false),
|
||||||
|
asNullableString(row.customHeaders),
|
||||||
asNullableString(row.status) ?? 'active',
|
asNullableString(row.status) ?? 'active',
|
||||||
asBoolean(row.isPinned, false),
|
asBoolean(row.isPinned, false),
|
||||||
asNumber(row.sortOrder, 0),
|
asNumber(row.sortOrder, 0),
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { buildConfig, config } from '../config.js';
|
import { buildConfig, config } from '../config.js';
|
||||||
import { db, schema, switchRuntimeDatabase } from '../db/index.js';
|
import { db, schema, switchRuntimeDatabase } from '../db/index.js';
|
||||||
import { updateBalanceRefreshCron, updateCheckinCron } from './checkinScheduler.js';
|
import { updateBalanceRefreshCron, updateCheckinCron, updateLogCleanupSettings } from './checkinScheduler.js';
|
||||||
import { ensureDefaultSitesSeeded } from './defaultSiteSeedService.js';
|
import { ensureDefaultSitesSeeded } from './defaultSiteSeedService.js';
|
||||||
|
import { startProxyLogRetentionService } from './proxyLogRetentionService.js';
|
||||||
import { invalidateSiteProxyCache } from './siteProxy.js';
|
import { invalidateSiteProxyCache } from './siteProxy.js';
|
||||||
|
|
||||||
export const FACTORY_RESET_ADMIN_TOKEN = 'change-me-admin-token';
|
export const FACTORY_RESET_ADMIN_TOKEN = 'change-me-admin-token';
|
||||||
@@ -38,8 +39,19 @@ function resetRuntimeConfigToInitialState() {
|
|||||||
config.dbType = 'sqlite';
|
config.dbType = 'sqlite';
|
||||||
config.dbUrl = '';
|
config.dbUrl = '';
|
||||||
config.dbSsl = false;
|
config.dbSsl = false;
|
||||||
|
config.logCleanupConfigured = false;
|
||||||
|
config.logCleanupUsageLogsEnabled = config.proxyLogRetentionDays > 0;
|
||||||
|
config.logCleanupProgramLogsEnabled = false;
|
||||||
|
config.logCleanupRetentionDays = Math.max(1, Math.trunc(config.proxyLogRetentionDays || config.logCleanupRetentionDays || 30));
|
||||||
updateCheckinCron(config.checkinCron);
|
updateCheckinCron(config.checkinCron);
|
||||||
updateBalanceRefreshCron(config.balanceRefreshCron);
|
updateBalanceRefreshCron(config.balanceRefreshCron);
|
||||||
|
updateLogCleanupSettings({
|
||||||
|
cronExpr: config.logCleanupCron,
|
||||||
|
usageLogsEnabled: config.logCleanupUsageLogsEnabled,
|
||||||
|
programLogsEnabled: config.logCleanupProgramLogsEnabled,
|
||||||
|
retentionDays: config.logCleanupRetentionDays,
|
||||||
|
});
|
||||||
|
startProxyLogRetentionService();
|
||||||
invalidateSiteProxyCache();
|
invalidateSiteProxyCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { mkdtempSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
type DbModule = typeof import('../db/index.js');
|
||||||
|
type ConfigModule = typeof import('../config.js');
|
||||||
|
type CleanupModule = typeof import('./logCleanupService.js');
|
||||||
|
|
||||||
|
describe('logCleanupService', () => {
|
||||||
|
let db: DbModule['db'];
|
||||||
|
let schema: DbModule['schema'];
|
||||||
|
let config: ConfigModule['config'];
|
||||||
|
let cleanupConfiguredLogs: CleanupModule['cleanupConfiguredLogs'];
|
||||||
|
let dataDir = '';
|
||||||
|
let originalConfig: {
|
||||||
|
logCleanupUsageLogsEnabled: boolean;
|
||||||
|
logCleanupProgramLogsEnabled: boolean;
|
||||||
|
logCleanupRetentionDays: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
dataDir = mkdtempSync(join(tmpdir(), 'metapi-log-cleanup-'));
|
||||||
|
process.env.DATA_DIR = dataDir;
|
||||||
|
|
||||||
|
await import('../db/migrate.js');
|
||||||
|
const dbModule = await import('../db/index.js');
|
||||||
|
const configModule = await import('../config.js');
|
||||||
|
const cleanupModule = await import('./logCleanupService.js');
|
||||||
|
db = dbModule.db;
|
||||||
|
schema = dbModule.schema;
|
||||||
|
config = configModule.config;
|
||||||
|
cleanupConfiguredLogs = cleanupModule.cleanupConfiguredLogs;
|
||||||
|
originalConfig = {
|
||||||
|
logCleanupUsageLogsEnabled: config.logCleanupUsageLogsEnabled,
|
||||||
|
logCleanupProgramLogsEnabled: config.logCleanupProgramLogsEnabled,
|
||||||
|
logCleanupRetentionDays: config.logCleanupRetentionDays,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await db.delete(schema.events).run();
|
||||||
|
await db.delete(schema.proxyLogs).run();
|
||||||
|
await db.delete(schema.accounts).run();
|
||||||
|
await db.delete(schema.sites).run();
|
||||||
|
|
||||||
|
config.logCleanupUsageLogsEnabled = false;
|
||||||
|
config.logCleanupProgramLogsEnabled = false;
|
||||||
|
config.logCleanupRetentionDays = 30;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
config.logCleanupUsageLogsEnabled = originalConfig.logCleanupUsageLogsEnabled;
|
||||||
|
config.logCleanupProgramLogsEnabled = originalConfig.logCleanupProgramLogsEnabled;
|
||||||
|
config.logCleanupRetentionDays = originalConfig.logCleanupRetentionDays;
|
||||||
|
delete process.env.DATA_DIR;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function seedAccount() {
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'cleanup-site',
|
||||||
|
url: 'https://cleanup.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
return await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'cleanup-user',
|
||||||
|
accessToken: 'cleanup-access-token',
|
||||||
|
apiToken: 'cleanup-api-token',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
}
|
||||||
|
|
||||||
|
it('cleans usage logs and program logs older than retention days', async () => {
|
||||||
|
const account = await seedAccount();
|
||||||
|
|
||||||
|
await db.insert(schema.proxyLogs).values([
|
||||||
|
{
|
||||||
|
accountId: account.id,
|
||||||
|
modelRequested: 'gpt-4.1-mini',
|
||||||
|
status: 'success',
|
||||||
|
createdAt: '2026-02-01 08:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountId: account.id,
|
||||||
|
modelRequested: 'gpt-4.1-mini',
|
||||||
|
status: 'success',
|
||||||
|
createdAt: '2026-03-10 08:00:00',
|
||||||
|
},
|
||||||
|
]).run();
|
||||||
|
|
||||||
|
await db.insert(schema.events).values([
|
||||||
|
{
|
||||||
|
type: 'status',
|
||||||
|
title: 'old event',
|
||||||
|
message: 'cleanup me',
|
||||||
|
createdAt: '2026-02-01 08:00:00',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'status',
|
||||||
|
title: 'new event',
|
||||||
|
message: 'keep me',
|
||||||
|
createdAt: '2026-03-10 08:00:00',
|
||||||
|
},
|
||||||
|
]).run();
|
||||||
|
|
||||||
|
const result = await cleanupConfiguredLogs({
|
||||||
|
usageLogsEnabled: true,
|
||||||
|
programLogsEnabled: true,
|
||||||
|
retentionDays: 7,
|
||||||
|
nowMs: Date.parse('2026-03-12T00:00:00Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.enabled).toBe(true);
|
||||||
|
expect(result.usageLogsDeleted).toBe(1);
|
||||||
|
expect(result.programLogsDeleted).toBe(1);
|
||||||
|
expect(result.totalDeleted).toBe(2);
|
||||||
|
|
||||||
|
const remainingProxyLogs = await db.select().from(schema.proxyLogs).all();
|
||||||
|
const remainingEvents = await db.select().from(schema.events).all();
|
||||||
|
expect(remainingProxyLogs).toHaveLength(1);
|
||||||
|
expect(remainingProxyLogs[0]?.createdAt).toBe('2026-03-10 08:00:00');
|
||||||
|
expect(remainingEvents).toHaveLength(1);
|
||||||
|
expect(remainingEvents[0]?.title).toBe('new event');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips cleanup when no target is enabled', async () => {
|
||||||
|
const account = await seedAccount();
|
||||||
|
|
||||||
|
await db.insert(schema.proxyLogs).values({
|
||||||
|
accountId: account.id,
|
||||||
|
modelRequested: 'gpt-4.1-mini',
|
||||||
|
status: 'success',
|
||||||
|
createdAt: '2026-01-01 08:00:00',
|
||||||
|
}).run();
|
||||||
|
await db.insert(schema.events).values({
|
||||||
|
type: 'status',
|
||||||
|
title: 'still here',
|
||||||
|
createdAt: '2026-01-01 08:00:00',
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const result = await cleanupConfiguredLogs({
|
||||||
|
usageLogsEnabled: false,
|
||||||
|
programLogsEnabled: false,
|
||||||
|
retentionDays: 7,
|
||||||
|
nowMs: Date.parse('2026-03-12T00:00:00Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.enabled).toBe(false);
|
||||||
|
expect(result.totalDeleted).toBe(0);
|
||||||
|
expect(await db.select().from(schema.proxyLogs).all()).toHaveLength(1);
|
||||||
|
expect(await db.select().from(schema.events).all()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { lt } from 'drizzle-orm';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
import { db, schema } from '../db/index.js';
|
||||||
|
import { formatUtcSqlDateTime } from './localTimeService.js';
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
export type LogCleanupOptions = {
|
||||||
|
usageLogsEnabled?: boolean;
|
||||||
|
programLogsEnabled?: boolean;
|
||||||
|
retentionDays?: number;
|
||||||
|
nowMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LogCleanupResult = {
|
||||||
|
enabled: boolean;
|
||||||
|
usageLogsEnabled: boolean;
|
||||||
|
programLogsEnabled: boolean;
|
||||||
|
retentionDays: number;
|
||||||
|
cutoffUtc: string | null;
|
||||||
|
usageLogsDeleted: number;
|
||||||
|
programLogsDeleted: number;
|
||||||
|
totalDeleted: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeLogCleanupRetentionDays(value: unknown, fallback = 30): number {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed) && parsed >= 1) return Math.trunc(parsed);
|
||||||
|
|
||||||
|
const fallbackParsed = Number(fallback);
|
||||||
|
if (Number.isFinite(fallbackParsed) && fallbackParsed >= 1) return Math.trunc(fallbackParsed);
|
||||||
|
|
||||||
|
return 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLogCleanupCutoffUtc(retentionDays: number, nowMs = Date.now()): string | null {
|
||||||
|
const normalizedDays = normalizeLogCleanupRetentionDays(retentionDays);
|
||||||
|
return formatUtcSqlDateTime(new Date(nowMs - normalizedDays * DAY_MS));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupUsageLogs(retentionDays: number, nowMs = Date.now()): Promise<{
|
||||||
|
retentionDays: number;
|
||||||
|
cutoffUtc: string | null;
|
||||||
|
deleted: number;
|
||||||
|
}> {
|
||||||
|
const normalizedDays = normalizeLogCleanupRetentionDays(retentionDays);
|
||||||
|
const cutoffUtc = getLogCleanupCutoffUtc(normalizedDays, nowMs);
|
||||||
|
if (!cutoffUtc) {
|
||||||
|
return {
|
||||||
|
retentionDays: normalizedDays,
|
||||||
|
cutoffUtc: null,
|
||||||
|
deleted: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = (
|
||||||
|
await db.delete(schema.proxyLogs)
|
||||||
|
.where(lt(schema.proxyLogs.createdAt, cutoffUtc))
|
||||||
|
.run()
|
||||||
|
).changes;
|
||||||
|
|
||||||
|
return {
|
||||||
|
retentionDays: normalizedDays,
|
||||||
|
cutoffUtc,
|
||||||
|
deleted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupProgramLogs(retentionDays: number, nowMs = Date.now()): Promise<{
|
||||||
|
retentionDays: number;
|
||||||
|
cutoffUtc: string | null;
|
||||||
|
deleted: number;
|
||||||
|
}> {
|
||||||
|
const normalizedDays = normalizeLogCleanupRetentionDays(retentionDays);
|
||||||
|
const cutoffUtc = getLogCleanupCutoffUtc(normalizedDays, nowMs);
|
||||||
|
if (!cutoffUtc) {
|
||||||
|
return {
|
||||||
|
retentionDays: normalizedDays,
|
||||||
|
cutoffUtc: null,
|
||||||
|
deleted: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = (
|
||||||
|
await db.delete(schema.events)
|
||||||
|
.where(lt(schema.events.createdAt, cutoffUtc))
|
||||||
|
.run()
|
||||||
|
).changes;
|
||||||
|
|
||||||
|
return {
|
||||||
|
retentionDays: normalizedDays,
|
||||||
|
cutoffUtc,
|
||||||
|
deleted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupConfiguredLogs(options: LogCleanupOptions = {}): Promise<LogCleanupResult> {
|
||||||
|
const usageLogsEnabled = options.usageLogsEnabled ?? config.logCleanupUsageLogsEnabled;
|
||||||
|
const programLogsEnabled = options.programLogsEnabled ?? config.logCleanupProgramLogsEnabled;
|
||||||
|
const retentionDays = normalizeLogCleanupRetentionDays(
|
||||||
|
options.retentionDays ?? config.logCleanupRetentionDays,
|
||||||
|
config.logCleanupRetentionDays,
|
||||||
|
);
|
||||||
|
const nowMs = options.nowMs ?? Date.now();
|
||||||
|
const enabled = usageLogsEnabled || programLogsEnabled;
|
||||||
|
const cutoffUtc = enabled ? getLogCleanupCutoffUtc(retentionDays, nowMs) : null;
|
||||||
|
|
||||||
|
if (!enabled || !cutoffUtc) {
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
usageLogsEnabled,
|
||||||
|
programLogsEnabled,
|
||||||
|
retentionDays,
|
||||||
|
cutoffUtc,
|
||||||
|
usageLogsDeleted: 0,
|
||||||
|
programLogsDeleted: 0,
|
||||||
|
totalDeleted: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const usageResult = usageLogsEnabled
|
||||||
|
? await cleanupUsageLogs(retentionDays, nowMs)
|
||||||
|
: { deleted: 0 };
|
||||||
|
const programResult = programLogsEnabled
|
||||||
|
? await cleanupProgramLogs(retentionDays, nowMs)
|
||||||
|
: { deleted: 0 };
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
usageLogsEnabled,
|
||||||
|
programLogsEnabled,
|
||||||
|
retentionDays,
|
||||||
|
cutoffUtc,
|
||||||
|
usageLogsDeleted: usageResult.deleted,
|
||||||
|
programLogsDeleted: programResult.deleted,
|
||||||
|
totalDeleted: usageResult.deleted + programResult.deleted,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,16 +1,12 @@
|
|||||||
import { lt } from 'drizzle-orm';
|
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { db, schema } from '../db/index.js';
|
import { cleanupUsageLogs, getLogCleanupCutoffUtc } from './logCleanupService.js';
|
||||||
import { formatUtcSqlDateTime } from './localTimeService.js';
|
|
||||||
|
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
let retentionTimer: ReturnType<typeof setInterval> | null = null;
|
let retentionTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
export function getProxyLogRetentionCutoffUtc(nowMs = Date.now()): string | null {
|
export function getProxyLogRetentionCutoffUtc(nowMs = Date.now()): string | null {
|
||||||
const days = Math.max(0, Math.trunc(config.proxyLogRetentionDays));
|
const days = Math.max(0, Math.trunc(config.proxyLogRetentionDays));
|
||||||
if (days <= 0) return null;
|
if (days <= 0) return null;
|
||||||
return formatUtcSqlDateTime(new Date(nowMs - days * DAY_MS));
|
return getLogCleanupCutoffUtc(days, nowMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanupExpiredProxyLogs(nowMs = Date.now()): Promise<{
|
export async function cleanupExpiredProxyLogs(nowMs = Date.now()): Promise<{
|
||||||
@@ -30,10 +26,7 @@ export async function cleanupExpiredProxyLogs(nowMs = Date.now()): Promise<{
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleted = (await db.delete(schema.proxyLogs)
|
const { deleted } = await cleanupUsageLogs(retentionDays, nowMs);
|
||||||
.where(lt(schema.proxyLogs.createdAt, cutoffUtc))
|
|
||||||
.run())
|
|
||||||
.changes;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -68,3 +61,11 @@ export function stopProxyLogRetentionService(): void {
|
|||||||
clearInterval(retentionTimer);
|
clearInterval(retentionTimer);
|
||||||
retentionTimer = null;
|
retentionTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setLegacyProxyLogRetentionFallbackEnabled(enabled: boolean): void {
|
||||||
|
if (enabled) {
|
||||||
|
startProxyLogRetentionService();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopProxyLogRetentionService();
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { Headers, type HeadersInit } from 'undici';
|
||||||
|
|
||||||
|
export type SiteCustomHeadersRecord = Record<string, string>;
|
||||||
|
|
||||||
|
export type ParsedSiteCustomHeadersInput = {
|
||||||
|
present: boolean;
|
||||||
|
valid: boolean;
|
||||||
|
customHeaders: string | null;
|
||||||
|
headers: SiteCustomHeadersRecord | null;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Object.prototype.toString.call(value) === '[object Object]';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSiteCustomHeadersRecord(input: Record<string, unknown>): SiteCustomHeadersRecord | null {
|
||||||
|
const normalized = new Headers();
|
||||||
|
|
||||||
|
for (const [rawKey, rawValue] of Object.entries(input)) {
|
||||||
|
const key = rawKey.trim();
|
||||||
|
if (!key) {
|
||||||
|
throw new Error('Header name cannot be empty.');
|
||||||
|
}
|
||||||
|
if (typeof rawValue !== 'string') {
|
||||||
|
throw new Error(`Header "${key}" must use a string value.`);
|
||||||
|
}
|
||||||
|
normalized.set(key, rawValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = Array.from(normalized.entries()).sort(([left], [right]) => left.localeCompare(right));
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.fromEntries(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSiteCustomHeadersInput(input: unknown): ParsedSiteCustomHeadersInput {
|
||||||
|
if (input === undefined) {
|
||||||
|
return { present: false, valid: true, customHeaders: null, headers: null };
|
||||||
|
}
|
||||||
|
if (input === null) {
|
||||||
|
return { present: true, valid: true, customHeaders: null, headers: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedInput: unknown = input;
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return { present: true, valid: true, customHeaders: null, headers: null };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
parsedInput = JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
present: true,
|
||||||
|
valid: false,
|
||||||
|
customHeaders: null,
|
||||||
|
headers: null,
|
||||||
|
error: 'Invalid customHeaders. Expected a JSON object like {"x-header":"value"}.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlainObject(parsedInput)) {
|
||||||
|
return {
|
||||||
|
present: true,
|
||||||
|
valid: false,
|
||||||
|
customHeaders: null,
|
||||||
|
headers: null,
|
||||||
|
error: 'Invalid customHeaders. Expected a JSON object like {"x-header":"value"}.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers = normalizeSiteCustomHeadersRecord(parsedInput);
|
||||||
|
return {
|
||||||
|
present: true,
|
||||||
|
valid: true,
|
||||||
|
customHeaders: headers ? JSON.stringify(headers) : null,
|
||||||
|
headers,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
present: true,
|
||||||
|
valid: false,
|
||||||
|
customHeaders: null,
|
||||||
|
headers: null,
|
||||||
|
error: error instanceof Error
|
||||||
|
? `Invalid customHeaders. ${error.message}`
|
||||||
|
: 'Invalid customHeaders. Expected a JSON object like {"x-header":"value"}.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readSiteCustomHeaders(input: unknown): SiteCustomHeadersRecord | null {
|
||||||
|
const parsed = parseSiteCustomHeadersInput(input);
|
||||||
|
if (!parsed.valid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed.headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeHeadersWithSiteCustomHeaders(
|
||||||
|
siteCustomHeaders: unknown,
|
||||||
|
requestHeaders?: HeadersInit,
|
||||||
|
): HeadersInit | undefined {
|
||||||
|
const normalizedSiteHeaders = readSiteCustomHeaders(siteCustomHeaders);
|
||||||
|
if (!normalizedSiteHeaders) {
|
||||||
|
return requestHeaders;
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged = new Headers(normalizedSiteHeaders);
|
||||||
|
if (requestHeaders) {
|
||||||
|
const explicitHeaders = new Headers(requestHeaders);
|
||||||
|
explicitHeaders.forEach((value, key) => {
|
||||||
|
merged.set(key, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { mkdtempSync } from 'node:fs';
|
|||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { Headers } from 'undici';
|
||||||
|
|
||||||
type DbModule = typeof import('../db/index.js');
|
type DbModule = typeof import('../db/index.js');
|
||||||
|
|
||||||
@@ -69,4 +70,49 @@ describe('siteProxy', () => {
|
|||||||
|
|
||||||
expect('dispatcher' in requestInit).toBe(true);
|
expect('dispatcher' in requestInit).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('merges site custom headers by matched request url and keeps explicit headers authoritative', async () => {
|
||||||
|
await db.insert(schema.sites).values({
|
||||||
|
name: 'headers-site',
|
||||||
|
url: 'https://headers-site.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
customHeaders: JSON.stringify({
|
||||||
|
'cf-access-client-id': 'site-client',
|
||||||
|
authorization: 'Bearer site-default',
|
||||||
|
}),
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const { withSiteProxyRequestInit } = await import('./siteProxy.js');
|
||||||
|
const requestInit = await withSiteProxyRequestInit('https://headers-site.example.com/v1/models', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer request-token',
|
||||||
|
'X-Trace-Id': 'trace-1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const headers = new Headers(requestInit.headers);
|
||||||
|
|
||||||
|
expect(headers.get('cf-access-client-id')).toBe('site-client');
|
||||||
|
expect(headers.get('authorization')).toBe('Bearer request-token');
|
||||||
|
expect(headers.get('x-trace-id')).toBe('trace-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges site custom headers from site records even without cache lookup', async () => {
|
||||||
|
const { withSiteRecordProxyRequestInit } = await import('./siteProxy.js');
|
||||||
|
const requestInit = withSiteRecordProxyRequestInit({
|
||||||
|
useSystemProxy: false,
|
||||||
|
customHeaders: JSON.stringify({
|
||||||
|
'x-site-scope': 'site-level',
|
||||||
|
}),
|
||||||
|
}, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-Request-Id': 'req-1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const headers = new Headers(requestInit.headers);
|
||||||
|
|
||||||
|
expect(headers.get('x-site-scope')).toBe('site-level');
|
||||||
|
expect(headers.get('x-request-id')).toBe('req-1');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { eq } from 'drizzle-orm';
|
|||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import type { Dispatcher, RequestInit as UndiciRequestInit } from 'undici';
|
import type { Dispatcher, RequestInit as UndiciRequestInit } from 'undici';
|
||||||
import { ProxyAgent } from 'undici';
|
import { ProxyAgent } from 'undici';
|
||||||
|
import { mergeHeadersWithSiteCustomHeaders } from './siteCustomHeaders.js';
|
||||||
|
|
||||||
const SITE_PROXY_CACHE_TTL_MS = 3_000;
|
const SITE_PROXY_CACHE_TTL_MS = 3_000;
|
||||||
const SUPPORTED_PROXY_PROTOCOLS = new Set([
|
const SUPPORTED_PROXY_PROTOCOLS = new Set([
|
||||||
@@ -18,6 +19,7 @@ const SUPPORTED_PROXY_PROTOCOLS = new Set([
|
|||||||
type SiteProxyRow = {
|
type SiteProxyRow = {
|
||||||
siteUrl: string;
|
siteUrl: string;
|
||||||
useSystemProxy: boolean;
|
useSystemProxy: boolean;
|
||||||
|
customHeaders: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ParsedSiteProxyInput = {
|
type ParsedSiteProxyInput = {
|
||||||
@@ -28,6 +30,7 @@ type ParsedSiteProxyInput = {
|
|||||||
|
|
||||||
type SiteProxyConfigLike = {
|
type SiteProxyConfigLike = {
|
||||||
useSystemProxy?: boolean | null;
|
useSystemProxy?: boolean | null;
|
||||||
|
customHeaders?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
let siteProxyCache: {
|
let siteProxyCache: {
|
||||||
@@ -67,6 +70,7 @@ async function getCachedSiteProxyRows(nowMs = Date.now()): Promise<SiteProxyRow[
|
|||||||
.select({
|
.select({
|
||||||
siteUrl: schema.sites.url,
|
siteUrl: schema.sites.url,
|
||||||
useSystemProxy: schema.sites.useSystemProxy,
|
useSystemProxy: schema.sites.useSystemProxy,
|
||||||
|
customHeaders: schema.sites.customHeaders,
|
||||||
})
|
})
|
||||||
.from(schema.sites)
|
.from(schema.sites)
|
||||||
.all(),
|
.all(),
|
||||||
@@ -92,6 +96,7 @@ async function getCachedSiteProxyRows(nowMs = Date.now()): Promise<SiteProxyRow[
|
|||||||
rows: rows.map((row) => ({
|
rows: rows.map((row) => ({
|
||||||
siteUrl: normalizeSiteUrl(row.siteUrl),
|
siteUrl: normalizeSiteUrl(row.siteUrl),
|
||||||
useSystemProxy: !!row.useSystemProxy,
|
useSystemProxy: !!row.useSystemProxy,
|
||||||
|
customHeaders: typeof row.customHeaders === 'string' ? row.customHeaders : null,
|
||||||
})),
|
})),
|
||||||
systemProxyUrl: parsedSystemProxyUrl,
|
systemProxyUrl: parsedSystemProxyUrl,
|
||||||
};
|
};
|
||||||
@@ -167,18 +172,11 @@ export function invalidateSiteProxyCache(): void {
|
|||||||
siteProxyCache = { loadedAt: 0, rows: [], systemProxyUrl: null };
|
siteProxyCache = { loadedAt: 0, rows: [], systemProxyUrl: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveSiteProxyUrlByRequestUrl(requestUrl: string): Promise<string | null> {
|
function findBestMatchingSiteRow(rows: SiteProxyRow[], normalizedRequestUrl: string): SiteProxyRow | null {
|
||||||
const normalizedRequestUrl = normalizeSiteUrl(requestUrl);
|
let bestMatch: SiteProxyRow | null = null;
|
||||||
if (!normalizedRequestUrl) return null;
|
|
||||||
|
|
||||||
const rows = await getCachedSiteProxyRows();
|
|
||||||
const systemProxyUrl = siteProxyCache.systemProxyUrl;
|
|
||||||
if (!systemProxyUrl) return null;
|
|
||||||
let bestMatch: string | null = null;
|
|
||||||
let bestMatchLength = -1;
|
let bestMatchLength = -1;
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (!row.useSystemProxy) continue;
|
|
||||||
if (!row.siteUrl) continue;
|
if (!row.siteUrl) continue;
|
||||||
|
|
||||||
const isPrefixMatch = (
|
const isPrefixMatch = (
|
||||||
@@ -189,7 +187,7 @@ export async function resolveSiteProxyUrlByRequestUrl(requestUrl: string): Promi
|
|||||||
if (!isPrefixMatch) continue;
|
if (!isPrefixMatch) continue;
|
||||||
|
|
||||||
if (row.siteUrl.length > bestMatchLength) {
|
if (row.siteUrl.length > bestMatchLength) {
|
||||||
bestMatch = systemProxyUrl;
|
bestMatch = row;
|
||||||
bestMatchLength = row.siteUrl.length;
|
bestMatchLength = row.siteUrl.length;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,18 +195,53 @@ export async function resolveSiteProxyUrlByRequestUrl(requestUrl: string): Promi
|
|||||||
return bestMatch;
|
return bestMatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveSiteRequestConfigByRequestUrl(requestUrl: string): Promise<{
|
||||||
|
proxyUrl: string | null;
|
||||||
|
customHeaders: string | null;
|
||||||
|
}> {
|
||||||
|
const normalizedRequestUrl = normalizeSiteUrl(requestUrl);
|
||||||
|
if (!normalizedRequestUrl) {
|
||||||
|
return { proxyUrl: null, customHeaders: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await getCachedSiteProxyRows();
|
||||||
|
const matchedRow = findBestMatchingSiteRow(rows, normalizedRequestUrl);
|
||||||
|
const proxyUrl = matchedRow?.useSystemProxy ? siteProxyCache.systemProxyUrl : null;
|
||||||
|
return {
|
||||||
|
proxyUrl: proxyUrl || null,
|
||||||
|
customHeaders: matchedRow?.customHeaders ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveSiteProxyUrlByRequestUrl(requestUrl: string): Promise<string | null> {
|
||||||
|
const resolved = await resolveSiteRequestConfigByRequestUrl(requestUrl);
|
||||||
|
return resolved.proxyUrl;
|
||||||
|
}
|
||||||
|
|
||||||
export async function withSiteProxyRequestInit(
|
export async function withSiteProxyRequestInit(
|
||||||
requestUrl: string,
|
requestUrl: string,
|
||||||
options?: UndiciRequestInit,
|
options?: UndiciRequestInit,
|
||||||
): Promise<UndiciRequestInit> {
|
): Promise<UndiciRequestInit> {
|
||||||
const proxyUrl = await resolveSiteProxyUrlByRequestUrl(requestUrl);
|
const resolved = await resolveSiteRequestConfigByRequestUrl(requestUrl);
|
||||||
if (!proxyUrl) return options ?? {};
|
const nextOptions: UndiciRequestInit = {
|
||||||
|
...(options || {}),
|
||||||
|
};
|
||||||
|
const mergedHeaders = mergeHeadersWithSiteCustomHeaders(resolved.customHeaders, options?.headers);
|
||||||
|
if (mergedHeaders) {
|
||||||
|
nextOptions.headers = mergedHeaders;
|
||||||
|
}
|
||||||
|
|
||||||
const dispatcher = getDispatcherByProxyUrl(proxyUrl);
|
if (!resolved.proxyUrl) {
|
||||||
if (!dispatcher) return options ?? {};
|
return nextOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dispatcher = getDispatcherByProxyUrl(resolved.proxyUrl);
|
||||||
|
if (!dispatcher) {
|
||||||
|
return nextOptions;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...(options || {}),
|
...nextOptions,
|
||||||
dispatcher,
|
dispatcher,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -238,5 +271,12 @@ export function withSiteRecordProxyRequestInit(
|
|||||||
site: SiteProxyConfigLike | null | undefined,
|
site: SiteProxyConfigLike | null | undefined,
|
||||||
options?: UndiciRequestInit,
|
options?: UndiciRequestInit,
|
||||||
): UndiciRequestInit {
|
): UndiciRequestInit {
|
||||||
return withExplicitProxyRequestInit(resolveProxyUrlForSite(site), options);
|
const nextOptions: UndiciRequestInit = {
|
||||||
|
...(options || {}),
|
||||||
|
};
|
||||||
|
const mergedHeaders = mergeHeadersWithSiteCustomHeaders(site?.customHeaders, options?.headers);
|
||||||
|
if (mergedHeaders) {
|
||||||
|
nextOptions.headers = mergedHeaders;
|
||||||
|
}
|
||||||
|
return withExplicitProxyRequestInit(resolveProxyUrlForSite(site), nextOptions);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -320,4 +320,202 @@ describe('Models marketplace text', () => {
|
|||||||
root?.unmount();
|
root?.unmount();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('re-sorts models using site-scoped counts after selecting a site filter', async () => {
|
||||||
|
apiMock.getModelsMarketplace.mockResolvedValue({
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
name: 'gpt-4o',
|
||||||
|
accountCount: 4,
|
||||||
|
tokenCount: 4,
|
||||||
|
avgLatency: 300,
|
||||||
|
successRate: 98,
|
||||||
|
description: null,
|
||||||
|
tags: [],
|
||||||
|
supportedEndpointTypes: [],
|
||||||
|
pricingSources: [],
|
||||||
|
accounts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
site: '站点 A',
|
||||||
|
username: 'user-a-1',
|
||||||
|
latency: 300,
|
||||||
|
balance: 8,
|
||||||
|
tokens: [{ id: 1, name: 'token-a-1', isDefault: true }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
site: '站点 B',
|
||||||
|
username: 'user-b-1',
|
||||||
|
latency: 200,
|
||||||
|
balance: 8,
|
||||||
|
tokens: [{ id: 2, name: 'token-b-1', isDefault: true }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
site: '站点 B',
|
||||||
|
username: 'user-b-2',
|
||||||
|
latency: 250,
|
||||||
|
balance: 8,
|
||||||
|
tokens: [{ id: 3, name: 'token-b-2', isDefault: true }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
site: '站点 B',
|
||||||
|
username: 'user-b-3',
|
||||||
|
latency: 260,
|
||||||
|
balance: 8,
|
||||||
|
tokens: [{ id: 4, name: 'token-b-3', isDefault: true }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'claude-3-5-sonnet',
|
||||||
|
accountCount: 2,
|
||||||
|
tokenCount: 2,
|
||||||
|
avgLatency: 420,
|
||||||
|
successRate: 95,
|
||||||
|
description: null,
|
||||||
|
tags: [],
|
||||||
|
supportedEndpointTypes: [],
|
||||||
|
pricingSources: [],
|
||||||
|
accounts: [
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
site: '站点 A',
|
||||||
|
username: 'user-a-2',
|
||||||
|
latency: 410,
|
||||||
|
balance: 9,
|
||||||
|
tokens: [{ id: 5, name: 'token-a-2', isDefault: true }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
site: '站点 A',
|
||||||
|
username: 'user-a-3',
|
||||||
|
latency: 430,
|
||||||
|
balance: 9,
|
||||||
|
tokens: [{ id: 6, name: 'token-a-3', isDefault: true }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/models']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<Models />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const siteFilterItem = root!.root.find((node) => (
|
||||||
|
node.type === 'div'
|
||||||
|
&& typeof node.props.className === 'string'
|
||||||
|
&& node.props.className.includes('filter-item')
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node).includes('站点 A')
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
siteFilterItem.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const cards = root!.root.findAll((node) => (
|
||||||
|
node.type === 'div'
|
||||||
|
&& typeof node.props.className === 'string'
|
||||||
|
&& node.props.className.split(' ').includes('model-card')
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(cards.length).toBe(2);
|
||||||
|
expect(collectText(cards[0]!)).toContain('claude-3-5-sonnet');
|
||||||
|
expect(collectText(cards[1]!)).toContain('gpt-4o');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders unknown latency instead of falling back to another site latency', async () => {
|
||||||
|
apiMock.getModelsMarketplace.mockResolvedValue({
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
name: 'gpt-4o',
|
||||||
|
accountCount: 2,
|
||||||
|
tokenCount: 2,
|
||||||
|
avgLatency: 680,
|
||||||
|
successRate: 93,
|
||||||
|
description: null,
|
||||||
|
tags: [],
|
||||||
|
supportedEndpointTypes: [],
|
||||||
|
pricingSources: [],
|
||||||
|
accounts: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
site: '站点 A',
|
||||||
|
username: 'user-a',
|
||||||
|
latency: null,
|
||||||
|
balance: 12,
|
||||||
|
tokens: [{ id: 1, name: 'token-a', isDefault: true }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
site: '站点 B',
|
||||||
|
username: 'user-b',
|
||||||
|
latency: 680,
|
||||||
|
balance: 12,
|
||||||
|
tokens: [{ id: 2, name: 'token-b', isDefault: true }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/models']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<Models />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const siteFilterItem = root!.root.find((node) => (
|
||||||
|
node.type === 'div'
|
||||||
|
&& typeof node.props.className === 'string'
|
||||||
|
&& node.props.className.includes('filter-item')
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node).includes('站点 A')
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
siteFilterItem.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const latencyBadge = root!.root.find((node) => (
|
||||||
|
node.type === 'span'
|
||||||
|
&& node.props['data-tooltip'] === '平均延迟'
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(String(latencyBadge.props.className || '')).toContain('badge-muted');
|
||||||
|
expect(collectText(latencyBadge)).toContain('延迟');
|
||||||
|
expect(collectText(latencyBadge)).toContain('—');
|
||||||
|
expect(collectText(root!.root)).not.toContain('680ms');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+59
-30
@@ -49,7 +49,7 @@ interface ModelRow {
|
|||||||
name: string;
|
name: string;
|
||||||
accountCount: number;
|
accountCount: number;
|
||||||
tokenCount: number;
|
tokenCount: number;
|
||||||
avgLatency: number;
|
avgLatency: number | null;
|
||||||
successRate: number | null;
|
successRate: number | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
@@ -68,7 +68,13 @@ interface ModelsMarketplaceResponse {
|
|||||||
refreshJobId?: string | null;
|
refreshJobId?: string | null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function getMetricColor(latency: number) {
|
|
||||||
|
function isKnownLatency(latency: number | null | undefined): latency is number {
|
||||||
|
return typeof latency === 'number' && Number.isFinite(latency);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMetricColor(latency: number | null) {
|
||||||
|
if (!isKnownLatency(latency)) return 'var(--color-text-muted)';
|
||||||
if (latency >= 3000) return 'var(--color-danger)';
|
if (latency >= 3000) return 'var(--color-danger)';
|
||||||
if (latency >= 2000) return 'color-mix(in srgb, var(--color-warning) 30%, var(--color-danger))';
|
if (latency >= 2000) return 'color-mix(in srgb, var(--color-warning) 30%, var(--color-danger))';
|
||||||
if (latency >= 1500) return 'color-mix(in srgb, var(--color-warning) 60%, var(--color-danger))';
|
if (latency >= 1500) return 'color-mix(in srgb, var(--color-warning) 60%, var(--color-danger))';
|
||||||
@@ -77,12 +83,17 @@ function getMetricColor(latency: number) {
|
|||||||
return 'var(--color-success)';
|
return 'var(--color-success)';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLatencyBadgeClass(latency: number) {
|
function getLatencyBadgeClass(latency: number | null) {
|
||||||
|
if (!isKnownLatency(latency)) return 'badge-muted';
|
||||||
if (latency >= 3000) return 'badge-error';
|
if (latency >= 3000) return 'badge-error';
|
||||||
if (latency >= 1000) return 'badge-warning';
|
if (latency >= 1000) return 'badge-warning';
|
||||||
return 'badge-success';
|
return 'badge-success';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatLatency(latency: number | null): string {
|
||||||
|
return isKnownLatency(latency) ? `${latency}ms` : '—';
|
||||||
|
}
|
||||||
|
|
||||||
function getSuccessBadgeClass(rate: number | null) {
|
function getSuccessBadgeClass(rate: number | null) {
|
||||||
if (rate == null) return 'badge-muted';
|
if (rate == null) return 'badge-muted';
|
||||||
if (rate >= 90) return 'badge-success';
|
if (rate >= 90) return 'badge-success';
|
||||||
@@ -113,6 +124,29 @@ function renderGroupPricingValue(pricing: ModelGroupPricing): string {
|
|||||||
|
|
||||||
const PAGE_SIZES = [10, 20, 50];
|
const PAGE_SIZES = [10, 20, 50];
|
||||||
|
|
||||||
|
function compareModels(a: ModelRow, b: ModelRow, sortBy: SortColumn, sortDir: 'asc' | 'desc'): number {
|
||||||
|
if (sortBy === 'name') {
|
||||||
|
const cmp = a.name.localeCompare(b.name);
|
||||||
|
return sortDir === 'asc' ? cmp : -cmp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveNumericSortValue = (model: ModelRow) => {
|
||||||
|
if (sortBy === 'successRate') return model.successRate ?? -1;
|
||||||
|
if (sortBy === 'avgLatency') {
|
||||||
|
if (!isKnownLatency(model.avgLatency)) {
|
||||||
|
return sortDir === 'asc' ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY;
|
||||||
|
}
|
||||||
|
return model.avgLatency;
|
||||||
|
}
|
||||||
|
return model[sortBy] ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const va = resolveNumericSortValue(a);
|
||||||
|
const vb = resolveNumericSortValue(b);
|
||||||
|
if (va === vb) return a.name.localeCompare(b.name);
|
||||||
|
return sortDir === 'desc' ? vb - va : va - vb;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- component ---- */
|
/* ---- component ---- */
|
||||||
export default function Models() {
|
export default function Models() {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -259,7 +293,7 @@ export default function Models() {
|
|||||||
return [...m.entries()].sort((a, b) => b[1] - a[1]);
|
return [...m.entries()].sort((a, b) => b[1] - a[1]);
|
||||||
}, [data.models]);
|
}, [data.models]);
|
||||||
|
|
||||||
/* ---- filtered + sorted ---- */
|
/* ---- filtered ---- */
|
||||||
const filteredModels = useMemo(() => {
|
const filteredModels = useMemo(() => {
|
||||||
let list = data.models;
|
let list = data.models;
|
||||||
|
|
||||||
@@ -280,29 +314,19 @@ export default function Models() {
|
|||||||
list = list.filter(m => m.name.toLowerCase().includes(q));
|
list = list.filter(m => m.name.toLowerCase().includes(q));
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...list].sort((a, b) => {
|
return list;
|
||||||
if (sortBy === 'name') {
|
}, [data.models, search, activeSite, activeBrand]);
|
||||||
const cmp = a.name.localeCompare(b.name);
|
|
||||||
return sortDir === 'asc' ? cmp : -cmp;
|
|
||||||
}
|
|
||||||
const va = sortBy === 'successRate' ? (a.successRate ?? -1) : (a[sortBy] ?? 0);
|
|
||||||
const vb = sortBy === 'successRate' ? (b.successRate ?? -1) : (b[sortBy] ?? 0);
|
|
||||||
if (va === vb) return a.name.localeCompare(b.name);
|
|
||||||
return sortDir === 'desc' ? vb - va : va - vb;
|
|
||||||
});
|
|
||||||
}, [data.models, search, activeSite, activeBrand, sortBy, sortDir]);
|
|
||||||
|
|
||||||
// Keep expanded detail consistent with filters (especially site filter).
|
// Keep expanded detail consistent with filters (especially site filter).
|
||||||
// The list-level filter uses "model has at least one account on this site" semantics;
|
// The list-level filter uses "model has at least one account on this site" semantics;
|
||||||
// once a model is shown, its detail should honor the active site as well.
|
// once a model is shown, its detail should honor the active site as well.
|
||||||
const detailModels = useMemo(() => {
|
const detailModels = useMemo(() => {
|
||||||
if (!activeSite) return filteredModels;
|
const scopedModels = activeSite ? filteredModels.map((model) => {
|
||||||
return filteredModels.map((model) => {
|
|
||||||
const accounts = model.accounts.filter((account) => account.site === activeSite);
|
const accounts = model.accounts.filter((account) => account.site === activeSite);
|
||||||
const pricingSources = model.pricingSources.filter((source) => source.siteName === activeSite);
|
const pricingSources = model.pricingSources.filter((source) => source.siteName === activeSite);
|
||||||
const latencyValues = accounts
|
const latencyValues = accounts
|
||||||
.map((account) => account.latency)
|
.map((account) => account.latency)
|
||||||
.filter((latency): latency is number => typeof latency === 'number' && Number.isFinite(latency));
|
.filter(isKnownLatency);
|
||||||
return {
|
return {
|
||||||
...model,
|
...model,
|
||||||
accounts,
|
accounts,
|
||||||
@@ -311,13 +335,15 @@ export default function Models() {
|
|||||||
tokenCount: accounts.reduce((sum, account) => sum + account.tokens.length, 0),
|
tokenCount: accounts.reduce((sum, account) => sum + account.tokens.length, 0),
|
||||||
avgLatency: latencyValues.length > 0
|
avgLatency: latencyValues.length > 0
|
||||||
? Math.round(latencyValues.reduce((sum, latency) => sum + latency, 0) / latencyValues.length)
|
? Math.round(latencyValues.reduce((sum, latency) => sum + latency, 0) / latencyValues.length)
|
||||||
: model.avgLatency,
|
: null,
|
||||||
};
|
};
|
||||||
});
|
}) : filteredModels;
|
||||||
}, [filteredModels, activeSite]);
|
|
||||||
|
return [...scopedModels].sort((a, b) => compareModels(a, b, sortBy, sortDir));
|
||||||
|
}, [filteredModels, activeSite, sortBy, sortDir]);
|
||||||
|
|
||||||
/* ---- pagination ---- */
|
/* ---- pagination ---- */
|
||||||
const totalPages = Math.max(1, Math.ceil(filteredModels.length / pageSize));
|
const totalPages = Math.max(1, Math.ceil(detailModels.length / pageSize));
|
||||||
const safePageVal = Math.min(page, totalPages);
|
const safePageVal = Math.min(page, totalPages);
|
||||||
const paged = detailModels.slice((safePageVal - 1) * pageSize, safePageVal * pageSize);
|
const paged = detailModels.slice((safePageVal - 1) * pageSize, safePageVal * pageSize);
|
||||||
|
|
||||||
@@ -334,9 +360,12 @@ export default function Models() {
|
|||||||
}
|
}
|
||||||
return ids.size;
|
return ids.size;
|
||||||
})();
|
})();
|
||||||
const avgLatency = detailModels.length
|
const latencyMetrics = detailModels
|
||||||
? Math.round(detailModels.reduce((s, m) => s + m.avgLatency, 0) / detailModels.length)
|
.map((model) => model.avgLatency)
|
||||||
: 0;
|
.filter(isKnownLatency);
|
||||||
|
const avgLatency = latencyMetrics.length > 0
|
||||||
|
? Math.round(latencyMetrics.reduce((sum, latency) => sum + latency, 0) / latencyMetrics.length)
|
||||||
|
: null;
|
||||||
|
|
||||||
/* ---- copy ---- */
|
/* ---- copy ---- */
|
||||||
const copyName = (name: string) => {
|
const copyName = (name: string) => {
|
||||||
@@ -532,12 +561,12 @@ export default function Models() {
|
|||||||
<span data-tooltip={tr('当前筛选范围内去重后的唯一账号数')}>
|
<span data-tooltip={tr('当前筛选范围内去重后的唯一账号数')}>
|
||||||
{tr('去重账号')} <b style={{ color: 'var(--color-text-primary)' }}>{uniqueAccountCount}</b>
|
{tr('去重账号')} <b style={{ color: 'var(--color-text-primary)' }}>{uniqueAccountCount}</b>
|
||||||
</span>
|
</span>
|
||||||
<span>{tr('平均延迟')} <b style={{ color: getMetricColor(avgLatency) }}>{avgLatency}ms</b></span>
|
<span>{tr('平均延迟')} <b style={{ color: getMetricColor(avgLatency) }}>{formatLatency(avgLatency)}</b></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Empty */}
|
{/* Empty */}
|
||||||
{filteredModels.length === 0 ? (
|
{detailModels.length === 0 ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<div className="empty-state-icon">
|
<div className="empty-state-icon">
|
||||||
<svg width="40" height="40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg width="40" height="40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
@@ -572,7 +601,7 @@ export default function Models() {
|
|||||||
style={{ fontVariantNumeric: 'tabular-nums' }}
|
style={{ fontVariantNumeric: 'tabular-nums' }}
|
||||||
data-tooltip={tr('平均延迟')}
|
data-tooltip={tr('平均延迟')}
|
||||||
>
|
>
|
||||||
{tr('延迟')} {m.avgLatency}ms
|
{tr('延迟')} {formatLatency(m.avgLatency)}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`badge ${getSuccessBadgeClass(m.successRate)}`}
|
className={`badge ${getSuccessBadgeClass(m.successRate)}`}
|
||||||
@@ -617,7 +646,7 @@ export default function Models() {
|
|||||||
{m.successRate != null && m.successRate < 60 && (
|
{m.successRate != null && m.successRate < 60 && (
|
||||||
<span className="model-tag model-tag-orange">{tr('风险')}</span>
|
<span className="model-tag model-tag-orange">{tr('风险')}</span>
|
||||||
)}
|
)}
|
||||||
{m.avgLatency <= 500 && (
|
{isKnownLatency(m.avgLatency) && m.avgLatency <= 500 && (
|
||||||
<span className="model-tag model-tag-purple">{tr('低延迟')}</span>
|
<span className="model-tag model-tag-purple">{tr('低延迟')}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -761,7 +790,7 @@ export default function Models() {
|
|||||||
className={`badge ${getLatencyBadgeClass(m.avgLatency)}`}
|
className={`badge ${getLatencyBadgeClass(m.avgLatency)}`}
|
||||||
style={{ fontSize: 12, fontVariantNumeric: 'tabular-nums' }}
|
style={{ fontSize: 12, fontVariantNumeric: 'tabular-nums' }}
|
||||||
>
|
>
|
||||||
{m.avgLatency}ms
|
{formatLatency(m.avgLatency)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ type DbDialect = 'sqlite' | 'mysql' | 'postgres';
|
|||||||
type RuntimeSettings = {
|
type RuntimeSettings = {
|
||||||
checkinCron: string;
|
checkinCron: string;
|
||||||
balanceRefreshCron: string;
|
balanceRefreshCron: string;
|
||||||
|
logCleanupCron: string;
|
||||||
|
logCleanupUsageLogsEnabled: boolean;
|
||||||
|
logCleanupProgramLogsEnabled: boolean;
|
||||||
|
logCleanupRetentionDays: number;
|
||||||
routingFallbackUnitCost: number;
|
routingFallbackUnitCost: number;
|
||||||
routingWeights: RoutingWeights;
|
routingWeights: RoutingWeights;
|
||||||
systemProxyUrl: string;
|
systemProxyUrl: string;
|
||||||
@@ -179,6 +183,10 @@ export default function Settings() {
|
|||||||
const [runtime, setRuntime] = useState<RuntimeSettings>({
|
const [runtime, setRuntime] = useState<RuntimeSettings>({
|
||||||
checkinCron: '0 8 * * *',
|
checkinCron: '0 8 * * *',
|
||||||
balanceRefreshCron: '0 * * * *',
|
balanceRefreshCron: '0 * * * *',
|
||||||
|
logCleanupCron: '0 6 * * *',
|
||||||
|
logCleanupUsageLogsEnabled: false,
|
||||||
|
logCleanupProgramLogsEnabled: false,
|
||||||
|
logCleanupRetentionDays: 30,
|
||||||
routingFallbackUnitCost: 1,
|
routingFallbackUnitCost: 1,
|
||||||
routingWeights: defaultWeights,
|
routingWeights: defaultWeights,
|
||||||
systemProxyUrl: '',
|
systemProxyUrl: '',
|
||||||
@@ -381,6 +389,12 @@ export default function Settings() {
|
|||||||
setRuntime({
|
setRuntime({
|
||||||
checkinCron: runtimeInfo.checkinCron || '0 8 * * *',
|
checkinCron: runtimeInfo.checkinCron || '0 8 * * *',
|
||||||
balanceRefreshCron: runtimeInfo.balanceRefreshCron || '0 * * * *',
|
balanceRefreshCron: runtimeInfo.balanceRefreshCron || '0 * * * *',
|
||||||
|
logCleanupCron: runtimeInfo.logCleanupCron || '0 6 * * *',
|
||||||
|
logCleanupUsageLogsEnabled: !!runtimeInfo.logCleanupUsageLogsEnabled,
|
||||||
|
logCleanupProgramLogsEnabled: !!runtimeInfo.logCleanupProgramLogsEnabled,
|
||||||
|
logCleanupRetentionDays: Number(runtimeInfo.logCleanupRetentionDays) >= 1
|
||||||
|
? Math.trunc(Number(runtimeInfo.logCleanupRetentionDays))
|
||||||
|
: 30,
|
||||||
routingFallbackUnitCost: Number(runtimeInfo.routingFallbackUnitCost) > 0
|
routingFallbackUnitCost: Number(runtimeInfo.routingFallbackUnitCost) > 0
|
||||||
? Number(runtimeInfo.routingFallbackUnitCost)
|
? Number(runtimeInfo.routingFallbackUnitCost)
|
||||||
: 1,
|
: 1,
|
||||||
@@ -452,8 +466,12 @@ export default function Settings() {
|
|||||||
await api.updateRuntimeSettings({
|
await api.updateRuntimeSettings({
|
||||||
checkinCron: runtime.checkinCron,
|
checkinCron: runtime.checkinCron,
|
||||||
balanceRefreshCron: runtime.balanceRefreshCron,
|
balanceRefreshCron: runtime.balanceRefreshCron,
|
||||||
|
logCleanupCron: runtime.logCleanupCron,
|
||||||
|
logCleanupUsageLogsEnabled: runtime.logCleanupUsageLogsEnabled,
|
||||||
|
logCleanupProgramLogsEnabled: runtime.logCleanupProgramLogsEnabled,
|
||||||
|
logCleanupRetentionDays: runtime.logCleanupRetentionDays,
|
||||||
});
|
});
|
||||||
toast.success('Schedule settings saved');
|
toast.success('定时任务设置已保存');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
toast.error(err?.message || '保存失败');
|
toast.error(err?.message || '保存失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -903,6 +921,66 @@ export default function Settings() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
paddingTop: 16,
|
||||||
|
borderTop: '1px solid var(--color-border-light)',
|
||||||
|
display: 'grid',
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 13 }}>自动清理日志</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 160px', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>清理 Cron</div>
|
||||||
|
<input
|
||||||
|
value={runtime.logCleanupCron}
|
||||||
|
onChange={(e) => setRuntime((prev) => ({ ...prev, logCleanupCron: e.target.value }))}
|
||||||
|
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>保留天数</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={runtime.logCleanupRetentionDays}
|
||||||
|
onChange={(e) => setRuntime((prev) => {
|
||||||
|
const nextValue = Number(e.target.value);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
logCleanupRetentionDays: Number.isFinite(nextValue) && nextValue >= 1
|
||||||
|
? Math.trunc(nextValue)
|
||||||
|
: prev.logCleanupRetentionDays,
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 16 }}>
|
||||||
|
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={runtime.logCleanupUsageLogsEnabled}
|
||||||
|
onChange={(e) => setRuntime((prev) => ({ ...prev, logCleanupUsageLogsEnabled: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
清理使用日志
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--color-text-secondary)' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={runtime.logCleanupProgramLogsEnabled}
|
||||||
|
onChange={(e) => setRuntime((prev) => ({ ...prev, logCleanupProgramLogsEnabled: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
清理程序日志
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.6 }}>
|
||||||
|
默认每天早上 6 点执行。按每次定时任务执行时间,清理早于“保留天数”的日志;两个选项都不勾选时不会实际删除日志。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div style={{ marginTop: 12 }}>
|
<div style={{ marginTop: 12 }}>
|
||||||
<button onClick={saveSchedule} disabled={savingSchedule} className="btn btn-primary">
|
<button onClick={saveSchedule} disabled={savingSchedule} className="btn btn-primary">
|
||||||
{savingSchedule ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存定时任务'}
|
{savingSchedule ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : '保存定时任务'}
|
||||||
@@ -1758,4 +1836,3 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+152
-55
@@ -1,10 +1,10 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
|
import CenteredModal from '../components/CenteredModal.js';
|
||||||
import { useToast } from '../components/Toast.js';
|
import { useToast } from '../components/Toast.js';
|
||||||
import ModernSelect from '../components/ModernSelect.js';
|
import ModernSelect from '../components/ModernSelect.js';
|
||||||
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
import { MobileCard, MobileField } from '../components/MobileCard.js';
|
||||||
import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js';
|
|
||||||
import { useIsMobile } from '../components/useIsMobile.js';
|
import { useIsMobile } from '../components/useIsMobile.js';
|
||||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||||
import { clearFocusParams, readFocusSiteId } from './helpers/navigationFocus.js';
|
import { clearFocusParams, readFocusSiteId } from './helpers/navigationFocus.js';
|
||||||
@@ -14,7 +14,9 @@ import { shouldIgnoreRowSelectionClick } from './helpers/rowSelection.js';
|
|||||||
import { resolveInitialConnectionSegment } from './helpers/defaultConnectionSegment.js';
|
import { resolveInitialConnectionSegment } from './helpers/defaultConnectionSegment.js';
|
||||||
import {
|
import {
|
||||||
buildSiteSaveAction,
|
buildSiteSaveAction,
|
||||||
|
emptySiteCustomHeader,
|
||||||
emptySiteForm,
|
emptySiteForm,
|
||||||
|
serializeSiteCustomHeaders,
|
||||||
siteFormFromSite,
|
siteFormFromSite,
|
||||||
type SiteEditorState,
|
type SiteEditorState,
|
||||||
type SiteForm,
|
type SiteForm,
|
||||||
@@ -28,6 +30,7 @@ type SiteRow = {
|
|||||||
platform?: string;
|
platform?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
useSystemProxy?: boolean;
|
useSystemProxy?: boolean;
|
||||||
|
customHeaders?: string | null;
|
||||||
globalWeight?: number;
|
globalWeight?: number;
|
||||||
isPinned?: boolean;
|
isPinned?: boolean;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
@@ -35,6 +38,10 @@ type SiteRow = {
|
|||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function hasConfiguredCustomHeaders(customHeaders?: string | null): boolean {
|
||||||
|
return typeof customHeaders === 'string' && customHeaders.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
const platformColors: Record<string, string> = {
|
const platformColors: Record<string, string> = {
|
||||||
'new-api': 'badge-info',
|
'new-api': 'badge-info',
|
||||||
'one-api': 'badge-success',
|
'one-api': 'badge-success',
|
||||||
@@ -83,7 +90,6 @@ export default function Sites() {
|
|||||||
const [expandedSiteIds, setExpandedSiteIds] = useState<number[]>([]);
|
const [expandedSiteIds, setExpandedSiteIds] = useState<number[]>([]);
|
||||||
const isMobile = useIsMobile(768);
|
const isMobile = useIsMobile(768);
|
||||||
const [batchActionLoading, setBatchActionLoading] = useState(false);
|
const [batchActionLoading, setBatchActionLoading] = useState(false);
|
||||||
const editorPresence = useAnimatedVisibility(Boolean(editor), 220);
|
|
||||||
const lastEditorRef = useRef<SiteEditorState | null>(null);
|
const lastEditorRef = useRef<SiteEditorState | null>(null);
|
||||||
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
|
const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map());
|
||||||
const highlightTimerRef = useRef<number | null>(null);
|
const highlightTimerRef = useRef<number | null>(null);
|
||||||
@@ -93,6 +99,16 @@ export default function Sites() {
|
|||||||
const activeEditor = editor || lastEditorRef.current;
|
const activeEditor = editor || lastEditorRef.current;
|
||||||
const isEditing = activeEditor?.mode === 'edit';
|
const isEditing = activeEditor?.mode === 'edit';
|
||||||
const isAdding = activeEditor?.mode === 'add';
|
const isAdding = activeEditor?.mode === 'add';
|
||||||
|
const formInputStyle = {
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 14px',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
fontSize: 13,
|
||||||
|
outline: 'none',
|
||||||
|
background: 'var(--color-bg)',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
} as const;
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -190,6 +206,11 @@ export default function Sites() {
|
|||||||
toast.error('全局权重必须是大于 0 的数字');
|
toast.error('全局权重必须是大于 0 的数字');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const serializedCustomHeaders = serializeSiteCustomHeaders(form.customHeaders);
|
||||||
|
if (!serializedCustomHeaders.valid) {
|
||||||
|
toast.error(serializedCustomHeaders.error || '自定义请求头格式不正确');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
@@ -197,6 +218,7 @@ export default function Sites() {
|
|||||||
externalCheckinUrl: form.externalCheckinUrl.trim(),
|
externalCheckinUrl: form.externalCheckinUrl.trim(),
|
||||||
platform: form.platform.trim(),
|
platform: form.platform.trim(),
|
||||||
useSystemProxy: !!form.useSystemProxy,
|
useSystemProxy: !!form.useSystemProxy,
|
||||||
|
customHeaders: serializedCustomHeaders.customHeaders,
|
||||||
globalWeight: Number(parsedGlobalWeight.toFixed(3)),
|
globalWeight: Number(parsedGlobalWeight.toFixed(3)),
|
||||||
};
|
};
|
||||||
if (!payload.name || !payload.url) {
|
if (!payload.name || !payload.url) {
|
||||||
@@ -238,6 +260,34 @@ export default function Sites() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateCustomHeaderRow = (index: number, field: 'key' | 'value', value: string) => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
customHeaders: prev.customHeaders.map((item, itemIndex) => (
|
||||||
|
itemIndex === index
|
||||||
|
? { ...item, [field]: value }
|
||||||
|
: item
|
||||||
|
)),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCustomHeaderRow = () => {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
customHeaders: [...prev.customHeaders, emptySiteCustomHeader()],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCustomHeaderRow = (index: number) => {
|
||||||
|
setForm((prev) => {
|
||||||
|
const nextHeaders = prev.customHeaders.filter((_, itemIndex) => itemIndex !== index);
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
customHeaders: nextHeaders.length > 0 ? nextHeaders : [emptySiteCustomHeader()],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleDetect = async () => {
|
const handleDetect = async () => {
|
||||||
if (!form.url.trim()) {
|
if (!form.url.trim()) {
|
||||||
toast.error('请先输入 URL');
|
toast.error('请先输入 URL');
|
||||||
@@ -465,33 +515,45 @@ export default function Sites() {
|
|||||||
站点权重说明:最终站点倍率 = 站点全局权重 × 设置页中下游 API Key 的站点倍率。它会与路由策略因子(基础权重、价值分、成本、余额、使用频次)共同作用。数值越大,该站点在同优先级下越容易被选中。建议范围 0.5-3,默认 1;长期不建议超过 5。
|
站点权重说明:最终站点倍率 = 站点全局权重 × 设置页中下游 API Key 的站点倍率。它会与路由策略因子(基础权重、价值分、成本、余额、使用频次)共同作用。数值越大,该站点在同优先级下越容易被选中。建议范围 0.5-3,默认 1;长期不建议超过 5。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editorPresence.shouldRender && activeEditor && (
|
{activeEditor && (
|
||||||
<div className={`card panel-presence ${editorPresence.isVisible ? '' : 'is-closing'}`.trim()} style={{ padding: 20, marginBottom: 16 }}>
|
<CenteredModal
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
open={Boolean(editor)}
|
||||||
|
onClose={closeEditor}
|
||||||
|
title={(
|
||||||
<div style={{ fontSize: 14, fontWeight: 600 }}>
|
<div style={{ fontSize: 14, fontWeight: 600 }}>
|
||||||
{isEditing ? '编辑站点' : '添加站点'}
|
{isEditing ? '编辑站点' : '添加站点'}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={closeEditor} className="btn btn-ghost" style={{ fontSize: 12, padding: '4px 10px' }}>
|
)}
|
||||||
|
maxWidth={920}
|
||||||
|
bodyStyle={{
|
||||||
|
maxHeight: isMobile ? '78vh' : '72vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
footer={(
|
||||||
|
<>
|
||||||
|
<button onClick={closeEditor} className="btn btn-ghost" style={{ border: '1px solid var(--color-border)' }}>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<button
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
onClick={handleSave}
|
||||||
|
disabled={saving || !form.name.trim() || !form.url.trim()}
|
||||||
|
className="btn btn-primary"
|
||||||
|
>
|
||||||
|
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : (isEditing ? '保存修改' : '保存站点')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
placeholder="站点名称"
|
placeholder="站点名称"
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||||
style={{
|
style={formInputStyle}
|
||||||
width: '100%',
|
|
||||||
padding: '10px 14px',
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
fontSize: 13,
|
|
||||||
outline: 'none',
|
|
||||||
background: 'var(--color-bg)',
|
|
||||||
color: 'var(--color-text-primary)',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8, flexDirection: isMobile ? 'column' : 'row' }}>
|
||||||
<input
|
<input
|
||||||
placeholder="站点 URL (例如 https://api.example.com)"
|
placeholder="站点 URL (例如 https://api.example.com)"
|
||||||
value={form.url}
|
value={form.url}
|
||||||
@@ -501,16 +563,7 @@ export default function Sites() {
|
|||||||
handleDetect();
|
handleDetect();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{ ...formInputStyle, flex: 1 }}
|
||||||
flex: 1,
|
|
||||||
padding: '10px 14px',
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
fontSize: 13,
|
|
||||||
outline: 'none',
|
|
||||||
background: 'var(--color-bg)',
|
|
||||||
color: 'var(--color-text-primary)',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleDetect}
|
onClick={handleDetect}
|
||||||
@@ -540,17 +593,68 @@ export default function Sites() {
|
|||||||
placeholder="外部签到/福利站点 URL(可选)"
|
placeholder="外部签到/福利站点 URL(可选)"
|
||||||
value={form.externalCheckinUrl}
|
value={form.externalCheckinUrl}
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, externalCheckinUrl: e.target.value }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, externalCheckinUrl: e.target.value }))}
|
||||||
|
style={formInputStyle}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
display: 'flex',
|
||||||
padding: '10px 14px',
|
flexDirection: 'column',
|
||||||
|
gap: 10,
|
||||||
|
padding: 12,
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
borderRadius: 'var(--radius-sm)',
|
borderRadius: 'var(--radius-sm)',
|
||||||
fontSize: 13,
|
background: 'color-mix(in srgb, var(--color-surface) 82%, transparent)',
|
||||||
outline: 'none',
|
|
||||||
background: 'var(--color-bg)',
|
|
||||||
color: 'var(--color-text-primary)',
|
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600 }}>
|
||||||
|
站点自定义请求头
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addCustomHeaderRow}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ border: '1px solid var(--color-border)' }}
|
||||||
|
>
|
||||||
|
+ 添加请求头
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{form.customHeaders.map((header, index) => (
|
||||||
|
<div
|
||||||
|
key={`custom-header-${index}`}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 8,
|
||||||
|
flexDirection: isMobile ? 'column' : 'row',
|
||||||
|
alignItems: isMobile ? 'stretch' : 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
placeholder="Header 名称"
|
||||||
|
value={header.key}
|
||||||
|
onChange={(e) => updateCustomHeaderRow(index, 'key', e.target.value)}
|
||||||
|
style={{ ...formInputStyle, flex: 1, fontFamily: 'var(--font-mono)' }}
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="Header 值"
|
||||||
|
value={header.value}
|
||||||
|
onChange={(e) => updateCustomHeaderRow(index, 'value', e.target.value)}
|
||||||
|
style={{ ...formInputStyle, flex: 1, fontFamily: 'var(--font-mono)' }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeCustomHeaderRow(index)}
|
||||||
|
className="btn btn-link btn-link-danger"
|
||||||
|
style={isMobile ? { alignSelf: 'flex-end' } : undefined}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.6 }}>
|
||||||
|
按 key/value 逐条填写。整行留空会自动忽略;同名请求头不允许重复;请求本身显式传入的请求头优先级更高。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<label style={{
|
<label style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -573,30 +677,12 @@ export default function Sites() {
|
|||||||
placeholder="站点全局权重(默认 1)"
|
placeholder="站点全局权重(默认 1)"
|
||||||
value={form.globalWeight}
|
value={form.globalWeight}
|
||||||
onChange={(e) => setForm((prev) => ({ ...prev, globalWeight: e.target.value }))}
|
onChange={(e) => setForm((prev) => ({ ...prev, globalWeight: e.target.value }))}
|
||||||
style={{
|
style={formInputStyle}
|
||||||
width: '100%',
|
|
||||||
padding: '10px 14px',
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
fontSize: 13,
|
|
||||||
outline: 'none',
|
|
||||||
background: 'var(--color-bg)',
|
|
||||||
color: 'var(--color-text-primary)',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||||
越大越容易被路由选中。建议 0.5-3,默认 1。
|
越大越容易被路由选中。建议 0.5-3,默认 1。
|
||||||
</div>
|
</div>
|
||||||
<button
|
</CenteredModal>
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving || !form.name.trim() || !form.url.trim()}
|
|
||||||
className="btn btn-primary"
|
|
||||||
style={{ alignSelf: 'flex-start' }}
|
|
||||||
>
|
|
||||||
{saving ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</> : (isEditing ? '保存修改' : '保存站点')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="card" style={{ overflowX: 'auto' }}>
|
<div className="card" style={{ overflowX: 'auto' }}>
|
||||||
@@ -666,6 +752,10 @@ export default function Sites() {
|
|||||||
</a>
|
</a>
|
||||||
) : '-'}
|
) : '-'}
|
||||||
/>
|
/>
|
||||||
|
<MobileField
|
||||||
|
label="自定义头"
|
||||||
|
value={hasConfiguredCustomHeaders(site.customHeaders) ? '已配置' : '-'}
|
||||||
|
/>
|
||||||
<MobileField
|
<MobileField
|
||||||
label="创建时间"
|
label="创建时间"
|
||||||
value={formatDateTimeLocal(site.createdAt)}
|
value={formatDateTimeLocal(site.createdAt)}
|
||||||
@@ -774,6 +864,7 @@ export default function Sites() {
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ fontWeight: 600 }}>
|
<td style={{ fontWeight: 600 }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-start' }}>
|
||||||
<a
|
<a
|
||||||
href={site.url}
|
href={site.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -785,6 +876,12 @@ export default function Sites() {
|
|||||||
>
|
>
|
||||||
{site.name}
|
{site.name}
|
||||||
</a>
|
</a>
|
||||||
|
{hasConfiguredCustomHeaders(site.customHeaders) ? (
|
||||||
|
<span className="badge badge-info" style={{ fontSize: 11 }}>
|
||||||
|
自定义头
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="sites-url-cell" style={{ maxWidth: 300 }}>
|
<td className="sites-url-cell" style={{ maxWidth: 300 }}>
|
||||||
{site.externalCheckinUrl ? (
|
{site.externalCheckinUrl ? (
|
||||||
|
|||||||
@@ -364,7 +364,6 @@ export default function TokenRoutes() {
|
|||||||
try {
|
try {
|
||||||
await api.updateRoute(route.id, { routingStrategy });
|
await api.updateRoute(route.id, { routingStrategy });
|
||||||
toast.success(routingStrategy === 'round_robin' ? '已切换为轮询策略' : '已切换为权重随机策略');
|
toast.success(routingStrategy === 'round_robin' ? '已切换为轮询策略' : '已切换为权重随机策略');
|
||||||
await load();
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setRouteSummaries((prev) => prev.map((item) => (
|
setRouteSummaries((prev) => prev.map((item) => (
|
||||||
item.id === route.id
|
item.id === route.id
|
||||||
@@ -372,9 +371,16 @@ export default function TokenRoutes() {
|
|||||||
: item
|
: item
|
||||||
)));
|
)));
|
||||||
toast.error(e.message || '更新路由策略失败');
|
toast.error(e.message || '更新路由策略失败');
|
||||||
|
return;
|
||||||
} finally {
|
} finally {
|
||||||
setUpdatingRoutingStrategyByRoute((prev) => ({ ...prev, [route.id]: false }));
|
setUpdatingRoutingStrategyByRoute((prev) => ({ ...prev, [route.id]: false }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await load();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.message || '路由策略已保存,但刷新列表失败');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Stable derived value: only changes when route patterns change (not on enabled toggle)
|
// Stable derived value: only changes when route patterns change (not on enabled toggle)
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { buildSiteSaveAction, emptySiteForm, siteFormFromSite } from './sitesEditor.js';
|
import {
|
||||||
|
buildSiteSaveAction,
|
||||||
|
emptySiteCustomHeader,
|
||||||
|
emptySiteForm,
|
||||||
|
serializeSiteCustomHeaders,
|
||||||
|
siteFormFromSite,
|
||||||
|
} from './sitesEditor.js';
|
||||||
|
|
||||||
describe('buildSiteSaveAction', () => {
|
describe('buildSiteSaveAction', () => {
|
||||||
it('returns add action in add mode', () => {
|
it('returns add action in add mode', () => {
|
||||||
@@ -10,8 +16,9 @@ describe('buildSiteSaveAction', () => {
|
|||||||
url: 'https://a.example.com/',
|
url: 'https://a.example.com/',
|
||||||
externalCheckinUrl: 'https://checkin.a.example.com',
|
externalCheckinUrl: 'https://checkin.a.example.com',
|
||||||
platform: 'new-api',
|
platform: 'new-api',
|
||||||
proxyUrl: 'http://127.0.0.1:7890',
|
customHeaders: '{"x-site-token":"alpha"}',
|
||||||
globalWeight: '1.2',
|
useSystemProxy: false,
|
||||||
|
globalWeight: 1.2,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -22,8 +29,9 @@ describe('buildSiteSaveAction', () => {
|
|||||||
url: 'https://a.example.com/',
|
url: 'https://a.example.com/',
|
||||||
externalCheckinUrl: 'https://checkin.a.example.com',
|
externalCheckinUrl: 'https://checkin.a.example.com',
|
||||||
platform: 'new-api',
|
platform: 'new-api',
|
||||||
proxyUrl: 'http://127.0.0.1:7890',
|
customHeaders: '{"x-site-token":"alpha"}',
|
||||||
globalWeight: '1.2',
|
useSystemProxy: false,
|
||||||
|
globalWeight: 1.2,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -36,8 +44,9 @@ describe('buildSiteSaveAction', () => {
|
|||||||
url: 'https://b.example.com',
|
url: 'https://b.example.com',
|
||||||
externalCheckinUrl: '',
|
externalCheckinUrl: '',
|
||||||
platform: 'one-api',
|
platform: 'one-api',
|
||||||
proxyUrl: '',
|
useSystemProxy: true,
|
||||||
globalWeight: '0.8',
|
customHeaders: '',
|
||||||
|
globalWeight: 0.8,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -49,8 +58,9 @@ describe('buildSiteSaveAction', () => {
|
|||||||
url: 'https://b.example.com',
|
url: 'https://b.example.com',
|
||||||
externalCheckinUrl: '',
|
externalCheckinUrl: '',
|
||||||
platform: 'one-api',
|
platform: 'one-api',
|
||||||
proxyUrl: '',
|
useSystemProxy: true,
|
||||||
globalWeight: '0.8',
|
customHeaders: '',
|
||||||
|
globalWeight: 0.8,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -64,8 +74,9 @@ describe('buildSiteSaveAction', () => {
|
|||||||
url: 'https://c.example.com',
|
url: 'https://c.example.com',
|
||||||
externalCheckinUrl: '',
|
externalCheckinUrl: '',
|
||||||
platform: '',
|
platform: '',
|
||||||
proxyUrl: '',
|
useSystemProxy: false,
|
||||||
globalWeight: '1',
|
customHeaders: '',
|
||||||
|
globalWeight: 1,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
).toThrow('editingSiteId is required in edit mode');
|
).toThrow('editingSiteId is required in edit mode');
|
||||||
@@ -73,14 +84,48 @@ describe('buildSiteSaveAction', () => {
|
|||||||
|
|
||||||
it('does not expose deprecated apiKey in site editor state', () => {
|
it('does not expose deprecated apiKey in site editor state', () => {
|
||||||
expect(emptySiteForm()).not.toHaveProperty('apiKey');
|
expect(emptySiteForm()).not.toHaveProperty('apiKey');
|
||||||
|
expect(emptySiteForm().customHeaders).toEqual([emptySiteCustomHeader()]);
|
||||||
expect(siteFormFromSite({
|
expect(siteFormFromSite({
|
||||||
name: 'site-d',
|
name: 'site-d',
|
||||||
url: 'https://d.example.com',
|
url: 'https://d.example.com',
|
||||||
externalCheckinUrl: null,
|
externalCheckinUrl: null,
|
||||||
platform: 'new-api',
|
platform: 'new-api',
|
||||||
proxyUrl: null,
|
proxyUrl: null,
|
||||||
|
customHeaders: '{"x-site-token":"alpha"}',
|
||||||
globalWeight: 1,
|
globalWeight: 1,
|
||||||
apiKey: 'sk-legacy-site-key',
|
apiKey: 'sk-legacy-site-key',
|
||||||
})).not.toHaveProperty('apiKey');
|
})).not.toHaveProperty('apiKey');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('parses custom headers json into key value rows', () => {
|
||||||
|
expect(siteFormFromSite({
|
||||||
|
name: 'site-e',
|
||||||
|
customHeaders: '{"x-site-token":"alpha","cf-access-client-id":"beta"}',
|
||||||
|
}).customHeaders).toEqual([
|
||||||
|
{ key: 'x-site-token', value: 'alpha' },
|
||||||
|
{ key: 'cf-access-client-id', value: 'beta' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes key value rows into json', () => {
|
||||||
|
expect(serializeSiteCustomHeaders([
|
||||||
|
{ key: 'x-site-token', value: 'alpha' },
|
||||||
|
{ key: 'cf-access-client-id', value: 'beta' },
|
||||||
|
emptySiteCustomHeader(),
|
||||||
|
])).toEqual({
|
||||||
|
valid: true,
|
||||||
|
customHeaders: '{"x-site-token":"alpha","cf-access-client-id":"beta"}',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects duplicate custom header names case-insensitively', () => {
|
||||||
|
expect(serializeSiteCustomHeaders([
|
||||||
|
{ key: 'Authorization', value: 'Bearer a' },
|
||||||
|
{ key: 'authorization', value: 'Bearer b' },
|
||||||
|
])).toEqual({
|
||||||
|
valid: false,
|
||||||
|
customHeaders: '',
|
||||||
|
error: '请求头 "authorization" 重复了',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
|
export type SiteCustomHeaderField = {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type SiteForm = {
|
export type SiteForm = {
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
externalCheckinUrl: string;
|
externalCheckinUrl: string;
|
||||||
platform: string;
|
platform: string;
|
||||||
useSystemProxy: boolean;
|
useSystemProxy: boolean;
|
||||||
|
customHeaders: SiteCustomHeaderField[];
|
||||||
globalWeight: string;
|
globalWeight: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -11,17 +17,69 @@ export type SiteEditorState =
|
|||||||
| { mode: 'add' }
|
| { mode: 'add' }
|
||||||
| { mode: 'edit'; editingSiteId: number };
|
| { mode: 'edit'; editingSiteId: number };
|
||||||
|
|
||||||
type SiteSaveAction =
|
export type SiteSavePayload = {
|
||||||
| { kind: 'add'; payload: SiteForm }
|
name: string;
|
||||||
| { kind: 'update'; id: number; payload: SiteForm };
|
url: string;
|
||||||
|
externalCheckinUrl: string;
|
||||||
|
platform: string;
|
||||||
|
useSystemProxy: boolean;
|
||||||
|
customHeaders: string;
|
||||||
|
globalWeight: number;
|
||||||
|
};
|
||||||
|
|
||||||
export function emptySiteForm(): SiteForm {
|
type SiteSaveAction =
|
||||||
return { name: '', url: '', externalCheckinUrl: '', platform: '', useSystemProxy: false, globalWeight: '1' };
|
| { kind: 'add'; payload: SiteSavePayload }
|
||||||
|
| { kind: 'update'; id: number; payload: SiteSavePayload };
|
||||||
|
|
||||||
|
export function emptySiteCustomHeader(): SiteCustomHeaderField {
|
||||||
|
return { key: '', value: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function siteFormFromSite(site: Partial<SiteForm> & {
|
function ensureSiteCustomHeaderRows(rows: SiteCustomHeaderField[]): SiteCustomHeaderField[] {
|
||||||
|
return rows.length > 0 ? rows : [emptySiteCustomHeader()];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptySiteForm(): SiteForm {
|
||||||
|
return {
|
||||||
|
name: '',
|
||||||
|
url: '',
|
||||||
|
externalCheckinUrl: '',
|
||||||
|
platform: '',
|
||||||
|
useSystemProxy: false,
|
||||||
|
customHeaders: [emptySiteCustomHeader()],
|
||||||
|
globalWeight: '1',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCustomHeadersForEditor(raw: unknown): SiteCustomHeaderField[] {
|
||||||
|
if (typeof raw !== 'string') {
|
||||||
|
return ensureSiteCustomHeaderRows([]);
|
||||||
|
}
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return ensureSiteCustomHeaderRows([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
return ensureSiteCustomHeaderRows([]);
|
||||||
|
}
|
||||||
|
return ensureSiteCustomHeaderRows(
|
||||||
|
Object.entries(parsed as Record<string, unknown>).map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
value: typeof value === 'string' ? value : String(value ?? ''),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return ensureSiteCustomHeaderRows([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function siteFormFromSite(site: Partial<Omit<SiteForm, 'customHeaders' | 'globalWeight' | 'externalCheckinUrl' | 'useSystemProxy'>> & {
|
||||||
externalCheckinUrl?: string | null;
|
externalCheckinUrl?: string | null;
|
||||||
useSystemProxy?: boolean | null;
|
useSystemProxy?: boolean | null;
|
||||||
|
customHeaders?: string | null;
|
||||||
globalWeight?: number | string | null;
|
globalWeight?: number | string | null;
|
||||||
}): SiteForm {
|
}): SiteForm {
|
||||||
const globalWeightRaw = Number(site.globalWeight);
|
const globalWeightRaw = Number(site.globalWeight);
|
||||||
@@ -32,11 +90,42 @@ export function siteFormFromSite(site: Partial<SiteForm> & {
|
|||||||
externalCheckinUrl: site.externalCheckinUrl ?? '',
|
externalCheckinUrl: site.externalCheckinUrl ?? '',
|
||||||
platform: site.platform ?? '',
|
platform: site.platform ?? '',
|
||||||
useSystemProxy: !!site.useSystemProxy,
|
useSystemProxy: !!site.useSystemProxy,
|
||||||
|
customHeaders: parseCustomHeadersForEditor(site.customHeaders),
|
||||||
globalWeight,
|
globalWeight,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSiteSaveAction(editor: SiteEditorState, form: SiteForm): SiteSaveAction {
|
export function serializeSiteCustomHeaders(fields: SiteCustomHeaderField[]): {
|
||||||
|
valid: boolean;
|
||||||
|
customHeaders: string;
|
||||||
|
error?: string;
|
||||||
|
} {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const field of fields) {
|
||||||
|
const key = field.key.trim();
|
||||||
|
const value = field.value;
|
||||||
|
const hasAnyInput = key.length > 0 || value.trim().length > 0;
|
||||||
|
if (!hasAnyInput) continue;
|
||||||
|
if (!key) {
|
||||||
|
return { valid: false, customHeaders: '', error: '请求头名称不能为空' };
|
||||||
|
}
|
||||||
|
const normalizedKey = key.toLowerCase();
|
||||||
|
if (seen.has(normalizedKey)) {
|
||||||
|
return { valid: false, customHeaders: '', error: `请求头 "${key}" 重复了` };
|
||||||
|
}
|
||||||
|
seen.add(normalizedKey);
|
||||||
|
headers[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
customHeaders: Object.keys(headers).length > 0 ? JSON.stringify(headers) : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSiteSaveAction(editor: SiteEditorState, form: SiteSavePayload): SiteSaveAction {
|
||||||
if (editor.mode === 'edit') {
|
if (editor.mode === 'edit') {
|
||||||
if (!Number.isFinite(editor.editingSiteId)) {
|
if (!Number.isFinite(editor.editingSiteId)) {
|
||||||
throw new Error('editingSiteId is required in edit mode');
|
throw new Error('editingSiteId is required in edit mode');
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const { apiMock } = vi.hoisted(() => ({
|
|||||||
getAuthInfo: vi.fn(),
|
getAuthInfo: vi.fn(),
|
||||||
getRuntimeSettings: vi.fn(),
|
getRuntimeSettings: vi.fn(),
|
||||||
getDownstreamApiKeys: vi.fn(),
|
getDownstreamApiKeys: vi.fn(),
|
||||||
getRoutes: vi.fn(),
|
getRoutesLite: vi.fn(),
|
||||||
getRuntimeDatabaseConfig: vi.fn(),
|
getRuntimeDatabaseConfig: vi.fn(),
|
||||||
factoryReset: vi.fn(),
|
factoryReset: vi.fn(),
|
||||||
},
|
},
|
||||||
@@ -66,13 +66,17 @@ describe('Settings factory reset', () => {
|
|||||||
apiMock.getRuntimeSettings.mockResolvedValue({
|
apiMock.getRuntimeSettings.mockResolvedValue({
|
||||||
checkinCron: '0 8 * * *',
|
checkinCron: '0 8 * * *',
|
||||||
balanceRefreshCron: '0 * * * *',
|
balanceRefreshCron: '0 * * * *',
|
||||||
|
logCleanupCron: '0 6 * * *',
|
||||||
|
logCleanupUsageLogsEnabled: false,
|
||||||
|
logCleanupProgramLogsEnabled: false,
|
||||||
|
logCleanupRetentionDays: 30,
|
||||||
routingFallbackUnitCost: 1,
|
routingFallbackUnitCost: 1,
|
||||||
routingWeights: {},
|
routingWeights: {},
|
||||||
adminIpAllowlist: [],
|
adminIpAllowlist: [],
|
||||||
systemProxyUrl: '',
|
systemProxyUrl: '',
|
||||||
});
|
});
|
||||||
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
|
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
|
||||||
apiMock.getRoutes.mockResolvedValue([]);
|
apiMock.getRoutesLite.mockResolvedValue([]);
|
||||||
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
|
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
|
||||||
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
|
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
|
||||||
saved: null,
|
saved: null,
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { ToastProvider } from '../components/Toast.js';
|
||||||
|
import Settings from './Settings.js';
|
||||||
|
|
||||||
|
const { apiMock } = vi.hoisted(() => ({
|
||||||
|
apiMock: {
|
||||||
|
getAuthInfo: vi.fn(),
|
||||||
|
getRuntimeSettings: vi.fn(),
|
||||||
|
getDownstreamApiKeys: vi.fn(),
|
||||||
|
getRoutesLite: vi.fn(),
|
||||||
|
getRuntimeDatabaseConfig: vi.fn(),
|
||||||
|
updateRuntimeSettings: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../api.js', () => ({
|
||||||
|
api: apiMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/BrandIcon.js', () => ({
|
||||||
|
BrandGlyph: () => null,
|
||||||
|
InlineBrandIcon: () => null,
|
||||||
|
getBrand: () => null,
|
||||||
|
normalizeBrandIconKey: (icon: string) => icon,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function collectText(node: ReactTestInstance): string {
|
||||||
|
return (node.children || []).map((child) => {
|
||||||
|
if (typeof child === 'string') return child;
|
||||||
|
return collectText(child);
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushMicrotasks() {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Settings log cleanup schedule', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
apiMock.getAuthInfo.mockResolvedValue({ masked: 'sk-****' });
|
||||||
|
apiMock.getRuntimeSettings.mockResolvedValue({
|
||||||
|
checkinCron: '0 8 * * *',
|
||||||
|
balanceRefreshCron: '0 * * * *',
|
||||||
|
logCleanupCron: '15 4 * * *',
|
||||||
|
logCleanupUsageLogsEnabled: true,
|
||||||
|
logCleanupProgramLogsEnabled: true,
|
||||||
|
logCleanupRetentionDays: 14,
|
||||||
|
routingFallbackUnitCost: 1,
|
||||||
|
routingWeights: {},
|
||||||
|
adminIpAllowlist: [],
|
||||||
|
systemProxyUrl: '',
|
||||||
|
});
|
||||||
|
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
|
||||||
|
apiMock.getRoutesLite.mockResolvedValue([]);
|
||||||
|
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
|
||||||
|
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
|
||||||
|
saved: null,
|
||||||
|
restartRequired: false,
|
||||||
|
});
|
||||||
|
apiMock.updateRuntimeSettings.mockResolvedValue({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves log cleanup fields together with other schedule settings', async () => {
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ToastProvider>
|
||||||
|
<Settings />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const saveButton = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node).trim() === '保存定时任务'
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
saveButton.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith({
|
||||||
|
checkinCron: '0 8 * * *',
|
||||||
|
balanceRefreshCron: '0 * * * *',
|
||||||
|
logCleanupCron: '15 4 * * *',
|
||||||
|
logCleanupUsageLogsEnabled: true,
|
||||||
|
logCleanupProgramLogsEnabled: true,
|
||||||
|
logCleanupRetentionDays: 14,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,7 +9,7 @@ const { apiMock } = vi.hoisted(() => ({
|
|||||||
getAuthInfo: vi.fn(),
|
getAuthInfo: vi.fn(),
|
||||||
getRuntimeSettings: vi.fn(),
|
getRuntimeSettings: vi.fn(),
|
||||||
getDownstreamApiKeys: vi.fn(),
|
getDownstreamApiKeys: vi.fn(),
|
||||||
getRoutes: vi.fn(),
|
getRoutesLite: vi.fn(),
|
||||||
getRuntimeDatabaseConfig: vi.fn(),
|
getRuntimeDatabaseConfig: vi.fn(),
|
||||||
updateRuntimeSettings: vi.fn(),
|
updateRuntimeSettings: vi.fn(),
|
||||||
},
|
},
|
||||||
@@ -47,13 +47,17 @@ describe('Settings system proxy', () => {
|
|||||||
apiMock.getRuntimeSettings.mockResolvedValue({
|
apiMock.getRuntimeSettings.mockResolvedValue({
|
||||||
checkinCron: '0 8 * * *',
|
checkinCron: '0 8 * * *',
|
||||||
balanceRefreshCron: '0 * * * *',
|
balanceRefreshCron: '0 * * * *',
|
||||||
|
logCleanupCron: '0 6 * * *',
|
||||||
|
logCleanupUsageLogsEnabled: false,
|
||||||
|
logCleanupProgramLogsEnabled: false,
|
||||||
|
logCleanupRetentionDays: 30,
|
||||||
routingFallbackUnitCost: 1,
|
routingFallbackUnitCost: 1,
|
||||||
routingWeights: {},
|
routingWeights: {},
|
||||||
adminIpAllowlist: [],
|
adminIpAllowlist: [],
|
||||||
systemProxyUrl: '',
|
systemProxyUrl: '',
|
||||||
});
|
});
|
||||||
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
|
apiMock.getDownstreamApiKeys.mockResolvedValue({ items: [] });
|
||||||
apiMock.getRoutes.mockResolvedValue([]);
|
apiMock.getRoutesLite.mockResolvedValue([]);
|
||||||
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
|
apiMock.getRuntimeDatabaseConfig.mockResolvedValue({
|
||||||
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
|
active: { dialect: 'sqlite', connection: '(default sqlite path)', ssl: false },
|
||||||
saved: null,
|
saved: null,
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
describe('Sites centered modal adoption', () => {
|
||||||
|
it('uses CenteredModal for add/edit site flows instead of inline form panels', () => {
|
||||||
|
const source = readFileSync(resolve(process.cwd(), 'src/web/pages/Sites.tsx'), 'utf8');
|
||||||
|
|
||||||
|
expect(source).toContain("import CenteredModal from '../components/CenteredModal.js'");
|
||||||
|
expect(source).toContain('<CenteredModal');
|
||||||
|
expect(source).not.toContain('editorPresence.shouldRender && activeEditor && (');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -30,6 +30,29 @@ describe('tokenBindingPresentation', () => {
|
|||||||
expect(result.helperText).toContain('不会跟着变');
|
expect(result.helperText).toContain('不会跟着变');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('describes fixed mode when the selected token is not the account default', () => {
|
||||||
|
const result = describeTokenBinding([
|
||||||
|
{ id: 1, name: 'default', isDefault: true },
|
||||||
|
{ id: 2, name: 'backup', isDefault: false },
|
||||||
|
], 2);
|
||||||
|
|
||||||
|
expect(result.bindingModeLabel).toBe('固定令牌');
|
||||||
|
expect(result.effectiveTokenName).toBe('backup');
|
||||||
|
expect(result.helperText).toContain('不会随账号默认变化');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('describes follow-account-default mode when no default token exists yet', () => {
|
||||||
|
const result = describeTokenBinding([
|
||||||
|
{ id: 2, name: 'backup', isDefault: false },
|
||||||
|
{ id: 3, name: 'spare', isDefault: false },
|
||||||
|
], 0);
|
||||||
|
|
||||||
|
expect(result.bindingModeLabel).toBe('跟随账号默认');
|
||||||
|
expect(result.effectiveTokenName).toBe('未设置默认令牌');
|
||||||
|
expect(result.helperText).toContain('当前账号还没有默认令牌');
|
||||||
|
expect(result.followOptionDescription).toContain('以后账号默认变化时会自动切换');
|
||||||
|
});
|
||||||
|
|
||||||
it('formats fixed token options with clearer labels and descriptions', () => {
|
it('formats fixed token options with clearer labels and descriptions', () => {
|
||||||
const token = {
|
const token = {
|
||||||
id: 3,
|
id: 3,
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { ToastProvider } from '../components/Toast.js';
|
||||||
|
import TokenRoutes from './TokenRoutes.js';
|
||||||
|
|
||||||
|
const { apiMock, getBrandMock } = vi.hoisted(() => ({
|
||||||
|
apiMock: {
|
||||||
|
getRoutesSummary: vi.fn(),
|
||||||
|
getRouteChannels: vi.fn(),
|
||||||
|
getModelTokenCandidates: vi.fn(),
|
||||||
|
getRouteDecisionsBatch: vi.fn(),
|
||||||
|
getRouteWideDecisionsBatch: vi.fn(),
|
||||||
|
updateRoute: vi.fn(),
|
||||||
|
},
|
||||||
|
getBrandMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../api.js', () => ({
|
||||||
|
api: apiMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../components/BrandIcon.js', () => ({
|
||||||
|
BrandGlyph: ({ brand, icon, model }: { brand?: { name?: string } | null; icon?: string | null; model?: string | null }) => (
|
||||||
|
<span>{brand?.name || icon || model || ''}</span>
|
||||||
|
),
|
||||||
|
InlineBrandIcon: ({ model }: { model: string }) => model ? <span>{model}</span> : null,
|
||||||
|
getBrand: (...args: unknown[]) => getBrandMock(...args),
|
||||||
|
hashColor: () => 'linear-gradient(135deg,#4f46e5,#818cf8)',
|
||||||
|
normalizeBrandIconKey: (icon: string) => icon,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function collectText(node: ReactTestInstance): string {
|
||||||
|
return (node.children || []).map((child) => {
|
||||||
|
if (typeof child === 'string') return child;
|
||||||
|
return collectText(child);
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushMicrotasks() {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TokenRoutes routing strategy updates', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
getBrandMock.mockReset();
|
||||||
|
getBrandMock.mockReturnValue(null);
|
||||||
|
apiMock.getRoutesSummary
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
modelPattern: 'gpt-4o-mini',
|
||||||
|
displayName: 'gpt-4o-mini',
|
||||||
|
displayIcon: null,
|
||||||
|
modelMapping: null,
|
||||||
|
routingStrategy: 'weighted',
|
||||||
|
enabled: true,
|
||||||
|
channelCount: 0,
|
||||||
|
enabledChannelCount: 0,
|
||||||
|
siteNames: [],
|
||||||
|
decisionSnapshot: null,
|
||||||
|
decisionRefreshedAt: null,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.mockRejectedValueOnce(new Error('refresh failed'));
|
||||||
|
apiMock.getRouteChannels.mockResolvedValue([]);
|
||||||
|
apiMock.getModelTokenCandidates.mockResolvedValue({ models: {} });
|
||||||
|
apiMock.getRouteDecisionsBatch.mockResolvedValue({ decisions: {} });
|
||||||
|
apiMock.getRouteWideDecisionsBatch.mockResolvedValue({ decisions: {} });
|
||||||
|
apiMock.updateRoute.mockResolvedValue({});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the optimistic routing strategy when refresh fails after a successful save', async () => {
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/routes']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<TokenRoutes />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const expandButton = root.root.find((node) => (
|
||||||
|
node.type === 'div'
|
||||||
|
&& String(node.props.className || '').includes('route-card-collapsed')
|
||||||
|
));
|
||||||
|
await act(async () => {
|
||||||
|
expandButton.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const roundRobinOption = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.className === 'string'
|
||||||
|
&& node.props.className.includes('modern-select-option')
|
||||||
|
&& collectText(node).includes('轮询')
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
roundRobinOption.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(apiMock.updateRoute).toHaveBeenCalledWith(1, { routingStrategy: 'round_robin' });
|
||||||
|
expect(apiMock.getRoutesSummary).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
const strategyTrigger = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.className === 'string'
|
||||||
|
&& node.props.className.includes('modern-select-trigger')
|
||||||
|
&& collectText(node).includes('轮询')
|
||||||
|
));
|
||||||
|
expect(collectText(strategyTrigger)).toContain('轮询');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user