overview: one endpoint, live projections and a response cache (m36)

This commit is contained in:
2026-08-27 17:48:20 +02:00
parent 7cdb74ec17
commit c8470724ce
40 changed files with 3298 additions and 2242 deletions
+3 -3
View File
@@ -1,4 +1,4 @@
import { ApiError, deleteGroup, getQueries, getStats, listGroups, login, putGroupSources } from "@/lib/api";
import { ApiError, deleteGroup, getOverview, getQueries, listGroups, login, putGroupSources } from "@/lib/api";
function jsonResponse(payload: unknown, status = 200, headers: Record<string, string> = {}): Response {
return new Response(JSON.stringify(payload), {
@@ -50,7 +50,7 @@ test("falls back to a status message on a non-JSON error body", async () => {
test("parses Retry-After on 429", async () => {
fetchMock.mockResolvedValue(jsonResponse({ error: "rate limited" }, 429, { "Retry-After": "17" }));
const failure = await getStats("1h").catch((e: unknown) => e);
const failure = await getOverview("1h").catch((e: unknown) => e);
expect(failure).toBeInstanceOf(ApiError);
expect((failure as ApiError).status).toBe(429);
expect((failure as ApiError).retryAfter).toBe(17);
@@ -58,7 +58,7 @@ test("parses Retry-After on 429", async () => {
test("ignores a malformed Retry-After header", async () => {
fetchMock.mockResolvedValue(jsonResponse({ error: "rate limited" }, 429, { "Retry-After": "soon" }));
const failure = await getStats().catch((e: unknown) => e);
const failure = await getOverview().catch((e: unknown) => e);
expect((failure as ApiError).retryAfter).toBeUndefined();
});
+4 -13
View File
@@ -23,6 +23,7 @@ import type {
LoginResponse,
LogoutResponse,
LookupResult,
Overview,
PausePost,
PauseState,
Period,
@@ -35,11 +36,6 @@ import type {
SettingsEnvelope,
SettingsPatch,
SourceStatus,
StatsClients,
StatsRoutes,
StatsTimeseries,
StatsTotals,
StatsTypes,
Upstream,
UpstreamEcho,
UpstreamInput,
@@ -112,7 +108,7 @@ export const login = (body: LoginRequest): Promise<LoginResponse> =>
request("/api/auth/login", { method: "POST", body });
export const logout = (): Promise<LogoutResponse> => request("/api/auth/logout", { method: "POST", body: {} });
// Query log + stats
// Query log + overview
export const getQueries = (filter: QueriesFilter = {}): Promise<QueriesPage> =>
request(`/api/queries${qs({ ...filter })}`);
@@ -123,13 +119,8 @@ export const getQueryDetail = (id: number): Promise<QueryDetail> => request(`/ap
/** `EventSource` URL for the live stream; not a fetch route. */
export const liveQueriesUrl = "/api/queries/live";
export const getStats = (period?: Period): Promise<StatsTotals> => request(`/api/stats${qs({ period })}`);
export const getStatsTimeseries = (period?: Period): Promise<StatsTimeseries> =>
request(`/api/stats/timeseries${qs({ period })}`);
export const getStatsTypes = (period?: Period): Promise<StatsTypes> => request(`/api/stats/types${qs({ period })}`);
export const getStatsRoutes = (period?: Period): Promise<StatsRoutes> => request(`/api/stats/routes${qs({ period })}`);
export const getStatsClients = (period?: Period): Promise<StatsClients> =>
request(`/api/stats/clients${qs({ period })}`);
/** Every Overview panel for one window, from one read transaction. */
export const getOverview = (period?: Period): Promise<Overview> => request(`/api/overview${qs({ period })}`);
export const getLookup = (domain: string, groupId?: number): Promise<LookupResult> =>
request(`/api/lookup${qs({ domain, group_id: groupId })}`);
+32 -75
View File
@@ -29,6 +29,7 @@ import type {
LoginResponse,
LogoutResponse,
LookupResult,
Overview,
PauseState,
QueriesPage,
QueryDetail,
@@ -36,11 +37,6 @@ import type {
RuleEcho,
SettingsEnvelope,
SourceStatus,
StatsClients,
StatsRoutes,
StatsTimeseries,
StatsTotals,
StatsTypes,
Upstream,
UpstreamEcho,
Version,
@@ -588,39 +584,6 @@ export const sample_get_query_detail: QueryDetail = {
},
};
export const sample_get_stats: StatsTotals = {
avg_response_time_us: null,
blocked: 0,
clients: 0,
coverage: {
available_since: 0,
complete: true,
},
period: "1h",
queries: 0,
since: 0,
until: 0,
};
export const sample_get_stats_timeseries: StatsTimeseries = {
bucket_seconds: 0,
buckets: [
{
blocked: 0,
cached: 0,
queries: 0,
ts: 0,
},
],
coverage: {
available_since: 0,
complete: true,
},
period: "1h",
since: 0,
until: 0,
};
export const sample_get_pause: PauseState = {
paused: false,
until: null,
@@ -837,31 +800,35 @@ export const sample_error_not_found: ErrorEnvelope = {
error: "not found",
};
export const sample_get_stats_types: StatsTypes = {
coverage: {
available_since: 0,
complete: true,
},
period: "1h",
since: 0,
types: [
export const sample_get_overview: Overview = {
bucket_seconds: 0,
buckets: [
{
count: 0,
qtype: 0,
blocked: 0,
cached: 0,
queries: 0,
ts: 0,
},
],
clients: [
{
count: 0,
qtype: null,
buckets: [0],
client: "192.0.2.30",
},
{
buckets: [0],
client: "192.0.2.31",
},
{
buckets: [0],
client: "192.0.2.32",
},
],
until: 0,
};
export const sample_get_stats_routes: StatsRoutes = {
coverage: {
available_since: 0,
complete: true,
},
other: [0],
period: "1h",
routes: [
{
@@ -906,32 +873,22 @@ export const sample_get_stats_routes: StatsRoutes = {
},
],
since: 0,
until: 0,
};
export const sample_get_stats_clients: StatsClients = {
bucket_seconds: 0,
clients: [
totals: {
avg_response_time_us: 0,
blocked: 0,
clients: 0,
queries: 0,
},
types: [
{
buckets: [0],
client: "192.0.2.30",
count: 0,
qtype: 0,
},
{
buckets: [0],
client: "192.0.2.31",
},
{
buckets: [0],
client: "192.0.2.32",
count: 0,
qtype: null,
},
],
coverage: {
available_since: 0,
complete: true,
},
other: [0],
period: "1h",
since: 0,
until: 0,
};
+4 -32
View File
@@ -21,11 +21,7 @@ import type {
export const queryKeys = {
health: ["health"] as const,
version: ["version"] as const,
stats: (period: Period) => ["stats", period] as const,
timeseries: (period: Period) => ["stats", "timeseries", period] as const,
statsTypes: (period: Period) => ["stats", "types", period] as const,
statsRoutes: (period: Period) => ["stats", "routes", period] as const,
statsClients: (period: Period) => ["stats", "clients", period] as const,
overview: (period: Period) => ["overview", period] as const,
queriesInfinite: (filter: QueriesFilter) => ["queries", "infinite", filter] as const,
queryDetail: (id: number) => ["queries", "detail", id] as const,
diagnosticsInfinite: (filter: DiagnosticsFilter) => ["diagnostics", "infinite", filter] as const,
@@ -54,34 +50,10 @@ export const healthQuery = () =>
export const versionQuery = () =>
queryOptions({ queryKey: queryKeys.version, queryFn: api.getVersion, staleTime: Infinity });
export const statsQuery = (period: Period = "24h") =>
queryOptions({ queryKey: queryKeys.stats(period), queryFn: () => api.getStats(period), refetchInterval: 30_000 });
export const timeseriesQuery = (period: Period = "24h") =>
export const overviewQuery = (period: Period = "24h") =>
queryOptions({
queryKey: queryKeys.timeseries(period),
queryFn: () => api.getStatsTimeseries(period),
refetchInterval: 30_000,
});
export const statsTypesQuery = (period: Period = "24h") =>
queryOptions({
queryKey: queryKeys.statsTypes(period),
queryFn: () => api.getStatsTypes(period),
refetchInterval: 30_000,
});
export const statsRoutesQuery = (period: Period = "24h") =>
queryOptions({
queryKey: queryKeys.statsRoutes(period),
queryFn: () => api.getStatsRoutes(period),
refetchInterval: 30_000,
});
export const statsClientsQuery = (period: Period = "24h") =>
queryOptions({
queryKey: queryKeys.statsClients(period),
queryFn: () => api.getStatsClients(period),
queryKey: queryKeys.overview(period),
queryFn: () => api.getOverview(period),
refetchInterval: 30_000,
});
+20 -36
View File
@@ -291,15 +291,13 @@ export interface DiagnosticsFilter {
before?: number;
}
export interface StatsTotals {
period: Period;
since: number;
until: number;
/** The window's four headline numbers. */
export interface OverviewTotals {
queries: number;
blocked: number;
/** Distinct clients seen in the window, not a sum of per-bucket counts. */
clients: number;
avg_response_time_us: number | null;
coverage: Coverage;
}
export interface Bucket {
@@ -309,67 +307,53 @@ export interface Bucket {
cached: number;
}
export interface StatsTimeseries {
period: Period;
since: number;
until: number;
bucket_seconds: number;
buckets: Bucket[];
coverage: Coverage;
}
/**
* One DNS type's share of the window. `qtype` is the numeric code as logged:
* naming it is the admin's job (`features/provenance/qtype.ts`), and a row whose
* type was never recorded keeps its own `null` group rather than disappearing.
*/
export interface StatsTypeRow {
export interface OverviewTypeRow {
qtype: number | null;
count: number;
}
export interface StatsTypes {
period: Period;
since: number;
until: number;
types: StatsTypeRow[];
coverage: Coverage;
}
/**
* How the window's queries were answered. `source` names the answering upstream
* on `upstream` rows and the zone on `forward_zone` rows; every other route kind
* carries null, as does a row whose identity was not recorded.
*/
export interface StatsRouteRow {
export interface OverviewRouteRow {
route: RouteKind;
source: string | null;
count: number;
}
export interface StatsRoutes {
period: Period;
since: number;
until: number;
routes: StatsRouteRow[];
coverage: Coverage;
}
/** One client's per-bucket counts, aligned to `StatsTimeseries`'s buckets. */
export interface StatsClientSeries {
/** One client's per-bucket counts, aligned to `Overview.buckets`. */
export interface OverviewClientSeries {
client: string;
buckets: number[];
}
export interface StatsClients {
/**
* Everything the Overview page draws, for one window, from one request. The
* server answers all six panels out of a single read transaction, so the
* headline totals, the two timelines and the two breakdowns are guaranteed to
* describe the same span *and* the same database state — a coherence the five
* endpoints this replaces could not offer.
*/
export interface Overview {
period: Period;
since: number;
until: number;
bucket_seconds: number;
totals: OverviewTotals;
buckets: Bucket[];
/** The eight busiest clients in the window, ranked by total count. */
clients: StatsClientSeries[];
clients: OverviewClientSeries[];
/** Everything outside the top eight. Always present and always bucket-count-sized. */
other: number[];
types: OverviewTypeRow[];
routes: OverviewRouteRow[];
coverage: Coverage;
}