Added Job Scheduler Logic

This commit is contained in:
2025-09-23 00:02:39 -06:00
parent a81883ea04
commit 836b5783db
5 changed files with 1236 additions and 254 deletions

View File

@@ -21,7 +21,8 @@ import {
TableHead,
TableRow,
TableCell,
TableBody
TableBody,
TableSortLabel
} from "@mui/material";
import { Add as AddIcon, Delete as DeleteIcon } from "@mui/icons-material";
import { SimpleTreeView, TreeItem } from "@mui/x-tree-view";
@@ -159,7 +160,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
]); // {type:'script'|'workflow', path, name, description}
const [targets, setTargets] = useState([]); // array of hostnames
const [scheduleType, setScheduleType] = useState("immediately");
const [startDateTime, setStartDateTime] = useState(() => dayjs().add(5, "minute"));
const [startDateTime, setStartDateTime] = useState(() => dayjs().add(5, "minute").second(0));
const [stopAfterEnabled, setStopAfterEnabled] = useState(false);
const [expiration, setExpiration] = useState("no_expire");
const [execContext, setExecContext] = useState("system");
@@ -188,6 +189,158 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
const [confirmOpen, setConfirmOpen] = useState(false);
const editing = !!(initialJob && initialJob.id);
// --- Job History (only when editing) ---
const [historyRows, setHistoryRows] = useState([]);
const [historyOrderBy, setHistoryOrderBy] = useState("started_ts");
const [historyOrder, setHistoryOrder] = useState("desc");
const fmtTs = useCallback((ts) => {
if (!ts) return '';
try {
const d = new Date(Number(ts) * 1000);
return d.toLocaleString(undefined, { year:'numeric', month:'2-digit', day:'2-digit', hour:'numeric', minute:'2-digit' });
} catch { return ''; }
}, []);
const loadHistory = useCallback(async () => {
if (!editing) return;
try {
const [runsResp, jobResp, devResp] = await Promise.all([
fetch(`/api/scheduled_jobs/${initialJob.id}/runs?days=30`),
fetch(`/api/scheduled_jobs/${initialJob.id}`),
fetch(`/api/scheduled_jobs/${initialJob.id}/devices`)
]);
const runs = await runsResp.json();
const job = await jobResp.json();
const dev = await devResp.json();
if (!runsResp.ok) throw new Error(runs.error || `HTTP ${runsResp.status}`);
if (!jobResp.ok) throw new Error(job.error || `HTTP ${jobResp.status}`);
if (!devResp.ok) throw new Error(dev.error || `HTTP ${devResp.status}`);
setHistoryRows(Array.isArray(runs.runs) ? runs.runs : []);
setJobSummary(job.job || {});
setDeviceRows(Array.isArray(dev.devices) ? dev.devices : []);
} catch {
setHistoryRows([]);
setJobSummary({});
setDeviceRows([]);
}
}, [editing, initialJob?.id]);
useEffect(() => {
if (!editing) return;
let t;
(async () => { try { await loadHistory(); } catch {} })();
t = setInterval(loadHistory, 10000);
return () => { if (t) clearInterval(t); };
}, [editing, loadHistory]);
const resultChip = (status) => {
const map = {
Success: { bg: '#00d18c', fg: '#000' },
Running: { bg: '#58a6ff', fg: '#000' },
Scheduled: { bg: '#999999', fg: '#fff' },
Expired: { bg: '#777777', fg: '#fff' },
Failed: { bg: '#ff4f4f', fg: '#fff' },
Warning: { bg: '#ff8c00', fg: '#000' }
};
const c = map[status] || { bg: '#aaa', fg: '#000' };
return (
<span style={{ display: 'inline-block', padding: '2px 8px', borderRadius: 999, background: c.bg, color: c.fg, fontWeight: 600, fontSize: 12 }}>
{status || ''}
</span>
);
};
const sortedHistory = useMemo(() => {
const dir = historyOrder === 'asc' ? 1 : -1;
const key = historyOrderBy;
return [...historyRows].sort((a, b) => {
const A = a?.[key];
const B = b?.[key];
if (key === 'started_ts' || key === 'finished_ts' || key === 'scheduled_ts') {
return ((A || 0) - (B || 0)) * dir;
}
return String(A ?? '').localeCompare(String(B ?? '')) * dir;
});
}, [historyRows, historyOrderBy, historyOrder]);
const handleHistorySort = (col) => {
if (historyOrderBy === col) setHistoryOrder(historyOrder === 'asc' ? 'desc' : 'asc');
else { setHistoryOrderBy(col); setHistoryOrder('asc'); }
};
const renderHistory = () => (
<Box sx={{ maxHeight: 400, overflowY: 'auto' }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sortDirection={historyOrderBy === 'scheduled_ts' ? historyOrder : false}>
<TableSortLabel active={historyOrderBy === 'scheduled_ts'} direction={historyOrderBy === 'scheduled_ts' ? historyOrder : 'asc'} onClick={() => handleHistorySort('scheduled_ts')}>
Scheduled
</TableSortLabel>
</TableCell>
<TableCell sortDirection={historyOrderBy === 'started_ts' ? historyOrder : false}>
<TableSortLabel active={historyOrderBy === 'started_ts'} direction={historyOrderBy === 'started_ts' ? historyOrder : 'asc'} onClick={() => handleHistorySort('started_ts')}>
Started
</TableSortLabel>
</TableCell>
<TableCell sortDirection={historyOrderBy === 'finished_ts' ? historyOrder : false}>
<TableSortLabel active={historyOrderBy === 'finished_ts'} direction={historyOrderBy === 'finished_ts' ? historyOrder : 'asc'} onClick={() => handleHistorySort('finished_ts')}>
Finished
</TableSortLabel>
</TableCell>
<TableCell>Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sortedHistory.map((r) => (
<TableRow key={r.id}>
<TableCell>{fmtTs(r.scheduled_ts)}</TableCell>
<TableCell>{fmtTs(r.started_ts)}</TableCell>
<TableCell>{fmtTs(r.finished_ts)}</TableCell>
<TableCell>{resultChip(r.status)}</TableCell>
</TableRow>
))}
{sortedHistory.length === 0 && (
<TableRow>
<TableCell colSpan={4} sx={{ color: '#888' }}>No runs in the last 30 days.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Box>
);
// --- Job Progress (summary) ---
const [jobSummary, setJobSummary] = useState({});
const sumCounts = (o, k) => Number((o?.result_counts||{})[k] || 0);
const counts = jobSummary?.result_counts || {};
const ProgressSummary = () => (
<Box sx={{ p: 2, bgcolor: '#1b1b1b', border: '1px solid #333', borderRadius: 1, mb: 2 }}>
<Typography variant="subtitle1" sx={{ color: '#7db7ff', mb: 1 }}>Job Progress</Typography>
<Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
{[
['pending','Pending','#999999'],
['running','Running','#58a6ff'],
['success','Success','#00d18c'],
['failed','Failed','#ff4f4f'],
['expired','Expired','#777777'],
['timed_out','Timed Out','#b36ae2']
].map(([key,label,color]) => (
<Box key={key} sx={{ color: '#ddd', display: 'flex', alignItems: 'center', gap: 1 }}>
<span style={{ display:'inline-block', width:10, height:10, borderRadius:10, background: color }} />
<span>{label}: {Number((counts||{})[key] || 0)}</span>
</Box>
))}
</Box>
</Box>
);
// --- Devices breakdown ---
const [deviceRows, setDeviceRows] = useState([]);
const deviceSorted = useMemo(() => deviceRows, [deviceRows]);
useEffect(() => {
if (initialJob && initialJob.id) {
setJobName(initialJob.name || "");
@@ -195,7 +348,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
setComponents(comps.length ? comps : [{ type: "script", path: "demo/component", name: "Demonstration Component", description: "placeholder" }]);
setTargets(Array.isArray(initialJob.targets) ? initialJob.targets : []);
setScheduleType(initialJob.schedule_type || initialJob.schedule?.type || "immediately");
setStartDateTime(initialJob.start_ts ? dayjs(Number(initialJob.start_ts) * 1000) : (initialJob.schedule?.start ? dayjs(initialJob.schedule.start) : dayjs().add(5, "minute")));
setStartDateTime(initialJob.start_ts ? dayjs(Number(initialJob.start_ts) * 1000).second(0) : (initialJob.schedule?.start ? dayjs(initialJob.schedule.start).second(0) : dayjs().add(5, "minute").second(0)));
setStopAfterEnabled(Boolean(initialJob.duration_stop_enabled));
setExpiration(initialJob.expiration || "no_expire");
setExecContext(initialJob.execution_context || "system");
@@ -269,7 +422,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
name: jobName,
components,
targets,
schedule: { type: scheduleType, start: scheduleType !== "immediately" ? (startDateTime?.toISOString?.() || startDateTime) : null },
schedule: { type: scheduleType, start: scheduleType !== "immediately" ? (() => { try { const d = startDateTime?.toDate?.() || new Date(startDateTime); d.setSeconds(0,0); return d.toISOString(); } catch { return startDateTime; } })() : null },
duration: { stopAfterEnabled, expiration },
execution_context: execContext
};
@@ -288,13 +441,17 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
}
};
const tabDefs = useMemo(() => ([
{ key: "name", label: "Job Name" },
{ key: "components", label: "Scripts/Workflows" },
{ key: "targets", label: "Targets" },
{ key: "schedule", label: "Schedule" },
{ key: "context", label: "Execution Context" }
]), []);
const tabDefs = useMemo(() => {
const base = [
{ key: "name", label: "Job Name" },
{ key: "components", label: "Scripts/Workflows" },
{ key: "targets", label: "Targets" },
{ key: "schedule", label: "Schedule" },
{ key: "context", label: "Execution Context" }
];
if (editing) base.push({ key: 'history', label: 'Job History' });
return base;
}, [editing]);
return (
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}>
@@ -441,7 +598,9 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DateTimePicker
value={startDateTime}
onChange={(val) => setStartDateTime(val)}
onChange={(val) => setStartDateTime(val?.second ? val.second(0) : val)}
views={['year','month','day','hours','minutes']}
format="YYYY-MM-DD hh:mm A"
slotProps={{ textField: { size: "small" } }}
/>
</LocalizationProvider>
@@ -481,6 +640,70 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
</Select>
</Box>
)}
{/* Job History tab (only when editing) */}
{editing && tab === tabDefs.findIndex(t => t.key === 'history') && (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6" sx={{ color: '#58a6ff' }}>Job History</Typography>
<Button size="small" variant="outlined" sx={{ color: '#ff6666', borderColor: '#ff6666', textTransform: 'none' }}
onClick={async () => { try { await fetch(`/api/scheduled_jobs/${initialJob.id}/runs`, { method: 'DELETE' }); await loadHistory(); } catch {} }}
>
Clear Job History
</Button>
</Box>
<Typography variant="caption" sx={{ color: '#aaa' }}>Showing the last 30 days of runs.</Typography>
<Box sx={{ mt: 2 }}>
<ProgressSummary />
</Box>
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle1" sx={{ color: '#7db7ff', mb: 1 }}>Devices</Typography>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Hostname</TableCell>
<TableCell>Status</TableCell>
<TableCell>Site</TableCell>
<TableCell>Ran On</TableCell>
<TableCell>Job Status</TableCell>
<TableCell>StdOut / StdErr</TableCell>
</TableRow>
</TableHead>
<TableBody>
{deviceSorted.map((d, i) => (
<TableRow key={`${d.hostname}-${i}`} hover>
<TableCell>{d.hostname}</TableCell>
<TableCell>
<span style={{ display:'inline-block', width:10, height:10, borderRadius:10, background: d.online ? '#00d18c' : '#ff4f4f', marginRight:8, verticalAlign:'middle' }} />
{d.online ? 'Online' : 'Offline'}
</TableCell>
<TableCell>{d.site || ''}</TableCell>
<TableCell>{fmtTs(d.ran_on)}</TableCell>
<TableCell>{resultChip(d.job_status)}</TableCell>
<TableCell>
<Box sx={{ display:'flex', gap:1 }}>
{d.has_stdout ? <Button size="small" sx={{ color:'#58a6ff', textTransform:'none', minWidth:0, p:0 }}>StdOut</Button> : null}
{d.has_stderr ? <Button size="small" sx={{ color:'#ff4f4f', textTransform:'none', minWidth:0, p:0 }}>StdErr</Button> : null}
</Box>
</TableCell>
</TableRow>
))}
{deviceSorted.length === 0 && (
<TableRow>
<TableCell colSpan={6} sx={{ color:'#888' }}>No targets found for this job.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Box>
<Box sx={{ mt: 2 }}>
{renderHistory()}
</Box>
</Box>
)}
</Box>
{/* Bottom actions removed per design; actions live next to tabs. */}

View File

@@ -42,17 +42,22 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken
const occurrence = (j.schedule_type || 'immediately').replace(/^./, (c) => c.toUpperCase());
const fmt = (ts) => {
if (!ts) return '';
try { const d = new Date(Number(ts) * 1000); return d.toLocaleString(); } catch { return ''; }
try {
const d = new Date(Number(ts) * 1000);
return d.toLocaleString(undefined, { year:'numeric', month:'2-digit', day:'2-digit', hour:'numeric', minute:'2-digit' });
} catch { return ''; }
};
const result = j.last_status || (j.next_run_ts ? 'Scheduled' : '');
return {
id: j.id,
name: j.name,
scriptWorkflow: compName,
target: targetText,
occurrence,
lastRun: '',
nextRun: fmt(j.start_ts),
result: 'Success',
lastRun: fmt(j.last_run_ts),
nextRun: fmt(j.next_run_ts || j.start_ts),
result,
resultsCounts: j.result_counts || { pending: (Array.isArray(j.targets)?j.targets.length:0) },
enabled: !!j.enabled,
raw: j
};
@@ -64,8 +69,13 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken
}
};
React.useEffect(() => { loadJobs(); }, []);
React.useEffect(() => { loadJobs(); }, [refreshToken]);
// Initial load and polling each 5 seconds for live status updates
React.useEffect(() => {
let timer;
(async () => { try { await loadJobs(); } catch {} })();
timer = setInterval(loadJobs, 5000);
return () => { if (timer) clearInterval(timer); };
}, [refreshToken]);
const handleSort = (col) => {
if (orderBy === col) setOrder(order === "asc" ? "desc" : "asc");
@@ -84,8 +94,48 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken
});
}, [rows, orderBy, order]);
const resultColor = (r) =>
r === "Success" ? "#00d18c" : r === "Warning" ? "#ff8c00" : "#ff4f4f";
const resultColor = (r) => {
if (r === 'Success') return '#00d18c';
if (r === 'Running') return '#58a6ff';
if (r === 'Scheduled') return '#999999';
if (r === 'Expired') return '#777777';
if (r === 'Timed Out') return '#b36ae2';
if (r === 'Warning') return '#ff8c00';
if (r === 'Failed') return '#ff4f4f';
return '#aaaaaa';
};
const ResultsBar = ({ counts }) => {
const total = Math.max(1, Number(counts?.total_targets || 0));
const seg = (n) => `${Math.round(((n||0)/total)*100)}%`;
const styleSeg = (bg, w) => ({ display: 'inline-block', height: 8, background: bg, width: w });
const s = counts || {};
const sections = [
{ key: 'success', color: '#00d18c' },
{ key: 'running', color: '#58a6ff' },
{ key: 'failed', color: '#ff4f4f' },
{ key: 'timed_out', color: '#b36ae2' },
{ key: 'expired', color: '#777777' },
{ key: 'pending', color: '#999999' }
];
return (
<div>
<div style={{ background: '#333', borderRadius: 2, overflow: 'hidden', width: 260 }}>
{sections.map(({key,color}) => (s[key] ? <span key={key} style={styleSeg(color, seg(s[key]))} /> : null))}
</div>
<div style={{ color: '#aaa', fontSize: 12, marginTop: 4 }}>
{['success','running','failed','timed_out','expired','pending']
.filter(k => s[k])
.map((k,i) => (
<span key={k} style={{ marginRight: 10 }}>
<span style={{ display: 'inline-block', width: 8, height: 8, background: sections.find(x=>x.key===k).color, marginRight: 6 }} />
{s[k]} {k.replace('_',' ').replace(/^./, c=>c.toUpperCase())}
</span>
))}
</div>
</div>
);
};
return (
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}>
@@ -125,12 +175,13 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken
["occurrence", "Schedule Occurrence"],
["lastRun", "Last Run"],
["nextRun", "Next Run"],
["result", "Result"],
["enabled", "Enabled"],
["edit", "Edit Job"]
["result", "Result"],
["results", "Results"],
["enabled", "Enabled"],
["edit", "Edit Job"]
].map(([key, label]) => (
<TableCell key={key} sortDirection={orderBy === key ? order : false}>
{key !== "edit" ? (
{key !== "edit" && key !== "results" ? (
<TableSortLabel
active={orderBy === key}
direction={orderBy === key ? order : "asc"}
@@ -168,6 +219,9 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken
/>
{r.result}
</TableCell>
<TableCell>
<ResultsBar counts={r.resultsCounts} />
</TableCell>
<TableCell>
<Switch
checked={r.enabled}