feat(dashboard): expose cron job execution fields
This commit is contained in:
parent
50f6855217
commit
c655cdf2c1
9 changed files with 1589 additions and 366 deletions
|
|
@ -6,7 +6,20 @@ import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
|
|||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
|
||||
import { api } from "@/lib/api";
|
||||
import type { CronJob, CronDeliveryTarget, ProfileInfo, SkillInfo } from "@/lib/api";
|
||||
import type {
|
||||
CronJob,
|
||||
CronDeliveryTarget,
|
||||
ModelOptionsResponse,
|
||||
ProfileInfo,
|
||||
SkillInfo,
|
||||
ToolsetInfo,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
buildCronJobPayload,
|
||||
cronJobHasExecutionContent,
|
||||
cronJobFormFromJob,
|
||||
type CronJobFormState,
|
||||
} from "@/lib/cron-job";
|
||||
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
||||
import {
|
||||
DEFAULT_SCHEDULE_STATE,
|
||||
|
|
@ -16,6 +29,7 @@ import {
|
|||
buildScheduleString,
|
||||
describeSchedule,
|
||||
englishOrdinal,
|
||||
parseScheduleString,
|
||||
type ScheduleBuilderState,
|
||||
type ScheduleDescribeStrings,
|
||||
} from "@/lib/schedule";
|
||||
|
|
@ -53,14 +67,7 @@ function getJobPrompt(job: CronJob): string {
|
|||
return asText(job.prompt);
|
||||
}
|
||||
|
||||
/** Compact multi-select for attaching skills to a cron job.
|
||||
*
|
||||
* A checkbox list (native inputs — the `onValueChange` rule is Select-only)
|
||||
* capped to a scrollable box. Skills already on the job but missing from the
|
||||
* available list (e.g. removed from disk, or the job was created via CLI in
|
||||
* another profile) are still rendered so saving doesn't silently drop them.
|
||||
*/
|
||||
function SkillsPicker({
|
||||
function NameCheckboxPicker({
|
||||
id,
|
||||
available,
|
||||
selected,
|
||||
|
|
@ -68,12 +75,12 @@ function SkillsPicker({
|
|||
emptyLabel,
|
||||
}: {
|
||||
id: string;
|
||||
available: SkillInfo[];
|
||||
available: Array<{ name: string; description?: string | null }>;
|
||||
selected: string[];
|
||||
onChange: (skills: string[]) => void;
|
||||
onChange: (names: string[]) => void;
|
||||
emptyLabel: string;
|
||||
}) {
|
||||
const names = available.map((s) => s.name);
|
||||
const names = available.map((item) => item.name);
|
||||
const orphaned = selected.filter((s) => !names.includes(s));
|
||||
const all = [...orphaned.map((name) => ({ name, description: "" })), ...available];
|
||||
|
||||
|
|
@ -91,25 +98,332 @@ function SkillsPicker({
|
|||
id={id}
|
||||
className="max-h-36 overflow-y-auto border border-border bg-background/40 p-1"
|
||||
>
|
||||
{all.map((skill) => (
|
||||
{all.map((item) => (
|
||||
<label
|
||||
key={skill.name}
|
||||
key={item.name}
|
||||
className="flex cursor-pointer items-center gap-2 px-2 py-1 text-xs hover:bg-muted/40"
|
||||
title={skill.description || undefined}
|
||||
title={item.description || undefined}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-foreground"
|
||||
checked={selected.includes(skill.name)}
|
||||
onChange={(e) => toggle(skill.name, e.target.checked)}
|
||||
checked={selected.includes(item.name)}
|
||||
onChange={(e) => toggle(item.name, e.target.checked)}
|
||||
/>
|
||||
<span className="font-mono-ui truncate">{skill.name}</span>
|
||||
<span className="font-mono-ui truncate">{item.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CronJobEditorState extends CronJobFormState {
|
||||
scheduleState: ScheduleBuilderState;
|
||||
}
|
||||
|
||||
interface CronJobFormResources {
|
||||
availableSkills: SkillInfo[];
|
||||
availableToolsets: ToolsetInfo[];
|
||||
modelOptions: ModelOptionsResponse | null;
|
||||
deliveryTargets: CronDeliveryTarget[];
|
||||
}
|
||||
|
||||
function emptyCronJobForm(): CronJobEditorState {
|
||||
return {
|
||||
name: "",
|
||||
prompt: "",
|
||||
schedule: "",
|
||||
deliver: "local",
|
||||
skills: [],
|
||||
provider: "",
|
||||
model: "",
|
||||
base_url: "",
|
||||
script: "",
|
||||
no_agent: false,
|
||||
context_from: "",
|
||||
enabled_toolsets: [],
|
||||
workdir: "",
|
||||
scheduleState: { ...DEFAULT_SCHEDULE_STATE },
|
||||
};
|
||||
}
|
||||
|
||||
function editorFormFromJob(job: CronJob): CronJobEditorState {
|
||||
const form = cronJobFormFromJob(job);
|
||||
return { ...form, scheduleState: parseScheduleString(form.schedule) };
|
||||
}
|
||||
|
||||
function buildCronJobPayloadFromEditor(form: CronJobEditorState) {
|
||||
const { scheduleState, ...payloadForm } = form;
|
||||
return buildCronJobPayload({
|
||||
...payloadForm,
|
||||
schedule: buildScheduleString(scheduleState),
|
||||
});
|
||||
}
|
||||
|
||||
function selectOptions(
|
||||
current: string,
|
||||
options: Array<{ value: string; label: string }>,
|
||||
) {
|
||||
const known = new Set(options.map((option) => option.value));
|
||||
return [
|
||||
...options.map((option) => (
|
||||
<SelectOption key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectOption>
|
||||
)),
|
||||
...(current && !known.has(current)
|
||||
? [
|
||||
<SelectOption key={current} value={current}>
|
||||
{current}
|
||||
</SelectOption>,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
function CronAdvancedFields({
|
||||
idPrefix,
|
||||
form,
|
||||
onChange,
|
||||
modelOptions,
|
||||
availableToolsets,
|
||||
}: {
|
||||
idPrefix: string;
|
||||
form: CronJobEditorState;
|
||||
onChange: (form: CronJobEditorState) => void;
|
||||
modelOptions: ModelOptionsResponse | null;
|
||||
availableToolsets: ToolsetInfo[];
|
||||
}) {
|
||||
const update = <K extends keyof CronJobEditorState,>(
|
||||
key: K,
|
||||
next: CronJobEditorState[K],
|
||||
) => {
|
||||
onChange({ ...form, [key]: next });
|
||||
};
|
||||
|
||||
const providers = (modelOptions?.providers ?? []).filter(
|
||||
(p) => p.authenticated !== false,
|
||||
);
|
||||
const selectedProvider = providers.find((p) => p.slug === form.provider);
|
||||
const models = selectedProvider?.models ?? [];
|
||||
|
||||
return (
|
||||
<details className="border border-border bg-background/30 p-3" open>
|
||||
<summary className="cursor-pointer text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Advanced fields
|
||||
</summary>
|
||||
<div className="mt-3 grid gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor={`${idPrefix}-provider`}>Provider</Label>
|
||||
<Select
|
||||
id={`${idPrefix}-provider`}
|
||||
value={form.provider}
|
||||
onValueChange={(v) => {
|
||||
onChange({ ...form, provider: v, model: "" });
|
||||
}}
|
||||
>
|
||||
<SelectOption value="">Default</SelectOption>
|
||||
{selectOptions(
|
||||
form.provider,
|
||||
providers.map((p) => ({ value: p.slug, label: p.name })),
|
||||
)}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor={`${idPrefix}-model`}>Model</Label>
|
||||
<Select
|
||||
id={`${idPrefix}-model`}
|
||||
value={form.model}
|
||||
onValueChange={(v) => update("model", v)}
|
||||
>
|
||||
<SelectOption value="">Default</SelectOption>
|
||||
{selectOptions(
|
||||
form.model,
|
||||
models.map((model) => ({ value: model, label: model })),
|
||||
)}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor={`${idPrefix}-base-url`}>Base URL override</Label>
|
||||
<Input
|
||||
id={`${idPrefix}-base-url`}
|
||||
placeholder="https://api.example.com/v1"
|
||||
value={form.base_url}
|
||||
onChange={(e) => update("base_url", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 items-end">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-foreground"
|
||||
checked={form.no_agent}
|
||||
onChange={(e) => update("no_agent", e.target.checked)}
|
||||
/>
|
||||
no_agent: run the script only and deliver stdout verbatim
|
||||
</label>
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor={`${idPrefix}-script`}>Script</Label>
|
||||
<Input
|
||||
id={`${idPrefix}-script`}
|
||||
value={form.script}
|
||||
onChange={(e) => update("script", e.target.value)}
|
||||
placeholder="relative/path/in/scripts"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor={`${idPrefix}-workdir`}>Workdir</Label>
|
||||
<Input
|
||||
id={`${idPrefix}-workdir`}
|
||||
value={form.workdir}
|
||||
onChange={(e) => update("workdir", e.target.value)}
|
||||
placeholder="/absolute/project/path"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor={`${idPrefix}-context-from`}>context_from job IDs</Label>
|
||||
<textarea
|
||||
id={`${idPrefix}-context-from`}
|
||||
className="flex min-h-[64px] w-full border border-border bg-background/40 px-3 py-2 text-xs 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="one job id per line"
|
||||
value={form.context_from}
|
||||
onChange={(e) => update("context_from", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<Label htmlFor={`${idPrefix}-toolsets`}>enabled_toolsets</Label>
|
||||
<NameCheckboxPicker
|
||||
id={`${idPrefix}-toolsets`}
|
||||
available={availableToolsets}
|
||||
selected={form.enabled_toolsets}
|
||||
onChange={(v) => update("enabled_toolsets", v)}
|
||||
emptyLabel="No toolsets available."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
interface CronJobFormFieldsProps {
|
||||
idPrefix: string;
|
||||
autoFocus?: boolean;
|
||||
form: CronJobEditorState;
|
||||
resources: CronJobFormResources;
|
||||
onChange: (form: CronJobEditorState) => void;
|
||||
}
|
||||
|
||||
function CronJobFormFields({
|
||||
idPrefix,
|
||||
autoFocus,
|
||||
form,
|
||||
resources,
|
||||
onChange,
|
||||
}: CronJobFormFieldsProps) {
|
||||
const { t } = useI18n();
|
||||
const { availableSkills, availableToolsets, deliveryTargets, modelOptions } = resources;
|
||||
const update = <K extends keyof CronJobEditorState,>(
|
||||
key: K,
|
||||
next: CronJobEditorState[K],
|
||||
) => {
|
||||
onChange({ ...form, [key]: next });
|
||||
};
|
||||
const onlyLocalAvailable =
|
||||
deliveryTargets.filter((target) => target.id !== "local").length === 0;
|
||||
|
||||
const deliveryOptions = selectOptions(
|
||||
form.deliver,
|
||||
deliveryTargets.map((target) => {
|
||||
const base = target.id === "local" ? t.cron.delivery.local : target.name;
|
||||
if (target.id !== "local" && !target.home_target_set) {
|
||||
const hint = t.cron.delivery.needsHomeChannel ?? "set a home channel first";
|
||||
return { value: target.id, label: `${base} — ${hint}` };
|
||||
}
|
||||
return { value: target.id, label: base };
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${idPrefix}-name`}>{t.cron.nameOptional}</Label>
|
||||
<Input
|
||||
id={`${idPrefix}-name`}
|
||||
autoFocus={autoFocus}
|
||||
placeholder={t.cron.namePlaceholder}
|
||||
value={form.name}
|
||||
onChange={(e) => update("name", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${idPrefix}-prompt`}>{t.cron.prompt}</Label>
|
||||
<textarea
|
||||
id={`${idPrefix}-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={form.prompt}
|
||||
onChange={(e) => update("prompt", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScheduleBuilder
|
||||
value={form.scheduleState}
|
||||
onChange={(state) => update("scheduleState", state)}
|
||||
/>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${idPrefix}-deliver`}>{t.cron.deliverTo}</Label>
|
||||
<Select
|
||||
id={`${idPrefix}-deliver`}
|
||||
value={form.deliver}
|
||||
onValueChange={(v) => update("deliver", v)}
|
||||
>
|
||||
{deliveryOptions}
|
||||
</Select>
|
||||
{onlyLocalAvailable && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t.cron.delivery.noneConfigured ??
|
||||
"No messaging platforms configured. Set one up under Channels to deliver reports."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`${idPrefix}-skills`}>Skills (optional)</Label>
|
||||
<NameCheckboxPicker
|
||||
id={`${idPrefix}-skills`}
|
||||
available={availableSkills}
|
||||
selected={form.skills}
|
||||
onChange={(skills) => update("skills", skills)}
|
||||
emptyLabel="No skills installed for this profile."
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Selected skills are loaded before the prompt runs — the cron
|
||||
sets when, the skill sets how.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CronAdvancedFields
|
||||
idPrefix={`${idPrefix}-advanced`}
|
||||
form={form}
|
||||
onChange={onChange}
|
||||
modelOptions={modelOptions}
|
||||
availableToolsets={availableToolsets}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getJobName(job: CronJob): string {
|
||||
return asText(job.name).trim();
|
||||
}
|
||||
|
|
@ -148,6 +462,26 @@ function getJobState(job: CronJob): string {
|
|||
return asText(job.state) || (job.enabled === false ? "disabled" : "scheduled");
|
||||
}
|
||||
|
||||
function getRepeatDisplay(job: CronJob): string {
|
||||
const repeat = job.repeat;
|
||||
if (!repeat || repeat.times == null) return "forever";
|
||||
const completed = repeat.completed ?? 0;
|
||||
return completed > 0 ? `${completed}/${repeat.times}` : `${repeat.times} times`;
|
||||
}
|
||||
|
||||
function getJobMode(job: CronJob): string {
|
||||
if (job.no_agent) return "no_agent";
|
||||
if (job.script) return "script+agent";
|
||||
return "agent";
|
||||
}
|
||||
|
||||
function getModelDisplay(job: CronJob): string {
|
||||
const provider = asText(job.provider);
|
||||
const model = asText(job.model);
|
||||
if (provider && model) return `${provider}/${model}`;
|
||||
return model || provider;
|
||||
}
|
||||
|
||||
function getJobProfile(job: CronJob): string {
|
||||
return asText(job.profile) || asText(job.profile_name) || "default";
|
||||
}
|
||||
|
|
@ -201,36 +535,25 @@ export default function CronPage() {
|
|||
|
||||
// New job modal state
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
// The schedule is now constructed via the ScheduleBuilder; we keep
|
||||
// the full builder state so flipping between modes during edit
|
||||
// doesn't erase the user's intermediate inputs. The actual string
|
||||
// sent to the backend is derived via ``buildScheduleString`` at
|
||||
// submit time.
|
||||
const [scheduleState, setScheduleState] = useState<ScheduleBuilderState>(
|
||||
DEFAULT_SCHEDULE_STATE,
|
||||
const [createProfile, setCreateProfile] = useState("default");
|
||||
const [createForm, setCreateForm] = useState<CronJobEditorState>(
|
||||
emptyCronJobForm,
|
||||
);
|
||||
const [name, setName] = useState("");
|
||||
const closeCreateModal = useCallback(() => setCreateModalOpen(false), []);
|
||||
const createModalRef = useModalBehavior({
|
||||
open: createModalOpen,
|
||||
onClose: closeCreateModal,
|
||||
});
|
||||
const [deliver, setDeliver] = useState("local");
|
||||
const [jobSkills, setJobSkills] = useState<string[]>([]);
|
||||
const [deliveryTargets, setDeliveryTargets] = useState<CronDeliveryTarget[]>([
|
||||
{ id: "local", name: "Local", home_target_set: true, home_env_var: null },
|
||||
]);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const createProfile = selectedProfile === "all" ? "default" : selectedProfile;
|
||||
|
||||
// Edit job modal state
|
||||
const [editJob, setEditJob] = useState<CronJob | null>(null);
|
||||
const [editPrompt, setEditPrompt] = useState("");
|
||||
const [editSchedule, setEditSchedule] = useState("");
|
||||
const [editName, setEditName] = useState("");
|
||||
const [editDeliver, setEditDeliver] = useState("local");
|
||||
const [editSkills, setEditSkills] = useState<string[]>([]);
|
||||
const [editForm, setEditForm] = useState<CronJobEditorState>(
|
||||
emptyCronJobForm,
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const closeEditModal = useCallback(() => setEditJob(null), []);
|
||||
const editModalRef = useModalBehavior({
|
||||
|
|
@ -243,16 +566,14 @@ export default function CronPage() {
|
|||
// Keyed on the create-modal profile; the edit modal reuses the list —
|
||||
// a job's current skills are always shown even if not in it.
|
||||
const [availableSkills, setAvailableSkills] = useState<SkillInfo[]>([]);
|
||||
const [availableToolsets, setAvailableToolsets] = useState<ToolsetInfo[]>([]);
|
||||
const [modelOptions, setModelOptions] = useState<ModelOptionsResponse | null>(null);
|
||||
|
||||
const resourceProfile = editJob ? getJobProfile(editJob) : createProfile;
|
||||
|
||||
const openEditModal = useCallback((job: CronJob) => {
|
||||
setEditJob(job);
|
||||
setEditPrompt(getJobPrompt(job));
|
||||
setEditSchedule(
|
||||
asText(job.schedule?.expr) || asText(job.schedule_display) || "",
|
||||
);
|
||||
setEditName(getJobName(job));
|
||||
setEditDeliver(asText(job.deliver) || "local");
|
||||
setEditSkills(Array.isArray(job.skills) ? job.skills.filter(Boolean) : []);
|
||||
setEditForm(editorFormFromJob(job));
|
||||
}, []);
|
||||
|
||||
const loadJobs = useCallback(() => {
|
||||
|
|
@ -286,102 +607,44 @@ export default function CronPage() {
|
|||
loadJobs();
|
||||
}, [loadJobs]);
|
||||
|
||||
// Load installed skills for the profile new jobs will be created under.
|
||||
// "" / "default" maps to the dashboard's own profile via the optional
|
||||
// ?profile= scoping on /api/skills.
|
||||
// Load resources from the profile the create/edit form actually targets.
|
||||
// Pass "default" explicitly so the global dashboard profile switch cannot
|
||||
// redirect a default-profile cron form to some other profile.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.getSkills(createProfile === "default" ? undefined : createProfile)
|
||||
.then((s) => {
|
||||
if (!cancelled)
|
||||
setAvailableSkills(
|
||||
[...s].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
);
|
||||
})
|
||||
.catch(() => !cancelled && setAvailableSkills([]));
|
||||
Promise.all([
|
||||
api.getSkills(resourceProfile).catch(() => []),
|
||||
api.getToolsets(resourceProfile).catch(() => []),
|
||||
api.getModelOptions(resourceProfile).catch(() => null),
|
||||
]).then(([skills, toolsets, options]) => {
|
||||
if (cancelled) return;
|
||||
setAvailableSkills([...skills].sort((a, b) => a.name.localeCompare(b.name)));
|
||||
setAvailableToolsets([...toolsets].sort((a, b) => a.name.localeCompare(b.name)));
|
||||
setModelOptions(options);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [createProfile]);
|
||||
|
||||
const scheduleString = buildScheduleString(scheduleState);
|
||||
|
||||
// Label for a delivery option. Configured platforms missing their cron home
|
||||
// channel are still offered (option B), annotated so the user knows what to
|
||||
// fix rather than wondering why delivery silently no-ops.
|
||||
const deliverLabel = useCallback(
|
||||
(target: CronDeliveryTarget): string => {
|
||||
const base = target.id === "local" ? t.cron.delivery.local : target.name;
|
||||
if (target.id !== "local" && !target.home_target_set) {
|
||||
const hint = t.cron.delivery.needsHomeChannel ?? "set a home channel first";
|
||||
return `${base} — ${hint}`;
|
||||
}
|
||||
return base;
|
||||
},
|
||||
[t.cron.delivery],
|
||||
);
|
||||
|
||||
const renderDeliverOptions = useCallback(
|
||||
() =>
|
||||
deliveryTargets.map((target) => (
|
||||
<SelectOption key={target.id} value={target.id}>
|
||||
{deliverLabel(target)}
|
||||
</SelectOption>
|
||||
)),
|
||||
[deliveryTargets, deliverLabel],
|
||||
);
|
||||
|
||||
// The edit modal must always show the job's current target, even if that
|
||||
// platform is no longer configured (e.g. job created via CLI, or the
|
||||
// gateway was later removed) — otherwise the value would silently vanish
|
||||
// from the dropdown and saving would drop it.
|
||||
const renderEditDeliverOptions = useCallback(
|
||||
(current: string) => {
|
||||
const known = new Set(deliveryTargets.map((target) => target.id));
|
||||
const options = deliveryTargets.map((target) => (
|
||||
<SelectOption key={target.id} value={target.id}>
|
||||
{deliverLabel(target)}
|
||||
</SelectOption>
|
||||
));
|
||||
if (current && !known.has(current)) {
|
||||
options.push(
|
||||
<SelectOption key={current} value={current}>
|
||||
{current}
|
||||
</SelectOption>,
|
||||
);
|
||||
}
|
||||
return options;
|
||||
},
|
||||
[deliveryTargets, deliverLabel],
|
||||
);
|
||||
|
||||
const onlyLocalAvailable =
|
||||
deliveryTargets.filter((target) => target.id !== "local").length === 0;
|
||||
}, [resourceProfile]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!prompt.trim() || !scheduleString) {
|
||||
const payload = buildCronJobPayloadFromEditor(createForm);
|
||||
if (
|
||||
!payload.schedule ||
|
||||
(!payload.no_agent && !cronJobHasExecutionContent(payload))
|
||||
) {
|
||||
showToast(`${t.cron.prompt} & ${t.cron.schedule} required`, "error");
|
||||
return;
|
||||
}
|
||||
if (payload.no_agent && !payload.script) {
|
||||
showToast("no_agent jobs require a script", "error");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
await api.createCronJob(
|
||||
{
|
||||
prompt: prompt.trim(),
|
||||
schedule: scheduleString,
|
||||
name: name.trim() || undefined,
|
||||
deliver,
|
||||
skills: jobSkills.length > 0 ? jobSkills : undefined,
|
||||
},
|
||||
createProfile,
|
||||
);
|
||||
await api.createCronJob(payload, createProfile);
|
||||
showToast(t.common.create + " ✓", "success");
|
||||
setPrompt("");
|
||||
setScheduleState(DEFAULT_SCHEDULE_STATE);
|
||||
setName("");
|
||||
setDeliver("local");
|
||||
setJobSkills([]);
|
||||
setCreateForm(emptyCronJobForm());
|
||||
setCreateModalOpen(false);
|
||||
loadJobs();
|
||||
} catch (e) {
|
||||
|
|
@ -393,21 +656,23 @@ export default function CronPage() {
|
|||
|
||||
const handleEdit = async () => {
|
||||
if (!editJob) return;
|
||||
if (!editPrompt.trim() || !editSchedule.trim()) {
|
||||
const payload = buildCronJobPayloadFromEditor(editForm);
|
||||
if (
|
||||
!payload.schedule ||
|
||||
(!payload.no_agent && !cronJobHasExecutionContent(payload))
|
||||
) {
|
||||
showToast(`${t.cron.prompt} & ${t.cron.schedule} required`, "error");
|
||||
return;
|
||||
}
|
||||
if (payload.no_agent && !payload.script) {
|
||||
showToast("no_agent jobs require a script", "error");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.updateCronJob(
|
||||
editJob.id,
|
||||
{
|
||||
prompt: editPrompt.trim(),
|
||||
schedule: editSchedule.trim(),
|
||||
name: editName.trim(),
|
||||
deliver: editDeliver,
|
||||
skills: editSkills,
|
||||
},
|
||||
payload,
|
||||
getJobProfile(editJob),
|
||||
);
|
||||
showToast("Saved changes ✓", "success");
|
||||
|
|
@ -483,7 +748,10 @@ export default function CronPage() {
|
|||
<Button
|
||||
className="uppercase"
|
||||
size="sm"
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
onClick={() => {
|
||||
setCreateProfile(selectedProfile === "all" ? "default" : selectedProfile);
|
||||
setCreateModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{t.common.create}
|
||||
</Button>,
|
||||
|
|
@ -491,7 +759,7 @@ export default function CronPage() {
|
|||
return () => {
|
||||
setEnd(null);
|
||||
};
|
||||
}, [setEnd, t.common.create, loading]);
|
||||
}, [setEnd, t.common.create, loading, selectedProfile]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -552,7 +820,7 @@ export default function CronPage() {
|
|||
aria-modal="true"
|
||||
aria-labelledby="create-cron-title"
|
||||
>
|
||||
<div className={cn(themedBody, "relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col")}>
|
||||
<div className={cn(themedBody, "relative w-full max-w-3xl max-h-[90vh] border border-border bg-card shadow-2xl flex flex-col")}>
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
|
|
@ -572,13 +840,13 @@ export default function CronPage() {
|
|||
</h2>
|
||||
</header>
|
||||
|
||||
<div className="p-5 grid gap-4">
|
||||
<div className="min-h-0 overflow-y-auto 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)}
|
||||
onValueChange={(v) => setCreateProfile(v)}
|
||||
>
|
||||
{profiles.map((profile) => (
|
||||
<SelectOption key={profile.name} value={profile.name}>
|
||||
|
|
@ -588,65 +856,19 @@ export default function CronPage() {
|
|||
</Select>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<ScheduleBuilder
|
||||
value={scheduleState}
|
||||
onChange={setScheduleState}
|
||||
<CronJobFormFields
|
||||
idPrefix="cron"
|
||||
autoFocus
|
||||
form={createForm}
|
||||
onChange={setCreateForm}
|
||||
resources={{
|
||||
availableSkills,
|
||||
availableToolsets,
|
||||
modelOptions,
|
||||
deliveryTargets,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-deliver">{t.cron.deliverTo}</Label>
|
||||
<Select
|
||||
id="cron-deliver"
|
||||
value={deliver}
|
||||
onValueChange={(v) => setDeliver(v)}
|
||||
>
|
||||
{renderDeliverOptions()}
|
||||
</Select>
|
||||
{onlyLocalAvailable && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t.cron.delivery.noneConfigured ??
|
||||
"No messaging platforms configured. Set one up under Channels to deliver reports."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-skills">Skills (optional)</Label>
|
||||
<SkillsPicker
|
||||
id="cron-skills"
|
||||
available={availableSkills}
|
||||
selected={jobSkills}
|
||||
onChange={setJobSkills}
|
||||
emptyLabel="No skills installed for this profile."
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Selected skills are loaded before the prompt runs — the cron
|
||||
sets when, the skill sets how.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className="uppercase"
|
||||
|
|
@ -673,7 +895,7 @@ export default function CronPage() {
|
|||
aria-modal="true"
|
||||
aria-labelledby="edit-cron-title"
|
||||
>
|
||||
<div className={cn(themedBody, "relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col")}>
|
||||
<div className={cn(themedBody, "relative w-full max-w-3xl max-h-[90vh] border border-border bg-card shadow-2xl flex flex-col")}>
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
|
|
@ -693,64 +915,24 @@ export default function CronPage() {
|
|||
</h2>
|
||||
</header>
|
||||
|
||||
<div className="p-5 grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-cron-name">{t.cron.nameOptional}</Label>
|
||||
<Input
|
||||
id="edit-cron-name"
|
||||
autoFocus
|
||||
placeholder={t.cron.namePlaceholder}
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 overflow-y-auto p-5 grid gap-4">
|
||||
<CronJobFormFields
|
||||
idPrefix="edit-cron"
|
||||
autoFocus
|
||||
form={editForm}
|
||||
onChange={setEditForm}
|
||||
resources={{
|
||||
availableSkills,
|
||||
availableToolsets,
|
||||
modelOptions,
|
||||
deliveryTargets,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-cron-prompt">{t.cron.prompt}</Label>
|
||||
<textarea
|
||||
id="edit-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={editPrompt}
|
||||
onChange={(e) => setEditPrompt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-cron-schedule">{t.cron.schedule}</Label>
|
||||
<Input
|
||||
id="edit-cron-schedule"
|
||||
placeholder={t.cron.schedulePlaceholder}
|
||||
value={editSchedule}
|
||||
onChange={(e) => setEditSchedule(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-cron-deliver">{t.cron.deliverTo}</Label>
|
||||
<Select
|
||||
id="edit-cron-deliver"
|
||||
value={editDeliver}
|
||||
onValueChange={(v) => setEditDeliver(v)}
|
||||
>
|
||||
{renderEditDeliverOptions(editDeliver)}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="edit-cron-skills">Skills</Label>
|
||||
<SkillsPicker
|
||||
id="edit-cron-skills"
|
||||
available={availableSkills}
|
||||
selected={editSkills}
|
||||
onChange={setEditSkills}
|
||||
emptyLabel="No skills installed for this profile."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground font-mono-ui truncate pr-4">
|
||||
{editJob.id}
|
||||
</span>
|
||||
<Button
|
||||
className="uppercase"
|
||||
size="sm"
|
||||
|
|
@ -810,6 +992,11 @@ export default function CronPage() {
|
|||
const deliver = asText(job.deliver);
|
||||
const profile = getJobProfile(job);
|
||||
const jobKey = getJobKey(job);
|
||||
const mode = getJobMode(job);
|
||||
const modelDisplay = getModelDisplay(job);
|
||||
const toolsets = Array.isArray(job.enabled_toolsets)
|
||||
? job.enabled_toolsets.filter(Boolean)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Card key={jobKey}>
|
||||
|
|
@ -833,6 +1020,19 @@ export default function CronPage() {
|
|||
: `${job.skills.length} skills`}
|
||||
</Badge>
|
||||
)}
|
||||
{mode !== "agent" && (
|
||||
<Badge tone="outline">{mode}</Badge>
|
||||
)}
|
||||
{modelDisplay && (
|
||||
<Badge tone="outline" title={modelDisplay}>
|
||||
model
|
||||
</Badge>
|
||||
)}
|
||||
{toolsets.length > 0 && (
|
||||
<Badge tone="outline" title={toolsets.join(", ")}>
|
||||
{toolsets.length} toolsets
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{hasName && promptText && (
|
||||
<p className="text-xs text-muted-foreground truncate mb-1">
|
||||
|
|
@ -843,6 +1043,7 @@ export default function CronPage() {
|
|||
<span className="font-mono-ui">
|
||||
{getJobScheduleDisplay(job, scheduleDescribeStrings)}
|
||||
</span>
|
||||
<span>repeat: {getRepeatDisplay(job)}</span>
|
||||
<span>
|
||||
{t.cron.last}: {formatTime(job.last_run_at)}
|
||||
</span>
|
||||
|
|
@ -850,6 +1051,11 @@ export default function CronPage() {
|
|||
{t.cron.next}: {formatTime(job.next_run_at)}
|
||||
</span>
|
||||
</div>
|
||||
{job.last_delivery_error && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
delivery: {job.last_delivery_error}
|
||||
</p>
|
||||
)}
|
||||
{job.last_error && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{job.last_error}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue