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>
This commit is contained in:
182
src/views/execute.tsx
Normal file
182
src/views/execute.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user