First Version
This commit is contained in:
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
Reference in New Issue
Block a user