All checks were successful
Release / Scraper / Test (push) Successful in 10s
Release / UI / Build (push) Successful in 26s
Release / v2 / Build ui-v2 (push) Successful in 17s
Release / Scraper / Docker (push) Successful in 47s
Release / UI / Docker (push) Successful in 56s
CI / Scraper / Lint (pull_request) Successful in 7s
CI / Scraper / Test (pull_request) Successful in 8s
CI / UI / Build (pull_request) Successful in 16s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
Release / v2 / Docker / ui-v2 (push) Successful in 56s
Release / v2 / Test backend (push) Successful in 4m35s
iOS CI / Build (pull_request) Successful in 4m28s
Release / v2 / Docker / backend (push) Successful in 1m29s
Release / v2 / Docker / runner (push) Successful in 1m39s
iOS CI / Test (pull_request) Successful in 9m51s
- backend/: Go API server and runner binaries with PocketBase + MinIO storage - ui-v2/: SvelteKit frontend rewrite - docker-compose-new.yml: compose file for the v2 stack - .gitea/workflows/release-v2.yaml: CI/CD for backend, runner, and ui-v2 Docker Hub images - scripts/pb-init.sh: migrate from wget to curl, add superuser bootstrap for fresh installs - .env.example: document DOCKER_BUILDKIT=1 for Colima users
239 lines
8.6 KiB
Svelte
239 lines
8.6 KiB
Svelte
<script lang="ts">
|
|
import { untrack } from 'svelte';
|
|
import { invalidateAll } from '$app/navigation';
|
|
import type { PageData } from './$types';
|
|
import type { ScrapingTask } from '$lib/server/pocketbase';
|
|
|
|
let { data }: { data: PageData } = $props();
|
|
|
|
// ── Live-poll status ────────────────────────────────────────────────────────
|
|
let running = $state(untrack(() => data.running));
|
|
let tasks = $state(untrack(() => data.tasks));
|
|
let polling = $state(false);
|
|
|
|
// Poll every 5 s while a job is running
|
|
$effect(() => {
|
|
if (!running) return;
|
|
const id = setInterval(async () => {
|
|
const res = await fetch('/api/admin/scrape').catch(() => null);
|
|
if (res?.ok) {
|
|
const body = await res.json().catch(() => null);
|
|
running = body?.running ?? false;
|
|
if (!running) {
|
|
// Refresh tasks list once job finishes
|
|
await invalidateAll();
|
|
}
|
|
}
|
|
}, 5000);
|
|
return () => clearInterval(id);
|
|
});
|
|
|
|
// Keep local state in sync when server re-loads
|
|
$effect(() => {
|
|
running = data.running;
|
|
tasks = data.tasks;
|
|
});
|
|
|
|
// ── Trigger scrape ──────────────────────────────────────────────────────────
|
|
let scrapeUrl = $state('');
|
|
let scrapeError = $state('');
|
|
let scraping = $state(false);
|
|
|
|
async function triggerScrape(url?: string) {
|
|
if (running || scraping) return;
|
|
scraping = true;
|
|
scrapeError = '';
|
|
try {
|
|
const body = url ? { url } : {};
|
|
const res = await fetch('/api/scrape', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body)
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}));
|
|
scrapeError = data.error ?? data.message ?? `Error ${res.status}`;
|
|
} else {
|
|
running = true;
|
|
if (url) scrapeUrl = '';
|
|
}
|
|
} catch {
|
|
scrapeError = 'Network error.';
|
|
} finally {
|
|
scraping = false;
|
|
}
|
|
}
|
|
|
|
// ── Cancel task ─────────────────────────────────────────────────────────────
|
|
// Tracks which task IDs are currently being cancelled (to disable the button).
|
|
let cancellingIds = $state(new Set<string>());
|
|
let cancelErrors: Record<string, string> = $state({});
|
|
|
|
async function cancelTask(id: string) {
|
|
if (cancellingIds.has(id)) return;
|
|
cancellingIds = new Set([...cancellingIds, id]);
|
|
delete cancelErrors[id];
|
|
try {
|
|
const res = await fetch(`/api/scrape/cancel/${encodeURIComponent(id)}`, { method: 'POST' });
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
cancelErrors = { ...cancelErrors, [id]: body.error ?? body.message ?? `Error ${res.status}` };
|
|
} else {
|
|
// Optimistically flip status in the local list so the button disappears immediately.
|
|
tasks = tasks.map((t: ScrapingTask) => (t.id === id ? { ...t, status: 'cancelled' } : t));
|
|
}
|
|
} catch {
|
|
cancelErrors = { ...cancelErrors, [id]: 'Network error.' };
|
|
} finally {
|
|
cancellingIds = new Set([...cancellingIds].filter((x) => x !== id));
|
|
}
|
|
}
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
function statusColor(status: string) {
|
|
if (status === 'done') return 'text-green-400';
|
|
if (status === 'running') return 'text-amber-400 animate-pulse';
|
|
if (status === 'failed') return 'text-red-400';
|
|
if (status === 'cancelled') return 'text-zinc-400';
|
|
return 'text-zinc-300';
|
|
}
|
|
|
|
function fmtDate(s: string) {
|
|
if (!s) return '—';
|
|
return new Date(s).toLocaleString(undefined, {
|
|
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
|
});
|
|
}
|
|
|
|
function duration(started: string, finished: string) {
|
|
if (!started || !finished) return '—';
|
|
const ms = new Date(finished).getTime() - new Date(started).getTime();
|
|
if (ms < 0) return '—';
|
|
const s = Math.floor(ms / 1000);
|
|
if (s < 60) return `${s}s`;
|
|
const m = Math.floor(s / 60);
|
|
return `${m}m ${s % 60}s`;
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>Scrape tasks — libnovel admin</title>
|
|
</svelte:head>
|
|
|
|
<div class="space-y-8">
|
|
<div class="flex items-center justify-between flex-wrap gap-3">
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-zinc-100">Scrape tasks</h1>
|
|
<p class="text-zinc-400 text-sm mt-1">
|
|
Job status:
|
|
{#if running}
|
|
<span class="text-amber-400 font-medium animate-pulse">Running</span>
|
|
{:else}
|
|
<span class="text-green-400 font-medium">Idle</span>
|
|
{/if}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Trigger controls -->
|
|
<div class="flex flex-wrap gap-3 items-start">
|
|
<button
|
|
onclick={() => triggerScrape()}
|
|
disabled={running || scraping}
|
|
class="px-4 py-2 rounded-lg bg-amber-400 text-zinc-900 font-semibold text-sm hover:bg-amber-300 transition-colors disabled:opacity-50"
|
|
>
|
|
Full catalogue scrape
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Single book scrape -->
|
|
<div class="bg-zinc-800 rounded-xl border border-zinc-700 p-5 space-y-3">
|
|
<h2 class="text-sm font-semibold text-zinc-300">Scrape a single book</h2>
|
|
<div class="flex gap-2">
|
|
<input
|
|
type="url"
|
|
bind:value={scrapeUrl}
|
|
placeholder="https://novelfire.net/book/..."
|
|
class="flex-1 bg-zinc-700 border border-zinc-600 rounded-lg px-3 py-2 text-zinc-100 text-sm placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-amber-400"
|
|
/>
|
|
<button
|
|
onclick={() => triggerScrape(scrapeUrl.trim() || undefined)}
|
|
disabled={!scrapeUrl.trim() || running || scraping}
|
|
class="px-4 py-2 rounded-lg bg-zinc-600 text-zinc-100 font-semibold text-sm hover:bg-zinc-500 transition-colors disabled:opacity-50"
|
|
>
|
|
Scrape
|
|
</button>
|
|
</div>
|
|
{#if scrapeError}
|
|
<p class="text-sm text-red-400">{scrapeError}</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Tasks table -->
|
|
{#if tasks.length === 0}
|
|
<p class="text-zinc-500 text-sm py-8 text-center">No scrape tasks yet.</p>
|
|
{:else}
|
|
<div class="overflow-x-auto rounded-xl border border-zinc-700">
|
|
<table class="w-full text-sm">
|
|
<thead class="bg-zinc-800 text-zinc-400 text-xs uppercase tracking-wide">
|
|
<tr>
|
|
<th class="px-4 py-3 text-left">Kind</th>
|
|
<th class="px-4 py-3 text-left">Status</th>
|
|
<th class="px-4 py-3 text-right">Books</th>
|
|
<th class="px-4 py-3 text-right">Chapters</th>
|
|
<th class="px-4 py-3 text-right">Skipped</th>
|
|
<th class="px-4 py-3 text-right">Errors</th>
|
|
<th class="px-4 py-3 text-left">Started</th>
|
|
<th class="px-4 py-3 text-left">Duration</th>
|
|
<th class="px-4 py-3 text-left">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="divide-y divide-zinc-700/50">
|
|
{#each tasks as task}
|
|
<tr class="bg-zinc-900 hover:bg-zinc-800/50 transition-colors">
|
|
<td class="px-4 py-3 font-mono text-xs text-zinc-300">
|
|
{task.kind}
|
|
{#if task.target_url}
|
|
<br />
|
|
<span class="text-zinc-500 truncate max-w-[16rem] block" title={task.target_url}>
|
|
{task.target_url.replace('https://novelfire.net/book/', '')}
|
|
</span>
|
|
{/if}
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<span class="font-medium {statusColor(task.status)}">{task.status}</span>
|
|
</td>
|
|
<td class="px-4 py-3 text-right text-zinc-300">{task.books_found ?? 0}</td>
|
|
<td class="px-4 py-3 text-right text-zinc-300">{task.chapters_scraped ?? 0}</td>
|
|
<td class="px-4 py-3 text-right text-zinc-400">{task.chapters_skipped ?? 0}</td>
|
|
<td class="px-4 py-3 text-right {task.errors > 0 ? 'text-red-400' : 'text-zinc-400'}">{task.errors ?? 0}</td>
|
|
<td class="px-4 py-3 text-zinc-400">{fmtDate(task.started)}</td>
|
|
<td class="px-4 py-3 text-zinc-400">{duration(task.started, task.finished)}</td>
|
|
<td class="px-4 py-3">
|
|
{#if task.status === 'pending'}
|
|
<button
|
|
onclick={() => cancelTask(task.id)}
|
|
disabled={cancellingIds.has(task.id)}
|
|
class="px-2 py-1 rounded text-xs font-medium bg-zinc-700 text-zinc-300 hover:bg-red-900 hover:text-red-300 transition-colors disabled:opacity-50"
|
|
title="Cancel this task"
|
|
>
|
|
{cancellingIds.has(task.id) ? 'Cancelling…' : 'Cancel'}
|
|
</button>
|
|
{#if cancelErrors[task.id]}
|
|
<p class="text-xs text-red-400 mt-1">{cancelErrors[task.id]}</p>
|
|
{/if}
|
|
{/if}
|
|
</td>
|
|
</tr>
|
|
{#if task.error_message}
|
|
<tr class="bg-red-950/20">
|
|
<td colspan="9" class="px-4 py-2 text-xs text-red-400 font-mono">{task.error_message}</td>
|
|
</tr>
|
|
{/if}
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{/if}
|
|
</div>
|