bun + hono + htmx service with sqlite, jellyfin/radarr/sonarr api clients, stream analyzer, ffmpeg command builder, ssh remote execution, setup wizard, scan with sse progress, review ui with inline edits, execute queue, remote node management, docker deployment Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
183 lines
5.4 KiB
TypeScript
183 lines
5.4 KiB
TypeScript
import type { FC } from 'hono/jsx';
|
|
import { Layout } from './layout';
|
|
import type { Job, Node, MediaItem } from '../types';
|
|
|
|
interface ExecutePageProps {
|
|
jobs: Array<{
|
|
job: Job;
|
|
item: MediaItem | null;
|
|
node: Node | null;
|
|
}>;
|
|
nodes: Node[];
|
|
}
|
|
|
|
export const ExecutePage: FC<ExecutePageProps> = ({ jobs, nodes }) => {
|
|
const pending = jobs.filter((j) => j.job.status === 'pending').length;
|
|
const running = jobs.filter((j) => j.job.status === 'running').length;
|
|
const done = jobs.filter((j) => j.job.status === 'done').length;
|
|
const errors = jobs.filter((j) => j.job.status === 'error').length;
|
|
|
|
const hasActiveJobs = running > 0;
|
|
|
|
return (
|
|
<Layout title="Execute" activeNav="execute">
|
|
<div class="page-header">
|
|
<h1>Execute Jobs</h1>
|
|
</div>
|
|
|
|
{/* Stats row */}
|
|
<div class="flex-row" style="gap:0.75rem;margin-bottom:1.5rem;flex-wrap:wrap;">
|
|
<span class="badge badge-pending">{pending} pending</span>
|
|
{running > 0 && <span class="badge badge-running">{running} running</span>}
|
|
{done > 0 && <span class="badge badge-done">{done} done</span>}
|
|
{errors > 0 && <span class="badge badge-error">{errors} error(s)</span>}
|
|
</div>
|
|
|
|
{/* Controls */}
|
|
<div class="flex-row" style="margin-bottom:1.5rem;gap:0.75rem;">
|
|
{pending > 0 && (
|
|
<form method="post" action="/execute/start" style="display:inline">
|
|
<button type="submit">▶ Run all pending</button>
|
|
</form>
|
|
)}
|
|
{jobs.length === 0 && (
|
|
<p class="muted">No jobs yet. Go to <a href="/review">Review</a> and approve items first.</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Job table */}
|
|
{jobs.length > 0 && (
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>#</th>
|
|
<th>Item</th>
|
|
<th>Command</th>
|
|
<th>Node</th>
|
|
<th>Status</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{jobs.map(({ job, item, node }) => (
|
|
<JobRow key={job.id} job={job} item={item} node={node} nodes={nodes} />
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
|
|
{/* SSE for live updates */}
|
|
{hasActiveJobs && (
|
|
<script
|
|
dangerouslySetInnerHTML={{
|
|
__html: `
|
|
(function() {
|
|
var es = new EventSource('/execute/events');
|
|
es.addEventListener('job_update', function(e) {
|
|
var d = JSON.parse(e.data);
|
|
var row = document.getElementById('job-row-' + d.id);
|
|
if (!row) return;
|
|
var statusCell = row.querySelector('.job-status');
|
|
if (statusCell) statusCell.innerHTML = '<span class="badge badge-' + d.status + '">' + d.status + '</span>';
|
|
var logCell = document.getElementById('job-log-' + d.id);
|
|
if (logCell && d.output) logCell.textContent = d.output;
|
|
});
|
|
es.addEventListener('complete', function() { es.close(); location.reload(); });
|
|
})();
|
|
`,
|
|
}}
|
|
/>
|
|
)}
|
|
</Layout>
|
|
);
|
|
};
|
|
|
|
const JobRow: FC<{ job: Job; item: MediaItem | null; node: Node | null; nodes: Node[] }> = ({
|
|
job,
|
|
item,
|
|
node,
|
|
nodes,
|
|
}) => {
|
|
const itemName = item
|
|
? (item.type === 'Episode' && item.series_name
|
|
? `${item.series_name} S${String(item.season_number ?? 0).padStart(2, '0')}E${String(item.episode_number ?? 0).padStart(2, '0')}`
|
|
: item.name)
|
|
: `Item #${job.item_id}`;
|
|
|
|
const cmdShort = job.command.length > 80 ? job.command.slice(0, 77) + '…' : job.command;
|
|
|
|
return (
|
|
<>
|
|
<tr id={`job-row-${job.id}`}>
|
|
<td class="mono">{job.id}</td>
|
|
<td>
|
|
<div class="truncate" style="max-width:200px;" title={itemName}>{itemName}</div>
|
|
{item?.file_path && (
|
|
<div class="mono muted truncate" style="font-size:0.72rem;max-width:200px;" title={item.file_path}>
|
|
{item.file_path.split('/').pop()}
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td class="mono" style="font-size:0.75rem;max-width:300px;">
|
|
<span title={job.command}>{cmdShort}</span>
|
|
</td>
|
|
<td>
|
|
{job.status === 'pending' ? (
|
|
<form
|
|
hx-post={`/execute/job/${job.id}/assign`}
|
|
hx-target={`#job-row-${job.id}`}
|
|
hx-swap="outerHTML"
|
|
style="display:inline"
|
|
>
|
|
<select name="node_id" style="font-size:0.8rem;padding:0.2em 0.4em;" onchange="this.form.submit()">
|
|
<option value="">Local</option>
|
|
{nodes.map((n) => (
|
|
<option key={n.id} value={n.id} selected={node?.id === n.id}>
|
|
{n.name} ({n.host})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</form>
|
|
) : (
|
|
<span class="muted">{node?.name ?? 'Local'}</span>
|
|
)}
|
|
</td>
|
|
<td>
|
|
<span class={`badge badge-${job.status} job-status`}>{job.status}</span>
|
|
{job.exit_code != null && job.exit_code !== 0 && (
|
|
<span class="badge badge-error">exit {job.exit_code}</span>
|
|
)}
|
|
</td>
|
|
<td class="actions-col">
|
|
{job.status === 'pending' && (
|
|
<form method="post" action={`/execute/job/${job.id}/run`} style="display:inline">
|
|
<button type="submit" data-size="sm">▶ Run</button>
|
|
</form>
|
|
)}
|
|
{job.status === 'pending' && (
|
|
<form method="post" action={`/execute/job/${job.id}/cancel`} style="display:inline">
|
|
<button type="submit" class="secondary" data-size="sm">✕</button>
|
|
</form>
|
|
)}
|
|
{(job.status === 'done' || job.status === 'error') && job.output && (
|
|
<button
|
|
data-size="sm"
|
|
class="secondary"
|
|
onclick={`document.getElementById('job-log-${job.id}').toggleAttribute('hidden')`}
|
|
>
|
|
Log
|
|
</button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
{job.output && (
|
|
<tr>
|
|
<td colspan={6} style="padding:0;">
|
|
<div id={`job-log-${job.id}`} hidden class="log-output">{job.output}</div>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</>
|
|
);
|
|
};
|