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 },
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
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';
|
||||
import { getCredentialModeFromExtraConfig } from '../../services/accountExtraConfig.js';
|
||||
import {
|
||||
formatLocalDateTime,
|
||||
formatUtcSqlDateTime,
|
||||
getLocalDayRangeUtc,
|
||||
getLocalRangeStartUtc,
|
||||
parseStoredUtcDateTime,
|
||||
toLocalDayKeyFromStoredUtc,
|
||||
} from '../../services/localTimeService.js';
|
||||
|
||||
@@ -105,6 +107,20 @@ function normalizeProxyLogSearch(raw?: string): string {
|
||||
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) {
|
||||
if (!search) return null;
|
||||
const likeTerm = `%${search}%`;
|
||||
@@ -127,10 +143,16 @@ function buildProxyLogStatusCondition(status: ProxyLogStatusFilter) {
|
||||
function buildProxyLogWhereClause(params: {
|
||||
status?: ProxyLogStatusFilter;
|
||||
search?: string;
|
||||
siteId?: number | null;
|
||||
fromUtc?: string | null;
|
||||
toUtc?: string | null;
|
||||
}) {
|
||||
const conditions = [
|
||||
params.status ? buildProxyLogStatusCondition(params.status) : 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);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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(
|
||||
row: {
|
||||
proxy_logs: Record<string, unknown> & { billingDetails?: string | 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 },
|
||||
) {
|
||||
@@ -155,6 +325,7 @@ function mapProxyLogRow(
|
||||
? { billingDetails: parseProxyLogBillingDetails(row.proxy_logs.billingDetails) }
|
||||
: {}),
|
||||
username: row.accounts?.username || null,
|
||||
siteId: row.sites?.id || null,
|
||||
siteName: row.sites?.name || null,
|
||||
siteUrl: row.sites?.url || null,
|
||||
};
|
||||
@@ -271,6 +442,49 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
extraConfig: account.extraConfig,
|
||||
}), 0);
|
||||
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 {
|
||||
totalBalance,
|
||||
@@ -286,18 +500,30 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
requestsPerMinute,
|
||||
tokensPerMinute,
|
||||
},
|
||||
siteAvailability,
|
||||
modelAnalysis,
|
||||
};
|
||||
});
|
||||
|
||||
// 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 offset = normalizeProxyLogOffset(request.query.offset);
|
||||
const status = normalizeProxyLogStatusFilter(request.query.status);
|
||||
const search = normalizeProxyLogSearch(request.query.search);
|
||||
const listWhere = buildProxyLogWhereClause({ status, search });
|
||||
const summaryWhere = buildProxyLogWhereClause({ search });
|
||||
const siteId = normalizeProxyLogSiteId(request.query.siteId);
|
||||
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 }) => {
|
||||
let query = db.select({
|
||||
@@ -320,12 +546,14 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
}, { includeBillingDetails: false }) as Array<{
|
||||
proxy_logs: Record<string, unknown> & { billingDetails?: string | 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({
|
||||
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) {
|
||||
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)`,
|
||||
totalCost: sql<number>`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 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) {
|
||||
summaryQuery = summaryQuery.where(summaryWhere) as typeof summaryQuery;
|
||||
}
|
||||
@@ -377,7 +607,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
||||
), { includeBillingDetails: true }) as {
|
||||
proxy_logs: Record<string, unknown> & { billingDetails?: string | 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;
|
||||
|
||||
if (!row) {
|
||||
|
||||
@@ -184,6 +184,7 @@ export type ProxyLogListItem = {
|
||||
totalTokens: number | null;
|
||||
retryCount: number;
|
||||
accountId?: number | null;
|
||||
siteId?: number | null;
|
||||
username?: string | null;
|
||||
siteName?: string | null;
|
||||
siteUrl?: string | null;
|
||||
@@ -213,6 +214,9 @@ export type ProxyLogsQuery = {
|
||||
offset?: number;
|
||||
status?: ProxyLogStatusFilter;
|
||||
search?: string;
|
||||
siteId?: number;
|
||||
from?: string;
|
||||
to?: string;
|
||||
};
|
||||
|
||||
export type ProxyLogsResponse = {
|
||||
|
||||
@@ -3533,6 +3533,43 @@ textarea {
|
||||
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 {
|
||||
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 ===== */
|
||||
@media (max-width: 900px) {
|
||||
.topbar-search-trigger {
|
||||
@@ -4055,10 +4287,54 @@ textarea {
|
||||
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 {
|
||||
height: calc(100vh - 320px);
|
||||
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) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Suspense, lazy, useEffect, useState, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import { formatCompactTokenMetric } from '../numberFormat.js';
|
||||
@@ -36,6 +37,105 @@ type SiteSpeedState =
|
||||
| { status: 'done'; ms: number }
|
||||
| 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 }) {
|
||||
const [data, setData] = useState<any>(null);
|
||||
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 requestsPerMinute = safeNumber(data?.performance?.requestsPerMinute);
|
||||
const tokensPerMinute = safeNumber(data?.performance?.tokensPerMinute);
|
||||
const siteAvailability: SiteAvailabilitySummary[] = Array.isArray(data?.siteAvailability)
|
||||
? data.siteAvailability
|
||||
: [];
|
||||
|
||||
const getLatencyColor = (ms: number) => (
|
||||
ms <= 500
|
||||
@@ -461,6 +564,97 @@ export default function Dashboard({ adminName = '\u7ba1\u7406\u5458' }: { adminN
|
||||
</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 className="chart-container animate-slide-up stagger-8">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 ProxyLogs from './ProxyLogs.js';
|
||||
|
||||
@@ -7,6 +8,7 @@ const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getProxyLogs: vi.fn(),
|
||||
getProxyLogDetail: vi.fn(),
|
||||
getSites: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -79,6 +81,10 @@ function buildListResponse(overrides?: Partial<{
|
||||
describe('ProxyLogs server-driven page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMock.getSites.mockResolvedValue([
|
||||
{ id: 9, name: 'main-site', status: 'active' },
|
||||
{ id: 12, name: 'backup-site', status: 'active' },
|
||||
]);
|
||||
apiMock.getProxyLogs.mockResolvedValue(buildListResponse());
|
||||
apiMock.getProxyLogDetail.mockResolvedValue({
|
||||
id: 101,
|
||||
@@ -138,9 +144,11 @@ describe('ProxyLogs server-driven page', () => {
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<ProxyLogs />
|
||||
</ToastProvider>,
|
||||
<MemoryRouter initialEntries={['/logs']}>
|
||||
<ToastProvider>
|
||||
<ProxyLogs />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
@@ -168,9 +176,11 @@ describe('ProxyLogs server-driven page', () => {
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<ProxyLogs />
|
||||
</ToastProvider>,
|
||||
<MemoryRouter initialEntries={['/logs']}>
|
||||
<ToastProvider>
|
||||
<ProxyLogs />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
@@ -214,9 +224,11 @@ describe('ProxyLogs server-driven page', () => {
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<ProxyLogs />
|
||||
</ToastProvider>,
|
||||
<MemoryRouter initialEntries={['/logs']}>
|
||||
<ToastProvider>
|
||||
<ProxyLogs />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
@@ -248,4 +260,38 @@ describe('ProxyLogs server-driven page', () => {
|
||||
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 {
|
||||
api,
|
||||
type ProxyLogBillingDetails,
|
||||
@@ -32,6 +33,7 @@ type ProxyLogDetailState = {
|
||||
};
|
||||
|
||||
const PAGE_SIZES = [20, 50, 100];
|
||||
const DEFAULT_PAGE_SIZE = 50;
|
||||
const EMPTY_SUMMARY: ProxyLogsSummary = {
|
||||
totalCount: 0,
|
||||
successCount: 0,
|
||||
@@ -113,27 +115,173 @@ function buildBillingProcessLines(log: ProxyLogRenderItem) {
|
||||
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() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const initialRouteState = useMemo(() => readProxyLogsRouteState(location.search), [location.search]);
|
||||
const [logs, setLogs] = useState<ProxyLogListItem[]>([]);
|
||||
const [summary, setSummary] = useState<ProxyLogsSummary>(EMPTY_SUMMARY);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [statusFilter, setStatusFilter] = useState<ProxyLogStatusFilter>('all');
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<ProxyLogStatusFilter>(initialRouteState.status);
|
||||
const [searchInput, setSearchInput] = useState(initialRouteState.search);
|
||||
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 [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [page, setPage] = useState(initialRouteState.page);
|
||||
const [pageSize, setPageSize] = useState(initialRouteState.pageSize);
|
||||
const [detailById, setDetailById] = useState<Record<number, ProxyLogDetailState>>({});
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [sites, setSites] = useState<Array<{ id: number; name: string; status?: string | null }>>([]);
|
||||
const isMobile = useIsMobile(768);
|
||||
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(() => {
|
||||
setPage(1);
|
||||
setSearchQuery(deferredSearchInput);
|
||||
}, [deferredSearchInput]);
|
||||
const next = readProxyLogsRouteState(location.search);
|
||||
setStatusFilter((current) => (current === next.status ? current : next.status));
|
||||
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 safePage = Math.min(page, totalPages);
|
||||
@@ -150,24 +298,60 @@ export default function ProxyLogs() {
|
||||
})
|
||||
), [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 seq = ++loadSeq.current;
|
||||
if (hasInvalidTimeRange) {
|
||||
setLogs([]);
|
||||
setTotal(0);
|
||||
setSummary(EMPTY_SUMMARY);
|
||||
if (seq === loadSeq.current) setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.getProxyLogs({
|
||||
const params = {
|
||||
limit: pageSize,
|
||||
offset: currentOffset,
|
||||
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 : []);
|
||||
setTotal(Number(data.total || 0));
|
||||
setSummary(data.summary || EMPTY_SUMMARY);
|
||||
} catch (e: any) {
|
||||
if (seq !== loadSeq.current) return;
|
||||
toast.error(e.message || '加载日志失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (seq === loadSeq.current) setLoading(false);
|
||||
}
|
||||
}, [currentOffset, pageSize, searchQuery, statusFilter, toast]);
|
||||
}, [currentOffset, deferredSearchInput, fromApiBoundary, hasInvalidTimeRange, pageSize, siteFilter, statusFilter, toApiBoundaryValue, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
@@ -239,12 +423,69 @@ export default function ProxyLogs() {
|
||||
</button>
|
||||
))}
|
||||
</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 }}>
|
||||
<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" />
|
||||
</svg>
|
||||
<input value={searchInput} onChange={(e) => setSearchInput(e.target.value)} placeholder="搜索模型名称..." />
|
||||
<input
|
||||
value={searchInput}
|
||||
onChange={(e) => {
|
||||
setSearchInput(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="搜索模型名称..."
|
||||
/>
|
||||
</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 style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<h2 className="page-title">{tr('使用日志')}</h2>
|
||||
<span className="kpi-chip">
|
||||
{activeSiteLabel}
|
||||
</span>
|
||||
<span className="kpi-chip kpi-chip-success">
|
||||
消耗总额 ${summary.totalCost.toFixed(4)}
|
||||
</span>
|
||||
@@ -292,6 +536,12 @@ export default function ProxyLogs() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasInvalidTimeRange && (
|
||||
<div className="alert alert-error" style={{ marginBottom: 12 }}>
|
||||
结束时间必须晚于开始时间
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card" style={{ overflowX: 'auto' }}>
|
||||
{loading ? (
|
||||
<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={log.siteName || '-'} />
|
||||
<MobileField label="用时" value={formatLatency(log.latencyMs)} />
|
||||
<MobileField label="输入" value={log.promptTokens?.toLocaleString() || '-'} />
|
||||
<MobileField label="输出" value={log.completionTokens?.toLocaleString() || '-'} />
|
||||
@@ -373,6 +624,7 @@ export default function ProxyLogs() {
|
||||
<th style={{ width: 28 }} />
|
||||
<th>时间</th>
|
||||
<th>模型</th>
|
||||
<th>站点</th>
|
||||
<th>{tr('状态')}</th>
|
||||
<th style={{ textAlign: 'center' }}>用时</th>
|
||||
<th style={{ textAlign: 'right' }}>输入</th>
|
||||
@@ -416,6 +668,9 @@ export default function ProxyLogs() {
|
||||
<td>
|
||||
<ModelBadge model={log.modelRequested} />
|
||||
</td>
|
||||
<td style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>
|
||||
{log.siteName || '-'}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`badge ${log.status === 'success' ? 'badge-success' : 'badge-error'}`} style={{ fontSize: 11, fontWeight: 600 }}>
|
||||
<span style={{
|
||||
@@ -457,7 +712,7 @@ export default function ProxyLogs() {
|
||||
</tr>
|
||||
{expanded === log.id && (
|
||||
<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-inner">
|
||||
<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