feat(kanban): show dashboard cron jobs across profiles

Salvages #27568 by @SerenityTn. Dashboard cron page now lists cron
jobs from all profiles, with profile-aware filter UI and storage
routing. Includes test coverage for cross-profile listing, mutation,
deletion, and validation.

Also fixes orphan conflict markers in config.py left by an earlier
salvage merge (kanban.dispatch_stale_timeout_seconds was double-nested
in HEAD/PR markers from #28452 salvage of #23790).
This commit is contained in:
SerenityTn 2026-05-18 21:26:39 -07:00 committed by Teknium
parent 264e85b3dd
commit 1a5172742e
4 changed files with 416 additions and 62 deletions

View file

@ -138,21 +138,22 @@ export const api = {
},
// Cron jobs
getCronJobs: () => fetchJSON<CronJob[]>("/api/cron/jobs"),
createCronJob: (job: { prompt: string; schedule: string; name?: string; deliver?: string }) =>
fetchJSON<CronJob>("/api/cron/jobs", {
getCronJobs: (profile = "all") =>
fetchJSON<CronJob[]>(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`),
createCronJob: (job: { prompt: string; schedule: string; name?: string; deliver?: string }, profile = "default") =>
fetchJSON<CronJob>(`/api/cron/jobs?profile=${encodeURIComponent(profile)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(job),
}),
pauseCronJob: (id: string) =>
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/pause`, { method: "POST" }),
resumeCronJob: (id: string) =>
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/resume`, { method: "POST" }),
triggerCronJob: (id: string) =>
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/trigger`, { method: "POST" }),
deleteCronJob: (id: string) =>
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}`, { method: "DELETE" }),
pauseCronJob: (id: string, profile = "default") =>
fetchJSON<CronJob>(`/api/cron/jobs/${encodeURIComponent(id)}/pause?profile=${encodeURIComponent(profile)}`, { method: "POST" }),
resumeCronJob: (id: string, profile = "default") =>
fetchJSON<CronJob>(`/api/cron/jobs/${encodeURIComponent(id)}/resume?profile=${encodeURIComponent(profile)}`, { method: "POST" }),
triggerCronJob: (id: string, profile = "default") =>
fetchJSON<CronJob>(`/api/cron/jobs/${encodeURIComponent(id)}/trigger?profile=${encodeURIComponent(profile)}`, { method: "POST" }),
deleteCronJob: (id: string, profile = "default") =>
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${encodeURIComponent(id)}?profile=${encodeURIComponent(profile)}`, { method: "DELETE" }),
// Profiles (minimal)
getProfiles: () =>
@ -553,6 +554,10 @@ export interface ModelsAnalyticsResponse {
export interface CronJob {
id: string;
profile?: string | null;
profile_name?: string | null;
hermes_home?: string | null;
is_default_profile?: boolean;
name?: string | null;
prompt?: string | null;
script?: string | null;

View file

@ -6,7 +6,7 @@ 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 type { CronJob, ProfileInfo } from "@/lib/api";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import { useToast } from "@/hooks/useToast";
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
@ -69,6 +69,24 @@ function getJobState(job: CronJob): string {
return asText(job.state) || (job.enabled === false ? "disabled" : "scheduled");
}
function getJobProfile(job: CronJob): string {
return asText(job.profile) || asText(job.profile_name) || "default";
}
function getJobKey(job: CronJob): string {
return `${getJobProfile(job)}:${job.id}`;
}
function splitJobKey(key: string): { profile: string; id: string } {
const idx = key.indexOf(":");
if (idx === -1) return { profile: "default", id: key };
return { profile: key.slice(0, idx) || "default", id: key.slice(idx + 1) };
}
function profileLabel(profile: string): string {
return profile === "default" ? "default" : profile;
}
const STATUS_TONE: Record<string, "success" | "warning" | "destructive"> = {
enabled: "success",
scheduled: "success",
@ -79,6 +97,8 @@ const STATUS_TONE: Record<string, "success" | "warning" | "destructive"> = {
export default function CronPage() {
const [jobs, setJobs] = useState<CronJob[]>([]);
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
const [selectedProfile, setSelectedProfile] = useState("all");
const [loading, setLoading] = useState(true);
const { toast, showToast } = useToast();
const { t } = useI18n();
@ -96,14 +116,22 @@ export default function CronPage() {
});
const [deliver, setDeliver] = useState("local");
const [creating, setCreating] = useState(false);
const createProfile = selectedProfile === "all" ? "default" : selectedProfile;
const loadJobs = useCallback(() => {
api
.getCronJobs()
.getCronJobs(selectedProfile)
.then(setJobs)
.catch(() => showToast(t.common.loading, "error"))
.finally(() => setLoading(false));
}, [showToast, t.common.loading]);
}, [selectedProfile, showToast, t.common.loading]);
useEffect(() => {
api
.getProfiles()
.then((res) => setProfiles(res.profiles))
.catch(() => setProfiles([]));
}, []);
useEffect(() => {
loadJobs();
@ -116,12 +144,15 @@ export default function CronPage() {
}
setCreating(true);
try {
await api.createCronJob({
prompt: prompt.trim(),
schedule: schedule.trim(),
name: name.trim() || undefined,
deliver,
});
await api.createCronJob(
{
prompt: prompt.trim(),
schedule: schedule.trim(),
name: name.trim() || undefined,
deliver,
},
createProfile,
);
showToast(t.common.create + " ✓", "success");
setPrompt("");
setSchedule("");
@ -139,14 +170,15 @@ export default function CronPage() {
const handlePauseResume = async (job: CronJob) => {
try {
const isPaused = getJobState(job) === "paused";
const profile = getJobProfile(job);
if (isPaused) {
await api.resumeCronJob(job.id);
await api.resumeCronJob(job.id, profile);
showToast(
`${t.cron.resume}: "${truncateText(getJobTitle(job), 30)}"`,
"success",
);
} else {
await api.pauseCronJob(job.id);
await api.pauseCronJob(job.id, profile);
showToast(
`${t.cron.pause}: "${truncateText(getJobTitle(job), 30)}"`,
"success",
@ -160,7 +192,7 @@ export default function CronPage() {
const handleTrigger = async (job: CronJob) => {
try {
await api.triggerCronJob(job.id);
await api.triggerCronJob(job.id, getJobProfile(job));
showToast(
`${t.cron.triggerNow}: "${truncateText(getJobTitle(job), 30)}"`,
"success",
@ -173,10 +205,11 @@ export default function CronPage() {
const jobDelete = useConfirmDelete({
onDelete: useCallback(
async (id: string) => {
const job = jobs.find((j) => j.id === id);
async (key: string) => {
const { profile, id } = splitJobKey(key);
const job = jobs.find((j) => getJobKey(j) === key);
try {
await api.deleteCronJob(id);
await api.deleteCronJob(id, profile);
showToast(
`${t.common.delete}: "${job ? truncateText(getJobTitle(job), 30) : id}"`,
"success",
@ -216,7 +249,7 @@ export default function CronPage() {
}
const pendingJob = jobDelete.pendingId
? jobs.find((j) => j.id === jobDelete.pendingId)
? jobs.find((j) => getJobKey(j) === jobDelete.pendingId)
: null;
return (
@ -270,6 +303,21 @@ export default function CronPage() {
</header>
<div className="p-5 grid gap-4">
<div className="grid gap-2">
<Label htmlFor="cron-profile">Profile</Label>
<Select
id="cron-profile"
value={createProfile}
onValueChange={(v) => setSelectedProfile(v)}
>
{profiles.map((profile) => (
<SelectOption key={profile.name} value={profile.name}>
{profileLabel(profile.name)}
</SelectOption>
))}
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="cron-name">{t.cron.nameOptional}</Label>
<Input
@ -345,13 +393,31 @@ export default function CronPage() {
)}
<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>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<H2
variant="sm"
className="flex items-center gap-2 text-muted-foreground"
>
<Clock className="h-4 w-4" />
{t.cron.scheduledJobs} ({jobs.length})
</H2>
<div className="grid gap-1 min-w-[220px]">
<Label htmlFor="cron-profile-filter">Profile</Label>
<Select
id="cron-profile-filter"
value={selectedProfile}
onValueChange={(v) => setSelectedProfile(v)}
>
<SelectOption value="all">All profiles</SelectOption>
{profiles.map((profile) => (
<SelectOption key={profile.name} value={profile.name}>
{profileLabel(profile.name)}
</SelectOption>
))}
</Select>
</div>
</div>
{jobs.length === 0 && (
<Card>
@ -367,9 +433,11 @@ export default function CronPage() {
const title = getJobTitle(job);
const hasName = Boolean(getJobName(job));
const deliver = asText(job.deliver);
const profile = getJobProfile(job);
const jobKey = getJobKey(job);
return (
<Card key={job.id}>
<Card key={jobKey}>
<CardContent className="flex items-start gap-4 py-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
@ -379,6 +447,7 @@ export default function CronPage() {
<Badge tone={STATUS_TONE[state] ?? "secondary"}>
{state}
</Badge>
<Badge tone="outline">{profileLabel(profile)}</Badge>
{deliver && deliver !== "local" && (
<Badge tone="outline">{deliver}</Badge>
)}
@ -436,7 +505,7 @@ export default function CronPage() {
size="icon"
title={t.common.delete}
aria-label={t.common.delete}
onClick={() => jobDelete.requestDelete(job.id)}
onClick={() => jobDelete.requestDelete(jobKey)}
>
<Trash2 />
</Button>