mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 10:51:02 +01:00
refactor: remove old card view for jobs, replace with expandable table. Use table comp for history
This commit is contained in:
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user