{formatOptionalDateTime(metrics.oldest_lease_expires_at)}
@@ -388,17 +395,7 @@ function HistoryRow({
{formatDuration(result.duration_ms)} |
-
- {result.status}
-
+
|
{result.error_message || "—"}
diff --git a/web/src/pages/AdminTasks.tsx b/web/src/pages/AdminTasks.tsx
index 33262de0..f3580d6e 100644
--- a/web/src/pages/AdminTasks.tsx
+++ b/web/src/pages/AdminTasks.tsx
@@ -1,8 +1,10 @@
+import { useEffect, useRef, useState } from "react";
import { Link } from "react-router";
import { useEventChannel } from "@/components/realtimeEventsContext";
-import { AlertTriangle, Play, Square } from "lucide-react";
+import { AlertTriangle, Loader2, Play, Square } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
+import { TaskStatusBadge } from "@/components/admin/TaskStatusBadge";
import {
useTasks,
useRunTask,
@@ -10,9 +12,12 @@ import {
useTaskMetrics,
type MetadataRefreshMetrics,
} from "@/hooks/queries/admin/tasks";
+import { usePageActivity } from "@/hooks/usePageActivity";
+import { cn } from "@/lib/utils";
import type { TaskCategory, TaskInfo, TriggerConfig } from "@/api/types";
const CATEGORY_ORDER: TaskCategory[] = ["library", "metadata", "system"];
+const RUN_BUTTON_MIN_VISIBLE_MS = 1_000;
const CATEGORY_LABELS: Record = {
library: "Library",
@@ -27,8 +32,25 @@ const REFRESH_REASON_LABELS: Record = {
core_metadata_incomplete: "Core metadata incomplete",
};
-function formatRelativeTime(dateStr: string): string {
- const diff = Date.now() - new Date(dateStr).getTime();
+function useTaskClock() {
+ const pageActivity = usePageActivity();
+ const [now, setNow] = useState(() => Date.now());
+
+ useEffect(() => {
+ if (!pageActivity.canApplyRealtimeUpdates) {
+ return;
+ }
+
+ setNow(Date.now());
+ const id = window.setInterval(() => setNow(Date.now()), 30_000);
+ return () => window.clearInterval(id);
+ }, [pageActivity.canApplyRealtimeUpdates]);
+
+ return now;
+}
+
+function formatRelativeTime(dateStr: string, now: number): string {
+ const diff = Math.max(0, now - new Date(dateStr).getTime());
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return "just now";
const minutes = Math.floor(seconds / 60);
@@ -39,6 +61,45 @@ function formatRelativeTime(dateStr: string): string {
return `${days}d ago`;
}
+function formatDuration(ms: number): string {
+ if (ms <= 0) return "<1ms";
+ if (ms < 1000) return `${ms}ms`;
+ const totalSeconds = Math.floor(ms / 1000);
+ if (totalSeconds < 60) return `${totalSeconds}s`;
+ const minutes = Math.floor(totalSeconds / 60);
+ const remainSec = totalSeconds - minutes * 60;
+ if (minutes < 60) return `${minutes}m ${remainSec}s`;
+ const hours = Math.floor(minutes / 60);
+ const remainMinutes = minutes % 60;
+ return `${hours}h ${remainMinutes}m`;
+}
+
+function numberFromResultData(data: Record | undefined, key: string) {
+ const value = data?.[key];
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
+}
+
+function formatTaskResultSummary(task: TaskInfo): string | null {
+ const resultData = task.last_execution?.result_data;
+ if (!resultData || task.key !== "refresh_trending_discover") {
+ return null;
+ }
+
+ const combos = numberFromResultData(resultData, "combos");
+ const refreshed = numberFromResultData(resultData, "refreshed");
+ const empty = numberFromResultData(resultData, "empty");
+ const failed = numberFromResultData(resultData, "failed");
+ if (combos == null || refreshed == null || empty == null || failed == null) {
+ return null;
+ }
+
+ if (combos === 0) {
+ return "No enabled Trending Discover sections";
+ }
+
+ return `${refreshed} refreshed, ${empty} empty, ${failed} failed`;
+}
+
function describeTrigger(t: TriggerConfig): string {
switch (t.type) {
case "interval": {
@@ -66,12 +127,12 @@ function describeSchedule(triggers: TriggerConfig[]): string | null {
return triggers.map(describeTrigger).join(", ");
}
-function isOverdue(dateStr: string): boolean {
- return new Date(dateStr).getTime() < Date.now();
+function isOverdue(dateStr: string, now: number): boolean {
+ return new Date(dateStr).getTime() < now;
}
-function formatNextRun(dateStr: string): string {
- const diff = new Date(dateStr).getTime() - Date.now();
+function formatNextRun(dateStr: string, now: number): string {
+ const diff = new Date(dateStr).getTime() - now;
if (diff < 0) return "overdue";
const minutes = Math.floor(diff / 60_000);
if (minutes < 60) return `in ${minutes}m`;
@@ -86,6 +147,10 @@ function formatDateTime(dateStr?: string | null): string {
return new Date(dateStr).toLocaleString();
}
+function delay(ms: number) {
+ return new Promise((resolve) => window.setTimeout(resolve, ms));
+}
+
function RefreshMetadataSummary({ metrics }: { metrics: MetadataRefreshMetrics }) {
const topReasons = metrics.reason_counts.filter((entry) => entry.count > 0);
@@ -93,23 +158,23 @@ function RefreshMetadataSummary({ metrics }: { metrics: MetadataRefreshMetrics }
- Queue
+ Refresh Backlog
{metrics.total}
- Due now
+ Due for Refresh
{metrics.due}
- Leased
+ Processing
{metrics.leased}
- Oldest due
+ Waiting Since
{formatDateTime(metrics.oldest_due_at)}
- Oldest lease
+ Next Claim Timeout
{formatDateTime(metrics.oldest_lease_expires_at)}
@@ -132,14 +197,39 @@ function RefreshMetadataSummary({ metrics }: { metrics: MetadataRefreshMetrics }
function TaskRow({
task,
refreshMetrics,
+ now,
}: {
task: TaskInfo;
refreshMetrics?: MetadataRefreshMetrics;
+ now: number;
}) {
const runTask = useRunTask();
const cancelTask = useCancelTask();
+ const runFeedbackStartedAtRef = useRef (null);
+ const [isRunFeedbackVisible, setIsRunFeedbackVisible] = useState(false);
const isRunning = task.state === "running" || task.state === "cancelling";
+ const isShowingRunFeedback = isRunning || isRunFeedbackVisible;
+ const resultSummary = formatTaskResultSummary(task);
+
+ const handleRunTask = async () => {
+ runFeedbackStartedAtRef.current = Date.now();
+ setIsRunFeedbackVisible(true);
+
+ try {
+ await runTask.mutateAsync(task.key);
+ } finally {
+ const startedAt = runFeedbackStartedAtRef.current;
+ if (startedAt !== null) {
+ const remaining = RUN_BUTTON_MIN_VISIBLE_MS - (Date.now() - startedAt);
+ if (remaining > 0) {
+ await delay(remaining);
+ }
+ }
+ runFeedbackStartedAtRef.current = null;
+ setIsRunFeedbackVisible(false);
+ }
+ };
return (
@@ -158,20 +248,24 @@ function TaskRow({
{!describeSchedule(task.triggers) && No schedule}
{task.last_execution && (
- · Last run: {formatRelativeTime(task.last_execution.completed_at)}
+ · Last run: {formatRelativeTime(task.last_execution.completed_at, now)}
+ {typeof task.last_execution.duration_ms === "number"
+ ? ` · Duration: ${formatDuration(task.last_execution.duration_ms)}`
+ : ""}
+ {resultSummary ? ` · Result: ${resultSummary}` : ""}
)}
{!task.last_execution && !describeSchedule(task.triggers) && (
· Never run
)}
{task.next_run_at &&
- (isOverdue(task.next_run_at) ? (
+ (isOverdue(task.next_run_at, now) ? (
Overdue
) : (
- · Next: {formatNextRun(task.next_run_at)}
+ · Next: {formatNextRun(task.next_run_at, now)}
))}
>
)}
@@ -201,31 +295,41 @@ function TaskRow({
{task.state === "idle" && task.last_execution && (
-
- {task.last_execution.status}
-
+
)}
- {isRunning ? (
+ {isShowingRunFeedback ? (
) : (
|