arden scrape safety telemetry and polish run state UX
This commit is contained in:
parent
110e246a1c
commit
d7404abf18
33 changed files with 2074 additions and 96 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
27
frontend/src/components/patterns/ScrapeSafetyBadge.vue
Normal file
27
frontend/src/components/patterns/ScrapeSafetyBadge.vue
Normal 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>
|
||||
102
frontend/src/components/patterns/ScrapeSafetyBanner.vue
Normal file
102
frontend/src/components/patterns/ScrapeSafetyBanner.vue
Normal 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>
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
112
frontend/src/features/safety/index.ts
Normal file
112
frontend/src/features/safety/index.ts
Normal 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`;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue