refactor: remove old card view for jobs, replace with expandable table. Use table comp for history

This commit is contained in:
Sam Chau
2026-01-18 17:32:57 +10:30
parent efcc30f8c9
commit ee65444717
3 changed files with 268 additions and 400 deletions

View File

@@ -1,9 +1,75 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { alertStore } from '$alerts/store';
import type { PageData } from './$types';
import JobCard from './components/JobCard.svelte';
import type { Column } from '$lib/client/ui/table/types';
import ExpandableTable from '$lib/client/ui/table/ExpandableTable.svelte';
import TableActionButton from '$lib/client/ui/table/TableActionButton.svelte';
import Badge from '$lib/client/ui/badge/Badge.svelte';
import JobHistory from './components/JobHistory.svelte';
import { Play, Edit2, Power, CheckCircle, XCircle, AlertCircle } from 'lucide-svelte';
export let data: PageData;
type Job = (typeof data.jobs)[0];
const columns: Column<Job>[] = [
{ key: 'name', header: 'Name', sortable: true },
{ key: 'enabled', header: 'Status', sortable: true, width: 'w-24' },
{ key: 'scheduleDisplay', header: 'Schedule', sortable: true },
{ key: 'last_run_at', header: 'Last Run', sortable: true },
{ key: 'next_run_at', header: 'Next Run', sortable: true }
];
// Format job name: sync_databases -> Sync Databases
function formatJobName(name: string): string {
return name
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
// Format duration in ms to human readable
function formatDuration(ms: number | null): string {
if (!ms) return '-';
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${(ms / 60000).toFixed(1)}m`;
}
// Format date/time
function formatDateTime(dateStr: string | null): string {
if (!dateStr) return 'Never';
return new Date(dateStr).toLocaleString();
}
// Get relative time (e.g., "in 5 minutes", "2 hours ago")
function getRelativeTime(dateStr: string | null): string {
if (!dateStr) return '-';
const date = new Date(dateStr);
const now = new Date();
const diff = date.getTime() - now.getTime();
const absDiff = Math.abs(diff);
const seconds = Math.floor(absDiff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const isPast = diff < 0;
if (days > 0) {
return isPast ? `${days}d ago` : `in ${days}d`;
}
if (hours > 0) {
return isPast ? `${hours}h ago` : `in ${hours}h`;
}
if (minutes > 0) {
return isPast ? `${minutes}m ago` : `in ${minutes}m`;
}
return isPast ? `${seconds}s ago` : `in ${seconds}s`;
}
</script>
<div class="p-8">
@@ -15,17 +81,164 @@
</p>
</div>
<!-- Jobs List -->
<div class="mb-8 grid grid-cols-1 gap-4 md:grid-cols-2">
{#each data.jobs as job (job.id)}
<JobCard {job} />
{:else}
<div
class="col-span-full rounded-lg border border-neutral-200 bg-white p-8 text-center dark:border-neutral-800 dark:bg-neutral-900"
>
<p class="text-neutral-600 dark:text-neutral-400">No background jobs configured</p>
</div>
{/each}
<!-- Jobs Table -->
<div class="mb-8">
<ExpandableTable
{columns}
data={data.jobs}
getRowId={(job) => job.id}
emptyMessage="No background jobs configured"
flushExpanded
chevronPosition="right"
>
<svelte:fragment slot="cell" let:row let:column>
{#if column.key === 'name'}
<span class="font-medium">{formatJobName(row.name)}</span>
{:else if column.key === 'enabled'}
<Badge variant={row.enabled ? 'accent' : 'neutral'}>
{row.enabled ? 'Enabled' : 'Disabled'}
</Badge>
{:else if column.key === 'scheduleDisplay'}
<Badge variant="neutral" mono>{row.scheduleDisplay}</Badge>
{:else if column.key === 'last_run_at'}
<div class="flex items-center gap-2">
<Badge variant="neutral" mono>{getRelativeTime(row.last_run_at)}</Badge>
{#if row.last_run_status === 'success'}
<Badge variant="success" icon={CheckCircle}>Success</Badge>
{:else if row.last_run_status === 'failure'}
<Badge variant="danger" icon={XCircle}>Failed</Badge>
{/if}
</div>
{:else if column.key === 'next_run_at'}
{#if row.enabled}
<Badge variant="neutral" mono>{getRelativeTime(row.next_run_at)}</Badge>
{:else}
<span class="text-neutral-400 dark:text-neutral-600">-</span>
{/if}
{/if}
</svelte:fragment>
<svelte:fragment slot="actions" let:row>
<div class="flex items-center justify-end gap-1">
<!-- Edit Button -->
<a href="/settings/jobs/{row.id}/edit">
<TableActionButton icon={Edit2} title="Edit job" variant="neutral" size="sm" />
</a>
<!-- Run Now Button -->
<form
method="POST"
action="?/trigger"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
alertStore.add(
'error',
(result.data as { error?: string }).error || 'Failed to trigger job'
);
} else if (result.type === 'success') {
alertStore.add('success', `Job "${formatJobName(row.name)}" triggered`);
}
await update();
};
}}
>
<input type="hidden" name="job_name" value={row.name} />
<button type="submit" disabled={!row.enabled} class="disabled:opacity-50 disabled:cursor-not-allowed">
<TableActionButton icon={Play} title="Run now" variant="accent" size="sm" />
</button>
</form>
<!-- Enable/Disable Button -->
<form
method="POST"
action="?/toggleEnabled"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
alertStore.add(
'error',
(result.data as { error?: string }).error || 'Failed to update job'
);
} else if (result.type === 'success') {
alertStore.add('success', `Job ${row.enabled ? 'disabled' : 'enabled'}`);
}
await update();
};
}}
>
<input type="hidden" name="job_id" value={row.id} />
<input type="hidden" name="enabled" value={!row.enabled} />
<button
type="submit"
class="inline-flex h-6 w-6 items-center justify-center rounded border transition-colors
{row.enabled
? 'border-emerald-300 bg-emerald-50 text-emerald-600 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-400 dark:hover:bg-emerald-900/40'
: 'border-neutral-300 bg-white text-neutral-400 hover:border-emerald-300 hover:bg-emerald-50 hover:text-emerald-600 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-500 dark:hover:border-emerald-700 dark:hover:bg-emerald-900/20 dark:hover:text-emerald-400'}"
title={row.enabled ? 'Disable job' : 'Enable job'}
>
<Power size={12} />
</button>
</form>
</div>
</svelte:fragment>
<svelte:fragment slot="expanded" let:row>
<div class="space-y-3 px-6 py-4 text-sm">
<!-- Description -->
{#if row.description}
<p class="text-neutral-600 dark:text-neutral-400">{row.description}</p>
{/if}
<div class="grid grid-cols-3 gap-4">
<!-- Last Run Details -->
<div>
<div class="text-xs font-medium uppercase tracking-wider text-neutral-500 dark:text-neutral-500">
Last Run
</div>
<div class="mt-1">
<Badge variant="neutral" mono>{formatDateTime(row.last_run_at)}</Badge>
</div>
</div>
<!-- Duration -->
<div>
<div class="text-xs font-medium uppercase tracking-wider text-neutral-500 dark:text-neutral-500">
Duration
</div>
<div class="mt-1">
<Badge variant="neutral" mono>{formatDuration(row.last_run_duration)}</Badge>
</div>
</div>
<!-- Next Run Details -->
<div>
<div class="text-xs font-medium uppercase tracking-wider text-neutral-500 dark:text-neutral-500">
Next Run
</div>
<div class="mt-1">
{#if row.enabled}
<Badge variant="neutral" mono>{formatDateTime(row.next_run_at)}</Badge>
{:else}
<Badge variant="neutral">Disabled</Badge>
{/if}
</div>
</div>
</div>
<!-- Error Message -->
{#if row.last_run_error}
<div class="flex items-start gap-2 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-900/20">
<AlertCircle size={16} class="mt-0.5 flex-shrink-0 text-red-600 dark:text-red-400" />
<div>
<div class="text-xs font-medium text-red-800 dark:text-red-200">Last Run Error</div>
<div class="mt-1 text-sm text-red-700 dark:text-red-300">{row.last_run_error}</div>
</div>
</div>
{/if}
</div>
</svelte:fragment>
</ExpandableTable>
</div>
<!-- Job History -->

View File

@@ -1,260 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { alertStore } from '$alerts/store';
import { Play, CheckCircle, XCircle, AlertCircle, Edit2, Power } from 'lucide-svelte';
export let job: {
id: number;
name: string;
description: string | null;
schedule: string;
scheduleDisplay: string;
enabled: boolean;
last_run_at: string | null;
next_run_at: string | null;
last_run_status: string | null;
last_run_duration: number | null;
last_run_error: string | null;
};
// Format duration in ms to human readable
function formatDuration(ms: number | null): string {
if (!ms) return '-';
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${(ms / 60000).toFixed(1)}m`;
}
// Format date/time
function formatDateTime(dateStr: string | null): string {
if (!dateStr) return 'Never';
return new Date(dateStr).toLocaleString();
}
// Format job name: sync_databases -> Sync Databases
function formatJobName(name: string): string {
return name
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
// Get relative time (e.g., "in 5 minutes", "2 hours ago")
function getRelativeTime(dateStr: string | null): string {
if (!dateStr) return 'Not scheduled';
const date = new Date(dateStr);
const now = new Date();
const diff = date.getTime() - now.getTime();
const absDiff = Math.abs(diff);
const seconds = Math.floor(absDiff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const isPast = diff < 0;
if (days > 0) {
return isPast ? `${days}d ago` : `in ${days}d`;
}
if (hours > 0) {
return isPast ? `${hours}h ago` : `in ${hours}h`;
}
if (minutes > 0) {
return isPast ? `${minutes}m ago` : `in ${minutes}m`;
}
return isPast ? `${seconds}s ago` : `in ${seconds}s`;
}
</script>
<div
class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900"
>
<!-- Job Header -->
<div class="border-b border-neutral-200 px-6 py-4 dark:border-neutral-800">
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
<div class="flex items-center gap-3">
<h3 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">
{formatJobName(job.name)}
</h3>
<!-- Enabled/Disabled Badge -->
<span
class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium {job.enabled
? 'bg-accent-100 text-accent-800 dark:bg-accent-900/40 dark:text-accent-100'
: 'bg-neutral-100 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-100'}"
>
{job.enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
{#if job.description}
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
{job.description}
</p>
{/if}
</div>
<!-- Action Buttons -->
<div class="flex items-center gap-2">
<!-- Edit Button -->
<a
href="/settings/jobs/{job.id}/edit"
class="inline-flex h-7 w-7 items-center justify-center rounded-lg border border-neutral-300 bg-white text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
title="Edit job"
>
<Edit2 size={14} />
</a>
<!-- Run Now Button -->
<form
method="POST"
action="?/trigger"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
alertStore.add(
'error',
(result.data as { error?: string }).error || 'Failed to trigger job'
);
} else if (result.type === 'success') {
alertStore.add('success', `Job "${formatJobName(job.name)}" triggered successfully`);
}
await update();
};
}}
>
<input type="hidden" name="job_name" value={job.name} />
<button
type="submit"
disabled={!job.enabled}
class="inline-flex h-7 w-7 items-center justify-center rounded-lg border border-neutral-300 bg-white text-neutral-700 transition-colors hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700"
title="Run now"
>
<Play size={14} />
</button>
</form>
<!-- Enable/Disable Button -->
<form
method="POST"
action="?/toggleEnabled"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure' && result.data) {
alertStore.add(
'error',
(result.data as { error?: string }).error || 'Failed to update job'
);
} else if (result.type === 'success') {
alertStore.add('success', `Job ${job.enabled ? 'disabled' : 'enabled'}`);
}
await update();
};
}}
>
<input type="hidden" name="job_id" value={job.id} />
<input type="hidden" name="enabled" value={!job.enabled} />
<button
type="submit"
class="inline-flex h-7 w-7 items-center justify-center rounded-lg border border-neutral-300 bg-white transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:bg-neutral-700 {job.enabled
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'}"
title={job.enabled ? 'Disable job' : 'Enable job'}
>
<Power size={14} />
</button>
</form>
</div>
</div>
</div>
<!-- Job Details Table -->
<div class="overflow-x-auto">
<table class="w-full text-xs">
<thead>
<tr>
<th class="px-4 py-2 text-left font-semibold text-neutral-900 dark:text-neutral-50">
Schedule
</th>
<th class="px-4 py-2 text-left font-semibold text-neutral-900 dark:text-neutral-50">
Last Run
</th>
<th class="px-4 py-2 text-left font-semibold text-neutral-900 dark:text-neutral-50">
Next Run
</th>
</tr>
</thead>
<tbody>
<tr>
<!-- Schedule -->
<td class="border-t border-neutral-200 px-4 py-3 align-top dark:border-neutral-800">
<code
class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-xs text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100"
>
{job.scheduleDisplay}
</code>
</td>
<!-- Last Run -->
<td class="border-t border-neutral-200 px-4 py-3 align-top dark:border-neutral-800">
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<code
class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-xs text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100"
>
{formatDateTime(job.last_run_at)}
</code>
{#if job.last_run_status === 'success'}
<span
class="inline-flex items-center gap-1 rounded bg-green-100 px-1.5 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900/40 dark:text-green-100"
>
<CheckCircle size={10} />
Success
</span>
{:else if job.last_run_status === 'failure'}
<span
class="inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900/40 dark:text-red-100"
>
<XCircle size={10} />
Failed
</span>
{/if}
</div>
{#if job.last_run_duration}
<div class="text-xs text-neutral-500 dark:text-neutral-400">
Duration: <code
class="rounded bg-neutral-100 px-1 py-0.5 font-mono text-xs text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100"
>{formatDuration(job.last_run_duration)}</code
>
</div>
{/if}
{#if job.last_run_error}
<div class="flex items-start gap-1 text-xs text-red-600 dark:text-red-400">
<AlertCircle size={12} class="mt-0.5 flex-shrink-0" />
<span class="line-clamp-2">{job.last_run_error}</span>
</div>
{/if}
</div>
</td>
<!-- Next Run -->
<td class="border-t border-neutral-200 px-4 py-3 align-top dark:border-neutral-800">
<div class="flex flex-col gap-1">
<code
class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-xs text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100"
>
{getRelativeTime(job.next_run_at)}
</code>
<div class="text-xs text-neutral-500 dark:text-neutral-400">
{formatDateTime(job.next_run_at)}
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>

View File

@@ -1,7 +1,10 @@
<script lang="ts">
import type { Column } from '$lib/client/ui/table/types';
import Table from '$lib/client/ui/table/Table.svelte';
import Badge from '$lib/client/ui/badge/Badge.svelte';
import { CheckCircle, XCircle, Clock } from 'lucide-svelte';
export let jobRuns: Array<{
type JobRun = {
id: number;
job_id: number;
job_name: string;
@@ -11,7 +14,17 @@
duration_ms: number;
error: string | null;
output: string | null;
}>;
};
export let jobRuns: JobRun[];
const columns: Column<JobRun>[] = [
{ key: 'job_name', header: 'Job', sortable: true },
{ key: 'status', header: 'Status', sortable: true, width: 'w-28' },
{ key: 'started_at', header: 'Started', sortable: true },
{ key: 'duration_ms', header: 'Duration', sortable: true, width: 'w-28' },
{ key: 'output', header: 'Output' }
];
// Format duration in ms to human readable
function formatDuration(ms: number): string {
@@ -20,11 +33,6 @@
return `${(ms / 60000).toFixed(1)}m`;
}
// Format date/time
function formatDateTime(dateStr: string): string {
return new Date(dateStr).toLocaleString();
}
// Format job name: sync_databases -> Sync Databases
function formatJobName(name: string): string {
return name
@@ -51,128 +59,35 @@
}
</script>
<div
class="rounded-lg border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900"
>
<!-- Header -->
<div class="border-b border-neutral-200 px-6 py-4 dark:border-neutral-800">
<div class="flex items-center gap-2">
<Clock size={18} class="text-neutral-600 dark:text-neutral-400" />
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">Recent Job Runs</h2>
</div>
<div class="space-y-4">
<div class="flex items-center gap-2">
<Clock size={18} class="text-neutral-600 dark:text-neutral-400" />
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-50">Recent Job Runs</h2>
</div>
<!-- Table -->
<div class="overflow-x-auto">
<table class="w-full text-xs">
<thead>
<tr>
<th
class="border-b border-neutral-200 px-4 py-3 text-left font-semibold text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
Job
</th>
<th
class="border-b border-neutral-200 px-4 py-3 text-left font-semibold text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
Status
</th>
<th
class="border-b border-neutral-200 px-4 py-3 text-left font-semibold text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
Started
</th>
<th
class="border-b border-neutral-200 px-4 py-3 text-left font-semibold text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
Duration
</th>
<th
class="border-b border-neutral-200 px-4 py-3 text-left font-semibold text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
Output
</th>
</tr>
</thead>
<tbody class="[&_tr:last-child_td]:border-b-0">
{#each jobRuns as run (run.id)}
<tr class="hover:bg-neutral-50 dark:hover:bg-neutral-800">
<!-- Job Name -->
<td
class="border-b border-neutral-200 px-4 py-2 text-neutral-900 dark:border-neutral-800 dark:text-neutral-50"
>
{formatJobName(run.job_name)}
</td>
<!-- Status -->
<td class="border-b border-neutral-200 px-4 py-2 dark:border-neutral-800">
{#if run.status === 'success'}
<span
class="inline-flex items-center gap-1 rounded bg-green-100 px-1.5 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900/40 dark:text-green-100"
>
<CheckCircle size={10} />
Success
</span>
{:else}
<span
class="inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900/40 dark:text-red-100"
>
<XCircle size={10} />
Failed
</span>
{/if}
</td>
<!-- Started At -->
<td
class="border-b border-neutral-200 px-4 py-2 text-neutral-600 dark:border-neutral-800 dark:text-neutral-400"
>
<div class="flex flex-col gap-0.5">
<code
class="rounded bg-neutral-100 px-1 py-0.5 font-mono text-xs text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100"
>
{getRelativeTime(run.started_at)}
</code>
<span class="text-xs text-neutral-500 dark:text-neutral-500">
{formatDateTime(run.started_at)}
</span>
</div>
</td>
<!-- Duration -->
<td
class="border-b border-neutral-200 px-4 py-2 text-neutral-600 dark:border-neutral-800 dark:text-neutral-400"
>
<code
class="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-xs text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100"
>
{formatDuration(run.duration_ms)}
</code>
</td>
<!-- Output/Error -->
<td
class="border-b border-neutral-200 px-4 py-2 text-neutral-600 dark:border-neutral-800 dark:text-neutral-400"
>
{#if run.error}
<div class="flex items-start gap-1 text-xs text-red-600 dark:text-red-400">
<span class="line-clamp-2">{run.error}</span>
</div>
{:else if run.output}
<span class="line-clamp-2 text-xs">{run.output}</span>
{:else}
<span class="text-neutral-400">-</span>
{/if}
</td>
</tr>
<Table {columns} data={jobRuns} emptyMessage="No job runs yet" compact>
<svelte:fragment slot="cell" let:row let:column>
{#if column.key === 'job_name'}
<span class="text-xs font-medium">{formatJobName(row.job_name)}</span>
{:else if column.key === 'status'}
{#if row.status === 'success'}
<Badge variant="success" icon={CheckCircle}>Success</Badge>
{:else}
<tr>
<td colspan="5" class="px-4 py-8 text-center text-neutral-500 dark:text-neutral-400">
No job runs yet
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<Badge variant="danger" icon={XCircle}>Failed</Badge>
{/if}
{:else if column.key === 'started_at'}
<Badge variant="neutral" mono>{getRelativeTime(row.started_at)}</Badge>
{:else if column.key === 'duration_ms'}
<Badge variant="neutral" mono>{formatDuration(row.duration_ms)}</Badge>
{:else if column.key === 'output'}
{#if row.error}
<span class="line-clamp-1 text-xs font-mono text-red-600 dark:text-red-400">{row.error}</span>
{:else if row.output}
<span class="line-clamp-1 text-xs font-mono text-neutral-600 dark:text-neutral-400">{row.output}</span>
{:else}
<span class="text-neutral-400 dark:text-neutral-600">-</span>
{/if}
{/if}
</svelte:fragment>
</Table>
</div>