* fix: harden cron add and align channels * fix: keep cron tool id params --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
393 lines
14 KiB
TypeScript
393 lines
14 KiB
TypeScript
import { html, nothing } from "lit";
|
|
|
|
import { formatMs } from "../format";
|
|
import {
|
|
formatCronPayload,
|
|
formatCronSchedule,
|
|
formatCronState,
|
|
formatNextRun,
|
|
} from "../presenter";
|
|
import type { CronJob, CronRunLogEntry, CronStatus } from "../types";
|
|
import type { CronFormState } from "../ui-types";
|
|
|
|
export type CronProps = {
|
|
loading: boolean;
|
|
status: CronStatus | null;
|
|
jobs: CronJob[];
|
|
error: string | null;
|
|
busy: boolean;
|
|
form: CronFormState;
|
|
runsJobId: string | null;
|
|
runs: CronRunLogEntry[];
|
|
onFormChange: (patch: Partial<CronFormState>) => void;
|
|
onRefresh: () => void;
|
|
onAdd: () => void;
|
|
onToggle: (job: CronJob, enabled: boolean) => void;
|
|
onRun: (job: CronJob) => void;
|
|
onRemove: (job: CronJob) => void;
|
|
onLoadRuns: (jobId: string) => void;
|
|
};
|
|
|
|
export function renderCron(props: CronProps) {
|
|
return html`
|
|
<section class="grid grid-cols-2">
|
|
<div class="card">
|
|
<div class="card-title">Scheduler</div>
|
|
<div class="card-sub">Gateway-owned cron scheduler status.</div>
|
|
<div class="stat-grid" style="margin-top: 16px;">
|
|
<div class="stat">
|
|
<div class="stat-label">Enabled</div>
|
|
<div class="stat-value">
|
|
${props.status
|
|
? props.status.enabled
|
|
? "Yes"
|
|
: "No"
|
|
: "n/a"}
|
|
</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="stat-label">Jobs</div>
|
|
<div class="stat-value">${props.status?.jobs ?? "n/a"}</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="stat-label">Next wake</div>
|
|
<div class="stat-value">${formatNextRun(props.status?.nextWakeAtMs ?? null)}</div>
|
|
</div>
|
|
</div>
|
|
<div class="row" style="margin-top: 12px;">
|
|
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
|
|
${props.loading ? "Refreshing…" : "Refresh"}
|
|
</button>
|
|
${props.error ? html`<span class="muted">${props.error}</span>` : nothing}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-title">New Job</div>
|
|
<div class="card-sub">Create a scheduled wakeup or agent run.</div>
|
|
<div class="form-grid" style="margin-top: 16px;">
|
|
<label class="field">
|
|
<span>Name</span>
|
|
<input
|
|
.value=${props.form.name}
|
|
@input=${(e: Event) =>
|
|
props.onFormChange({ name: (e.target as HTMLInputElement).value })}
|
|
/>
|
|
</label>
|
|
<label class="field">
|
|
<span>Description</span>
|
|
<input
|
|
.value=${props.form.description}
|
|
@input=${(e: Event) =>
|
|
props.onFormChange({ description: (e.target as HTMLInputElement).value })}
|
|
/>
|
|
</label>
|
|
<label class="field checkbox">
|
|
<span>Enabled</span>
|
|
<input
|
|
type="checkbox"
|
|
.checked=${props.form.enabled}
|
|
@change=${(e: Event) =>
|
|
props.onFormChange({ enabled: (e.target as HTMLInputElement).checked })}
|
|
/>
|
|
</label>
|
|
<label class="field">
|
|
<span>Schedule</span>
|
|
<select
|
|
.value=${props.form.scheduleKind}
|
|
@change=${(e: Event) =>
|
|
props.onFormChange({
|
|
scheduleKind: (e.target as HTMLSelectElement).value as CronFormState["scheduleKind"],
|
|
})}
|
|
>
|
|
<option value="every">Every</option>
|
|
<option value="at">At</option>
|
|
<option value="cron">Cron</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
${renderScheduleFields(props)}
|
|
<div class="form-grid" style="margin-top: 12px;">
|
|
<label class="field">
|
|
<span>Session</span>
|
|
<select
|
|
.value=${props.form.sessionTarget}
|
|
@change=${(e: Event) =>
|
|
props.onFormChange({
|
|
sessionTarget: (e.target as HTMLSelectElement).value as CronFormState["sessionTarget"],
|
|
})}
|
|
>
|
|
<option value="main">Main</option>
|
|
<option value="isolated">Isolated</option>
|
|
</select>
|
|
</label>
|
|
<label class="field">
|
|
<span>Wake mode</span>
|
|
<select
|
|
.value=${props.form.wakeMode}
|
|
@change=${(e: Event) =>
|
|
props.onFormChange({
|
|
wakeMode: (e.target as HTMLSelectElement).value as CronFormState["wakeMode"],
|
|
})}
|
|
>
|
|
<option value="next-heartbeat">Next heartbeat</option>
|
|
<option value="now">Now</option>
|
|
</select>
|
|
</label>
|
|
<label class="field">
|
|
<span>Payload</span>
|
|
<select
|
|
.value=${props.form.payloadKind}
|
|
@change=${(e: Event) =>
|
|
props.onFormChange({
|
|
payloadKind: (e.target as HTMLSelectElement).value as CronFormState["payloadKind"],
|
|
})}
|
|
>
|
|
<option value="systemEvent">System event</option>
|
|
<option value="agentTurn">Agent turn</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<label class="field" style="margin-top: 12px;">
|
|
<span>${props.form.payloadKind === "systemEvent" ? "System text" : "Agent message"}</span>
|
|
<textarea
|
|
.value=${props.form.payloadText}
|
|
@input=${(e: Event) =>
|
|
props.onFormChange({
|
|
payloadText: (e.target as HTMLTextAreaElement).value,
|
|
})}
|
|
rows="4"
|
|
></textarea>
|
|
</label>
|
|
${props.form.payloadKind === "agentTurn"
|
|
? html`
|
|
<div class="form-grid" style="margin-top: 12px;">
|
|
<label class="field checkbox">
|
|
<span>Deliver</span>
|
|
<input
|
|
type="checkbox"
|
|
.checked=${props.form.deliver}
|
|
@change=${(e: Event) =>
|
|
props.onFormChange({
|
|
deliver: (e.target as HTMLInputElement).checked,
|
|
})}
|
|
/>
|
|
</label>
|
|
<label class="field">
|
|
<span>Channel</span>
|
|
<select
|
|
.value=${props.form.channel}
|
|
@change=${(e: Event) =>
|
|
props.onFormChange({
|
|
channel: (e.target as HTMLSelectElement).value as CronFormState["channel"],
|
|
})}
|
|
>
|
|
<option value="last">Last</option>
|
|
<option value="whatsapp">WhatsApp</option>
|
|
<option value="telegram">Telegram</option>
|
|
<option value="discord">Discord</option>
|
|
<option value="slack">Slack</option>
|
|
<option value="signal">Signal</option>
|
|
<option value="imessage">iMessage</option>
|
|
</select>
|
|
</label>
|
|
<label class="field">
|
|
<span>To</span>
|
|
<input
|
|
.value=${props.form.to}
|
|
@input=${(e: Event) =>
|
|
props.onFormChange({ to: (e.target as HTMLInputElement).value })}
|
|
placeholder="+1555… or chat id"
|
|
/>
|
|
</label>
|
|
<label class="field">
|
|
<span>Timeout (seconds)</span>
|
|
<input
|
|
.value=${props.form.timeoutSeconds}
|
|
@input=${(e: Event) =>
|
|
props.onFormChange({
|
|
timeoutSeconds: (e.target as HTMLInputElement).value,
|
|
})}
|
|
/>
|
|
</label>
|
|
${props.form.sessionTarget === "isolated"
|
|
? html`
|
|
<label class="field">
|
|
<span>Post to main prefix</span>
|
|
<input
|
|
.value=${props.form.postToMainPrefix}
|
|
@input=${(e: Event) =>
|
|
props.onFormChange({
|
|
postToMainPrefix: (e.target as HTMLInputElement).value,
|
|
})}
|
|
/>
|
|
</label>
|
|
`
|
|
: nothing}
|
|
</div>
|
|
`
|
|
: nothing}
|
|
<div class="row" style="margin-top: 14px;">
|
|
<button class="btn primary" ?disabled=${props.busy} @click=${props.onAdd}>
|
|
${props.busy ? "Saving…" : "Add job"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="card" style="margin-top: 18px;">
|
|
<div class="card-title">Jobs</div>
|
|
<div class="card-sub">All scheduled jobs stored in the gateway.</div>
|
|
${props.jobs.length === 0
|
|
? html`<div class="muted" style="margin-top: 12px;">No jobs yet.</div>`
|
|
: html`
|
|
<div class="list" style="margin-top: 12px;">
|
|
${props.jobs.map((job) => renderJob(job, props))}
|
|
</div>
|
|
`}
|
|
</section>
|
|
|
|
<section class="card" style="margin-top: 18px;">
|
|
<div class="card-title">Run history</div>
|
|
<div class="card-sub">Latest runs for ${props.runsJobId ?? "(select a job)"}.</div>
|
|
${props.runs.length === 0
|
|
? html`<div class="muted" style="margin-top: 12px;">No runs yet.</div>`
|
|
: html`
|
|
<div class="list" style="margin-top: 12px;">
|
|
${props.runs.map((entry) => renderRun(entry))}
|
|
</div>
|
|
`}
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
function renderScheduleFields(props: CronProps) {
|
|
const form = props.form;
|
|
if (form.scheduleKind === "at") {
|
|
return html`
|
|
<label class="field" style="margin-top: 12px;">
|
|
<span>Run at</span>
|
|
<input
|
|
type="datetime-local"
|
|
.value=${form.scheduleAt}
|
|
@input=${(e: Event) =>
|
|
props.onFormChange({
|
|
scheduleAt: (e.target as HTMLInputElement).value,
|
|
})}
|
|
/>
|
|
</label>
|
|
`;
|
|
}
|
|
if (form.scheduleKind === "every") {
|
|
return html`
|
|
<div class="form-grid" style="margin-top: 12px;">
|
|
<label class="field">
|
|
<span>Every</span>
|
|
<input
|
|
.value=${form.everyAmount}
|
|
@input=${(e: Event) =>
|
|
props.onFormChange({
|
|
everyAmount: (e.target as HTMLInputElement).value,
|
|
})}
|
|
/>
|
|
</label>
|
|
<label class="field">
|
|
<span>Unit</span>
|
|
<select
|
|
.value=${form.everyUnit}
|
|
@change=${(e: Event) =>
|
|
props.onFormChange({
|
|
everyUnit: (e.target as HTMLSelectElement).value as CronFormState["everyUnit"],
|
|
})}
|
|
>
|
|
<option value="minutes">Minutes</option>
|
|
<option value="hours">Hours</option>
|
|
<option value="days">Days</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
`;
|
|
}
|
|
return html`
|
|
<div class="form-grid" style="margin-top: 12px;">
|
|
<label class="field">
|
|
<span>Expression</span>
|
|
<input
|
|
.value=${form.cronExpr}
|
|
@input=${(e: Event) =>
|
|
props.onFormChange({ cronExpr: (e.target as HTMLInputElement).value })}
|
|
/>
|
|
</label>
|
|
<label class="field">
|
|
<span>Timezone (optional)</span>
|
|
<input
|
|
.value=${form.cronTz}
|
|
@input=${(e: Event) =>
|
|
props.onFormChange({ cronTz: (e.target as HTMLInputElement).value })}
|
|
/>
|
|
</label>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderJob(job: CronJob, props: CronProps) {
|
|
return html`
|
|
<div class="list-item">
|
|
<div class="list-main">
|
|
<div class="list-title">${job.name}</div>
|
|
<div class="list-sub">${formatCronSchedule(job)}</div>
|
|
<div class="muted">${formatCronPayload(job)}</div>
|
|
<div class="chip-row" style="margin-top: 6px;">
|
|
<span class="chip">${job.enabled ? "enabled" : "disabled"}</span>
|
|
<span class="chip">${job.sessionTarget}</span>
|
|
<span class="chip">${job.wakeMode}</span>
|
|
</div>
|
|
</div>
|
|
<div class="list-meta">
|
|
<div>${formatCronState(job)}</div>
|
|
<div class="row" style="justify-content: flex-end; margin-top: 8px;">
|
|
<button
|
|
class="btn"
|
|
?disabled=${props.busy}
|
|
@click=${() => props.onToggle(job, !job.enabled)}
|
|
>
|
|
${job.enabled ? "Disable" : "Enable"}
|
|
</button>
|
|
<button class="btn" ?disabled=${props.busy} @click=${() => props.onRun(job)}>
|
|
Run
|
|
</button>
|
|
<button
|
|
class="btn"
|
|
?disabled=${props.busy}
|
|
@click=${() => props.onLoadRuns(job.id)}
|
|
>
|
|
Runs
|
|
</button>
|
|
<button
|
|
class="btn danger"
|
|
?disabled=${props.busy}
|
|
@click=${() => props.onRemove(job)}
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderRun(entry: CronRunLogEntry) {
|
|
return html`
|
|
<div class="list-item">
|
|
<div class="list-main">
|
|
<div class="list-title">${entry.status}</div>
|
|
<div class="list-sub">${entry.summary ?? ""}</div>
|
|
</div>
|
|
<div class="list-meta">
|
|
<div>${formatMs(entry.ts)}</div>
|
|
<div class="muted">${entry.durationMs ?? 0}ms</div>
|
|
${entry.error ? html`<div class="muted">${entry.error}</div>` : nothing}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|