From b8adf6b365defbe05b91cf0f462b66be3f12e79c Mon Sep 17 00:00:00 2001 From: Erol Date: Sat, 11 Apr 2026 16:01:36 +0200 Subject: [PATCH] =?UTF-8?q?F=C3=B8r=20nytt=20oppfriskingsgrensesnitt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/main.py | 14 +- backend/scrape_jobs.py | 219 ++++++++++++++++++++++++++++---- backend/worker.py | 43 +++++-- frontend/src/app/admin/page.tsx | 211 +++++++++++++++++++++++++++++- 4 files changed, 449 insertions(+), 38 deletions(-) diff --git a/backend/main.py b/backend/main.py index 2393a5e..8053fe8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -175,17 +175,27 @@ async def queue_scrape_job(job_type: str, facility_ids: List[int], requested_by: if not facility_ids: raise HTTPException(status_code=400, detail="Ingen anleggs-IDer ble oppgitt.") - job, was_created = await enqueue_scrape_job(app.state.pool, job_type, facility_ids, requested_by=requested_by) - status = "queued" if was_created else "already_queued" + requested_ids = sorted({int(facility_id) for facility_id in facility_ids if str(facility_id).strip()}) + job, status = await enqueue_scrape_job(app.state.pool, job_type, requested_ids, requested_by=requested_by) + was_created = status == "queued" + overlapping_ids = sorted({int(facility_id) for facility_id in (job.get("overlapping_facility_ids") or [])}) + available_ids = [facility_id for facility_id in requested_ids if facility_id not in overlapping_ids] message = ( f"{job_type.capitalize()}-skraping for {len(job['facility_ids'])} anlegg ble lagt i kø." if was_created else f"Fant allerede en aktiv {job_type}-jobb for samme anlegg." ) + if status == "conflict": + message = ( + f"Kunne ikke legge jobben i kø fordi {len(overlapping_ids)} valgt" + f" anlegg allerede inngår i en aktiv {job_type}-jobb." + ) return { "status": status, "message": message, "job": job, + "conflicting_facility_ids": overlapping_ids, + "idle_facility_ids": available_ids, } diff --git a/backend/scrape_jobs.py b/backend/scrape_jobs.py index 97d96d7..635440d 100755 --- a/backend/scrape_jobs.py +++ b/backend/scrape_jobs.py @@ -4,6 +4,7 @@ from typing import Any, Iterable SCRAPE_JOB_TYPES = ("banestatus", "medlemskap", "greenfee", "vtg") SCRAPE_JOB_STATUSES = ("pending", "running", "completed", "failed") +DEFAULT_MAX_ATTEMPTS = 3 def normalize_facility_ids(facility_ids: Iterable[int]) -> list[int]: @@ -15,6 +16,16 @@ def normalize_facility_ids(facility_ids: Iterable[int]) -> list[int]: return sorted(facility_id for facility_id in cleaned if facility_id > 0) +def _normalize_int_list(values: Iterable[Any]) -> list[int]: + cleaned: set[int] = set() + for value in values: + try: + cleaned.add(int(value)) + except (TypeError, ValueError): + continue + return sorted(item for item in cleaned if item > 0) + + def _parse_json(value: Any, fallback: Any) -> Any: if value is None: return fallback @@ -32,7 +43,7 @@ def format_scrape_job_row(row: Any) -> dict[str, Any] | None: data = dict(row) - for key in ("created_at", "started_at", "finished_at", "updated_at", "last_heartbeat_at"): + for key in ("created_at", "started_at", "finished_at", "updated_at", "last_heartbeat_at", "next_retry_at", "last_error_at"): if isinstance(data.get(key), (date, datetime)): data[key] = data[key].isoformat() @@ -41,10 +52,52 @@ def format_scrape_job_row(row: Any) -> dict[str, Any] | None: result_summary = _parse_json(data.get("result_summary"), {}) data["result_summary"] = result_summary if isinstance(result_summary, dict) else {} + overlapping_facility_ids = _parse_json(data.get("overlapping_facility_ids"), []) + data["overlapping_facility_ids"] = _normalize_int_list(overlapping_facility_ids if isinstance(overlapping_facility_ids, list) else []) + data["retryable"] = bool(data.get("retryable", False)) + data["attempt_count"] = int(data.get("attempt_count") or 0) + data["max_attempts"] = int(data.get("max_attempts") or DEFAULT_MAX_ATTEMPTS) return data +def classify_scrape_error(exc: Exception) -> tuple[str, bool]: + message = str(exc).lower() + type_name = type(exc).__name__.lower() + module_name = type(exc).__module__.lower() + + if "json" in type_name or "jsondecodeerror" in type_name or "expecting value" in message: + return "json_parse", False + if "gemini_api_key" in message or "api_key" in message or "mangler i .env" in message: + return "configuration", False + if "permission denied" in message or "not found" in message and "module" in message: + return "configuration", False + if "timeout" in message or "timed out" in message or "timeouterror" in type_name: + return "timeout", True + if "playwright" in module_name or "browser" in message or "page.goto" in message: + return "browser", True + if "connection" in message or "connectionreseterror" in type_name or "dns" in message or "network" in message: + return "network", True + if "asyncpg" in module_name or "postgres" in message or "database" in message: + return "database", True + if "valueerror" in type_name: + return "validation", False + return "unknown", False + + +def compute_retry_delay_seconds(attempt_count: int, error_code: str) -> int: + base_delay = { + "timeout": 30, + "network": 45, + "browser": 60, + "database": 30, + "unknown": 90, + }.get(error_code, 0) + if base_delay <= 0: + return 0 + return min(300, base_delay * max(1, attempt_count)) + + async def ensure_scrape_jobs_table(conn) -> None: await conn.execute( """ @@ -57,16 +110,26 @@ async def ensure_scrape_jobs_table(conn) -> None: requested_by TEXT, worker_name TEXT, attempt_count INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 3, error_message TEXT, + error_code TEXT, + retryable BOOLEAN NOT NULL DEFAULT FALSE, result_summary JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), started_at TIMESTAMPTZ, finished_at TIMESTAMPTZ, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - last_heartbeat_at TIMESTAMPTZ + last_heartbeat_at TIMESTAMPTZ, + next_retry_at TIMESTAMPTZ, + last_error_at TIMESTAMPTZ ) """ ) + await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS max_attempts INTEGER NOT NULL DEFAULT 3") + await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS error_code TEXT") + await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS retryable BOOLEAN NOT NULL DEFAULT FALSE") + await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS next_retry_at TIMESTAMPTZ") + await conn.execute("ALTER TABLE scrape_jobs ADD COLUMN IF NOT EXISTS last_error_at TIMESTAMPTZ") await conn.execute( """ CREATE INDEX IF NOT EXISTS idx_scrape_jobs_status_created_at @@ -79,9 +142,52 @@ async def ensure_scrape_jobs_table(conn) -> None: ON scrape_jobs (job_type, created_at DESC) """ ) + await conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_scrape_jobs_status_next_retry_at + ON scrape_jobs (status, next_retry_at) + """ + ) -async def enqueue_scrape_job(pool, job_type: str, facility_ids: Iterable[int], requested_by: str | None = None) -> tuple[dict[str, Any], bool]: +async def _find_active_job_conflict(conn, job_type: str, normalized_ids: list[int]) -> tuple[dict[str, Any] | None, str | None]: + if not normalized_ids: + return None, None + + requested_set = set(normalized_ids) + rows = await conn.fetch( + """ + SELECT * + FROM scrape_jobs + WHERE job_type = $1 + AND status IN ('pending', 'running') + ORDER BY created_at DESC + """, + job_type, + ) + + partial_conflict: dict[str, Any] | None = None + for row in rows: + formatted = format_scrape_job_row(row) + if not formatted: + continue + existing_ids = normalize_facility_ids(formatted.get("facility_ids", [])) + overlap = sorted(requested_set.intersection(existing_ids)) + if not overlap: + continue + + formatted["overlapping_facility_ids"] = overlap + if existing_ids == normalized_ids: + return formatted, "already_queued" + if partial_conflict is None: + partial_conflict = formatted + + if partial_conflict: + return partial_conflict, "conflict" + return None, None + + +async def enqueue_scrape_job(pool, job_type: str, facility_ids: Iterable[int], requested_by: str | None = None) -> tuple[dict[str, Any], str]: if job_type not in SCRAPE_JOB_TYPES: raise ValueError(f"Ugyldig job_type: {job_type}") @@ -90,21 +196,9 @@ async def enqueue_scrape_job(pool, job_type: str, facility_ids: Iterable[int], r async with pool.acquire() as conn: async with conn.transaction(): - existing = await conn.fetchrow( - """ - SELECT * - FROM scrape_jobs - WHERE job_type = $1 - AND status IN ('pending', 'running') - AND facility_ids = $2::jsonb - ORDER BY created_at DESC - LIMIT 1 - """, - job_type, - facility_ids_json, - ) - if existing: - return format_scrape_job_row(existing), False + existing_job, queue_status = await _find_active_job_conflict(conn, job_type, normalized_ids) + if existing_job and queue_status: + return existing_job, queue_status row = await conn.fetchrow( """ @@ -122,7 +216,7 @@ async def enqueue_scrape_job(pool, job_type: str, facility_ids: Iterable[int], r len(normalized_ids), requested_by, ) - return format_scrape_job_row(row), True + return format_scrape_job_row(row), "queued" async def list_scrape_jobs(pool, job_type: str | None = None, limit: int = 10) -> list[dict[str, Any]]: @@ -154,15 +248,53 @@ async def list_scrape_jobs(pool, job_type: str | None = None, limit: int = 10) - return [format_scrape_job_row(row) for row in rows] -async def claim_next_scrape_job(pool, worker_name: str) -> dict[str, Any] | None: +async def claim_next_scrape_job(pool, worker_name: str, stale_heartbeat_seconds: int = 120) -> dict[str, Any] | None: async with pool.acquire() as conn: async with conn.transaction(): + await conn.execute( + """ + UPDATE scrape_jobs + SET status = 'pending', + worker_name = NULL, + updated_at = NOW(), + retryable = TRUE, + error_code = COALESCE(error_code, 'worker_stale'), + error_message = COALESCE(error_message, 'Forrige worker mistet heartbeat før jobben ble ferdig.'), + last_error_at = NOW(), + next_retry_at = NOW() + INTERVAL '15 seconds' + WHERE status = 'running' + AND attempt_count < max_attempts + AND last_heartbeat_at IS NOT NULL + AND last_heartbeat_at < NOW() - ($1 * INTERVAL '1 second') + """, + stale_heartbeat_seconds, + ) + await conn.execute( + """ + UPDATE scrape_jobs + SET status = 'failed', + worker_name = NULL, + updated_at = NOW(), + finished_at = NOW(), + retryable = FALSE, + error_code = COALESCE(error_code, 'worker_stale'), + error_message = COALESCE(error_message, 'Forrige worker mistet heartbeat og maks forsok er oppbrukt.'), + last_error_at = NOW(), + next_retry_at = NULL + WHERE status = 'running' + AND attempt_count >= max_attempts + AND last_heartbeat_at IS NOT NULL + AND last_heartbeat_at < NOW() - ($1 * INTERVAL '1 second') + """, + stale_heartbeat_seconds, + ) row = await conn.fetchrow( """ WITH next_job AS ( SELECT id FROM scrape_jobs WHERE status = 'pending' + AND COALESCE(next_retry_at, created_at) <= NOW() ORDER BY created_at ASC FOR UPDATE SKIP LOCKED LIMIT 1 @@ -174,7 +306,8 @@ async def claim_next_scrape_job(pool, worker_name: str) -> dict[str, Any] | None started_at = COALESCE(job.started_at, NOW()), updated_at = NOW(), last_heartbeat_at = NOW(), - error_message = NULL + error_message = NULL, + next_retry_at = NULL FROM next_job WHERE job.id = next_job.id RETURNING job.* @@ -208,6 +341,9 @@ async def complete_scrape_job(pool, job_id: int, result_summary: dict[str, Any] updated_at = NOW(), last_heartbeat_at = NOW(), error_message = NULL, + error_code = NULL, + retryable = FALSE, + next_retry_at = NULL, result_summary = $2::jsonb WHERE id = $1 """, @@ -216,21 +352,52 @@ async def complete_scrape_job(pool, job_id: int, result_summary: dict[str, Any] ) -async def fail_scrape_job(pool, job_id: int, error_message: str, result_summary: dict[str, Any] | None = None) -> None: +async def fail_scrape_job( + pool, + job_id: int, + error_message: str, + result_summary: dict[str, Any] | None = None, + *, + error_code: str = "unknown", + retryable: bool = False, + retry_delay_seconds: int = 0, +) -> dict[str, Any] | None: payload = json.dumps(result_summary or {}) async with pool.acquire() as conn: - await conn.execute( + row = await conn.fetchrow( """ UPDATE scrape_jobs - SET status = 'failed', - finished_at = NOW(), + SET status = CASE + WHEN $4 AND attempt_count < max_attempts THEN 'pending' + ELSE 'failed' + END, + finished_at = CASE + WHEN $4 AND attempt_count < max_attempts THEN NULL + ELSE NOW() + END, updated_at = NOW(), last_heartbeat_at = NOW(), error_message = $2, - result_summary = $3::jsonb + error_code = $3, + retryable = $4, + result_summary = $5::jsonb, + last_error_at = NOW(), + worker_name = CASE + WHEN $4 AND attempt_count < max_attempts THEN NULL + ELSE worker_name + END, + next_retry_at = CASE + WHEN $4 AND attempt_count < max_attempts THEN NOW() + ($6 * INTERVAL '1 second') + ELSE NULL + END WHERE id = $1 + RETURNING * """, job_id, error_message[:1000], + error_code, + retryable, payload, + max(0, retry_delay_seconds), ) + return format_scrape_job_row(row) diff --git a/backend/worker.py b/backend/worker.py index a5f60df..0f4ebaa 100755 --- a/backend/worker.py +++ b/backend/worker.py @@ -7,8 +7,10 @@ from dotenv import load_dotenv from scrape_job_runner import run_scrape_job from scrape_jobs import ( + classify_scrape_error, claim_next_scrape_job, complete_scrape_job, + compute_retry_delay_seconds, ensure_scrape_jobs_table, fail_scrape_job, heartbeat_scrape_job, @@ -20,6 +22,9 @@ DB_URL = os.getenv("DATABASE_URL", "postgresql://teeoff_admin:teeoff_secret_pass WORKER_NAME = os.getenv("SCRAPE_WORKER_NAME", f"scrape-worker-{os.getpid()}") POLL_INTERVAL_SECONDS = int(os.getenv("SCRAPE_WORKER_POLL_INTERVAL", "5")) HEARTBEAT_INTERVAL_SECONDS = int(os.getenv("SCRAPE_WORKER_HEARTBEAT_INTERVAL", "15")) +STALE_HEARTBEAT_SECONDS = int( + os.getenv("SCRAPE_WORKER_STALE_HEARTBEAT_SECONDS", str(max(HEARTBEAT_INTERVAL_SECONDS * 4, 60))) +) async def heartbeat_loop(pool, job_id: int, stop_event: asyncio.Event) -> None: @@ -30,11 +35,11 @@ async def heartbeat_loop(pool, job_id: int, stop_event: asyncio.Event) -> None: try: await heartbeat_scrape_job(pool, job_id) except Exception as exc: - print(f"⚠️ Klarte ikke å oppdatere heartbeat for jobb {job_id}: {exc}") + print(f"Warning: Klarte ikke å oppdatere heartbeat for jobb {job_id}: {exc}") async def main() -> None: - print(f"🚀 Starter scrape worker: {WORKER_NAME}") + print(f"Starter scrape worker: {WORKER_NAME}") pool = await asyncpg.create_pool(DB_URL, min_size=1, max_size=5, command_timeout=60) try: @@ -42,13 +47,16 @@ async def main() -> None: await ensure_scrape_jobs_table(conn) while True: - job = await claim_next_scrape_job(pool, WORKER_NAME) + job = await claim_next_scrape_job(pool, WORKER_NAME, stale_heartbeat_seconds=STALE_HEARTBEAT_SECONDS) if not job: await asyncio.sleep(POLL_INTERVAL_SECONDS) continue job_id = job["id"] - print(f"🎯 Worker plukket jobb #{job_id} ({job['job_type']}) for {len(job.get('facility_ids', []))} anlegg") + print( + f"Worker plukket jobb #{job_id} ({job['job_type']}) " + f"for {len(job.get('facility_ids', []))} anlegg" + ) stop_event = asyncio.Event() heartbeat_task = asyncio.create_task(heartbeat_loop(pool, job_id, stop_event)) @@ -56,16 +64,35 @@ async def main() -> None: try: result_summary = await run_scrape_job(job) await complete_scrape_job(pool, job_id, result_summary) - print(f"✅ Jobb #{job_id} fullført") + print(f"Jobb #{job_id} fullført") except Exception as exc: trace = traceback.format_exc(limit=5) - print(f"🔥 Jobb #{job_id} feilet: {exc}\n{trace}") - await fail_scrape_job( + error_code, retryable = classify_scrape_error(exc) + retry_delay_seconds = ( + compute_retry_delay_seconds(int(job.get("attempt_count") or 1), error_code) + if retryable + else 0 + ) + updated_job = await fail_scrape_job( pool, job_id, str(exc), - {"traceback": trace}, + { + "error_code": error_code, + "traceback": trace, + }, + error_code=error_code, + retryable=retryable, + retry_delay_seconds=retry_delay_seconds, ) + if updated_job and updated_job.get("status") == "pending": + print( + f"Jobb #{job_id} feilet midlertidig ({error_code}). " + f"Nytt forsøk {updated_job.get('attempt_count', 0)}/" + f"{updated_job.get('max_attempts', 0)} om {retry_delay_seconds} sek." + ) + else: + print(f"Jobb #{job_id} feilet permanent ({error_code}): {exc}\n{trace}") finally: stop_event.set() await heartbeat_task diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 7a8286b..4067144 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -20,12 +20,24 @@ type ScrapeJob = { facility_ids: number[]; total_facilities: number; error_message?: string | null; + error_code?: string | null; + retryable?: boolean; + attempt_count?: number; + max_attempts?: number; + next_retry_at?: string | null; + last_error_at?: string | null; result_summary?: Record; created_at?: string | null; started_at?: string | null; finished_at?: string | null; }; +type QueueFeedback = { + tone: 'success' | 'warning' | 'info' | 'error'; + title: string; + message: string; +}; + type TwoFactorSetupResponse = { issuer: string; account_name: string; @@ -48,6 +60,18 @@ const JOB_STATUS_LABELS: Record = { failed: 'Feilet', }; +const JOB_ERROR_LABELS: Record = { + json_parse: 'JSON-tolkning', + configuration: 'Konfigurasjon', + timeout: 'Tidsavbrudd', + browser: 'Nettleser / Playwright', + network: 'Nettverk', + database: 'Database', + validation: 'Validering', + unknown: 'Ukjent feil', + worker_stale: 'Worker mistet heartbeat', +}; + const InlineEdit = ({ facilityId, field, initialValue, onSave }: { facilityId: number, field: string, initialValue: string, onSave: (id: number, field: string, val: string) => void }) => { const [isEditing, setIsEditing] = useState(false); const [value, setValue] = useState(initialValue || ''); @@ -101,6 +125,7 @@ export default function AdminDashboard() { const [isLoadingTwoFactor, setIsLoadingTwoFactor] = useState(false); const [twoFactorSetup, setTwoFactorSetup] = useState(null); const [copiedTwoFactorField, setCopiedTwoFactorField] = useState<'secret' | 'uri' | null>(null); + const [queueFeedback, setQueueFeedback] = useState(null); const fetchFacilities = () => { fetch(`${API_URL}/facilities`) @@ -140,6 +165,7 @@ export default function AdminDashboard() { useEffect(() => { setSelectedFacilities([]); + setQueueFeedback(null); fetchScrapeJobs(activeTab); }, [activeTab]); @@ -150,7 +176,9 @@ export default function AdminDashboard() { }, [isScraping]); useEffect(() => { - const currentState = latestJob ? `${latestJob.id}:${latestJob.status}` : null; + const currentState = latestJob + ? `${latestJob.id}:${latestJob.status}:${latestJob.attempt_count ?? 0}:${latestJob.next_retry_at ?? ''}:${latestJob.finished_at ?? ''}` + : null; const previousState = latestJobStateRef.current; latestJobStateRef.current = currentState; @@ -216,6 +244,29 @@ export default function AdminDashboard() { .join(' • '); }, [latestJob]); + const latestJobAttemptLabel = useMemo(() => { + if (!latestJob) return ''; + const attempts = latestJob.attempt_count ?? 0; + const maxAttempts = latestJob.max_attempts ?? 0; + if (!attempts || !maxAttempts) return ''; + return `Forsøk ${attempts} av ${maxAttempts}`; + }, [latestJob]); + + const latestJobRetryLabel = useMemo(() => { + if (!latestJob?.next_retry_at || latestJob.status !== 'pending') return ''; + return `Nytt forsøk planlagt ${new Date(latestJob.next_retry_at).toLocaleString('nb-NO')}`; + }, [latestJob]); + + const latestJobErrorLabel = useMemo(() => { + if (!latestJob?.error_code) return ''; + return JOB_ERROR_LABELS[latestJob.error_code] || latestJob.error_code.replaceAll('_', ' '); + }, [latestJob]); + + const recentJobs = useMemo(() => { + if (!latestJob) return scrapeJobs.slice(0, 4); + return scrapeJobs.filter(job => job.id !== latestJob.id).slice(0, 4); + }, [latestJob, scrapeJobs]); + const handleSelectAll = (e: React.ChangeEvent) => { if (e.target.checked) setSelectedFacilities(filteredFacilities.map(f => f.id)); else setSelectedFacilities([]); @@ -244,6 +295,7 @@ export default function AdminDashboard() { const handleRunScrapers = async () => { if (selectedFacilities.length === 0) return; setIsQueueing(true); + setQueueFeedback(null); const endpoint = activeTab === 'banestatus' ? '/admin/run-scraper' : activeTab === 'medlemskap' ? '/admin/run-membership-scraper' : activeTab === 'greenfee' ? '/admin/run-greenfee-scraper' : @@ -256,9 +308,39 @@ export default function AdminDashboard() { }); const data = await response.json(); if (!response.ok) throw new Error(data.detail || "Kunne ikke starte skraping"); - setSelectedFacilities([]); + if (data.status === 'queued') { + setQueueFeedback({ + tone: 'success', + title: 'Jobb lagt i kø', + message: data.message || 'Skrapejobben ble lagt i kø.' + }); + setSelectedFacilities([]); + } else if (data.status === 'already_queued') { + setQueueFeedback({ + tone: 'info', + title: 'Jobben finnes allerede', + message: data.message || 'Det finnes allerede en aktiv jobb for dette utvalget.' + }); + setSelectedFacilities([]); + } else if (data.status === 'conflict') { + const conflictingCount = Array.isArray(data.conflicting_facility_ids) ? data.conflicting_facility_ids.length : 0; + const idleCount = Array.isArray(data.idle_facility_ids) ? data.idle_facility_ids.length : 0; + setQueueFeedback({ + tone: 'warning', + title: 'Noen anlegg er allerede i arbeid', + message: + conflictingCount > 0 + ? `${data.message || 'Utvalget overlapper med en aktiv jobb.'}${idleCount > 0 ? ` ${idleCount} andre anlegg i valget er fortsatt ledige hvis du vil justere utvalget.` : ''}` + : (data.message || 'Utvalget overlapper med en aktiv jobb.') + }); + } fetchScrapeJobs(activeTab); } catch (error) { + setQueueFeedback({ + tone: 'error', + title: 'Kunne ikke starte jobben', + message: `Det oppstod en feil ved start av ${activeTab}-skraperen.` + }); alert(`Feil ved start av ${activeTab}-skraperen.`); } finally { setIsQueueing(false); @@ -716,6 +798,32 @@ export default function AdminDashboard() { + {queueFeedback && ( +
+
+
+

Køstatus

+

{queueFeedback.title}

+

{queueFeedback.message}

+
+ +
+
+ )} + {latestJob && latestJob.job_type === activeTab && (
{latestJob.total_facilities} anlegg + {latestJobAttemptLabel && ( + + {latestJobAttemptLabel} + + )} + {latestJobErrorLabel && ( + + {latestJobErrorLabel} + + )} {latestJob.created_at && ( Opprettet {new Date(latestJob.created_at).toLocaleString('nb-NO')} )}
+ {latestJobRetryLabel && ( +

+ {latestJobRetryLabel} +

+ )} {latestJobSummary && (

{latestJobSummary}

)} @@ -765,6 +888,90 @@ export default function AdminDashboard() { )} + {recentJobs.length > 0 && ( +
+
+
+

Jobbhistorikk

+

Siste jobber i {JOB_LABELS[activeTab].toLowerCase()}

+
+

Kortene viser status, forsøk og siste kjente resultat uten sideveis scrolling.

+
+
+ {recentJobs.map(job => { + const historyErrorLabel = job.error_code ? (JOB_ERROR_LABELS[job.error_code] || job.error_code.replaceAll('_', ' ')) : ''; + const historyAttemptLabel = job.attempt_count && job.max_attempts ? `Forsøk ${job.attempt_count} av ${job.max_attempts}` : ''; + const historySummary = job.result_summary + ? Object.entries(job.result_summary) + .filter(([, value]) => value !== null && value !== undefined && value !== '') + .map(([key, value]) => `${key.replaceAll('_', ' ')}: ${value}`) + .join(' • ') + : ''; + + return ( +
+
+ + {JOB_STATUS_LABELS[job.status]} + + Jobb #{job.id} + {job.total_facilities} anlegg +
+
+ {historyAttemptLabel && ( + + {historyAttemptLabel} + + )} + {historyErrorLabel && ( + + {historyErrorLabel} + + )} + {job.finished_at && ( + + Ferdig {new Date(job.finished_at).toLocaleString('nb-NO')} + + )} + {!job.finished_at && job.created_at && ( + + Opprettet {new Date(job.created_at).toLocaleString('nb-NO')} + + )} +
+ {job.next_retry_at && job.status === 'pending' && ( +

+ Nytt forsøk planlagt {new Date(job.next_retry_at).toLocaleString('nb-NO')} +

+ )} + {historySummary && ( +

{historySummary}

+ )} + {job.error_message && ( +

{job.error_message}

+ )} +
+ ); + })} +
+
+ )} + {/* VELDIG SYNLIGE FANER */}