First Version

This commit is contained in:
2026-07-10 15:02:09 +01:00
commit 38494a85a9
562 changed files with 196313 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
import { html, nothing } from 'lit';
import { LightElement } from '../../lib/base.js';
import { toString as cronToString } from 'cronstrue';
import { formatDate } from './utils.js';
export class CronJobsSection extends LightElement {
static properties = {
_jobs: { state: true },
_error: { state: true },
};
constructor() {
super();
this._jobs = [];
this._error = null;
}
async load() {
this._error = null;
try {
const res = await fetch('/api/cron/jobs');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const allJobs = await res.json();
this._jobs = allJobs.filter(j => j.kind === 'cron' && !j.single_run);
} catch (e) {
this._error = e.message;
}
}
async _delete(job) {
if (!confirm(`Delete job "${job.title}"?`)) return;
try {
const res = await fetch(`/api/cron/jobs/${job.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text());
await this.load();
} catch (e) { this._error = e.message; }
}
async _toggle(job) {
try {
const res = await fetch(`/api/cron/jobs/${job.id}/toggle`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: !job.enabled }),
});
if (!res.ok) throw new Error(await res.text());
await this.load();
} catch (e) { this._error = e.message; }
}
_statusBadge(job) {
if (job.running_session_id != null)
return html`<span class="task-badge task-badge--running">running</span>`;
if (!job.enabled)
return html`<span class="task-badge task-badge--disabled">disabled</span>`;
return html`<span class="task-badge task-badge--idle">idle</span>`;
}
_renderCard(job) {
return html`
<div class="task-card ${job.enabled ? '' : 'task-card--disabled'}">
<div class="task-card-header">
<div class="task-card-title-row">
<span class="task-card-title">${job.title}</span>
${this._statusBadge(job)}
</div>
<button class="task-card-delete" title="Delete" @click=${() => this._delete(job)}>
<i class="bi bi-trash"></i>
</button>
</div>
${job.description ? html`<div class="task-card-desc">${job.description}</div>` : nothing}
<div class="task-card-expr">
<i class="bi bi-clock"></i>
<div class="task-card-expr-text">
<span class="task-card-human">${cronToString(job.cron)}</span>
<code class="task-card-raw">${job.cron}</code>
</div>
</div>
<div class="task-card-meta">
<div class="task-card-meta-item">
<span class="task-card-meta-label">Agent</span>
<span class="task-card-meta-value">${job.agent_id}</span>
</div>
<div class="task-card-meta-item">
<span class="task-card-meta-label">Last run</span>
<span class="task-card-meta-value">${formatDate(job.last_run_at)}</span>
</div>
<div class="task-card-meta-item">
<span class="task-card-meta-label">Next run</span>
<span class="task-card-meta-value">${formatDate(job.next_run_at)}</span>
</div>
</div>
<div class="task-card-footer">
<div class="form-check form-switch mb-0 task-card-toggle">
<input class="form-check-input" type="checkbox" role="switch"
.checked=${job.enabled}
@change=${() => this._toggle(job)} />
<span class="task-card-toggle-label">${job.enabled ? 'Enabled' : 'Disabled'}</span>
</div>
</div>
</div>
`;
}
render() {
return html`
<div class="task-page">
<div class="task-page-header">
<h2 class="task-page-title"><i class="bi bi-repeat"></i> Cron Jobs</h2>
<div style="font-size:0.82rem;color:var(--bs-secondary-color)">
${this._jobs.length} job${this._jobs.length !== 1 ? 's' : ''}
</div>
</div>
${this._error ? html`
<div class="alert alert-danger py-2 mx-3 mb-0" style="font-size:0.85rem">${this._error}</div>
` : nothing}
${this._jobs.length === 0 ? html`
<div class="task-empty">
<i class="bi bi-repeat"></i>
<p>No recurring cron jobs. Ask the agent to create one with <code>execute_task</code>.</p>
</div>
` : html`
<div class="task-grid">
${this._jobs.map(j => this._renderCard(j))}
</div>
`}
</div>
`;
}
}
+126
View File
@@ -0,0 +1,126 @@
import { html, nothing } from 'lit';
import { LightElement } from '../../lib/base.js';
import { formatDate, formatDuration } from './utils.js';
export class TaskHistorySection extends LightElement {
static properties = {
_runs: { state: true },
_error: { state: true },
_loading: { state: true },
_expanded: { state: true },
};
constructor() {
super();
this._runs = [];
this._error = null;
this._loading = false;
this._expanded = null;
}
async load() {
this._error = null;
this._loading = true;
try {
const res = await fetch('/api/cron/runs');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
this._runs = await res.json();
} catch (e) {
this._error = e.message;
} finally {
this._loading = false;
}
}
_statusClass(status) {
return { completed: 'success', failed: 'danger', cancelled: 'warning' }[status] ?? 'secondary';
}
_toggleExpand(id) {
this._expanded = this._expanded === id ? null : id;
}
_renderRow(run) {
const isExpanded = this._expanded === run.id;
return html`
<tr class="task-history-row ${isExpanded ? 'task-history-row--expanded' : ''}"
@click=${() => this._toggleExpand(run.id)}>
<td>
<span class="badge bg-${this._statusClass(run.status)}">${run.status}</span>
</td>
<td class="task-history-title">${run.job_title ?? `Job #${run.job_id}`}</td>
<td>${run.agent_id ?? '—'}</td>
<td>${formatDate(run.completed_at)}</td>
<td>${formatDuration(run.duration_ms)}</td>
<td><i class="bi bi-chevron-${isExpanded ? 'up' : 'down'} task-history-chevron"></i></td>
</tr>
${isExpanded ? html`
<tr class="task-history-detail">
<td colspan="6">
${run.session_id != null ? html`
<div style="margin-bottom:0.5rem">
<a href="#session/${run.session_id}"
style="font-size:0.8rem;font-family:var(--bs-font-monospace)">
<i class="bi bi-chat-text me-1"></i>View session #${run.session_id}
</a>
</div>
` : nothing}
${run.error ? html`
<div class="task-history-error"><strong>Error:</strong> ${run.error}</div>
` : nothing}
${run.final_response ? html`
<div class="task-history-response"><pre>${run.final_response}</pre></div>
` : nothing}
${!run.error && !run.final_response ? html`
<div class="text-muted" style="font-size:0.82rem">No output recorded.</div>
` : nothing}
</td>
</tr>
` : nothing}
`;
}
render() {
return html`
<div class="task-page">
<div class="task-page-header">
<h2 class="task-page-title"><i class="bi bi-journal-text"></i> History</h2>
<div style="font-size:0.82rem;color:var(--bs-secondary-color)">
${this._runs.length} run${this._runs.length !== 1 ? 's' : ''}
</div>
</div>
${this._error ? html`
<div class="alert alert-danger py-2 mx-3 mb-2" style="font-size:0.85rem">${this._error}</div>
` : nothing}
${this._loading ? html`
<div class="task-empty"><i class="bi bi-hourglass-split"></i><p>Loading…</p></div>
` : this._runs.length === 0 ? html`
<div class="task-empty">
<i class="bi bi-journal-text"></i>
<p>No completed runs yet.</p>
</div>
` : html`
<div class="task-history-table-wrap">
<table class="task-history-table">
<thead>
<tr>
<th>Status</th>
<th>Task</th>
<th>Agent</th>
<th>Completed</th>
<th>Duration</th>
<th></th>
</tr>
</thead>
<tbody>
${this._runs.map(r => this._renderRow(r))}
</tbody>
</table>
</div>
`}
</div>
`;
}
}
+82
View File
@@ -0,0 +1,82 @@
import { html, nothing } from 'lit';
import { LightElement } from '../../lib/base.js';
import { RunningTasksSection } from './running.js';
import { CronJobsSection } from './cron.js';
import { ScheduledTasksSection } from './scheduled.js';
import { TaskHistorySection } from './history.js';
const SECTIONS = ['running', 'cron', 'scheduled', 'history'];
export class TasksPage extends LightElement {
static properties = {
_open: { state: true },
_section: { state: true },
};
constructor() {
super();
this._open = false;
this._section = 'running';
}
connectedCallback() {
super.connectedCallback();
window.addEventListener('llm-page-change', (e) => {
const open = e.detail.page === 'tasks';
this._open = open;
this.style.display = open ? 'flex' : 'none';
if (open) {
const sec = this._sectionFromHash();
this._section = sec;
this._loadSection(sec);
if (!location.hash.includes('/')) {
history.replaceState({ page: 'tasks', section: sec }, '', '#tasks/' + sec);
}
}
});
window.addEventListener('tasks-section-change', (e) => {
if (!this._open) return;
const sec = e.detail.section;
this._section = sec;
this._loadSection(sec);
});
}
_sectionFromHash() {
const parts = location.hash.slice(1).split('/');
if (parts[0] === 'tasks' && parts[1]) {
return SECTIONS.includes(parts[1]) ? parts[1] : 'running';
}
return 'running';
}
_loadSection(sec) {
this.updateComplete.then(() => {
const el = this.querySelector(`[data-section="${sec}"]`);
if (el?.load) el.load();
});
}
render() {
if (!this._open) return nothing;
return html`
${this._section === 'running'
? html`<task-running-section data-section="running"></task-running-section>`
: nothing}
${this._section === 'cron'
? html`<task-cron-jobs-section data-section="cron"></task-cron-jobs-section>`
: nothing}
${this._section === 'scheduled'
? html`<task-scheduled-section data-section="scheduled"></task-scheduled-section>`
: nothing}
${this._section === 'history'
? html`<task-history-section data-section="history"></task-history-section>`
: nothing}
`;
}
}
customElements.define('task-running-section', RunningTasksSection);
customElements.define('task-cron-jobs-section', CronJobsSection);
customElements.define('task-scheduled-section', ScheduledTasksSection);
customElements.define('task-history-section', TaskHistorySection);
+151
View File
@@ -0,0 +1,151 @@
import { html, nothing } from 'lit';
import { LightElement } from '../../lib/base.js';
import { formatDate, formatElapsed } from './utils.js';
export class RunningTasksSection extends LightElement {
static properties = {
_jobs: { state: true },
_error: { state: true },
_tick: { state: true },
_killing: { state: true },
};
constructor() {
super();
this._jobs = [];
this._error = null;
this._tick = 0;
this._killing = new Set();
this._timer = null;
this._pollTimer = null;
}
connectedCallback() {
super.connectedCallback();
this._timer = setInterval(() => { this._tick++; }, 1000);
this._pollTimer = setInterval(() => this.load(), 10000);
}
disconnectedCallback() {
super.disconnectedCallback();
clearInterval(this._timer);
clearInterval(this._pollTimer);
}
async load() {
this._error = null;
try {
const res = await fetch('/api/cron/jobs');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const all = await res.json();
this._jobs = all.filter(j => j.running_session_id != null);
} catch (e) {
this._error = e.message;
}
}
async _kill(job) {
const confirmed = window.confirm(
`Terminate task "${job.title}"?\n\nThe task will be stopped immediately and recorded as failed.`
);
if (!confirmed) return;
this._killing = new Set([...this._killing, job.id]);
try {
const res = await fetch(`/api/cron/jobs/${job.id}/kill`, { method: 'POST' });
if (!res.ok) {
const text = await res.text();
throw new Error(text || `HTTP ${res.status}`);
}
await this.load();
} catch (e) {
this._error = `Failed to terminate task: ${e.message}`;
} finally {
const next = new Set(this._killing);
next.delete(job.id);
this._killing = next;
}
}
_kindLabel(job) {
if (job.kind === 'cron' && !job.single_run)
return html`<span class="task-badge task-badge--cron">cron</span>`;
if (job.kind === 'cron' && job.single_run)
return html`<span class="task-badge task-badge--oneshot">one-shot</span>`;
return html`<span class="task-badge task-badge--async">async</span>`;
}
_renderCard(job) {
const killing = this._killing.has(job.id);
return html`
<div class="task-card task-card--running">
<div class="task-card-header">
<div class="task-card-title-row">
<span class="task-card-title">${job.title}</span>
${this._kindLabel(job)}
<span class="task-badge task-badge--running">running</span>
</div>
<button
class="task-kill-btn"
title="Terminate task"
?disabled=${killing}
@click=${() => this._kill(job)}
>
${killing
? html`<i class="bi bi-hourglass-split"></i>`
: html`<i class="bi bi-x-lg"></i>`}
</button>
</div>
${job.description ? html`<div class="task-card-desc">${job.description}</div>` : nothing}
<div class="task-running-elapsed">
<i class="bi bi-stopwatch"></i>
<span>${formatElapsed(job.running_since)}</span>
<span class="task-running-since">since ${formatDate(job.running_since)}</span>
</div>
<div class="task-card-meta">
<div class="task-card-meta-item">
<span class="task-card-meta-label">Agent</span>
<span class="task-card-meta-value">${job.agent_id}</span>
</div>
<div class="task-card-meta-item">
<span class="task-card-meta-label">Session</span>
<a href="#session/${job.running_session_id}"
class="task-card-meta-value task-card-session-link">#${job.running_session_id}</a>
</div>
</div>
</div>
`;
}
render() {
void this._tick; // drives re-render every second for live elapsed time
return html`
<div class="task-page">
<div class="task-page-header">
<h2 class="task-page-title"><i class="bi bi-activity"></i> Running Tasks</h2>
<div style="font-size:0.82rem;color:var(--bs-secondary-color)">
${this._jobs.length} running
</div>
</div>
${this._error ? html`
<div class="alert alert-danger py-2 mx-3 mb-0" style="font-size:0.85rem">${this._error}</div>
` : nothing}
${this._jobs.length === 0 ? html`
<div class="task-empty">
<i class="bi bi-activity"></i>
<p>No tasks currently running.</p>
</div>
` : html`
<div class="task-grid">
${this._jobs.map(j => this._renderCard(j))}
</div>
`}
</div>
`;
}
}
+126
View File
@@ -0,0 +1,126 @@
import { html, nothing } from 'lit';
import { LightElement } from '../../lib/base.js';
import { toString as cronToString } from 'cronstrue';
import { formatDate } from './utils.js';
export class ScheduledTasksSection extends LightElement {
static properties = {
_jobs: { state: true },
_error: { state: true },
};
constructor() {
super();
this._jobs = [];
this._error = null;
}
async load() {
this._error = null;
try {
const res = await fetch('/api/cron/jobs');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const all = await res.json();
// one-shot scheduled or async/immediate, still active (pending or running)
this._jobs = all.filter(j =>
(j.kind !== 'cron' || j.single_run) &&
(j.enabled || j.running_session_id != null)
);
} catch (e) {
this._error = e.message;
}
}
async _delete(job) {
if (!confirm(`Delete task "${job.title}"?`)) return;
try {
const res = await fetch(`/api/cron/jobs/${job.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text());
await this.load();
} catch (e) { this._error = e.message; }
}
_statusBadge(job) {
if (job.running_session_id != null)
return html`<span class="task-badge task-badge--running">running</span>`;
if (job.kind === 'cron' && job.single_run)
return html`<span class="task-badge task-badge--pending">pending</span>`;
return html`<span class="task-badge task-badge--pending">queued</span>`;
}
_kindLabel(job) {
if (job.kind === 'cron' && job.single_run)
return html`<span class="task-badge task-badge--oneshot">one-shot</span>`;
if (job.kind === 'async')
return html`<span class="task-badge task-badge--async">async</span>`;
return nothing;
}
_renderCard(job) {
return html`
<div class="task-card">
<div class="task-card-header">
<div class="task-card-title-row">
<span class="task-card-title">${job.title}</span>
${this._kindLabel(job)}
${this._statusBadge(job)}
</div>
<button class="task-card-delete" title="Delete" @click=${() => this._delete(job)}>
<i class="bi bi-trash"></i>
</button>
</div>
${job.description ? html`<div class="task-card-desc">${job.description}</div>` : nothing}
${job.kind === 'cron' && job.cron ? html`
<div class="task-card-expr">
<i class="bi bi-clock"></i>
<div class="task-card-expr-text">
<span class="task-card-human">${cronToString(job.cron)}</span>
<code class="task-card-raw">${job.cron}</code>
</div>
</div>
` : nothing}
<div class="task-card-meta">
<div class="task-card-meta-item">
<span class="task-card-meta-label">Agent</span>
<span class="task-card-meta-value">${job.agent_id}</span>
</div>
<div class="task-card-meta-item">
<span class="task-card-meta-label">${job.next_run_at ? 'Scheduled at' : 'Created'}</span>
<span class="task-card-meta-value">${formatDate(job.next_run_at || job.created_at)}</span>
</div>
</div>
</div>
`;
}
render() {
return html`
<div class="task-page">
<div class="task-page-header">
<h2 class="task-page-title"><i class="bi bi-clock"></i> Scheduled Tasks</h2>
<div style="font-size:0.82rem;color:var(--bs-secondary-color)">
${this._jobs.length} task${this._jobs.length !== 1 ? 's' : ''}
</div>
</div>
${this._error ? html`
<div class="alert alert-danger py-2 mx-3 mb-0" style="font-size:0.85rem">${this._error}</div>
` : nothing}
${this._jobs.length === 0 ? html`
<div class="task-empty">
<i class="bi bi-clock"></i>
<p>No pending or running tasks.</p>
</div>
` : html`
<div class="task-grid">
${this._jobs.map(j => this._renderCard(j))}
</div>
`}
</div>
`;
}
}
+33
View File
@@ -0,0 +1,33 @@
export function formatDate(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleString(undefined, {
day: '2-digit', month: '2-digit', year: '2-digit',
hour: '2-digit', minute: '2-digit',
});
}
export function formatDuration(ms) {
if (ms == null) return '—';
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
const m = Math.floor(ms / 60000);
const s = Math.round((ms % 60000) / 1000);
return `${m}m ${s}s`;
}
// SQLite stores UTC without timezone suffix — normalise before parsing.
export function parseUtc(iso) {
if (!iso) return null;
return new Date(iso.replace(' ', 'T') + 'Z');
}
export function formatElapsed(since) {
const d = parseUtc(since);
if (!d) return '—';
const s = Math.floor((Date.now() - d.getTime()) / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ${s % 60}s`;
const h = Math.floor(m / 60);
return `${h}h ${m % 60}m`;
}