Add SSH key management, job history persistence, and live streaming
- SSH key management: generate ed25519 keypairs or import public keys from UI (/ssh-keys), per-machine key selection in Machines form, one-time private key download with hash verification - Fix engine to use machine-specific SSH key (was hardcoded to server key) - Job log persistence: write to job_logs table (DB) with batched inserts, buffer of 50 lines; GetAllFiltered with status/pair/date range filters - EventBus refactor: per-job subscriber channels, global channel, non-blocking - SSE endpoints: /jobs/stream (all), /jobs/:id/log/stream (per-job live) - JobDetail page: live log streaming, auto-scroll, cancel, duration - JobHistory: filters (pair, status, date range), pagination, link to detail - Cleanup scheduler: daily purge of job_logs and finished jobs older than SYNCSERVER_RETENTION_DAYS (default 30) - Migration 0002: indexes on job_logs(job_id), jobs(status,created_at), jobs(sync_pair_id)
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { api, Job, LogLine, SyncPair } from '../api/client';
|
||||
|
||||
interface SSEEvent {
|
||||
type: string;
|
||||
job_id: number;
|
||||
status?: string;
|
||||
line?: string;
|
||||
stream?: string;
|
||||
}
|
||||
|
||||
export default function JobDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [job, setJob] = useState<Job | null>(null);
|
||||
const [pair, setPair] = useState<SyncPair | null>(null);
|
||||
const [logs, setLogs] = useState<LogLine[]>([]);
|
||||
const [lines, setLines] = useState<{ stream: string; text: string }[]>([]);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
const esRef = useRef<EventSource | null>(null);
|
||||
const jobId = Number(id);
|
||||
|
||||
useEffect(() => {
|
||||
loadJob();
|
||||
if (jobId) {
|
||||
loadLogs(0);
|
||||
const es = new EventSource(`/api/jobs/${jobId}/log/stream?job_id=${jobId}`);
|
||||
esRef.current = es;
|
||||
es.onmessage = (e) => {
|
||||
const evt: SSEEvent = JSON.parse(e.data);
|
||||
if (evt.type === 'log') {
|
||||
setLines(prev => [...prev, { stream: evt.stream!, text: evt.line! }]);
|
||||
}
|
||||
if (evt.type === 'status') {
|
||||
setJob(prev => prev ? { ...prev, status: evt.status! } : prev);
|
||||
}
|
||||
};
|
||||
}
|
||||
return () => esRef.current?.close();
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScroll && logEndRef.current) {
|
||||
logEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [lines, autoScroll]);
|
||||
|
||||
async function loadJob() {
|
||||
try {
|
||||
const j = await api<Job>(`/api/jobs/${id}`);
|
||||
setJob(j);
|
||||
const pairs = await api<SyncPair[]>('/api/sync-pairs');
|
||||
const p = pairs.find((sp: SyncPair) => sp.id === j.sync_pair_id);
|
||||
setPair(p || null);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadLogs(offset: number) {
|
||||
try {
|
||||
const ls = await api<LogLine[]>(`/api/jobs/${id}/log?offset=${offset}&limit=1000`);
|
||||
if (offset === 0) {
|
||||
setLogs(ls);
|
||||
} else {
|
||||
setLogs(prev => [...prev, ...ls]);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
if (!confirm('Cancel this job?')) return;
|
||||
try {
|
||||
await api(`/api/jobs/${id}/cancel`, { method: 'POST' });
|
||||
loadJob();
|
||||
} catch { alert('Cancel failed'); }
|
||||
}
|
||||
|
||||
function statusColor(s: string) {
|
||||
const map: Record<string, string> = {
|
||||
queued: 'bg-gray-600', waking_up: 'bg-yellow-600', running: 'bg-blue-600',
|
||||
success: 'bg-green-600', failed: 'bg-red-600', cancelled: 'bg-gray-600',
|
||||
};
|
||||
return map[s] || 'bg-gray-600';
|
||||
}
|
||||
|
||||
function duration(j: Job) {
|
||||
if (!j.started_at) return '-';
|
||||
const start = new Date(j.started_at).getTime();
|
||||
const end = j.finished_at ? new Date(j.finished_at).getTime() : Date.now();
|
||||
const secs = Math.round((end - start) / 1000);
|
||||
if (secs < 60) return `${secs}s`;
|
||||
const mins = Math.floor(secs / 60);
|
||||
const rem = secs % 60;
|
||||
if (mins < 60) return `${mins}m ${rem}s`;
|
||||
return `${Math.floor(mins / 60)}h ${mins % 60}m`;
|
||||
}
|
||||
|
||||
if (!job) return <div className="p-6 text-gray-400">Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="p-6 h-screen flex flex-col">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Link to="/jobs" className="text-gray-400 hover:text-white text-sm">← Job History</Link>
|
||||
<h1 className="text-2xl font-bold">Job #{job.id}</h1>
|
||||
<span className={`${statusColor(job.status)} text-white text-xs px-2 py-0.5 rounded`}>
|
||||
{job.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 rounded-lg p-4 mb-4 grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-gray-400 text-xs">Sync Pair</div>
|
||||
<div className="text-white font-medium">{pair?.name || `Pair ${job.sync_pair_id}`}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400 text-xs">Trigger</div>
|
||||
<div className="text-white">{job.trigger_type}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400 text-xs">Duration</div>
|
||||
<div className="text-white">{duration(job)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400 text-xs">Started</div>
|
||||
<div className="text-white text-xs">{job.started_at ? new Date(job.started_at).toLocaleString() : '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{['queued', 'waking_up', 'running'].includes(job.status) && (
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button onClick={cancel} className="bg-red-600 hover:bg-red-700 text-white px-4 py-1.5 rounded text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
<label className="flex items-center gap-2 text-gray-400 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={autoScroll} onChange={e => setAutoScroll(e.target.checked)} />
|
||||
Auto-scroll
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 bg-gray-900 rounded-lg overflow-hidden flex flex-col min-h-0">
|
||||
<div className="bg-gray-800 px-4 py-2 flex items-center justify-between">
|
||||
<span className="text-gray-400 text-xs font-mono">Output</span>
|
||||
<span className="text-gray-500 text-xs">{lines.length + logs.length} lines</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 font-mono text-xs space-y-0.5">
|
||||
{logs.map(l => (
|
||||
<div key={l.id} className={l.stream === 'stderr' ? 'text-red-400' : 'text-gray-300'}>
|
||||
<span className="text-gray-600 mr-2">{((): string => {
|
||||
const d = new Date(l.timestamp);
|
||||
return `${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}:${d.getSeconds().toString().padStart(2,'0')}`;
|
||||
})()}</span>
|
||||
{l.content}
|
||||
</div>
|
||||
))}
|
||||
{lines.map((l, i) => (
|
||||
<div key={`live-${i}`} className={l.stream === 'stderr' ? 'text-red-400' : 'text-gray-300'}>
|
||||
<span className="text-gray-600 mr-2">LIVE</span>
|
||||
{l.text}
|
||||
</div>
|
||||
))}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+129
-61
@@ -1,44 +1,54 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api, Job, SyncPair } from '../api/client';
|
||||
|
||||
interface SSEEvent {
|
||||
type: string;
|
||||
job_id: number;
|
||||
status?: string;
|
||||
line?: string;
|
||||
stream?: string;
|
||||
}
|
||||
|
||||
export default function JobHistory() {
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [pairs, setPairs] = useState<SyncPair[]>([]);
|
||||
const esRef = useRef<EventSource | null>(null);
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [filterPair, setFilterPair] = useState('');
|
||||
const [filterRange, setFilterRange] = useState('7d');
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const limit = 50;
|
||||
|
||||
useEffect(() => {
|
||||
loadPairs();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const es = new EventSource('/api/jobs/stream');
|
||||
esRef.current = es;
|
||||
es.onmessage = (e) => {
|
||||
const evt: SSEEvent = JSON.parse(e.data);
|
||||
if (evt.type === 'status') {
|
||||
setJobs(prev => prev.map(j => j.id === evt.job_id ? { ...j, status: evt.status! } : j));
|
||||
}
|
||||
};
|
||||
return () => es.close();
|
||||
}, []);
|
||||
}, [filterStatus, filterPair, filterRange, page]);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [j, p] = await Promise.all([
|
||||
api<Job[]>('/api/jobs?limit=100'),
|
||||
api<SyncPair[]>('/api/sync-pairs'),
|
||||
]);
|
||||
setJobs(j);
|
||||
setPairs(p);
|
||||
let url = `/api/jobs?limit=${limit}&offset=${page * limit}`;
|
||||
if (filterStatus) url += `&status=${filterStatus}`;
|
||||
if (filterPair) url += `&sync_pair_id=${filterPair}`;
|
||||
if (filterRange === '24h') {
|
||||
const from = new Date(Date.now() - 24 * 3600 * 1000).toISOString();
|
||||
url += `&from=${encodeURIComponent(from)}`;
|
||||
} else if (filterRange === '7d') {
|
||||
const from = new Date(Date.now() - 7 * 24 * 3600 * 1000).toISOString();
|
||||
url += `&from=${encodeURIComponent(from)}`;
|
||||
} else if (filterRange === '30d') {
|
||||
const from = new Date(Date.now() - 30 * 24 * 3600 * 1000).toISOString();
|
||||
url += `&from=${encodeURIComponent(from)}`;
|
||||
}
|
||||
const res = await fetch(url, { credentials: 'include' });
|
||||
const totalCount = res.headers.get('X-Total-Count');
|
||||
if (totalCount) setTotal(Number(totalCount));
|
||||
const data = await res.json();
|
||||
setJobs(data);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadPairs() {
|
||||
try { setPairs(await api<SyncPair[]>('/api/sync-pairs')); } catch {}
|
||||
}
|
||||
|
||||
async function cancel(id: number) {
|
||||
if (!confirm('Cancel this job?')) return;
|
||||
try {
|
||||
await api(`/api/jobs/${id}/cancel`, { method: 'POST' });
|
||||
load();
|
||||
@@ -58,44 +68,102 @@ export default function JobHistory() {
|
||||
return map[s] || 'bg-gray-600';
|
||||
}
|
||||
|
||||
function duration(j: Job) {
|
||||
if (!j.started_at) return '-';
|
||||
const start = new Date(j.started_at).getTime();
|
||||
const end = j.finished_at ? new Date(j.finished_at).getTime() : Date.now();
|
||||
const secs = Math.round((end - start) / 1000);
|
||||
if (secs < 60) return `${secs}s`;
|
||||
const mins = Math.floor(secs / 60);
|
||||
const rem = secs % 60;
|
||||
if (mins < 60) return `${mins}m ${rem}s`;
|
||||
return `${Math.floor(mins / 60)}h ${mins % 60}m`;
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold mb-6">Job History</h1>
|
||||
<table className="w-full text-sm bg-gray-800 rounded-lg overflow-hidden">
|
||||
<thead className="bg-gray-700">
|
||||
<tr className="text-left text-gray-400">
|
||||
<th className="p-3">ID</th>
|
||||
<th className="p-3">Sync Pair</th>
|
||||
<th className="p-3">Trigger</th>
|
||||
<th className="p-3">Status</th>
|
||||
<th className="p-3">Started</th>
|
||||
<th className="p-3">Finished</th>
|
||||
<th className="p-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map(j => (
|
||||
<tr key={j.id} className="border-t border-gray-700">
|
||||
<td className="p-3">{j.id}</td>
|
||||
<td className="p-3">{pairName(j.sync_pair_id)}</td>
|
||||
<td className="p-3">{j.trigger_type}</td>
|
||||
<td className="p-3">
|
||||
<span className={`${statusColor(j.status)} text-white text-xs px-2 py-0.5 rounded`}>
|
||||
{j.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3">{j.started_at ? new Date(j.started_at).toLocaleString() : '-'}</td>
|
||||
<td className="p-3">{j.finished_at ? new Date(j.finished_at).toLocaleString() : '-'}</td>
|
||||
<td className="p-3">
|
||||
{['queued', 'waking_up', 'running'].includes(j.status) && (
|
||||
<button onClick={() => cancel(j.id)} className="text-red-400 hover:text-red-300">Cancel</button>
|
||||
)}
|
||||
</td>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Job History</h1>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<select value={filterPair} onChange={e => { setFilterPair(e.target.value); setPage(0); }}
|
||||
className="bg-gray-700 text-white rounded px-2 py-1.5">
|
||||
<option value="">All Pairs</option>
|
||||
{pairs.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
<select value={filterStatus} onChange={e => { setFilterStatus(e.target.value); setPage(0); }}
|
||||
className="bg-gray-700 text-white rounded px-2 py-1.5">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="queued">Queued</option>
|
||||
<option value="waking_up">Waking Up</option>
|
||||
<option value="running">Running</option>
|
||||
<option value="success">Success</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
<select value={filterRange} onChange={e => { setFilterRange(e.target.value); setPage(0); }}
|
||||
className="bg-gray-700 text-white rounded px-2 py-1.5">
|
||||
<option value="24h">Last 24h</option>
|
||||
<option value="7d">Last 7 days</option>
|
||||
<option value="30d">Last 30 days</option>
|
||||
<option value="all">All time</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-700">
|
||||
<tr className="text-left text-gray-400">
|
||||
<th className="p-3">ID</th>
|
||||
<th className="p-3">Sync Pair</th>
|
||||
<th className="p-3">Trigger</th>
|
||||
<th className="p-3">Status</th>
|
||||
<th className="p-3">Duration</th>
|
||||
<th className="p-3">Started</th>
|
||||
<th className="p-3">Finished</th>
|
||||
<th className="p-3">Actions</th>
|
||||
</tr>
|
||||
))}
|
||||
{jobs.length === 0 && <tr><td colSpan={7} className="p-4 text-center text-gray-500">No jobs</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map(j => (
|
||||
<tr key={j.id} className="border-t border-gray-700 hover:bg-gray-750 cursor-pointer"
|
||||
onClick={() => window.location.href = `/jobs/${j.id}`}>
|
||||
<td className="p-3 text-blue-400 hover:text-blue-300">
|
||||
<Link to={`/jobs/${j.id}`}>#{j.id}</Link>
|
||||
</td>
|
||||
<td className="p-3">{pairName(j.sync_pair_id)}</td>
|
||||
<td className="p-3">{j.trigger_type}</td>
|
||||
<td className="p-3">
|
||||
<span className={`${statusColor(j.status)} text-white text-xs px-2 py-0.5 rounded`}>
|
||||
{j.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3 text-gray-400 text-xs">{duration(j)}</td>
|
||||
<td className="p-3 text-xs">{j.started_at ? new Date(j.started_at).toLocaleString() : '-'}</td>
|
||||
<td className="p-3 text-xs">{j.finished_at ? new Date(j.finished_at).toLocaleString() : '-'}</td>
|
||||
<td className="p-3" onClick={e => e.stopPropagation()}>
|
||||
{['queued', 'waking_up', 'running'].includes(j.status) && (
|
||||
<button onClick={() => cancel(j.id)} className="text-red-400 hover:text-red-300 text-xs">Cancel</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{jobs.length === 0 && <tr><td colSpan={8} className="p-4 text-center text-gray-500">No jobs</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="bg-gray-800 px-4 py-3 flex items-center justify-between border-t border-gray-700">
|
||||
<button onClick={() => setPage(p => Math.max(0, p - 1))} disabled={page === 0}
|
||||
className="text-sm text-gray-400 hover:text-white disabled:opacity-50">← Previous</button>
|
||||
<span className="text-gray-400 text-sm">{page + 1} / {totalPages} ({total} total)</span>
|
||||
<button onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1}
|
||||
className="text-sm text-gray-400 hover:text-white disabled:opacity-50">Next →</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, Machine } from '../api/client';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api, Machine, SSHKey } from '../api/client';
|
||||
|
||||
export default function Machines() {
|
||||
const [machines, setMachines] = useState<Machine[]>([]);
|
||||
const [sshKeys, setSSHKeys] = useState<SSHKey[]>([]);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
id: undefined as number | undefined, name: '', host: '', port: 22, ssh_user: 'root',
|
||||
ssh_key_id: null as number | null,
|
||||
mac_address: '', wol_enabled: false,
|
||||
wake_timeout_seconds: 120, wake_check_interval_seconds: 5,
|
||||
});
|
||||
@@ -13,7 +16,14 @@ export default function Machines() {
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function load() {
|
||||
try { setMachines(await api<Machine[]>('/api/machines')); } catch {}
|
||||
try {
|
||||
const [ms, ks] = await Promise.all([
|
||||
api<Machine[]>('/api/machines'),
|
||||
api<SSHKey[]>('/api/ssh-keys'),
|
||||
]);
|
||||
setMachines(ms);
|
||||
setSSHKeys(ks);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
@@ -21,7 +31,8 @@ export default function Machines() {
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
id: form.id || null, name: form.name, host: form.host, port: Number(form.port),
|
||||
ssh_user: form.ssh_user, mac_address: form.mac_address || null,
|
||||
ssh_user: form.ssh_user, ssh_key_id: form.ssh_key_id,
|
||||
mac_address: form.mac_address || null,
|
||||
wol_enabled: Boolean(form.wol_enabled),
|
||||
wake_timeout_seconds: Number(form.wake_timeout_seconds),
|
||||
wake_check_interval_seconds: Number(form.wake_check_interval_seconds),
|
||||
@@ -35,7 +46,7 @@ export default function Machines() {
|
||||
body: payload,
|
||||
});
|
||||
setShowForm(false);
|
||||
setForm({ id: undefined, name: '', host: '', port: 22, ssh_user: 'root', mac_address: '', wol_enabled: false as boolean, wake_timeout_seconds: 120, wake_check_interval_seconds: 5 });
|
||||
setForm({ id: undefined, name: '', host: '', port: 22, ssh_user: 'root', ssh_key_id: null, mac_address: '', wol_enabled: false, wake_timeout_seconds: 120, wake_check_interval_seconds: 5 });
|
||||
load();
|
||||
} catch (e: unknown) { alert((e as Error).message); }
|
||||
}
|
||||
@@ -43,7 +54,8 @@ export default function Machines() {
|
||||
function edit(m: Machine) {
|
||||
setForm({
|
||||
id: m.id, name: m.name, host: m.host, port: m.port,
|
||||
ssh_user: m.ssh_user, mac_address: m.mac_address || '',
|
||||
ssh_user: m.ssh_user, ssh_key_id: m.ssh_key_id,
|
||||
mac_address: m.mac_address || '',
|
||||
wol_enabled: m.wol_enabled,
|
||||
wake_timeout_seconds: m.wake_timeout_seconds,
|
||||
wake_check_interval_seconds: m.wake_check_interval_seconds,
|
||||
@@ -56,10 +68,19 @@ export default function Machines() {
|
||||
try { await api(`/api/machines/${id}`, { method: 'DELETE' }); load(); } catch { alert('Delete failed'); }
|
||||
}
|
||||
|
||||
function keyLabel(id: number | null) {
|
||||
if (!id) return 'Server Key';
|
||||
const k = sshKeys.find(k => k.id === id);
|
||||
return k ? k.label : `Key #${id}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">Machines</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-2xl font-bold">Machines</h1>
|
||||
<Link to="/ssh-keys" className="text-sm text-blue-400 hover:text-blue-300">Manage SSH Keys</Link>
|
||||
</div>
|
||||
<button onClick={() => setShowForm(true)} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">
|
||||
Add Machine
|
||||
</button>
|
||||
@@ -67,12 +88,17 @@ export default function Machines() {
|
||||
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<form onSubmit={handleSubmit} className="bg-gray-800 p-6 rounded-lg w-96 space-y-3">
|
||||
<form onSubmit={handleSubmit} className="bg-gray-800 p-6 rounded-lg w-[480px] space-y-3">
|
||||
<h2 className="text-lg font-bold">Machine</h2>
|
||||
<input placeholder="Name" value={form.name} onChange={e => setForm({...form, name: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" required />
|
||||
<input placeholder="Host / IP" value={form.host} onChange={e => setForm({...form, host: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" required />
|
||||
<input placeholder="SSH Port" type="number" value={form.port} onChange={e => setForm({...form, port: +e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" />
|
||||
<input placeholder="SSH User" value={form.ssh_user} onChange={e => setForm({...form, ssh_user: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" />
|
||||
<select value={form.ssh_key_id ?? ''} onChange={e => setForm({...form, ssh_key_id: e.target.value ? Number(e.target.value) : null})}
|
||||
className="w-full bg-gray-700 rounded px-3 py-2 text-white">
|
||||
<option value="">Server Key (default)</option>
|
||||
{sshKeys.map(k => <option key={k.id} value={k.id}>{k.label} {k.in_use ? '(in use)' : ''}</option>)}
|
||||
</select>
|
||||
<input placeholder="MAC Address (AA:BB:CC:DD:EE:FF)" value={form.mac_address} onChange={e => setForm({...form, mac_address: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" />
|
||||
<label className="flex items-center gap-2 text-gray-300">
|
||||
<input type="checkbox" checked={form.wol_enabled} onChange={e => setForm({...form, wol_enabled: e.target.checked})} />
|
||||
@@ -91,6 +117,7 @@ export default function Machines() {
|
||||
<tr className="text-left text-gray-400">
|
||||
<th className="p-3">Name</th>
|
||||
<th className="p-3">Host</th>
|
||||
<th className="p-3">SSH Key</th>
|
||||
<th className="p-3">WoL</th>
|
||||
<th className="p-3">Status</th>
|
||||
<th className="p-3">Actions</th>
|
||||
@@ -101,6 +128,7 @@ export default function Machines() {
|
||||
<tr key={m.id} className="border-t border-gray-700">
|
||||
<td className="p-3 font-medium">{m.name}</td>
|
||||
<td className="p-3">{m.host}:{m.port}</td>
|
||||
<td className="p-3 text-gray-400 text-xs">{keyLabel(m.ssh_key_id)}</td>
|
||||
<td className="p-3">{m.wol_enabled ? 'Yes' : 'No'}</td>
|
||||
<td className="p-3 text-gray-400">{m.status}</td>
|
||||
<td className="p-3">
|
||||
@@ -109,7 +137,7 @@ export default function Machines() {
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{machines.length === 0 && <tr><td colSpan={5} className="p-4 text-center text-gray-500">No machines</td></tr>}
|
||||
{machines.length === 0 && <tr><td colSpan={6} className="p-4 text-center text-gray-500">No machines</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { api, SSHKey } from '../api/client';
|
||||
|
||||
export default function SSHKeys() {
|
||||
const [keys, setKeys] = useState<SSHKey[]>([]);
|
||||
const [showGen, setShowGen] = useState(false);
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [genLabel, setGenLabel] = useState('');
|
||||
const [importLabel, setImportLabel] = useState('');
|
||||
const [importPubKey, setImportPubKey] = useState('');
|
||||
const [downloading, setDownloading] = useState<number | null>(null);
|
||||
const [copied, setCopied] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function load() {
|
||||
try { setKeys(await api<SSHKey[]>('/api/ssh-keys')); } catch {}
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
if (!genLabel.trim()) { alert('Label is required'); return; }
|
||||
setLoading(true);
|
||||
try {
|
||||
await api('/api/ssh-keys', {
|
||||
method: 'POST',
|
||||
body: { label: genLabel.trim(), generate: true },
|
||||
});
|
||||
setShowGen(false);
|
||||
setGenLabel('');
|
||||
load();
|
||||
} catch (e: unknown) { alert((e as Error).message); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
async function importKey() {
|
||||
if (!importLabel.trim()) { alert('Label is required'); return; }
|
||||
if (!importPubKey.trim()) { alert('Public key is required'); return; }
|
||||
setLoading(true);
|
||||
try {
|
||||
await api('/api/ssh-keys', {
|
||||
method: 'POST',
|
||||
body: { label: importLabel.trim(), generate: false, public_key: importPubKey.trim() },
|
||||
});
|
||||
setShowImport(false);
|
||||
setImportLabel('');
|
||||
setImportPubKey('');
|
||||
load();
|
||||
} catch (e: unknown) { alert((e as Error).message); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
if (!confirm('Delete this SSH key? Machines using it will fall back to the server key.')) return;
|
||||
try {
|
||||
await api(`/api/ssh-keys/${id}`, { method: 'DELETE' });
|
||||
load();
|
||||
} catch (e: unknown) { alert((e as Error).message); }
|
||||
}
|
||||
|
||||
async function downloadPrivate(id: number) {
|
||||
try {
|
||||
const res = await fetch(`/api/ssh-keys/${id}/private`, { credentials: 'include' });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Failed' }));
|
||||
alert((err as { error: string }).error);
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `ssh-key-${id}.key`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
setDownloading(id);
|
||||
setTimeout(() => setDownloading(null), 3000);
|
||||
} catch (e: unknown) { alert((e as Error).message); }
|
||||
}
|
||||
|
||||
async function copyPubKey(key: SSHKey) {
|
||||
await navigator.clipboard.writeText(key.public_key);
|
||||
setCopied(key.id);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">SSH Keys</h1>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setShowGen(true)} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm">
|
||||
Generate New
|
||||
</button>
|
||||
<button onClick={() => setShowImport(true)} className="bg-gray-600 hover:bg-gray-500 text-white px-4 py-2 rounded text-sm">
|
||||
Import Public Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(showGen || showImport) && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-gray-800 p-6 rounded-lg w-[500px] space-y-3">
|
||||
<h2 className="text-lg font-bold">{showGen ? 'Generate SSH Key Pair' : 'Import Public Key'}</h2>
|
||||
<input placeholder="Label (e.g. backup-nas)" value={genLabel} onChange={e => setGenLabel(e.target.value)}
|
||||
className="w-full bg-gray-700 rounded px-3 py-2 text-white" />
|
||||
{showImport && (
|
||||
<textarea placeholder="ssh-ed25519 AAAA..." value={importPubKey} onChange={e => setImportPubKey(e.target.value)}
|
||||
className="w-full bg-gray-700 rounded px-3 py-2 text-white font-mono text-xs h-32" />
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={showGen ? generate : importKey} disabled={loading}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded flex-1 disabled:opacity-50">
|
||||
{loading ? 'Working...' : showGen ? 'Generate' : 'Import'}
|
||||
</button>
|
||||
<button onClick={() => { setShowGen(false); setShowImport(false); }}
|
||||
className="bg-gray-600 hover:bg-gray-500 text-white px-4 py-2 rounded">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{keys.map(k => (
|
||||
<div key={k.id} className="bg-gray-800 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-white">{k.label}</span>
|
||||
{k.in_use && <span className="text-xs bg-green-900 text-green-400 px-2 py-0.5 rounded">In Use</span>}
|
||||
{!k.has_private_key && <span className="text-xs bg-gray-700 text-gray-400 px-2 py-0.5 rounded">Imported Only</span>}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mb-2">Fingerprint: {k.fingerprint}</div>
|
||||
<div className="bg-gray-900 p-2 rounded font-mono text-xs text-green-400 break-all max-w-2xl">
|
||||
{k.public_key}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4">
|
||||
<button onClick={() => copyPubKey(k)}
|
||||
className="text-gray-400 hover:text-white text-xs px-3 py-1.5 rounded bg-gray-700 hover:bg-gray-600">
|
||||
{copied === k.id ? 'Copied!' : 'Copy Public'}
|
||||
</button>
|
||||
{k.has_private_key && (
|
||||
<button onClick={() => downloadPrivate(k.id)}
|
||||
className="text-yellow-400 hover:text-yellow-300 text-xs px-3 py-1.5 rounded bg-gray-700 hover:bg-gray-600">
|
||||
{downloading === k.id ? 'Downloaded!' : 'Download Private Key'}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => remove(k.id)}
|
||||
className="text-red-400 hover:text-red-300 text-xs px-3 py-1.5 rounded bg-gray-700 hover:bg-gray-600">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{keys.length === 0 && <div className="text-gray-500 text-center py-12">No SSH keys. Generate one or import a public key above.</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function Settings() {
|
||||
const [pubKey, setPubKey] = useState('');
|
||||
@@ -35,6 +36,14 @@ export default function Settings() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 rounded-lg p-4 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-3">SSH Keys</h2>
|
||||
<p className="text-gray-400 text-sm mb-3">
|
||||
Manage SSH key pairs for authenticating to remote machines. Go to the{' '}
|
||||
<Link to="/ssh-keys" className="text-blue-400 hover:text-blue-300">SSH Keys page</Link>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 rounded-lg p-4">
|
||||
<h2 className="text-lg font-semibold mb-3">Quick Reference</h2>
|
||||
<div className="text-gray-400 text-sm space-y-2">
|
||||
|
||||
Reference in New Issue
Block a user