milestone 26: upstream health answers for the selected period
This commit is contained in:
@@ -118,7 +118,8 @@ export const getStatsTimeseries = (period?: Period): Promise<StatsTimeseries> =>
|
||||
export const getLookup = (domain: string, groupId?: number): Promise<LookupResult> =>
|
||||
request(`/api/lookup${qs({ domain, group_id: groupId })}`);
|
||||
|
||||
export const getUpstreamHealth = (): Promise<UpstreamHealth> => request("/api/upstream/health");
|
||||
export const getUpstreamHealth = (period?: Period): Promise<UpstreamHealth> =>
|
||||
request(`/api/upstream/health${qs({ period })}`);
|
||||
|
||||
// Groups
|
||||
|
||||
|
||||
@@ -333,16 +333,23 @@ export const sample_update_upstream: UpstreamEcho = {
|
||||
|
||||
export const sample_get_upstream_health: UpstreamHealth = {
|
||||
available: 0,
|
||||
complete: true,
|
||||
period: "24h",
|
||||
since: 0,
|
||||
total: 0,
|
||||
until: 0,
|
||||
upstreams: [
|
||||
{
|
||||
available: true,
|
||||
consecutive_failures: 0,
|
||||
enabled: true,
|
||||
last_error: "",
|
||||
success_rate: 0,
|
||||
total_failures: 0,
|
||||
total_successes: 0,
|
||||
period: {
|
||||
attempts: 0,
|
||||
failures: 0,
|
||||
last_failure_at: null,
|
||||
last_failure_error: null,
|
||||
success_rate: null,
|
||||
successes: 0,
|
||||
},
|
||||
url: "https://dns.example/dns-query",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formatBytes, formatMicros, formatTime } from "@/lib/format";
|
||||
import { formatAge, formatBytes, formatMicros, formatTime } from "@/lib/format";
|
||||
|
||||
test("formatTime renders unix seconds in the given locale and zone", () => {
|
||||
// 2024-01-01T00:00:00Z; ICU emits U+202F before AM/PM in recent Node.
|
||||
@@ -15,6 +15,18 @@ test("formatBytes humanizes with binary units", () => {
|
||||
expect(formatBytes(2 * 1024 ** 4)).toBe("2.0 TiB");
|
||||
});
|
||||
|
||||
test("formatAge steps up a unit at each boundary and truncates", () => {
|
||||
expect(formatAge(0)).toBe("0s ago");
|
||||
expect(formatAge(59)).toBe("59s ago");
|
||||
expect(formatAge(60)).toBe("1m ago");
|
||||
expect(formatAge(3599)).toBe("59m ago");
|
||||
expect(formatAge(3600)).toBe("1h ago");
|
||||
expect(formatAge(10800)).toBe("3h ago");
|
||||
expect(formatAge(86399)).toBe("23h ago");
|
||||
expect(formatAge(86400)).toBe("1d ago");
|
||||
expect(formatAge(400000)).toBe("4d ago");
|
||||
});
|
||||
|
||||
test("formatMicros renders milliseconds with one decimal", () => {
|
||||
expect(formatMicros(0)).toBe("0.0 ms");
|
||||
expect(formatMicros(1234)).toBe("1.2 ms");
|
||||
|
||||
@@ -21,6 +21,24 @@ export function formatBytes(bytes: number): string {
|
||||
return `${value.toFixed(1)} ${unit}`;
|
||||
}
|
||||
|
||||
const AGE_UNITS = [
|
||||
{ seconds: 86400, suffix: "d" },
|
||||
{ seconds: 3600, suffix: "h" },
|
||||
{ seconds: 60, suffix: "m" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Seconds of elapsed time → a coarse "3h ago". Truncating and single-unit on
|
||||
* purpose: this labels a snapshot the caller renders once, so a reader must not
|
||||
* take it for a live count. Nothing re-renders it as it ages.
|
||||
*/
|
||||
export function formatAge(seconds: number): string {
|
||||
for (const unit of AGE_UNITS) {
|
||||
if (seconds >= unit.seconds) return `${Math.floor(seconds / unit.seconds)}${unit.suffix} ago`;
|
||||
}
|
||||
return `${Math.floor(seconds)}s ago`;
|
||||
}
|
||||
|
||||
/** Microseconds → milliseconds with one decimal, e.g. 1234 → "1.2 ms". */
|
||||
export function formatMicros(micros: number): string {
|
||||
return `${(micros / 1000).toFixed(1)} ms`;
|
||||
|
||||
@@ -23,7 +23,7 @@ export const queryKeys = {
|
||||
stats: (period: Period) => ["stats", period] as const,
|
||||
timeseries: (period: Period) => ["stats", "timeseries", period] as const,
|
||||
queriesInfinite: (filter: QueriesFilter) => ["queries", "infinite", filter] as const,
|
||||
upstreamHealth: ["upstream-health"] as const,
|
||||
upstreamHealth: (period: Period) => ["upstream-health", period] as const,
|
||||
lookup: (domain: string, groupId?: number) => ["lookup", domain, groupId ?? null] as const,
|
||||
/** Prefix of every `lookup` entry; the invalidation target after any verdict input changes. */
|
||||
lookupAll: ["lookup"] as const,
|
||||
@@ -69,8 +69,15 @@ export const queriesInfiniteQuery = (filter: QueriesFilter = {}) =>
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
export const upstreamHealthQuery = () =>
|
||||
queryOptions({ queryKey: queryKeys.upstreamHealth, queryFn: api.getUpstreamHealth, refetchInterval: 30_000 });
|
||||
// The period is part of the key: the upstream aggregates are ranged like the
|
||||
// stats ones, so the picker has to refetch them rather than reuse a cached
|
||||
// window under a new label.
|
||||
export const upstreamHealthQuery = (period: Period = "24h") =>
|
||||
queryOptions({
|
||||
queryKey: queryKeys.upstreamHealth(period),
|
||||
queryFn: () => api.getUpstreamHealth(period),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
export const lookupQuery = (domain: string, groupId?: number) =>
|
||||
queryOptions({ queryKey: queryKeys.lookup(domain, groupId), queryFn: () => api.getLookup(domain, groupId) });
|
||||
|
||||
+25
-6
@@ -119,21 +119,40 @@ export interface LookupResult {
|
||||
safe_search_rewrite: string | null;
|
||||
}
|
||||
|
||||
export interface UpstreamPeriodStats {
|
||||
attempts: number;
|
||||
successes: number;
|
||||
failures: number;
|
||||
/** successes/attempts, 0 to 1; null when attempts is 0 — no observations is not perfect reliability. */
|
||||
success_rate: number | null;
|
||||
/** The newest failure inside the window, unix seconds; null when the window holds none. */
|
||||
last_failure_at: number | null;
|
||||
/** The error name belonging to last_failure_at; null exactly when it is. */
|
||||
last_failure_error: string | null;
|
||||
}
|
||||
|
||||
export interface UpstreamHealthEntry {
|
||||
url: string;
|
||||
/** Live configuration, not history. */
|
||||
enabled: boolean;
|
||||
/** Live state; false while the upstream is backing off. */
|
||||
available: boolean;
|
||||
consecutive_failures: number;
|
||||
total_successes: number;
|
||||
total_failures: number;
|
||||
success_rate: number;
|
||||
last_error: string;
|
||||
period: UpstreamPeriodStats;
|
||||
}
|
||||
|
||||
export interface UpstreamHealth {
|
||||
upstreams: UpstreamHealthEntry[];
|
||||
period: Period;
|
||||
since: number;
|
||||
until: number;
|
||||
available: number;
|
||||
total: number;
|
||||
/**
|
||||
* No capacity drops known in this process within the selected window; up to about a minute of
|
||||
* the newest outcomes may not have flushed yet, and outcomes lost in an unclean shutdown are
|
||||
* not detectable.
|
||||
*/
|
||||
complete: boolean;
|
||||
upstreams: UpstreamHealthEntry[];
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
|
||||
Reference in New Issue
Block a user