forked from boos/BoosAPI
Merge pull request #54 from bnvnvnv/feat/site-observability-proxy-logs
feat: add site observability drilldown for proxy logs
This commit is contained in:
@@ -199,4 +199,99 @@ describe('stats proxy logs routes', () => {
|
|||||||
usage: { promptTokens: 100, completionTokens: 20 },
|
usage: { promptTokens: 100, completionTokens: 20 },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('filters proxy logs by site and time range', async () => {
|
||||||
|
const alphaSite = await db.insert(schema.sites).values({
|
||||||
|
name: 'alpha-site',
|
||||||
|
url: 'https://alpha.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
}).returning().get();
|
||||||
|
const betaSite = await db.insert(schema.sites).values({
|
||||||
|
name: 'beta-site',
|
||||||
|
url: 'https://beta.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const alphaAccount = await db.insert(schema.accounts).values({
|
||||||
|
siteId: alphaSite.id,
|
||||||
|
username: 'alpha-user',
|
||||||
|
accessToken: 'alpha-token',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
const betaAccount = await db.insert(schema.accounts).values({
|
||||||
|
siteId: betaSite.id,
|
||||||
|
username: 'beta-user',
|
||||||
|
accessToken: 'beta-token',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
await db.insert(schema.proxyLogs).values([
|
||||||
|
{
|
||||||
|
accountId: alphaAccount.id,
|
||||||
|
modelRequested: 'gpt-4o',
|
||||||
|
modelActual: 'gpt-4o',
|
||||||
|
status: 'success',
|
||||||
|
totalTokens: 10,
|
||||||
|
estimatedCost: 0.11,
|
||||||
|
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T08:15:00.000Z')),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountId: alphaAccount.id,
|
||||||
|
modelRequested: 'gpt-4.1-mini',
|
||||||
|
modelActual: 'gpt-4.1-mini',
|
||||||
|
status: 'failed',
|
||||||
|
totalTokens: 20,
|
||||||
|
estimatedCost: 0.22,
|
||||||
|
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T08:45:00.000Z')),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountId: alphaAccount.id,
|
||||||
|
modelRequested: 'gpt-4.1',
|
||||||
|
modelActual: 'gpt-4.1',
|
||||||
|
status: 'success',
|
||||||
|
totalTokens: 30,
|
||||||
|
estimatedCost: 0.33,
|
||||||
|
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T09:15:00.000Z')),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountId: betaAccount.id,
|
||||||
|
modelRequested: 'claude-3-7-sonnet',
|
||||||
|
modelActual: 'claude-3-7-sonnet',
|
||||||
|
status: 'success',
|
||||||
|
totalTokens: 40,
|
||||||
|
estimatedCost: 0.44,
|
||||||
|
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T08:30:00.000Z')),
|
||||||
|
},
|
||||||
|
]).run();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/stats/proxy-logs?siteId=${alphaSite.id}&from=${encodeURIComponent('2026-03-09T08:00:00.000Z')}&to=${encodeURIComponent('2026-03-09T09:00:00.000Z')}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json() as {
|
||||||
|
items: Array<Record<string, unknown>>;
|
||||||
|
total: number;
|
||||||
|
summary: {
|
||||||
|
totalCount: number;
|
||||||
|
successCount: number;
|
||||||
|
failedCount: number;
|
||||||
|
totalCost: number;
|
||||||
|
totalTokensAll: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(body.total).toBe(2);
|
||||||
|
expect(body.items).toHaveLength(2);
|
||||||
|
expect(body.items.map((item) => item.siteId)).toEqual([alphaSite.id, alphaSite.id]);
|
||||||
|
expect(body.items.map((item) => item.siteName)).toEqual(['alpha-site', 'alpha-site']);
|
||||||
|
expect(body.summary).toEqual({
|
||||||
|
totalCount: 2,
|
||||||
|
successCount: 1,
|
||||||
|
failedCount: 1,
|
||||||
|
totalCost: 0.33,
|
||||||
|
totalTokensAll: 30,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -242,4 +242,108 @@ describe('stats dashboard filters disabled sites', () => {
|
|||||||
tokensPerMinute: 1800,
|
tokensPerMinute: 1800,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns site availability buckets and average latency from per-request proxy logs', async () => {
|
||||||
|
const activeSite = await db.insert(schema.sites).values({
|
||||||
|
name: 'availability-site',
|
||||||
|
url: 'https://availability.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const disabledSite = await db.insert(schema.sites).values({
|
||||||
|
name: 'disabled-availability-site',
|
||||||
|
url: 'https://disabled-availability.example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
await db.run(sql`update sites set status = 'disabled' where id = ${disabledSite.id}`);
|
||||||
|
|
||||||
|
const activeAccount = await db.insert(schema.accounts).values({
|
||||||
|
siteId: activeSite.id,
|
||||||
|
username: 'availability-user',
|
||||||
|
accessToken: 'availability-token',
|
||||||
|
balance: 20,
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const disabledAccount = await db.insert(schema.accounts).values({
|
||||||
|
siteId: disabledSite.id,
|
||||||
|
username: 'disabled-availability-user',
|
||||||
|
accessToken: 'disabled-availability-token',
|
||||||
|
balance: 20,
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const recentSuccess = formatUtcSqlDateTime(new Date(now - 5 * 60_000));
|
||||||
|
const recentFailure = formatUtcSqlDateTime(new Date(now - 65 * 60_000));
|
||||||
|
|
||||||
|
await db.insert(schema.proxyLogs).values([
|
||||||
|
{
|
||||||
|
accountId: activeAccount.id,
|
||||||
|
status: 'success',
|
||||||
|
latencyMs: 120,
|
||||||
|
createdAt: recentSuccess,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountId: activeAccount.id,
|
||||||
|
status: 'failed',
|
||||||
|
latencyMs: 280,
|
||||||
|
createdAt: recentFailure,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountId: disabledAccount.id,
|
||||||
|
status: 'success',
|
||||||
|
latencyMs: 999,
|
||||||
|
createdAt: recentSuccess,
|
||||||
|
},
|
||||||
|
]).run();
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/stats/dashboard',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json() as {
|
||||||
|
siteAvailability?: Array<{
|
||||||
|
siteId: number;
|
||||||
|
siteName: string;
|
||||||
|
totalRequests: number;
|
||||||
|
successCount: number;
|
||||||
|
failedCount: number;
|
||||||
|
availabilityPercent: number | null;
|
||||||
|
averageLatencyMs: number | null;
|
||||||
|
buckets: Array<{
|
||||||
|
startUtc?: string;
|
||||||
|
totalRequests: number;
|
||||||
|
successCount: number;
|
||||||
|
failedCount: number;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(Array.isArray(body.siteAvailability)).toBe(true);
|
||||||
|
expect(body.siteAvailability).toHaveLength(1);
|
||||||
|
expect(body.siteAvailability?.[0]).toMatchObject({
|
||||||
|
siteId: activeSite.id,
|
||||||
|
siteName: 'availability-site',
|
||||||
|
totalRequests: 2,
|
||||||
|
successCount: 1,
|
||||||
|
failedCount: 1,
|
||||||
|
availabilityPercent: 50,
|
||||||
|
averageLatencyMs: 200,
|
||||||
|
});
|
||||||
|
expect(body.siteAvailability?.[0]?.buckets).toHaveLength(24);
|
||||||
|
expect(
|
||||||
|
body.siteAvailability?.[0]?.buckets.reduce((sum, bucket) => sum + bucket.totalRequests, 0),
|
||||||
|
).toBe(2);
|
||||||
|
expect(
|
||||||
|
body.siteAvailability?.[0]?.buckets.reduce((sum, bucket) => sum + bucket.successCount, 0),
|
||||||
|
).toBe(1);
|
||||||
|
expect(
|
||||||
|
body.siteAvailability?.[0]?.buckets.reduce((sum, bucket) => sum + bucket.failedCount, 0),
|
||||||
|
).toBe(1);
|
||||||
|
expect(typeof body.siteAvailability?.[0]?.buckets[0]?.startUtc).toBe('string');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,9 +19,11 @@ import {
|
|||||||
} from '../../services/proxyLogStore.js';
|
} from '../../services/proxyLogStore.js';
|
||||||
import { getCredentialModeFromExtraConfig } from '../../services/accountExtraConfig.js';
|
import { getCredentialModeFromExtraConfig } from '../../services/accountExtraConfig.js';
|
||||||
import {
|
import {
|
||||||
|
formatLocalDateTime,
|
||||||
formatUtcSqlDateTime,
|
formatUtcSqlDateTime,
|
||||||
getLocalDayRangeUtc,
|
getLocalDayRangeUtc,
|
||||||
getLocalRangeStartUtc,
|
getLocalRangeStartUtc,
|
||||||
|
parseStoredUtcDateTime,
|
||||||
toLocalDayKeyFromStoredUtc,
|
toLocalDayKeyFromStoredUtc,
|
||||||
} from '../../services/localTimeService.js';
|
} from '../../services/localTimeService.js';
|
||||||
|
|
||||||
@@ -105,6 +107,20 @@ function normalizeProxyLogSearch(raw?: string): string {
|
|||||||
return (raw || '').trim().toLowerCase();
|
return (raw || '').trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeProxyLogSiteId(raw?: string): number | null {
|
||||||
|
const parsed = Number.parseInt(raw || '', 10);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProxyLogTimeBoundary(raw?: string): string | null {
|
||||||
|
const text = (raw || '').trim();
|
||||||
|
if (!text) return null;
|
||||||
|
const parsed = new Date(text);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return null;
|
||||||
|
return formatUtcSqlDateTime(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
function buildProxyLogSearchCondition(search: string) {
|
function buildProxyLogSearchCondition(search: string) {
|
||||||
if (!search) return null;
|
if (!search) return null;
|
||||||
const likeTerm = `%${search}%`;
|
const likeTerm = `%${search}%`;
|
||||||
@@ -127,10 +143,16 @@ function buildProxyLogStatusCondition(status: ProxyLogStatusFilter) {
|
|||||||
function buildProxyLogWhereClause(params: {
|
function buildProxyLogWhereClause(params: {
|
||||||
status?: ProxyLogStatusFilter;
|
status?: ProxyLogStatusFilter;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
siteId?: number | null;
|
||||||
|
fromUtc?: string | null;
|
||||||
|
toUtc?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const conditions = [
|
const conditions = [
|
||||||
params.status ? buildProxyLogStatusCondition(params.status) : null,
|
params.status ? buildProxyLogStatusCondition(params.status) : null,
|
||||||
params.search ? buildProxyLogSearchCondition(params.search) : null,
|
params.search ? buildProxyLogSearchCondition(params.search) : null,
|
||||||
|
params.siteId ? eq(schema.sites.id, params.siteId) : null,
|
||||||
|
params.fromUtc ? gte(schema.proxyLogs.createdAt, params.fromUtc) : null,
|
||||||
|
params.toUtc ? lt(schema.proxyLogs.createdAt, params.toUtc) : null,
|
||||||
].filter((condition): condition is NonNullable<typeof condition> => condition !== null);
|
].filter((condition): condition is NonNullable<typeof condition> => condition !== null);
|
||||||
|
|
||||||
if (conditions.length === 0) return undefined;
|
if (conditions.length === 0) return undefined;
|
||||||
@@ -141,11 +163,159 @@ function toRoundedMicroNumber(value: number | null | undefined): number {
|
|||||||
return Math.round(Number(value || 0) * 1_000_000) / 1_000_000;
|
return Math.round(Number(value || 0) * 1_000_000) / 1_000_000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SITE_AVAILABILITY_BUCKET_COUNT = 24;
|
||||||
|
const SITE_AVAILABILITY_BUCKET_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
type SiteAvailabilitySiteRow = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
url: string | null;
|
||||||
|
platform: string | null;
|
||||||
|
sortOrder: number | null;
|
||||||
|
isPinned: boolean | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SiteAvailabilityLogRow = {
|
||||||
|
siteId: number | null;
|
||||||
|
createdAt: string | null;
|
||||||
|
status: string | null;
|
||||||
|
latencyMs: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SiteAvailabilityBucketAccumulator = {
|
||||||
|
startUtc: string;
|
||||||
|
label: string;
|
||||||
|
totalRequests: number;
|
||||||
|
successCount: number;
|
||||||
|
failedCount: number;
|
||||||
|
latencyTotalMs: number;
|
||||||
|
latencyCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function roundPercent(value: number | null): number | null {
|
||||||
|
if (value == null || !Number.isFinite(value)) return null;
|
||||||
|
return Math.round(value * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSiteAvailabilitySummaries(
|
||||||
|
sites: SiteAvailabilitySiteRow[],
|
||||||
|
logs: SiteAvailabilityLogRow[],
|
||||||
|
now = new Date(),
|
||||||
|
) {
|
||||||
|
const endLocal = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), 0, 0, 0);
|
||||||
|
const startLocal = new Date(endLocal.getTime() - (SITE_AVAILABILITY_BUCKET_COUNT - 1) * SITE_AVAILABILITY_BUCKET_MS);
|
||||||
|
const startMs = startLocal.getTime();
|
||||||
|
const rangeMs = SITE_AVAILABILITY_BUCKET_COUNT * SITE_AVAILABILITY_BUCKET_MS;
|
||||||
|
|
||||||
|
const createBucketTemplate = (): SiteAvailabilityBucketAccumulator[] => (
|
||||||
|
Array.from({ length: SITE_AVAILABILITY_BUCKET_COUNT }, (_, index) => {
|
||||||
|
const bucketStart = new Date(startMs + index * SITE_AVAILABILITY_BUCKET_MS);
|
||||||
|
return {
|
||||||
|
startUtc: bucketStart.toISOString(),
|
||||||
|
label: formatLocalDateTime(bucketStart),
|
||||||
|
totalRequests: 0,
|
||||||
|
successCount: 0,
|
||||||
|
failedCount: 0,
|
||||||
|
latencyTotalMs: 0,
|
||||||
|
latencyCount: 0,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const siteMap = new Map<number, {
|
||||||
|
site: SiteAvailabilitySiteRow;
|
||||||
|
totalRequests: number;
|
||||||
|
successCount: number;
|
||||||
|
failedCount: number;
|
||||||
|
latencyTotalMs: number;
|
||||||
|
latencyCount: number;
|
||||||
|
buckets: SiteAvailabilityBucketAccumulator[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
for (const site of sites) {
|
||||||
|
siteMap.set(site.id, {
|
||||||
|
site,
|
||||||
|
totalRequests: 0,
|
||||||
|
successCount: 0,
|
||||||
|
failedCount: 0,
|
||||||
|
latencyTotalMs: 0,
|
||||||
|
latencyCount: 0,
|
||||||
|
buckets: createBucketTemplate(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const log of logs) {
|
||||||
|
if (log.siteId == null) continue;
|
||||||
|
const target = siteMap.get(log.siteId);
|
||||||
|
if (!target) continue;
|
||||||
|
|
||||||
|
const parsed = parseStoredUtcDateTime(log.createdAt);
|
||||||
|
if (!parsed) continue;
|
||||||
|
const timestampMs = parsed.getTime();
|
||||||
|
const diffMs = timestampMs - startMs;
|
||||||
|
if (diffMs < 0 || diffMs >= rangeMs) continue;
|
||||||
|
|
||||||
|
const bucketIndex = Math.floor(diffMs / SITE_AVAILABILITY_BUCKET_MS);
|
||||||
|
const bucket = target.buckets[bucketIndex];
|
||||||
|
const isSuccess = (log.status || '').trim().toLowerCase() === 'success';
|
||||||
|
|
||||||
|
target.totalRequests += 1;
|
||||||
|
bucket.totalRequests += 1;
|
||||||
|
if (isSuccess) {
|
||||||
|
target.successCount += 1;
|
||||||
|
bucket.successCount += 1;
|
||||||
|
} else {
|
||||||
|
target.failedCount += 1;
|
||||||
|
bucket.failedCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latencyMs = Number(log.latencyMs);
|
||||||
|
if (Number.isFinite(latencyMs) && latencyMs >= 0) {
|
||||||
|
target.latencyTotalMs += latencyMs;
|
||||||
|
target.latencyCount += 1;
|
||||||
|
bucket.latencyTotalMs += latencyMs;
|
||||||
|
bucket.latencyCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sites.map((site) => {
|
||||||
|
const aggregate = siteMap.get(site.id)!;
|
||||||
|
return {
|
||||||
|
siteId: site.id,
|
||||||
|
siteName: site.name,
|
||||||
|
siteUrl: site.url,
|
||||||
|
platform: site.platform,
|
||||||
|
totalRequests: aggregate.totalRequests,
|
||||||
|
successCount: aggregate.successCount,
|
||||||
|
failedCount: aggregate.failedCount,
|
||||||
|
availabilityPercent: aggregate.totalRequests > 0
|
||||||
|
? roundPercent((aggregate.successCount / aggregate.totalRequests) * 100)
|
||||||
|
: null,
|
||||||
|
averageLatencyMs: aggregate.latencyCount > 0
|
||||||
|
? Math.round(aggregate.latencyTotalMs / aggregate.latencyCount)
|
||||||
|
: null,
|
||||||
|
buckets: aggregate.buckets.map((bucket) => ({
|
||||||
|
startUtc: bucket.startUtc,
|
||||||
|
label: bucket.label,
|
||||||
|
totalRequests: bucket.totalRequests,
|
||||||
|
successCount: bucket.successCount,
|
||||||
|
failedCount: bucket.failedCount,
|
||||||
|
availabilityPercent: bucket.totalRequests > 0
|
||||||
|
? roundPercent((bucket.successCount / bucket.totalRequests) * 100)
|
||||||
|
: null,
|
||||||
|
averageLatencyMs: bucket.latencyCount > 0
|
||||||
|
? Math.round(bucket.latencyTotalMs / bucket.latencyCount)
|
||||||
|
: null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function mapProxyLogRow(
|
function mapProxyLogRow(
|
||||||
row: {
|
row: {
|
||||||
proxy_logs: Record<string, unknown> & { billingDetails?: string | null };
|
proxy_logs: Record<string, unknown> & { billingDetails?: string | null };
|
||||||
accounts: { username?: string | null } | null;
|
accounts: { username?: string | null } | null;
|
||||||
sites: { name?: string | null; url?: string | null } | null;
|
sites: { id?: number | null; name?: string | null; url?: string | null } | null;
|
||||||
},
|
},
|
||||||
options?: { includeBillingDetails?: boolean },
|
options?: { includeBillingDetails?: boolean },
|
||||||
) {
|
) {
|
||||||
@@ -155,6 +325,7 @@ function mapProxyLogRow(
|
|||||||
? { billingDetails: parseProxyLogBillingDetails(row.proxy_logs.billingDetails) }
|
? { billingDetails: parseProxyLogBillingDetails(row.proxy_logs.billingDetails) }
|
||||||
: {}),
|
: {}),
|
||||||
username: row.accounts?.username || null,
|
username: row.accounts?.username || null,
|
||||||
|
siteId: row.sites?.id || null,
|
||||||
siteName: row.sites?.name || null,
|
siteName: row.sites?.name || null,
|
||||||
siteUrl: row.sites?.url || null,
|
siteUrl: row.sites?.url || null,
|
||||||
};
|
};
|
||||||
@@ -271,6 +442,49 @@ export async function statsRoutes(app: FastifyInstance) {
|
|||||||
extraConfig: account.extraConfig,
|
extraConfig: account.extraConfig,
|
||||||
}), 0);
|
}), 0);
|
||||||
const modelAnalysis = buildModelAnalysis(recentProxyLogs, { days: 7 });
|
const modelAnalysis = buildModelAnalysis(recentProxyLogs, { days: 7 });
|
||||||
|
const activeSites = (await db.select({
|
||||||
|
id: schema.sites.id,
|
||||||
|
name: schema.sites.name,
|
||||||
|
url: schema.sites.url,
|
||||||
|
platform: schema.sites.platform,
|
||||||
|
sortOrder: schema.sites.sortOrder,
|
||||||
|
isPinned: schema.sites.isPinned,
|
||||||
|
}).from(schema.sites)
|
||||||
|
.where(eq(schema.sites.status, 'active'))
|
||||||
|
.all())
|
||||||
|
.sort((left: SiteAvailabilitySiteRow, right: SiteAvailabilitySiteRow) => {
|
||||||
|
const leftPinned = left.isPinned ? 1 : 0;
|
||||||
|
const rightPinned = right.isPinned ? 1 : 0;
|
||||||
|
if (leftPinned !== rightPinned) return rightPinned - leftPinned;
|
||||||
|
const leftOrder = Number(left.sortOrder || 0);
|
||||||
|
const rightOrder = Number(right.sortOrder || 0);
|
||||||
|
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
|
||||||
|
return String(left.name || '').localeCompare(String(right.name || ''));
|
||||||
|
});
|
||||||
|
const siteAvailabilityNow = new Date();
|
||||||
|
const siteAvailabilitySinceUtc = formatUtcSqlDateTime(new Date(
|
||||||
|
siteAvailabilityNow.getFullYear(),
|
||||||
|
siteAvailabilityNow.getMonth(),
|
||||||
|
siteAvailabilityNow.getDate(),
|
||||||
|
siteAvailabilityNow.getHours() - (SITE_AVAILABILITY_BUCKET_COUNT - 1),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
));
|
||||||
|
const siteAvailabilityLogs = await db.select({
|
||||||
|
siteId: schema.sites.id,
|
||||||
|
createdAt: schema.proxyLogs.createdAt,
|
||||||
|
status: schema.proxyLogs.status,
|
||||||
|
latencyMs: schema.proxyLogs.latencyMs,
|
||||||
|
}).from(schema.proxyLogs)
|
||||||
|
.innerJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
|
||||||
|
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||||
|
.where(and(
|
||||||
|
gte(schema.proxyLogs.createdAt, siteAvailabilitySinceUtc),
|
||||||
|
eq(schema.sites.status, 'active'),
|
||||||
|
))
|
||||||
|
.all();
|
||||||
|
const siteAvailability = buildSiteAvailabilitySummaries(activeSites, siteAvailabilityLogs, siteAvailabilityNow);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalBalance,
|
totalBalance,
|
||||||
@@ -286,18 +500,30 @@ export async function statsRoutes(app: FastifyInstance) {
|
|||||||
requestsPerMinute,
|
requestsPerMinute,
|
||||||
tokensPerMinute,
|
tokensPerMinute,
|
||||||
},
|
},
|
||||||
|
siteAvailability,
|
||||||
modelAnalysis,
|
modelAnalysis,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Proxy logs
|
// Proxy logs
|
||||||
app.get<{ Querystring: { limit?: string; offset?: string; status?: string; search?: string } }>('/api/stats/proxy-logs', async (request) => {
|
app.get<{ Querystring: {
|
||||||
|
limit?: string;
|
||||||
|
offset?: string;
|
||||||
|
status?: string;
|
||||||
|
search?: string;
|
||||||
|
siteId?: string;
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
} }>('/api/stats/proxy-logs', async (request) => {
|
||||||
const limit = normalizeProxyLogPageSize(request.query.limit);
|
const limit = normalizeProxyLogPageSize(request.query.limit);
|
||||||
const offset = normalizeProxyLogOffset(request.query.offset);
|
const offset = normalizeProxyLogOffset(request.query.offset);
|
||||||
const status = normalizeProxyLogStatusFilter(request.query.status);
|
const status = normalizeProxyLogStatusFilter(request.query.status);
|
||||||
const search = normalizeProxyLogSearch(request.query.search);
|
const search = normalizeProxyLogSearch(request.query.search);
|
||||||
const listWhere = buildProxyLogWhereClause({ status, search });
|
const siteId = normalizeProxyLogSiteId(request.query.siteId);
|
||||||
const summaryWhere = buildProxyLogWhereClause({ search });
|
const fromUtc = normalizeProxyLogTimeBoundary(request.query.from);
|
||||||
|
const toUtc = normalizeProxyLogTimeBoundary(request.query.to);
|
||||||
|
const listWhere = buildProxyLogWhereClause({ status, search, siteId, fromUtc, toUtc });
|
||||||
|
const summaryWhere = buildProxyLogWhereClause({ search, siteId, fromUtc, toUtc });
|
||||||
|
|
||||||
const listRows = await withProxyLogSelectFields(({ fields }) => {
|
const listRows = await withProxyLogSelectFields(({ fields }) => {
|
||||||
let query = db.select({
|
let query = db.select({
|
||||||
@@ -320,12 +546,14 @@ export async function statsRoutes(app: FastifyInstance) {
|
|||||||
}, { includeBillingDetails: false }) as Array<{
|
}, { includeBillingDetails: false }) as Array<{
|
||||||
proxy_logs: Record<string, unknown> & { billingDetails?: string | null };
|
proxy_logs: Record<string, unknown> & { billingDetails?: string | null };
|
||||||
accounts: { username?: string | null } | null;
|
accounts: { username?: string | null } | null;
|
||||||
sites: { name?: string | null; url?: string | null } | null;
|
sites: { id?: number | null; name?: string | null; url?: string | null } | null;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
let totalQuery = db.select({
|
let totalQuery = db.select({
|
||||||
total: sql<number>`count(*)`,
|
total: sql<number>`count(*)`,
|
||||||
}).from(schema.proxyLogs);
|
}).from(schema.proxyLogs)
|
||||||
|
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
|
||||||
|
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id));
|
||||||
if (listWhere) {
|
if (listWhere) {
|
||||||
totalQuery = totalQuery.where(listWhere) as typeof totalQuery;
|
totalQuery = totalQuery.where(listWhere) as typeof totalQuery;
|
||||||
}
|
}
|
||||||
@@ -337,7 +565,9 @@ export async function statsRoutes(app: FastifyInstance) {
|
|||||||
failedCount: sql<number>`coalesce(sum(case when coalesce(${schema.proxyLogs.status}, '') <> 'success' then 1 else 0 end), 0)`,
|
failedCount: sql<number>`coalesce(sum(case when coalesce(${schema.proxyLogs.status}, '') <> 'success' then 1 else 0 end), 0)`,
|
||||||
totalCost: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`,
|
totalCost: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`,
|
||||||
totalTokensAll: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`,
|
totalTokensAll: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`,
|
||||||
}).from(schema.proxyLogs);
|
}).from(schema.proxyLogs)
|
||||||
|
.leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id))
|
||||||
|
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id));
|
||||||
if (summaryWhere) {
|
if (summaryWhere) {
|
||||||
summaryQuery = summaryQuery.where(summaryWhere) as typeof summaryQuery;
|
summaryQuery = summaryQuery.where(summaryWhere) as typeof summaryQuery;
|
||||||
}
|
}
|
||||||
@@ -377,7 +607,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
|||||||
), { includeBillingDetails: true }) as {
|
), { includeBillingDetails: true }) as {
|
||||||
proxy_logs: Record<string, unknown> & { billingDetails?: string | null };
|
proxy_logs: Record<string, unknown> & { billingDetails?: string | null };
|
||||||
accounts: { username?: string | null } | null;
|
accounts: { username?: string | null } | null;
|
||||||
sites: { name?: string | null; url?: string | null } | null;
|
sites: { id?: number | null; name?: string | null; url?: string | null } | null;
|
||||||
} | undefined;
|
} | undefined;
|
||||||
|
|
||||||
if (!row) {
|
if (!row) {
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ export type ProxyLogListItem = {
|
|||||||
totalTokens: number | null;
|
totalTokens: number | null;
|
||||||
retryCount: number;
|
retryCount: number;
|
||||||
accountId?: number | null;
|
accountId?: number | null;
|
||||||
|
siteId?: number | null;
|
||||||
username?: string | null;
|
username?: string | null;
|
||||||
siteName?: string | null;
|
siteName?: string | null;
|
||||||
siteUrl?: string | null;
|
siteUrl?: string | null;
|
||||||
@@ -213,6 +214,9 @@ export type ProxyLogsQuery = {
|
|||||||
offset?: number;
|
offset?: number;
|
||||||
status?: ProxyLogStatusFilter;
|
status?: ProxyLogStatusFilter;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
siteId?: number;
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProxyLogsResponse = {
|
export type ProxyLogsResponse = {
|
||||||
|
|||||||
@@ -3533,6 +3533,43 @@ textarea {
|
|||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.proxy-logs-filter-select {
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proxy-logs-time-field {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.proxy-logs-time-field span {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proxy-logs-time-field input {
|
||||||
|
min-width: 170px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proxy-logs-time-field input:focus {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.proxy-logs-filter-reset {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== View Toggle ===== */
|
/* ===== View Toggle ===== */
|
||||||
.view-toggle {
|
.view-toggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -3968,6 +4005,201 @@ textarea {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.site-observability-panel {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-observability-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-observability-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-observability-subtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-observability-legend {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-observability-legend-text {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-observability-legend-chip {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-border) 80%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-observability-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-observability-row {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--color-border-light);
|
||||||
|
background: color-mix(in srgb, var(--color-bg) 88%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-observability-row-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-observability-row-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-observability-site-meta {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-observability-site-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-observability-site-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-observability-platform-badge {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--color-info-soft) 72%, white);
|
||||||
|
color: var(--color-info);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-observability-link {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-info);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-observability-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-observability-site-stats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-observability-log-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-observability-log-link:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-availability-strip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(24, minmax(0, 1fr));
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-availability-cell {
|
||||||
|
width: 100%;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-border) 78%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-availability-cell-link {
|
||||||
|
display: block;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-availability-cell-link:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px color-mix(in srgb, var(--color-border) 78%, transparent),
|
||||||
|
0 2px 6px rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-availability-cell-link:focus-visible,
|
||||||
|
.site-observability-log-link:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 16%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-observability-empty {
|
||||||
|
padding: 28px 16px;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px dashed var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: color-mix(in srgb, var(--color-bg) 90%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-observability-empty-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-observability-empty-note {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Responsive Adjustments ===== */
|
/* ===== Responsive Adjustments ===== */
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.topbar-search-trigger {
|
.topbar-search-trigger {
|
||||||
@@ -4055,10 +4287,54 @@ textarea {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.site-observability-row {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-observability-row-header {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-observability-row-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-observability-site-stats {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-availability-strip {
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-availability-cell {
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
.monitor-iframe {
|
.monitor-iframe {
|
||||||
height: calc(100vh - 320px);
|
height: calc(100vh - 320px);
|
||||||
min-height: 460px;
|
min-height: 460px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.proxy-logs-filter-select,
|
||||||
|
.proxy-logs-time-field,
|
||||||
|
.proxy-logs-filter-reset {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proxy-logs-time-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proxy-logs-time-field input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Suspense, lazy, useEffect, useState, useCallback } from 'react';
|
import { Suspense, lazy, useEffect, useState, useCallback } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { useToast } from '../components/Toast.js';
|
import { useToast } from '../components/Toast.js';
|
||||||
import { formatCompactTokenMetric } from '../numberFormat.js';
|
import { formatCompactTokenMetric } from '../numberFormat.js';
|
||||||
@@ -36,6 +37,105 @@ type SiteSpeedState =
|
|||||||
| { status: 'done'; ms: number }
|
| { status: 'done'; ms: number }
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
|
type SiteAvailabilityBucket = {
|
||||||
|
startUtc?: string | null;
|
||||||
|
label: string;
|
||||||
|
totalRequests: number;
|
||||||
|
successCount: number;
|
||||||
|
failedCount: number;
|
||||||
|
availabilityPercent: number | null;
|
||||||
|
averageLatencyMs: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SiteAvailabilitySummary = {
|
||||||
|
siteId: number;
|
||||||
|
siteName: string;
|
||||||
|
siteUrl?: string | null;
|
||||||
|
platform?: string | null;
|
||||||
|
totalRequests: number;
|
||||||
|
successCount: number;
|
||||||
|
failedCount: number;
|
||||||
|
availabilityPercent: number | null;
|
||||||
|
averageLatencyMs: number | null;
|
||||||
|
buckets: SiteAvailabilityBucket[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatAvailabilityPercent(value: number | null | undefined): string {
|
||||||
|
if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) return '—';
|
||||||
|
return `${Math.round(value)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAvailabilityColor(value: number | null | undefined): string {
|
||||||
|
if (typeof value !== 'number' || Number.isNaN(value) || !Number.isFinite(value)) {
|
||||||
|
return 'var(--color-border-light)';
|
||||||
|
}
|
||||||
|
const hue = Math.max(0, Math.min(120, value * 1.2));
|
||||||
|
return `hsl(${hue}, 72%, 45%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function padDateTimeSegment(value: number): string {
|
||||||
|
return String(value).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTimeRouteValue(value: Date): string {
|
||||||
|
return `${value.getFullYear()}-${padDateTimeSegment(value.getMonth() + 1)}-${padDateTimeSegment(value.getDate())}T${padDateTimeSegment(value.getHours())}:${padDateTimeSegment(value.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSiteLogsRoute(siteId: number, range?: { from: Date; to: Date }): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('siteId', String(siteId));
|
||||||
|
if (range) {
|
||||||
|
params.set('from', formatDateTimeRouteValue(range.from));
|
||||||
|
params.set('to', formatDateTimeRouteValue(range.to));
|
||||||
|
}
|
||||||
|
return `/logs?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSiteLast24hLogsRoute(siteId: number): string {
|
||||||
|
const now = new Date();
|
||||||
|
const from = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours() - 23, 0, 0, 0);
|
||||||
|
const to = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours() + 1, 0, 0, 0);
|
||||||
|
return buildSiteLogsRoute(siteId, { from, to });
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAvailabilityBucketStart(startUtc?: string | null): Date | null {
|
||||||
|
const text = (startUtc || '').trim();
|
||||||
|
if (!text) return null;
|
||||||
|
const parsed = new Date(text);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return null;
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAvailabilityBucketLabel(label: string): Date | null {
|
||||||
|
const match = label.match(/^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})(?::(\d{2}))?$/);
|
||||||
|
if (!match) return null;
|
||||||
|
const [, year, month, day, hour, minute, second = '0'] = match;
|
||||||
|
const parsed = new Date(
|
||||||
|
Number(year),
|
||||||
|
Number(month) - 1,
|
||||||
|
Number(day),
|
||||||
|
Number(hour),
|
||||||
|
Number(minute),
|
||||||
|
Number(second),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return null;
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAvailabilityBucketLabel(bucket: SiteAvailabilityBucket): string {
|
||||||
|
const parsed = parseAvailabilityBucketStart(bucket.startUtc) || parseAvailabilityBucketLabel(bucket.label);
|
||||||
|
if (!parsed) return bucket.label;
|
||||||
|
return `${parsed.getFullYear()}-${padDateTimeSegment(parsed.getMonth() + 1)}-${padDateTimeSegment(parsed.getDate())} ${padDateTimeSegment(parsed.getHours())}:${padDateTimeSegment(parsed.getMinutes())}:${padDateTimeSegment(parsed.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAvailabilityBucketLogsRoute(siteId: number, bucket: SiteAvailabilityBucket): string {
|
||||||
|
const start = parseAvailabilityBucketStart(bucket.startUtc) || parseAvailabilityBucketLabel(bucket.label);
|
||||||
|
if (!start) return buildSiteLast24hLogsRoute(siteId);
|
||||||
|
const end = new Date(start.getTime() + 60 * 60 * 1000);
|
||||||
|
return buildSiteLogsRoute(siteId, { from: start, to: end });
|
||||||
|
}
|
||||||
|
|
||||||
export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminName?: string }) {
|
export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminName?: string }) {
|
||||||
const [data, setData] = useState<any>(null);
|
const [data, setData] = useState<any>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -213,6 +313,9 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
|
|||||||
const performanceWindowSeconds = Math.max(1, safeNumber(data?.performance?.windowSeconds) || 60);
|
const performanceWindowSeconds = Math.max(1, safeNumber(data?.performance?.windowSeconds) || 60);
|
||||||
const requestsPerMinute = safeNumber(data?.performance?.requestsPerMinute);
|
const requestsPerMinute = safeNumber(data?.performance?.requestsPerMinute);
|
||||||
const tokensPerMinute = safeNumber(data?.performance?.tokensPerMinute);
|
const tokensPerMinute = safeNumber(data?.performance?.tokensPerMinute);
|
||||||
|
const siteAvailability: SiteAvailabilitySummary[] = Array.isArray(data?.siteAvailability)
|
||||||
|
? data.siteAvailability
|
||||||
|
: [];
|
||||||
|
|
||||||
const getLatencyColor = (ms: number) => (
|
const getLatencyColor = (ms: number) => (
|
||||||
ms <= 500
|
ms <= 500
|
||||||
@@ -461,6 +564,97 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="chart-container animate-slide-up stagger-8 site-observability-panel">
|
||||||
|
<div className="site-observability-header">
|
||||||
|
<div>
|
||||||
|
<div className="site-observability-title">
|
||||||
|
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12h4l3 8 4-16 3 8h4" /></svg>
|
||||||
|
站点可用性观测
|
||||||
|
</div>
|
||||||
|
<div className="site-observability-subtitle">最近 24 小时,每个色块代表 1 小时,按单次上游请求统计</div>
|
||||||
|
</div>
|
||||||
|
<div className="site-observability-legend">
|
||||||
|
<span className="site-observability-legend-text">低</span>
|
||||||
|
<span className="site-observability-legend-chip" style={{ background: getAvailabilityColor(0) }} />
|
||||||
|
<span className="site-observability-legend-chip" style={{ background: getAvailabilityColor(50) }} />
|
||||||
|
<span className="site-observability-legend-chip" style={{ background: getAvailabilityColor(100) }} />
|
||||||
|
<span className="site-observability-legend-text">高</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{siteAvailability.length > 0 ? (
|
||||||
|
<div className="site-observability-list">
|
||||||
|
{siteAvailability.map((site) => (
|
||||||
|
<div key={site.siteId} className="site-observability-row">
|
||||||
|
<div className="site-observability-row-header">
|
||||||
|
<div className="site-observability-site-meta">
|
||||||
|
<div className="site-observability-site-name-row">
|
||||||
|
<span className="site-observability-site-name">{site.siteName}</span>
|
||||||
|
{site.platform && (
|
||||||
|
<span className="site-observability-platform-badge">{site.platform}</span>
|
||||||
|
)}
|
||||||
|
{site.siteUrl && (
|
||||||
|
<a
|
||||||
|
href={site.siteUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="site-observability-link"
|
||||||
|
>
|
||||||
|
打开
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="site-observability-site-stats">
|
||||||
|
<span>{formatAvailabilityPercent(site.availabilityPercent)} 可用性</span>
|
||||||
|
<span style={site.averageLatencyMs != null ? { color: getLatencyColor(site.averageLatencyMs) } : undefined}>
|
||||||
|
{site.averageLatencyMs != null ? `${site.averageLatencyMs}ms` : '—'} 平均响应
|
||||||
|
</span>
|
||||||
|
<span>{Math.round(site.totalRequests || 0)} 次请求</span>
|
||||||
|
<span>{Math.round(site.successCount || 0)} 成功 / {Math.round(site.failedCount || 0)} 失败</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="site-observability-row-actions">
|
||||||
|
<Link
|
||||||
|
to={buildSiteLast24hLogsRoute(site.siteId)}
|
||||||
|
className="site-observability-log-link"
|
||||||
|
>
|
||||||
|
查看日志
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="site-availability-strip">
|
||||||
|
{site.buckets.map((bucket, index) => (
|
||||||
|
<Link
|
||||||
|
key={`${site.siteId}-${index}`}
|
||||||
|
to={buildAvailabilityBucketLogsRoute(site.siteId, bucket)}
|
||||||
|
className="site-availability-cell site-availability-cell-link"
|
||||||
|
style={{
|
||||||
|
background: getAvailabilityColor(bucket.availabilityPercent),
|
||||||
|
opacity: bucket.totalRequests > 0 ? 1 : 0.35,
|
||||||
|
}}
|
||||||
|
title={[
|
||||||
|
formatAvailabilityBucketLabel(bucket),
|
||||||
|
bucket.totalRequests > 0
|
||||||
|
? `可用性 ${formatAvailabilityPercent(bucket.availabilityPercent)}`
|
||||||
|
: '无请求',
|
||||||
|
`${bucket.successCount} 成功 / ${bucket.failedCount} 失败`,
|
||||||
|
bucket.averageLatencyMs != null ? `平均响应 ${bucket.averageLatencyMs}ms` : '平均响应 —',
|
||||||
|
].join(' | ')}
|
||||||
|
aria-label={`${site.siteName} ${formatAvailabilityBucketLabel(bucket)} 使用日志`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="site-observability-empty">
|
||||||
|
<div className="site-observability-empty-title">暂无站点观测数据</div>
|
||||||
|
<div className="site-observability-empty-note">有代理请求后,这里会自动生成每个站点的可用性条和平均响应速度。</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap: 16 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 300px', gap: 16 }}>
|
||||||
<div className="chart-container animate-slide-up stagger-8">
|
<div className="chart-container animate-slide-up stagger-8">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import { ToastProvider } from '../components/Toast.js';
|
import { ToastProvider } from '../components/Toast.js';
|
||||||
import ProxyLogs from './ProxyLogs.js';
|
import ProxyLogs from './ProxyLogs.js';
|
||||||
|
|
||||||
@@ -7,6 +8,7 @@ const { apiMock } = vi.hoisted(() => ({
|
|||||||
apiMock: {
|
apiMock: {
|
||||||
getProxyLogs: vi.fn(),
|
getProxyLogs: vi.fn(),
|
||||||
getProxyLogDetail: vi.fn(),
|
getProxyLogDetail: vi.fn(),
|
||||||
|
getSites: vi.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -79,6 +81,10 @@ function buildListResponse(overrides?: Partial<{
|
|||||||
describe('ProxyLogs server-driven page', () => {
|
describe('ProxyLogs server-driven page', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
apiMock.getSites.mockResolvedValue([
|
||||||
|
{ id: 9, name: 'main-site', status: 'active' },
|
||||||
|
{ id: 12, name: 'backup-site', status: 'active' },
|
||||||
|
]);
|
||||||
apiMock.getProxyLogs.mockResolvedValue(buildListResponse());
|
apiMock.getProxyLogs.mockResolvedValue(buildListResponse());
|
||||||
apiMock.getProxyLogDetail.mockResolvedValue({
|
apiMock.getProxyLogDetail.mockResolvedValue({
|
||||||
id: 101,
|
id: 101,
|
||||||
@@ -138,9 +144,11 @@ describe('ProxyLogs server-driven page', () => {
|
|||||||
try {
|
try {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root = create(
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/logs']}>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<ProxyLogs />
|
<ProxyLogs />
|
||||||
</ToastProvider>,
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
@@ -168,9 +176,11 @@ describe('ProxyLogs server-driven page', () => {
|
|||||||
try {
|
try {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root = create(
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/logs']}>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<ProxyLogs />
|
<ProxyLogs />
|
||||||
</ToastProvider>,
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
@@ -214,9 +224,11 @@ describe('ProxyLogs server-driven page', () => {
|
|||||||
try {
|
try {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root = create(
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/logs']}>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<ProxyLogs />
|
<ProxyLogs />
|
||||||
</ToastProvider>,
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
@@ -248,4 +260,38 @@ describe('ProxyLogs server-driven page', () => {
|
|||||||
root?.unmount();
|
root?.unmount();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hydrates site and time filters from the route query', async () => {
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/logs?siteId=9&from=2026-03-09T08:00&to=2026-03-09T09:00']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<ProxyLogs />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const expectedFrom = new Date(2026, 2, 9, 8, 0).toISOString();
|
||||||
|
const expectedTo = new Date(2026, 2, 9, 9, 0).toISOString();
|
||||||
|
expect(apiMock.getProxyLogs).toHaveBeenCalledWith({
|
||||||
|
limit: 50,
|
||||||
|
offset: 0,
|
||||||
|
status: 'all',
|
||||||
|
search: '',
|
||||||
|
siteId: 9,
|
||||||
|
from: expectedFrom,
|
||||||
|
to: expectedTo,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rendered = JSON.stringify(root!.toJSON());
|
||||||
|
expect(rendered).toContain('main-site');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+271
-16
@@ -1,4 +1,5 @@
|
|||||||
import React, { useDeferredValue, useEffect, useMemo, useState, useCallback } from 'react';
|
import React, { useDeferredValue, useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
type ProxyLogBillingDetails,
|
type ProxyLogBillingDetails,
|
||||||
@@ -32,6 +33,7 @@ type ProxyLogDetailState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const PAGE_SIZES = [20, 50, 100];
|
const PAGE_SIZES = [20, 50, 100];
|
||||||
|
const DEFAULT_PAGE_SIZE = 50;
|
||||||
const EMPTY_SUMMARY: ProxyLogsSummary = {
|
const EMPTY_SUMMARY: ProxyLogsSummary = {
|
||||||
totalCount: 0,
|
totalCount: 0,
|
||||||
successCount: 0,
|
successCount: 0,
|
||||||
@@ -113,27 +115,173 @@ function buildBillingProcessLines(log: ProxyLogRenderItem) {
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function padDateTimeSegment(value: number) {
|
||||||
|
return String(value).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTimeInputValue(value: Date) {
|
||||||
|
return `${value.getFullYear()}-${padDateTimeSegment(value.getMonth() + 1)}-${padDateTimeSegment(value.getDate())}T${padDateTimeSegment(value.getHours())}:${padDateTimeSegment(value.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRoutePage(raw: string | null): number {
|
||||||
|
const parsed = Number.parseInt(raw || '', 10);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) return 1;
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRoutePageSize(raw: string | null): number {
|
||||||
|
const parsed = Number.parseInt(raw || '', 10);
|
||||||
|
return PAGE_SIZES.includes(parsed) ? parsed : DEFAULT_PAGE_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRouteStatus(raw: string | null): ProxyLogStatusFilter {
|
||||||
|
if (raw === 'success' || raw === 'failed') return raw;
|
||||||
|
return 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRouteSearch(raw: string | null): string {
|
||||||
|
return (raw || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRouteSiteId(raw: string | null): number | null {
|
||||||
|
const parsed = Number.parseInt(raw || '', 10);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRouteDateTimeInput(raw: string | null): string {
|
||||||
|
const text = (raw || '').trim();
|
||||||
|
if (!text) return '';
|
||||||
|
const parsed = new Date(text);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return '';
|
||||||
|
return formatDateTimeInputValue(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readProxyLogsRouteState(search: string) {
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
return {
|
||||||
|
page: normalizeRoutePage(params.get('page')),
|
||||||
|
pageSize: normalizeRoutePageSize(params.get('pageSize')),
|
||||||
|
status: normalizeRouteStatus(params.get('status')),
|
||||||
|
search: normalizeRouteSearch(params.get('q')),
|
||||||
|
siteId: normalizeRouteSiteId(params.get('siteId')),
|
||||||
|
from: normalizeRouteDateTimeInput(params.get('from')),
|
||||||
|
to: normalizeRouteDateTimeInput(params.get('to')),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProxyLogsRouteSearch(input: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
status: ProxyLogStatusFilter;
|
||||||
|
search: string;
|
||||||
|
siteId: number | null;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (input.page > 1) params.set('page', String(input.page));
|
||||||
|
if (input.pageSize !== DEFAULT_PAGE_SIZE) params.set('pageSize', String(input.pageSize));
|
||||||
|
if (input.status !== 'all') params.set('status', input.status);
|
||||||
|
if (input.search.trim()) params.set('q', input.search.trim());
|
||||||
|
if (input.siteId) params.set('siteId', String(input.siteId));
|
||||||
|
if (input.from.trim()) params.set('from', input.from.trim());
|
||||||
|
if (input.to.trim()) params.set('to', input.to.trim());
|
||||||
|
const next = params.toString();
|
||||||
|
return next ? `?${next}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toApiTimeBoundary(value: string): string | undefined {
|
||||||
|
const text = value.trim();
|
||||||
|
if (!text) return undefined;
|
||||||
|
const parsed = new Date(text);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return undefined;
|
||||||
|
return parsed.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProxyLogs() {
|
export default function ProxyLogs() {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const initialRouteState = useMemo(() => readProxyLogsRouteState(location.search), [location.search]);
|
||||||
const [logs, setLogs] = useState<ProxyLogListItem[]>([]);
|
const [logs, setLogs] = useState<ProxyLogListItem[]>([]);
|
||||||
const [summary, setSummary] = useState<ProxyLogsSummary>(EMPTY_SUMMARY);
|
const [summary, setSummary] = useState<ProxyLogsSummary>(EMPTY_SUMMARY);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [statusFilter, setStatusFilter] = useState<ProxyLogStatusFilter>('all');
|
const [statusFilter, setStatusFilter] = useState<ProxyLogStatusFilter>(initialRouteState.status);
|
||||||
const [searchInput, setSearchInput] = useState('');
|
const [searchInput, setSearchInput] = useState(initialRouteState.search);
|
||||||
const deferredSearchInput = useDeferredValue(searchInput.trim());
|
const deferredSearchInput = useDeferredValue(searchInput.trim());
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [siteFilter, setSiteFilter] = useState<number | null>(initialRouteState.siteId);
|
||||||
|
const [fromInput, setFromInput] = useState(initialRouteState.from);
|
||||||
|
const [toInput, setToInput] = useState(initialRouteState.to);
|
||||||
const [expanded, setExpanded] = useState<number | null>(null);
|
const [expanded, setExpanded] = useState<number | null>(null);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(initialRouteState.page);
|
||||||
const [pageSize, setPageSize] = useState(50);
|
const [pageSize, setPageSize] = useState(initialRouteState.pageSize);
|
||||||
const [detailById, setDetailById] = useState<Record<number, ProxyLogDetailState>>({});
|
const [detailById, setDetailById] = useState<Record<number, ProxyLogDetailState>>({});
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
const [sites, setSites] = useState<Array<{ id: number; name: string; status?: string | null }>>([]);
|
||||||
const isMobile = useIsMobile(768);
|
const isMobile = useIsMobile(768);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
const loadSeq = useRef(0);
|
||||||
|
const fromApiBoundary = toApiTimeBoundary(fromInput);
|
||||||
|
const toApiBoundaryValue = toApiTimeBoundary(toInput);
|
||||||
|
const hasInvalidTimeRange = Boolean(
|
||||||
|
fromApiBoundary
|
||||||
|
&& toApiBoundaryValue
|
||||||
|
&& new Date(fromApiBoundary).getTime() >= new Date(toApiBoundaryValue).getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1);
|
const next = readProxyLogsRouteState(location.search);
|
||||||
setSearchQuery(deferredSearchInput);
|
setStatusFilter((current) => (current === next.status ? current : next.status));
|
||||||
}, [deferredSearchInput]);
|
setSearchInput((current) => (current === next.search ? current : next.search));
|
||||||
|
setSiteFilter((current) => (current === next.siteId ? current : next.siteId));
|
||||||
|
setFromInput((current) => (current === next.from ? current : next.from));
|
||||||
|
setToInput((current) => (current === next.to ? current : next.to));
|
||||||
|
setPage((current) => (current === next.page ? current : next.page));
|
||||||
|
setPageSize((current) => (current === next.pageSize ? current : next.pageSize));
|
||||||
|
}, [location.search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextSearch = buildProxyLogsRouteSearch({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
status: statusFilter,
|
||||||
|
search: searchInput,
|
||||||
|
siteId: siteFilter,
|
||||||
|
from: fromInput,
|
||||||
|
to: toInput,
|
||||||
|
});
|
||||||
|
if (nextSearch === location.search) return;
|
||||||
|
navigate({ pathname: location.pathname, search: nextSearch }, { replace: true });
|
||||||
|
}, [fromInput, location.pathname, location.search, navigate, page, pageSize, searchInput, siteFilter, statusFilter, toInput]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const loadSites = async () => {
|
||||||
|
try {
|
||||||
|
const result = await api.getSites();
|
||||||
|
const rows = Array.isArray(result) ? result : (result?.sites || []);
|
||||||
|
const normalized = rows
|
||||||
|
.map((site: any) => ({
|
||||||
|
id: Number(site?.id || 0),
|
||||||
|
name: String(site?.name || '').trim() || `站点 #${site?.id ?? ''}`,
|
||||||
|
status: typeof site?.status === 'string' ? site.status : null,
|
||||||
|
}))
|
||||||
|
.filter((site) => site.id > 0)
|
||||||
|
.sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'));
|
||||||
|
if (!cancelled) setSites(normalized);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load sites for proxy log filters:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadSites();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
const safePage = Math.min(page, totalPages);
|
const safePage = Math.min(page, totalPages);
|
||||||
@@ -150,24 +298,60 @@ export default function ProxyLogs() {
|
|||||||
})
|
})
|
||||||
), [safePage, totalPages]);
|
), [safePage, totalPages]);
|
||||||
|
|
||||||
|
const siteOptions = useMemo(() => {
|
||||||
|
const options = sites.map((site) => ({
|
||||||
|
value: String(site.id),
|
||||||
|
label: site.status === 'disabled' ? `${site.name}(已禁用)` : site.name,
|
||||||
|
}));
|
||||||
|
if (siteFilter && !options.some((option) => option.value === String(siteFilter))) {
|
||||||
|
options.unshift({
|
||||||
|
value: String(siteFilter),
|
||||||
|
label: `站点 #${siteFilter}(已删除)`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ value: '', label: '全部站点' },
|
||||||
|
...options,
|
||||||
|
];
|
||||||
|
}, [siteFilter, sites]);
|
||||||
|
|
||||||
|
const activeSiteLabel = useMemo(() => {
|
||||||
|
if (!siteFilter) return '全部站点';
|
||||||
|
return siteOptions.find((option) => option.value === String(siteFilter))?.label || `站点 #${siteFilter}`;
|
||||||
|
}, [siteFilter, siteOptions]);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
|
const seq = ++loadSeq.current;
|
||||||
|
if (hasInvalidTimeRange) {
|
||||||
|
setLogs([]);
|
||||||
|
setTotal(0);
|
||||||
|
setSummary(EMPTY_SUMMARY);
|
||||||
|
if (seq === loadSeq.current) setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await api.getProxyLogs({
|
const params = {
|
||||||
limit: pageSize,
|
limit: pageSize,
|
||||||
offset: currentOffset,
|
offset: currentOffset,
|
||||||
status: statusFilter,
|
status: statusFilter,
|
||||||
search: searchQuery,
|
search: deferredSearchInput,
|
||||||
});
|
...(siteFilter ? { siteId: siteFilter } : {}),
|
||||||
|
...(fromApiBoundary ? { from: fromApiBoundary } : {}),
|
||||||
|
...(toApiBoundaryValue ? { to: toApiBoundaryValue } : {}),
|
||||||
|
};
|
||||||
|
const data = await api.getProxyLogs(params);
|
||||||
|
if (seq !== loadSeq.current) return;
|
||||||
setLogs(Array.isArray(data.items) ? data.items : []);
|
setLogs(Array.isArray(data.items) ? data.items : []);
|
||||||
setTotal(Number(data.total || 0));
|
setTotal(Number(data.total || 0));
|
||||||
setSummary(data.summary || EMPTY_SUMMARY);
|
setSummary(data.summary || EMPTY_SUMMARY);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
if (seq !== loadSeq.current) return;
|
||||||
toast.error(e.message || '加载日志失败');
|
toast.error(e.message || '加载日志失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (seq === loadSeq.current) setLoading(false);
|
||||||
}
|
}
|
||||||
}, [currentOffset, pageSize, searchQuery, statusFilter, toast]);
|
}, [currentOffset, deferredSearchInput, fromApiBoundary, hasInvalidTimeRange, pageSize, siteFilter, statusFilter, toApiBoundaryValue, toast]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
@@ -239,12 +423,69 @@ export default function ProxyLogs() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="proxy-logs-filter-select">
|
||||||
|
<ModernSelect
|
||||||
|
size="sm"
|
||||||
|
value={siteFilter ? String(siteFilter) : ''}
|
||||||
|
onChange={(nextValue) => {
|
||||||
|
setSiteFilter(nextValue ? Number(nextValue) : null);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
options={siteOptions}
|
||||||
|
placeholder="全部站点"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="proxy-logs-time-field">
|
||||||
|
<span>开始</span>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={fromInput}
|
||||||
|
max={toInput || undefined}
|
||||||
|
onChange={(e) => {
|
||||||
|
setFromInput(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="proxy-logs-time-field">
|
||||||
|
<span>结束</span>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={toInput}
|
||||||
|
min={fromInput || undefined}
|
||||||
|
onChange={(e) => {
|
||||||
|
setToInput(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<div className="toolbar-search" style={{ maxWidth: 280 }}>
|
<div className="toolbar-search" style={{ maxWidth: 280 }}>
|
||||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
<input value={searchInput} onChange={(e) => setSearchInput(e.target.value)} placeholder="搜索模型名称..." />
|
<input
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchInput(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
placeholder="搜索模型名称..."
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost proxy-logs-filter-reset"
|
||||||
|
onClick={() => {
|
||||||
|
setStatusFilter('all');
|
||||||
|
setSiteFilter(null);
|
||||||
|
setFromInput('');
|
||||||
|
setToInput('');
|
||||||
|
setSearchInput('');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
清空筛选
|
||||||
|
</button>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -253,6 +494,9 @@ export default function ProxyLogs() {
|
|||||||
<div className="page-header" style={{ marginBottom: 16 }}>
|
<div className="page-header" style={{ marginBottom: 16 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
<h2 className="page-title">{tr('使用日志')}</h2>
|
<h2 className="page-title">{tr('使用日志')}</h2>
|
||||||
|
<span className="kpi-chip">
|
||||||
|
{activeSiteLabel}
|
||||||
|
</span>
|
||||||
<span className="kpi-chip kpi-chip-success">
|
<span className="kpi-chip kpi-chip-success">
|
||||||
消耗总额 ${summary.totalCost.toFixed(4)}
|
消耗总额 ${summary.totalCost.toFixed(4)}
|
||||||
</span>
|
</span>
|
||||||
@@ -292,6 +536,12 @@ export default function ProxyLogs() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{hasInvalidTimeRange && (
|
||||||
|
<div className="alert alert-error" style={{ marginBottom: 12 }}>
|
||||||
|
结束时间必须晚于开始时间
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="card" style={{ overflowX: 'auto' }}>
|
<div className="card" style={{ overflowX: 'auto' }}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
@@ -328,6 +578,7 @@ export default function ProxyLogs() {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MobileField label="时间" value={formatDateTimeLocal(log.createdAt)} />
|
<MobileField label="时间" value={formatDateTimeLocal(log.createdAt)} />
|
||||||
|
<MobileField label="站点" value={log.siteName || '-'} />
|
||||||
<MobileField label="用时" value={formatLatency(log.latencyMs)} />
|
<MobileField label="用时" value={formatLatency(log.latencyMs)} />
|
||||||
<MobileField label="输入" value={log.promptTokens?.toLocaleString() || '-'} />
|
<MobileField label="输入" value={log.promptTokens?.toLocaleString() || '-'} />
|
||||||
<MobileField label="输出" value={log.completionTokens?.toLocaleString() || '-'} />
|
<MobileField label="输出" value={log.completionTokens?.toLocaleString() || '-'} />
|
||||||
@@ -373,6 +624,7 @@ export default function ProxyLogs() {
|
|||||||
<th style={{ width: 28 }} />
|
<th style={{ width: 28 }} />
|
||||||
<th>时间</th>
|
<th>时间</th>
|
||||||
<th>模型</th>
|
<th>模型</th>
|
||||||
|
<th>站点</th>
|
||||||
<th>{tr('状态')}</th>
|
<th>{tr('状态')}</th>
|
||||||
<th style={{ textAlign: 'center' }}>用时</th>
|
<th style={{ textAlign: 'center' }}>用时</th>
|
||||||
<th style={{ textAlign: 'right' }}>输入</th>
|
<th style={{ textAlign: 'right' }}>输入</th>
|
||||||
@@ -416,6 +668,9 @@ export default function ProxyLogs() {
|
|||||||
<td>
|
<td>
|
||||||
<ModelBadge model={log.modelRequested} />
|
<ModelBadge model={log.modelRequested} />
|
||||||
</td>
|
</td>
|
||||||
|
<td style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
|
||||||
|
{log.siteName || '-'}
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`badge ${log.status === 'success' ? 'badge-success' : 'badge-error'}`} style={{ fontSize: 11, fontWeight: 600 }}>
|
<span className={`badge ${log.status === 'success' ? 'badge-success' : 'badge-error'}`} style={{ fontSize: 11, fontWeight: 600 }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
@@ -457,7 +712,7 @@ export default function ProxyLogs() {
|
|||||||
</tr>
|
</tr>
|
||||||
{expanded === log.id && (
|
{expanded === log.id && (
|
||||||
<tr style={{ background: 'var(--color-bg)' }}>
|
<tr style={{ background: 'var(--color-bg)' }}>
|
||||||
<td colSpan={9} style={{ padding: 0 }}>
|
<td colSpan={10} style={{ padding: 0 }}>
|
||||||
<div className="anim-collapse is-open">
|
<div className="anim-collapse is-open">
|
||||||
<div className="anim-collapse-inner">
|
<div className="anim-collapse-inner">
|
||||||
<div className="animate-fade-in" style={{
|
<div className="animate-fade-in" style={{
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
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 Dashboard from './Dashboard.js';
|
||||||
|
|
||||||
|
const { apiMock } = vi.hoisted(() => ({
|
||||||
|
apiMock: {
|
||||||
|
getDashboard: vi.fn(),
|
||||||
|
getSiteDistribution: vi.fn(),
|
||||||
|
getSiteTrend: vi.fn(),
|
||||||
|
getSites: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../api.js', () => ({
|
||||||
|
api: apiMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
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('Dashboard site observability panel', () => {
|
||||||
|
const originalDocument = globalThis.document;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
apiMock.getDashboard.mockResolvedValue({
|
||||||
|
totalBalance: 0,
|
||||||
|
totalUsed: 0,
|
||||||
|
todaySpend: 0,
|
||||||
|
todayReward: 0,
|
||||||
|
activeAccounts: 0,
|
||||||
|
totalAccounts: 0,
|
||||||
|
todayCheckin: { success: 0, total: 0 },
|
||||||
|
proxy24h: { success: 0, total: 0, totalTokens: 0 },
|
||||||
|
performance: { windowSeconds: 60, requestsPerMinute: 0, tokensPerMinute: 0 },
|
||||||
|
siteAvailability: [{
|
||||||
|
siteId: 1,
|
||||||
|
siteName: 'Demo Site',
|
||||||
|
siteUrl: 'https://example.com',
|
||||||
|
platform: 'new-api',
|
||||||
|
totalRequests: 8,
|
||||||
|
successCount: 6,
|
||||||
|
failedCount: 2,
|
||||||
|
availabilityPercent: 75,
|
||||||
|
averageLatencyMs: 320,
|
||||||
|
buckets: Array.from({ length: 24 }, (_, index) => ({
|
||||||
|
startUtc: new Date(Date.UTC(2026, 2, 11, index, 0, 0)).toISOString(),
|
||||||
|
label: `2026-03-11 ${String(index).padStart(2, '0')}:00:00`,
|
||||||
|
totalRequests: index < 8 ? 1 : 0,
|
||||||
|
successCount: index < 6 ? 1 : 0,
|
||||||
|
failedCount: index >= 6 && index < 8 ? 1 : 0,
|
||||||
|
availabilityPercent: index < 6 ? 100 : index < 8 ? 0 : null,
|
||||||
|
averageLatencyMs: index < 8 ? 320 : null,
|
||||||
|
})),
|
||||||
|
}],
|
||||||
|
modelAnalysis: null,
|
||||||
|
});
|
||||||
|
apiMock.getSiteDistribution.mockResolvedValue({ distribution: [] });
|
||||||
|
apiMock.getSiteTrend.mockResolvedValue({ trend: [] });
|
||||||
|
apiMock.getSites.mockResolvedValue([]);
|
||||||
|
globalThis.document = {
|
||||||
|
visibilityState: 'visible',
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
getElementById: vi.fn(() => null),
|
||||||
|
} as unknown as Document;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.document = originalDocument;
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders site availability strips and summary metrics', async () => {
|
||||||
|
let root: ReturnType<typeof create> | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter initialEntries={['/']}>
|
||||||
|
<ToastProvider>
|
||||||
|
<Dashboard />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const panel = root!.root.find((node) => (
|
||||||
|
typeof node.props.className === 'string'
|
||||||
|
&& node.props.className.includes('site-observability-panel')
|
||||||
|
));
|
||||||
|
|
||||||
|
const cells = panel.findAll((node) => (
|
||||||
|
node.type === 'a'
|
||||||
|
&& typeof node.props.className === 'string'
|
||||||
|
&& node.props.className.includes('site-availability-cell')
|
||||||
|
));
|
||||||
|
|
||||||
|
const logLink = panel.find((node) => (
|
||||||
|
node.type === 'a'
|
||||||
|
&& typeof node.props.className === 'string'
|
||||||
|
&& node.props.className.includes('site-observability-log-link')
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(collectText(panel)).toContain('站点可用性观测');
|
||||||
|
expect(collectText(panel)).toContain('Demo Site');
|
||||||
|
expect(collectText(panel)).toContain('75%');
|
||||||
|
expect(collectText(panel)).toContain('320ms');
|
||||||
|
expect(collectText(panel)).toContain('查看日志');
|
||||||
|
expect(cells).toHaveLength(24);
|
||||||
|
expect(String(cells[0]?.props.title || '')).toContain('可用性 100%');
|
||||||
|
expect(String(cells[7]?.props.title || '')).toContain('可用性 0%');
|
||||||
|
expect(String(logLink.props.href || logLink.props.to || '')).toContain('/logs?siteId=1');
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user