603 lines
23 KiB
TypeScript
603 lines
23 KiB
TypeScript
/**
|
|
* <folk-schedule-app> — schedule management UI.
|
|
*
|
|
* Job list with create/edit forms, execution log viewer,
|
|
* and manual run triggers. REST-based (no Automerge client sync).
|
|
*/
|
|
|
|
interface JobData {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
enabled: boolean;
|
|
cronExpression: string;
|
|
cronHuman?: string;
|
|
timezone: string;
|
|
actionType: string;
|
|
actionConfig: Record<string, unknown>;
|
|
lastRunAt: number | null;
|
|
lastRunStatus: "success" | "error" | null;
|
|
lastRunMessage: string;
|
|
nextRunAt: number | null;
|
|
runCount: number;
|
|
createdBy: string;
|
|
createdAt: number;
|
|
updatedAt: number;
|
|
}
|
|
|
|
interface LogEntry {
|
|
id: string;
|
|
jobId: string;
|
|
status: "success" | "error";
|
|
message: string;
|
|
durationMs: number;
|
|
timestamp: number;
|
|
}
|
|
|
|
const ACTION_TYPES = [
|
|
{ value: "email", label: "Email" },
|
|
{ value: "webhook", label: "Webhook" },
|
|
{ value: "calendar-event", label: "Calendar Event" },
|
|
{ value: "broadcast", label: "Broadcast" },
|
|
{ value: "backlog-briefing", label: "Backlog Briefing" },
|
|
];
|
|
|
|
const CRON_PRESETS = [
|
|
{ label: "Every minute", value: "* * * * *" },
|
|
{ label: "Every 5 minutes", value: "*/5 * * * *" },
|
|
{ label: "Hourly", value: "0 * * * *" },
|
|
{ label: "Daily at 9am", value: "0 9 * * *" },
|
|
{ label: "Weekday mornings", value: "0 9 * * 1-5" },
|
|
{ label: "Weekly (Monday 9am)", value: "0 9 * * 1" },
|
|
{ label: "Monthly (1st at 9am)", value: "0 9 1 * *" },
|
|
{ label: "Custom", value: "" },
|
|
];
|
|
|
|
class FolkScheduleApp extends HTMLElement {
|
|
private shadow: ShadowRoot;
|
|
private space = "";
|
|
private jobs: JobData[] = [];
|
|
private log: LogEntry[] = [];
|
|
private view: "jobs" | "log" | "form" = "jobs";
|
|
private editingJob: JobData | null = null;
|
|
private loading = false;
|
|
private runningJobId: string | null = null;
|
|
|
|
// Form state
|
|
private formName = "";
|
|
private formDescription = "";
|
|
private formCron = "0 9 * * 1-5";
|
|
private formTimezone = "America/Vancouver";
|
|
private formActionType = "email";
|
|
private formEnabled = true;
|
|
private formConfig: Record<string, string> = {};
|
|
|
|
constructor() {
|
|
super();
|
|
this.shadow = this.attachShadow({ mode: "open" });
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.space = this.getAttribute("space") || "demo";
|
|
this.loadJobs();
|
|
}
|
|
|
|
private getApiBase(): string {
|
|
const path = window.location.pathname;
|
|
const match = path.match(/^(\/[^/]+)?\/schedule/);
|
|
return match ? match[0] : "";
|
|
}
|
|
|
|
private async loadJobs() {
|
|
this.loading = true;
|
|
this.render();
|
|
try {
|
|
const base = this.getApiBase();
|
|
const res = await fetch(`${base}/api/jobs`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
this.jobs = data.results || [];
|
|
}
|
|
} catch { this.jobs = []; }
|
|
this.loading = false;
|
|
this.render();
|
|
}
|
|
|
|
private async loadLog() {
|
|
try {
|
|
const base = this.getApiBase();
|
|
const res = await fetch(`${base}/api/log`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
this.log = data.results || [];
|
|
}
|
|
} catch { this.log = []; }
|
|
this.render();
|
|
}
|
|
|
|
private async toggleJob(id: string, enabled: boolean) {
|
|
const base = this.getApiBase();
|
|
await fetch(`${base}/api/jobs/${id}`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ enabled }),
|
|
});
|
|
await this.loadJobs();
|
|
}
|
|
|
|
private async deleteJob(id: string) {
|
|
if (!confirm("Delete this scheduled job?")) return;
|
|
const base = this.getApiBase();
|
|
await fetch(`${base}/api/jobs/${id}`, { method: "DELETE" });
|
|
await this.loadJobs();
|
|
}
|
|
|
|
private async runJob(id: string) {
|
|
this.runningJobId = id;
|
|
this.render();
|
|
const base = this.getApiBase();
|
|
try {
|
|
const res = await fetch(`${base}/api/jobs/${id}/run`, { method: "POST" });
|
|
const result = await res.json();
|
|
alert(result.success ? `Success: ${result.message}` : `Error: ${result.message}`);
|
|
} catch (e: any) {
|
|
alert(`Run failed: ${e.message}`);
|
|
}
|
|
this.runningJobId = null;
|
|
await this.loadJobs();
|
|
}
|
|
|
|
private async submitForm() {
|
|
const base = this.getApiBase();
|
|
const payload: Record<string, unknown> = {
|
|
name: this.formName,
|
|
description: this.formDescription,
|
|
cronExpression: this.formCron,
|
|
timezone: this.formTimezone,
|
|
actionType: this.formActionType,
|
|
actionConfig: { ...this.formConfig },
|
|
enabled: this.formEnabled,
|
|
};
|
|
|
|
const isEdit = !!this.editingJob;
|
|
const url = isEdit ? `${base}/api/jobs/${this.editingJob!.id}` : `${base}/api/jobs`;
|
|
const method = isEdit ? "PUT" : "POST";
|
|
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ error: "Request failed" }));
|
|
alert(err.error || "Failed to save job");
|
|
return;
|
|
}
|
|
|
|
this.view = "jobs";
|
|
this.editingJob = null;
|
|
await this.loadJobs();
|
|
}
|
|
|
|
private openCreateForm() {
|
|
this.editingJob = null;
|
|
this.formName = "";
|
|
this.formDescription = "";
|
|
this.formCron = "0 9 * * 1-5";
|
|
this.formTimezone = "America/Vancouver";
|
|
this.formActionType = "email";
|
|
this.formEnabled = true;
|
|
this.formConfig = {};
|
|
this.view = "form";
|
|
this.render();
|
|
}
|
|
|
|
private openEditForm(job: JobData) {
|
|
this.editingJob = job;
|
|
this.formName = job.name;
|
|
this.formDescription = job.description;
|
|
this.formCron = job.cronExpression;
|
|
this.formTimezone = job.timezone;
|
|
this.formActionType = job.actionType;
|
|
this.formEnabled = job.enabled;
|
|
this.formConfig = {};
|
|
if (job.actionConfig) {
|
|
for (const [k, v] of Object.entries(job.actionConfig)) {
|
|
this.formConfig[k] = String(v);
|
|
}
|
|
}
|
|
this.view = "form";
|
|
this.render();
|
|
}
|
|
|
|
private formatTime(ts: number | null): string {
|
|
if (!ts) return "—";
|
|
const d = new Date(ts);
|
|
const now = Date.now();
|
|
const diff = now - ts;
|
|
|
|
if (diff < 60_000) return "just now";
|
|
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
|
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)}h ago`;
|
|
|
|
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
|
}
|
|
|
|
private formatFuture(ts: number | null): string {
|
|
if (!ts) return "—";
|
|
const diff = ts - Date.now();
|
|
if (diff < 0) return "overdue";
|
|
if (diff < 60_000) return "< 1m";
|
|
if (diff < 3600_000) return `in ${Math.floor(diff / 60_000)}m`;
|
|
if (diff < 86400_000) return `in ${Math.floor(diff / 3600_000)}h`;
|
|
return `in ${Math.floor(diff / 86400_000)}d`;
|
|
}
|
|
|
|
private renderActionConfigFields(): string {
|
|
switch (this.formActionType) {
|
|
case "email":
|
|
return `
|
|
<label class="s-label">To <input type="email" class="s-input" data-config="to" value="${this.esc(this.formConfig.to || "")}" placeholder="user@example.com"></label>
|
|
<label class="s-label">Subject <input type="text" class="s-input" data-config="subject" value="${this.esc(this.formConfig.subject || "")}" placeholder="[rSchedule] {{jobName}}"></label>
|
|
<label class="s-label">Body (HTML) <textarea class="s-input s-textarea" data-config="bodyTemplate" placeholder="<p>Hello from {{jobName}}</p>">${this.esc(this.formConfig.bodyTemplate || "")}</textarea></label>
|
|
`;
|
|
case "webhook":
|
|
return `
|
|
<label class="s-label">URL <input type="url" class="s-input" data-config="url" value="${this.esc(this.formConfig.url || "")}" placeholder="https://example.com/webhook"></label>
|
|
<label class="s-label">Method
|
|
<select class="s-input" data-config="method">
|
|
<option value="POST" ${this.formConfig.method === "POST" || !this.formConfig.method ? "selected" : ""}>POST</option>
|
|
<option value="GET" ${this.formConfig.method === "GET" ? "selected" : ""}>GET</option>
|
|
<option value="PUT" ${this.formConfig.method === "PUT" ? "selected" : ""}>PUT</option>
|
|
</select>
|
|
</label>
|
|
<label class="s-label">Body Template <textarea class="s-input s-textarea" data-config="bodyTemplate" placeholder='{"job":"{{jobName}}","ts":"{{timestamp}}"}'>${this.esc(this.formConfig.bodyTemplate || "")}</textarea></label>
|
|
`;
|
|
case "calendar-event":
|
|
return `
|
|
<label class="s-label">Event Title <input type="text" class="s-input" data-config="title" value="${this.esc(this.formConfig.title || "")}" placeholder="Recurring meeting"></label>
|
|
<label class="s-label">Duration (minutes) <input type="number" class="s-input" data-config="duration" value="${this.esc(this.formConfig.duration || "60")}" min="5" max="1440"></label>
|
|
`;
|
|
case "broadcast":
|
|
return `
|
|
<label class="s-label">Channel <input type="text" class="s-input" data-config="channel" value="${this.esc(this.formConfig.channel || "")}" placeholder="default"></label>
|
|
<label class="s-label">Message <textarea class="s-input s-textarea" data-config="message" placeholder="Scheduled broadcast">${this.esc(this.formConfig.message || "")}</textarea></label>
|
|
`;
|
|
case "backlog-briefing":
|
|
return `
|
|
<label class="s-label">Mode
|
|
<select class="s-input" data-config="mode">
|
|
<option value="morning" ${this.formConfig.mode === "morning" || !this.formConfig.mode ? "selected" : ""}>Morning</option>
|
|
<option value="weekly" ${this.formConfig.mode === "weekly" ? "selected" : ""}>Weekly</option>
|
|
<option value="monthly" ${this.formConfig.mode === "monthly" ? "selected" : ""}>Monthly</option>
|
|
</select>
|
|
</label>
|
|
<label class="s-label">To <input type="email" class="s-input" data-config="to" value="${this.esc(this.formConfig.to || "")}" placeholder="user@example.com"></label>
|
|
`;
|
|
default:
|
|
return `<p style="color:#94a3b8">No configuration needed for this action type.</p>`;
|
|
}
|
|
}
|
|
|
|
private esc(s: string): string {
|
|
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
}
|
|
|
|
private render() {
|
|
const styles = `
|
|
<style>
|
|
:host { display:block; font-family:system-ui,-apple-system,sans-serif; color:#e2e8f0; }
|
|
.s-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:24px; flex-wrap:wrap; gap:12px; }
|
|
.s-title { font-size:1.5rem; font-weight:700; margin:0; }
|
|
.s-tabs { display:flex; gap:4px; background:rgba(30,41,59,0.6); border-radius:8px; padding:3px; }
|
|
.s-tab { padding:6px 16px; border-radius:6px; border:none; background:transparent; color:#94a3b8; cursor:pointer; font-size:13px; transition:all 0.15s; }
|
|
.s-tab:hover { color:#e2e8f0; }
|
|
.s-tab.active { background:rgba(245,158,11,0.15); color:#f59e0b; }
|
|
.s-btn { padding:8px 16px; border-radius:8px; border:none; cursor:pointer; font-size:13px; font-weight:600; transition:all 0.15s; }
|
|
.s-btn-primary { background:linear-gradient(135deg,#f59e0b,#f97316); color:#0f172a; }
|
|
.s-btn-primary:hover { opacity:0.9; }
|
|
.s-btn-secondary { background:rgba(51,65,85,0.6); color:#e2e8f0; }
|
|
.s-btn-secondary:hover { background:rgba(71,85,105,0.6); }
|
|
.s-btn-danger { background:rgba(239,68,68,0.15); color:#ef4444; }
|
|
.s-btn-danger:hover { background:rgba(239,68,68,0.25); }
|
|
.s-btn-sm { padding:4px 10px; font-size:12px; }
|
|
.s-btn:disabled { opacity:0.5; cursor:not-allowed; }
|
|
.s-table { width:100%; border-collapse:collapse; }
|
|
.s-table th { text-align:left; padding:10px 12px; font-size:11px; text-transform:uppercase; letter-spacing:0.05em; color:#64748b; border-bottom:2px solid #1e293b; }
|
|
.s-table td { padding:10px 12px; border-bottom:1px solid rgba(30,41,59,0.6); font-size:14px; vertical-align:middle; }
|
|
.s-table tr:hover td { background:rgba(30,41,59,0.3); }
|
|
.s-status { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:6px; }
|
|
.s-status-success { background:#22c55e; }
|
|
.s-status-error { background:#ef4444; }
|
|
.s-status-null { background:#475569; }
|
|
.s-toggle { position:relative; width:36px; height:20px; cursor:pointer; }
|
|
.s-toggle input { opacity:0; width:0; height:0; position:absolute; }
|
|
.s-toggle .slider { position:absolute; inset:0; background:#334155; border-radius:20px; transition:0.2s; }
|
|
.s-toggle .slider::before { content:''; position:absolute; height:14px; width:14px; left:3px; bottom:3px; background:#94a3b8; border-radius:50%; transition:0.2s; }
|
|
.s-toggle input:checked + .slider { background:rgba(245,158,11,0.3); }
|
|
.s-toggle input:checked + .slider::before { transform:translateX(16px); background:#f59e0b; }
|
|
.s-card { background:rgba(15,23,42,0.6); border:1px solid rgba(30,41,59,0.8); border-radius:12px; padding:24px; margin-bottom:16px; }
|
|
.s-form-grid { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
|
|
.s-form-full { grid-column:1/-1; }
|
|
.s-label { display:flex; flex-direction:column; gap:6px; font-size:13px; color:#94a3b8; font-weight:500; }
|
|
.s-input { padding:8px 12px; background:rgba(30,41,59,0.8); border:1px solid #334155; border-radius:6px; color:#e2e8f0; font-size:14px; font-family:inherit; }
|
|
.s-input:focus { outline:none; border-color:#f59e0b; }
|
|
.s-textarea { min-height:80px; resize:vertical; }
|
|
.s-actions { display:flex; gap:6px; flex-wrap:wrap; }
|
|
.s-empty { text-align:center; padding:48px 20px; color:#64748b; }
|
|
.s-loading { text-align:center; padding:48px; color:#94a3b8; }
|
|
.s-log-item { display:flex; gap:12px; padding:10px 0; border-bottom:1px solid rgba(30,41,59,0.6); font-size:13px; align-items:flex-start; }
|
|
.s-log-time { color:#64748b; white-space:nowrap; min-width:120px; }
|
|
.s-log-msg { color:#cbd5e1; flex:1; word-break:break-word; }
|
|
.s-log-dur { color:#64748b; white-space:nowrap; }
|
|
@media (max-width:768px) {
|
|
.s-form-grid { grid-template-columns:1fr; }
|
|
.s-table { font-size:12px; }
|
|
.s-table th, .s-table td { padding:8px 6px; }
|
|
}
|
|
</style>
|
|
`;
|
|
|
|
if (this.loading) {
|
|
this.shadow.innerHTML = `${styles}<div class="s-loading">Loading schedule...</div>`;
|
|
return;
|
|
}
|
|
|
|
let content = "";
|
|
|
|
if (this.view === "jobs") {
|
|
content = this.renderJobList();
|
|
} else if (this.view === "log") {
|
|
content = this.renderLog();
|
|
} else if (this.view === "form") {
|
|
content = this.renderForm();
|
|
}
|
|
|
|
this.shadow.innerHTML = `
|
|
${styles}
|
|
<div class="s-header">
|
|
<h1 class="s-title">rSchedule</h1>
|
|
<div class="s-tabs">
|
|
<button class="s-tab ${this.view === "jobs" ? "active" : ""}" data-view="jobs">Jobs</button>
|
|
<button class="s-tab ${this.view === "log" ? "active" : ""}" data-view="log">Execution Log</button>
|
|
</div>
|
|
${this.view === "jobs" ? `<button class="s-btn s-btn-primary" data-action="create">+ New Job</button>` : ""}
|
|
</div>
|
|
${content}
|
|
`;
|
|
|
|
this.attachListeners();
|
|
}
|
|
|
|
private renderJobList(): string {
|
|
if (this.jobs.length === 0) {
|
|
return `<div class="s-empty"><p>No scheduled jobs yet.</p><p style="margin-top:8px"><button class="s-btn s-btn-primary" data-action="create">Create your first job</button></p></div>`;
|
|
}
|
|
|
|
const rows = this.jobs.map((j) => `
|
|
<tr>
|
|
<td>
|
|
<label class="s-toggle">
|
|
<input type="checkbox" ${j.enabled ? "checked" : ""} data-toggle="${j.id}">
|
|
<span class="slider"></span>
|
|
</label>
|
|
</td>
|
|
<td>
|
|
<strong style="color:#e2e8f0">${this.esc(j.name)}</strong>
|
|
${j.description ? `<br><span style="color:#64748b;font-size:12px">${this.esc(j.description)}</span>` : ""}
|
|
</td>
|
|
<td style="font-family:monospace;font-size:12px;color:#94a3b8" title="${this.esc(j.cronExpression)}">${this.esc(j.cronHuman || j.cronExpression)}</td>
|
|
<td style="color:#94a3b8;font-size:13px">${this.esc(j.timezone)}</td>
|
|
<td><span style="background:rgba(245,158,11,0.1);color:#f59e0b;padding:2px 8px;border-radius:4px;font-size:12px">${this.esc(j.actionType)}</span></td>
|
|
<td>
|
|
<span class="s-status s-status-${j.lastRunStatus || "null"}"></span>
|
|
<span style="color:#94a3b8;font-size:13px">${this.formatTime(j.lastRunAt)}</span>
|
|
</td>
|
|
<td style="color:#94a3b8;font-size:13px">${this.formatFuture(j.nextRunAt)}</td>
|
|
<td>
|
|
<div class="s-actions">
|
|
<button class="s-btn s-btn-secondary s-btn-sm" data-run="${j.id}" ${this.runningJobId === j.id ? "disabled" : ""}>${this.runningJobId === j.id ? "Running..." : "Run Now"}</button>
|
|
<button class="s-btn s-btn-secondary s-btn-sm" data-edit="${j.id}">Edit</button>
|
|
<button class="s-btn s-btn-danger s-btn-sm" data-delete="${j.id}">Delete</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`).join("");
|
|
|
|
return `
|
|
<div style="overflow-x:auto">
|
|
<table class="s-table">
|
|
<thead>
|
|
<tr>
|
|
<th style="width:50px">On</th>
|
|
<th>Job</th>
|
|
<th>Schedule</th>
|
|
<th>Timezone</th>
|
|
<th>Action</th>
|
|
<th>Last Run</th>
|
|
<th>Next Run</th>
|
|
<th style="width:200px">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>${rows}</tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private renderLog(): string {
|
|
if (this.log.length === 0) {
|
|
return `<div class="s-empty"><p>No execution log entries yet.</p><p style="color:#64748b;margin-top:8px">Jobs will log their results here after they run.</p></div>`;
|
|
}
|
|
|
|
const jobNames = new Map(this.jobs.map((j) => [j.id, j.name]));
|
|
const entries = this.log.map((e) => `
|
|
<div class="s-log-item">
|
|
<span class="s-status s-status-${e.status}" style="margin-top:4px"></span>
|
|
<span class="s-log-time">${new Date(e.timestamp).toLocaleString()}</span>
|
|
<span style="color:#f59e0b;font-weight:500;min-width:140px">${this.esc(jobNames.get(e.jobId) || e.jobId)}</span>
|
|
<span class="s-log-msg">${this.esc(e.message)}</span>
|
|
<span class="s-log-dur">${e.durationMs}ms</span>
|
|
</div>
|
|
`).join("");
|
|
|
|
return `<div class="s-card">${entries}</div>`;
|
|
}
|
|
|
|
private renderForm(): string {
|
|
const isEdit = !!this.editingJob;
|
|
const presetOptions = CRON_PRESETS.map((p) =>
|
|
`<option value="${p.value}" ${this.formCron === p.value ? "selected" : ""}>${p.label}</option>`
|
|
).join("");
|
|
|
|
const actionOptions = ACTION_TYPES.map((a) =>
|
|
`<option value="${a.value}" ${this.formActionType === a.value ? "selected" : ""}>${a.label}</option>`
|
|
).join("");
|
|
|
|
return `
|
|
<div class="s-card">
|
|
<h2 style="margin:0 0 20px;font-size:1.1rem">${isEdit ? "Edit Job" : "Create New Job"}</h2>
|
|
<div class="s-form-grid">
|
|
<label class="s-label">Name <input type="text" class="s-input" id="f-name" value="${this.esc(this.formName)}" placeholder="My scheduled job"></label>
|
|
<label class="s-label">
|
|
Enabled
|
|
<label class="s-toggle" style="margin-top:4px">
|
|
<input type="checkbox" id="f-enabled" ${this.formEnabled ? "checked" : ""}>
|
|
<span class="slider"></span>
|
|
</label>
|
|
</label>
|
|
<label class="s-label s-form-full">Description <input type="text" class="s-input" id="f-desc" value="${this.esc(this.formDescription)}" placeholder="What this job does..."></label>
|
|
<label class="s-label">Cron Preset
|
|
<select class="s-input" id="f-preset">${presetOptions}</select>
|
|
</label>
|
|
<label class="s-label">Cron Expression <input type="text" class="s-input" id="f-cron" value="${this.esc(this.formCron)}" placeholder="* * * * *" style="font-family:monospace"></label>
|
|
<label class="s-label">Timezone <input type="text" class="s-input" id="f-tz" value="${this.esc(this.formTimezone)}" placeholder="America/Vancouver"></label>
|
|
<label class="s-label">Action Type
|
|
<select class="s-input" id="f-action">${actionOptions}</select>
|
|
</label>
|
|
<div class="s-form-full" style="border-top:1px solid #1e293b;padding-top:16px;margin-top:8px">
|
|
<h3 style="font-size:14px;color:#94a3b8;margin:0 0 12px">Action Configuration</h3>
|
|
<div class="s-form-grid" id="f-config-fields">
|
|
${this.renderActionConfigFields()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div style="display:flex;gap:8px;margin-top:24px">
|
|
<button class="s-btn s-btn-primary" data-action="submit">${isEdit ? "Update Job" : "Create Job"}</button>
|
|
<button class="s-btn s-btn-secondary" data-action="cancel">Cancel</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private attachListeners() {
|
|
// Tab switching
|
|
this.shadow.querySelectorAll<HTMLButtonElement>("[data-view]").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
this.view = btn.dataset.view as "jobs" | "log";
|
|
if (this.view === "log") this.loadLog();
|
|
else this.render();
|
|
});
|
|
});
|
|
|
|
// Create button
|
|
this.shadow.querySelectorAll<HTMLButtonElement>("[data-action='create']").forEach((btn) => {
|
|
btn.addEventListener("click", () => this.openCreateForm());
|
|
});
|
|
|
|
// Toggle
|
|
this.shadow.querySelectorAll<HTMLInputElement>("[data-toggle]").forEach((input) => {
|
|
input.addEventListener("change", () => {
|
|
this.toggleJob(input.dataset.toggle!, input.checked);
|
|
});
|
|
});
|
|
|
|
// Run
|
|
this.shadow.querySelectorAll<HTMLButtonElement>("[data-run]").forEach((btn) => {
|
|
btn.addEventListener("click", () => this.runJob(btn.dataset.run!));
|
|
});
|
|
|
|
// Edit
|
|
this.shadow.querySelectorAll<HTMLButtonElement>("[data-edit]").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const job = this.jobs.find((j) => j.id === btn.dataset.edit);
|
|
if (job) this.openEditForm(job);
|
|
});
|
|
});
|
|
|
|
// Delete
|
|
this.shadow.querySelectorAll<HTMLButtonElement>("[data-delete]").forEach((btn) => {
|
|
btn.addEventListener("click", () => this.deleteJob(btn.dataset.delete!));
|
|
});
|
|
|
|
// Form: cancel
|
|
this.shadow.querySelector<HTMLButtonElement>("[data-action='cancel']")?.addEventListener("click", () => {
|
|
this.view = "jobs";
|
|
this.render();
|
|
});
|
|
|
|
// Form: submit
|
|
this.shadow.querySelector<HTMLButtonElement>("[data-action='submit']")?.addEventListener("click", () => {
|
|
this.collectFormData();
|
|
this.submitForm();
|
|
});
|
|
|
|
// Form: preset selector
|
|
this.shadow.querySelector<HTMLSelectElement>("#f-preset")?.addEventListener("change", (e) => {
|
|
const val = (e.target as HTMLSelectElement).value;
|
|
if (val) {
|
|
this.formCron = val;
|
|
const cronInput = this.shadow.querySelector<HTMLInputElement>("#f-cron");
|
|
if (cronInput) cronInput.value = val;
|
|
}
|
|
});
|
|
|
|
// Form: action type change -> re-render config fields
|
|
this.shadow.querySelector<HTMLSelectElement>("#f-action")?.addEventListener("change", (e) => {
|
|
this.collectFormData();
|
|
this.formActionType = (e.target as HTMLSelectElement).value;
|
|
this.formConfig = {}; // reset config for new action type
|
|
const container = this.shadow.querySelector<HTMLDivElement>("#f-config-fields");
|
|
if (container) container.innerHTML = this.renderActionConfigFields();
|
|
this.attachConfigListeners();
|
|
});
|
|
|
|
this.attachConfigListeners();
|
|
}
|
|
|
|
private attachConfigListeners() {
|
|
this.shadow.querySelectorAll<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>("[data-config]").forEach((el) => {
|
|
el.addEventListener("input", () => {
|
|
this.formConfig[el.dataset.config!] = el.value;
|
|
});
|
|
el.addEventListener("change", () => {
|
|
this.formConfig[el.dataset.config!] = el.value;
|
|
});
|
|
});
|
|
}
|
|
|
|
private collectFormData() {
|
|
const getName = this.shadow.querySelector<HTMLInputElement>("#f-name");
|
|
const getDesc = this.shadow.querySelector<HTMLInputElement>("#f-desc");
|
|
const getCron = this.shadow.querySelector<HTMLInputElement>("#f-cron");
|
|
const getTz = this.shadow.querySelector<HTMLInputElement>("#f-tz");
|
|
const getAction = this.shadow.querySelector<HTMLSelectElement>("#f-action");
|
|
const getEnabled = this.shadow.querySelector<HTMLInputElement>("#f-enabled");
|
|
|
|
if (getName) this.formName = getName.value;
|
|
if (getDesc) this.formDescription = getDesc.value;
|
|
if (getCron) this.formCron = getCron.value;
|
|
if (getTz) this.formTimezone = getTz.value;
|
|
if (getAction) this.formActionType = getAction.value;
|
|
if (getEnabled) this.formEnabled = getEnabled.checked;
|
|
|
|
// Collect config fields
|
|
this.shadow.querySelectorAll<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>("[data-config]").forEach((el) => {
|
|
this.formConfig[el.dataset.config!] = el.value;
|
|
});
|
|
}
|
|
}
|
|
|
|
customElements.define("folk-schedule-app", FolkScheduleApp);
|