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

@ -3,7 +3,9 @@ import { computed } from "vue";
import { useRouter } from "vue-router";
import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue";
import ScrapeSafetyBadge from "@/components/patterns/ScrapeSafetyBadge.vue";
import AppButton from "@/components/ui/AppButton.vue";
import { formatCooldownCountdown } from "@/features/safety";
import { useAuthStore } from "@/stores/auth";
import { useRunStatusStore } from "@/stores/run_status";
import { useUserSettingsStore } from "@/stores/user_settings";
@ -31,21 +33,34 @@ const links = computed(() => {
return userSettings.isPageVisible(item.id);
});
});
const navRunStatus = computed(() => {
const navSafetyText = computed(() => {
if (!userSettings.manualRunAllowed) {
return "Manual checks disabled";
}
if (runStatus.safetyState.cooldown_active) {
return `Cooldown: ${formatCooldownCountdown(runStatus.safetyState.cooldown_remaining_seconds)}`;
}
return "Safety ready";
});
const navRunStateStatus = computed(() => {
if (runStatus.isSubmitting && !runStatus.isLikelyRunning) {
return "starting";
}
if (runStatus.isLikelyRunning) {
return "running";
}
return "idle";
});
const navRunText = computed(() => {
const label = navRunStatus.value
.replaceAll("_", " ")
.split(" ")
const navRunStateLabel = computed(() =>
navRunStateStatus.value
.split("_")
.filter((segment) => segment.length > 0)
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
.join(" ");
return `State: ${label}`;
});
.join(" "),
);
const showSafetyRow = computed(
() => runStatus.safetyState.cooldown_active || !userSettings.manualRunAllowed,
);
async function onLogout(): Promise<void> {
await auth.logout();
@ -71,12 +86,18 @@ async function onLogout(): Promise<void> {
</nav>
<div class="mt-auto grid gap-2 border-t border-stroke-subtle pt-3">
<div
v-if="auth.isAdmin"
class="flex items-center justify-between gap-2 rounded-lg border border-stroke-default bg-surface-card-muted px-2.5 py-2 text-xs text-secondary"
>
<span class="truncate">{{ navRunText }}</span>
<RunStatusBadge :status="navRunStatus" />
<div class="grid gap-1 rounded-lg border border-stroke-default bg-surface-card-muted px-2.5 py-2 text-xs text-secondary">
<div class="flex items-center justify-between gap-2">
<span class="truncate">State: {{ navRunStateLabel }}</span>
<RunStatusBadge :status="navRunStateStatus" />
</div>
<div
v-if="showSafetyRow"
class="flex items-center justify-between gap-2 border-t border-stroke-subtle pt-1"
>
<span class="truncate">{{ navSafetyText }}</span>
<ScrapeSafetyBadge :state="runStatus.safetyState" />
</div>
</div>
<AppButton variant="ghost" class="w-full justify-start" @click="onLogout">Logout</AppButton>
</div>

View file

@ -20,6 +20,9 @@ const toneClass = computed(() => {
if (props.status === "running") {
return "border-state-info-border bg-state-info-bg text-state-info-text";
}
if (props.status === "starting") {
return "border-state-info-border bg-state-info-bg text-state-info-text";
}
return "border-stroke-default bg-surface-card-muted text-ink-secondary";
});
</script>

View file

@ -0,0 +1,27 @@
<script setup lang="ts">
import { computed } from "vue";
import { type ScrapeSafetyState } from "@/features/safety";
const props = defineProps<{
state: ScrapeSafetyState;
}>();
const label = computed(() => (props.state.cooldown_active ? "Safety cooldown" : "Safety ready"));
const toneClass = computed(() => {
if (!props.state.cooldown_active) {
return "border-state-success-border bg-state-success-bg text-state-success-text";
}
if (props.state.cooldown_reason === "blocked_failure_threshold_exceeded") {
return "border-state-danger-border bg-state-danger-bg text-state-danger-text";
}
return "border-state-warning-border bg-state-warning-bg text-state-warning-text";
});
</script>
<template>
<span class="inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold" :class="toneClass">
{{ label }}
</span>
</template>

View file

@ -0,0 +1,102 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import AppAlert from "@/components/ui/AppAlert.vue";
import {
formatCooldownCountdown,
type ScrapeSafetyState,
} from "@/features/safety";
const props = withDefaults(
defineProps<{
safetyState: ScrapeSafetyState;
manualRunAllowed?: boolean;
}>(),
{
manualRunAllowed: true,
},
);
const now = ref(Date.now());
let timer: ReturnType<typeof setInterval> | null = null;
const cooldownUntilMs = computed(() => {
if (!props.safetyState.cooldown_until) {
return null;
}
const parsed = new Date(props.safetyState.cooldown_until).getTime();
if (Number.isNaN(parsed)) {
return null;
}
return parsed;
});
const cooldownRemainingSeconds = computed(() => {
if (!props.safetyState.cooldown_active) {
return 0;
}
if (cooldownUntilMs.value !== null) {
return Math.max(0, Math.floor((cooldownUntilMs.value - now.value) / 1000));
}
return Math.max(0, props.safetyState.cooldown_remaining_seconds || 0);
});
const isCooldownBlocked = computed(() => props.safetyState.cooldown_active && cooldownRemainingSeconds.value > 0);
const isPolicyBlocked = computed(() => !props.manualRunAllowed);
const isVisible = computed(() => isPolicyBlocked.value || isCooldownBlocked.value);
const tone = computed(() => {
if (isPolicyBlocked.value) {
return "warning" as const;
}
if (props.safetyState.cooldown_reason === "blocked_failure_threshold_exceeded") {
return "danger" as const;
}
return "warning" as const;
});
const title = computed(() => {
if (isPolicyBlocked.value) {
return "Manual checks disabled by server policy";
}
return props.safetyState.cooldown_reason_label || "Safety cooldown active";
});
const detailText = computed(() => {
if (isPolicyBlocked.value) {
return "This server currently disallows manual checks. Automatic checks may still run if enabled by policy.";
}
const countdown = formatCooldownCountdown(cooldownRemainingSeconds.value);
return `Manual and scheduled checks are paused for ${countdown}.`;
});
const actionText = computed(() => {
if (isPolicyBlocked.value) {
return "Ask an admin to enable manual checks in server environment policy.";
}
return props.safetyState.recommended_action;
});
onMounted(() => {
timer = setInterval(() => {
now.value = Date.now();
}, 1000);
});
onBeforeUnmount(() => {
if (timer !== null) {
clearInterval(timer);
timer = null;
}
});
</script>
<template>
<AppAlert v-if="isVisible" :tone="tone">
<template #title>{{ title }}</template>
<p>{{ detailText }}</p>
<p v-if="actionText" class="text-secondary">{{ actionText }}</p>
</AppAlert>
</template>

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,

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import { fetchDashboardSnapshot, type DashboardSnapshot } from "@/features/dashboard";
import { ApiRequestError } from "@/lib/api/errors";
@ -8,22 +8,51 @@ import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
import QueueHealthBadge from "@/components/patterns/QueueHealthBadge.vue";
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue";
import ScrapeSafetyBanner from "@/components/patterns/ScrapeSafetyBanner.vue";
import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue";
import AppEmptyState from "@/components/ui/AppEmptyState.vue";
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
import { useAuthStore } from "@/stores/auth";
import { useRunStatusStore } from "@/stores/run_status";
import { useUserSettingsStore } from "@/stores/user_settings";
const loading = ref(true);
const errorMessage = ref<string | null>(null);
const errorRequestId = ref<string | null>(null);
const successMessage = ref<string | null>(null);
const snapshot = ref<DashboardSnapshot | null>(null);
const refreshingAfterCompletion = ref(false);
const auth = useAuthStore();
const runStatus = useRunStatusStore();
const userSettings = useUserSettingsStore();
const isStartBlocked = computed(
() =>
runStatus.isRunActive ||
!userSettings.manualRunAllowed ||
runStatus.safetyState.cooldown_active,
);
const startCheckDisabledReason = computed(() => {
if (!userSettings.manualRunAllowed) {
return "Manual checks are disabled by server policy.";
}
if (runStatus.safetyState.cooldown_active) {
return runStatus.safetyState.cooldown_reason_label || "Safety cooldown is active.";
}
if (runStatus.isRunActive) {
return "A check is already in progress.";
}
return null;
});
const startCheckLabel = computed(() => {
if (!userSettings.manualRunAllowed) {
return "Manual checks disabled";
}
if (runStatus.safetyState.cooldown_active) {
return "Safety cooldown";
}
if (runStatus.isLikelyRunning) {
return "Check in progress";
}
@ -32,6 +61,9 @@ const startCheckLabel = computed(() => {
}
return "Start check";
});
const isStartCheckAnimating = computed(
() => runStatus.isSubmitting || runStatus.isLikelyRunning,
);
const displayedLatestRun = computed(() => {
const snapshotLatest = snapshot.value?.latestRun ?? null;
@ -95,6 +127,7 @@ async function loadSnapshot(): Promise<void> {
const dashboardSnapshot = await fetchDashboardSnapshot();
snapshot.value = dashboardSnapshot;
runStatus.setLatestRun(dashboardSnapshot.latestRun);
runStatus.setSafetyState(dashboardSnapshot.safetyState);
} catch (error) {
snapshot.value = null;
if (error instanceof ApiRequestError) {
@ -140,6 +173,29 @@ async function onTriggerRun(): Promise<void> {
onMounted(() => {
void loadSnapshot();
});
watch(
() => runStatus.latestRun,
(nextRun, previousRun) => {
if (refreshingAfterCompletion.value) {
return;
}
if (!nextRun || !previousRun) {
return;
}
if (nextRun.id !== previousRun.id) {
return;
}
if (previousRun.status !== "running" || nextRun.status === "running") {
return;
}
refreshingAfterCompletion.value = true;
void loadSnapshot().finally(() => {
refreshingAfterCompletion.value = false;
});
},
);
</script>
<template>
@ -156,6 +212,10 @@ onMounted(() => {
error-title="Dashboard request failed"
@dismiss-success="successMessage = null"
/>
<ScrapeSafetyBanner
:safety-state="runStatus.safetyState"
:manual-run-allowed="userSettings.manualRunAllowed"
/>
<div class="h-0 min-h-0 flex-1 xl:overflow-hidden">
<AsyncStateGate :loading="loading" :loading-lines="6" :show-empty="false">
@ -254,10 +314,20 @@ onMounted(() => {
<div class="flex items-center gap-2">
<AppButton
v-if="auth.isAdmin"
:disabled="runStatus.isRunActive"
:disabled="isStartBlocked"
:title="startCheckDisabledReason || undefined"
:class="isStartCheckAnimating ? 'shadow-[0_0_0_1px_var(--color-state-info-border)]' : ''"
@click="onTriggerRun"
>
{{ startCheckLabel }}
<span class="inline-flex items-center gap-2">
<span v-if="isStartCheckAnimating" class="relative inline-flex h-2.5 w-2.5">
<span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-60"
/>
<span class="relative inline-flex h-2.5 w-2.5 rounded-full bg-current" />
</span>
{{ startCheckLabel }}
</span>
</AppButton>
<RouterLink
v-if="auth.isAdmin"

View file

@ -28,7 +28,7 @@ type PublicationSortKey = "title" | "scholar" | "year" | "citations" | "status"
const loading = ref(true);
const publishingAll = ref(false);
const publishingSelected = ref(false);
const mode = ref<PublicationMode>("new");
const mode = ref<PublicationMode>("all");
const selectedScholarFilter = ref("");
const searchQuery = ref("");
const sortKey = ref<PublicationSortKey>("first_seen");

View file

@ -6,6 +6,7 @@ import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
import QueueHealthBadge from "@/components/patterns/QueueHealthBadge.vue";
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue";
import ScrapeSafetyBanner from "@/components/patterns/ScrapeSafetyBanner.vue";
import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue";
import AppEmptyState from "@/components/ui/AppEmptyState.vue";
@ -22,6 +23,7 @@ import {
} from "@/features/runs";
import { ApiRequestError } from "@/lib/api/errors";
import { useRunStatusStore } from "@/stores/run_status";
import { useUserSettingsStore } from "@/stores/user_settings";
const loading = ref(true);
const runs = ref<RunListItem[]>([]);
@ -31,6 +33,7 @@ const errorRequestId = ref<string | null>(null);
const successMessage = ref<string | null>(null);
const activeQueueItemId = ref<number | null>(null);
const runStatus = useRunStatusStore();
const userSettings = useUserSettingsStore();
function formatDate(value: string | null): string {
if (!value) {
@ -59,7 +62,31 @@ function queueHealth() {
const queueCounts = computed(() => queueHealth());
const activeRunId = computed(() => runStatus.latestRun?.status === "running" ? runStatus.latestRun.id : null);
const isStartBlocked = computed(
() =>
runStatus.isRunActive ||
!userSettings.manualRunAllowed ||
runStatus.safetyState.cooldown_active,
);
const startCheckDisabledReason = computed(() => {
if (!userSettings.manualRunAllowed) {
return "Manual checks are disabled by server policy.";
}
if (runStatus.safetyState.cooldown_active) {
return runStatus.safetyState.cooldown_reason_label || "Safety cooldown is active.";
}
if (runStatus.isRunActive) {
return "A check is already in progress.";
}
return null;
});
const runButtonLabel = computed(() => {
if (!userSettings.manualRunAllowed) {
return "Manual checks disabled";
}
if (runStatus.safetyState.cooldown_active) {
return "Safety cooldown";
}
if (runStatus.isLikelyRunning) {
return "Check in progress";
}
@ -75,10 +102,11 @@ async function loadData(): Promise<void> {
errorRequestId.value = null;
try {
const [loadedRuns, loadedQueue] = await Promise.all([listRuns({ limit: 100 }), listQueueItems(200)]);
runs.value = loadedRuns;
const [loadedRunsPayload, loadedQueue] = await Promise.all([listRuns({ limit: 100 }), listQueueItems(200)]);
runs.value = loadedRunsPayload.runs;
queueItems.value = loadedQueue;
runStatus.setLatestRun(loadedRuns[0] ?? null);
runStatus.setLatestRun(loadedRunsPayload.runs[0] ?? null);
runStatus.setSafetyState(loadedRunsPayload.safety_state);
} catch (error) {
runs.value = [];
queueItems.value = [];
@ -174,8 +202,12 @@ onMounted(() => {
:dropped="queueCounts.dropped"
/>
</div>
<ScrapeSafetyBanner
:safety-state="runStatus.safetyState"
:manual-run-allowed="userSettings.manualRunAllowed"
/>
<div class="flex flex-wrap items-center gap-2">
<AppButton :disabled="runStatus.isRunActive" @click="onTriggerManualRun">
<AppButton :disabled="isStartBlocked" :title="startCheckDisabledReason || undefined" @click="onTriggerManualRun">
{{ runButtonLabel }}
</AppButton>
<AppButton variant="secondary" :disabled="loading" @click="loadData">

View file

@ -14,10 +14,12 @@ import {
changePassword,
fetchSettings,
type UserSettings,
type UserSettingsUpdate,
updateSettings,
} from "@/features/settings";
import { ApiRequestError } from "@/lib/api/errors";
import { useAuthStore } from "@/stores/auth";
import { useRunStatusStore } from "@/stores/run_status";
import { normalizeUserNavVisiblePages, useUserSettingsStore } from "@/stores/user_settings";
interface NavPageOption {
@ -78,6 +80,7 @@ const NAV_PAGE_OPTIONS: NavPageOption[] = [
const auth = useAuthStore();
const userSettings = useUserSettingsStore();
const runStatus = useRunStatusStore();
const loading = ref(true);
const saving = ref(false);
@ -98,8 +101,14 @@ const successMessage = ref<string | null>(null);
const showIngestionModal = ref(false);
const showPasswordModal = ref(false);
const showNavigationModal = ref(false);
const MIN_CHECK_INTERVAL_MINUTES = 15;
const MIN_REQUEST_DELAY_SECONDS = 2;
const minCheckIntervalMinutes = ref(15);
const minRequestDelaySeconds = ref(2);
const automationAllowed = ref(true);
const manualRunAllowed = ref(true);
const blockedFailureThreshold = ref(1);
const networkFailureThreshold = ref(2);
const cooldownBlockedSeconds = ref(1800);
const cooldownNetworkSeconds = ref(900);
const visibleNavOptions = computed(() =>
NAV_PAGE_OPTIONS.filter((option) => !option.adminOnly || auth.isAdmin),
@ -111,11 +120,35 @@ const visibleNavLabels = computed(() =>
);
function hydrateSettings(settings: UserSettings): void {
autoRunEnabled.value = settings.auto_run_enabled;
const parsedMinRunInterval = Number(settings.policy?.min_run_interval_minutes);
minCheckIntervalMinutes.value = Number.isFinite(parsedMinRunInterval)
? Math.max(15, parsedMinRunInterval)
: 15;
const parsedMinRequestDelay = Number(settings.policy?.min_request_delay_seconds);
minRequestDelaySeconds.value = Number.isFinite(parsedMinRequestDelay)
? Math.max(2, parsedMinRequestDelay)
: 2;
automationAllowed.value = Boolean(settings.policy?.automation_allowed ?? true);
manualRunAllowed.value = Boolean(settings.policy?.manual_run_allowed ?? true);
blockedFailureThreshold.value = Number.isFinite(settings.policy?.blocked_failure_threshold)
? Math.max(1, settings.policy.blocked_failure_threshold)
: 1;
networkFailureThreshold.value = Number.isFinite(settings.policy?.network_failure_threshold)
? Math.max(1, settings.policy.network_failure_threshold)
: 2;
cooldownBlockedSeconds.value = Number.isFinite(settings.policy?.cooldown_blocked_seconds)
? Math.max(60, settings.policy.cooldown_blocked_seconds)
: 1800;
cooldownNetworkSeconds.value = Number.isFinite(settings.policy?.cooldown_network_seconds)
? Math.max(60, settings.policy.cooldown_network_seconds)
: 900;
autoRunEnabled.value = Boolean(settings.auto_run_enabled) && automationAllowed.value;
runIntervalMinutes.value = String(settings.run_interval_minutes);
requestDelaySeconds.value = String(settings.request_delay_seconds);
navVisiblePages.value = normalizeUserNavVisiblePages(settings.nav_visible_pages);
userSettings.applySettings(settings);
runStatus.setSafetyState(settings.safety_state);
}
function parseBoundedInteger(value: string, label: string, minimum: number): number {
@ -176,17 +209,17 @@ async function onSaveSettings(): Promise<void> {
successMessage.value = null;
try {
const payload: UserSettings = {
const payload: UserSettingsUpdate = {
auto_run_enabled: autoRunEnabled.value,
run_interval_minutes: parseBoundedInteger(
runIntervalMinutes.value,
"Check interval (minutes)",
MIN_CHECK_INTERVAL_MINUTES,
minCheckIntervalMinutes.value,
),
request_delay_seconds: parseBoundedInteger(
requestDelaySeconds.value,
"Delay between requests (seconds)",
MIN_REQUEST_DELAY_SECONDS,
minRequestDelaySeconds.value,
),
nav_visible_pages: normalizeUserNavVisiblePages(navVisiblePages.value),
};
@ -268,29 +301,13 @@ onMounted(() => {
<AsyncStateGate :loading="loading" :loading-lines="7" :show-empty="false">
<section class="grid gap-4 xl:grid-cols-3">
<AppCard class="flex h-full flex-col gap-4">
<div class="flex items-center gap-1">
<h2 class="text-lg font-semibold text-ink-primary">Automatic Checking</h2>
<AppHelpHint text="Controls when Scholarr runs automatic profile checks and how cautiously it scrapes." />
</div>
<div class="flex items-center gap-1">
<h2 class="text-lg font-semibold text-ink-primary">Automatic Checking</h2>
<AppHelpHint text="Controls when Scholarr runs automatic profile checks and how cautiously it scrapes." />
</div>
<p class="text-sm text-secondary">
Configure the background checker that looks for new publications on your tracked profiles.
</p>
<dl class="grid gap-2 text-sm text-secondary">
<div class="flex items-center justify-between gap-2">
<dt>Automatic checks</dt>
<dd class="font-semibold text-ink-primary">
{{ autoRunEnabled ? "Enabled" : "Disabled" }}
</dd>
</div>
<div class="flex items-center justify-between gap-2">
<dt>Check interval</dt>
<dd class="font-semibold text-ink-primary">Every {{ runIntervalMinutes }} min</dd>
</div>
<div class="flex items-center justify-between gap-2">
<dt>Delay between requests</dt>
<dd class="font-semibold text-ink-primary">{{ requestDelaySeconds }} sec</dd>
</div>
</dl>
<AppButton variant="secondary" class="mt-auto self-start" @click="showIngestionModal = true">
Edit checking rules
</AppButton>
@ -330,15 +347,23 @@ onMounted(() => {
@close="showIngestionModal = false"
>
<form class="grid gap-3" @submit.prevent="onSaveSettings">
<AppCheckbox id="auto-run-enabled" v-model="autoRunEnabled" label="Enable automatic background checks" />
<AppCheckbox
id="auto-run-enabled"
v-model="autoRunEnabled"
:disabled="!automationAllowed"
label="Enable automatic background checks"
/>
<p v-if="!automationAllowed" class="text-xs text-secondary">
Automatic checks are disabled by server safety policy.
</p>
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
<span class="inline-flex items-center gap-1">
Check interval (minutes)
<AppHelpHint text="How often Scholarr starts a background update check." />
</span>
<AppInput id="run-interval" v-model="runIntervalMinutes" type="number" :min="MIN_CHECK_INTERVAL_MINUTES" />
<span class="text-xs text-secondary">Minimum: {{ MIN_CHECK_INTERVAL_MINUTES }} minutes.</span>
<AppInput id="run-interval" v-model="runIntervalMinutes" type="number" :min="minCheckIntervalMinutes" />
<span class="text-xs text-secondary">Minimum: {{ minCheckIntervalMinutes }} minutes.</span>
</label>
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
@ -350,11 +375,18 @@ onMounted(() => {
id="request-delay"
v-model="requestDelaySeconds"
type="number"
:min="MIN_REQUEST_DELAY_SECONDS"
:min="minRequestDelaySeconds"
/>
<span class="text-xs text-secondary">Minimum: {{ MIN_REQUEST_DELAY_SECONDS }} seconds.</span>
<span class="text-xs text-secondary">Minimum: {{ minRequestDelaySeconds }} seconds.</span>
</label>
<div class="grid gap-1 rounded-lg border border-stroke-default bg-surface-card-muted px-3 py-2 text-xs text-secondary">
<p class="font-medium text-ink-primary">Server-enforced scrape safety policy</p>
<p>Blocked failures trigger cooldown at {{ blockedFailureThreshold }} failures.</p>
<p>Network failures trigger cooldown at {{ networkFailureThreshold }} failures.</p>
<p>Blocked cooldown: {{ cooldownBlockedSeconds }}s. Network cooldown: {{ cooldownNetworkSeconds }}s.</p>
</div>
<div class="mt-2 flex flex-wrap justify-end gap-2">
<AppButton
variant="ghost"

View file

@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { ApiRequestError } from "@/lib/api/errors";
import { createDefaultSafetyState } from "@/features/safety";
vi.mock("@/features/runs", () => ({
listRuns: vi.fn(),
@ -40,6 +41,13 @@ function buildRun(overrides: Partial<{
};
}
function buildRunsPayload(runs: ReturnType<typeof buildRun>[]) {
return {
runs,
safety_state: createDefaultSafetyState(),
};
}
describe("run status store", () => {
const mockedListRuns = vi.mocked(listRuns);
const mockedTriggerManualRun = vi.mocked(triggerManualRun);
@ -57,7 +65,7 @@ describe("run status store", () => {
});
it("bootstraps from latest run and exposes idle start state", async () => {
mockedListRuns.mockResolvedValueOnce([buildRun({ id: 11, status: "success" })]);
mockedListRuns.mockResolvedValueOnce(buildRunsPayload([buildRun({ id: 11, status: "success" })]));
const store = useRunStatusStore();
await store.bootstrap();
@ -80,8 +88,11 @@ describe("run status store", () => {
new_publication_count: 0,
reused_existing_run: false,
idempotency_key: "abc",
safety_state: createDefaultSafetyState(),
});
mockedListRuns.mockResolvedValueOnce([buildRun({ id: 25, status: "running", end_dt: null })]);
mockedListRuns.mockResolvedValueOnce(
buildRunsPayload([buildRun({ id: 25, status: "running", end_dt: null })]),
);
const store = useRunStatusStore();
const result = await store.startManualCheck();
@ -106,7 +117,9 @@ describe("run status store", () => {
requestId: "req_123",
}),
);
mockedListRuns.mockResolvedValueOnce([buildRun({ id: 42, status: "running", end_dt: null })]);
mockedListRuns.mockResolvedValueOnce(
buildRunsPayload([buildRun({ id: 42, status: "running", end_dt: null })]),
);
const store = useRunStatusStore();
const result = await store.startManualCheck();
@ -124,8 +137,8 @@ describe("run status store", () => {
it("polls while a run is active and stops when it completes", async () => {
vi.useFakeTimers();
mockedListRuns
.mockResolvedValueOnce([buildRun({ id: 90, status: "running", end_dt: null })])
.mockResolvedValueOnce([buildRun({ id: 90, status: "success" })]);
.mockResolvedValueOnce(buildRunsPayload([buildRun({ id: 90, status: "running", end_dt: null })]))
.mockResolvedValueOnce(buildRunsPayload([buildRun({ id: 90, status: "success" })]));
const store = useRunStatusStore();
await store.syncLatest();
@ -139,6 +152,42 @@ describe("run status store", () => {
expect(store.isRunActive).toBe(false);
});
it("stores cooldown safety state when manual start is blocked by policy cooldown", async () => {
mockedTriggerManualRun.mockRejectedValueOnce(
new ApiRequestError({
status: 429,
code: "scrape_cooldown_active",
message: "Scrape safety cooldown is active; run start is temporarily blocked.",
details: {
safety_state: {
cooldown_active: true,
cooldown_reason: "blocked_failure_threshold_exceeded",
cooldown_reason_label: "Blocked responses exceeded safety threshold",
cooldown_until: "2026-02-19T12:30:00Z",
cooldown_remaining_seconds: 600,
recommended_action: "Wait for cooldown to expire.",
counters: {
consecutive_blocked_runs: 1,
consecutive_network_runs: 0,
cooldown_entry_count: 1,
blocked_start_count: 2,
last_blocked_failure_count: 1,
last_network_failure_count: 0,
last_evaluated_run_id: 10,
},
},
},
}),
);
const store = useRunStatusStore();
const result = await store.startManualCheck();
expect(result.kind).toBe("error");
expect(store.safetyState.cooldown_active).toBe(true);
expect(store.canStart).toBe(false);
});
it("switches from starting to in-progress when trigger request remains open", async () => {
vi.useFakeTimers();
mockedTriggerManualRun.mockImplementation(
@ -155,11 +204,12 @@ describe("run status store", () => {
new_publication_count: 0,
reused_existing_run: false,
idempotency_key: "x",
safety_state: createDefaultSafetyState(),
});
}, RUN_STATUS_STARTING_PHASE_MS + 500);
}),
);
mockedListRuns.mockResolvedValueOnce([]);
mockedListRuns.mockResolvedValueOnce(buildRunsPayload([]));
const store = useRunStatusStore();
const startPromise = store.startManualCheck();

View file

@ -1,6 +1,11 @@
import { defineStore } from "pinia";
import { listRuns, triggerManualRun, type RunListItem } from "@/features/runs";
import {
createDefaultSafetyState,
normalizeSafetyState,
type ScrapeSafetyState,
} from "@/features/safety";
import { ApiRequestError } from "@/lib/api/errors";
export const RUN_STATUS_POLL_INTERVAL_MS = 5000;
@ -49,6 +54,19 @@ function extractRunIdFromDetails(details: unknown): number | null {
return parseRunId(runIdCandidate);
}
function extractSafetyStateFromDetails(details: unknown): ScrapeSafetyState | null {
if (!details || typeof details !== "object") {
return null;
}
const candidate = (details as Record<string, unknown>).safety_state;
if (!candidate || typeof candidate !== "object") {
return null;
}
return normalizeSafetyState(candidate);
}
function buildPlaceholderRunningRun(runId: number): RunListItem {
return {
id: runId,
@ -66,6 +84,7 @@ function buildPlaceholderRunningRun(runId: number): RunListItem {
export const useRunStatusStore = defineStore("runStatus", {
state: () => ({
latestRun: null as RunListItem | null,
safetyState: createDefaultSafetyState() as ScrapeSafetyState,
isSubmitting: false,
assumeRunningFromSubmission: false,
isPolling: false,
@ -81,7 +100,7 @@ export const useRunStatusStore = defineStore("runStatus", {
return state.latestRun?.status === "running" || state.assumeRunningFromSubmission;
},
canStart(): boolean {
return !this.isRunActive;
return !this.isRunActive && !this.safetyState.cooldown_active;
},
},
actions: {
@ -113,6 +132,9 @@ export const useRunStatusStore = defineStore("runStatus", {
}
this.updatePolling();
},
setSafetyState(value: unknown): void {
this.safetyState = normalizeSafetyState(value);
},
startPolling(): void {
if (pollTimer !== null) {
this.isPolling = true;
@ -146,9 +168,10 @@ export const useRunStatusStore = defineStore("runStatus", {
syncPromise = (async () => {
try {
const runs = await listRuns({ limit: 1 });
const latest = runs[0] ?? null;
const payload = await listRuns({ limit: 1 });
const latest = payload.runs[0] ?? null;
this.latestRun = latest;
this.safetyState = normalizeSafetyState(payload.safety_state);
this.lastSyncAt = Date.now();
this.lastErrorMessage = null;
this.lastErrorRequestId = null;
@ -184,6 +207,18 @@ export const useRunStatusStore = defineStore("runStatus", {
requestId: null,
};
}
if (this.safetyState.cooldown_active) {
const message =
this.safetyState.recommended_action ||
"Scrape safety cooldown is active; run start is temporarily blocked.";
this.lastErrorMessage = message;
this.lastErrorRequestId = null;
return {
kind: "error",
message,
requestId: null,
};
}
this.isSubmitting = true;
this.lastErrorMessage = null;
@ -193,6 +228,7 @@ export const useRunStatusStore = defineStore("runStatus", {
try {
const result = await triggerManualRun();
this.safetyState = normalizeSafetyState(result.safety_state);
await this.syncLatest();
if (!this.latestRun || this.latestRun.id !== result.run_id) {
@ -223,6 +259,20 @@ export const useRunStatusStore = defineStore("runStatus", {
};
}
if (error instanceof ApiRequestError && error.code === "scrape_cooldown_active") {
const safetyState = extractSafetyStateFromDetails(error.details);
if (safetyState) {
this.safetyState = safetyState;
}
this.lastErrorMessage = error.message;
this.lastErrorRequestId = error.requestId;
return {
kind: "error",
message: error.message,
requestId: error.requestId,
};
}
if (error instanceof ApiRequestError) {
this.lastErrorMessage = error.message;
this.lastErrorRequestId = error.requestId;
@ -259,6 +309,7 @@ export const useRunStatusStore = defineStore("runStatus", {
this.lastErrorMessage = null;
this.lastErrorRequestId = null;
this.lastSyncAt = null;
this.safetyState = createDefaultSafetyState();
},
},
});

View file

@ -1,6 +1,7 @@
import { defineStore } from "pinia";
import { fetchSettings, type UserSettings } from "@/features/settings";
import { createDefaultSafetyState, normalizeSafetyState, type ScrapeSafetyState } from "@/features/safety";
export const REQUIRED_NAV_PAGES = ["dashboard", "scholars", "settings"] as const;
export const DEFAULT_NAV_VISIBLE_PAGES = [
@ -51,6 +52,15 @@ function normalizeNavVisiblePages(value: unknown): string[] {
export const useUserSettingsStore = defineStore("userSettings", {
state: () => ({
navVisiblePages: [...DEFAULT_NAV_VISIBLE_PAGES] as string[],
minRunIntervalMinutes: 15,
minRequestDelaySeconds: 2,
automationAllowed: true,
manualRunAllowed: true,
blockedFailureThreshold: 1,
networkFailureThreshold: 2,
cooldownBlockedSeconds: 1800,
cooldownNetworkSeconds: 900,
safetyState: createDefaultSafetyState() as ScrapeSafetyState,
}),
getters: {
visiblePageSet: (state) => new Set(state.navVisiblePages),
@ -61,9 +71,39 @@ export const useUserSettingsStore = defineStore("userSettings", {
},
applySettings(settings: UserSettings): void {
this.setNavVisiblePages(settings.nav_visible_pages);
this.minRunIntervalMinutes = Number.isFinite(settings.policy?.min_run_interval_minutes)
? Math.max(15, settings.policy.min_run_interval_minutes)
: 15;
this.minRequestDelaySeconds = Number.isFinite(settings.policy?.min_request_delay_seconds)
? Math.max(2, settings.policy.min_request_delay_seconds)
: 2;
this.automationAllowed = Boolean(settings.policy?.automation_allowed ?? true);
this.manualRunAllowed = Boolean(settings.policy?.manual_run_allowed ?? true);
this.blockedFailureThreshold = Number.isFinite(settings.policy?.blocked_failure_threshold)
? Math.max(1, settings.policy.blocked_failure_threshold)
: 1;
this.networkFailureThreshold = Number.isFinite(settings.policy?.network_failure_threshold)
? Math.max(1, settings.policy.network_failure_threshold)
: 1;
this.cooldownBlockedSeconds = Number.isFinite(settings.policy?.cooldown_blocked_seconds)
? Math.max(60, settings.policy.cooldown_blocked_seconds)
: 1800;
this.cooldownNetworkSeconds = Number.isFinite(settings.policy?.cooldown_network_seconds)
? Math.max(60, settings.policy.cooldown_network_seconds)
: 900;
this.safetyState = normalizeSafetyState(settings.safety_state);
},
reset(): void {
this.navVisiblePages = [...DEFAULT_NAV_VISIBLE_PAGES];
this.minRunIntervalMinutes = 15;
this.minRequestDelaySeconds = 2;
this.automationAllowed = true;
this.manualRunAllowed = true;
this.blockedFailureThreshold = 1;
this.networkFailureThreshold = 2;
this.cooldownBlockedSeconds = 1800;
this.cooldownNetworkSeconds = 900;
this.safetyState = createDefaultSafetyState();
},
isPageVisible(pageId: string): boolean {
if (REQUIRED_NAV_PAGES_SET.has(pageId)) {