ENGINE: Web Asset Fixes

This commit is contained in:
2025-11-01 04:07:15 -06:00
parent 0e13358c7e
commit b815592639
55764 changed files with 22585 additions and 936312 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,593 @@
import React, { useEffect, useState, useCallback } from "react";
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Box,
Typography,
Paper,
FormControlLabel,
Checkbox,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
CircularProgress
} from "@mui/material";
import { Folder as FolderIcon, Description as DescriptionIcon } from "@mui/icons-material";
import { SimpleTreeView, TreeItem } from "@mui/x-tree-view";
function buildTree(items, folders, rootLabel = "Scripts") {
const map = {};
const rootNode = {
id: "root",
label: rootLabel,
path: "",
isFolder: true,
children: []
};
map[rootNode.id] = rootNode;
(folders || []).forEach((f) => {
const parts = (f || "").split("/");
let children = rootNode.children;
let parentPath = "";
parts.forEach((part) => {
const path = parentPath ? `${parentPath}/${part}` : part;
let node = children.find((n) => n.id === path);
if (!node) {
node = { id: path, label: part, path, isFolder: true, children: [] };
children.push(node);
map[path] = node;
}
children = node.children;
parentPath = path;
});
});
(items || []).forEach((s) => {
const parts = (s.rel_path || "").split("/");
let children = rootNode.children;
let parentPath = "";
parts.forEach((part, idx) => {
const path = parentPath ? `${parentPath}/${part}` : part;
const isFile = idx === parts.length - 1;
let node = children.find((n) => n.id === path);
if (!node) {
node = {
id: path,
label: isFile ? (s.name || s.file_name || part) : part,
path,
isFolder: !isFile,
fileName: s.file_name,
script: isFile ? s : null,
children: []
};
children.push(node);
map[path] = node;
}
if (!isFile) {
children = node.children;
parentPath = path;
}
});
});
return { root: [rootNode], map };
}
export default function QuickJob({ open, onClose, hostnames = [] }) {
const [tree, setTree] = useState([]);
const [nodeMap, setNodeMap] = useState({});
const [selectedPath, setSelectedPath] = useState("");
const [running, setRunning] = useState(false);
const [error, setError] = useState("");
const [runAsCurrentUser, setRunAsCurrentUser] = useState(false);
const [mode, setMode] = useState("scripts"); // 'scripts' | 'ansible'
const [credentials, setCredentials] = useState([]);
const [credentialsLoading, setCredentialsLoading] = useState(false);
const [credentialsError, setCredentialsError] = useState("");
const [selectedCredentialId, setSelectedCredentialId] = useState("");
const [useSvcAccount, setUseSvcAccount] = useState(true);
const [variables, setVariables] = useState([]);
const [variableValues, setVariableValues] = useState({});
const [variableErrors, setVariableErrors] = useState({});
const [variableStatus, setVariableStatus] = useState({ loading: false, error: "" });
const loadTree = useCallback(async () => {
try {
const island = mode === 'ansible' ? 'ansible' : 'scripts';
const resp = await fetch(`/api/assembly/list?island=${island}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const { root, map } = buildTree(data.items || [], data.folders || [], mode === 'ansible' ? 'Ansible Playbooks' : 'Scripts');
setTree(root);
setNodeMap(map);
} catch (err) {
console.error("Failed to load scripts:", err);
setTree([]);
setNodeMap({});
}
}, [mode]);
useEffect(() => {
if (open) {
setSelectedPath("");
setError("");
setVariables([]);
setVariableValues({});
setVariableErrors({});
setVariableStatus({ loading: false, error: "" });
setUseSvcAccount(true);
setSelectedCredentialId("");
loadTree();
}
}, [open, loadTree]);
useEffect(() => {
if (!open || mode !== "ansible") return;
let canceled = false;
setCredentialsLoading(true);
setCredentialsError("");
(async () => {
try {
const resp = await fetch("/api/credentials");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
if (canceled) return;
const list = Array.isArray(data?.credentials)
? data.credentials.filter((cred) => {
const conn = String(cred.connection_type || "").toLowerCase();
return conn === "ssh" || conn === "winrm";
})
: [];
list.sort((a, b) => String(a?.name || "").localeCompare(String(b?.name || "")));
setCredentials(list);
} catch (err) {
if (!canceled) {
setCredentials([]);
setCredentialsError(String(err.message || err));
}
} finally {
if (!canceled) setCredentialsLoading(false);
}
})();
return () => {
canceled = true;
};
}, [open, mode]);
useEffect(() => {
if (!open) {
setSelectedCredentialId("");
}
}, [open]);
useEffect(() => {
if (mode !== "ansible" || useSvcAccount) return;
if (!credentials.length) {
setSelectedCredentialId("");
return;
}
if (!selectedCredentialId || !credentials.some((cred) => String(cred.id) === String(selectedCredentialId))) {
setSelectedCredentialId(String(credentials[0].id));
}
}, [mode, credentials, selectedCredentialId, useSvcAccount]);
const renderNodes = (nodes = []) =>
nodes.map((n) => (
<TreeItem
key={n.id}
itemId={n.id}
label={
<Box sx={{ display: "flex", alignItems: "center" }}>
{n.isFolder ? (
<FolderIcon fontSize="small" sx={{ mr: 1, color: "#ccc" }} />
) : (
<DescriptionIcon fontSize="small" sx={{ mr: 1, color: "#ccc" }} />
)}
<Typography variant="body2" sx={{ color: "#e6edf3" }}>{n.label}</Typography>
</Box>
}
>
{n.children && n.children.length ? renderNodes(n.children) : null}
</TreeItem>
));
const onItemSelect = (_e, itemId) => {
const node = nodeMap[itemId];
if (node && !node.isFolder) {
setSelectedPath(node.path);
setError("");
setVariableErrors({});
}
};
const normalizeVariables = (list) => {
if (!Array.isArray(list)) return [];
return list
.map((raw) => {
if (!raw || typeof raw !== "object") return null;
const name = typeof raw.name === "string" ? raw.name.trim() : typeof raw.key === "string" ? raw.key.trim() : "";
if (!name) return null;
const type = typeof raw.type === "string" ? raw.type.toLowerCase() : "string";
const label = typeof raw.label === "string" && raw.label.trim() ? raw.label.trim() : name;
const description = typeof raw.description === "string" ? raw.description : "";
const required = Boolean(raw.required);
const defaultValue = raw.hasOwnProperty("default")
? raw.default
: raw.hasOwnProperty("defaultValue")
? raw.defaultValue
: raw.hasOwnProperty("default_value")
? raw.default_value
: "";
return { name, label, type, description, required, default: defaultValue };
})
.filter(Boolean);
};
const deriveInitialValue = (variable) => {
const { type, default: defaultValue } = variable;
if (type === "boolean") {
if (typeof defaultValue === "boolean") return defaultValue;
if (defaultValue == null) return false;
const str = String(defaultValue).trim().toLowerCase();
if (!str) return false;
return ["true", "1", "yes", "on"].includes(str);
}
if (type === "number") {
if (defaultValue == null || defaultValue === "") return "";
if (typeof defaultValue === "number" && Number.isFinite(defaultValue)) {
return String(defaultValue);
}
const parsed = Number(defaultValue);
return Number.isFinite(parsed) ? String(parsed) : "";
}
return defaultValue == null ? "" : String(defaultValue);
};
useEffect(() => {
if (!selectedPath) {
setVariables([]);
setVariableValues({});
setVariableErrors({});
setVariableStatus({ loading: false, error: "" });
return;
}
let canceled = false;
const loadAssembly = async () => {
setVariableStatus({ loading: true, error: "" });
try {
const island = mode === "ansible" ? "ansible" : "scripts";
const trimmed = (selectedPath || "").replace(/\\/g, "/").replace(/^\/+/, "").trim();
if (!trimmed) {
setVariables([]);
setVariableValues({});
setVariableErrors({});
setVariableStatus({ loading: false, error: "" });
return;
}
let relPath = trimmed;
if (island === "scripts" && relPath.toLowerCase().startsWith("scripts/")) {
relPath = relPath.slice("Scripts/".length);
} else if (island === "ansible" && relPath.toLowerCase().startsWith("ansible_playbooks/")) {
relPath = relPath.slice("Ansible_Playbooks/".length);
}
const resp = await fetch(`/api/assembly/load?island=${island}&path=${encodeURIComponent(relPath)}`);
if (!resp.ok) throw new Error(`Failed to load assembly (HTTP ${resp.status})`);
const data = await resp.json();
const defs = normalizeVariables(data?.assembly?.variables || []);
if (!canceled) {
setVariables(defs);
const initialValues = {};
defs.forEach((v) => {
initialValues[v.name] = deriveInitialValue(v);
});
setVariableValues(initialValues);
setVariableErrors({});
setVariableStatus({ loading: false, error: "" });
}
} catch (err) {
if (!canceled) {
setVariables([]);
setVariableValues({});
setVariableErrors({});
setVariableStatus({ loading: false, error: err?.message || String(err) });
}
}
};
loadAssembly();
return () => {
canceled = true;
};
}, [selectedPath, mode]);
const handleVariableChange = (variable, rawValue) => {
const { name, type } = variable;
if (!name) return;
setVariableValues((prev) => ({
...prev,
[name]: type === "boolean" ? Boolean(rawValue) : rawValue
}));
setVariableErrors((prev) => {
if (!prev[name]) return prev;
const next = { ...prev };
delete next[name];
return next;
});
};
const buildVariablePayload = () => {
const payload = {};
variables.forEach((variable) => {
if (!variable?.name) return;
const { name, type } = variable;
const hasOverride = Object.prototype.hasOwnProperty.call(variableValues, name);
const raw = hasOverride ? variableValues[name] : deriveInitialValue(variable);
if (type === "boolean") {
payload[name] = Boolean(raw);
} else if (type === "number") {
if (raw === "" || raw === null || raw === undefined) {
payload[name] = "";
} else {
const num = Number(raw);
payload[name] = Number.isFinite(num) ? num : "";
}
} else {
payload[name] = raw == null ? "" : String(raw);
}
});
return payload;
};
const onRun = async () => {
if (!selectedPath) {
setError(mode === 'ansible' ? "Please choose a playbook to run." : "Please choose a script to run.");
return;
}
if (mode === 'ansible' && !useSvcAccount && !selectedCredentialId) {
setError("Select a credential to run this playbook.");
return;
}
if (variables.length) {
const errors = {};
variables.forEach((variable) => {
if (!variable) return;
if (!variable.required) return;
if (variable.type === "boolean") return;
const hasOverride = Object.prototype.hasOwnProperty.call(variableValues, variable.name);
const raw = hasOverride ? variableValues[variable.name] : deriveInitialValue(variable);
if (raw == null || raw === "") {
errors[variable.name] = "Required";
}
});
if (Object.keys(errors).length) {
setVariableErrors(errors);
setError("Please fill in all required variable values.");
return;
}
}
setRunning(true);
setError("");
try {
let resp;
const variableOverrides = buildVariablePayload();
if (mode === 'ansible') {
const playbook_path = selectedPath; // relative to ansible island
resp = await fetch("/api/ansible/quick_run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
playbook_path,
hostnames,
variable_values: variableOverrides,
credential_id: !useSvcAccount && selectedCredentialId ? Number(selectedCredentialId) : null,
use_service_account: Boolean(useSvcAccount)
})
});
} else {
// quick_run expects a path relative to Assemblies root with 'Scripts/' prefix
const script_path = selectedPath.startsWith('Scripts/') ? selectedPath : `Scripts/${selectedPath}`;
resp = await fetch("/api/scripts/quick_run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
script_path,
hostnames,
run_mode: runAsCurrentUser ? "current_user" : "system",
variable_values: variableOverrides
})
});
}
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
onClose && onClose();
} catch (err) {
setError(String(err.message || err));
} finally {
setRunning(false);
}
};
const credentialRequired = mode === "ansible" && !useSvcAccount;
const disableRun =
running ||
!selectedPath ||
(credentialRequired && (!selectedCredentialId || !credentials.length));
return (
<Dialog open={open} onClose={running ? undefined : onClose} fullWidth maxWidth="md"
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
>
<DialogTitle>Quick Job</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Button size="small" variant={mode === 'scripts' ? 'outlined' : 'text'} onClick={() => setMode('scripts')} sx={{ textTransform: 'none', color: '#58a6ff', borderColor: '#58a6ff' }}>Scripts</Button>
<Button size="small" variant={mode === 'ansible' ? 'outlined' : 'text'} onClick={() => setMode('ansible')} sx={{ textTransform: 'none', color: '#58a6ff', borderColor: '#58a6ff' }}>Ansible</Button>
</Box>
<Typography variant="body2" sx={{ color: "#aaa", mb: 1 }}>
Select a {mode === 'ansible' ? 'playbook' : 'script'} to run on {hostnames.length} device{hostnames.length !== 1 ? "s" : ""}.
</Typography>
{mode === 'ansible' && (
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap", mb: 2 }}>
<FormControlLabel
control={
<Checkbox
checked={useSvcAccount}
onChange={(e) => {
const checked = e.target.checked;
setUseSvcAccount(checked);
if (checked) {
setSelectedCredentialId("");
} else if (!selectedCredentialId && credentials.length) {
setSelectedCredentialId(String(credentials[0].id));
}
}}
size="small"
/>
}
label="Use Configured svcBorealis Account"
sx={{ mr: 2 }}
/>
<FormControl
size="small"
sx={{ minWidth: 260 }}
disabled={useSvcAccount || credentialsLoading || !credentials.length}
>
<InputLabel sx={{ color: "#aaa" }}>Credential</InputLabel>
<Select
value={selectedCredentialId}
label="Credential"
onChange={(e) => setSelectedCredentialId(e.target.value)}
sx={{ bgcolor: "#1f1f1f", color: "#fff" }}
>
{credentials.map((cred) => {
const conn = String(cred.connection_type || "").toUpperCase();
return (
<MenuItem key={cred.id} value={String(cred.id)}>
{cred.name}
{conn ? ` (${conn})` : ""}
</MenuItem>
);
})}
</Select>
</FormControl>
{useSvcAccount && (
<Typography variant="body2" sx={{ color: "#aaa" }}>
Runs with the agent&apos;s svcBorealis account.
</Typography>
)}
{credentialsLoading && <CircularProgress size={18} sx={{ color: "#58a6ff" }} />}
{!credentialsLoading && credentialsError && (
<Typography variant="body2" sx={{ color: "#ff8080" }}>{credentialsError}</Typography>
)}
{!useSvcAccount && !credentialsLoading && !credentialsError && !credentials.length && (
<Typography variant="body2" sx={{ color: "#ff8080" }}>
No SSH or WinRM credentials available. Create one under Access Management.
</Typography>
)}
</Box>
)}
<Box sx={{ display: "flex", gap: 2 }}>
<Paper sx={{ flex: 1, p: 1, bgcolor: "#1e1e1e", maxHeight: 400, overflow: "auto" }}>
<SimpleTreeView sx={{ color: "#e6edf3" }} onItemSelectionToggle={onItemSelect}>
{tree.length ? renderNodes(tree) : (
<Typography variant="body2" sx={{ color: "#888", p: 1 }}>
{mode === 'ansible' ? 'No playbooks found.' : 'No scripts found.'}
</Typography>
)}
</SimpleTreeView>
</Paper>
<Box sx={{ width: 320 }}>
<Typography variant="subtitle2" sx={{ color: "#ccc", mb: 1 }}>Selection</Typography>
<Typography variant="body2" sx={{ color: selectedPath ? "#e6edf3" : "#888" }}>
{selectedPath || (mode === 'ansible' ? 'No playbook selected' : 'No script selected')}
</Typography>
<Box sx={{ mt: 2 }}>
{mode !== 'ansible' && (
<>
<FormControlLabel
control={<Checkbox size="small" checked={runAsCurrentUser} onChange={(e) => setRunAsCurrentUser(e.target.checked)} />}
label={<Typography variant="body2">Run as currently logged-in user</Typography>}
/>
<Typography variant="caption" sx={{ color: "#888" }}>
Unchecked = Run-As BUILTIN\SYSTEM
</Typography>
</>
)}
</Box>
<Box sx={{ mt: 3 }}>
<Typography variant="subtitle2" sx={{ color: "#ccc", mb: 1 }}>Variables</Typography>
{variableStatus.loading ? (
<Typography variant="body2" sx={{ color: "#888" }}>Loading variables</Typography>
) : variableStatus.error ? (
<Typography variant="body2" sx={{ color: "#ff4f4f" }}>{variableStatus.error}</Typography>
) : variables.length ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{variables.map((variable) => (
<Box key={variable.name}>
{variable.type === "boolean" ? (
<FormControlLabel
control={(
<Checkbox
size="small"
checked={Boolean(variableValues[variable.name])}
onChange={(e) => handleVariableChange(variable, e.target.checked)}
/>
)}
label={
<Typography variant="body2">
{variable.label}
{variable.required ? " *" : ""}
</Typography>
}
/>
) : (
<TextField
fullWidth
size="small"
label={`${variable.label}${variable.required ? " *" : ""}`}
type={variable.type === "number" ? "number" : variable.type === "credential" ? "password" : "text"}
value={variableValues[variable.name] ?? ""}
onChange={(e) => handleVariableChange(variable, e.target.value)}
InputLabelProps={{ shrink: true }}
sx={{
"& .MuiOutlinedInput-root": { bgcolor: "#1b1b1b", color: "#e6edf3" },
"& .MuiInputBase-input": { color: "#e6edf3" }
}}
error={Boolean(variableErrors[variable.name])}
helperText={variableErrors[variable.name] || variable.description || ""}
/>
)}
{variable.type === "boolean" && variable.description ? (
<Typography variant="caption" sx={{ color: "#888", ml: 3 }}>
{variable.description}
</Typography>
) : null}
</Box>
))}
</Box>
) : (
<Typography variant="body2" sx={{ color: "#888" }}>No variables defined for this assembly.</Typography>
)}
</Box>
{error && (
<Typography variant="body2" sx={{ color: "#ff4f4f", mt: 1 }}>{error}</Typography>
)}
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={running} sx={{ color: "#58a6ff" }}>Cancel</Button>
<Button onClick={onRun} disabled={disableRun}
sx={{ color: disableRun ? "#666" : "#58a6ff" }}
>
Run
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,685 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Scheduled_Jobs_List.jsx
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState
} from "react";
import {
Paper,
Box,
Typography,
Button,
Switch,
Dialog,
DialogTitle,
DialogActions,
CircularProgress
} from "@mui/material";
import { AgGridReact } from "ag-grid-react";
import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community";
ModuleRegistry.registerModules([AllCommunityModule]);
const myTheme = themeQuartz.withParams({
accentColor: "#FFA6FF",
backgroundColor: "#1f2836",
browserColorScheme: "dark",
chromeBackgroundColor: {
ref: "foregroundColor",
mix: 0.07,
onto: "backgroundColor"
},
fontFamily: {
googleFont: "IBM Plex Sans"
},
foregroundColor: "#FFF",
headerFontSize: 14
});
const themeClassName = myTheme.themeName || "ag-theme-quartz";
const gridFontFamily = '"IBM Plex Sans", "Helvetica Neue", Arial, sans-serif';
const iconFontFamily = '"Quartz Regular"';
function ResultsBar({ counts }) {
const total = Math.max(1, Number(counts?.total_targets || 0));
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" }
];
const labelFor = (key) =>
key === "pending"
? "Scheduled"
: key
.replace(/_/g, " ")
.replace(/^./, (c) => c.toUpperCase());
const hasNonPending = sections
.filter((section) => section.key !== "pending")
.some((section) => Number(counts?.[section.key] || 0) > 0);
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 0.25,
lineHeight: 1.7,
fontFamily: gridFontFamily
}}
>
<Box
sx={{
display: "flex",
borderRadius: 1,
overflow: "hidden",
width: 220,
height: 6
}}
>
{sections.map((section) => {
const value = Number(counts?.[section.key] || 0);
if (!value) return null;
const width = `${Math.round((value / total) * 100)}%`;
return (
<Box
key={section.key}
component="span"
sx={{ display: "block", height: "100%", width, backgroundColor: section.color }}
/>
);
})}
</Box>
<Box
sx={{
display: "flex",
flexWrap: "wrap",
columnGap: 0.75,
rowGap: 0.25,
color: "#aaa",
fontSize: 11,
fontFamily: gridFontFamily
}}
>
{(() => {
if (!hasNonPending && Number(counts?.pending || 0) > 0) {
return <Box component="span">Scheduled</Box>;
}
return sections
.filter((section) => Number(counts?.[section.key] || 0) > 0)
.map((section) => (
<Box
key={section.key}
component="span"
sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}
>
<Box
component="span"
sx={{
width: 6,
height: 6,
borderRadius: 1,
backgroundColor: section.color
}}
/>
{counts?.[section.key]} {labelFor(section.key)}
</Box>
));
})()}
</Box>
</Box>
);
}
export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken }) {
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [selectedIds, setSelectedIds] = useState(() => new Set());
const gridApiRef = useRef(null);
const loadJobs = useCallback(
async ({ showLoading = false } = {}) => {
if (showLoading) {
setLoading(true);
setError("");
}
try {
const resp = await fetch("/api/scheduled_jobs");
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
throw new Error(data?.error || `HTTP ${resp.status}`);
}
const pretty = (st) => {
const s = String(st || "").toLowerCase();
const map = {
immediately: "Immediately",
once: "Once",
every_5_minutes: "Every 5 Minutes",
every_10_minutes: "Every 10 Minutes",
every_15_minutes: "Every 15 Minutes",
every_30_minutes: "Every 30 Minutes",
every_hour: "Every Hour",
daily: "Daily",
weekly: "Weekly",
monthly: "Monthly",
yearly: "Yearly"
};
if (map[s]) return map[s];
try {
return s.replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase());
} catch {
return String(st || "");
}
};
const fmt = (ts) => {
if (!ts) return "";
try {
const d = new Date(Number(ts) * 1000);
if (Number.isNaN(d?.getTime())) return "";
return d.toLocaleString(undefined, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "numeric",
minute: "2-digit"
});
} catch {
return "";
}
};
const mappedRows = (data?.jobs || []).map((j) => {
const compName = (Array.isArray(j.components) && j.components[0]?.name) || "Demonstration Component";
const targetText = Array.isArray(j.targets)
? `${j.targets.length} device${j.targets.length !== 1 ? "s" : ""}`
: "";
const occurrence = pretty(j.schedule_type || "immediately");
const resultsCounts = {
total_targets: Array.isArray(j.targets) ? j.targets.length : 0,
pending: Array.isArray(j.targets) ? j.targets.length : 0,
...(j.result_counts || {})
};
if (resultsCounts && resultsCounts.total_targets == null) {
resultsCounts.total_targets = Array.isArray(j.targets) ? j.targets.length : 0;
}
return {
id: j.id,
name: j.name,
scriptWorkflow: compName,
target: targetText,
occurrence,
lastRun: fmt(j.last_run_ts),
nextRun: fmt(j.next_run_ts || j.start_ts),
result: j.last_status || (j.next_run_ts ? "Scheduled" : ""),
resultsCounts,
enabled: Boolean(j.enabled),
raw: j
};
});
setRows(mappedRows);
setError("");
setSelectedIds((prev) => {
if (!prev.size) return prev;
const valid = new Set(
mappedRows.map((row, index) => row.id ?? row.name ?? String(index))
);
let changed = false;
const next = new Set();
prev.forEach((value) => {
if (valid.has(value)) {
next.add(value);
} else {
changed = true;
}
});
return changed ? next : prev;
});
} catch (err) {
setRows([]);
setSelectedIds(() => new Set());
setError(String(err?.message || err || "Failed to load scheduled jobs"));
} finally {
if (showLoading) {
setLoading(false);
}
}
},
[]
);
useEffect(() => {
let timer;
let isMounted = true;
(async () => {
if (!isMounted) return;
await loadJobs({ showLoading: true });
})();
timer = setInterval(() => {
loadJobs();
}, 5000);
return () => {
isMounted = false;
if (timer) clearInterval(timer);
};
}, [loadJobs, refreshToken]);
const handleGridReady = useCallback((params) => {
gridApiRef.current = params.api;
}, []);
useEffect(() => {
const api = gridApiRef.current;
if (!api) return;
if (loading) {
api.showLoadingOverlay();
} else if (!rows.length) {
api.showNoRowsOverlay();
} else {
api.hideOverlay();
}
}, [loading, rows]);
useEffect(() => {
const api = gridApiRef.current;
if (!api) return;
api.forEachNode((node) => {
const shouldSelect = selectedIds.has(node.id);
if (node.isSelected() !== shouldSelect) {
node.setSelected(shouldSelect);
}
});
}, [rows, selectedIds]);
const anySelected = selectedIds.size > 0;
const handleSelectionChanged = useCallback(() => {
const api = gridApiRef.current;
if (!api) return;
const selectedNodes = api.getSelectedNodes();
const next = new Set();
selectedNodes.forEach((node) => {
if (node?.id != null) {
next.add(String(node.id));
}
});
setSelectedIds(next);
}, []);
const getRowId = useCallback((params) => {
return (
params?.data?.id ??
params?.data?.name ??
String(params?.rowIndex ?? "")
);
}, []);
const nameCellRenderer = useCallback(
(params) => {
const row = params.data;
if (!row) return null;
const handleClick = (event) => {
event.preventDefault();
event.stopPropagation();
if (typeof onEditJob === "function") {
onEditJob(row.raw);
}
};
return (
<Button
onClick={handleClick}
sx={{
color: "#58a6ff",
textTransform: "none",
p: 0,
minWidth: 0,
fontFamily: gridFontFamily
}}
>
{row.name || "-"}
</Button>
);
},
[onEditJob]
);
const resultsCellRenderer = useCallback((params) => {
return <ResultsBar counts={params?.data?.resultsCounts} />;
}, []);
const enabledCellRenderer = useCallback(
(params) => {
const row = params.data;
if (!row) return null;
const handleToggle = async (event) => {
event.stopPropagation();
const nextEnabled = event.target.checked;
try {
await fetch(`/api/scheduled_jobs/${row.id}/toggle`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: nextEnabled })
});
} catch {
// ignore network errors for toggle
}
setRows((prev) =>
prev.map((job) => {
if ((job.id ?? job.name) === (row.id ?? row.name)) {
const updatedRaw = { ...(job.raw || {}), enabled: nextEnabled };
return { ...job, enabled: nextEnabled, raw: updatedRaw };
}
return job;
})
);
};
return (
<Switch
size="small"
checked={Boolean(row.enabled)}
onChange={handleToggle}
onClick={(event) => event.stopPropagation()}
sx={{
"& .MuiSwitch-switchBase.Mui-checked": {
color: "#58a6ff"
},
"& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track": {
bgcolor: "#58a6ff"
}
}}
/>
);
},
[]
);
const columnDefs = useMemo(
() => [
{
headerName: "",
field: "__checkbox__",
checkboxSelection: true,
headerCheckboxSelection: true,
maxWidth: 60,
minWidth: 60,
sortable: false,
filter: false,
resizable: false,
suppressMenu: true,
pinned: false
},
{
headerName: "Name",
field: "name",
cellRenderer: nameCellRenderer,
sort: "asc"
},
{
headerName: "Assembly(s)",
field: "scriptWorkflow",
valueGetter: (params) => params.data?.scriptWorkflow || "Demonstration Component"
},
{
headerName: "Target",
field: "target"
},
{
headerName: "Recurrence",
field: "occurrence"
},
{
headerName: "Last Run",
field: "lastRun"
},
{
headerName: "Next Run",
field: "nextRun"
},
{
headerName: "Results",
field: "resultsCounts",
minWidth: 280,
cellRenderer: resultsCellRenderer,
sortable: false,
filter: false
},
{
headerName: "Enabled",
field: "enabled",
minWidth: 140,
maxWidth: 160,
cellRenderer: enabledCellRenderer,
sortable: false,
filter: false,
resizable: false,
suppressMenu: true
}
],
[enabledCellRenderer, nameCellRenderer, resultsCellRenderer]
);
const defaultColDef = useMemo(
() => ({
sortable: true,
filter: "agTextColumnFilter",
resizable: true,
flex: 1,
minWidth: 140,
cellStyle: {
display: "flex",
alignItems: "center",
color: "#f5f7fa",
fontFamily: gridFontFamily,
fontSize: "13px"
},
headerClass: "scheduled-jobs-grid-header"
}),
[]
);
return (
<Paper
sx={{
m: 2,
p: 0,
bgcolor: "#1e1e1e",
color: "#f5f7fa",
fontFamily: gridFontFamily,
display: "flex",
flexDirection: "column",
flexGrow: 1,
minWidth: 0,
minHeight: 420
}}
elevation={2}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
p: 2,
borderBottom: "1px solid #2a2a2a"
}}
>
<Box>
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0.3 }}>
Scheduled Jobs
</Typography>
<Typography variant="body2" sx={{ color: "#aaa" }}>
List of automation jobs with schedules, results, and actions.
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
<Button
variant="outlined"
size="small"
disabled={!anySelected}
sx={{
color: anySelected ? "#ff8080" : "#666",
borderColor: anySelected ? "#ff8080" : "#333",
textTransform: "none",
fontFamily: gridFontFamily,
"&:hover": {
borderColor: anySelected ? "#ff8080" : "#333"
}
}}
onClick={() => setBulkDeleteOpen(true)}
>
Delete Job
</Button>
<Button
variant="contained"
size="small"
sx={{
bgcolor: "#58a6ff",
color: "#0b0f19",
textTransform: "none",
fontFamily: gridFontFamily,
"&:hover": {
bgcolor: "#7db7ff"
}
}}
onClick={() => onCreateJob && onCreateJob()}
>
Create Job
</Button>
</Box>
</Box>
{loading && (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
color: "#7db7ff",
px: 2,
py: 1.5,
borderBottom: "1px solid #2a2a2a"
}}
>
<CircularProgress size={18} sx={{ color: "#58a6ff" }} />
<Typography variant="body2">Loading scheduled jobs</Typography>
</Box>
)}
{error && (
<Box sx={{ px: 2, py: 1.5, color: "#ff8080", borderBottom: "1px solid #2a2a2a" }}>
<Typography variant="body2">{error}</Typography>
</Box>
)}
<Box
sx={{
flexGrow: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
mt: "10px",
px: 2,
pb: 2
}}
>
<Box
className={themeClassName}
sx={{
width: "100%",
height: "100%",
flexGrow: 1,
fontFamily: gridFontFamily,
"--ag-font-family": gridFontFamily,
"--ag-icon-font-family": iconFontFamily,
"--ag-row-border-style": "solid",
"--ag-row-border-color": "#2a2a2a",
"--ag-row-border-width": "1px",
"& .ag-root-wrapper": {
borderRadius: 1,
minHeight: 320
},
"& .ag-root, & .ag-header, & .ag-center-cols-container, & .ag-paging-panel": {
fontFamily: gridFontFamily
},
"& .ag-icon": {
fontFamily: iconFontFamily
},
"& .scheduled-jobs-grid-header": {
fontFamily: gridFontFamily,
fontWeight: 600,
color: "#f5f7fa"
}
}}
>
<AgGridReact
rowData={rows}
columnDefs={columnDefs}
defaultColDef={defaultColDef}
animateRows
rowHeight={46}
headerHeight={44}
suppressCellFocus
rowSelection="multiple"
rowMultiSelectWithClick
suppressRowClickSelection
getRowId={getRowId}
overlayNoRowsTemplate="<span class='ag-overlay-no-rows-center'>No scheduled jobs found.</span>"
onGridReady={handleGridReady}
onSelectionChanged={handleSelectionChanged}
theme={myTheme}
style={{
width: "100%",
height: "100%",
fontFamily: gridFontFamily,
"--ag-icon-font-family": iconFontFamily
}}
/>
</Box>
</Box>
<Dialog
open={bulkDeleteOpen}
onClose={() => setBulkDeleteOpen(false)}
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
>
<DialogTitle>Are you sure you want to delete this job(s)?</DialogTitle>
<DialogActions>
<Button onClick={() => setBulkDeleteOpen(false)} sx={{ color: "#58a6ff" }}>
Cancel
</Button>
<Button
onClick={async () => {
try {
const ids = Array.from(selectedIds);
const idSet = new Set(ids);
await Promise.allSettled(
ids.map((id) => fetch(`/api/scheduled_jobs/${id}`, { method: "DELETE" }))
);
setRows((prev) =>
prev.filter((job, index) => {
const key = getRowId({ data: job, rowIndex: index });
return !idSet.has(key);
})
);
setSelectedIds(() => new Set());
} catch {
// ignore delete errors here; a fresh load will surface them
}
setBulkDeleteOpen(false);
await loadJobs({ showLoading: true });
}}
variant="outlined"
sx={{ color: "#58a6ff", borderColor: "#58a6ff" }}
>
Confirm
</Button>
</DialogActions>
</Dialog>
</Paper>
);
}