From 110e246a1c73c38c8782b2ba7e4438f4c1bbbd68 Mon Sep 17 00:00:00 2001 From: Justin Visser Date: Thu, 19 Feb 2026 20:07:08 +0100 Subject: [PATCH 01/79] Added run state front end support Updated scholars layout to match dashboard --- README.md | 11 + frontend/src/app/providers.ts | 4 + frontend/src/components/layout/AppNav.vue | 26 +- frontend/src/pages/DashboardPage.vue | 139 ++++- frontend/src/pages/RunsPage.vue | 62 ++- frontend/src/pages/ScholarsPage.vue | 564 +++++++++++--------- frontend/src/stores/auth.ts | 5 + frontend/src/stores/run_status.test.ts | 176 ++++++ frontend/src/stores/run_status.ts | 264 +++++++++ tests/integration/test_multi_user_schema.py | 128 +++++ 10 files changed, 1095 insertions(+), 284 deletions(-) create mode 100644 frontend/src/stores/run_status.test.ts create mode 100644 frontend/src/stores/run_status.ts diff --git a/README.md b/README.md index 9ee3ccb..4489432 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,17 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml down - `CONTRIBUTING.md`: contribution policy and merge checklist. - `scripts/check_no_generated_artifacts.sh`: tracked-artifact guard used by CI. +## Data Model Notes + +- Scholar tracking is user-scoped: each account can track the same Scholar ID independently. +- Publications are shared/global records deduplicated by Scholar cluster ID and normalized fingerprint. +- Per-account visibility and read state is stored on scholar-publication links, not on the global publication row. + +## Name Search Status + +- Scholar name search is intentionally WIP in the UI. +- Current Google Scholar behavior often redirects name-search traffic to login/challenge flows, so production onboarding should use direct Scholar ID/profile URL adds. + ## Environment Variables (Complete Reference) Notes: diff --git a/frontend/src/app/providers.ts b/frontend/src/app/providers.ts index d55bbb3..50a60e1 100644 --- a/frontend/src/app/providers.ts +++ b/frontend/src/app/providers.ts @@ -1,5 +1,6 @@ import { setCsrfTokenProvider } from "@/lib/api/client"; import { useAuthStore } from "@/stores/auth"; +import { useRunStatusStore } from "@/stores/run_status"; import { useThemeStore } from "@/stores/theme"; import { useUserSettingsStore } from "@/stores/user_settings"; @@ -12,9 +13,12 @@ export async function bootstrapAppProviders(): Promise { await auth.bootstrapSession(); const userSettings = useUserSettingsStore(); + const runStatus = useRunStatusStore(); if (auth.isAuthenticated) { await userSettings.bootstrap(); + await runStatus.bootstrap(); } else { userSettings.reset(); + runStatus.reset(); } } diff --git a/frontend/src/components/layout/AppNav.vue b/frontend/src/components/layout/AppNav.vue index 997687c..1e07d9c 100644 --- a/frontend/src/components/layout/AppNav.vue +++ b/frontend/src/components/layout/AppNav.vue @@ -2,11 +2,14 @@ import { computed } from "vue"; import { useRouter } from "vue-router"; +import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue"; import AppButton from "@/components/ui/AppButton.vue"; import { useAuthStore } from "@/stores/auth"; +import { useRunStatusStore } from "@/stores/run_status"; import { useUserSettingsStore } from "@/stores/user_settings"; const auth = useAuthStore(); +const runStatus = useRunStatusStore(); const userSettings = useUserSettingsStore(); const router = useRouter(); @@ -28,6 +31,21 @@ const links = computed(() => { return userSettings.isPageVisible(item.id); }); }); +const navRunStatus = computed(() => { + if (runStatus.isLikelyRunning) { + return "running"; + } + return "idle"; +}); +const navRunText = computed(() => { + const label = navRunStatus.value + .replaceAll("_", " ") + .split(" ") + .filter((segment) => segment.length > 0) + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(" "); + return `State: ${label}`; +}); async function onLogout(): Promise { await auth.logout(); @@ -53,7 +71,13 @@ async function onLogout(): Promise {
-

{{ auth.user?.email }}

+
+ {{ navRunText }} + +
Logout
diff --git a/frontend/src/pages/DashboardPage.vue b/frontend/src/pages/DashboardPage.vue index cd66c32..d6bf8d3 100644 --- a/frontend/src/pages/DashboardPage.vue +++ b/frontend/src/pages/DashboardPage.vue @@ -1,8 +1,7 @@ diff --git a/frontend/src/components/patterns/ScrapeSafetyBadge.vue b/frontend/src/components/patterns/ScrapeSafetyBadge.vue new file mode 100644 index 0000000..b40c1c9 --- /dev/null +++ b/frontend/src/components/patterns/ScrapeSafetyBadge.vue @@ -0,0 +1,27 @@ + + + diff --git a/frontend/src/components/patterns/ScrapeSafetyBanner.vue b/frontend/src/components/patterns/ScrapeSafetyBanner.vue new file mode 100644 index 0000000..300b05a --- /dev/null +++ b/frontend/src/components/patterns/ScrapeSafetyBanner.vue @@ -0,0 +1,102 @@ + + + diff --git a/frontend/src/features/dashboard/index.ts b/frontend/src/features/dashboard/index.ts index 94d546a..78affe4 100644 --- a/frontend/src/features/dashboard/index.ts +++ b/frontend/src/features/dashboard/index.ts @@ -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 { - 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 { recentRuns: runs, recentPublications: publications.publications, queue: queueHealth, + safetyState: runsPayload.safety_state, }; } diff --git a/frontend/src/features/runs/index.ts b/frontend/src/features/runs/index.ts index 4fe4add..97d49c6 100644 --- a/frontend/src/features/runs/index.ts +++ b/frontend/src/features/runs/index.ts @@ -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 { +export async function listRuns(query: RunsListQuery = {}): Promise { const params = new URLSearchParams(); if (query.failedOnly) { params.set("failed_only", "true"); @@ -92,7 +95,7 @@ export async function listRuns(query: RunsListQuery = {}): Promise(`/runs${suffix ? `?${suffix}` : ""}`, { method: "GET", }); - return response.data.runs; + return response.data; } export async function getRunDetail(runId: number): Promise { @@ -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 = { "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, diff --git a/frontend/src/features/safety/index.ts b/frontend/src/features/safety/index.ts new file mode 100644 index 0000000..185540b --- /dev/null +++ b/frontend/src/features/safety/index.ts @@ -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; + const rawCounters = raw.counters; + const counters = rawCounters && typeof rawCounters === "object" + ? (rawCounters as Record) + : {}; + + 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`; +} diff --git a/frontend/src/features/settings/index.ts b/frontend/src/features/settings/index.ts index e809ada..e8ef325 100644 --- a/frontend/src/features/settings/index.ts +++ b/frontend/src/features/settings/index.ts @@ -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 { return response.data; } -export async function updateSettings(payload: UserSettings): Promise { +export async function updateSettings(payload: UserSettingsUpdate): Promise { const response = await apiRequest("/settings", { method: "PUT", body: payload, diff --git a/frontend/src/pages/DashboardPage.vue b/frontend/src/pages/DashboardPage.vue index d6bf8d3..211ce9e 100644 --- a/frontend/src/pages/DashboardPage.vue +++ b/frontend/src/pages/DashboardPage.vue @@ -1,5 +1,5 @@