mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-20 05:01:30 +00:00
Dashboard UX polish pass — consolidates create forms into modals triggered from the page header, fixes layout inconsistencies, adds scroll-to navigation for the Keys page, and aligns the TokenBar with the design system. Changes: - App.tsx: add padding to sidebar header - resolve-page-title.ts: add missing routes, better fallback title - en.ts: fix nav labels (Profiles was 'profiles : multi agents') - ModelsPage: two-col layout, auxiliary tasks modal, TokenBar redesign - ProfilesPage: create button in header, form in modal, Checkbox component - CronPage: create button in header, form in modal - EnvPage: scroll-to sub-nav in header, fix text overflow Modal and dialog standardization: - Replace all native confirm()/window.confirm() with ConfirmDialog (OAuthProvidersCard, PluginsPage, ModelsPage, ConfigPage) - Add useModalBehavior hook (Escape-to-close, scroll lock, focus restore) - Apply hook to ProfilesPage, CronPage, AuxiliaryTasksModal Component fixes (from PR review): - Checkbox: fix controlled/uncontrolled mismatch, add focus-visible ring - TokenBar: add rounded-full to legend dots, remove dead code CI/test fixes: - Fix TS unused imports (noUnusedLocals), type-narrow PickerTarget union - Add windows-footgun suppression on platform-guarded os.killpg - Fix 19 stale unit tests + 9 e2e tests broken by recent main changes - Restore minimal example-dashboard plugin for plugin auth test
453 lines
14 KiB
TypeScript
453 lines
14 KiB
TypeScript
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
|
|
import { Clock, Pause, Play, Plus, Trash2, X, Zap } from "lucide-react";
|
|
import { Badge } from "@nous-research/ui/ui/components/badge";
|
|
import { Button } from "@nous-research/ui/ui/components/button";
|
|
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
|
|
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
|
import { H2 } from "@/components/NouiTypography";
|
|
import { api } from "@/lib/api";
|
|
import type { CronJob } from "@/lib/api";
|
|
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
|
import { useToast } from "@/hooks/useToast";
|
|
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
|
|
import { useModalBehavior } from "@/hooks/useModalBehavior";
|
|
import { Toast } from "@/components/Toast";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { useI18n } from "@/i18n";
|
|
import { usePageHeader } from "@/contexts/usePageHeader";
|
|
import { PluginSlot } from "@/plugins";
|
|
|
|
function formatTime(iso?: string | null): string {
|
|
if (!iso) return "—";
|
|
const d = new Date(iso);
|
|
return d.toLocaleString();
|
|
}
|
|
|
|
function asText(value: unknown): string {
|
|
return typeof value === "string" ? value : "";
|
|
}
|
|
|
|
function truncateText(value: string, maxLength: number): string {
|
|
return value.length > maxLength
|
|
? value.slice(0, maxLength) + "..."
|
|
: value;
|
|
}
|
|
|
|
function getJobPrompt(job: CronJob): string {
|
|
return asText(job.prompt);
|
|
}
|
|
|
|
function getJobName(job: CronJob): string {
|
|
return asText(job.name).trim();
|
|
}
|
|
|
|
function getJobTitle(job: CronJob): string {
|
|
const name = getJobName(job);
|
|
if (name) return name;
|
|
|
|
const prompt = getJobPrompt(job);
|
|
if (prompt) return truncateText(prompt, 60);
|
|
|
|
const script = asText(job.script);
|
|
if (script) return truncateText(script, 60);
|
|
|
|
return job.id || "Cron job";
|
|
}
|
|
|
|
function getJobScheduleDisplay(job: CronJob): string {
|
|
return (
|
|
asText(job.schedule_display) ||
|
|
asText(job.schedule?.display) ||
|
|
asText(job.schedule?.expr) ||
|
|
"—"
|
|
);
|
|
}
|
|
|
|
function getJobState(job: CronJob): string {
|
|
return asText(job.state) || (job.enabled === false ? "disabled" : "scheduled");
|
|
}
|
|
|
|
const STATUS_TONE: Record<string, "success" | "warning" | "destructive"> = {
|
|
enabled: "success",
|
|
scheduled: "success",
|
|
paused: "warning",
|
|
error: "destructive",
|
|
completed: "destructive",
|
|
};
|
|
|
|
export default function CronPage() {
|
|
const [jobs, setJobs] = useState<CronJob[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const { toast, showToast } = useToast();
|
|
const { t } = useI18n();
|
|
const { setEnd } = usePageHeader();
|
|
|
|
// New job modal state
|
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
|
const [prompt, setPrompt] = useState("");
|
|
const [schedule, setSchedule] = useState("");
|
|
const [name, setName] = useState("");
|
|
const closeCreateModal = useCallback(() => setCreateModalOpen(false), []);
|
|
const createModalRef = useModalBehavior({
|
|
open: createModalOpen,
|
|
onClose: closeCreateModal,
|
|
});
|
|
const [deliver, setDeliver] = useState("local");
|
|
const [creating, setCreating] = useState(false);
|
|
|
|
const loadJobs = useCallback(() => {
|
|
api
|
|
.getCronJobs()
|
|
.then(setJobs)
|
|
.catch(() => showToast(t.common.loading, "error"))
|
|
.finally(() => setLoading(false));
|
|
}, [showToast, t.common.loading]);
|
|
|
|
useEffect(() => {
|
|
loadJobs();
|
|
}, [loadJobs]);
|
|
|
|
const handleCreate = async () => {
|
|
if (!prompt.trim() || !schedule.trim()) {
|
|
showToast(`${t.cron.prompt} & ${t.cron.schedule} required`, "error");
|
|
return;
|
|
}
|
|
setCreating(true);
|
|
try {
|
|
await api.createCronJob({
|
|
prompt: prompt.trim(),
|
|
schedule: schedule.trim(),
|
|
name: name.trim() || undefined,
|
|
deliver,
|
|
});
|
|
showToast(t.common.create + " ✓", "success");
|
|
setPrompt("");
|
|
setSchedule("");
|
|
setName("");
|
|
setDeliver("local");
|
|
setCreateModalOpen(false);
|
|
loadJobs();
|
|
} catch (e) {
|
|
showToast(`${t.config.failedToSave}: ${e}`, "error");
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
};
|
|
|
|
const handlePauseResume = async (job: CronJob) => {
|
|
try {
|
|
const isPaused = getJobState(job) === "paused";
|
|
if (isPaused) {
|
|
await api.resumeCronJob(job.id);
|
|
showToast(
|
|
`${t.cron.resume}: "${truncateText(getJobTitle(job), 30)}"`,
|
|
"success",
|
|
);
|
|
} else {
|
|
await api.pauseCronJob(job.id);
|
|
showToast(
|
|
`${t.cron.pause}: "${truncateText(getJobTitle(job), 30)}"`,
|
|
"success",
|
|
);
|
|
}
|
|
loadJobs();
|
|
} catch (e) {
|
|
showToast(`${t.status.error}: ${e}`, "error");
|
|
}
|
|
};
|
|
|
|
const handleTrigger = async (job: CronJob) => {
|
|
try {
|
|
await api.triggerCronJob(job.id);
|
|
showToast(
|
|
`${t.cron.triggerNow}: "${truncateText(getJobTitle(job), 30)}"`,
|
|
"success",
|
|
);
|
|
loadJobs();
|
|
} catch (e) {
|
|
showToast(`${t.status.error}: ${e}`, "error");
|
|
}
|
|
};
|
|
|
|
const jobDelete = useConfirmDelete({
|
|
onDelete: useCallback(
|
|
async (id: string) => {
|
|
const job = jobs.find((j) => j.id === id);
|
|
try {
|
|
await api.deleteCronJob(id);
|
|
showToast(
|
|
`${t.common.delete}: "${job ? truncateText(getJobTitle(job), 30) : id}"`,
|
|
"success",
|
|
);
|
|
loadJobs();
|
|
} catch (e) {
|
|
showToast(`${t.status.error}: ${e}`, "error");
|
|
throw e;
|
|
}
|
|
},
|
|
[jobs, loadJobs, showToast, t.common.delete, t.status.error],
|
|
),
|
|
});
|
|
|
|
// Put "Create" button in page header
|
|
useLayoutEffect(() => {
|
|
setEnd(
|
|
<Button
|
|
size="sm"
|
|
onClick={() => setCreateModalOpen(true)}
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
{t.common.create}
|
|
</Button>,
|
|
);
|
|
return () => {
|
|
setEnd(null);
|
|
};
|
|
}, [setEnd, t.common.create, loading]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-24">
|
|
<Spinner className="text-2xl text-primary" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const pendingJob = jobDelete.pendingId
|
|
? jobs.find((j) => j.id === jobDelete.pendingId)
|
|
: null;
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
<PluginSlot name="cron:top" />
|
|
<Toast toast={toast} />
|
|
|
|
<DeleteConfirmDialog
|
|
open={jobDelete.isOpen}
|
|
onCancel={jobDelete.cancel}
|
|
onConfirm={jobDelete.confirm}
|
|
title={t.cron.confirmDeleteTitle}
|
|
description={
|
|
pendingJob
|
|
? `"${truncateText(getJobTitle(pendingJob), 40)}" — ${
|
|
t.cron.confirmDeleteMessage
|
|
}`
|
|
: t.cron.confirmDeleteMessage
|
|
}
|
|
loading={jobDelete.isDeleting}
|
|
/>
|
|
|
|
{/* Create job modal */}
|
|
{createModalOpen && (
|
|
<div
|
|
ref={createModalRef}
|
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
|
onClick={(e) => e.target === e.currentTarget && setCreateModalOpen(false)}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="create-cron-title"
|
|
>
|
|
<div className="relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col">
|
|
<Button
|
|
ghost
|
|
size="icon"
|
|
onClick={() => setCreateModalOpen(false)}
|
|
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
|
|
aria-label="Close"
|
|
>
|
|
<X />
|
|
</Button>
|
|
|
|
<header className="p-5 pb-3 border-b border-border">
|
|
<h2
|
|
id="create-cron-title"
|
|
className="font-display text-base tracking-wider uppercase"
|
|
>
|
|
{t.cron.newJob}
|
|
</h2>
|
|
</header>
|
|
|
|
<div className="p-5 grid gap-4">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="cron-name">{t.cron.nameOptional}</Label>
|
|
<Input
|
|
id="cron-name"
|
|
autoFocus
|
|
placeholder={t.cron.namePlaceholder}
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="cron-prompt">{t.cron.prompt}</Label>
|
|
<textarea
|
|
id="cron-prompt"
|
|
className="flex min-h-[80px] w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
|
|
placeholder={t.cron.promptPlaceholder}
|
|
value={prompt}
|
|
onChange={(e) => setPrompt(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="cron-schedule">{t.cron.schedule}</Label>
|
|
<Input
|
|
id="cron-schedule"
|
|
placeholder={t.cron.schedulePlaceholder}
|
|
value={schedule}
|
|
onChange={(e) => setSchedule(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="cron-deliver">{t.cron.deliverTo}</Label>
|
|
<Select
|
|
id="cron-deliver"
|
|
value={deliver}
|
|
onValueChange={(v) => setDeliver(v)}
|
|
>
|
|
<SelectOption value="local">
|
|
{t.cron.delivery.local}
|
|
</SelectOption>
|
|
<SelectOption value="telegram">
|
|
{t.cron.delivery.telegram}
|
|
</SelectOption>
|
|
<SelectOption value="discord">
|
|
{t.cron.delivery.discord}
|
|
</SelectOption>
|
|
<SelectOption value="slack">
|
|
{t.cron.delivery.slack}
|
|
</SelectOption>
|
|
<SelectOption value="email">
|
|
{t.cron.delivery.email}
|
|
</SelectOption>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end">
|
|
<Button
|
|
size="sm"
|
|
onClick={handleCreate}
|
|
disabled={creating}
|
|
prefix={creating ? <Spinner /> : <Plus />}
|
|
>
|
|
{creating ? t.common.creating : t.common.create}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-col gap-3">
|
|
<H2
|
|
variant="sm"
|
|
className="flex items-center gap-2 text-muted-foreground"
|
|
>
|
|
<Clock className="h-4 w-4" />
|
|
{t.cron.scheduledJobs} ({jobs.length})
|
|
</H2>
|
|
|
|
{jobs.length === 0 && (
|
|
<Card>
|
|
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
|
{t.cron.noJobs}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{jobs.map((job) => {
|
|
const state = getJobState(job);
|
|
const promptText = getJobPrompt(job);
|
|
const title = getJobTitle(job);
|
|
const hasName = Boolean(getJobName(job));
|
|
const deliver = asText(job.deliver);
|
|
|
|
return (
|
|
<Card key={job.id}>
|
|
<CardContent className="flex items-center gap-4 py-4">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className="font-medium text-sm truncate">
|
|
{title}
|
|
</span>
|
|
<Badge tone={STATUS_TONE[state] ?? "secondary"}>
|
|
{state}
|
|
</Badge>
|
|
{deliver && deliver !== "local" && (
|
|
<Badge tone="outline">{deliver}</Badge>
|
|
)}
|
|
</div>
|
|
{hasName && promptText && (
|
|
<p className="text-xs text-muted-foreground truncate mb-1">
|
|
{truncateText(promptText, 100)}
|
|
</p>
|
|
)}
|
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
<span className="font-mono">{getJobScheduleDisplay(job)}</span>
|
|
<span>
|
|
{t.cron.last}: {formatTime(job.last_run_at)}
|
|
</span>
|
|
<span>
|
|
{t.cron.next}: {formatTime(job.next_run_at)}
|
|
</span>
|
|
</div>
|
|
{job.last_error && (
|
|
<p className="text-xs text-destructive mt-1">
|
|
{job.last_error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
<Button
|
|
ghost
|
|
size="icon"
|
|
title={state === "paused" ? t.cron.resume : t.cron.pause}
|
|
aria-label={
|
|
state === "paused" ? t.cron.resume : t.cron.pause
|
|
}
|
|
onClick={() => handlePauseResume(job)}
|
|
className={
|
|
state === "paused" ? "text-success" : "text-warning"
|
|
}
|
|
>
|
|
{state === "paused" ? <Play /> : <Pause />}
|
|
</Button>
|
|
|
|
<Button
|
|
ghost
|
|
size="icon"
|
|
title={t.cron.triggerNow}
|
|
aria-label={t.cron.triggerNow}
|
|
onClick={() => handleTrigger(job)}
|
|
>
|
|
<Zap />
|
|
</Button>
|
|
|
|
<Button
|
|
ghost
|
|
destructive
|
|
size="icon"
|
|
title={t.common.delete}
|
|
aria-label={t.common.delete}
|
|
onClick={() => jobDelete.requestDelete(job.id)}
|
|
>
|
|
<Trash2 />
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<PluginSlot name="cron:bottom" />
|
|
</div>
|
|
);
|
|
}
|