Files
fusion/packages/dashboard/app/components/RoutineCard.tsx
gsxdsm dc0064bd15 feat(dashboard): right-dock inline tools, left-sidebar views, embedded-view polish
- Right dock: Files first/default; usage/activity-log/git-manager render inline; embedded Git Manager uses a container query (compact horizontal tab strip in the dock, full two-pane in the wide pop-out); Files inline viewer + pop-out.
- Import Tasks layout fits its container (stacked when narrow, two-pane when wide); Import Tasks uses the GitHub mark.
- Planning embeds full-area and works on mobile; planning shows the board WorkflowSwitcher.
- Automations screen uses theme color tokens.
- Workflow selector matches the project selector height/font.
- Left sidebar: uniform spacing across the primary/secondary boundary.

Adds a changeset for @runfusion/fusion (minor).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 03:34:15 -07:00

342 lines
14 KiB
TypeScript

import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Play, Pause, Pencil, Trash2, Clock, CheckCircle, XCircle, ChevronDown, ChevronUp, Calendar, Webhook, Code, Zap, Globe, Folder, Layers, Loader2 } from "lucide-react";
import type { Routine, RoutineExecutionResult, RoutineTriggerType, RoutineCatchUpPolicy, RoutineExecutionPolicy } from "@fusion/core";
import { useConfirm } from "../hooks/useConfirm";
/**
* Format a duration in milliseconds to a human-readable string.
*/
function formatDurationMs(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes < 60) return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
}
/**
* Format an ISO timestamp to a relative time string.
*
* FNXC:RelativeTime 2026-06-17-20:48:
* FN-6618 deliberately keeps RoutineCard separate from getRelativeTimeBucket because scheduled routines expose future-time copy (`in a moment`, `in Xm`, `in Xh`, `in Xd`) that the shared helper represents as null.
*/
function relativeTime(iso: string): string {
const now = Date.now();
const then = new Date(iso).getTime();
const diffMs = now - then;
// Future
if (diffMs < 0) {
const absDiff = Math.abs(diffMs);
if (absDiff < 60_000) return "in a moment";
if (absDiff < 3_600_000) return `in ${Math.floor(absDiff / 60_000)}m`;
if (absDiff < 86_400_000) return `in ${Math.floor(absDiff / 3_600_000)}h`;
return `in ${Math.floor(absDiff / 86_400_000)}d`;
}
// Past
if (diffMs < 60_000) return "just now";
if (diffMs < 3_600_000) return `${Math.floor(diffMs / 60_000)}m ago`;
if (diffMs < 86_400_000) return `${Math.floor(diffMs / 3_600_000)}h ago`;
return `${Math.floor(diffMs / 86_400_000)}d ago`;
}
/*
FNXC:Automations 2026-06-22-12:00:
Trigger-type badge colors must use the design system's THEME COLOR TOKENS so the Automations screen follows the
active theme (including light theme) instead of fixed hex literals. The previous values referenced undefined tokens
(--color-blue/-purple/-green/-gray) with hardcoded hex fallbacks that never resolved to a real token and never
adapted to the theme. Mapped to the closest defined semantic tokens from styles.css: cron→--todo (blue status),
webhook→--accent (brand purple), api→--color-success (green), manual→--text-muted (neutral). The badge applies this
to both border and text via inline style on .routine-trigger-badge.
*/
const TRIGGER_TYPE_COLORS: Record<RoutineTriggerType, string> = {
cron: "var(--todo)",
webhook: "var(--accent)",
api: "var(--color-success)",
manual: "var(--text-muted)",
};
const TRIGGER_TYPE_LABELS: Record<RoutineTriggerType, string> = {
cron: "Cron",
webhook: "Webhook",
api: "API",
manual: "Manual",
};
const TRIGGER_TYPE_ICONS: Record<RoutineTriggerType, React.ComponentType<{ size: number }>> = {
cron: Calendar,
webhook: Webhook,
api: Code,
manual: Zap,
};
const EXECUTION_POLICY_LABELS: Record<RoutineExecutionPolicy, string> = {
parallel: "Concurrent",
queue: "Queued",
reject: "Exclusive",
};
const CATCH_UP_POLICY_LABELS: Record<RoutineCatchUpPolicy, string> = {
run: "Catch up",
skip: "Skip missed",
run_one: "Catch up (latest)",
};
interface RoutineCardProps {
routine: Routine;
onEdit: (routine: Routine) => void;
onDelete: (routine: Routine) => void;
onRun: (routine: Routine) => void;
onToggle: (routine: Routine) => void;
/** Whether a manual run is currently in progress. */
running?: boolean;
/** Latest manual-run output shown inline until refreshed routine data catches up. */
lastRunOutput?: { output: string; error?: string; success: boolean } | null;
}
function RunResultBadge({ result }: { result: RoutineExecutionResult }) {
const { t } = useTranslation("app");
const duration = result.completedAt && result.startedAt
? new Date(result.completedAt).getTime() - new Date(result.startedAt).getTime()
: 0;
return (
<span className={`schedule-run-badge ${result.success ? "success" : "failure"}`}>
{result.success ? (
<CheckCircle size={12} />
) : (
<XCircle size={12} />
)}
<span>{result.success ? t("routine.resultSuccess", "Success") : t("routine.resultFailed", "Failed")}</span>
{duration > 0 && (
<span className="schedule-run-duration">{formatDurationMs(duration)}</span>
)}
</span>
);
}
function RunHistoryItem({ result, index }: { result: RoutineExecutionResult; index: number }) {
const [expanded, setExpanded] = useState(false);
const duration = result.completedAt && result.startedAt
? new Date(result.completedAt).getTime() - new Date(result.startedAt).getTime()
: 0;
return (
<div className="schedule-history-item">
<button
className="schedule-history-header"
onClick={() => setExpanded(!expanded)}
aria-expanded={expanded}
aria-label={`Run #${index + 1}: ${result.success ? "succeeded" : "failed"} ${relativeTime(result.startedAt)}`}
>
<span className={`schedule-history-status ${result.success ? "success" : "failure"}`}>
{result.success ? <CheckCircle size={12} /> : <XCircle size={12} />}
</span>
<span className="schedule-history-time">{relativeTime(result.startedAt)}</span>
{result.triggerType && (
<span className="routine-history-trigger-type">{TRIGGER_TYPE_LABELS[result.triggerType]}</span>
)}
<span className="schedule-history-duration">{formatDurationMs(duration)}</span>
{expanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</button>
{expanded && (
<div className="schedule-history-detail">
{result.output && (
<pre className="schedule-history-output">{result.output}</pre>
)}
{result.error && (
<div className="schedule-history-error">{result.error}</div>
)}
</div>
)}
</div>
);
}
export function RoutineCard({ routine, onEdit, onDelete, onRun, onToggle, running, lastRunOutput }: RoutineCardProps) {
const { t } = useTranslation("app");
const [showHistory, setShowHistory] = useState(false);
const { confirm } = useConfirm();
const handleDelete = useCallback(async () => {
const shouldDelete = await confirm({
title: t("routine.deleteTitle", "Delete Routine"),
message: t("routine.deleteMessage", "Delete routine \"{{name}}\"? This cannot be undone.", { name: routine.name }),
danger: true,
});
if (shouldDelete) {
onDelete(routine);
}
}, [routine, onDelete, confirm, t]);
const triggerColor = TRIGGER_TYPE_COLORS[routine.trigger.type];
const TriggerIcon = TRIGGER_TYPE_ICONS[routine.trigger.type];
// Get cron expression if available (from trigger or direct field)
const cronExpression = routine.trigger.type === "cron"
? (("cronExpression" in routine.trigger ? routine.trigger.cronExpression : undefined) as string | undefined) || routine.cronExpression || ""
: routine.cronExpression || "";
return (
<div className={`routine-card${routine.enabled ? "" : " disabled"}${running ? " running" : ""}`}>
<div className="routine-card-header">
<div className="routine-card-info">
<div className="routine-card-name-row">
<span className="routine-card-name">{routine.name}</span>
<span
className="routine-trigger-badge"
style={{ borderColor: triggerColor, color: triggerColor }}
>
<TriggerIcon size={10} />
{TRIGGER_TYPE_LABELS[routine.trigger.type]}
</span>
{routine.scope && (
<span
className={`routine-scope-badge${routine.scope === "global" ? " global" : " project"}`}
title={`${routine.scope === "global" ? "Global" : "Project"}-scoped routine`}
>
{routine.scope === "global" ? <Globe size={10} /> : <Folder size={10} />}
{routine.scope}
</span>
)}
</div>
{routine.description && (
<p className="routine-card-description">{routine.description}</p>
)}
</div>
<div className="routine-card-actions">
<button
className="btn-icon"
onClick={() => onRun(routine)}
disabled={running}
title={running ? t("routine.running", "Running…") : t("routine.runNow", "Run now")}
aria-label={running ? t("routine.running", "Running…") : t("routine.runNameNow", "Run {{name}} now", { name: routine.name })}
>
{running ? <Loader2 className="spinner" /> : <Play />}
</button>
<button
className="btn-icon"
onClick={() => onToggle(routine)}
title={routine.enabled ? t("routine.disable", "Disable") : t("routine.enable", "Enable")}
aria-label={routine.enabled ? t("routine.disableName", "Disable {{name}}", { name: routine.name }) : t("routine.enableName", "Enable {{name}}", { name: routine.name })}
aria-pressed={routine.enabled}
>
{routine.enabled ? <Pause /> : <Play />}
</button>
<button
className="btn-icon"
onClick={() => onEdit(routine)}
title={t("routine.edit", "Edit")}
aria-label={t("routine.editName", "Edit {{name}}", { name: routine.name })}
>
<Pencil />
</button>
<button
className="btn-icon"
onClick={handleDelete}
title={t("routine.delete", "Delete")}
aria-label={t("routine.deleteName", "Delete {{name}}", { name: routine.name })}
>
<Trash2 />
</button>
</div>
</div>
<div className="routine-card-meta">
{routine.steps && routine.steps.length > 0 ? (
<div className="routine-meta-item">
<Layers size={12} />
<span className="routine-policy-badge">{t("routine.stepCount", "{{count}} step", { count: routine.steps.length, defaultValue_one: "{{count}} step", defaultValue_other: "{{count}} steps" })}</span>
</div>
) : routine.command ? (
<div className="routine-meta-item routine-meta-command-preview" title={routine.command}>
<code className="routine-cron">{routine.command}</code>
</div>
) : null}
{/* Cron expression for cron triggers */}
{routine.trigger.type === "cron" && cronExpression && (
<div className="routine-meta-item">
<Clock size={12} />
<code className="routine-cron">{cronExpression}</code>
</div>
)}
{/* Policy badges */}
<div className="routine-meta-item">
<span className="routine-policy-badge" title={`Execution policy: ${routine.executionPolicy}`}>
{EXECUTION_POLICY_LABELS[routine.executionPolicy]}
</span>
</div>
<div className="routine-meta-item">
<span className="routine-policy-badge" title={`Catch-up policy: ${routine.catchUpPolicy}`}>
{CATCH_UP_POLICY_LABELS[routine.catchUpPolicy]}
</span>
</div>
{/* Timing info */}
{routine.nextRunAt && routine.enabled && (
<div className="routine-meta-item">
<span className="routine-meta-label">{t("schedule.nextLabel", "Next:")}</span>
<span title={routine.nextRunAt}>{relativeTime(routine.nextRunAt)}</span>
</div>
)}
{routine.lastRunAt && (
<div className="routine-meta-item">
<span className="routine-meta-label">{t("schedule.lastLabel", "Last:")}</span>
<span title={routine.lastRunAt}>{relativeTime(routine.lastRunAt)}</span>
</div>
)}
{routine.lastRunResult && (
<RunResultBadge result={routine.lastRunResult} />
)}
<div className="routine-meta-item">
<span className="routine-meta-label">{t("schedule.runsLabel", "Runs:")}</span>
<span>{routine.runCount}</span>
</div>
</div>
{lastRunOutput && (
<div className={`routine-run-output ${lastRunOutput.success ? "success" : "failure"}`}>
{lastRunOutput.output && (
<pre className="routine-run-output-text">{lastRunOutput.output}</pre>
)}
{lastRunOutput.error && (
<div className="routine-run-output-error">{lastRunOutput.error}</div>
)}
</div>
)}
{routine.runHistory.length > 0 && (
<div className="routine-card-history">
<button
className="schedule-history-toggle"
onClick={() => setShowHistory((h) => !h)}
aria-expanded={showHistory}
>
{showHistory ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
<span>{t("routine.runHistory", "Run History ({{count}})", { count: routine.runHistory.length })}</span>
</button>
{showHistory && (
<div className="schedule-history-list">
{routine.runHistory.slice(0, 10).map((result, i) => (
<RunHistoryItem key={`${result.startedAt}-${i}`} result={result} index={i} />
))}
{routine.runHistory.length > 10 && (
<div className="schedule-history-more">
{t("routine.andMore", "…and {{count}} more", { count: routine.runHistory.length - 10 })}
</div>
)}
</div>
)}
</div>
)}
</div>
);
}