arden scrape safety telemetry and polish run state UX

This commit is contained in:
Justin Visser 2026-02-19 21:59:36 +01:00
parent 110e246a1c
commit d7404abf18
33 changed files with 2074 additions and 96 deletions

View file

@ -4,6 +4,7 @@ import {
type PublicationMode,
} from "@/features/publications";
import { listQueueItems, listRuns, type RunListItem } from "@/features/runs";
import { type ScrapeSafetyState } from "@/features/safety";
export interface QueueHealth {
queued: number;
@ -19,6 +20,7 @@ export interface DashboardSnapshot {
recentRuns: RunListItem[];
recentPublications: PublicationItem[];
queue: QueueHealth;
safetyState: ScrapeSafetyState;
}
function countQueueStatuses(statuses: string[]): QueueHealth {
@ -38,11 +40,12 @@ function countQueueStatuses(statuses: string[]): QueueHealth {
}
export async function fetchDashboardSnapshot(): Promise<DashboardSnapshot> {
const [publications, runs, queueItems] = await Promise.all([
const [publications, runsPayload, queueItems] = await Promise.all([
listPublications({ mode: "new", limit: 20 }),
listRuns({ limit: 20 }),
listQueueItems(200),
]);
const runs = runsPayload.runs;
const queueHealth = countQueueStatuses(queueItems.map((item) => item.status));
@ -54,5 +57,6 @@ export async function fetchDashboardSnapshot(): Promise<DashboardSnapshot> {
recentRuns: runs,
recentPublications: publications.publications,
queue: queueHealth,
safetyState: runsPayload.safety_state,
};
}

View file

@ -1,4 +1,5 @@
import { apiRequest } from "@/lib/api/client";
import { type ScrapeSafetyState } from "@/features/safety";
export interface RunListItem {
id: number;
@ -49,6 +50,7 @@ export interface RunDetail {
run: RunListItem;
summary: RunSummary;
scholar_results: RunScholarResult[];
safety_state: ScrapeSafetyState;
}
export interface QueueItem {
@ -68,6 +70,7 @@ export interface QueueItem {
interface RunsListData {
runs: RunListItem[];
safety_state: ScrapeSafetyState;
}
interface QueueListData {
@ -79,7 +82,7 @@ export interface RunsListQuery {
limit?: number;
}
export async function listRuns(query: RunsListQuery = {}): Promise<RunListItem[]> {
export async function listRuns(query: RunsListQuery = {}): Promise<RunsListData> {
const params = new URLSearchParams();
if (query.failedOnly) {
params.set("failed_only", "true");
@ -92,7 +95,7 @@ export async function listRuns(query: RunsListQuery = {}): Promise<RunListItem[]
const response = await apiRequest<RunsListData>(`/runs${suffix ? `?${suffix}` : ""}`, {
method: "GET",
});
return response.data.runs;
return response.data;
}
export async function getRunDetail(runId: number): Promise<RunDetail> {
@ -118,6 +121,7 @@ export async function triggerManualRun(): Promise<{
new_publication_count: number;
reused_existing_run: boolean;
idempotency_key: string | null;
safety_state: ScrapeSafetyState;
}> {
const headers: Record<string, string> = {
"Idempotency-Key": generateIdempotencyKey(),
@ -133,6 +137,7 @@ export async function triggerManualRun(): Promise<{
new_publication_count: number;
reused_existing_run: boolean;
idempotency_key: string | null;
safety_state: ScrapeSafetyState;
}>("/runs/manual", {
method: "POST",
headers,

View file

@ -0,0 +1,112 @@
export interface ScrapeSafetyCounters {
consecutive_blocked_runs: number;
consecutive_network_runs: number;
cooldown_entry_count: number;
blocked_start_count: number;
last_blocked_failure_count: number;
last_network_failure_count: number;
last_evaluated_run_id: number | null;
}
export interface ScrapeSafetyState {
cooldown_active: boolean;
cooldown_reason: string | null;
cooldown_reason_label: string | null;
cooldown_until: string | null;
cooldown_remaining_seconds: number;
recommended_action: string | null;
counters: ScrapeSafetyCounters;
}
export function createDefaultSafetyCounters(): ScrapeSafetyCounters {
return {
consecutive_blocked_runs: 0,
consecutive_network_runs: 0,
cooldown_entry_count: 0,
blocked_start_count: 0,
last_blocked_failure_count: 0,
last_network_failure_count: 0,
last_evaluated_run_id: null,
};
}
export function createDefaultSafetyState(): ScrapeSafetyState {
return {
cooldown_active: false,
cooldown_reason: null,
cooldown_reason_label: null,
cooldown_until: null,
cooldown_remaining_seconds: 0,
recommended_action: null,
counters: createDefaultSafetyCounters(),
};
}
function parseNumber(value: unknown, fallback: number): number {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim().length > 0) {
const parsed = Number(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return fallback;
}
function parseNullableString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
export function normalizeSafetyState(value: unknown): ScrapeSafetyState {
if (!value || typeof value !== "object") {
return createDefaultSafetyState();
}
const raw = value as Record<string, unknown>;
const rawCounters = raw.counters;
const counters = rawCounters && typeof rawCounters === "object"
? (rawCounters as Record<string, unknown>)
: {};
return {
cooldown_active: Boolean(raw.cooldown_active),
cooldown_reason: parseNullableString(raw.cooldown_reason),
cooldown_reason_label: parseNullableString(raw.cooldown_reason_label),
cooldown_until: parseNullableString(raw.cooldown_until),
cooldown_remaining_seconds: Math.max(0, parseNumber(raw.cooldown_remaining_seconds, 0)),
recommended_action: parseNullableString(raw.recommended_action),
counters: {
consecutive_blocked_runs: Math.max(0, parseNumber(counters.consecutive_blocked_runs, 0)),
consecutive_network_runs: Math.max(0, parseNumber(counters.consecutive_network_runs, 0)),
cooldown_entry_count: Math.max(0, parseNumber(counters.cooldown_entry_count, 0)),
blocked_start_count: Math.max(0, parseNumber(counters.blocked_start_count, 0)),
last_blocked_failure_count: Math.max(0, parseNumber(counters.last_blocked_failure_count, 0)),
last_network_failure_count: Math.max(0, parseNumber(counters.last_network_failure_count, 0)),
last_evaluated_run_id:
counters.last_evaluated_run_id === null
? null
: Math.max(0, parseNumber(counters.last_evaluated_run_id, 0)),
},
};
}
export function formatCooldownCountdown(seconds: number): string {
const bounded = Number.isFinite(seconds) ? Math.max(0, Math.floor(seconds)) : 0;
if (bounded <= 0) {
return "0s";
}
const hours = Math.floor(bounded / 3600);
const minutes = Math.floor((bounded % 3600) / 60);
const secs = bounded % 60;
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
if (minutes > 0) {
return `${minutes}m ${secs}s`;
}
return `${secs}s`;
}

View file

@ -1,10 +1,31 @@
import { apiRequest } from "@/lib/api/client";
import { type ScrapeSafetyState } from "@/features/safety";
export interface UserSettingsPolicy {
min_run_interval_minutes: number;
min_request_delay_seconds: number;
automation_allowed: boolean;
manual_run_allowed: boolean;
blocked_failure_threshold: number;
network_failure_threshold: number;
cooldown_blocked_seconds: number;
cooldown_network_seconds: number;
}
export interface UserSettings {
auto_run_enabled: boolean;
run_interval_minutes: number;
request_delay_seconds: number;
nav_visible_pages: string[];
policy: UserSettingsPolicy;
safety_state: ScrapeSafetyState;
}
export interface UserSettingsUpdate {
auto_run_enabled: boolean;
run_interval_minutes: number;
request_delay_seconds: number;
nav_visible_pages: string[];
}
export interface ChangePasswordPayload {
@ -18,7 +39,7 @@ export async function fetchSettings(): Promise<UserSettings> {
return response.data;
}
export async function updateSettings(payload: UserSettings): Promise<UserSettings> {
export async function updateSettings(payload: UserSettingsUpdate): Promise<UserSettings> {
const response = await apiRequest<UserSettings>("/settings", {
method: "PUT",
body: payload,