overview: one endpoint, live projections and a response cache (m36)
This commit is contained in:
@@ -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
@@ -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 })}`);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user