Files
netfelix-audio-fix/src/views/execute.tsx
Felix Förtsch ea536ce533 initial implementation: jellyfin audio/subtitle cleanup service
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>
2026-02-26 22:29:33 +01:00

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>
)}
</>
);
};