mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-26 22:01:59 -06:00
Scheduled Job Page Design Changes
This commit is contained in:
@@ -58,6 +58,7 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
const [quickJobOpen, setQuickJobOpen] = useState(false);
|
const [quickJobOpen, setQuickJobOpen] = useState(false);
|
||||||
const [menuAnchor, setMenuAnchor] = useState(null);
|
const [menuAnchor, setMenuAnchor] = useState(null);
|
||||||
const [clearDialogOpen, setClearDialogOpen] = useState(false);
|
const [clearDialogOpen, setClearDialogOpen] = useState(false);
|
||||||
|
const [assemblyNameMap, setAssemblyNameMap] = useState({});
|
||||||
// Snapshotted status for the lifetime of this page
|
// Snapshotted status for the lifetime of this page
|
||||||
const [lockedStatus, setLockedStatus] = useState(() => {
|
const [lockedStatus, setLockedStatus] = useState(() => {
|
||||||
// Prefer status provided by the device list row if available
|
// Prefer status provided by the device list row if available
|
||||||
@@ -69,6 +70,68 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
return now - tsSec <= 300 ? "Online" : "Offline";
|
return now - tsSec <= 300 ? "Online" : "Offline";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let canceled = false;
|
||||||
|
const loadAssemblyNames = async () => {
|
||||||
|
const next = {};
|
||||||
|
const storeName = (rawPath, rawName, prefix = "") => {
|
||||||
|
const name = typeof rawName === "string" ? rawName.trim() : "";
|
||||||
|
if (!name) return;
|
||||||
|
const normalizedPath = String(rawPath || "")
|
||||||
|
.replace(/\\/g, "/")
|
||||||
|
.replace(/^\/+/, "")
|
||||||
|
.trim();
|
||||||
|
const keys = new Set();
|
||||||
|
if (normalizedPath) {
|
||||||
|
keys.add(normalizedPath);
|
||||||
|
if (prefix) {
|
||||||
|
const prefixed = `${prefix}/${normalizedPath}`.replace(/\/+/g, "/");
|
||||||
|
keys.add(prefixed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const base = normalizedPath ? normalizedPath.split("/").pop() || "" : "";
|
||||||
|
if (base) {
|
||||||
|
keys.add(base);
|
||||||
|
const dot = base.lastIndexOf(".");
|
||||||
|
if (dot > 0) {
|
||||||
|
keys.add(base.slice(0, dot));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keys.forEach((key) => {
|
||||||
|
if (key && !next[key]) {
|
||||||
|
next[key] = name;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const ingest = async (island, prefix = "") => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/assembly/list?island=${island}`);
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const data = await resp.json();
|
||||||
|
const items = Array.isArray(data.items) ? data.items : [];
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (!item || typeof item !== "object") return;
|
||||||
|
const rel = item.rel_path || item.path || item.file_name || item.playbook_path || "";
|
||||||
|
const label = (item.name || item.tab_name || item.display_name || item.file_name || "").trim();
|
||||||
|
storeName(rel, label, prefix);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore failures; map remains partial
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await ingest("scripts", "Scripts");
|
||||||
|
await ingest("workflows", "Workflows");
|
||||||
|
await ingest("ansible", "Ansible_Playbooks");
|
||||||
|
if (!canceled) {
|
||||||
|
setAssemblyNameMap(next);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadAssemblyNames();
|
||||||
|
return () => {
|
||||||
|
canceled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const statusFromHeartbeat = (tsSec, offlineAfter = 300) => {
|
const statusFromHeartbeat = (tsSec, offlineAfter = 300) => {
|
||||||
if (!tsSec) return "Offline";
|
if (!tsSec) return "Offline";
|
||||||
const now = Date.now() / 1000;
|
const now = Date.now() / 1000;
|
||||||
@@ -77,6 +140,21 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
|
|
||||||
const statusColor = (s) => (s === "Online" ? "#00d18c" : "#ff4f4f");
|
const statusColor = (s) => (s === "Online" ? "#00d18c" : "#ff4f4f");
|
||||||
|
|
||||||
|
const resolveAssemblyName = useCallback((scriptName, scriptPath) => {
|
||||||
|
const normalized = String(scriptPath || "").replace(/\\/g, "/").trim();
|
||||||
|
const base = normalized ? normalized.split("/").pop() || "" : "";
|
||||||
|
const baseNoExt = base && base.includes(".") ? base.slice(0, base.lastIndexOf(".")) : base;
|
||||||
|
return (
|
||||||
|
assemblyNameMap[normalized] ||
|
||||||
|
(base ? assemblyNameMap[base] : "") ||
|
||||||
|
(baseNoExt ? assemblyNameMap[baseNoExt] : "") ||
|
||||||
|
scriptName ||
|
||||||
|
base ||
|
||||||
|
scriptPath ||
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}, [assemblyNameMap]);
|
||||||
|
|
||||||
const formatLastSeen = (tsSec, offlineAfter = 120) => {
|
const formatLastSeen = (tsSec, offlineAfter = 120) => {
|
||||||
if (!tsSec) return "unknown";
|
if (!tsSec) return "unknown";
|
||||||
const now = Date.now() / 1000;
|
const now = Date.now() / 1000;
|
||||||
@@ -918,7 +996,8 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleViewOutput = async (row, which) => {
|
const handleViewOutput = useCallback(async (row, which) => {
|
||||||
|
if (!row || !row.id) return;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/device/activity/job/${row.id}`);
|
const resp = await fetch(`/api/device/activity/job/${row.id}`);
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
@@ -928,13 +1007,14 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
: ((data.script_path || "").toLowerCase().endsWith(".sh")) ? "bash"
|
: ((data.script_path || "").toLowerCase().endsWith(".sh")) ? "bash"
|
||||||
: ((data.script_path || "").toLowerCase().endsWith(".yml")) ? "yaml" : "powershell";
|
: ((data.script_path || "").toLowerCase().endsWith(".yml")) ? "yaml" : "powershell";
|
||||||
setOutputLang(lang);
|
setOutputLang(lang);
|
||||||
setOutputTitle(`${which === 'stderr' ? 'StdErr' : 'StdOut'} - ${data.script_name}`);
|
const friendly = resolveAssemblyName(data.script_name, data.script_path);
|
||||||
|
setOutputTitle(`${which === 'stderr' ? 'StdErr' : 'StdOut'} - ${friendly}`);
|
||||||
setOutputContent(which === 'stderr' ? (data.stderr || "") : (data.stdout || ""));
|
setOutputContent(which === 'stderr' ? (data.stderr || "") : (data.stdout || ""));
|
||||||
setOutputOpen(true);
|
setOutputOpen(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Failed to load output", e);
|
console.warn("Failed to load output", e);
|
||||||
}
|
}
|
||||||
};
|
}, [resolveAssemblyName]);
|
||||||
|
|
||||||
const handleHistorySort = (col) => {
|
const handleHistorySort = (col) => {
|
||||||
if (historyOrderBy === col) setHistoryOrder(historyOrder === "asc" ? "desc" : "asc");
|
if (historyOrderBy === col) setHistoryOrder(historyOrder === "asc" ? "desc" : "asc");
|
||||||
@@ -944,15 +1024,23 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const historyDisplayRows = useMemo(() => {
|
||||||
|
return (historyRows || []).map((row) => ({
|
||||||
|
...row,
|
||||||
|
script_display_name: resolveAssemblyName(row.script_name, row.script_path),
|
||||||
|
}));
|
||||||
|
}, [historyRows, resolveAssemblyName]);
|
||||||
|
|
||||||
const sortedHistory = useMemo(() => {
|
const sortedHistory = useMemo(() => {
|
||||||
const dir = historyOrder === "asc" ? 1 : -1;
|
const dir = historyOrder === "asc" ? 1 : -1;
|
||||||
return [...historyRows].sort((a, b) => {
|
const key = historyOrderBy === "script_name" ? "script_display_name" : historyOrderBy;
|
||||||
const A = a[historyOrderBy];
|
return [...historyDisplayRows].sort((a, b) => {
|
||||||
const B = b[historyOrderBy];
|
const A = a[key];
|
||||||
if (historyOrderBy === "ran_at") return ((A || 0) - (B || 0)) * dir;
|
const B = b[key];
|
||||||
|
if (key === "ran_at") return ((A || 0) - (B || 0)) * dir;
|
||||||
return String(A ?? "").localeCompare(String(B ?? "")) * dir;
|
return String(A ?? "").localeCompare(String(B ?? "")) * dir;
|
||||||
});
|
});
|
||||||
}, [historyRows, historyOrderBy, historyOrder]);
|
}, [historyDisplayRows, historyOrderBy, historyOrder]);
|
||||||
|
|
||||||
const renderHistory = () => (
|
const renderHistory = () => (
|
||||||
<Box>
|
<Box>
|
||||||
@@ -992,7 +1080,7 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
{sortedHistory.map((r) => (
|
{sortedHistory.map((r) => (
|
||||||
<TableRow key={r.id}>
|
<TableRow key={r.id}>
|
||||||
<TableCell>{(r.script_type || '').toLowerCase() === 'ansible' ? 'Ansible Playbook' : 'Script'}</TableCell>
|
<TableCell>{(r.script_type || '').toLowerCase() === 'ansible' ? 'Ansible Playbook' : 'Script'}</TableCell>
|
||||||
<TableCell>{r.script_name}</TableCell>
|
<TableCell>{r.script_display_name || r.script_name}</TableCell>
|
||||||
<TableCell>{formatTimestamp(r.ran_at)}</TableCell>
|
<TableCell>{formatTimestamp(r.ran_at)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Box sx={{
|
<Box sx={{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useMemo, useState, useCallback } from "react";
|
import React, { useEffect, useMemo, useState, useCallback, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
Paper,
|
Paper,
|
||||||
Box,
|
Box,
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Checkbox,
|
Checkbox,
|
||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
Select,
|
Select,
|
||||||
|
Menu,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Divider,
|
Divider,
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -22,14 +23,103 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableSortLabel
|
TableSortLabel,
|
||||||
|
GlobalStyles
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { Add as AddIcon, Delete as DeleteIcon } from "@mui/icons-material";
|
import {
|
||||||
|
Add as AddIcon,
|
||||||
|
Delete as DeleteIcon,
|
||||||
|
FilterList as FilterListIcon,
|
||||||
|
PendingActions as PendingActionsIcon,
|
||||||
|
Sync as SyncIcon,
|
||||||
|
Timer as TimerIcon,
|
||||||
|
Check as CheckIcon,
|
||||||
|
Error as ErrorIcon
|
||||||
|
} from "@mui/icons-material";
|
||||||
import { SimpleTreeView, TreeItem } from "@mui/x-tree-view";
|
import { SimpleTreeView, TreeItem } from "@mui/x-tree-view";
|
||||||
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
||||||
import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
|
import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
|
||||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
import Prism from "prismjs";
|
||||||
|
import "prismjs/components/prism-yaml";
|
||||||
|
import "prismjs/components/prism-bash";
|
||||||
|
import "prismjs/components/prism-powershell";
|
||||||
|
import "prismjs/components/prism-batch";
|
||||||
|
import "prismjs/themes/prism-okaidia.css";
|
||||||
|
import Editor from "react-simple-code-editor";
|
||||||
|
import ReactFlow, { Handle, Position } from "reactflow";
|
||||||
|
import "reactflow/dist/style.css";
|
||||||
|
|
||||||
|
const hiddenHandleStyle = {
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
border: "none",
|
||||||
|
background: "transparent",
|
||||||
|
opacity: 0,
|
||||||
|
pointerEvents: "none"
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_META = {
|
||||||
|
pending: { label: "Pending", color: "#aab2bf", Icon: PendingActionsIcon },
|
||||||
|
running: { label: "Running", color: "#58a6ff", Icon: SyncIcon },
|
||||||
|
expired: { label: "Expired", color: "#aab2bf", Icon: TimerIcon },
|
||||||
|
success: { label: "Success", color: "#00d18c", Icon: CheckIcon },
|
||||||
|
failed: { label: "Failed", color: "#ff4f4f", Icon: ErrorIcon }
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEVICE_COLUMNS = [
|
||||||
|
{ key: "hostname", label: "Hostname" },
|
||||||
|
{ key: "online", label: "Status" },
|
||||||
|
{ key: "site", label: "Site" },
|
||||||
|
{ key: "ran_on", label: "Ran On" },
|
||||||
|
{ key: "job_status", label: "Job Status" },
|
||||||
|
{ key: "output", label: "StdOut / StdErr" }
|
||||||
|
];
|
||||||
|
|
||||||
|
function StatusNode({ data }) {
|
||||||
|
const { label, color, count, onClick, isActive, Icon } = data || {};
|
||||||
|
const displayCount = Number.isFinite(count) ? count : Number(count) || 0;
|
||||||
|
const borderColor = color || "#333";
|
||||||
|
const activeGlow = color ? `${color}55` : "rgba(88,166,255,0.35)";
|
||||||
|
const handleClick = useCallback((event) => {
|
||||||
|
event?.preventDefault();
|
||||||
|
event?.stopPropagation();
|
||||||
|
onClick && onClick();
|
||||||
|
}, [onClick]);
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
onClick={handleClick}
|
||||||
|
sx={{
|
||||||
|
px: 5.4,
|
||||||
|
py: 3.8,
|
||||||
|
backgroundColor: "#1f1f1f",
|
||||||
|
borderRadius: 1.5,
|
||||||
|
border: `1px solid ${borderColor}`,
|
||||||
|
boxShadow: isActive ? `0 0 0 2px ${activeGlow}` : "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
minWidth: 324,
|
||||||
|
textAlign: "left",
|
||||||
|
transition: "border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease",
|
||||||
|
transform: isActive ? "translateY(-2px)" : "none",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
justifyContent: "flex-start"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Handle type="target" position={Position.Left} id="left-top" style={{ ...hiddenHandleStyle, top: "32%", transform: "translateY(-50%)" }} isConnectable={false} />
|
||||||
|
<Handle type="target" position={Position.Left} id="left-bottom" style={{ ...hiddenHandleStyle, top: "68%", transform: "translateY(-50%)" }} isConnectable={false} />
|
||||||
|
<Handle type="source" position={Position.Right} id="right-top" style={{ ...hiddenHandleStyle, top: "32%", transform: "translateY(-50%)" }} isConnectable={false} />
|
||||||
|
<Handle type="source" position={Position.Right} id="right-bottom" style={{ ...hiddenHandleStyle, top: "68%", transform: "translateY(-50%)" }} isConnectable={false} />
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1.2 }}>
|
||||||
|
{Icon ? <Icon sx={{ color: color || "#e6edf3", fontSize: 32 }} /> : null}
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: color || "#e6edf3", userSelect: "none", fontSize: "1.3rem" }}>
|
||||||
|
{`${displayCount} ${label || ""}`}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SectionHeader({ title, action }) {
|
function SectionHeader({ title, action }) {
|
||||||
return (
|
return (
|
||||||
@@ -322,6 +412,7 @@ function ComponentCard({ comp, onRemove, onVariableChange, errors = {} }) {
|
|||||||
export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
||||||
const [tab, setTab] = useState(0);
|
const [tab, setTab] = useState(0);
|
||||||
const [jobName, setJobName] = useState("");
|
const [jobName, setJobName] = useState("");
|
||||||
|
const [pageTitleJobName, setPageTitleJobName] = useState("");
|
||||||
// Components the job will run: {type:'script'|'workflow', path, name, description}
|
// Components the job will run: {type:'script'|'workflow', path, name, description}
|
||||||
const [components, setComponents] = useState([]);
|
const [components, setComponents] = useState([]);
|
||||||
const [targets, setTargets] = useState([]); // array of hostnames
|
const [targets, setTargets] = useState([]); // array of hostnames
|
||||||
@@ -344,12 +435,264 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
|||||||
const [selectedTargets, setSelectedTargets] = useState({}); // map hostname->bool
|
const [selectedTargets, setSelectedTargets] = useState({}); // map hostname->bool
|
||||||
const [deviceSearch, setDeviceSearch] = useState("");
|
const [deviceSearch, setDeviceSearch] = useState("");
|
||||||
const [componentVarErrors, setComponentVarErrors] = useState({});
|
const [componentVarErrors, setComponentVarErrors] = useState({});
|
||||||
|
const [deviceRows, setDeviceRows] = useState([]);
|
||||||
|
const [deviceStatusFilter, setDeviceStatusFilter] = useState(null);
|
||||||
|
const [deviceOrderBy, setDeviceOrderBy] = useState("hostname");
|
||||||
|
const [deviceOrder, setDeviceOrder] = useState("asc");
|
||||||
|
const [deviceFilters, setDeviceFilters] = useState({});
|
||||||
|
const [filterAnchorEl, setFilterAnchorEl] = useState(null);
|
||||||
|
const [activeFilterColumn, setActiveFilterColumn] = useState(null);
|
||||||
|
const [pendingFilterValue, setPendingFilterValue] = useState("");
|
||||||
|
|
||||||
const generateLocalId = useCallback(
|
const generateLocalId = useCallback(
|
||||||
() => `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
() => `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getDefaultFilterValue = useCallback((key) => (["online", "job_status", "output"].includes(key) ? "all" : ""), []);
|
||||||
|
|
||||||
|
const isColumnFiltered = useCallback((key) => {
|
||||||
|
if (!deviceFilters || typeof deviceFilters !== "object") return false;
|
||||||
|
const value = deviceFilters[key];
|
||||||
|
if (value == null) return false;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed || trimmed === "all") return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}, [deviceFilters]);
|
||||||
|
|
||||||
|
const openFilterMenu = useCallback((event, columnKey) => {
|
||||||
|
setActiveFilterColumn(columnKey);
|
||||||
|
setPendingFilterValue(deviceFilters[columnKey] ?? getDefaultFilterValue(columnKey));
|
||||||
|
setFilterAnchorEl(event.currentTarget);
|
||||||
|
}, [deviceFilters, getDefaultFilterValue]);
|
||||||
|
|
||||||
|
const closeFilterMenu = useCallback(() => {
|
||||||
|
setFilterAnchorEl(null);
|
||||||
|
setActiveFilterColumn(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyFilter = useCallback(() => {
|
||||||
|
if (!activeFilterColumn) {
|
||||||
|
closeFilterMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = pendingFilterValue;
|
||||||
|
setDeviceFilters((prev) => {
|
||||||
|
const next = { ...(prev || {}) };
|
||||||
|
if (!value || value === "all" || (typeof value === "string" && !value.trim())) {
|
||||||
|
delete next[activeFilterColumn];
|
||||||
|
} else {
|
||||||
|
next[activeFilterColumn] = value;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
closeFilterMenu();
|
||||||
|
}, [activeFilterColumn, pendingFilterValue, closeFilterMenu]);
|
||||||
|
|
||||||
|
const clearFilter = useCallback(() => {
|
||||||
|
if (!activeFilterColumn) {
|
||||||
|
closeFilterMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDeviceFilters((prev) => {
|
||||||
|
const next = { ...(prev || {}) };
|
||||||
|
delete next[activeFilterColumn];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setPendingFilterValue(getDefaultFilterValue(activeFilterColumn));
|
||||||
|
closeFilterMenu();
|
||||||
|
}, [activeFilterColumn, closeFilterMenu, getDefaultFilterValue]);
|
||||||
|
|
||||||
|
const renderFilterControl = () => {
|
||||||
|
const columnKey = activeFilterColumn;
|
||||||
|
if (!columnKey) return null;
|
||||||
|
if (columnKey === "online") {
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
value={typeof pendingFilterValue === "string" && pendingFilterValue ? pendingFilterValue : "all"}
|
||||||
|
onChange={(e) => setPendingFilterValue(e.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="all">All Statuses</MenuItem>
|
||||||
|
<MenuItem value="online">Online</MenuItem>
|
||||||
|
<MenuItem value="offline">Offline</MenuItem>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (columnKey === "job_status") {
|
||||||
|
const options = ["success", "failed", "running", "pending", "expired", "timed out"];
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
value={typeof pendingFilterValue === "string" && pendingFilterValue ? pendingFilterValue : "all"}
|
||||||
|
onChange={(e) => setPendingFilterValue(e.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="all">All Results</MenuItem>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<MenuItem key={opt} value={opt}>{opt.replace(/\b\w/g, (m) => m.toUpperCase())}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (columnKey === "output") {
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
value={typeof pendingFilterValue === "string" && pendingFilterValue ? pendingFilterValue : "all"}
|
||||||
|
onChange={(e) => setPendingFilterValue(e.target.value)}
|
||||||
|
>
|
||||||
|
<MenuItem value="all">All Output</MenuItem>
|
||||||
|
<MenuItem value="stdout">StdOut Only</MenuItem>
|
||||||
|
<MenuItem value="stderr">StdErr Only</MenuItem>
|
||||||
|
<MenuItem value="both">StdOut & StdErr</MenuItem>
|
||||||
|
<MenuItem value="none">No Output</MenuItem>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const placeholders = {
|
||||||
|
hostname: "Filter hostname",
|
||||||
|
site: "Filter site",
|
||||||
|
ran_on: "Filter date/time"
|
||||||
|
};
|
||||||
|
const value = typeof pendingFilterValue === "string" ? pendingFilterValue : "";
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
autoFocus
|
||||||
|
fullWidth
|
||||||
|
placeholder={placeholders[columnKey] || "Filter value"}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setPendingFilterValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
applyFilter();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeviceSort = useCallback((key) => {
|
||||||
|
setDeviceOrderBy((prevKey) => {
|
||||||
|
if (prevKey === key) {
|
||||||
|
setDeviceOrder((prevDir) => (prevDir === "asc" ? "desc" : "asc"));
|
||||||
|
return prevKey;
|
||||||
|
}
|
||||||
|
setDeviceOrder(key === "ran_on" ? "desc" : "asc");
|
||||||
|
return key;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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 deviceFiltered = useMemo(() => {
|
||||||
|
const matchStatusFilter = (status, filterKey) => {
|
||||||
|
if (filterKey === "pending") return status === "pending" || status === "scheduled" || status === "queued" || status === "";
|
||||||
|
if (filterKey === "running") return status === "running";
|
||||||
|
if (filterKey === "success") return status === "success";
|
||||||
|
if (filterKey === "failed") return status === "failed" || status === "failure" || status === "timed out" || status === "timed_out" || status === "warning";
|
||||||
|
if (filterKey === "expired") return status === "expired";
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return deviceRows.filter((row) => {
|
||||||
|
const normalizedStatus = String(row?.job_status || "").trim().toLowerCase();
|
||||||
|
if (deviceStatusFilter && !matchStatusFilter(normalizedStatus, deviceStatusFilter)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (deviceFilters && typeof deviceFilters === "object") {
|
||||||
|
for (const [key, rawValue] of Object.entries(deviceFilters)) {
|
||||||
|
if (rawValue == null) continue;
|
||||||
|
if (typeof rawValue === "string") {
|
||||||
|
const trimmed = rawValue.trim();
|
||||||
|
if (!trimmed || trimmed === "all") continue;
|
||||||
|
}
|
||||||
|
if (key === "hostname") {
|
||||||
|
const expected = String(rawValue || "").toLowerCase();
|
||||||
|
if (!String(row?.hostname || "").toLowerCase().includes(expected)) return false;
|
||||||
|
} else if (key === "online") {
|
||||||
|
if (rawValue === "online" && !row?.online) return false;
|
||||||
|
if (rawValue === "offline" && row?.online) return false;
|
||||||
|
} else if (key === "site") {
|
||||||
|
const expected = String(rawValue || "").toLowerCase();
|
||||||
|
if (!String(row?.site || "").toLowerCase().includes(expected)) return false;
|
||||||
|
} else if (key === "ran_on") {
|
||||||
|
const expected = String(rawValue || "").toLowerCase();
|
||||||
|
const formatted = fmtTs(row?.ran_on).toLowerCase();
|
||||||
|
if (!formatted.includes(expected)) return false;
|
||||||
|
} else if (key === "job_status") {
|
||||||
|
const expected = String(rawValue || "").toLowerCase();
|
||||||
|
if (!normalizedStatus.includes(expected)) return false;
|
||||||
|
} else if (key === "output") {
|
||||||
|
if (rawValue === "stdout" && !row?.has_stdout) return false;
|
||||||
|
if (rawValue === "stderr" && !row?.has_stderr) return false;
|
||||||
|
if (rawValue === "both" && (!row?.has_stdout || !row?.has_stderr)) return false;
|
||||||
|
if (rawValue === "none" && (row?.has_stdout || row?.has_stderr)) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [deviceRows, deviceStatusFilter, deviceFilters, fmtTs]);
|
||||||
|
|
||||||
|
const deviceSorted = useMemo(() => {
|
||||||
|
const arr = [...deviceFiltered];
|
||||||
|
const dir = deviceOrder === "asc" ? 1 : -1;
|
||||||
|
arr.sort((a, b) => {
|
||||||
|
let delta = 0;
|
||||||
|
switch (deviceOrderBy) {
|
||||||
|
case "hostname":
|
||||||
|
delta = String(a?.hostname || "").localeCompare(String(b?.hostname || ""));
|
||||||
|
break;
|
||||||
|
case "online":
|
||||||
|
delta = Number(a?.online ? 1 : 0) - Number(b?.online ? 1 : 0);
|
||||||
|
break;
|
||||||
|
case "site":
|
||||||
|
delta = String(a?.site || "").localeCompare(String(b?.site || ""));
|
||||||
|
break;
|
||||||
|
case "ran_on":
|
||||||
|
delta = Number(a?.ran_on || 0) - Number(b?.ran_on || 0);
|
||||||
|
break;
|
||||||
|
case "job_status":
|
||||||
|
delta = String(a?.job_status || "").localeCompare(String(b?.job_status || ""));
|
||||||
|
break;
|
||||||
|
case "output": {
|
||||||
|
const score = (row) => (row?.has_stdout ? 2 : 0) + (row?.has_stderr ? 1 : 0);
|
||||||
|
delta = score(a) - score(b);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
delta = 0;
|
||||||
|
}
|
||||||
|
if (delta === 0) {
|
||||||
|
delta = String(a?.hostname || "").localeCompare(String(b?.hostname || ""));
|
||||||
|
}
|
||||||
|
return delta * dir;
|
||||||
|
});
|
||||||
|
return arr;
|
||||||
|
}, [deviceFiltered, deviceOrder, deviceOrderBy]);
|
||||||
|
|
||||||
const normalizeComponentPath = useCallback((type, rawPath) => {
|
const normalizeComponentPath = useCallback((type, rawPath) => {
|
||||||
const trimmed = (rawPath || "").replace(/\\/g, "/").replace(/^\/+/, "").trim();
|
const trimmed = (rawPath || "").replace(/\\/g, "/").replace(/^\/+/, "").trim();
|
||||||
if (!trimmed) return "";
|
if (!trimmed) return "";
|
||||||
@@ -497,14 +840,12 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
|||||||
const [historyRows, setHistoryRows] = useState([]);
|
const [historyRows, setHistoryRows] = useState([]);
|
||||||
const [historyOrderBy, setHistoryOrderBy] = useState("started_ts");
|
const [historyOrderBy, setHistoryOrderBy] = useState("started_ts");
|
||||||
const [historyOrder, setHistoryOrder] = useState("desc");
|
const [historyOrder, setHistoryOrder] = useState("desc");
|
||||||
|
const activityCacheRef = useRef(new Map());
|
||||||
const fmtTs = useCallback((ts) => {
|
const [outputOpen, setOutputOpen] = useState(false);
|
||||||
if (!ts) return '';
|
const [outputTitle, setOutputTitle] = useState("");
|
||||||
try {
|
const [outputSections, setOutputSections] = useState([]);
|
||||||
const d = new Date(Number(ts) * 1000);
|
const [outputLoading, setOutputLoading] = useState(false);
|
||||||
return d.toLocaleString(undefined, { year:'numeric', month:'2-digit', day:'2-digit', hour:'numeric', minute:'2-digit' });
|
const [outputError, setOutputError] = useState("");
|
||||||
} catch { return ''; }
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadHistory = useCallback(async () => {
|
const loadHistory = useCallback(async () => {
|
||||||
if (!editing) return;
|
if (!editing) return;
|
||||||
@@ -522,7 +863,11 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
|||||||
if (!devResp.ok) throw new Error(dev.error || `HTTP ${devResp.status}`);
|
if (!devResp.ok) throw new Error(dev.error || `HTTP ${devResp.status}`);
|
||||||
setHistoryRows(Array.isArray(runs.runs) ? runs.runs : []);
|
setHistoryRows(Array.isArray(runs.runs) ? runs.runs : []);
|
||||||
setJobSummary(job.job || {});
|
setJobSummary(job.job || {});
|
||||||
setDeviceRows(Array.isArray(dev.devices) ? dev.devices : []);
|
const devices = Array.isArray(dev.devices) ? dev.devices.map((device) => ({
|
||||||
|
...device,
|
||||||
|
activities: Array.isArray(device.activities) ? device.activities : [],
|
||||||
|
})) : [];
|
||||||
|
setDeviceRows(devices);
|
||||||
} catch {
|
} catch {
|
||||||
setHistoryRows([]);
|
setHistoryRows([]);
|
||||||
setJobSummary({});
|
setJobSummary({});
|
||||||
@@ -555,18 +900,68 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const aggregatedHistory = useMemo(() => {
|
||||||
|
if (!Array.isArray(historyRows) || historyRows.length === 0) return [];
|
||||||
|
const map = new Map();
|
||||||
|
historyRows.forEach((row) => {
|
||||||
|
const key = row?.scheduled_ts || row?.started_ts || row?.finished_ts || row?.id;
|
||||||
|
if (!key) return;
|
||||||
|
const strKey = String(key);
|
||||||
|
const existing = map.get(strKey) || {
|
||||||
|
key: strKey,
|
||||||
|
scheduled_ts: row?.scheduled_ts || null,
|
||||||
|
started_ts: null,
|
||||||
|
finished_ts: null,
|
||||||
|
statuses: new Set()
|
||||||
|
};
|
||||||
|
if (!existing.scheduled_ts && row?.scheduled_ts) existing.scheduled_ts = row.scheduled_ts;
|
||||||
|
if (row?.started_ts) {
|
||||||
|
existing.started_ts = existing.started_ts == null ? row.started_ts : Math.min(existing.started_ts, row.started_ts);
|
||||||
|
}
|
||||||
|
if (row?.finished_ts) {
|
||||||
|
existing.finished_ts = existing.finished_ts == null ? row.finished_ts : Math.max(existing.finished_ts, row.finished_ts);
|
||||||
|
}
|
||||||
|
if (row?.status) existing.statuses.add(String(row.status));
|
||||||
|
map.set(strKey, existing);
|
||||||
|
});
|
||||||
|
const summaries = [];
|
||||||
|
map.forEach((entry) => {
|
||||||
|
const statuses = Array.from(entry.statuses).map((s) => String(s || "").trim().toLowerCase()).filter(Boolean);
|
||||||
|
if (!statuses.length) return;
|
||||||
|
const hasInFlight = statuses.some((s) => s === "running" || s === "pending" || s === "scheduled");
|
||||||
|
if (hasInFlight) return;
|
||||||
|
const hasFailure = statuses.some((s) => ["failed", "failure", "expired", "timed out", "timed_out", "warning"].includes(s));
|
||||||
|
const allSuccess = statuses.every((s) => s === "success");
|
||||||
|
const statusLabel = hasFailure ? "Failed" : (allSuccess ? "Success" : "Failed");
|
||||||
|
summaries.push({
|
||||||
|
key: entry.key,
|
||||||
|
scheduled_ts: entry.scheduled_ts,
|
||||||
|
started_ts: entry.started_ts,
|
||||||
|
finished_ts: entry.finished_ts,
|
||||||
|
status: statusLabel
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return summaries;
|
||||||
|
}, [historyRows]);
|
||||||
|
|
||||||
const sortedHistory = useMemo(() => {
|
const sortedHistory = useMemo(() => {
|
||||||
const dir = historyOrder === 'asc' ? 1 : -1;
|
const dir = historyOrder === 'asc' ? 1 : -1;
|
||||||
const key = historyOrderBy;
|
const key = historyOrderBy;
|
||||||
return [...historyRows].sort((a, b) => {
|
return [...aggregatedHistory].sort((a, b) => {
|
||||||
const A = a?.[key];
|
const getVal = (row) => {
|
||||||
const B = b?.[key];
|
if (key === 'scheduled_ts' || key === 'started_ts' || key === 'finished_ts') {
|
||||||
if (key === 'started_ts' || key === 'finished_ts' || key === 'scheduled_ts') {
|
return Number(row?.[key] || 0);
|
||||||
return ((A || 0) - (B || 0)) * dir;
|
|
||||||
}
|
}
|
||||||
return String(A ?? '').localeCompare(String(B ?? '')) * dir;
|
return String(row?.[key] || '');
|
||||||
|
};
|
||||||
|
const A = getVal(a);
|
||||||
|
const B = getVal(b);
|
||||||
|
if (typeof A === 'number' && typeof B === 'number') {
|
||||||
|
return (A - B) * dir;
|
||||||
|
}
|
||||||
|
return String(A).localeCompare(String(B)) * dir;
|
||||||
});
|
});
|
||||||
}, [historyRows, historyOrderBy, historyOrder]);
|
}, [aggregatedHistory, historyOrderBy, historyOrder]);
|
||||||
|
|
||||||
const handleHistorySort = (col) => {
|
const handleHistorySort = (col) => {
|
||||||
if (historyOrderBy === col) setHistoryOrder(historyOrder === 'asc' ? 'desc' : 'asc');
|
if (historyOrderBy === col) setHistoryOrder(historyOrder === 'asc' ? 'desc' : 'asc');
|
||||||
@@ -598,7 +993,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
|||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{sortedHistory.map((r) => (
|
{sortedHistory.map((r) => (
|
||||||
<TableRow key={r.id}>
|
<TableRow key={r.key}>
|
||||||
<TableCell>{fmtTs(r.scheduled_ts)}</TableCell>
|
<TableCell>{fmtTs(r.scheduled_ts)}</TableCell>
|
||||||
<TableCell>{fmtTs(r.started_ts)}</TableCell>
|
<TableCell>{fmtTs(r.started_ts)}</TableCell>
|
||||||
<TableCell>{fmtTs(r.finished_ts)}</TableCell>
|
<TableCell>{fmtTs(r.finished_ts)}</TableCell>
|
||||||
@@ -617,39 +1012,294 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
|||||||
|
|
||||||
// --- Job Progress (summary) ---
|
// --- Job Progress (summary) ---
|
||||||
const [jobSummary, setJobSummary] = useState({});
|
const [jobSummary, setJobSummary] = useState({});
|
||||||
const sumCounts = (o, k) => Number((o?.result_counts||{})[k] || 0);
|
|
||||||
const counts = jobSummary?.result_counts || {};
|
const counts = jobSummary?.result_counts || {};
|
||||||
|
|
||||||
const ProgressSummary = () => (
|
const deviceStatusCounts = useMemo(() => {
|
||||||
<Box sx={{ p: 2, bgcolor: '#1b1b1b', border: '1px solid #333', borderRadius: 1, mb: 2 }}>
|
const base = { pending: 0, running: 0, success: 0, failed: 0, expired: 0 };
|
||||||
<Typography variant="subtitle1" sx={{ color: '#7db7ff', mb: 1 }}>Job Progress</Typography>
|
deviceRows.forEach((row) => {
|
||||||
<Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
|
const normalized = String(row?.job_status || "").trim().toLowerCase();
|
||||||
{[
|
if (!normalized || normalized === "pending" || normalized === "scheduled" || normalized === "queued") {
|
||||||
['pending','Pending','#999999'],
|
base.pending += 1;
|
||||||
['running','Running','#58a6ff'],
|
} else if (normalized === "running") {
|
||||||
['success','Success','#00d18c'],
|
base.running += 1;
|
||||||
['failed','Failed','#ff4f4f'],
|
} else if (normalized === "success") {
|
||||||
['expired','Expired','#777777'],
|
base.success += 1;
|
||||||
['timed_out','Timed Out','#b36ae2']
|
} else if (normalized === "expired") {
|
||||||
].map(([key,label,color]) => (
|
base.expired += 1;
|
||||||
<Box key={key} sx={{ color: '#ddd', display: 'flex', alignItems: 'center', gap: 1 }}>
|
} else if (normalized === "failed" || normalized === "failure" || normalized === "timed out" || normalized === "timed_out" || normalized === "warning") {
|
||||||
<span style={{ display:'inline-block', width:10, height:10, borderRadius:10, background: color }} />
|
base.failed += 1;
|
||||||
<span>{label}: {Number((counts||{})[key] || 0)}</span>
|
} else {
|
||||||
|
base.pending += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return base;
|
||||||
|
}, [deviceRows]);
|
||||||
|
|
||||||
|
const statusCounts = useMemo(() => {
|
||||||
|
const merged = { pending: 0, running: 0, success: 0, failed: 0, expired: 0 };
|
||||||
|
Object.keys(merged).forEach((key) => {
|
||||||
|
const summaryVal = Number((counts || {})[key] ?? 0);
|
||||||
|
const fallback = deviceStatusCounts[key] ?? 0;
|
||||||
|
merged[key] = summaryVal > 0 ? summaryVal : fallback;
|
||||||
|
});
|
||||||
|
return merged;
|
||||||
|
}, [counts, deviceStatusCounts]);
|
||||||
|
|
||||||
|
const statusNodeTypes = useMemo(() => ({ statusNode: StatusNode }), []);
|
||||||
|
|
||||||
|
const handleStatusNodeClick = useCallback((key) => {
|
||||||
|
setDeviceStatusFilter((prev) => (prev === key ? null : key));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const statusNodes = useMemo(() => [
|
||||||
|
{
|
||||||
|
id: "pending",
|
||||||
|
type: "statusNode",
|
||||||
|
position: { x: -420, y: 170 },
|
||||||
|
data: {
|
||||||
|
label: STATUS_META.pending.label,
|
||||||
|
color: STATUS_META.pending.color,
|
||||||
|
count: statusCounts.pending,
|
||||||
|
Icon: STATUS_META.pending.Icon,
|
||||||
|
onClick: () => handleStatusNodeClick("pending"),
|
||||||
|
isActive: deviceStatusFilter === "pending"
|
||||||
|
},
|
||||||
|
draggable: false,
|
||||||
|
selectable: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "running",
|
||||||
|
type: "statusNode",
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: STATUS_META.running.label,
|
||||||
|
color: STATUS_META.running.color,
|
||||||
|
count: statusCounts.running,
|
||||||
|
Icon: STATUS_META.running.Icon,
|
||||||
|
onClick: () => handleStatusNodeClick("running"),
|
||||||
|
isActive: deviceStatusFilter === "running"
|
||||||
|
},
|
||||||
|
draggable: false,
|
||||||
|
selectable: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "expired",
|
||||||
|
type: "statusNode",
|
||||||
|
position: { x: 0, y: 340 },
|
||||||
|
data: {
|
||||||
|
label: STATUS_META.expired.label,
|
||||||
|
color: STATUS_META.expired.color,
|
||||||
|
count: statusCounts.expired,
|
||||||
|
Icon: STATUS_META.expired.Icon,
|
||||||
|
onClick: () => handleStatusNodeClick("expired"),
|
||||||
|
isActive: deviceStatusFilter === "expired"
|
||||||
|
},
|
||||||
|
draggable: false,
|
||||||
|
selectable: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "success",
|
||||||
|
type: "statusNode",
|
||||||
|
position: { x: 420, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: STATUS_META.success.label,
|
||||||
|
color: STATUS_META.success.color,
|
||||||
|
count: statusCounts.success,
|
||||||
|
Icon: STATUS_META.success.Icon,
|
||||||
|
onClick: () => handleStatusNodeClick("success"),
|
||||||
|
isActive: deviceStatusFilter === "success"
|
||||||
|
},
|
||||||
|
draggable: false,
|
||||||
|
selectable: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "failed",
|
||||||
|
type: "statusNode",
|
||||||
|
position: { x: 420, y: 340 },
|
||||||
|
data: {
|
||||||
|
label: STATUS_META.failed.label,
|
||||||
|
color: STATUS_META.failed.color,
|
||||||
|
count: statusCounts.failed,
|
||||||
|
Icon: STATUS_META.failed.Icon,
|
||||||
|
onClick: () => handleStatusNodeClick("failed"),
|
||||||
|
isActive: deviceStatusFilter === "failed"
|
||||||
|
},
|
||||||
|
draggable: false,
|
||||||
|
selectable: false
|
||||||
|
}
|
||||||
|
], [statusCounts, handleStatusNodeClick, deviceStatusFilter]);
|
||||||
|
|
||||||
|
const statusEdges = useMemo(() => [
|
||||||
|
{
|
||||||
|
id: "pending-running",
|
||||||
|
source: "pending",
|
||||||
|
target: "running",
|
||||||
|
sourceHandle: "right-top",
|
||||||
|
targetHandle: "left-top",
|
||||||
|
type: "smoothstep",
|
||||||
|
animated: true,
|
||||||
|
className: "status-flow-edge"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pending-expired",
|
||||||
|
source: "pending",
|
||||||
|
target: "expired",
|
||||||
|
sourceHandle: "right-bottom",
|
||||||
|
targetHandle: "left-bottom",
|
||||||
|
type: "smoothstep",
|
||||||
|
animated: true,
|
||||||
|
className: "status-flow-edge"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "running-success",
|
||||||
|
source: "running",
|
||||||
|
target: "success",
|
||||||
|
sourceHandle: "right-top",
|
||||||
|
targetHandle: "left-top",
|
||||||
|
type: "smoothstep",
|
||||||
|
animated: true,
|
||||||
|
className: "status-flow-edge"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "running-failed",
|
||||||
|
source: "running",
|
||||||
|
target: "failed",
|
||||||
|
sourceHandle: "right-bottom",
|
||||||
|
targetHandle: "left-bottom",
|
||||||
|
type: "smoothstep",
|
||||||
|
animated: true,
|
||||||
|
className: "status-flow-edge"
|
||||||
|
}
|
||||||
|
], []);
|
||||||
|
|
||||||
|
const JobStatusFlow = () => (
|
||||||
|
<Box sx={{ bgcolor: "#2e2e2e", border: "1px solid #2a2a2a", borderRadius: 1, mb: 2 }}>
|
||||||
|
<GlobalStyles styles={{
|
||||||
|
"@keyframes statusFlowDash": {
|
||||||
|
"0%": { strokeDashoffset: 0 },
|
||||||
|
"100%": { strokeDashoffset: -24 }
|
||||||
|
},
|
||||||
|
".status-flow-edge .react-flow__edge-path": {
|
||||||
|
strokeDasharray: "10 6",
|
||||||
|
animation: "statusFlowDash 1.2s linear infinite",
|
||||||
|
strokeWidth: 2,
|
||||||
|
stroke: "#58a6ff"
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
<Box sx={{ height: 380, p: 3 }}>
|
||||||
|
<ReactFlow
|
||||||
|
nodes={statusNodes}
|
||||||
|
edges={statusEdges}
|
||||||
|
nodeTypes={statusNodeTypes}
|
||||||
|
fitView
|
||||||
|
fitViewOptions={{ padding: 0.2 }}
|
||||||
|
nodesDraggable={false}
|
||||||
|
nodesConnectable={false}
|
||||||
|
elementsSelectable={false}
|
||||||
|
panOnDrag={false}
|
||||||
|
zoomOnScroll={false}
|
||||||
|
zoomOnPinch={false}
|
||||||
|
panOnScroll={false}
|
||||||
|
zoomOnDoubleClick={false}
|
||||||
|
preventScrolling={false}
|
||||||
|
onNodeClick={(_, node) => {
|
||||||
|
if (node?.id && STATUS_META[node.id]) handleStatusNodeClick(node.id);
|
||||||
|
}}
|
||||||
|
selectionOnDrag={false}
|
||||||
|
proOptions={{ hideAttribution: true }}
|
||||||
|
style={{ background: "transparent" }}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
{deviceStatusFilter ? (
|
||||||
|
<Box sx={{ mt: 1, px: 3, pb: 3, display: "flex", alignItems: "center", gap: 1.5 }}>
|
||||||
|
<Typography variant="caption" sx={{ color: "#aaa" }}>
|
||||||
|
Showing devices with {STATUS_META[deviceStatusFilter]?.label || deviceStatusFilter} results
|
||||||
|
</Typography>
|
||||||
|
<Button size="small" sx={{ color: "#58a6ff", textTransform: "none", p: 0 }} onClick={() => setDeviceStatusFilter(null)}>
|
||||||
|
Clear Filter
|
||||||
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
const inferLanguage = useCallback((path = "") => {
|
||||||
|
const lower = String(path || "").toLowerCase();
|
||||||
|
if (lower.endsWith(".ps1")) return "powershell";
|
||||||
|
if (lower.endsWith(".bat")) return "batch";
|
||||||
|
if (lower.endsWith(".sh")) return "bash";
|
||||||
|
if (lower.endsWith(".yml") || lower.endsWith(".yaml")) return "yaml";
|
||||||
|
return "powershell";
|
||||||
|
}, []);
|
||||||
|
|
||||||
// --- Devices breakdown ---
|
const highlightCode = useCallback((code, lang) => {
|
||||||
const [deviceRows, setDeviceRows] = useState([]);
|
try {
|
||||||
const deviceSorted = useMemo(() => deviceRows, [deviceRows]);
|
return Prism.highlight(code ?? "", Prism.languages[lang] || Prism.languages.markup, lang);
|
||||||
|
} catch {
|
||||||
|
return String(code || "");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadActivity = useCallback(async (activityId) => {
|
||||||
|
const idNum = Number(activityId || 0);
|
||||||
|
if (!idNum) return null;
|
||||||
|
if (activityCacheRef.current.has(idNum)) {
|
||||||
|
return activityCacheRef.current.get(idNum);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/device/activity/job/${idNum}`);
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
activityCacheRef.current.set(idNum, data);
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleViewDeviceOutput = useCallback(async (row, mode = "stdout") => {
|
||||||
|
if (!row) return;
|
||||||
|
const label = mode === "stderr" ? "StdErr" : "StdOut";
|
||||||
|
const activities = Array.isArray(row.activities) ? row.activities : [];
|
||||||
|
const relevant = activities.filter((act) => (mode === "stderr" ? act.has_stderr : act.has_stdout));
|
||||||
|
setOutputTitle(`${label} - ${row.hostname || ""}`);
|
||||||
|
setOutputSections([]);
|
||||||
|
setOutputError("");
|
||||||
|
setOutputLoading(true);
|
||||||
|
setOutputOpen(true);
|
||||||
|
if (!relevant.length) {
|
||||||
|
setOutputError(`No ${label} available for this device.`);
|
||||||
|
setOutputLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sections = [];
|
||||||
|
for (const act of relevant) {
|
||||||
|
const activityId = Number(act.activity_id || act.id || 0);
|
||||||
|
if (!activityId) continue;
|
||||||
|
const data = await loadActivity(activityId);
|
||||||
|
if (!data) continue;
|
||||||
|
const content = mode === "stderr" ? (data.stderr || "") : (data.stdout || "");
|
||||||
|
const sectionTitle = act.component_name || data.script_name || data.script_path || `Activity ${activityId}`;
|
||||||
|
sections.push({
|
||||||
|
key: `${activityId}-${mode}`,
|
||||||
|
title: sectionTitle,
|
||||||
|
path: data.script_path || "",
|
||||||
|
lang: inferLanguage(data.script_path || ""),
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!sections.length) {
|
||||||
|
setOutputError(`No ${label} available for this device.`);
|
||||||
|
}
|
||||||
|
setOutputSections(sections);
|
||||||
|
setOutputLoading(false);
|
||||||
|
}, [inferLanguage, loadActivity]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let canceled = false;
|
let canceled = false;
|
||||||
const hydrate = async () => {
|
const hydrate = async () => {
|
||||||
if (initialJob && initialJob.id) {
|
if (initialJob && initialJob.id) {
|
||||||
setJobName(initialJob.name || "");
|
setJobName(initialJob.name || "");
|
||||||
|
setPageTitleJobName(typeof initialJob.name === "string" ? initialJob.name.trim() : "");
|
||||||
setTargets(Array.isArray(initialJob.targets) ? initialJob.targets : []);
|
setTargets(Array.isArray(initialJob.targets) ? initialJob.targets : []);
|
||||||
setScheduleType(initialJob.schedule_type || initialJob.schedule?.type || "immediately");
|
setScheduleType(initialJob.schedule_type || initialJob.schedule?.type || "immediately");
|
||||||
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)));
|
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)));
|
||||||
@@ -663,6 +1313,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
|||||||
setComponentVarErrors({});
|
setComponentVarErrors({});
|
||||||
}
|
}
|
||||||
} else if (!initialJob) {
|
} else if (!initialJob) {
|
||||||
|
setPageTitleJobName("");
|
||||||
setComponents([]);
|
setComponents([]);
|
||||||
setComponentVarErrors({});
|
setComponentVarErrors({});
|
||||||
}
|
}
|
||||||
@@ -817,9 +1468,16 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
|||||||
}, [editing]);
|
}, [editing]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}>
|
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e", overflow: "auto" }} elevation={2}>
|
||||||
<Box sx={{ p: 2, pb: 1 }}>
|
<Box sx={{ p: 2, pb: 1 }}>
|
||||||
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>Create a Scheduled Job</Typography>
|
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>
|
||||||
|
Create a Scheduled Job
|
||||||
|
{pageTitleJobName && (
|
||||||
|
<Box component="span" sx={{ color: "#aaa", fontSize: "inherit", fontWeight: 400 }}>
|
||||||
|
{`: "${pageTitleJobName}"`}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ color: "#aaa" }}>
|
<Typography variant="body2" sx={{ color: "#aaa" }}>
|
||||||
Configure advanced schedulable automation jobs for one or more devices.
|
Configure advanced schedulable automation jobs for one or more devices.
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -864,6 +1522,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
|||||||
placeholder="Example Job Name"
|
placeholder="Example Job Name"
|
||||||
value={jobName}
|
value={jobName}
|
||||||
onChange={(e) => setJobName(e.target.value)}
|
onChange={(e) => setJobName(e.target.value)}
|
||||||
|
onBlur={(e) => setPageTitleJobName(e.target.value.trim())}
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
error={jobName.trim().length === 0}
|
error={jobName.trim().length === 0}
|
||||||
helperText={jobName.trim().length === 0 ? "Job name is required" : ""}
|
helperText={jobName.trim().length === 0 ? "Job name is required" : ""}
|
||||||
@@ -1027,7 +1686,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
|||||||
<Typography variant="caption" sx={{ color: '#aaa' }}>Showing the last 30 days of runs.</Typography>
|
<Typography variant="caption" sx={{ color: '#aaa' }}>Showing the last 30 days of runs.</Typography>
|
||||||
|
|
||||||
<Box sx={{ mt: 2 }}>
|
<Box sx={{ mt: 2 }}>
|
||||||
<ProgressSummary />
|
<JobStatusFlow />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ mt: 2 }}>
|
<Box sx={{ mt: 2 }}>
|
||||||
@@ -1036,12 +1695,26 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
|||||||
<Table size="small">
|
<Table size="small">
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>Hostname</TableCell>
|
{DEVICE_COLUMNS.map((col) => (
|
||||||
<TableCell>Status</TableCell>
|
<TableCell key={col.key}>
|
||||||
<TableCell>Site</TableCell>
|
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||||
<TableCell>Ran On</TableCell>
|
<TableSortLabel
|
||||||
<TableCell>Job Status</TableCell>
|
active={deviceOrderBy === col.key}
|
||||||
<TableCell>StdOut / StdErr</TableCell>
|
direction={deviceOrderBy === col.key ? deviceOrder : "asc"}
|
||||||
|
onClick={() => handleDeviceSort(col.key)}
|
||||||
|
>
|
||||||
|
{col.label}
|
||||||
|
</TableSortLabel>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(event) => openFilterMenu(event, col.key)}
|
||||||
|
sx={{ color: isColumnFiltered(col.key) ? "#58a6ff" : "#666" }}
|
||||||
|
>
|
||||||
|
<FilterListIcon fontSize="inherit" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -1057,8 +1730,24 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
|||||||
<TableCell>{resultChip(d.job_status)}</TableCell>
|
<TableCell>{resultChip(d.job_status)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Box sx={{ display:'flex', gap:1 }}>
|
<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_stdout ? (
|
||||||
{d.has_stderr ? <Button size="small" sx={{ color:'#ff4f4f', textTransform:'none', minWidth:0, p:0 }}>StdErr</Button> : null}
|
<Button
|
||||||
|
size="small"
|
||||||
|
sx={{ color:'#58a6ff', textTransform:'none', minWidth:0, p:0 }}
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleViewDeviceOutput(d, 'stdout'); }}
|
||||||
|
>
|
||||||
|
StdOut
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{d.has_stderr ? (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
sx={{ color:'#ff4f4f', textTransform:'none', minWidth:0, p:0 }}
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleViewDeviceOutput(d, 'stderr'); }}
|
||||||
|
>
|
||||||
|
StdErr
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -1070,6 +1759,25 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
|||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
<Menu
|
||||||
|
anchorEl={filterAnchorEl}
|
||||||
|
open={Boolean(filterAnchorEl)}
|
||||||
|
onClose={closeFilterMenu}
|
||||||
|
disableAutoFocusItem
|
||||||
|
PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#e6edf3", minWidth: 240 } }}
|
||||||
|
>
|
||||||
|
<Box sx={{ p: 1.5, display: "flex", flexDirection: "column", gap: 1 }}>
|
||||||
|
{renderFilterControl()}
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}>
|
||||||
|
<Button size="small" sx={{ color: "#ff6666", textTransform: "none" }} onClick={clearFilter}>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button size="small" sx={{ color: "#58a6ff", textTransform: "none" }} onClick={applyFilter}>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ mt: 2 }}>
|
<Box sx={{ mt: 2 }}>
|
||||||
@@ -1083,6 +1791,48 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<Dialog open={outputOpen} onClose={() => setOutputOpen(false)} fullWidth maxWidth="md"
|
||||||
|
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
|
||||||
|
>
|
||||||
|
<DialogTitle>{outputTitle}</DialogTitle>
|
||||||
|
<DialogContent dividers>
|
||||||
|
{outputLoading ? (
|
||||||
|
<Typography variant="body2" sx={{ color: "#888" }}>Loading output…</Typography>
|
||||||
|
) : null}
|
||||||
|
{!outputLoading && outputError ? (
|
||||||
|
<Typography variant="body2" sx={{ color: "#888" }}>{outputError}</Typography>
|
||||||
|
) : null}
|
||||||
|
{!outputLoading && !outputError ? (
|
||||||
|
outputSections.map((section) => (
|
||||||
|
<Box key={section.key} sx={{ mb: 2 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ color: "#7db7ff" }}>{section.title}</Typography>
|
||||||
|
{section.path ? (
|
||||||
|
<Typography variant="caption" sx={{ color: "#888", display: "block", mb: 0.5 }}>{section.path}</Typography>
|
||||||
|
) : null}
|
||||||
|
<Box sx={{ border: "1px solid #333", borderRadius: 1, bgcolor: "#1e1e1e" }}>
|
||||||
|
<Editor
|
||||||
|
value={section.content ?? ""}
|
||||||
|
onValueChange={() => {}}
|
||||||
|
highlight={(code) => highlightCode(code, section.lang)}
|
||||||
|
padding={12}
|
||||||
|
style={{
|
||||||
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#e6edf3",
|
||||||
|
minHeight: 160
|
||||||
|
}}
|
||||||
|
textareaProps={{ readOnly: true }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setOutputOpen(false)} sx={{ color: "#58a6ff" }}>Close</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* Bottom actions removed per design; actions live next to tabs. */}
|
{/* Bottom actions removed per design; actions live next to tabs. */}
|
||||||
|
|
||||||
{/* Add Component Dialog */}
|
{/* Add Component Dialog */}
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
{[
|
{[
|
||||||
["name", "Name"],
|
["name", "Name"],
|
||||||
["scriptWorkflow", "Script / Workflow"],
|
["scriptWorkflow", "Assembly(s)"],
|
||||||
["target", "Target"],
|
["target", "Target"],
|
||||||
["occurrence", "Recurrence"],
|
["occurrence", "Recurrence"],
|
||||||
["lastRun", "Last Run"],
|
["lastRun", "Last Run"],
|
||||||
|
|||||||
@@ -475,14 +475,14 @@ class JobScheduler:
|
|||||||
os.path.join(os.path.dirname(__file__), "..", "..", "Assemblies", "Ansible_Playbooks")
|
os.path.join(os.path.dirname(__file__), "..", "..", "Assemblies", "Ansible_Playbooks")
|
||||||
)
|
)
|
||||||
|
|
||||||
def _dispatch_ansible(self, hostname: str, rel_path: str, scheduled_job_id: int, scheduled_run_id: int) -> None:
|
def _dispatch_ansible(self, hostname: str, rel_path: str, scheduled_job_id: int, scheduled_run_id: int) -> Optional[Dict[str, Any]]:
|
||||||
try:
|
try:
|
||||||
import os, json, uuid
|
import os, json, uuid
|
||||||
ans_root = self._ansible_root()
|
ans_root = self._ansible_root()
|
||||||
rel_norm = (rel_path or "").replace("\\", "/").lstrip("/")
|
rel_norm = (rel_path or "").replace("\\", "/").lstrip("/")
|
||||||
abs_path = os.path.abspath(os.path.join(ans_root, rel_norm))
|
abs_path = os.path.abspath(os.path.join(ans_root, rel_norm))
|
||||||
if (not abs_path.startswith(ans_root)) or (not os.path.isfile(abs_path)):
|
if (not abs_path.startswith(ans_root)) or (not os.path.isfile(abs_path)):
|
||||||
return
|
return None
|
||||||
doc = self._load_assembly_document(abs_path, "ansible")
|
doc = self._load_assembly_document(abs_path, "ansible")
|
||||||
content = doc.get("script") or ""
|
content = doc.get("script") or ""
|
||||||
encoded_content = _encode_script_content(content)
|
encoded_content = _encode_script_content(content)
|
||||||
@@ -503,7 +503,7 @@ class JobScheduler:
|
|||||||
(
|
(
|
||||||
str(hostname),
|
str(hostname),
|
||||||
rel_norm,
|
rel_norm,
|
||||||
os.path.basename(abs_path),
|
doc.get("name") or os.path.basename(abs_path),
|
||||||
"ansible",
|
"ansible",
|
||||||
now,
|
now,
|
||||||
"Running",
|
"Running",
|
||||||
@@ -519,7 +519,7 @@ class JobScheduler:
|
|||||||
payload = {
|
payload = {
|
||||||
"run_id": uuid.uuid4().hex,
|
"run_id": uuid.uuid4().hex,
|
||||||
"target_hostname": str(hostname),
|
"target_hostname": str(hostname),
|
||||||
"playbook_name": os.path.basename(abs_path),
|
"playbook_name": doc.get("name") or os.path.basename(abs_path),
|
||||||
"playbook_content": encoded_content,
|
"playbook_content": encoded_content,
|
||||||
"playbook_encoding": "base64",
|
"playbook_encoding": "base64",
|
||||||
"activity_job_id": act_id,
|
"activity_job_id": act_id,
|
||||||
@@ -533,10 +533,19 @@ class JobScheduler:
|
|||||||
self.socketio.emit("ansible_playbook_run", payload)
|
self.socketio.emit("ansible_playbook_run", payload)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
if act_id:
|
||||||
|
return {
|
||||||
|
"activity_id": int(act_id),
|
||||||
|
"component_name": doc.get("name") or os.path.basename(abs_path),
|
||||||
|
"component_path": rel_norm,
|
||||||
|
"script_type": "ansible",
|
||||||
|
"component_kind": "ansible",
|
||||||
|
}
|
||||||
|
return None
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _dispatch_script(self, hostname: str, component: Dict[str, Any], run_mode: str) -> None:
|
def _dispatch_script(self, hostname: str, component: Dict[str, Any], run_mode: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Emit a quick_job_run event to agents for the given script/host.
|
"""Emit a quick_job_run event to agents for the given script/host.
|
||||||
Mirrors /api/scripts/quick_run behavior for scheduled jobs.
|
Mirrors /api/scripts/quick_run behavior for scheduled jobs.
|
||||||
"""
|
"""
|
||||||
@@ -553,12 +562,12 @@ class JobScheduler:
|
|||||||
path_norm = f"Scripts/{path_norm}"
|
path_norm = f"Scripts/{path_norm}"
|
||||||
abs_path = os.path.abspath(os.path.join(scripts_root, path_norm))
|
abs_path = os.path.abspath(os.path.join(scripts_root, path_norm))
|
||||||
if (not abs_path.startswith(scripts_root)) or (not self._is_valid_scripts_relpath(path_norm)) or (not os.path.isfile(abs_path)):
|
if (not abs_path.startswith(scripts_root)) or (not self._is_valid_scripts_relpath(path_norm)) or (not os.path.isfile(abs_path)):
|
||||||
return
|
return None
|
||||||
doc = self._load_assembly_document(abs_path, "powershell")
|
doc = self._load_assembly_document(abs_path, "powershell")
|
||||||
stype = (doc.get("type") or "powershell").lower()
|
stype = (doc.get("type") or "powershell").lower()
|
||||||
# For now, only PowerShell is supported by agents for scheduled jobs
|
# For now, only PowerShell is supported by agents for scheduled jobs
|
||||||
if stype != "powershell":
|
if stype != "powershell":
|
||||||
return
|
return None
|
||||||
content = doc.get("script") or ""
|
content = doc.get("script") or ""
|
||||||
doc_variables = doc.get("variables") if isinstance(doc.get("variables"), list) else []
|
doc_variables = doc.get("variables") if isinstance(doc.get("variables"), list) else []
|
||||||
|
|
||||||
@@ -603,7 +612,7 @@ class JobScheduler:
|
|||||||
(
|
(
|
||||||
str(hostname),
|
str(hostname),
|
||||||
path_norm,
|
path_norm,
|
||||||
os.path.basename(abs_path),
|
doc.get("name") or os.path.basename(abs_path),
|
||||||
stype,
|
stype,
|
||||||
now,
|
now,
|
||||||
"Running",
|
"Running",
|
||||||
@@ -620,7 +629,7 @@ class JobScheduler:
|
|||||||
"job_id": act_id,
|
"job_id": act_id,
|
||||||
"target_hostname": str(hostname),
|
"target_hostname": str(hostname),
|
||||||
"script_type": stype,
|
"script_type": stype,
|
||||||
"script_name": os.path.basename(abs_path),
|
"script_name": doc.get("name") or os.path.basename(abs_path),
|
||||||
"script_path": path_norm,
|
"script_path": path_norm,
|
||||||
"script_content": encoded_content,
|
"script_content": encoded_content,
|
||||||
"script_encoding": "base64",
|
"script_encoding": "base64",
|
||||||
@@ -636,9 +645,19 @@ class JobScheduler:
|
|||||||
self.socketio.emit("quick_job_run", payload)
|
self.socketio.emit("quick_job_run", payload)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
if act_id:
|
||||||
|
return {
|
||||||
|
"activity_id": int(act_id),
|
||||||
|
"component_name": doc.get("name") or os.path.basename(abs_path),
|
||||||
|
"component_path": path_norm,
|
||||||
|
"script_type": stype,
|
||||||
|
"component_kind": "script",
|
||||||
|
}
|
||||||
|
return None
|
||||||
except Exception:
|
except Exception:
|
||||||
# Keep scheduler resilient
|
# Keep scheduler resilient
|
||||||
pass
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
# ---------- DB helpers ----------
|
# ---------- DB helpers ----------
|
||||||
def _conn(self):
|
def _conn(self):
|
||||||
@@ -677,6 +696,27 @@ class JobScheduler:
|
|||||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_runs_job_sched_target ON scheduled_job_runs(job_id, scheduled_ts, target_hostname)")
|
cur.execute("CREATE INDEX IF NOT EXISTS idx_runs_job_sched_target ON scheduled_job_runs(job_id, scheduled_ts, target_hostname)")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS scheduled_job_run_activity (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
run_id INTEGER NOT NULL,
|
||||||
|
activity_id INTEGER NOT NULL,
|
||||||
|
component_kind TEXT,
|
||||||
|
script_type TEXT,
|
||||||
|
component_path TEXT,
|
||||||
|
component_name TEXT,
|
||||||
|
created_at INTEGER,
|
||||||
|
FOREIGN KEY(run_id) REFERENCES scheduled_job_runs(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(activity_id) REFERENCES activity_history(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute("CREATE INDEX IF NOT EXISTS idx_run_activity_run ON scheduled_job_run_activity(run_id)")
|
||||||
|
cur.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_run_activity_activity ON scheduled_job_run_activity(activity_id)")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -970,18 +1010,55 @@ class JobScheduler:
|
|||||||
)
|
)
|
||||||
run_row_id = c2.lastrowid or 0
|
run_row_id = c2.lastrowid or 0
|
||||||
conn2.commit()
|
conn2.commit()
|
||||||
|
activity_links: List[Dict[str, Any]] = []
|
||||||
# Dispatch all script components for this job to the target host
|
# Dispatch all script components for this job to the target host
|
||||||
for comp in script_components:
|
for comp in script_components:
|
||||||
try:
|
try:
|
||||||
self._dispatch_script(host, comp, run_mode)
|
link = self._dispatch_script(host, comp, run_mode)
|
||||||
|
if link and link.get("activity_id"):
|
||||||
|
activity_links.append({
|
||||||
|
"run_id": run_row_id,
|
||||||
|
"activity_id": int(link["activity_id"]),
|
||||||
|
"component_kind": link.get("component_kind") or "script",
|
||||||
|
"script_type": link.get("script_type") or "powershell",
|
||||||
|
"component_path": link.get("component_path") or "",
|
||||||
|
"component_name": link.get("component_name") or "",
|
||||||
|
})
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
# Dispatch ansible playbooks for this job to the target host
|
# Dispatch ansible playbooks for this job to the target host
|
||||||
for ap in ansible_paths:
|
for ap in ansible_paths:
|
||||||
try:
|
try:
|
||||||
self._dispatch_ansible(host, ap, job_id, run_row_id)
|
link = self._dispatch_ansible(host, ap, job_id, run_row_id)
|
||||||
|
if link and link.get("activity_id"):
|
||||||
|
activity_links.append({
|
||||||
|
"run_id": run_row_id,
|
||||||
|
"activity_id": int(link["activity_id"]),
|
||||||
|
"component_kind": link.get("component_kind") or "ansible",
|
||||||
|
"script_type": link.get("script_type") or "ansible",
|
||||||
|
"component_path": link.get("component_path") or "",
|
||||||
|
"component_name": link.get("component_name") or "",
|
||||||
|
})
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
if activity_links:
|
||||||
|
try:
|
||||||
|
for link in activity_links:
|
||||||
|
c2.execute(
|
||||||
|
"INSERT OR IGNORE INTO scheduled_job_run_activity(run_id, activity_id, component_kind, script_type, component_path, component_name, created_at) VALUES (?,?,?,?,?,?,?)",
|
||||||
|
(
|
||||||
|
int(link["run_id"]),
|
||||||
|
int(link["activity_id"]),
|
||||||
|
link.get("component_kind") or "",
|
||||||
|
link.get("script_type") or "",
|
||||||
|
link.get("component_path") or "",
|
||||||
|
link.get("component_name") or "",
|
||||||
|
ts_now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn2.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
@@ -1389,21 +1466,60 @@ class JobScheduler:
|
|||||||
|
|
||||||
# Status per target for occurrence
|
# Status per target for occurrence
|
||||||
run_by_host: Dict[str, Dict[str, Any]] = {}
|
run_by_host: Dict[str, Dict[str, Any]] = {}
|
||||||
|
run_ids: List[int] = []
|
||||||
if occ is not None:
|
if occ is not None:
|
||||||
try:
|
try:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT target_hostname, status, started_ts, finished_ts FROM scheduled_job_runs WHERE job_id=? AND scheduled_ts=? ORDER BY id DESC",
|
"SELECT id, target_hostname, status, started_ts, finished_ts FROM scheduled_job_runs WHERE job_id=? AND scheduled_ts=? ORDER BY id DESC",
|
||||||
(job_id, occ)
|
(job_id, occ)
|
||||||
)
|
)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
for h, st, st_ts, fin_ts in rows:
|
for rid, h, st, st_ts, fin_ts in rows:
|
||||||
h = str(h)
|
h = str(h)
|
||||||
if h not in run_by_host:
|
if h not in run_by_host:
|
||||||
run_by_host[h] = {
|
run_by_host[h] = {
|
||||||
"status": st or "",
|
"status": st or "",
|
||||||
"started_ts": st_ts,
|
"started_ts": st_ts,
|
||||||
"finished_ts": fin_ts,
|
"finished_ts": fin_ts,
|
||||||
|
"run_id": int(rid),
|
||||||
}
|
}
|
||||||
|
run_ids.append(int(rid))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
activities_by_run: Dict[int, List[Dict[str, Any]]] = {}
|
||||||
|
if run_ids:
|
||||||
|
try:
|
||||||
|
placeholders = ",".join(["?"] * len(run_ids))
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT
|
||||||
|
s.run_id,
|
||||||
|
s.activity_id,
|
||||||
|
s.component_kind,
|
||||||
|
s.script_type,
|
||||||
|
s.component_path,
|
||||||
|
s.component_name,
|
||||||
|
COALESCE(LENGTH(h.stdout), 0),
|
||||||
|
COALESCE(LENGTH(h.stderr), 0)
|
||||||
|
FROM scheduled_job_run_activity s
|
||||||
|
LEFT JOIN activity_history h ON h.id = s.activity_id
|
||||||
|
WHERE s.run_id IN ({placeholders})
|
||||||
|
""",
|
||||||
|
run_ids,
|
||||||
|
)
|
||||||
|
for rid, act_id, kind, stype, path, name, so_len, se_len in cur.fetchall():
|
||||||
|
rid = int(rid)
|
||||||
|
entry = {
|
||||||
|
"activity_id": int(act_id),
|
||||||
|
"component_kind": kind or "",
|
||||||
|
"script_type": stype or "",
|
||||||
|
"component_path": path or "",
|
||||||
|
"component_name": name or "",
|
||||||
|
"has_stdout": bool(so_len),
|
||||||
|
"has_stderr": bool(se_len),
|
||||||
|
}
|
||||||
|
activities_by_run.setdefault(rid, []).append(entry)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -1422,14 +1538,18 @@ class JobScheduler:
|
|||||||
rec = run_by_host.get(str(host), {})
|
rec = run_by_host.get(str(host), {})
|
||||||
job_status = rec.get("status") or "Pending"
|
job_status = rec.get("status") or "Pending"
|
||||||
ran_on = rec.get("started_ts") or rec.get("finished_ts")
|
ran_on = rec.get("started_ts") or rec.get("finished_ts")
|
||||||
|
activities = activities_by_run.get(rec.get("run_id", 0) or 0, [])
|
||||||
|
has_stdout = any(a.get("has_stdout") for a in activities)
|
||||||
|
has_stderr = any(a.get("has_stderr") for a in activities)
|
||||||
out.append({
|
out.append({
|
||||||
"hostname": str(host),
|
"hostname": str(host),
|
||||||
"online": str(host) in online,
|
"online": str(host) in online,
|
||||||
"site": site_by_host.get(str(host), ""),
|
"site": site_by_host.get(str(host), ""),
|
||||||
"ran_on": ran_on,
|
"ran_on": ran_on,
|
||||||
"job_status": job_status,
|
"job_status": job_status,
|
||||||
"has_stdout": False,
|
"has_stdout": has_stdout,
|
||||||
"has_stderr": False,
|
"has_stderr": has_stderr,
|
||||||
|
"activities": activities,
|
||||||
})
|
})
|
||||||
|
|
||||||
return json.dumps({"occurrence": occ, "devices": out}), 200, {"Content-Type": "application/json"}
|
return json.dumps({"occurrence": occ, "devices": out}), 200, {"Content-Type": "application/json"}
|
||||||
|
|||||||
@@ -4958,6 +4958,7 @@ def scripts_quick_run():
|
|||||||
timeout_seconds = max(0, int(doc.get("timeout_seconds") or 0))
|
timeout_seconds = max(0, int(doc.get("timeout_seconds") or 0))
|
||||||
except Exception:
|
except Exception:
|
||||||
timeout_seconds = 0
|
timeout_seconds = 0
|
||||||
|
friendly_name = (doc.get("name") or "").strip() or _safe_filename(rel_path)
|
||||||
|
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
results = []
|
results = []
|
||||||
@@ -4974,7 +4975,7 @@ def scripts_quick_run():
|
|||||||
(
|
(
|
||||||
host,
|
host,
|
||||||
rel_path.replace(os.sep, "/"),
|
rel_path.replace(os.sep, "/"),
|
||||||
_safe_filename(rel_path),
|
friendly_name,
|
||||||
script_type,
|
script_type,
|
||||||
now,
|
now,
|
||||||
"Running",
|
"Running",
|
||||||
@@ -4992,7 +4993,7 @@ def scripts_quick_run():
|
|||||||
"job_id": job_id,
|
"job_id": job_id,
|
||||||
"target_hostname": host,
|
"target_hostname": host,
|
||||||
"script_type": script_type,
|
"script_type": script_type,
|
||||||
"script_name": _safe_filename(rel_path),
|
"script_name": friendly_name,
|
||||||
"script_path": rel_path.replace(os.sep, "/"),
|
"script_path": rel_path.replace(os.sep, "/"),
|
||||||
"script_content": encoded_content,
|
"script_content": encoded_content,
|
||||||
"script_encoding": "base64",
|
"script_encoding": "base64",
|
||||||
@@ -5043,6 +5044,7 @@ def ansible_quick_run():
|
|||||||
encoded_content = _encode_script_content(content)
|
encoded_content = _encode_script_content(content)
|
||||||
variables = doc.get('variables') if isinstance(doc.get('variables'), list) else []
|
variables = doc.get('variables') if isinstance(doc.get('variables'), list) else []
|
||||||
files = doc.get('files') if isinstance(doc.get('files'), list) else []
|
files = doc.get('files') if isinstance(doc.get('files'), list) else []
|
||||||
|
friendly_name = (doc.get("name") or "").strip() or os.path.basename(abs_path)
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for host in hostnames:
|
for host in hostnames:
|
||||||
@@ -5060,7 +5062,7 @@ def ansible_quick_run():
|
|||||||
(
|
(
|
||||||
str(host),
|
str(host),
|
||||||
rel_path.replace(os.sep, "/"),
|
rel_path.replace(os.sep, "/"),
|
||||||
os.path.basename(abs_path),
|
friendly_name,
|
||||||
"ansible",
|
"ansible",
|
||||||
now_ts,
|
now_ts,
|
||||||
"Running",
|
"Running",
|
||||||
@@ -5082,7 +5084,7 @@ def ansible_quick_run():
|
|||||||
payload = {
|
payload = {
|
||||||
"run_id": run_id,
|
"run_id": run_id,
|
||||||
"target_hostname": str(host),
|
"target_hostname": str(host),
|
||||||
"playbook_name": os.path.basename(abs_path),
|
"playbook_name": friendly_name,
|
||||||
"playbook_content": encoded_content,
|
"playbook_content": encoded_content,
|
||||||
"playbook_encoding": "base64",
|
"playbook_encoding": "base64",
|
||||||
"connection": "winrm",
|
"connection": "winrm",
|
||||||
@@ -5192,6 +5194,28 @@ def handle_quick_job_result(data):
|
|||||||
(status, stdout, stderr, job_id),
|
(status, stdout, stderr, job_id),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"SELECT run_id FROM scheduled_job_run_activity WHERE activity_id=?",
|
||||||
|
(job_id,),
|
||||||
|
)
|
||||||
|
link = cur.fetchone()
|
||||||
|
if link:
|
||||||
|
run_id = int(link[0])
|
||||||
|
ts_now = _now_ts()
|
||||||
|
if status.lower() == "running":
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE scheduled_job_runs SET status='Running', updated_at=? WHERE id=?",
|
||||||
|
(ts_now, run_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE scheduled_job_runs SET status=?, finished_ts=COALESCE(finished_ts, ?), updated_at=? WHERE id=?",
|
||||||
|
(status, ts_now, ts_now, run_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT id, hostname, status FROM activity_history WHERE id=?",
|
"SELECT id, hostname, status FROM activity_history WHERE id=?",
|
||||||
|
|||||||
Reference in New Issue
Block a user