Files
kandev/apps/web/components/settings/system/job-progress-indicator.tsx
T
José Almeida 041a19f060 feat: service-managed UI self-update (#1210)
* feat: add service-managed self-update

* chore: add temporary self-update test scripts

* chore: simplify temporary script markdown fences

* chore: run frontend in temporary self-update scripts

* fix: show self-update job progress after apply

* chore: add real npm self-update test script

* fix: isolate npm self-update prefix

* fix: detect npm service installs from bin shim

* chore: reset service before real npm update test

* chore: expand real npm status diagnostics

* fix: preserve service home during self-update

* fix(cli): tee self-update helper output to a log file

* fix(cli): avoid launchd bootstrap race when reloading the macOS service

* fix(backend): run macOS self-update helper once via transient launchd plist

* fix(web): show self-update progress and lock the apply button until the update lands

* fix(cli): reinstall via the kandev wrapper on Homebrew self-update

* chore: remove temp self-update test scripts

* fix(web): drop unused apply path from useUpdates hook

* fix(backend): snapshot install state once in apply preflight

* docs: align self-update docs with transient LaunchAgent flow

* chore(release): run cli bundle from apps workspace root

* fix(backend): guard against concurrent self-update applies

* fix(backend): check scheme and helper paths in self-update apply

* fix(backend): drop duplicate binary name from npx manual command

* fix(cli): allow --help for service self-update without --intent

* fix(cli): harden self-update dir perms and darwin uid resolution

* fix(web): set required POST fields last in applyUpdate

* docs: use JSON body notation for the apply endpoint

* fix(backend): expire self-update guard so a stuck helper cannot wedge apply

* refactor(cli): collapse redundant writeSync branch in self-update log
2026-06-01 17:49:15 +01:00

84 lines
2.6 KiB
TypeScript

"use client";
import { Badge } from "@kandev/ui/badge";
import { Spinner } from "@kandev/ui/spinner";
import { IconCheck, IconAlertTriangle } from "@tabler/icons-react";
import { useSystemJob, useSystemJobs } from "@/hooks/domains/system/use-system-jobs";
import type { SystemJob, SystemJobKind } from "@/lib/types/system";
type JobProgressIndicatorProps = {
kind: SystemJobKind;
/** When provided, only the matching job id is rendered (ignores all others). */
jobId?: string;
/** Label shown when the job has succeeded. */
successLabel?: string;
/** Testid suffix; default "system-job-<kind>". */
testId?: string;
};
function pickJob(jobs: SystemJob[], jobId?: string): SystemJob | null {
if (!jobs.length) return null;
if (jobId) return jobs.find((j) => j.id === jobId) ?? null;
// Latest by started_at (fall back to insertion order).
const sorted = [...jobs].sort((a, b) => {
const at = Date.parse(a.started_at) || 0;
const bt = Date.parse(b.started_at) || 0;
return bt - at;
});
return sorted[0] ?? null;
}
function badgeVariant(state: SystemJob["state"]): "destructive" | "secondary" | "outline" {
if (state === "failed") return "destructive";
if (state === "succeeded") return "secondary";
return "outline";
}
function stateLabel(state: SystemJob["state"]): string {
switch (state) {
case "queued":
return "Queued";
case "running":
return "Running";
case "succeeded":
return "Done";
case "failed":
return "Failed";
default:
return state;
}
}
export function JobProgressIndicator({
kind,
jobId,
successLabel,
testId,
}: JobProgressIndicatorProps) {
const pinnedJob = useSystemJob(jobId);
const jobs = useSystemJobs(kind);
const job = jobId ? (pinnedJob ?? pickJob(jobs, jobId)) : pickJob(jobs);
if (!job) return null;
const tid = testId ?? `system-job-${kind}`;
const isActive = job.state === "queued" || job.state === "running";
const isSuccess = job.state === "succeeded";
const isFailed = job.state === "failed";
return (
<div
className="inline-flex items-center gap-2 text-xs text-muted-foreground"
data-testid={tid}
data-state={job.state}
>
{isActive && <Spinner className="size-3.5" />}
{isSuccess && <IconCheck className="size-3.5 text-emerald-500" />}
{isFailed && <IconAlertTriangle className="size-3.5 text-red-500" />}
<Badge variant={badgeVariant(job.state)} className="text-[10px]">
{isSuccess && successLabel ? successLabel : stateLabel(job.state)}
</Badge>
{job.message && <span className="truncate max-w-[24rem]">{job.message}</span>}
</div>
);
}