Files
Borealis-Github-Replica/Data/Engine/web-interface/src/Scheduling/Create_Job.jsx

3924 lines
140 KiB
JavaScript

import React, { useEffect, useMemo, useState, useCallback, useRef } from "react";
import {
Box,
Typography,
Tabs,
Tab,
TextField,
Button,
IconButton,
Checkbox,
FormControlLabel,
Select,
Menu,
MenuItem,
Divider,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
GlobalStyles,
CircularProgress
} from "@mui/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,
Refresh as RefreshIcon
} from "@mui/icons-material";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
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";
import { AgGridReact } from "ag-grid-react";
import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community";
import { DomainBadge } from "../Assemblies/Assembly_Badges";
import {
buildAssemblyIndex,
normalizeAssemblyPath,
parseAssemblyExport,
resolveAssemblyForComponent
} from "../Assemblies/assemblyUtils";
ModuleRegistry.registerModules([AllCommunityModule]);
const MAGIC_UI = {
shellBg:
"radial-gradient(120% 120% at 0% 0%, rgba(76, 186, 255, 0.16), transparent 55%), " +
"radial-gradient(120% 120% at 100% 0%, rgba(214, 130, 255, 0.18), transparent 60%), #040711",
panelBg:
"linear-gradient(145deg, rgba(7,10,24,0.96), rgba(6,10,28,0.92) 45%, rgba(14,8,30,0.95))",
panelBorder: "rgba(148, 163, 184, 0.32)",
textMuted: "#94a3b8",
textBright: "#e2e8f0",
accentA: "#7dd3fc",
accentB: "#c084fc",
accentC: "#34d399",
glow: "0 30px 70px rgba(2,6,23,0.85)",
};
const gridTheme = themeQuartz.withParams({
accentColor: "#8b5cf6",
backgroundColor: "#070b1a",
browserColorScheme: "dark",
fontFamily: { googleFont: "IBM Plex Sans" },
foregroundColor: "#f4f7ff",
headerFontSize: 13,
});
const gridThemeClass = gridTheme.themeName || "ag-theme-quartz";
const gridFontFamily = '"IBM Plex Sans","Helvetica Neue",Arial,sans-serif';
const iconFontFamily = '"Quartz Regular"';
const LEFT_ALIGN_CELL_STYLE = {
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
textAlign: "left",
};
const GRID_STYLE_BASE = {
width: "100%",
height: "100%",
fontFamily: gridFontFamily,
"--ag-icon-font-family": iconFontFamily,
"--ag-cell-horizontal-padding": "18px",
};
const GRID_WRAPPER_SX = {
width: "100%",
borderRadius: 3,
border: `1px solid ${MAGIC_UI.panelBorder}`,
background: "linear-gradient(170deg, rgba(5,8,20,0.92), rgba(8,13,32,0.9))",
boxShadow: "0 22px 60px rgba(2,6,23,0.75)",
position: "relative",
overflow: "hidden",
"& .ag-root-wrapper": {
borderRadius: 3,
minHeight: "100%",
},
"& .ag-root, & .ag-header, & .ag-center-cols-container": {
fontFamily: gridFontFamily,
background: "transparent",
},
"& .ag-header": {
backgroundColor: "rgba(3,7,18,0.9)",
borderBottom: "1px solid rgba(148,163,184,0.25)",
},
"& .ag-header-cell-label": {
color: "#e2e8f0",
fontWeight: 600,
letterSpacing: 0.3,
},
"& .ag-row": {
borderColor: "rgba(255,255,255,0.04)",
transition: "background 0.2s ease",
},
"& .ag-row:nth-of-type(even)": {
backgroundColor: "rgba(15,23,42,0.32)",
},
"& .ag-row-hover": {
backgroundColor: "rgba(125,183,255,0.08) !important",
},
"& .ag-row-selected": {
backgroundColor: "rgba(56,189,248,0.14) !important",
boxShadow: "inset 0 0 0 1px rgba(56,189,248,0.3)",
},
"& .ag-icon": {
fontFamily: iconFontFamily,
},
"& .ag-checkbox-input-wrapper": {
borderRadius: "3px",
},
"& .ag-center-cols-container .ag-cell, & .ag-pinned-left-cols-container .ag-cell, & .ag-pinned-right-cols-container .ag-cell": {
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
textAlign: "left",
paddingTop: "8px",
paddingBottom: "8px",
paddingLeft: "18px",
paddingRight: "12px",
gap: 0,
},
"& .ag-center-cols-container .ag-cell .ag-cell-wrapper, & .ag-pinned-left-cols-container .ag-cell .ag-cell-wrapper, & .ag-pinned-right-cols-container .ag-cell .ag-cell-wrapper": {
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
gap: 0,
paddingTop: 0,
paddingBottom: 0,
},
"& .ag-center-cols-container .ag-cell .ag-cell-value, & .ag-pinned-left-cols-container .ag-cell .ag-cell-value, & .ag-pinned-right-cols-container .ag-cell .ag-cell-value": {
flexGrow: 1,
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
textAlign: "left",
},
"& .ag-center-cols-container .ag-cell.auto-col-tight, & .ag-pinned-left-cols-container .ag-cell.auto-col-tight, & .ag-pinned-right-cols-container .ag-cell.auto-col-tight": {
paddingLeft: "12px",
paddingRight: "9px",
justifyContent: "flex-start",
textAlign: "left",
alignItems: "center",
gap: 0,
},
"& .ag-center-cols-container .ag-cell.auto-col-tight .ag-cell-wrapper, & .ag-pinned-left-cols-container .ag-cell.auto-col-tight .ag-cell-wrapper, & .ag-pinned-right-cols-container .ag-cell.auto-col-tight .ag-cell-wrapper": {
flex: 1,
justifyContent: "flex-start",
alignItems: "center",
gap: 0,
},
"& .ag-center-cols-container .ag-cell.auto-col-tight .ag-cell-value, & .ag-pinned-left-cols-container .ag-cell.auto-col-tight .ag-cell-value, & .ag-pinned-right-cols-container .ag-cell.auto-col-tight .ag-cell-value": {
width: "100%",
textAlign: "left",
display: "flex",
justifyContent: "flex-start",
alignItems: "center",
},
"& .status-pill-cell": {
display: "flex",
alignItems: "center",
},
"& .status-pill-cell .ag-cell-wrapper": {
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100%",
paddingTop: 0,
paddingBottom: 0,
lineHeight: "normal",
},
"& .status-pill-cell .ag-cell-value": {
width: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100%",
},
};
const DEVICE_STATUS_THEME = {
online: {
label: "Online",
text: "#00d18c",
background: "rgba(0,209,140,0.16)",
border: "1px solid rgba(0,209,140,0.35)",
dot: "#00d18c",
},
offline: {
label: "Offline",
text: "#b0b8c8",
background: "rgba(176,184,200,0.14)",
border: "1px solid rgba(176,184,200,0.35)",
dot: "#c3cada",
},
};
const JOB_RESULT_THEME = {
success: {
label: "Success",
text: "#34d399",
background: "linear-gradient(120deg, rgba(52,211,153,0.22), rgba(30,64,175,0.12))",
border: "1px solid rgba(52,211,153,0.45)",
dot: "#34d399",
},
running: {
label: "Running",
text: "#7dd3fc",
background: "linear-gradient(120deg, rgba(125,211,252,0.25), rgba(14,165,233,0.18))",
border: "1px solid rgba(125,211,252,0.45)",
dot: "#38bdf8",
},
failed: {
label: "Failed",
text: "#fb7185",
background: "rgba(251,113,133,0.18)",
border: "1px solid rgba(251,113,133,0.45)",
dot: "#fb7185",
},
pending: {
label: "Pending",
text: "#fbbf24",
background: "rgba(251,191,36,0.18)",
border: "1px solid rgba(251,191,36,0.35)",
dot: "#f59e0b",
},
expired: {
label: "Expired",
text: "#e5e7eb",
background: "rgba(226,232,240,0.14)",
border: "1px solid rgba(226,232,240,0.32)",
dot: "#cbd5f5",
},
default: {
label: "Status",
text: "#e2e8f0",
background: "rgba(226,232,240,0.12)",
border: "1px solid rgba(226,232,240,0.2)",
dot: "#94a3b8",
},
};
const StatusPill = ({ label, theme }) => {
if (!label) return null;
const pillTheme = theme || JOB_RESULT_THEME.default;
return (
<Box
component="span"
sx={{
display: "inline-flex",
alignItems: "center",
gap: 0.6,
px: 1.2,
py: 0.25,
borderRadius: 999,
background: pillTheme.background,
border: pillTheme.border,
color: pillTheme.text,
fontWeight: 600,
fontSize: "12px",
letterSpacing: 0.35,
textTransform: "uppercase",
lineHeight: 1,
fontFamily: gridFontFamily,
}}
>
{pillTheme.dot ? (
<Box
component="span"
sx={{
width: 8,
height: 8,
borderRadius: "50%",
backgroundColor: pillTheme.dot,
boxShadow: "0 0 0 2px rgba(8,12,24,0.65)",
}}
/>
) : null}
{label}
</Box>
);
};
const GLASS_PANEL_BASE_SX = {
background: MAGIC_UI.panelBg,
borderRadius: 3,
border: `1px solid ${MAGIC_UI.panelBorder}`,
boxShadow: MAGIC_UI.glow,
p: { xs: 2, md: 3 },
};
const TAB_SECTION_SX = {
width: "100%",
display: "flex",
flexDirection: "column",
gap: 1.5,
px: { xs: 1.5, md: 2 },
py: { xs: 1.25, md: 1.75 },
};
const TAB_HOVER_GRADIENT = "linear-gradient(120deg, rgba(125,211,252,0.18), rgba(192,132,252,0.22))";
const PRIMARY_CTA_SX = {
borderRadius: 999,
px: 3,
py: 1,
fontWeight: 600,
textTransform: "none",
color: "#041317",
backgroundImage: "linear-gradient(120deg,#34d399,#22d3ee)",
"&:hover": {
backgroundImage: "linear-gradient(120deg,#22d3ee,#34d399)",
},
};
const OUTLINE_BUTTON_SX = {
borderRadius: 999,
px: 2.5,
textTransform: "none",
borderColor: "rgba(148,163,184,0.45)",
color: MAGIC_UI.textBright,
"&:hover": {
borderColor: MAGIC_UI.accentA,
},
};
const INPUT_FIELD_SX = {
"& .MuiOutlinedInput-root": {
borderRadius: 2,
bgcolor: "rgba(5,9,18,0.85)",
color: MAGIC_UI.textBright,
"& fieldset": {
borderColor: "rgba(148,163,184,0.35)",
},
"&:hover fieldset": {
borderColor: MAGIC_UI.accentA,
},
"&.Mui-focused fieldset": {
borderColor: MAGIC_UI.accentB,
boxShadow: "0 0 0 1px rgba(192,132,252,0.3)",
},
},
"& .MuiInputLabel-root": {
color: MAGIC_UI.textMuted,
},
"& .MuiFormHelperText-root": {
color: "#fda4af",
},
};
const HERO_CARD_SX = {
display: "flex",
flexDirection: "column",
gap: 0.2,
px: 0,
py: 0,
minWidth: 160,
};
const GlassPanel = ({ children, sx }) => (
<Box sx={{ ...GLASS_PANEL_BASE_SX, ...(sx || {}) }}>{children}</Box>
);
const EXEC_CONTEXT_COPY = {
system: { title: "Windows (System)", detail: "Runs on device as SYSTEM" },
current_user: { title: "Windows (Logged-In User)", detail: "Runs on device as user session" },
ssh: { title: "Remote SSH", detail: "Executes from engine host" },
winrm: { title: "Remote WinRM", detail: "Executes from engine host" },
};
const SCHEDULE_LABELS = {
immediately: "Immediate",
once: "Single run",
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_15: "Every 15 minutes",
every_hour: "Hourly cadence",
daily: "Daily cadence",
weekly: "Weekly cadence",
monthly: "Monthly cadence",
yearly: "Yearly cadence",
};
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" }
];
const normalizeFilterCatalog = (raw) => {
if (!Array.isArray(raw)) return [];
return raw
.map((item, idx) => {
const idValue = item?.id ?? item?.filter_id ?? idx;
const id = Number(idValue);
if (!Number.isFinite(id)) return null;
const scopeText = String(item?.site_scope || item?.scope || item?.type || "global").trim().toLowerCase();
const scope = scopeText === "scoped" || scopeText === "site" ? "scoped" : "global";
const siteName = item?.site || item?.site_name || item?.target_site || item?.site_scope_value || null;
const deviceCount =
typeof item?.matching_device_count === "number" && Number.isFinite(item.matching_device_count)
? item.matching_device_count
: null;
return {
id,
name: item?.name || `Filter ${idx + 1}`,
scope,
site_scope: scope,
site: siteName,
site_name: siteName,
deviceCount,
};
})
.filter(Boolean);
};
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 gradientLayer = color
? `linear-gradient(140deg, rgba(8,12,24,0.92), ${color}1f)`
: "linear-gradient(140deg, rgba(8,12,24,0.92), rgba(14,20,38,0.85))";
const handleClick = useCallback((event) => {
event?.preventDefault();
event?.stopPropagation();
onClick && onClick();
}, [onClick]);
return (
<Box
onClick={handleClick}
sx={{
px: 5.4,
py: 3.8,
borderRadius: 2,
border: `1px solid ${borderColor}`,
boxShadow: isActive ? `0 0 25px ${activeGlow}` : "0 20px 40px rgba(2,6,23,0.65)",
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",
position: "relative",
overflow: "hidden",
"&::before": {
content: '""',
position: "absolute",
inset: 0,
background: gradientLayer,
borderRadius: "inherit",
opacity: 0.95,
transition: "opacity 0.2s ease",
},
"&::after": {
content: '""',
position: "absolute",
inset: "-25% -40%",
background: color
? `radial-gradient(circle at 30% 20%, ${color}30, transparent 55%)`
: "radial-gradient(circle at 30% 20%, rgba(125,183,255,0.3), transparent 55%)",
borderRadius: "inherit",
opacity: 0.65,
filter: "blur(0px)",
transition: "opacity 0.2s ease",
},
"&:hover::before": { opacity: 1 },
"&:hover::after": { opacity: 0.85 },
}}
>
<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, position: "relative", zIndex: 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, sx }) {
return (
<Box
sx={{
mb: 1.5,
display: "flex",
alignItems: action ? "flex-start" : "center",
justifyContent: "space-between",
gap: 2,
...sx,
}}
>
<Typography
variant="subtitle2"
sx={{
color: MAGIC_UI.textBright,
fontWeight: 400,
letterSpacing: 0.4,
textTransform: "uppercase",
fontSize: 13,
}}
>
{title}
</Typography>
{action || null}
</Box>
);
}
function normalizeVariableDefinitions(vars = []) {
return (Array.isArray(vars) ? vars : [])
.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 label = typeof raw.label === "string" && raw.label.trim() ? raw.label.trim() : name;
const type = typeof raw.type === "string" ? raw.type.toLowerCase() : "string";
const required = Boolean(raw.required);
const description = typeof raw.description === "string" ? raw.description : "";
let defaultValue = "";
if (Object.prototype.hasOwnProperty.call(raw, "default")) defaultValue = raw.default;
else if (Object.prototype.hasOwnProperty.call(raw, "defaultValue")) defaultValue = raw.defaultValue;
else if (Object.prototype.hasOwnProperty.call(raw, "default_value")) defaultValue = raw.default_value;
return { name, label, type, required, description, default: defaultValue };
})
.filter(Boolean);
}
function coerceVariableValue(type, value) {
if (type === "boolean") {
if (typeof value === "boolean") return value;
if (typeof value === "number") return value !== 0;
if (value == null) return false;
const str = String(value).trim().toLowerCase();
if (!str) return false;
return ["true", "1", "yes", "on"].includes(str);
}
if (type === "number") {
if (value == null || value === "") return "";
if (typeof value === "number" && Number.isFinite(value)) return String(value);
const parsed = Number(value);
return Number.isFinite(parsed) ? String(parsed) : "";
}
return value == null ? "" : String(value);
}
function mergeComponentVariables(docVars = [], storedVars = [], storedValueMap = {}) {
const definitions = normalizeVariableDefinitions(docVars);
const overrides = {};
const storedMeta = {};
(Array.isArray(storedVars) ? storedVars : []).forEach((raw) => {
if (!raw || typeof raw !== "object") return;
const name = typeof raw.name === "string" ? raw.name.trim() : "";
if (!name) return;
if (Object.prototype.hasOwnProperty.call(raw, "value")) overrides[name] = raw.value;
else if (Object.prototype.hasOwnProperty.call(raw, "default")) overrides[name] = raw.default;
storedMeta[name] = {
label: typeof raw.label === "string" && raw.label.trim() ? raw.label.trim() : name,
type: typeof raw.type === "string" ? raw.type.toLowerCase() : undefined,
required: Boolean(raw.required),
description: typeof raw.description === "string" ? raw.description : "",
default: Object.prototype.hasOwnProperty.call(raw, "default") ? raw.default : ""
};
});
if (storedValueMap && typeof storedValueMap === "object") {
Object.entries(storedValueMap).forEach(([key, val]) => {
const name = typeof key === "string" ? key.trim() : "";
if (name) overrides[name] = val;
});
}
const used = new Set();
const merged = definitions.map((def) => {
const override = Object.prototype.hasOwnProperty.call(overrides, def.name) ? overrides[def.name] : undefined;
used.add(def.name);
return {
...def,
value: override !== undefined ? coerceVariableValue(def.type, override) : coerceVariableValue(def.type, def.default)
};
});
(Array.isArray(storedVars) ? storedVars : []).forEach((raw) => {
if (!raw || typeof raw !== "object") return;
const name = typeof raw.name === "string" ? raw.name.trim() : "";
if (!name || used.has(name)) return;
const meta = storedMeta[name] || {};
const type = meta.type || (typeof overrides[name] === "boolean" ? "boolean" : typeof overrides[name] === "number" ? "number" : "string");
const defaultValue = Object.prototype.hasOwnProperty.call(meta, "default") ? meta.default : "";
const override = Object.prototype.hasOwnProperty.call(overrides, name)
? overrides[name]
: Object.prototype.hasOwnProperty.call(raw, "value")
? raw.value
: defaultValue;
merged.push({
name,
label: meta.label || name,
type,
required: Boolean(meta.required),
description: meta.description || "",
default: defaultValue,
value: coerceVariableValue(type, override)
});
used.add(name);
});
Object.entries(overrides).forEach(([nameRaw, val]) => {
const name = typeof nameRaw === "string" ? nameRaw.trim() : "";
if (!name || used.has(name)) return;
const type = typeof val === "boolean" ? "boolean" : typeof val === "number" ? "number" : "string";
merged.push({
name,
label: name,
type,
required: false,
description: "",
default: "",
value: coerceVariableValue(type, val)
});
used.add(name);
});
return merged;
}
function ComponentCard({ comp, onRemove, onVariableChange, errors = {} }) {
const variables = Array.isArray(comp.variables)
? comp.variables.filter((v) => v && typeof v.name === "string" && v.name)
: [];
const description = comp.description || comp.path || "";
return (
<GlassPanel sx={{ mb: 2, p: { xs: 2, md: 2.5 } }}>
<Box
sx={{
display: "flex",
gap: 2.5,
flexWrap: { xs: "wrap", md: "nowrap" },
alignItems: "stretch",
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.2 }}>
<Typography variant="subtitle1" sx={{ color: MAGIC_UI.textBright, fontWeight: 600 }}>
{comp.name}
</Typography>
{comp.domain ? <DomainBadge domain={comp.domain} size="small" /> : null}
</Box>
<Typography variant="body2" sx={{ color: "#7c879d", mt: 0.5 }}>
{description}
</Typography>
</Box>
<Divider orientation="vertical" flexItem sx={{ borderColor: "rgba(148,163,184,0.25)" }} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="subtitle2" sx={{ color: MAGIC_UI.textBright, mb: 1 }}>
Variables
</Typography>
{variables.length ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
{variables.map((variable) => (
<Box key={variable.name}>
{variable.type === "boolean" ? (
<>
<FormControlLabel
sx={{
color: MAGIC_UI.textBright,
"& .MuiTypography-root": { color: MAGIC_UI.textBright, fontWeight: 500 },
}}
control={
<Checkbox
size="small"
checked={Boolean(variable.value)}
onChange={(e) => onVariableChange(comp.localId, variable.name, e.target.checked)}
sx={{
color: MAGIC_UI.accentA,
"&.Mui-checked": { color: MAGIC_UI.accentB },
}}
/>
}
label={
<>
{variable.label}
{variable.required ? " *" : ""}
</>
}
/>
{variable.description ? (
<Typography variant="caption" sx={{ color: MAGIC_UI.textMuted, display: "block", ml: 4 }}>
{variable.description}
</Typography>
) : null}
</>
) : (
<TextField
fullWidth
size="small"
label={`${variable.label}${variable.required ? " *" : ""}`}
type={variable.type === "number" ? "number" : variable.type === "credential" ? "password" : "text"}
value={variable.value ?? ""}
onChange={(e) => onVariableChange(comp.localId, variable.name, e.target.value)}
InputLabelProps={{ shrink: true }}
sx={{ ...INPUT_FIELD_SX }}
error={Boolean(errors[variable.name])}
helperText={errors[variable.name] || variable.description || ""}
/>
)}
</Box>
))}
</Box>
) : (
<Typography variant="body2" sx={{ color: MAGIC_UI.textMuted }}>
No variables defined for this assembly.
</Typography>
)}
</Box>
<Box sx={{ display: "flex", alignItems: "flex-start" }}>
<IconButton
onClick={() => onRemove(comp.localId)}
size="small"
sx={{
color: "#f87171",
border: "1px solid rgba(248,113,113,0.4)",
borderRadius: 1.5,
"&:hover": { borderColor: "#fb7185", color: "#fb7185" },
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
</Box>
</GlassPanel>
);
}
export default function CreateJob({ onCancel, onCreated, initialJob = null, quickJobDraft = null, onConsumeQuickJobDraft }) {
const [tab, setTab] = useState(0);
const [jobName, setJobName] = useState("");
const [pageTitleJobName, setPageTitleJobName] = useState("");
// Components the job will run: {type:'script'|'workflow', path, name, description}
const [components, setComponents] = useState([]);
const [targets, setTargets] = useState([]); // array of target descriptors
const [filterCatalog, setFilterCatalog] = useState([]);
const [loadingFilterCatalog, setLoadingFilterCatalog] = useState(false);
const filterCatalogMapRef = useRef({});
const loadFilterCatalog = useCallback(async () => {
setLoadingFilterCatalog(true);
try {
const resp = await fetch("/api/device_filters");
if (resp.ok) {
const data = await resp.json();
setFilterCatalog(normalizeFilterCatalog(data?.filters || data || []));
} else {
setFilterCatalog([]);
}
} catch {
setFilterCatalog([]);
} finally {
setLoadingFilterCatalog(false);
}
}, []);
useEffect(() => {
loadFilterCatalog();
}, [loadFilterCatalog]);
useEffect(() => {
const nextMap = {};
filterCatalog.forEach((entry) => {
nextMap[entry.id] = entry;
nextMap[String(entry.id)] = entry;
});
filterCatalogMapRef.current = nextMap;
}, [filterCatalog]);
const [scheduleType, setScheduleType] = useState("immediately");
const [startDateTime, setStartDateTime] = useState(() => dayjs().add(5, "minute").second(0));
const [stopAfterEnabled, setStopAfterEnabled] = useState(false);
const [expiration, setExpiration] = useState("no_expire");
const [execContext, setExecContext] = useState("system");
const [credentials, setCredentials] = useState([]);
const [credentialLoading, setCredentialLoading] = useState(false);
const [credentialError, setCredentialError] = useState("");
const [selectedCredentialId, setSelectedCredentialId] = useState("");
const [useSvcAccount, setUseSvcAccount] = useState(true);
const [assembliesPayload, setAssembliesPayload] = useState({ items: [], queue: [] });
const [assembliesLoading, setAssembliesLoading] = useState(false);
const [assembliesError, setAssembliesError] = useState("");
const assemblyExportCacheRef = useRef(new Map());
const quickDraftAppliedRef = useRef(null);
const loadCredentials = useCallback(async () => {
setCredentialLoading(true);
setCredentialError("");
try {
const resp = await fetch("/api/credentials");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const list = Array.isArray(data?.credentials) ? data.credentials : [];
list.sort((a, b) => String(a?.name || "").localeCompare(String(b?.name || "")));
setCredentials(list);
} catch (err) {
setCredentials([]);
setCredentialError(String(err.message || err));
} finally {
setCredentialLoading(false);
}
}, []);
const loadAssemblies = useCallback(async () => {
setAssembliesLoading(true);
setAssembliesError("");
try {
const resp = await fetch("/api/assemblies");
if (!resp.ok) {
const detail = await resp.text();
throw new Error(detail || `HTTP ${resp.status}`);
}
const data = await resp.json();
assemblyExportCacheRef.current.clear();
setAssembliesPayload({
items: Array.isArray(data?.items) ? data.items : [],
queue: Array.isArray(data?.queue) ? data.queue : []
});
} catch (err) {
console.error("Failed to load assemblies:", err);
setAssembliesPayload({ items: [], queue: [] });
setAssembliesError(err?.message || "Failed to load assemblies");
} finally {
setAssembliesLoading(false);
}
}, []);
const assemblyIndex = useMemo(
() => buildAssemblyIndex(assembliesPayload.items, assembliesPayload.queue),
[assembliesPayload.items, assembliesPayload.queue]
);
const assemblyGridRows = useMemo(() => {
const toRow = (record) => ({
id: record.assemblyGuid || record.pathLower || record.displayName,
name: record.displayName || record.path || record.assemblyGuid,
domain: record.domainLabel || record.domain || "General",
path: record.path || "",
summary: record.summary || "",
kind: record.kind || "script",
record
});
const grouped = assemblyIndex.grouped || {};
return {
scripts: (grouped.scripts || []).map(toRow),
ansible: (grouped.ansible || []).map(toRow),
workflows: (grouped.workflows || []).map(toRow)
};
}, [assemblyIndex]);
const loadAssemblyExport = useCallback(
async (assemblyGuid) => {
const cacheKey = assemblyGuid.toLowerCase();
if (assemblyExportCacheRef.current.has(cacheKey)) {
return assemblyExportCacheRef.current.get(cacheKey);
}
const resp = await fetch(`/api/assemblies/${encodeURIComponent(assemblyGuid)}/export`);
if (!resp.ok) {
throw new Error(`Failed to load assembly (HTTP ${resp.status})`);
}
const data = await resp.json();
assemblyExportCacheRef.current.set(cacheKey, data);
return data;
},
[]
);
useEffect(() => {
loadCredentials();
}, [loadCredentials]);
useEffect(() => {
loadAssemblies();
}, [loadAssemblies]);
// dialogs state
const [addCompOpen, setAddCompOpen] = useState(false);
const [compTab, setCompTab] = useState("scripts");
const [selectedNodeId, setSelectedNodeId] = useState("");
const [assemblyFilterText, setAssemblyFilterText] = useState("");
useEffect(() => {
setSelectedNodeId("");
}, [compTab]);
const selectedAssemblyRecord = useMemo(() => {
if (!selectedNodeId) return null;
const key = String(selectedNodeId).toLowerCase();
return assemblyIndex.byGuid?.get(key) || null;
}, [selectedNodeId, assemblyIndex]);
const assemblyRowData = useMemo(() => assemblyGridRows[compTab] || [], [assemblyGridRows, compTab]);
const filteredAssemblyRows = useMemo(() => {
const query = assemblyFilterText.trim().toLowerCase();
if (!query) return assemblyRowData;
return assemblyRowData.filter((row) => {
const fields = [row.name, row.domain, row.path, row.summary];
return fields.some((value) => typeof value === "string" && value.toLowerCase().includes(query));
});
}, [assemblyRowData, assemblyFilterText]);
const assemblyColumnDefs = useMemo(
() => [
{ field: "name", headerName: "Name", minWidth: 200, flex: 1.1 },
{ field: "domain", headerName: "Domain", minWidth: 140 },
{ field: "path", headerName: "Path", minWidth: 220, flex: 1.2 },
{ field: "summary", headerName: "Summary", minWidth: 260, flex: 1.4 }
],
[]
);
const assemblyDefaultColDef = useMemo(
() => ({
sortable: true,
resizable: false,
flex: 1,
suppressMenu: false,
filter: true,
floatingFilter: false,
cellClass: "auto-col-tight",
cellStyle: LEFT_ALIGN_CELL_STYLE,
}),
[]
);
const ASSEMBLY_AUTO_COLUMNS = useRef(["name", "domain", "path", "summary"]);
const assemblyGridApiRef = useRef(null);
const handleAssemblyGridReady = useCallback((params) => {
assemblyGridApiRef.current = params.api;
requestAnimationFrame(() => {
try {
params.api.autoSizeColumns(ASSEMBLY_AUTO_COLUMNS.current, true);
} catch {}
});
}, []);
useEffect(() => {
if (!assemblyGridApiRef.current) return;
requestAnimationFrame(() => {
try {
assemblyGridApiRef.current.autoSizeColumns(ASSEMBLY_AUTO_COLUMNS.current, true);
} catch {}
});
}, [assemblyRowData, compTab]);
const remoteExec = useMemo(() => execContext === "ssh" || execContext === "winrm", [execContext]);
const handleExecContextChange = useCallback((value) => {
const normalized = String(value || "system").toLowerCase();
setExecContext(normalized);
if (normalized === "winrm") {
setUseSvcAccount(true);
setSelectedCredentialId("");
} else {
setUseSvcAccount(false);
}
}, []);
const filteredCredentials = useMemo(() => {
if (!remoteExec) return credentials;
const target = execContext === "winrm" ? "winrm" : "ssh";
return credentials.filter((cred) => String(cred.connection_type || "").toLowerCase() === target);
}, [credentials, remoteExec, execContext]);
useEffect(() => {
if (!remoteExec) {
return;
}
if (execContext === "winrm" && useSvcAccount) {
setSelectedCredentialId("");
return;
}
if (!filteredCredentials.length) {
setSelectedCredentialId("");
return;
}
if (!selectedCredentialId || !filteredCredentials.some((cred) => String(cred.id) === String(selectedCredentialId))) {
setSelectedCredentialId(String(filteredCredentials[0].id));
}
}, [remoteExec, filteredCredentials, selectedCredentialId, execContext, useSvcAccount]);
const [addTargetOpen, setAddTargetOpen] = useState(false);
const [availableDevices, setAvailableDevices] = useState([]); // [{hostname, display, online}]
const [selectedDeviceTargets, setSelectedDeviceTargets] = useState({});
const [selectedFilterTargets, setSelectedFilterTargets] = useState({});
const [selectedFilterRows, setSelectedFilterRows] = useState([]);
const [deviceSearch, setDeviceSearch] = useState("");
const [filterSearch, setFilterSearch] = useState("");
const [targetPickerTab, setTargetPickerTab] = useState("devices");
const [componentVarErrors, setComponentVarErrors] = useState({});
const [quickJobMeta, setQuickJobMeta] = useState(null);
const primaryComponentName = useMemo(() => {
if (!components.length) return "";
const first = components[0] || {};
const candidates = [
first.displayName,
first.name,
first.component_name,
first.script_name,
first.script_path,
first.path
];
for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.trim()) {
return candidate.trim();
}
}
return "";
}, [components]);
const [deviceRows, setDeviceRows] = useState([]);
const [deviceStatusFilter, setDeviceStatusFilter] = useState(null);
const [deviceFilters, setDeviceFilters] = useState({});
const [filterAnchorEl, setFilterAnchorEl] = useState(null);
const [activeFilterColumn, setActiveFilterColumn] = useState(null);
const [pendingFilterValue, setPendingFilterValue] = useState("");
const devicePickerGridApiRef = useRef(null);
const filterPickerGridApiRef = useRef(null);
const normalizeTarget = useCallback((rawTarget) => {
if (!rawTarget) return null;
if (typeof rawTarget === "string") {
const host = rawTarget.trim();
return host ? { kind: "device", hostname: host } : null;
}
if (typeof rawTarget === "object") {
const rawKind = String(rawTarget.kind || "").toLowerCase();
if (rawKind === "device" || rawTarget.hostname) {
const host = String(rawTarget.hostname || "").trim();
if (!host) return null;
const osValue =
rawTarget.os ||
rawTarget.operating_system ||
rawTarget.device_os ||
rawTarget.platform ||
rawTarget.agent_os ||
rawTarget.system ||
rawTarget.os_name ||
(rawTarget.summary && (rawTarget.summary.os || rawTarget.summary.operating_system)) ||
"";
const siteValue = rawTarget.site || rawTarget.site_name || rawTarget.site_scope || rawTarget.siteScope || "";
return { kind: "device", hostname: host, os: osValue, site: siteValue };
}
if (rawKind === "filter" || rawTarget.filter_id != null || rawTarget.id != null) {
const idValue = rawTarget.filter_id ?? rawTarget.id;
const filterId = Number(idValue);
if (!Number.isFinite(filterId)) return null;
const catalogEntry =
filterCatalogMapRef.current[filterId] || filterCatalogMapRef.current[String(filterId)] || {};
const scopeText = String(
rawTarget.site_scope || rawTarget.scope || rawTarget.type || catalogEntry.scope || "global"
)
.trim()
.toLowerCase();
const scope = scopeText === "scoped" || scopeText === "site" ? "scoped" : "global";
const deviceCount =
typeof rawTarget.deviceCount === "number" && Number.isFinite(rawTarget.deviceCount)
? rawTarget.deviceCount
: typeof rawTarget.matching_device_count === "number" && Number.isFinite(rawTarget.matching_device_count)
? rawTarget.matching_device_count
: typeof catalogEntry.deviceCount === "number"
? catalogEntry.deviceCount
: null;
return {
kind: "filter",
filter_id: filterId,
name: rawTarget.name || catalogEntry.name || `Filter #${filterId}`,
site_scope: scope,
site: rawTarget.site || rawTarget.site_name || catalogEntry.site || null,
deviceCount,
};
}
}
return null;
}, []);
const targetKey = useCallback((target) => {
if (!target) return "";
if (target.kind === "filter") return `filter-${target.filter_id}`;
if (target.kind === "device") return `device-${(target.hostname || "").toLowerCase()}`;
return "";
}, []);
const normalizeTargetList = useCallback(
(list) => {
if (!Array.isArray(list)) return [];
const seen = new Set();
const next = [];
list.forEach((entry) => {
const normalized = normalizeTarget(entry);
if (!normalized) return;
const key = targetKey(normalized);
if (!key || seen.has(key)) return;
seen.add(key);
next.push(normalized);
});
return next;
},
[normalizeTarget, targetKey]
);
const serializeTargetsForSave = useCallback((list) => {
if (!Array.isArray(list)) return [];
return list
.map((target) => {
if (!target) return null;
if (target.kind === "filter") {
return {
kind: "filter",
filter_id: target.filter_id,
name: target.name,
site_scope: target.site_scope,
site: target.site,
};
}
if (target.kind === "device") {
return target.hostname;
}
return null;
})
.filter(Boolean);
}, []);
const addTargets = useCallback(
(entries) => {
const candidateList = Array.isArray(entries) ? entries : [entries];
setTargets((prev) => {
const seen = new Set(prev.map((existing) => targetKey(existing)).filter(Boolean));
const additions = [];
candidateList.forEach((entry) => {
const normalized = normalizeTarget(entry);
if (!normalized) return;
const key = targetKey(normalized);
if (!key || seen.has(key)) return;
seen.add(key);
additions.push(normalized);
});
if (!additions.length) return prev;
return [...prev, ...additions];
});
},
[normalizeTarget, targetKey]
);
const removeTarget = useCallback(
(targetToRemove) => {
const removalKey = targetKey(targetToRemove);
if (!removalKey) return;
setTargets((prev) => prev.filter((target) => targetKey(target) !== removalKey));
},
[targetKey]
);
const availableDeviceMap = useMemo(() => {
const map = new Map();
availableDevices.forEach((device) => {
const key = String(device?.hostname || "").toLowerCase();
if (!key) return;
map.set(key, device);
});
return map;
}, [availableDevices]);
const devicePickerRows = useMemo(() => {
const query = deviceSearch.trim().toLowerCase();
if (query.length < 3) return [];
return availableDevices
.filter((device) => {
const display = String(device?.display || device?.hostname || "").toLowerCase();
return display.includes(query);
})
.map((device, index) => ({
id: String(device?.hostname || device?.display || `device-${index}`).toLowerCase(),
name: device?.display || device?.hostname || "Device",
status: device?.online ? "Online" : "Offline",
os: device?.os || "",
site: device?.site || "",
raw: device,
}));
}, [availableDevices, deviceSearch]);
const devicePickerOverlay = useMemo(
() =>
deviceSearch.trim().length < 3
? "Type 3+ characters to search devices."
: "No devices match your search.",
[deviceSearch]
);
const filterPickerRows = useMemo(() => {
const query = filterSearch.trim().toLowerCase();
if (query.length < 3) return [];
return (filterCatalog || [])
.filter((f) => {
if (!query) return true;
return String(f?.name || "").toLowerCase().includes(query);
})
.map((f, index) => {
const scopeRaw = String(f.scope || f.site_scope || f.type || "").toLowerCase();
const scoped = scopeRaw === "scoped" || scopeRaw === "site";
const deviceCount =
typeof f.deviceCount === "number"
? f.deviceCount
: f.devices_targeted ?? f.matching_device_count ?? null;
return {
id: String(f.id ?? f.filter_id ?? index),
name: f.name || `Filter ${index + 1}`,
deviceCount,
scope: scoped ? "Site" : "Global",
scopeKey: scoped ? "scoped" : "global",
site: "",
raw: f,
};
});
}, [filterCatalog, filterSearch]);
const filterPickerRowMap = useMemo(() => {
const map = new Map();
filterPickerRows.forEach((row) => {
map.set(String(row.id), row);
});
return map;
}, [filterPickerRows]);
const filterPickerOverlay = useMemo(() => {
if (!filterCatalog?.length) return "No device filters available.";
const query = filterSearch.trim();
if (query.length < 3) return "Type 3+ characters to search filters.";
if (query && filterPickerRows.length === 0) return "No filters match your search.";
return "Select filters to target.";
}, [filterCatalog?.length, filterPickerRows.length, filterSearch]);
const deviceRowsMap = useMemo(() => {
const map = new Map();
deviceRows.forEach((device) => {
const key = String(device?.hostname || device?.agent_hostname || device?.id || "").toLowerCase();
if (!key) return;
const osValue =
device?.os ||
device?.operating_system ||
device?.agent_os ||
device?.system ||
device?.os_name ||
(device?.summary && (device.summary.os || device.summary.operating_system)) ||
"";
const siteValue = device?.site || device?.site_name || device?.site_scope || device?.summary?.site || "";
map.set(key, { ...device, os: osValue, site: siteValue });
});
return map;
}, [deviceRows]);
const targetGridRows = useMemo(() => {
const resolveOs = (target) => {
if (!target || target.kind !== "device") return "";
const explicit =
target.os ||
target.operating_system ||
target.device_os ||
target.platform ||
target.agent_os ||
target.system ||
target.os_name;
if (explicit) return explicit;
const key = String(target.hostname || "").toLowerCase();
if (!key) return "";
const fromAvailable = availableDeviceMap.get(key);
if (fromAvailable?.os) return fromAvailable.os;
const fromHistory = deviceRowsMap.get(key);
if (fromHistory?.os) return fromHistory.os;
return "";
};
return targets.map((target) => {
const key = targetKey(target) || `${target?.kind || "target"}-${Math.random().toString(36).slice(2, 8)}`;
const isFilter = target?.kind === "filter";
const siteLabel = isFilter
? ""
: target?.site ||
target?.site_name ||
(() => {
const key = String(target.hostname || "").toLowerCase();
return availableDeviceMap.get(key)?.site || deviceRowsMap.get(key)?.site || "";
})();
const deviceCount =
typeof target?.deviceCount === "number" && Number.isFinite(target.deviceCount) ? target.deviceCount : null;
const detailText = isFilter
? `${deviceCount != null ? deviceCount.toLocaleString() : "—"} device${deviceCount === 1 ? "" : "s"}`
: "—";
const osLabel = isFilter ? "—" : resolveOs(target) || "Unknown";
return {
id: key,
typeLabel: isFilter ? "Device Filter" : "Device",
siteLabel,
targetLabel: isFilter ? target?.name || `Filter #${target?.filter_id}` : target?.hostname,
detailText,
osLabel,
rawTarget: target,
};
});
}, [targets, targetKey, availableDeviceMap, deviceRowsMap]);
const targetGridColumnDefs = useMemo(
() => [
{ field: "typeLabel", headerName: "Type", minWidth: 120, flex: 0.9, filter: "agTextColumnFilter" },
{
field: "siteLabel",
headerName: "Site",
minWidth: 160,
flex: 1,
filter: "agTextColumnFilter",
cellRenderer: (params) => (
<Box component="span" sx={{ color: "#e2e8f0" }}>
{params.value || ""}
</Box>
),
cellClass: "auto-col-tight",
},
{ field: "targetLabel", headerName: "Target", minWidth: 200, flex: 1.2, filter: "agTextColumnFilter" },
{ field: "osLabel", headerName: "Operating System", minWidth: 170, flex: 1.1, filter: "agTextColumnFilter" },
{ field: "detailText", headerName: "Details", minWidth: 200, flex: 1, filter: "agTextColumnFilter" },
{
field: "actions",
headerName: "",
minWidth: 100,
flex: 1.5,
cellRenderer: "TargetActionsRenderer",
sortable: false,
suppressMenu: true,
filter: false,
},
],
[]
);
const targetGridComponents = useMemo(
() => ({
TargetActionsRenderer: (params) => (
<Box sx={{ display: "flex", justifyContent: "flex-end", width: "100%" }}>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
params.context?.removeTarget?.(params.data?.rawTarget);
}}
sx={{
color: "#fb7185",
"&:hover": { color: "#fecdd3" },
}}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
),
}),
[]
);
const targetGridDefaultColDef = useMemo(
() => ({
sortable: true,
resizable: false,
flex: 1,
suppressMenu: false,
filter: true,
floatingFilter: false,
cellClass: "auto-col-tight",
cellStyle: LEFT_ALIGN_CELL_STYLE,
}),
[]
);
const targetGridApiRef = useRef(null);
const TARGET_AUTO_COLS = useRef(["typeLabel", "targetLabel", "osLabel", "detailText"]);
const handleTargetGridReady = useCallback((params) => {
targetGridApiRef.current = params.api;
requestAnimationFrame(() => {
try {
params.api.autoSizeColumns(TARGET_AUTO_COLS.current, true);
} catch {}
});
}, []);
useEffect(() => {
if (!targetGridApiRef.current) return;
requestAnimationFrame(() => {
try {
targetGridApiRef.current.autoSizeColumns(TARGET_AUTO_COLS.current, true);
} catch {}
});
}, [targetGridRows]);
const devicePickerSelectionCol = {
headerName: "",
field: "__select__",
width: 52,
maxWidth: 52,
checkboxSelection: true,
headerCheckboxSelection: true,
resizable: false,
sortable: false,
suppressMenu: true,
filter: false,
pinned: "left",
lockPosition: true,
};
const devicePickerColumnDefs = useMemo(
() => [
devicePickerSelectionCol,
{ field: "site", headerName: "Site", minWidth: 160, flex: 1, cellClass: "auto-col-tight" },
{ field: "name", headerName: "Name", minWidth: 200, flex: 1.2, cellClass: "auto-col-tight" },
{ field: "status", headerName: "Status", minWidth: 140, flex: 0.9, cellClass: "auto-col-tight" },
{ field: "os", headerName: "OS", minWidth: 180, flex: 1.8, cellClass: "auto-col-tight" },
],
[]
);
const devicePickerDefaultColDef = useMemo(
() => ({
sortable: true,
filter: "agTextColumnFilter",
resizable: true,
flex: 1,
cellClass: "auto-col-tight",
cellStyle: LEFT_ALIGN_CELL_STYLE,
}),
[]
);
const handleDevicePickerReady = useCallback((params) => {
devicePickerGridApiRef.current = params.api;
requestAnimationFrame(() => {
try {
params.api.autoSizeColumns(["name", "status", "os"], true);
} catch {}
});
}, []);
useEffect(() => {
const api = devicePickerGridApiRef.current;
if (!api) return;
api.forEachNode((node) => {
const selected = !!selectedDeviceTargets[node?.data?.id];
if (node.isSelected() !== selected) {
node.setSelected(selected);
}
});
}, [selectedDeviceTargets, devicePickerRows]);
const handleDevicePickerSelectionChanged = useCallback(() => {
const api = devicePickerGridApiRef.current;
if (!api) return;
const next = {};
api.getSelectedNodes().forEach((node) => {
if (node?.data?.id) next[node.data.id] = true;
});
setSelectedDeviceTargets(next);
}, []);
const filterPickerSelectionCol = { ...devicePickerSelectionCol };
const filterPickerColumnDefs = useMemo(
() => [
filterPickerSelectionCol,
{ field: "name", headerName: "Filter", minWidth: 220, flex: 1.4, cellClass: "auto-col-tight" },
{
field: "deviceCount",
headerName: "Devices",
minWidth: 140,
flex: 0.8,
valueFormatter: (params) => (typeof params.value === "number" ? params.value.toLocaleString() : "—"),
cellClass: "auto-col-tight",
},
{ field: "scope", headerName: "Scope", minWidth: 140, flex: 0.9, cellClass: "auto-col-tight" },
],
[]
);
const filterPickerDefaultColDef = devicePickerDefaultColDef;
const handleFilterPickerReady = useCallback((params) => {
filterPickerGridApiRef.current = params.api;
requestAnimationFrame(() => {
try {
params.api.autoSizeColumns(["name", "deviceCount", "scope"], true);
} catch {}
});
}, []);
useEffect(() => {
const api = filterPickerGridApiRef.current;
if (!api) return;
api.forEachNode((node) => {
const selected = !!selectedFilterTargets[node?.data?.id];
if (node.isSelected() !== selected) {
node.setSelected(selected);
}
});
}, [selectedFilterTargets, filterPickerRows]);
const handleFilterPickerSelectionChanged = useCallback((params) => {
const api = params?.api || filterPickerGridApiRef.current;
if (!api) return;
const next = {};
const rows = typeof api.getSelectedRows === "function" ? api.getSelectedRows() : [];
if (rows.length) {
rows.forEach((row) => {
if (row?.id != null) next[row.id] = true;
});
setSelectedFilterRows(rows);
} else {
const nodes = api.getSelectedNodes ? api.getSelectedNodes() : [];
const collectedRows = [];
nodes.forEach((node) => {
if (node?.data?.id != null) {
next[node.data.id] = true;
collectedRows.push(node.data);
}
});
setSelectedFilterRows(collectedRows);
}
setSelectedFilterTargets(next);
}, []);
useEffect(() => {
setTargets((prev) => {
let changed = false;
const next = prev.map((target) => {
if (target?.kind === "filter") {
const normalized = normalizeTarget(target);
if (normalized) {
const sameKey = targetKey(normalized) === targetKey(target);
if (!sameKey || normalized.name !== target.name || normalized.deviceCount !== target.deviceCount || normalized.site !== target.site) {
changed = true;
return normalized;
}
}
}
return target;
});
return changed ? next : prev;
});
}, [filterCatalog, normalizeTarget, targetKey]);
const generateLocalId = useCallback(
() => `${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 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 jobHistoryGridRows = useMemo(
() =>
deviceFiltered.map((row, index) => ({
id: `${row.hostname || "device"}-${index}`,
hostname: row.hostname || "",
online: Boolean(row.online),
site: row.site || "",
ranOn: row.ran_on,
jobStatus: row.job_status || "",
hasStdOut: Boolean(row.has_stdout),
hasStdErr: Boolean(row.has_stderr),
raw: row,
})),
[deviceFiltered]
);
const hydrateExistingComponents = useCallback(async (rawComponents = []) => {
const results = [];
for (const raw of rawComponents) {
if (!raw || typeof raw !== "object") continue;
const typeRaw = raw.type || raw.component_type || "script";
if (typeRaw === "workflow") {
results.push({
...raw,
type: "workflow",
variables: Array.isArray(raw.variables) ? raw.variables : [],
localId: generateLocalId()
});
continue;
}
const kind = typeRaw === "ansible" ? "ansible" : "script";
const assemblyGuidRaw = raw.assembly_guid || raw.assemblyGuid;
let record = null;
if (assemblyGuidRaw) {
const guidKey = String(assemblyGuidRaw).trim().toLowerCase();
record = assemblyIndex.byGuid?.get(guidKey) || null;
}
if (!record) {
record = resolveAssemblyForComponent(assemblyIndex, raw, kind);
}
if (!record) {
const fallbackPath =
raw.path ||
raw.script_path ||
raw.playbook_path ||
raw.rel_path ||
raw.scriptPath ||
raw.playbookPath ||
"";
const normalizedFallback = normalizeAssemblyPath(
kind,
fallbackPath,
raw.name || raw.file_name || raw.tab_name || ""
);
record = assemblyIndex.byPath?.get(normalizedFallback.toLowerCase()) || null;
}
if (!record) {
const mergedFallback = mergeComponentVariables([], raw.variables, raw.variable_values);
results.push({
...raw,
type: kind,
path: normalizeAssemblyPath(
kind,
raw.path || raw.script_path || raw.playbook_path || "",
raw.name || raw.file_name || ""
),
name: raw.name || raw.file_name || raw.tab_name || raw.path || "Assembly",
description: raw.description || raw.path || "",
variables: mergedFallback,
localId: generateLocalId()
});
continue;
}
const exportDoc = await loadAssemblyExport(record.assemblyGuid);
const parsed = parseAssemblyExport(exportDoc);
const docVars = Array.isArray(parsed.rawVariables) ? parsed.rawVariables : [];
const mergedVariables = mergeComponentVariables(docVars, raw.variables, raw.variable_values);
results.push({
...raw,
type: kind,
path: normalizeAssemblyPath(kind, record.path || "", record.displayName),
name: raw.name || record.displayName,
description: raw.description || record.summary || record.path,
variables: mergedVariables,
localId: generateLocalId(),
assemblyGuid: record.assemblyGuid,
domain: record.domain,
domainLabel: record.domainLabel
});
}
return results;
}, [assemblyIndex, loadAssemblyExport, mergeComponentVariables, generateLocalId]);
const sanitizeComponentsForSave = useCallback((items) => {
return (Array.isArray(items) ? items : []).map((comp) => {
if (!comp || typeof comp !== "object") return comp;
const { localId, ...rest } = comp;
const sanitized = { ...rest };
if (Array.isArray(comp.variables)) {
const valuesMap = {};
sanitized.variables = comp.variables
.filter((v) => v && typeof v.name === "string" && v.name)
.map((v) => {
const entry = {
name: v.name,
label: v.label || v.name,
type: v.type || "string",
required: Boolean(v.required),
description: v.description || ""
};
if (Object.prototype.hasOwnProperty.call(v, "default")) entry.default = v.default;
if (Object.prototype.hasOwnProperty.call(v, "value")) {
entry.value = v.value;
valuesMap[v.name] = v.value;
}
return entry;
});
if (!sanitized.variables.length) sanitized.variables = [];
if (Object.keys(valuesMap).length) sanitized.variable_values = valuesMap;
else delete sanitized.variable_values;
}
return sanitized;
});
}, []);
const updateComponentVariable = useCallback((localId, name, value) => {
if (!localId || !name) return;
setComponents((prev) => prev.map((comp) => {
if (!comp || comp.localId !== localId) return comp;
const vars = Array.isArray(comp.variables) ? comp.variables : [];
const nextVars = vars.map((variable) => {
if (!variable || variable.name !== name) return variable;
return { ...variable, value: coerceVariableValue(variable.type || "string", value) };
});
return { ...comp, variables: nextVars };
}));
setComponentVarErrors((prev) => {
if (!prev[localId] || !prev[localId][name]) return prev;
const next = { ...prev };
const compErrors = { ...next[localId] };
delete compErrors[name];
if (Object.keys(compErrors).length) next[localId] = compErrors;
else delete next[localId];
return next;
});
}, []);
const removeComponent = useCallback((localId) => {
setComponents((prev) => prev.filter((comp) => comp.localId !== localId));
setComponentVarErrors((prev) => {
if (!prev[localId]) return prev;
const next = { ...prev };
delete next[localId];
return next;
});
}, []);
const isValid = useMemo(() => {
const base = jobName.trim().length > 0 && components.length > 0 && targets.length > 0;
if (!base) return false;
const needsCredential = remoteExec && !(execContext === "winrm" && useSvcAccount);
if (needsCredential && !selectedCredentialId) return false;
if (scheduleType !== "immediately") {
return !!startDateTime;
}
return true;
}, [jobName, components.length, targets.length, scheduleType, startDateTime, remoteExec, selectedCredentialId, execContext, useSvcAccount]);
const handleJobNameInputChange = useCallback((value) => {
setJobName(value);
setQuickJobMeta((prev) => {
if (!prev?.allowAutoRename) return prev;
if (!prev.currentAutoName) return prev;
if (value.trim() !== prev.currentAutoName.trim()) {
return { ...prev, allowAutoRename: false };
}
return prev;
});
}, []);
const [confirmOpen, setConfirmOpen] = useState(false);
const editing = !!(initialJob && initialJob.id);
useEffect(() => {
if (editing) {
quickDraftAppliedRef.current = null;
setQuickJobMeta(null);
}
}, [editing]);
// --- Job History (only when editing) ---
const [historyRows, setHistoryRows] = useState([]);
const activityCacheRef = useRef(new Map());
const [outputOpen, setOutputOpen] = useState(false);
const [outputTitle, setOutputTitle] = useState("");
const [outputSections, setOutputSections] = useState([]);
const [outputLoading, setOutputLoading] = useState(false);
const [outputError, setOutputError] = useState("");
const loadHistory = useCallback(async () => {
if (!editing) return;
try {
const [runsResp, jobResp, devResp] = await Promise.all([
fetch(`/api/scheduled_jobs/${initialJob.id}/runs?days=30`),
fetch(`/api/scheduled_jobs/${initialJob.id}`),
fetch(`/api/scheduled_jobs/${initialJob.id}/devices`)
]);
const runs = await runsResp.json();
const job = await jobResp.json();
const dev = await devResp.json();
if (!runsResp.ok) throw new Error(runs.error || `HTTP ${runsResp.status}`);
if (!jobResp.ok) throw new Error(job.error || `HTTP ${jobResp.status}`);
if (!devResp.ok) throw new Error(dev.error || `HTTP ${devResp.status}`);
setHistoryRows(Array.isArray(runs.runs) ? runs.runs : []);
setJobSummary(job.job || {});
const devices = Array.isArray(dev.devices) ? dev.devices.map((device) => ({
...device,
activities: Array.isArray(device.activities) ? device.activities : [],
})) : [];
setDeviceRows(devices);
} catch {
setHistoryRows([]);
setJobSummary({});
setDeviceRows([]);
}
}, [editing, initialJob?.id]);
useEffect(() => {
if (!editing) return;
let t;
(async () => { try { await loadHistory(); } catch {} })();
t = setInterval(loadHistory, 10000);
return () => { if (t) clearInterval(t); };
}, [editing, loadHistory]);
const resultChip = useCallback((status) => {
const key = String(status || "").toLowerCase();
const theme = JOB_RESULT_THEME[key] || JOB_RESULT_THEME.default;
const label = JOB_RESULT_THEME[key]?.label || status || "Status";
return <StatusPill label={label} theme={theme} />;
}, []);
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(() => {
return [...aggregatedHistory].sort(
(a, b) => Number(b?.finished_ts || 0) - Number(a?.finished_ts || 0)
);
}, [aggregatedHistory]);
const historySummaryComponents = useMemo(
() => ({
HistoryStatusRenderer: (params) => resultChip(params.value || ""),
}),
[resultChip]
);
const historySummaryColumnDefs = useMemo(
() => [
{
field: "scheduled_ts",
headerName: "Scheduled",
minWidth: 180,
valueFormatter: (params) => (params.value ? fmtTs(params.value) : ""),
},
{
field: "started_ts",
headerName: "Started",
minWidth: 180,
valueFormatter: (params) => (params.value ? fmtTs(params.value) : ""),
},
{
field: "finished_ts",
headerName: "Finished",
minWidth: 180,
valueFormatter: (params) => (params.value ? fmtTs(params.value) : ""),
},
{
field: "status",
headerName: "Status",
minWidth: 140,
cellRenderer: "HistoryStatusRenderer",
cellClass: "status-pill-cell",
sortable: false,
suppressMenu: true,
},
],
[fmtTs]
);
const historySummaryDefaultColDef = useMemo(
() => ({
sortable: true,
resizable: false,
flex: 1,
cellClass: "auto-col-tight",
}),
[]
);
const historySummaryGridApiRef = useRef(null);
const HISTORY_SUMMARY_AUTO_COLS = useRef(["scheduled_ts", "started_ts", "finished_ts", "status"]);
const handleHistorySummaryGridReady = useCallback((params) => {
historySummaryGridApiRef.current = params.api;
requestAnimationFrame(() => {
try {
params.api.autoSizeColumns(HISTORY_SUMMARY_AUTO_COLS.current, true);
} catch {}
});
}, []);
useEffect(() => {
if (!historySummaryGridApiRef.current) return;
requestAnimationFrame(() => {
try {
historySummaryGridApiRef.current.autoSizeColumns(HISTORY_SUMMARY_AUTO_COLS.current, true);
} catch {}
});
}, [sortedHistory]);
// --- Job Progress (summary) ---
const [jobSummary, setJobSummary] = useState({});
const counts = jobSummary?.result_counts || {};
const deviceStatusCounts = useMemo(() => {
const base = { pending: 0, running: 0, success: 0, failed: 0, expired: 0 };
deviceRows.forEach((row) => {
const normalized = String(row?.job_status || "").trim().toLowerCase();
if (!normalized || normalized === "pending" || normalized === "scheduled" || normalized === "queued") {
base.pending += 1;
} else if (normalized === "running") {
base.running += 1;
} else if (normalized === "success") {
base.success += 1;
} else if (normalized === "expired") {
base.expired += 1;
} else if (normalized === "failed" || normalized === "failure" || normalized === "timed out" || normalized === "timed_out" || normalized === "warning") {
base.failed += 1;
} 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 = () => (
<GlassPanel sx={{ mb: 2, p: 0, overflow: "hidden" }}>
<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: MAGIC_UI.accentA,
},
}}
/>
<Box sx={{ height: 380, p: { xs: 2, md: 3 }, background: "rgba(4,7,18,0.6)" }}>
<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>
{deviceStatusFilter ? (
<Box sx={{ px: { xs: 2, md: 3 }, pb: 2.5, display: "flex", alignItems: "center", gap: 1.5 }}>
<Typography variant="caption" sx={{ color: MAGIC_UI.textMuted }}>
Showing devices with {STATUS_META[deviceStatusFilter]?.label || deviceStatusFilter} results
</Typography>
<Button size="small" sx={{ color: MAGIC_UI.accentA, textTransform: "none", p: 0 }} onClick={() => setDeviceStatusFilter(null)}>
Clear Filter
</Button>
</Box>
) : null}
</GlassPanel>
);
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";
}, []);
const highlightCode = useCallback((code, lang) => {
try {
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]);
const jobHistoryGridComponents = useMemo(
() => ({
DeviceStatusRenderer: (params) => {
const online = Boolean(params.value);
const theme = online ? DEVICE_STATUS_THEME.online : DEVICE_STATUS_THEME.offline;
return (
<StatusPill
label={online ? DEVICE_STATUS_THEME.online.label : DEVICE_STATUS_THEME.offline.label}
theme={theme}
/>
);
},
JobStatusRenderer: (params) => resultChip(params.value || ""),
OutputActionsRenderer: (params) => {
const row = params.data?.raw;
if (!row) return null;
return (
<Box sx={{ display: "flex", gap: 1 }}>
{row.has_stdout ? (
<Button
size="small"
sx={{ color: MAGIC_UI.accentA, textTransform: "none", minWidth: 0, p: 0 }}
onClick={(e) => {
e.stopPropagation();
params.context?.viewOutput?.(row, "stdout");
}}
>
StdOut
</Button>
) : null}
{row.has_stderr ? (
<Button
size="small"
sx={{ color: "#fb7185", textTransform: "none", minWidth: 0, p: 0 }}
onClick={(e) => {
e.stopPropagation();
params.context?.viewOutput?.(row, "stderr");
}}
>
StdErr
</Button>
) : null}
</Box>
);
},
}),
[resultChip]
);
const jobHistoryGridColumnDefs = useMemo(
() => [
{ field: "hostname", headerName: "Hostname", minWidth: 180 },
{
field: "online",
headerName: "Status",
minWidth: 140,
cellRenderer: "DeviceStatusRenderer",
cellClass: "status-pill-cell",
sortable: false,
suppressMenu: true,
},
{ field: "site", headerName: "Site", minWidth: 160 },
{
field: "ranOn",
headerName: "Ran On",
minWidth: 200,
valueFormatter: (params) => (params.value ? fmtTs(params.value) : ""),
comparator: (a, b) => Number(a || 0) - Number(b || 0),
},
{
field: "jobStatus",
headerName: "Job Status",
minWidth: 150,
cellRenderer: "JobStatusRenderer",
cellClass: "status-pill-cell",
sortable: false,
suppressMenu: true,
},
{
field: "output",
headerName: "StdOut / StdErr",
minWidth: 210,
cellRenderer: "OutputActionsRenderer",
sortable: false,
suppressMenu: true,
},
],
[fmtTs]
);
const jobHistoryGridDefaultColDef = useMemo(
() => ({
sortable: true,
resizable: false,
flex: 1,
cellClass: "auto-col-tight",
}),
[]
);
const jobHistoryGridApiRef = useRef(null);
const JOB_HISTORY_AUTO_COLS = useRef(["hostname", "online", "site", "ranOn", "jobStatus"]);
const handleJobHistoryGridReady = useCallback((params) => {
jobHistoryGridApiRef.current = params.api;
requestAnimationFrame(() => {
try {
params.api.autoSizeColumns(JOB_HISTORY_AUTO_COLS.current, true);
} catch {}
});
}, []);
useEffect(() => {
if (!jobHistoryGridApiRef.current) return;
requestAnimationFrame(() => {
try {
jobHistoryGridApiRef.current.autoSizeColumns(JOB_HISTORY_AUTO_COLS.current, true);
} catch {}
});
}, [jobHistoryGridRows]);
useEffect(() => {
let canceled = false;
const hydrate = async () => {
if (initialJob && initialJob.id) {
setJobName(initialJob.name || "");
setPageTitleJobName(typeof initialJob.name === "string" ? initialJob.name.trim() : "");
setTargets(normalizeTargetList(initialJob.targets || []));
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)));
setStopAfterEnabled(Boolean(initialJob.duration_stop_enabled));
setExpiration(initialJob.expiration || "no_expire");
setExecContext(initialJob.execution_context || "system");
setSelectedCredentialId(initialJob.credential_id ? String(initialJob.credential_id) : "");
if ((initialJob.execution_context || "").toLowerCase() === "winrm") {
setUseSvcAccount(initialJob.use_service_account !== false);
} else {
setUseSvcAccount(false);
}
const comps = Array.isArray(initialJob.components) ? initialJob.components : [];
const hydrated = await hydrateExistingComponents(comps);
if (!canceled) {
setComponents(hydrated);
setComponentVarErrors({});
}
} else if (!initialJob) {
setPageTitleJobName("");
setComponents([]);
setComponentVarErrors({});
setSelectedCredentialId("");
setUseSvcAccount(true);
}
};
hydrate();
return () => {
canceled = true;
};
}, [initialJob, hydrateExistingComponents, normalizeTargetList]);
const openAddComponent = async () => {
setAddCompOpen(true);
setSelectedNodeId("");
if (!assembliesPayload.items.length && !assembliesLoading) {
loadAssemblies();
}
};
const addSelectedComponent = useCallback(async (recordOverride = null) => {
const record = recordOverride || selectedAssemblyRecord;
if (!record || !record.assemblyGuid) return false;
if (record.kind === "workflow") {
alert("Workflows within Scheduled Jobs are not supported yet");
return false;
}
try {
const exportDoc = await loadAssemblyExport(record.assemblyGuid);
const parsed = parseAssemblyExport(exportDoc);
const docVars = Array.isArray(parsed.rawVariables) ? parsed.rawVariables : [];
const mergedVariables = mergeComponentVariables(docVars, [], {});
const type = record.kind === "ansible" || record.type === "ansible" || compTab === "ansible" ? "ansible" : "script";
const normalizedPath = normalizeAssemblyPath(type, record.path || "", record.displayName);
setComponents((prev) => [
...prev,
{
type,
path: normalizedPath,
name: record.displayName,
description: record.summary || normalizedPath,
variables: mergedVariables,
localId: generateLocalId(),
assemblyGuid: record.assemblyGuid,
domain: record.domain,
domainLabel: record.domainLabel
}
]);
setSelectedNodeId("");
return true;
} catch (err) {
console.error("Failed to load assembly export:", err);
alert(err?.message || "Failed to load assembly details.");
return false;
}
}, [selectedAssemblyRecord, compTab, loadAssemblyExport, mergeComponentVariables, normalizeAssemblyPath, generateLocalId]);
const handleAssemblyRowClick = useCallback((event) => {
const record = event?.data?.record;
if (!record?.assemblyGuid) return;
setSelectedNodeId((record.assemblyGuid || "").toLowerCase());
}, []);
const handleAssemblyRowDoubleClick = useCallback(
async (event) => {
const record = event?.data?.record;
if (!record) return;
setSelectedNodeId((record.assemblyGuid || "").toLowerCase());
await addSelectedComponent(record);
},
[addSelectedComponent]
);
const handleAssemblySelectionChanged = useCallback((event) => {
const selectedNode = event.api.getSelectedNodes()[0];
if (selectedNode?.data?.record?.assemblyGuid) {
setSelectedNodeId(selectedNode.data.record.assemblyGuid.toLowerCase());
} else {
setSelectedNodeId("");
}
}, []);
const syncAssemblySelection = useCallback(() => {
if (!assemblyGridApiRef.current) return;
const targetId = String(selectedNodeId || "").toLowerCase();
assemblyGridApiRef.current.forEachNode((node) => {
const guid = String(node.data?.record?.assemblyGuid || "").toLowerCase();
node.setSelected(Boolean(targetId) && guid === targetId);
});
}, [selectedNodeId]);
useEffect(() => {
syncAssemblySelection();
}, [syncAssemblySelection, filteredAssemblyRows]);
const openAddTargets = async () => {
setAddTargetOpen(true);
setTargetPickerTab("devices");
setSelectedDeviceTargets({});
setSelectedFilterTargets({});
setSelectedFilterRows([]);
loadFilterCatalog();
try {
const resp = await fetch("/api/agents");
if (resp.ok) {
const data = await resp.json();
const list = Object.values(data || {}).map((a) => ({
hostname: a.hostname || a.agent_hostname || a.id || "unknown",
display: a.hostname || a.agent_hostname || a.id || "unknown",
online: !!a.collector_active,
site: a.site || a.site_name || a.site_scope || a.group || "",
os:
a.os ||
a.operating_system ||
a.platform ||
a.agent_os ||
a.system ||
a.os_name ||
(a.summary && (a.summary.os || a.summary.operating_system)) ||
"",
}));
list.sort((a, b) => a.display.localeCompare(b.display));
setAvailableDevices(list);
} else {
setAvailableDevices([]);
}
} catch {
setAvailableDevices([]);
}
};
const handleCreate = async () => {
if (remoteExec && !(execContext === "winrm" && useSvcAccount) && !selectedCredentialId) {
alert("Please select a credential for this execution context.");
return;
}
const requiredErrors = {};
components.forEach((comp) => {
if (!comp || !comp.localId) return;
(Array.isArray(comp.variables) ? comp.variables : []).forEach((variable) => {
if (!variable || !variable.name || !variable.required) return;
if ((variable.type || "string") === "boolean") return;
const value = variable.value;
if (value == null || value === "") {
if (!requiredErrors[comp.localId]) requiredErrors[comp.localId] = {};
requiredErrors[comp.localId][variable.name] = "Required";
}
});
});
if (Object.keys(requiredErrors).length) {
setComponentVarErrors(requiredErrors);
setTab(1);
alert("Please fill in all required variable values.");
return;
}
setComponentVarErrors({});
const payloadComponents = sanitizeComponentsForSave(components);
const payload = {
name: jobName,
components: payloadComponents,
targets: serializeTargetsForSave(targets),
schedule: { type: scheduleType, start: scheduleType !== "immediately" ? (() => { try { const d = startDateTime?.toDate?.() || new Date(startDateTime); d.setSeconds(0,0); return d.toISOString(); } catch { return startDateTime; } })() : null },
duration: { stopAfterEnabled, expiration },
execution_context: execContext,
credential_id: remoteExec && !useSvcAccount && selectedCredentialId ? Number(selectedCredentialId) : null,
use_service_account: execContext === "winrm" ? Boolean(useSvcAccount) : false
};
try {
const resp = await fetch(initialJob && initialJob.id ? `/api/scheduled_jobs/${initialJob.id}` : "/api/scheduled_jobs", {
method: initialJob && initialJob.id ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
onCreated && onCreated(data.job || payload);
onCancel && onCancel();
} catch (err) {
alert(String(err.message || err));
}
};
const tabDefs = useMemo(() => {
const base = [
{ key: "name", label: "Job Name" },
{ key: "components", label: "Assemblies" },
{ key: "targets", label: "Targets" },
{ key: "schedule", label: "Schedule" },
{ key: "context", label: "Execution Context" }
];
if (editing) base.push({ key: 'history', label: 'Job History' });
return base;
}, [editing]);
const historyTabIndex = useMemo(() => tabDefs.findIndex((t) => t.key === "history"), [tabDefs]);
const scheduleSummary = useMemo(() => {
const base = SCHEDULE_LABELS[scheduleType] || "Scheduled run";
if (scheduleType === "immediately") {
return "Runs as soon as the job is created";
}
const dt = startDateTime ? dayjs(startDateTime) : null;
if (dt && dt.isValid()) {
return `${base}${dt.format("MMM D, YYYY h:mm A")}`;
}
return base;
}, [scheduleType, startDateTime]);
const targetSummary = useMemo(() => {
if (!targets.length) return "No targets selected";
let deviceCount = 0;
let filterCount = 0;
targets.forEach((target) => {
if (target?.kind === "filter") filterCount += 1;
else deviceCount += 1;
});
const segments = [];
if (deviceCount) segments.push(`${deviceCount} device${deviceCount === 1 ? "" : "s"}`);
if (filterCount) segments.push(`${filterCount} filter${filterCount === 1 ? "" : "s"}`);
return segments.join(" • ") || `${targets.length} target${targets.length === 1 ? "" : "s"}`;
}, [targets]);
const heroTiles = useMemo(() => {
const execMeta = EXEC_CONTEXT_COPY[execContext] || EXEC_CONTEXT_COPY.system;
return [
{
key: "assemblies",
label: "Assemblies",
value: components.length ? components.length.toString() : "0",
},
{
key: "targets",
label: "Targets",
value: targets.length ? targets.length.toString() : "0",
},
{
key: "schedule",
label: "Schedule",
value: SCHEDULE_LABELS[scheduleType] || "Schedule",
},
{
key: "context",
label: "Execution",
value: execMeta.title,
},
];
}, [components.length, targets.length, scheduleType, scheduleSummary, targetSummary, execContext]);
useEffect(() => {
if (editing) return;
if (!quickJobDraft || !quickJobDraft.id) return;
if (quickDraftAppliedRef.current === quickJobDraft.id) return;
quickDraftAppliedRef.current = quickJobDraft.id;
const incoming = Array.isArray(quickJobDraft.hostnames) ? quickJobDraft.hostnames : [];
const normalizedTargets = normalizeTargetList(incoming);
setTargets(normalizedTargets);
setSelectedDeviceTargets({});
setSelectedFilterTargets({});
setComponents([]);
setComponentVarErrors({});
const normalizedSchedule = String(quickJobDraft.scheduleType || "immediately").trim().toLowerCase() || "immediately";
setScheduleType(normalizedSchedule);
const placeholderAssembly = (quickJobDraft.placeholderAssemblyLabel || "Choose Assembly").trim() || "Choose Assembly";
const defaultDeviceLabel = normalizedTargets[0]?.hostname || incoming[0] || "Selected Device";
const deviceLabel = (quickJobDraft.deviceLabel || defaultDeviceLabel).trim() || "Selected Device";
const initialName = `Quick Job - ${placeholderAssembly} - ${deviceLabel}`;
setJobName(initialName);
setPageTitleJobName(initialName.trim());
setQuickJobMeta({
id: quickJobDraft.id,
deviceLabel,
allowAutoRename: true,
currentAutoName: initialName
});
const targetTabKey = quickJobDraft.initialTabKey || "components";
const tabIndex = tabDefs.findIndex((t) => t.key === targetTabKey);
if (tabIndex >= 0) setTab(tabIndex);
else if (tabDefs.length > 1) setTab(1);
if (typeof onConsumeQuickJobDraft === "function") {
onConsumeQuickJobDraft(quickJobDraft.id);
}
}, [editing, quickJobDraft, tabDefs, onConsumeQuickJobDraft, normalizeTargetList]);
useEffect(() => {
if (!quickJobMeta?.allowAutoRename) return;
if (!primaryComponentName) return;
const deviceLabel = quickJobMeta.deviceLabel || "Selected Device";
const newName = `Quick Job - ${primaryComponentName} - ${deviceLabel}`;
if (jobName === newName) return;
setJobName(newName);
setPageTitleJobName(newName.trim());
setQuickJobMeta((prev) => {
if (!prev) return prev;
if (!prev.allowAutoRename) return prev;
return { ...prev, currentAutoName: newName };
});
}, [primaryComponentName, quickJobMeta, jobName]);
return (
<Box
sx={{
m: 0,
p: { xs: 2, md: 3 },
flexGrow: 1,
minWidth: 0,
minHeight: 0,
display: "flex",
flexDirection: "column",
gap: 3,
borderRadius: 0,
background: "transparent",
border: `1px solid ${MAGIC_UI.panelBorder}`,
boxShadow: MAGIC_UI.glow,
}}
>
<Box
sx={{
display: "flex",
flexWrap: "wrap",
gap: 2,
alignItems: "center",
justifyContent: "space-between",
}}
>
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<PendingActionsIcon sx={{ color: MAGIC_UI.accentA }} />
<Typography variant="h6" sx={{ color: MAGIC_UI.textBright, fontWeight: 700 }}>
Scheduled Job
{pageTitleJobName ? (
<Box component="span" sx={{ color: "rgba(226,232,240,0.65)", fontWeight: 500 }}>
{`: "${pageTitleJobName}"`}
</Box>
) : null}
</Typography>
</Box>
<Typography variant="body2" sx={{ color: MAGIC_UI.textMuted }}>
Configure advanced scheduled jobs against one or several targeted devices or device filters.
</Typography>
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap" }}>
<Button onClick={onCancel} sx={OUTLINE_BUTTON_SX}>
Cancel
</Button>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => (isValid ? setConfirmOpen(true) : null)}
disabled={!isValid}
sx={{
...PRIMARY_CTA_SX,
color: isValid ? "#041317" : "#ffffff",
backgroundImage: isValid
? PRIMARY_CTA_SX.backgroundImage
: "linear-gradient(135deg, rgba(148,163,184,0.35), rgba(51,65,85,0.45))",
boxShadow: isValid ? PRIMARY_CTA_SX.boxShadow : "none",
opacity: 1,
}}
>
{initialJob && initialJob.id ? "Save Changes" : "Create Job"}
</Button>
</Box>
</Box>
<Box
sx={{
display: "flex",
flexWrap: "wrap",
gap: 1.5,
justifyContent: { xs: "flex-start", md: "flex-end" },
}}
>
{heroTiles.map((tile) => {
let mainValue = tile.value || "";
let qualifier = "";
if (tile.key === "context") {
const match = mainValue.match(/^(.*?)\s*\((.+)\)$/);
if (match) {
mainValue = match[1].trim();
qualifier = match[2];
}
}
return (
<Box key={tile.key} sx={HERO_CARD_SX}>
<Typography
variant="caption"
sx={{ color: MAGIC_UI.textMuted, textTransform: "uppercase", fontWeight: 600, letterSpacing: 0.6 }}
>
{tile.label}
</Typography>
<Typography variant="h5" sx={{ color: MAGIC_UI.textBright, display: "flex", gap: 0.4, alignItems: "baseline" }}>
<Box component="span">{mainValue}</Box>
{qualifier ? (
<Box component="span" sx={{ color: "rgba(226,232,240,0.65)", fontSize: "0.9rem" }}>
({qualifier})
</Box>
) : null}
</Typography>
</Box>
);
})}
</Box>
<Tabs
value={tab}
onChange={(_, v) => setTab(v)}
variant="scrollable"
scrollButtons="auto"
TabIndicatorProps={{
style: {
height: 3,
borderRadius: 3,
background: "linear-gradient(90deg, #7dd3fc, #c084fc)",
},
}}
sx={{
borderBottom: `1px solid ${MAGIC_UI.panelBorder}`,
"& .MuiTab-root": {
color: MAGIC_UI.textMuted,
textTransform: "none",
fontWeight: 600,
opacity: 1,
borderRadius: 1,
transition: "background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease",
"&:hover": {
color: MAGIC_UI.textBright,
backgroundImage: TAB_HOVER_GRADIENT,
opacity: 1,
boxShadow: "0 0 0 1px rgba(148,163,184,0.25) inset",
},
},
"& .Mui-selected": {
color: MAGIC_UI.textBright,
"&:hover": {
backgroundImage: TAB_HOVER_GRADIENT,
},
},
}}
>
{tabDefs.map((t) => (
<Tab key={t.key} label={t.label} />
))}
</Tabs>
<Box sx={{ flexGrow: 1, minHeight: 0, display: "flex", flexDirection: "column", gap: 3 }}>
{tab === 0 && (
<Box sx={TAB_SECTION_SX}>
<SectionHeader title="Job Name" />
<TextField
fullWidth
sx={{ width: { xs: "100%", md: "60%" }, maxWidth: 540, ...INPUT_FIELD_SX }}
placeholder="Example Job Name"
value={jobName}
onChange={(e) => handleJobNameInputChange(e.target.value)}
onBlur={(e) => setPageTitleJobName(e.target.value.trim())}
InputLabelProps={{ shrink: true }}
error={jobName.trim().length === 0}
helperText={jobName.trim().length === 0 ? "Job name is required" : ""}
inputProps={{ sx: { py: 0.9 } }}
/>
</Box>
)}
{tab === 1 && (
<Box sx={TAB_SECTION_SX}>
<SectionHeader
title="Assemblies"
action={
<Button
size="small"
startIcon={<AddIcon />}
onClick={openAddComponent}
variant="outlined"
sx={{ ...OUTLINE_BUTTON_SX, borderColor: MAGIC_UI.accentB, color: MAGIC_UI.accentB }}
>
Add Assembly
</Button>
}
/>
{components.length === 0 && (
<Typography variant="body2" sx={{ color: MAGIC_UI.textMuted, mb: 1 }}>
No assemblies added yet.
</Typography>
)}
{components.map((c) => (
<ComponentCard
key={c.localId || `${c.type}-${c.path}`}
comp={c}
onRemove={removeComponent}
onVariableChange={updateComponentVariable}
errors={componentVarErrors[c.localId] || {}}
/>
))}
{components.length === 0 && (
<Typography variant="caption" sx={{ color: "#f87171" }}>
At least one assembly is required.
</Typography>
)}
</Box>
)}
{tab === 2 && (
<Box sx={{ ...TAB_SECTION_SX, flexGrow: 1, minHeight: 0, display: "flex", flexDirection: "column", gap: 1.5 }}>
<SectionHeader
title="Targets"
action={
<Button
size="small"
startIcon={<AddIcon />}
onClick={openAddTargets}
variant="outlined"
sx={{ ...OUTLINE_BUTTON_SX, borderColor: MAGIC_UI.accentA, color: MAGIC_UI.accentA }}
>
Add Target
</Button>
}
/>
<Box
className={gridThemeClass}
sx={{
...GRID_WRAPPER_SX,
flexGrow: 1,
minHeight: 420,
height: "100%",
maxHeight: "100%",
}}
>
<AgGridReact
rowData={targetGridRows}
columnDefs={targetGridColumnDefs}
defaultColDef={targetGridDefaultColDef}
components={targetGridComponents}
context={{ removeTarget }}
suppressCellFocus
headerHeight={44}
rowHeight={48}
pagination
paginationPageSize={20}
paginationPageSizeSelector={[20, 50, 100]}
overlayNoRowsTemplate="<span class='ag-overlay-no-rows-center'>No targets selected.</span>"
getRowId={(params) => params.data?.id || params.rowIndex}
onGridReady={handleTargetGridReady}
theme={gridTheme}
style={GRID_STYLE_BASE}
/>
</Box>
{targets.length === 0 && (
<Typography variant="caption" sx={{ color: "#f87171" }}>At least one target is required.</Typography>
)}
</Box>
)}
{tab === 3 && (
<Box sx={TAB_SECTION_SX}>
<SectionHeader title="Schedule" />
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
<TextField
select
size="small"
label="Recurrence"
value={scheduleType}
onChange={(e) => setScheduleType(e.target.value)}
sx={{ minWidth: 240, flex: "1 1 260px", ...INPUT_FIELD_SX }}
>
<MenuItem value="immediately">Immediately</MenuItem>
<MenuItem value="once">At selected date and time</MenuItem>
<MenuItem value="every_5_minutes">Every 5 Minutes</MenuItem>
<MenuItem value="every_10_minutes">Every 10 Minutes</MenuItem>
<MenuItem value="every_15_minutes">Every 15 Minutes</MenuItem>
<MenuItem value="every_30_minutes">Every 30 Minutes</MenuItem>
<MenuItem value="every_hour">Every Hour</MenuItem>
<MenuItem value="daily">Daily</MenuItem>
<MenuItem value="weekly">Weekly</MenuItem>
<MenuItem value="monthly">Monthly</MenuItem>
<MenuItem value="yearly">Yearly</MenuItem>
</TextField>
{scheduleType !== "immediately" && (
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DateTimePicker
value={startDateTime}
onChange={(val) => setStartDateTime(val?.second ? val.second(0) : val)}
views={["year", "month", "day", "hours", "minutes"]}
format="YYYY-MM-DD hh:mm A"
slotProps={{
textField: {
size: "small",
sx: { minWidth: 260, flex: "1 1 280px", ...INPUT_FIELD_SX },
},
}}
/>
</LocalizationProvider>
)}
</Box>
<Divider sx={{ my: 2, borderColor: MAGIC_UI.panelBorder, opacity: 0.6 }} />
<SectionHeader title="Duration" />
<FormControlLabel
sx={{
color: MAGIC_UI.textBright,
alignItems: "center",
"& .MuiTypography-root": { color: MAGIC_UI.textBright, fontSize: 13 },
}}
control={
<Checkbox
checked={stopAfterEnabled}
onChange={(e) => setStopAfterEnabled(e.target.checked)}
sx={{
color: MAGIC_UI.accentA,
"&.Mui-checked": { color: MAGIC_UI.accentB },
}}
/>
}
label="Stop running this job after"
/>
<TextField
select
size="small"
label="Expiration"
value={expiration}
onChange={(e) => setExpiration(e.target.value)}
sx={{ mt: 1, maxWidth: 260, ...INPUT_FIELD_SX }}
>
<MenuItem value="no_expire">Does not Expire</MenuItem>
<MenuItem value="30m">30 Minutes</MenuItem>
<MenuItem value="1h">1 Hour</MenuItem>
<MenuItem value="2h">2 Hours</MenuItem>
<MenuItem value="6h">6 Hours</MenuItem>
<MenuItem value="12h">12 Hours</MenuItem>
<MenuItem value="1d">1 Day</MenuItem>
<MenuItem value="2d">2 Days</MenuItem>
<MenuItem value="3d">3 Days</MenuItem>
</TextField>
</Box>
)}
{tab === 4 && (
<Box sx={TAB_SECTION_SX}>
<SectionHeader title="Execution Context" />
<TextField
select
size="small"
label="Context"
value={execContext}
onChange={(e) => handleExecContextChange(e.target.value)}
sx={{ minWidth: 320, ...INPUT_FIELD_SX }}
>
<MenuItem value="system">Run on agent as SYSTEM (device-local)</MenuItem>
<MenuItem value="current_user">Run on agent as logged-in user (device-local)</MenuItem>
<MenuItem value="ssh">Run from server via SSH (remote)</MenuItem>
<MenuItem value="winrm">Run from server via WinRM (remote)</MenuItem>
</TextField>
{remoteExec && (
<Box sx={{ mt: 2, display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap" }}>
{execContext === "winrm" && (
<FormControlLabel
sx={{ color: MAGIC_UI.textBright, "& .MuiTypography-root": { color: MAGIC_UI.textBright } }}
control={
<Checkbox
checked={useSvcAccount}
onChange={(e) => {
const checked = e.target.checked;
setUseSvcAccount(checked);
if (checked) {
setSelectedCredentialId("");
} else if (!selectedCredentialId && filteredCredentials.length) {
setSelectedCredentialId(String(filteredCredentials[0].id));
}
}}
sx={{
color: MAGIC_UI.accentA,
"&.Mui-checked": { color: MAGIC_UI.accentB },
}}
/>
}
label="Use Configured svcBorealis Account"
/>
)}
<TextField
select
size="small"
label="Credential"
value={selectedCredentialId}
onChange={(e) => setSelectedCredentialId(e.target.value)}
sx={{ minWidth: 280, ...INPUT_FIELD_SX }}
disabled={credentialLoading || !filteredCredentials.length || (execContext === "winrm" && useSvcAccount)}
>
{filteredCredentials.map((cred) => (
<MenuItem key={cred.id} value={String(cred.id)}>
{cred.name}
</MenuItem>
))}
</TextField>
<Button
size="small"
variant="outlined"
startIcon={<RefreshIcon fontSize="small" />}
onClick={loadCredentials}
disabled={credentialLoading}
sx={{ ...OUTLINE_BUTTON_SX, borderColor: MAGIC_UI.accentA, color: MAGIC_UI.accentA }}
>
Refresh
</Button>
{credentialLoading && <CircularProgress size={18} sx={{ color: MAGIC_UI.accentA }} />}
{!credentialLoading && credentialError && (
<Typography variant="body2" sx={{ color: "#f87171" }}>
{credentialError}
</Typography>
)}
{execContext === "winrm" && useSvcAccount && (
<Typography variant="body2" sx={{ color: MAGIC_UI.textMuted }}>
Runs with the agent&apos;s svcBorealis account.
</Typography>
)}
{!credentialLoading &&
!credentialError &&
!filteredCredentials.length &&
!(execContext === "winrm" && useSvcAccount) && (
<Typography variant="body2" sx={{ color: "#f87171" }}>
No {execContext === "winrm" ? "WinRM" : "SSH"} credentials available. Create one under Access Management &gt; Credentials.
</Typography>
)}
</Box>
)}
</Box>
)}
{editing && tab === historyTabIndex && (
<Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
<GlassPanel>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Typography variant="h6" sx={{ color: MAGIC_UI.textBright }}>
Job History
</Typography>
<Button
size="small"
variant="outlined"
sx={{ ...OUTLINE_BUTTON_SX, borderColor: "#fb7185", color: "#fb7185" }}
onClick={async () => {
try {
await fetch(`/api/scheduled_jobs/${initialJob.id}/runs`, { method: "DELETE" });
await loadHistory();
} catch {}
}}
>
Clear Job History
</Button>
</Box>
<Typography variant="caption" sx={{ color: MAGIC_UI.textMuted }}>
Showing the last 30 days of runs.
</Typography>
</GlassPanel>
<JobStatusFlow />
<GlassPanel>
<Typography variant="subtitle1" sx={{ color: MAGIC_UI.textBright, mb: 0.5 }}>
Devices
</Typography>
<Typography variant="caption" sx={{ color: MAGIC_UI.textMuted }}>
Devices targeted by this scheduled job. Individual job history is listed here.
</Typography>
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1, justifyContent: "flex-end", mt: 1 }}>
{DEVICE_COLUMNS.map((col) => (
<Button
key={col.key}
size="small"
startIcon={<FilterListIcon fontSize="inherit" />}
onClick={(event) => openFilterMenu(event, col.key)}
sx={{
textTransform: "none",
borderRadius: 999,
borderColor: isColumnFiltered(col.key) ? MAGIC_UI.accentA : "rgba(148,163,184,0.3)",
color: isColumnFiltered(col.key) ? MAGIC_UI.accentA : MAGIC_UI.textMuted,
}}
variant="outlined"
>
{col.label}
</Button>
))}
</Box>
<Box className={gridThemeClass} sx={{ ...GRID_WRAPPER_SX, height: 360 }}>
<AgGridReact
rowData={jobHistoryGridRows}
columnDefs={jobHistoryGridColumnDefs}
defaultColDef={jobHistoryGridDefaultColDef}
components={jobHistoryGridComponents}
context={{ viewOutput: handleViewDeviceOutput }}
suppressCellFocus
headerHeight={44}
rowHeight={50}
pagination
paginationPageSize={20}
paginationPageSizeSelector={[20, 50, 100]}
overlayNoRowsTemplate="<span class='ag-overlay-no-rows-center'>No targets found for this job.</span>"
getRowId={(params) => params.data?.id || params.rowIndex}
onGridReady={handleJobHistoryGridReady}
theme={gridTheme}
style={GRID_STYLE_BASE}
/>
</Box>
<Menu
anchorEl={filterAnchorEl}
open={Boolean(filterAnchorEl)}
onClose={closeFilterMenu}
disableAutoFocusItem
PaperProps={{
sx: {
bgcolor: "rgba(8,12,24,0.96)",
color: MAGIC_UI.textBright,
minWidth: 240,
border: `1px solid ${MAGIC_UI.panelBorder}`,
},
}}
>
<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: "#fb7185", textTransform: "none" }} onClick={clearFilter}>
Clear
</Button>
<Button size="small" sx={{ color: MAGIC_UI.accentA, textTransform: "none" }} onClick={applyFilter}>
Apply
</Button>
</Box>
</Box>
</Menu>
</GlassPanel>
<GlassPanel>
<Typography variant="subtitle1" sx={{ color: MAGIC_UI.textBright, mb: 0.5 }}>
Past Job History
</Typography>
<Typography variant="caption" sx={{ color: MAGIC_UI.textMuted }}>
Historical job history summaries. Detailed job history is not recorded.
</Typography>
<Box className={gridThemeClass} sx={{ ...GRID_WRAPPER_SX, mt: 1, height: 300 }}>
<AgGridReact
rowData={sortedHistory}
columnDefs={historySummaryColumnDefs}
defaultColDef={historySummaryDefaultColDef}
components={historySummaryComponents}
suppressCellFocus
headerHeight={44}
rowHeight={48}
pagination
paginationPageSize={20}
paginationPageSizeSelector={[20, 50, 100]}
overlayNoRowsTemplate="<span class='ag-overlay-no-rows-center'>No runs in the last 30 days.</span>"
getRowId={(params) => params.data?.key || params.rowIndex}
onGridReady={handleHistorySummaryGridReady}
theme={gridTheme}
style={GRID_STYLE_BASE}
/>
</Box>
</GlassPanel>
</Box>
)}
</Box>
<Dialog
open={outputOpen}
onClose={() => setOutputOpen(false)}
fullWidth
maxWidth="md"
PaperProps={{
sx: {
background: MAGIC_UI.panelBg,
color: MAGIC_UI.textBright,
border: `1px solid ${MAGIC_UI.panelBorder}`,
boxShadow: MAGIC_UI.glow,
},
}}
>
<DialogTitle>{outputTitle}</DialogTitle>
<DialogContent dividers sx={{ borderColor: MAGIC_UI.panelBorder }}>
{outputLoading ? (
<Typography variant="body2" sx={{ color: MAGIC_UI.textMuted }}>
Loading output
</Typography>
) : null}
{!outputLoading && outputError ? (
<Typography variant="body2" sx={{ color: "#f87171" }}>
{outputError}
</Typography>
) : null}
{!outputLoading && !outputError
? outputSections.map((section) => (
<Box key={section.key} sx={{ mb: 2 }}>
<Typography variant="subtitle2" sx={{ color: MAGIC_UI.textBright }}>
{section.title}
</Typography>
{section.path ? (
<Typography variant="caption" sx={{ color: MAGIC_UI.textMuted, display: "block", mb: 0.5 }}>
{section.path}
</Typography>
) : null}
<Box
sx={{
border: `1px solid ${MAGIC_UI.panelBorder}`,
borderRadius: 2,
bgcolor: "rgba(4,7,17,0.65)",
}}
>
<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: MAGIC_UI.accentA, textTransform: "none" }}>
Close
</Button>
</DialogActions>
</Dialog>
{/* Bottom actions removed per design; actions live next to tabs. */}
{/* Add Component Dialog */}
<Dialog
open={addCompOpen}
onClose={() => setAddCompOpen(false)}
fullWidth
maxWidth={false}
PaperProps={{
sx: {
background: MAGIC_UI.panelBg,
color: MAGIC_UI.textBright,
border: `1px solid ${MAGIC_UI.panelBorder}`,
boxShadow: MAGIC_UI.glow,
width: "95vw",
maxWidth: "95vw",
height: "95vh",
maxHeight: "95vh",
},
}}
>
<DialogTitle>Select an Assembly</DialogTitle>
<DialogContent sx={{ display: "flex", flexDirection: "column", gap: 2, height: "100%", pb: 0.5, pt: 2 }}>
<Box
sx={{
display: "flex",
flexWrap: "wrap",
gap: 2,
mb: 2,
alignItems: "center",
justifyContent: "space-between",
}}
>
<Tabs
value={compTab}
onChange={(_, value) => setCompTab(value)}
TabIndicatorProps={{
style: {
height: 3,
borderRadius: 3,
background: "linear-gradient(90deg, #7dd3fc, #c084fc)",
},
}}
sx={{
minHeight: 0,
borderBottom: `1px solid ${MAGIC_UI.panelBorder}`,
"& .MuiTab-root": {
textTransform: "none",
color: MAGIC_UI.textMuted,
fontFamily: '"IBM Plex Sans","Helvetica Neue",Arial,sans-serif',
fontSize: 15,
fontWeight: 600,
minHeight: 40,
opacity: 1,
borderRadius: 1,
transition: "background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease",
"&:hover": {
color: MAGIC_UI.textBright,
backgroundImage: TAB_HOVER_GRADIENT,
boxShadow: "0 0 0 1px rgba(148,163,184,0.25) inset",
},
},
"& .Mui-selected": {
color: MAGIC_UI.textBright,
"&:hover": {
backgroundImage: TAB_HOVER_GRADIENT,
},
},
}}
>
<Tab label="Scripts" value="scripts" />
<Tab label="Ansible Playbooks" value="ansible" />
<Tab label="Workflows" value="workflows" />
</Tabs>
<Box sx={{ display: "flex", gap: 1, alignItems: "center", ml: "auto" }}>
<TextField
size="small"
placeholder="Search assemblies…"
value={assemblyFilterText}
onChange={(e) => setAssemblyFilterText(e.target.value)}
sx={{ minWidth: 352, maxWidth: 520, ...INPUT_FIELD_SX }}
/>
</Box>
</Box>
{assembliesError ? (
<Typography variant="body2" sx={{ color: "#f87171", mb: 1 }}>
{assembliesError}
</Typography>
) : null}
{assembliesLoading && (
<Box sx={{ display: "flex", alignItems: "center", gap: 1, color: MAGIC_UI.accentA, mb: 1 }}>
<CircularProgress size={18} sx={{ color: MAGIC_UI.accentA }} />
<Typography variant="body2">Loading assemblies</Typography>
</Box>
)}
<Box
className={gridThemeClass}
sx={{
...GRID_WRAPPER_SX,
flexGrow: 1,
minHeight: 520,
maxHeight: "calc(95vh - 210px)",
height: "100%",
}}
>
<AgGridReact
rowData={filteredAssemblyRows}
columnDefs={assemblyColumnDefs}
defaultColDef={assemblyDefaultColDef}
suppressCellFocus
headerHeight={44}
rowHeight={48}
domLayout="normal"
overlayNoRowsTemplate="<span class='ag-overlay-no-rows-center'>No assemblies available.</span>"
pagination
paginationPageSize={20}
paginationPageSizeSelector={[20, 50, 100]}
theme={gridTheme}
style={GRID_STYLE_BASE}
rowSelection="single"
animateRows
getRowId={(params) => params.data?.id || params.rowIndex}
onGridReady={handleAssemblyGridReady}
onRowClicked={handleAssemblyRowClick}
onRowDoubleClicked={handleAssemblyRowDoubleClick}
onSelectionChanged={handleAssemblySelectionChanged}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setAddCompOpen(false)} sx={{ color: MAGIC_UI.textMuted, textTransform: "none" }}>
Close
</Button>
<Button
onClick={async () => {
const ok = await addSelectedComponent();
if (ok) setAddCompOpen(false);
}}
sx={{ color: MAGIC_UI.accentA, textTransform: "none" }}
disabled={!selectedAssemblyRecord}
>
Add
</Button>
</DialogActions>
</Dialog>
{/* Add Targets Dialog */}
<Dialog
open={addTargetOpen}
onClose={() => setAddTargetOpen(false)}
fullWidth
maxWidth="md"
PaperProps={{
sx: {
background: MAGIC_UI.panelBg,
color: MAGIC_UI.textBright,
border: `1px solid ${MAGIC_UI.panelBorder}`,
boxShadow: MAGIC_UI.glow,
},
}}
>
<DialogTitle>Select Targets</DialogTitle>
<DialogContent>
<Tabs
value={targetPickerTab}
onChange={(_, value) => setTargetPickerTab(value)}
TabIndicatorProps={{
style: {
height: 3,
borderRadius: 3,
background: "linear-gradient(90deg, #7dd3fc, #c084fc)",
},
}}
sx={{
mb: 2,
borderBottom: `1px solid ${MAGIC_UI.panelBorder}`,
"& .MuiTab-root": {
textTransform: "none",
color: MAGIC_UI.textMuted,
fontFamily: '"IBM Plex Sans","Helvetica Neue",Arial,sans-serif',
fontSize: 15,
fontWeight: 600,
minHeight: 44,
opacity: 1,
borderRadius: 1,
transition: "background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease",
"&:hover": {
color: MAGIC_UI.textBright,
backgroundImage: TAB_HOVER_GRADIENT,
boxShadow: "0 0 0 1px rgba(148,163,184,0.25) inset",
},
},
"& .Mui-selected": {
color: MAGIC_UI.textBright,
"&:hover": {
backgroundImage: TAB_HOVER_GRADIENT,
},
},
}}
>
<Tab label="Devices" value="devices" />
<Tab label="Filters" value="filters" />
</Tabs>
{targetPickerTab === "devices" ? (
<>
<Box sx={{ mb: 1.25, display: "flex", gap: 2 }}>
<TextField
size="small"
placeholder="Search devices..."
value={deviceSearch}
onChange={(e) => setDeviceSearch(e.target.value)}
sx={{ flex: 1, ...INPUT_FIELD_SX }}
helperText={deviceSearch.trim().length < 3 ? "Type at least 3 characters to search devices." : ""}
FormHelperTextProps={{ sx: { color: MAGIC_UI.textMuted } }}
/>
</Box>
<Box
className={gridThemeClass}
sx={{
...GRID_WRAPPER_SX,
height: 420,
}}
>
<AgGridReact
rowData={devicePickerRows}
columnDefs={devicePickerColumnDefs}
defaultColDef={devicePickerDefaultColDef}
animateRows
rowHeight={46}
headerHeight={44}
suppressCellFocus
rowSelection="multiple"
rowMultiSelectWithClick
overlayNoRowsTemplate={`<span class='ag-overlay-no-rows-center'>${devicePickerOverlay}</span>`}
getRowId={(params) => params.data?.id || params.rowIndex}
onGridReady={handleDevicePickerReady}
onSelectionChanged={handleDevicePickerSelectionChanged}
pagination
paginationPageSize={20}
paginationPageSizeSelector={[20, 50, 100]}
theme={gridTheme}
style={{ width: "100%", height: "100%", fontFamily: gridFontFamily, "--ag-icon-font-family": iconFontFamily }}
/>
</Box>
</>
) : (
<>
<Box sx={{ mb: 1.25, display: "flex", gap: 2 }}>
<TextField
size="small"
placeholder="Search filters..."
value={filterSearch}
onChange={(e) => setFilterSearch(e.target.value)}
sx={{ flex: 1, ...INPUT_FIELD_SX }}
helperText={filterSearch.trim().length < 3 ? "Type at least 3 characters to search filters." : ""}
FormHelperTextProps={{ sx: { color: MAGIC_UI.textMuted } }}
/>
</Box>
<Box
className={gridThemeClass}
sx={{
...GRID_WRAPPER_SX,
height: 420,
}}
>
<AgGridReact
rowData={filterPickerRows}
columnDefs={filterPickerColumnDefs}
defaultColDef={filterPickerDefaultColDef}
animateRows
rowHeight={46}
headerHeight={44}
suppressCellFocus
rowSelection="multiple"
rowMultiSelectWithClick
overlayNoRowsTemplate={`<span class='ag-overlay-no-rows-center'>${filterPickerOverlay}</span>`}
getRowId={(params) => params.data?.id || params.rowIndex}
onGridReady={handleFilterPickerReady}
onSelectionChanged={handleFilterPickerSelectionChanged}
pagination
paginationPageSize={20}
paginationPageSizeSelector={[20, 50, 100]}
theme={gridTheme}
style={{ width: "100%", height: "100%", fontFamily: gridFontFamily, "--ag-icon-font-family": iconFontFamily }}
/>
</Box>
</>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setAddTargetOpen(false)} sx={{ color: MAGIC_UI.textMuted, textTransform: "none" }}>
Cancel
</Button>
<Button
onClick={() => {
if (targetPickerTab === "filters") {
const gridNodes =
(filterPickerGridApiRef.current &&
filterPickerGridApiRef.current.getSelectedNodes()) ||
[];
const additions = [];
const api = filterPickerGridApiRef.current;
const fromStateIds = new Set(
Object.entries(selectedFilterTargets)
.filter(([, checked]) => checked)
.map(([id]) => String(id))
);
const rowsToUse =
(selectedFilterRows && selectedFilterRows.length
? selectedFilterRows
: null) ||
(() => {
if (api && typeof api.getSelectedRows === "function") {
const rows = api.getSelectedRows();
if (Array.isArray(rows) && rows.length) return rows;
}
if (gridNodes.length) {
const rows = gridNodes.map((n) => n?.data).filter(Boolean);
if (rows.length) return rows;
}
if (api && typeof api.forEachNode === "function") {
const rows = [];
api.forEachNode((node) => {
if (node && node.isSelected && node.isSelected()) {
rows.push(node.data);
}
});
if (rows.length) return rows;
}
return [];
})();
rowsToUse.forEach((row) => {
const parsedId = Number(row?.id ?? row?.filter_id);
if (!Number.isFinite(parsedId)) return;
additions.push({
kind: "filter",
filter_id: parsedId,
name: row?.name || `Filter #${parsedId}`,
site_scope: row?.scopeKey || row?.scope || "global",
site: null,
deviceCount: row?.deviceCount,
});
});
if (!additions.length && fromStateIds.size) {
fromStateIds.forEach((id) => {
const catalog =
filterCatalogMapRef.current[id] || filterCatalogMapRef.current[String(id)] || null;
const source = catalog || null;
const parsedId = Number(source?.id ?? source?.filter_id ?? id);
if (!Number.isFinite(parsedId)) return;
additions.push({
kind: "filter",
filter_id: parsedId,
name: source?.name || `Filter #${parsedId}`,
site_scope: source?.site_scope || source?.scope || source?.type || "global",
site: null,
deviceCount: source?.deviceCount ?? source?.devices_targeted ?? source?.matching_device_count,
});
});
}
if (!additions.length && filterPickerRows.length === 1) {
const row = filterPickerRows[0];
const parsedId = Number(row?.id ?? row?.filter_id);
if (Number.isFinite(parsedId)) {
additions.push({
kind: "filter",
filter_id: parsedId,
name: row?.name || `Filter #${parsedId}`,
site_scope: row?.scopeKey || row?.scope || "global",
site: null,
deviceCount: row?.deviceCount,
});
}
}
if (additions.length) {
addTargets(additions);
} else {
alert("Select at least one filter to add.");
}
} else {
const chosenDevices = Object.keys(selectedDeviceTargets)
.filter((hostname) => selectedDeviceTargets[hostname])
.map((hostname) => {
const normalized = String(hostname || "").toLowerCase();
const lookup = availableDeviceMap.get(normalized);
if (lookup) {
return { kind: "device", hostname: lookup.hostname, os: lookup.os, site: lookup.site };
}
return { kind: "device", hostname };
});
if (chosenDevices.length) addTargets(chosenDevices);
}
setAddTargetOpen(false);
}}
sx={{ color: MAGIC_UI.accentA, textTransform: "none" }}
>
Add Selected
</Button>
</DialogActions>
</Dialog>
{/* Confirm Create Dialog */}
<Dialog
open={confirmOpen}
onClose={() => setConfirmOpen(false)}
PaperProps={{
sx: {
background: MAGIC_UI.panelBg,
color: MAGIC_UI.textBright,
border: `1px solid ${MAGIC_UI.panelBorder}`,
boxShadow: MAGIC_UI.glow,
},
}}
>
<DialogTitle sx={{ pb: 0 }}>
{initialJob && initialJob.id ? "Are you sure you wish to save changes?" : "Are you sure you wish to create this Job?"}
</DialogTitle>
<DialogActions sx={{ p: 2 }}>
<Button onClick={() => setConfirmOpen(false)} sx={{ color: MAGIC_UI.textMuted, textTransform: "none" }}>
Cancel
</Button>
<Button
onClick={() => {
setConfirmOpen(false);
handleCreate();
}}
variant="outlined"
sx={{ ...OUTLINE_BUTTON_SX, borderColor: MAGIC_UI.accentA, color: MAGIC_UI.accentA }}
>
Confirm
</Button>
</DialogActions>
</Dialog>
</Box>
);
}