Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
output. With this, essentially the whole CLI works whether or not the daemon is
running (the only exceptions: `search games --moves-stats` and the admin-only
`players dedup`/`update-game-counts`/`apply-corrections`, which need `--local`).
- **Background activity view** — a new indicator in the header expands into a
panel showing the daemon's whole job pipeline: the active job, the queue
behind it, and recent finishes, across every job type (source syncs, the
scheduled update, and manual maintenance like dedup/index/backup). Background
work runs on the daemon even with the app closed, so this is its always-visible
home; running, interruptible jobs can be cancelled from here.
- **Enabling a source imports it automatically** — turning a source on in
Maintenance → Sources no longer requires pressing "Sync now". The daemon's
scheduler picks up enabled-but-not-yet-synced sources on its next tick (~1 min)
and runs the import in the background, even with the GUI closed. "Sync now"
remains as an optional manual trigger; a sync that fails or is cancelled is not
auto-retried, while one interrupted by a restart resumes.

## [0.4.0] - 2026-06-21

Expand Down
2 changes: 2 additions & 0 deletions chess-client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import DirectoryBrowser from "./components/local/DirectoryBrowser";
import LocalGameList from "./components/local/LocalGameList";
import HomeEmptyState from "./components/HomeEmptyState";
import UpdateBanner from "./components/UpdateBanner";
import ActivityIndicator from "./components/ActivityIndicator";
import { loadMyPlayer } from "./components/MyStatsWidget";
import { useUpdateCheck } from "./hooks/useUpdateCheck";
import { GameSummary, LocalGame, MoveStats, PlayerInfo, PrepContext, StatusInfo } from "./types";
Expand Down Expand Up @@ -447,6 +448,7 @@ export default function App() {
title="Database maintenance"
>Maintenance</button>

<ActivityIndicator />
<StatusBadge status={status} />
</div>
</header>
Expand Down
8 changes: 7 additions & 1 deletion chess-client/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ export async function cancelJob(jobId: string): Promise<void> {
await fetch(apiUrl(`/jobs/${encodeURIComponent(jobId)}/cancel`), { method: "POST" });
}

/** The daemon's whole job pipeline (active + queued + finished), newest last —
* what the global Activity view reads. */
export function getJobs(): Promise<Job[]> {
return apiGet<Job[]>("/jobs");
}

// ── Schedule (server-owned auto-update) ──────────────────────────────────────

export interface ScheduleInfo {
Expand Down Expand Up @@ -93,7 +99,7 @@ export async function runUpdateNow(): Promise<string> {

// ── Sources (multi-source import catalog, #40) ────────────────────────────────

import type { SourceStatus } from "./types";
import type { SourceStatus, Job } from "./types";

/** The curated source catalog + this database's state for each. */
export function getSources(): Promise<SourceStatus[]> {
Expand Down
190 changes: 190 additions & 0 deletions chess-client/src/components/ActivityIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { useState, useEffect, useRef } from "react";
import { getJobs, cancelJob } from "../api";
import type { Job } from "../types";

// ── Global background-activity view (#40 Phase C3) ────────────────────────────
//
// A header indicator that expands into a panel showing the daemon's whole job
// pipeline — the active job, the queue behind it, and recent finishes — across
// every job type (source syncs, the scheduled update, manual maintenance like
// dedup/index/backup). Background work runs serially on the daemon and even
// continues with the GUI closed, so it deserves one always-visible home.
// Source cards keep their own inline per-source progress (C1); this is the
// global view that also covers the non-source and post-import jobs.

const POLL_MS = 1500;

// Catalog source keys → display names, so a sync job reads as the source name.
const SOURCE_NAMES: Record<string, string> = {
"twic": "The Week in Chess",
"lichess-broadcasts": "Lichess Broadcasts",
"ajedrez-otb": "Ajedrez Data — OTB",
};

/** A human label for a job, derived from its type + params. */
function jobLabel(j: Job): string {
const p = (j.params ?? {}) as Record<string, string>;
const src = p.source ? SOURCE_NAMES[p.source] ?? p.source : "";
switch (j.type) {
case "sources_sync": return `Sync ${src}`;
case "sources_set_enabled":return `Update ${src || "source"}`;
case "sources_set_window": return `Window ${src || "source"}`;
case "update": return "Scheduled update";
case "index_positions": return "Build position index";
case "dedup_games": return "Deduplicate games";
case "cleanup": return "Clean up games";
case "normalise": return "Normalise player names";
case "import": return "Import";
case "import_pgn": return p.collection ? `Import PGN → ${p.collection}` : "Import PGN";
case "players_import": return "Import players";
case "players_export": return "Export players";
case "backup": return p.collection ? `Backup ${p.collection}` : "Backup";
default: return j.type;
}
}

function pct(j: Job): number {
return j.total > 0 ? Math.min(100, (j.value / j.total) * 100) : 0;
}

function ActiveRow({ job, onCancel }: { job: Job; onCancel: (id: string) => void }) {
const queued = job.status === "queued";
const known = job.total > 0;
return (
<div className="px-4 py-3 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<span className="text-body-sm text-on-surface truncate">{jobLabel(job)}</span>
{/* Cancel only running, interruptible jobs — an appender (fast) write
can corrupt the DB if killed mid-flight, so it isn't cancelable. */}
{job.status === "running" && job.interruptible && (
<button
onClick={() => onCancel(job.id)}
className="shrink-0 h-6 px-2 inline-flex items-center rounded-full text-error border border-outline text-label-sm hover:bg-error/8 transition-colors duration-short3 ease-standard"
>
Cancel
</button>
)}
</div>
{queued ? (
<div className="text-label-sm text-on-surface-variant">Queued</div>
) : (
<>
<div className="flex items-center justify-between gap-2 text-label-sm text-on-surface-variant">
<span className="truncate">{job.message || "Working…"}</span>
{known && <span className="shrink-0">{Math.round(pct(job))}%</span>}
</div>
<div className="w-full bg-surface-container-highest rounded-full h-1.5 overflow-hidden">
<div
className={`bg-primary h-1.5 rounded-full ${known ? "transition-all duration-short3 ease-standard" : "w-1/3 animate-pulse"}`}
style={known ? { width: `${pct(job)}%` } : undefined}
/>
</div>
</>
)}
</div>
);
}

function RecentRow({ job }: { job: Job }) {
const ok = job.status === "done";
return (
<div className="px-4 py-2 flex items-start gap-2">
<span className={`text-base leading-5 shrink-0 ${ok ? "text-success" : "text-error"}`}>{ok ? "✓" : "✕"}</span>
<div className="min-w-0">
<div className="text-body-sm text-on-surface truncate">{jobLabel(job)}</div>
{!ok && job.error && <div className="text-label-sm text-error break-words">{job.error}</div>}
{ok && job.message && <div className="text-label-sm text-on-surface-variant truncate">{job.message}</div>}
</div>
</div>
);
}

export default function ActivityIndicator() {
const [jobs, setJobs] = useState<Job[] | null>(null);
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);

// Poll the pipeline. It's a cheap local read, and the daemon keeps working
// even when this view is closed, so a steady poll keeps the badge honest.
useEffect(() => {
let stop = false;
const poll = () => { getJobs().then((j) => { if (!stop) setJobs(j); }).catch(() => { /* offline — leave last known */ }); };
poll();
const id = setInterval(poll, POLL_MS);
return () => { stop = true; clearInterval(id); };
}, []);

// Dismiss the panel on an outside click.
useEffect(() => {
if (!open) return;
function onDown(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);

const all = jobs ?? [];
// Submission order (oldest→newest); active oldest-first reads as the pipeline,
// recent newest-first so the latest finish is on top.
const active = all.filter((j) => j.status === "running" || j.status === "queued");
const recent = all.filter((j) => j.status === "done" || j.status === "error").slice(-8).reverse();
const busy = active.length > 0;

function handleCancel(id: string) {
void cancelJob(id);
}

return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((o) => !o)}
title="Background activity"
aria-label="Background activity"
className={`relative w-9 h-9 inline-flex items-center justify-center rounded-full text-label-md transition-colors duration-short3 ease-standard ${
open ? "bg-on-surface/12" : "text-on-surface-variant hover:bg-on-surface/8 active:bg-on-surface/12"
}`}
>
<span className={busy ? "inline-block animate-spin" : "inline-block"}>⟳</span>
{busy && (
<span className="absolute -top-0.5 -right-0.5 min-w-[1rem] h-4 px-1 inline-flex items-center justify-center rounded-full bg-primary text-on-primary text-[0.625rem] font-bold leading-none">
{active.length}
</span>
)}
</button>

{open && (
<div className="absolute right-0 top-full mt-2 w-[22rem] max-h-[28rem] overflow-y-auto z-50 rounded-2xl border border-outline-variant bg-surface-container-high shadow-lg">
<div className="px-4 py-3 border-b border-outline-variant flex items-center justify-between">
<span className="text-title-sm text-on-surface">Activity</span>
<span className="text-label-sm text-on-surface-variant">
{busy ? `${active.length} active` : "Idle"}
</span>
</div>

{jobs === null ? (
<div className="px-4 py-6 text-body-sm text-on-surface-variant">Loading…</div>
) : active.length === 0 && recent.length === 0 ? (
<div className="px-4 py-6 text-body-sm text-on-surface-variant">No background activity.</div>
) : (
<>
{active.length > 0 && (
<div className="divide-y divide-outline-variant">
{active.map((j) => <ActiveRow key={j.id} job={j} onCancel={handleCancel} />)}
</div>
)}
{recent.length > 0 && (
<>
<div className="px-4 pt-3 pb-1 text-label-sm text-on-surface-variant uppercase tracking-wider">Recent</div>
<div className="pb-2">
{recent.map((j) => <RecentRow key={j.id} job={j} />)}
</div>
</>
)}
</>
)}
</div>
)}
</div>
);
}
19 changes: 19 additions & 0 deletions chess-client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,3 +244,22 @@ export interface SourceStatus {
/** Items (issues/files) imported for this source. */
items: number;
}

// ── Background jobs (the daemon's job pipeline, #40 C3) ────────────────────────

/** One job tracked by the daemon's JobManager. Mirrors `GET /jobs`. */
export interface Job {
id: string;
/** Job kind, e.g. "sources_sync" | "update" | "index_positions" | "backup". */
type: string;
status: "queued" | "running" | "done" | "error";
value: number;
total: number;
message: string;
/** False for appender (fast) writes that must not be interrupted. */
interruptible: boolean;
path?: string;
error?: string;
/** Submission params, used to label a job by what it operates on. */
params?: Record<string, unknown>;
}
44 changes: 37 additions & 7 deletions chess-db/src/jobs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,9 @@ pub struct JobSnapshot {
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
/// The submission params, so the UI can label a job by what it touches (e.g.
/// which source/collection) and the scheduler can de-dupe by it.
pub params: serde_json::Value,
}

/// Whether a job uses DuckDB's appender path, which is not crash-safe: killing
Expand Down Expand Up @@ -300,6 +303,10 @@ struct JobState {
pub struct JobSlot {
id: String,
job_type: String,
/// The job's submission params (e.g. `{ "source": "ajedrez-otb" }`), kept so
/// the scheduler can de-dupe an auto-sync against an in-flight one and the
/// Activity view can label a job by what it operates on.
params: serde_json::Value,
interruptible: bool,
state: Mutex<JobState>,
events: broadcast::Sender<JobEvent>,
Expand All @@ -320,6 +327,7 @@ impl JobSlot {
interruptible: self.interruptible,
path: s.path.clone(),
error: s.error.clone(),
params: self.params.clone(),
}
}

Expand Down Expand Up @@ -407,6 +415,7 @@ impl JobManager {
let slot = Arc::new(JobSlot {
id: id.clone(),
job_type: job_type.clone(),
params: params.clone(),
interruptible: !uses_appender(&job_type, &params),
state: Mutex::new(JobState {
status: "queued".into(),
Expand Down Expand Up @@ -619,13 +628,34 @@ fn run_job(
let dir = crate::source_dir(source_key);
std::fs::create_dir_all(&dir)?;
let step = reporter.sub_step();
reporter.log(format!("{}: download", src.name));
rt.block_on(crate::sources::download_feed(conn, src, None, None, &dir, &step))?;
if reporter.is_cancelled() { return Ok(()); }
reporter.log(format!("{}: import", src.name));
importer::import(conn, &dir, src.key, src.collection, Some(40), 10, fast, false, &step)?;
crate::sources::record_run(conn, src.key, "ok")?;
reporter.done(format!("{} synced.", src.name));
let sync = (|| -> Result<()> {
reporter.log(format!("{}: download", src.name));
rt.block_on(crate::sources::download_feed(conn, src, None, None, &dir, &step))?;
if reporter.is_cancelled() { return Ok(()); }
reporter.log(format!("{}: import", src.name));
importer::import(conn, &dir, src.key, src.collection, Some(40), 10, fast, false, &step)?;
Ok(())
})();
// Record the run's outcome so the enable→auto-sync scheduler doesn't
// re-fire a source that finished or failed (a still-NULL last_run is
// what marks a source as "never synced"). A user cancellation also
// records, so it isn't auto-restarted; only a crash/restart mid-sync
// leaves last_run NULL, so an interrupted sync resumes. Errors still
// propagate (via `?`) so a DuckDB invalidation triggers recovery (#82).
if reporter.is_cancelled() {
let _ = crate::sources::record_run(conn, src.key, "cancelled");
return Ok(());
}
match sync {
Ok(()) => {
crate::sources::record_run(conn, src.key, "ok")?;
reporter.done(format!("{} synced.", src.name));
}
Err(e) => {
let _ = crate::sources::record_run(conn, src.key, &format!("error: {e}"));
return Err(e);
}
}
}
"import_pgn" => {
let path = path_param(p, "path")?;
Expand Down
Loading
Loading