mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-15 23:25:48 -07:00
Quick Jobs Now Dispatch and Log Like Normal Jobs
This commit is contained in:
@@ -115,10 +115,12 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
|||||||
const [userDisplayName, setUserDisplayName] = useState(null);
|
const [userDisplayName, setUserDisplayName] = useState(null);
|
||||||
const [editingJob, setEditingJob] = useState(null);
|
const [editingJob, setEditingJob] = useState(null);
|
||||||
const [jobsRefreshToken, setJobsRefreshToken] = useState(0);
|
const [jobsRefreshToken, setJobsRefreshToken] = useState(0);
|
||||||
|
const [quickJobDraft, setQuickJobDraft] = useState(null);
|
||||||
const [assemblyEditorState, setAssemblyEditorState] = useState(null); // { mode: 'script'|'ansible', row, nonce }
|
const [assemblyEditorState, setAssemblyEditorState] = useState(null); // { mode: 'script'|'ansible', row, nonce }
|
||||||
const [sessionResolved, setSessionResolved] = useState(false);
|
const [sessionResolved, setSessionResolved] = useState(false);
|
||||||
const initialPathRef = useRef(window.location.pathname + window.location.search);
|
const initialPathRef = useRef(window.location.pathname + window.location.search);
|
||||||
const pendingPathRef = useRef(null);
|
const pendingPathRef = useRef(null);
|
||||||
|
const quickJobSeedRef = useRef(0);
|
||||||
const [notAuthorizedOpen, setNotAuthorizedOpen] = useState(false);
|
const [notAuthorizedOpen, setNotAuthorizedOpen] = useState(false);
|
||||||
|
|
||||||
// Top-bar search state
|
// Top-bar search state
|
||||||
@@ -380,6 +382,45 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
|||||||
navigateByPathRef.current = navigateByPath;
|
navigateByPathRef.current = navigateByPath;
|
||||||
}, [navigateTo, navigateByPath]);
|
}, [navigateTo, navigateByPath]);
|
||||||
|
|
||||||
|
const handleQuickJobLaunch = useCallback(
|
||||||
|
(hostnames) => {
|
||||||
|
const list = Array.isArray(hostnames) ? hostnames : [hostnames];
|
||||||
|
const normalized = Array.from(
|
||||||
|
new Set(
|
||||||
|
list
|
||||||
|
.map((host) => (typeof host === "string" ? host.trim() : ""))
|
||||||
|
.filter((host) => Boolean(host))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (!normalized.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
quickJobSeedRef.current += 1;
|
||||||
|
const primary = normalized[0];
|
||||||
|
const extraCount = normalized.length - 1;
|
||||||
|
const deviceLabel = extraCount > 0 ? `${primary} +${extraCount} more` : primary;
|
||||||
|
setEditingJob(null);
|
||||||
|
setQuickJobDraft({
|
||||||
|
id: `${Date.now()}_${quickJobSeedRef.current}`,
|
||||||
|
hostnames: normalized,
|
||||||
|
deviceLabel,
|
||||||
|
initialTabKey: "components",
|
||||||
|
scheduleType: "immediately",
|
||||||
|
placeholderAssemblyLabel: "Choose Assembly",
|
||||||
|
});
|
||||||
|
navigateTo("create_job");
|
||||||
|
},
|
||||||
|
[navigateTo]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleConsumeQuickJobDraft = useCallback((draftId) => {
|
||||||
|
setQuickJobDraft((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
if (draftId && prev.id !== draftId) return prev;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Build breadcrumb items for current view
|
// Build breadcrumb items for current view
|
||||||
const breadcrumbs = React.useMemo(() => {
|
const breadcrumbs = React.useMemo(() => {
|
||||||
const items = [];
|
const items = [];
|
||||||
@@ -1039,6 +1080,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
|||||||
onSelectDevice={(d) => {
|
onSelectDevice={(d) => {
|
||||||
navigateTo("device_details", { device: d });
|
navigateTo("device_details", { device: d });
|
||||||
}}
|
}}
|
||||||
|
onQuickJobLaunch={handleQuickJobLaunch}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "agent_devices":
|
case "agent_devices":
|
||||||
@@ -1047,17 +1089,19 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
|||||||
onSelectDevice={(d) => {
|
onSelectDevice={(d) => {
|
||||||
navigateTo("device_details", { device: d });
|
navigateTo("device_details", { device: d });
|
||||||
}}
|
}}
|
||||||
|
onQuickJobLaunch={handleQuickJobLaunch}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "ssh_devices":
|
case "ssh_devices":
|
||||||
return <SSHDevices />;
|
return <SSHDevices onQuickJobLaunch={handleQuickJobLaunch} />;
|
||||||
case "winrm_devices":
|
case "winrm_devices":
|
||||||
return <WinRMDevices />;
|
return <WinRMDevices onQuickJobLaunch={handleQuickJobLaunch} />;
|
||||||
|
|
||||||
case "device_details":
|
case "device_details":
|
||||||
return (
|
return (
|
||||||
<DeviceDetails
|
<DeviceDetails
|
||||||
device={selectedDevice}
|
device={selectedDevice}
|
||||||
|
onQuickJobLaunch={handleQuickJobLaunch}
|
||||||
onBack={() => {
|
onBack={() => {
|
||||||
navigateTo("devices");
|
navigateTo("devices");
|
||||||
setSelectedDevice(null);
|
setSelectedDevice(null);
|
||||||
@@ -1078,8 +1122,19 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
|||||||
return (
|
return (
|
||||||
<CreateJob
|
<CreateJob
|
||||||
initialJob={editingJob}
|
initialJob={editingJob}
|
||||||
onCancel={() => { navigateTo("jobs"); setEditingJob(null); }}
|
quickJobDraft={quickJobDraft}
|
||||||
onCreated={() => { navigateTo("jobs"); setEditingJob(null); setJobsRefreshToken(Date.now()); }}
|
onConsumeQuickJobDraft={handleConsumeQuickJobDraft}
|
||||||
|
onCancel={() => {
|
||||||
|
navigateTo("jobs");
|
||||||
|
setEditingJob(null);
|
||||||
|
setQuickJobDraft(null);
|
||||||
|
}}
|
||||||
|
onCreated={() => {
|
||||||
|
navigateTo("jobs");
|
||||||
|
setEditingJob(null);
|
||||||
|
setJobsRefreshToken(Date.now());
|
||||||
|
setQuickJobDraft(null);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import "prismjs/components/prism-powershell";
|
|||||||
import "prismjs/components/prism-batch";
|
import "prismjs/components/prism-batch";
|
||||||
import "prismjs/themes/prism-okaidia.css";
|
import "prismjs/themes/prism-okaidia.css";
|
||||||
import Editor from "react-simple-code-editor";
|
import Editor from "react-simple-code-editor";
|
||||||
import QuickJob from "../Scheduling/Quick_Job.jsx";
|
|
||||||
import { AgGridReact } from "ag-grid-react";
|
import { AgGridReact } from "ag-grid-react";
|
||||||
import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community";
|
import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community";
|
||||||
|
|
||||||
@@ -248,7 +247,7 @@ const GRID_COMPONENTS = {
|
|||||||
HistoryActionsCell,
|
HistoryActionsCell,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DeviceDetails({ device, onBack }) {
|
export default function DeviceDetails({ device, onBack, onQuickJobLaunch }) {
|
||||||
const [tab, setTab] = useState(0);
|
const [tab, setTab] = useState(0);
|
||||||
const [agent, setAgent] = useState(device || {});
|
const [agent, setAgent] = useState(device || {});
|
||||||
const [details, setDetails] = useState({});
|
const [details, setDetails] = useState({});
|
||||||
@@ -266,7 +265,6 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
const [outputTitle, setOutputTitle] = useState("");
|
const [outputTitle, setOutputTitle] = useState("");
|
||||||
const [outputContent, setOutputContent] = useState("");
|
const [outputContent, setOutputContent] = useState("");
|
||||||
const [outputLang, setOutputLang] = useState("powershell");
|
const [outputLang, setOutputLang] = useState("powershell");
|
||||||
const [quickJobOpen, setQuickJobOpen] = useState(false);
|
|
||||||
const [menuAnchor, setMenuAnchor] = useState(null);
|
const [menuAnchor, setMenuAnchor] = useState(null);
|
||||||
const [clearDialogOpen, setClearDialogOpen] = useState(false);
|
const [clearDialogOpen, setClearDialogOpen] = useState(false);
|
||||||
const [assemblyNameMap, setAssemblyNameMap] = useState({});
|
const [assemblyNameMap, setAssemblyNameMap] = useState({});
|
||||||
@@ -281,6 +279,18 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
const now = Date.now() / 1000;
|
const now = Date.now() / 1000;
|
||||||
return now - tsSec <= 300 ? "Online" : "Offline";
|
return now - tsSec <= 300 ? "Online" : "Offline";
|
||||||
});
|
});
|
||||||
|
const quickJobTargets = useMemo(() => {
|
||||||
|
const values = [];
|
||||||
|
const push = (value) => {
|
||||||
|
const normalized = typeof value === "string" ? value.trim() : "";
|
||||||
|
if (!normalized) return;
|
||||||
|
if (!values.includes(normalized)) values.push(normalized);
|
||||||
|
};
|
||||||
|
push(agent?.hostname);
|
||||||
|
push(device?.hostname);
|
||||||
|
return values;
|
||||||
|
}, [agent, device]);
|
||||||
|
const canLaunchQuickJob = quickJobTargets.length > 0 && typeof onQuickJobLaunch === "function";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setConnectionError("");
|
setConnectionError("");
|
||||||
@@ -1626,11 +1636,11 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
>
|
>
|
||||||
<MoreHorizIcon fontSize="small" />
|
<MoreHorizIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Menu
|
<Menu
|
||||||
anchorEl={menuAnchor}
|
anchorEl={menuAnchor}
|
||||||
open={Boolean(menuAnchor)}
|
open={Boolean(menuAnchor)}
|
||||||
onClose={() => setMenuAnchor(null)}
|
onClose={() => setMenuAnchor(null)}
|
||||||
PaperProps={{
|
PaperProps={{
|
||||||
sx: {
|
sx: {
|
||||||
bgcolor: "rgba(8,12,24,0.96)",
|
bgcolor: "rgba(8,12,24,0.96)",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
@@ -1639,9 +1649,11 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
|
disabled={!canLaunchQuickJob}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMenuAnchor(null);
|
setMenuAnchor(null);
|
||||||
setQuickJobOpen(true);
|
if (!canLaunchQuickJob) return;
|
||||||
|
onQuickJobLaunch && onQuickJobLaunch(quickJobTargets);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Quick Job
|
Quick Job
|
||||||
@@ -1748,13 +1760,6 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{quickJobOpen && (
|
|
||||||
<QuickJob
|
|
||||||
open={quickJobOpen}
|
|
||||||
onClose={() => setQuickJobOpen(false)}
|
|
||||||
hostnames={[agent?.hostname || device?.hostname].filter(Boolean)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import CachedIcon from "@mui/icons-material/Cached";
|
|||||||
import { AgGridReact } from "ag-grid-react";
|
import { AgGridReact } from "ag-grid-react";
|
||||||
import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community";
|
import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community";
|
||||||
import { DeleteDeviceDialog, CreateCustomViewDialog, RenameCustomViewDialog } from "../Dialogs.jsx";
|
import { DeleteDeviceDialog, CreateCustomViewDialog, RenameCustomViewDialog } from "../Dialogs.jsx";
|
||||||
import QuickJob from "../Scheduling/Quick_Job.jsx";
|
|
||||||
import AddDevice from "./Add_Device.jsx";
|
import AddDevice from "./Add_Device.jsx";
|
||||||
|
|
||||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
@@ -339,6 +338,7 @@ function formatUptime(seconds) {
|
|||||||
|
|
||||||
export default function DeviceList({
|
export default function DeviceList({
|
||||||
onSelectDevice,
|
onSelectDevice,
|
||||||
|
onQuickJobLaunch,
|
||||||
filterMode = "all",
|
filterMode = "all",
|
||||||
title,
|
title,
|
||||||
showAddButton,
|
showAddButton,
|
||||||
@@ -351,7 +351,7 @@ export default function DeviceList({
|
|||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
// Track selection by agent id to avoid duplicate hostname collisions
|
// Track selection by agent id to avoid duplicate hostname collisions
|
||||||
const [selectedIds, setSelectedIds] = useState(() => new Set());
|
const [selectedIds, setSelectedIds] = useState(() => new Set());
|
||||||
const [quickJobOpen, setQuickJobOpen] = useState(false);
|
const canLaunchQuickJob = selectedIds.size > 0 && typeof onQuickJobLaunch === "function";
|
||||||
const [addDeviceOpen, setAddDeviceOpen] = useState(false);
|
const [addDeviceOpen, setAddDeviceOpen] = useState(false);
|
||||||
const [addDeviceType, setAddDeviceType] = useState(null);
|
const [addDeviceType, setAddDeviceType] = useState(null);
|
||||||
const computedTitle = useMemo(() => {
|
const computedTitle = useMemo(() => {
|
||||||
@@ -1739,18 +1739,26 @@ export default function DeviceList({
|
|||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
size="small"
|
size="small"
|
||||||
disabled={selectedIds.size === 0}
|
disabled={!canLaunchQuickJob}
|
||||||
disableElevation
|
disableElevation
|
||||||
onClick={() => setQuickJobOpen(true)}
|
onClick={() => {
|
||||||
|
if (!canLaunchQuickJob) return;
|
||||||
|
const hostnames = rows
|
||||||
|
.filter((r) => selectedIds.has(r.id))
|
||||||
|
.map((r) => r.hostname)
|
||||||
|
.filter((hostname) => Boolean(hostname));
|
||||||
|
if (!hostnames.length) return;
|
||||||
|
onQuickJobLaunch(hostnames);
|
||||||
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
px: 2.2,
|
px: 2.2,
|
||||||
textTransform: "none",
|
textTransform: "none",
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
background: selectedIds.size === 0 ? "rgba(148,163,184,0.2)" : "linear-gradient(135deg, #34d399, #22d3ee)",
|
background: canLaunchQuickJob ? "linear-gradient(135deg, #34d399, #22d3ee)" : "rgba(148,163,184,0.2)",
|
||||||
color: selectedIds.size === 0 ? MAGIC_UI.textMuted : "#041224",
|
color: canLaunchQuickJob ? "#041224" : MAGIC_UI.textMuted,
|
||||||
border: selectedIds.size === 0 ? "1px solid rgba(148,163,184,0.35)" : "none",
|
border: canLaunchQuickJob ? "none" : "1px solid rgba(148,163,184,0.35)",
|
||||||
boxShadow: selectedIds.size === 0 ? "none" : "0 0 24px rgba(45, 212, 191, 0.45)",
|
boxShadow: canLaunchQuickJob ? "0 0 24px rgba(45, 212, 191, 0.45)" : "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Quick Job
|
Quick Job
|
||||||
@@ -2090,13 +2098,6 @@ export default function DeviceList({
|
|||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{quickJobOpen && (
|
|
||||||
<QuickJob
|
|
||||||
open={quickJobOpen}
|
|
||||||
onClose={() => setQuickJobOpen(false)}
|
|
||||||
hostnames={rows.filter((r) => selectedIds.has(r.id)).map((r) => r.hostname)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{assignDialogOpen && (
|
{assignDialogOpen && (
|
||||||
<Popover
|
<Popover
|
||||||
open={assignDialogOpen}
|
open={assignDialogOpen}
|
||||||
|
|||||||
@@ -355,7 +355,7 @@ function ComponentCard({ comp, onRemove, onVariableChange, errors = {} }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
export default function CreateJob({ onCancel, onCreated, initialJob = null, quickJobDraft = null, onConsumeQuickJobDraft }) {
|
||||||
const [tab, setTab] = useState(0);
|
const [tab, setTab] = useState(0);
|
||||||
const [jobName, setJobName] = useState("");
|
const [jobName, setJobName] = useState("");
|
||||||
const [pageTitleJobName, setPageTitleJobName] = useState("");
|
const [pageTitleJobName, setPageTitleJobName] = useState("");
|
||||||
@@ -376,6 +376,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
|||||||
const [assembliesLoading, setAssembliesLoading] = useState(false);
|
const [assembliesLoading, setAssembliesLoading] = useState(false);
|
||||||
const [assembliesError, setAssembliesError] = useState("");
|
const [assembliesError, setAssembliesError] = useState("");
|
||||||
const assemblyExportCacheRef = useRef(new Map());
|
const assemblyExportCacheRef = useRef(new Map());
|
||||||
|
const quickDraftAppliedRef = useRef(null);
|
||||||
|
|
||||||
const loadCredentials = useCallback(async () => {
|
const loadCredentials = useCallback(async () => {
|
||||||
setCredentialLoading(true);
|
setCredentialLoading(true);
|
||||||
@@ -508,6 +509,25 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
|||||||
const [selectedTargets, setSelectedTargets] = useState({}); // map hostname->bool
|
const [selectedTargets, setSelectedTargets] = useState({}); // map hostname->bool
|
||||||
const [deviceSearch, setDeviceSearch] = useState("");
|
const [deviceSearch, setDeviceSearch] = useState("");
|
||||||
const [componentVarErrors, setComponentVarErrors] = useState({});
|
const [componentVarErrors, setComponentVarErrors] = useState({});
|
||||||
|
const [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 [deviceRows, setDeviceRows] = useState([]);
|
||||||
const [deviceStatusFilter, setDeviceStatusFilter] = useState(null);
|
const [deviceStatusFilter, setDeviceStatusFilter] = useState(null);
|
||||||
const [deviceOrderBy, setDeviceOrderBy] = useState("hostname");
|
const [deviceOrderBy, setDeviceOrderBy] = useState("hostname");
|
||||||
@@ -917,9 +937,28 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
|||||||
return true;
|
return true;
|
||||||
}, [jobName, components.length, targets.length, scheduleType, startDateTime, remoteExec, selectedCredentialId, execContext, useSvcAccount]);
|
}, [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 [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const editing = !!(initialJob && initialJob.id);
|
const editing = !!(initialJob && initialJob.id);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editing) {
|
||||||
|
quickDraftAppliedRef.current = null;
|
||||||
|
setQuickJobMeta(null);
|
||||||
|
}
|
||||||
|
}, [editing]);
|
||||||
|
|
||||||
// --- Job History (only when editing) ---
|
// --- Job History (only when editing) ---
|
||||||
const [historyRows, setHistoryRows] = useState([]);
|
const [historyRows, setHistoryRows] = useState([]);
|
||||||
const [historyOrderBy, setHistoryOrderBy] = useState("started_ts");
|
const [historyOrderBy, setHistoryOrderBy] = useState("started_ts");
|
||||||
@@ -1550,6 +1589,60 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
|||||||
return base;
|
return base;
|
||||||
}, [editing]);
|
}, [editing]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editing) return;
|
||||||
|
if (!quickJobDraft || !quickJobDraft.id) return;
|
||||||
|
if (quickDraftAppliedRef.current === quickJobDraft.id) return;
|
||||||
|
quickDraftAppliedRef.current = quickJobDraft.id;
|
||||||
|
const uniqueTargets = [];
|
||||||
|
const pushTarget = (value) => {
|
||||||
|
const normalized = typeof value === "string" ? value.trim() : "";
|
||||||
|
if (!normalized) return;
|
||||||
|
if (!uniqueTargets.includes(normalized)) uniqueTargets.push(normalized);
|
||||||
|
};
|
||||||
|
const incoming = Array.isArray(quickJobDraft.hostnames) ? quickJobDraft.hostnames : [];
|
||||||
|
incoming.forEach(pushTarget);
|
||||||
|
setTargets(uniqueTargets);
|
||||||
|
setSelectedTargets({});
|
||||||
|
setComponents([]);
|
||||||
|
setComponentVarErrors({});
|
||||||
|
const normalizedSchedule = String(quickJobDraft.scheduleType || "immediately").trim().toLowerCase() || "immediately";
|
||||||
|
setScheduleType(normalizedSchedule);
|
||||||
|
const placeholderAssembly = (quickJobDraft.placeholderAssemblyLabel || "Choose Assembly").trim() || "Choose Assembly";
|
||||||
|
const deviceLabel = (quickJobDraft.deviceLabel || uniqueTargets[0] || "Selected Device").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]);
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e", overflow: "auto" }} elevation={2}>
|
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e", overflow: "auto" }} elevation={2}>
|
||||||
<Box sx={{ p: 2, pb: 1 }}>
|
<Box sx={{ p: 2, pb: 1 }}>
|
||||||
@@ -1604,7 +1697,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
|||||||
}}
|
}}
|
||||||
placeholder="Example Job Name"
|
placeholder="Example Job Name"
|
||||||
value={jobName}
|
value={jobName}
|
||||||
onChange={(e) => setJobName(e.target.value)}
|
onChange={(e) => handleJobNameInputChange(e.target.value)}
|
||||||
onBlur={(e) => setPageTitleJobName(e.target.value.trim())}
|
onBlur={(e) => setPageTitleJobName(e.target.value.trim())}
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
error={jobName.trim().length === 0}
|
error={jobName.trim().length === 0}
|
||||||
|
|||||||
@@ -1,659 +0,0 @@
|
|||||||
import React, { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogTitle,
|
|
||||||
DialogContent,
|
|
||||||
DialogActions,
|
|
||||||
Button,
|
|
||||||
Box,
|
|
||||||
Typography,
|
|
||||||
Paper,
|
|
||||||
FormControlLabel,
|
|
||||||
Checkbox,
|
|
||||||
TextField,
|
|
||||||
FormControl,
|
|
||||||
InputLabel,
|
|
||||||
Select,
|
|
||||||
MenuItem,
|
|
||||||
CircularProgress,
|
|
||||||
Chip
|
|
||||||
} from "@mui/material";
|
|
||||||
import { Folder as FolderIcon, Description as DescriptionIcon } from "@mui/icons-material";
|
|
||||||
import { SimpleTreeView, TreeItem } from "@mui/x-tree-view";
|
|
||||||
import { DomainBadge } from "../Assemblies/Assembly_Badges";
|
|
||||||
import {
|
|
||||||
buildAssemblyIndex,
|
|
||||||
buildAssemblyTree,
|
|
||||||
normalizeAssemblyPath,
|
|
||||||
parseAssemblyExport
|
|
||||||
} from "../Assemblies/assemblyUtils";
|
|
||||||
|
|
||||||
const DIALOG_SHELL_SX = {
|
|
||||||
backgroundImage: "linear-gradient(120deg,#040711 0%,#0b1222 55%,#020617 100%)",
|
|
||||||
border: "1px solid rgba(148,163,184,0.35)",
|
|
||||||
boxShadow: "0 28px 60px rgba(2,6,12,0.65)",
|
|
||||||
borderRadius: 3,
|
|
||||||
color: "#e2e8f0",
|
|
||||||
overflow: "hidden"
|
|
||||||
};
|
|
||||||
|
|
||||||
const GLASS_PANEL_SX = {
|
|
||||||
backgroundColor: "rgba(15,23,42,0.78)",
|
|
||||||
border: "1px solid rgba(148,163,184,0.35)",
|
|
||||||
borderRadius: 3,
|
|
||||||
boxShadow: "0 16px 40px rgba(2,6,15,0.45)",
|
|
||||||
backdropFilter: "blur(22px)"
|
|
||||||
};
|
|
||||||
|
|
||||||
const PRIMARY_PILL_GRADIENT = "linear-gradient(135deg,#34d399,#22d3ee)";
|
|
||||||
const SECONDARY_PILL_GRADIENT = "linear-gradient(135deg,#7dd3fc,#c084fc)";
|
|
||||||
|
|
||||||
export default function QuickJob({ open, onClose, hostnames = [] }) {
|
|
||||||
const [assemblyPayload, setAssemblyPayload] = useState({ items: [], queue: [] });
|
|
||||||
const [assembliesLoading, setAssembliesLoading] = useState(false);
|
|
||||||
const [assembliesError, setAssembliesError] = useState("");
|
|
||||||
const [selectedAssemblyGuid, setSelectedAssemblyGuid] = useState("");
|
|
||||||
const [running, setRunning] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [runAsCurrentUser, setRunAsCurrentUser] = useState(false);
|
|
||||||
const [mode, setMode] = useState("scripts"); // 'scripts' | 'ansible'
|
|
||||||
const [credentials, setCredentials] = useState([]);
|
|
||||||
const [credentialsLoading, setCredentialsLoading] = useState(false);
|
|
||||||
const [credentialsError, setCredentialsError] = useState("");
|
|
||||||
const [selectedCredentialId, setSelectedCredentialId] = useState("");
|
|
||||||
const [useSvcAccount, setUseSvcAccount] = useState(true);
|
|
||||||
const [variables, setVariables] = useState([]);
|
|
||||||
const [variableValues, setVariableValues] = useState({});
|
|
||||||
const [variableErrors, setVariableErrors] = useState({});
|
|
||||||
const [variableStatus, setVariableStatus] = useState({ loading: false, error: "" });
|
|
||||||
const assemblyExportCacheRef = useRef(new Map());
|
|
||||||
|
|
||||||
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();
|
|
||||||
setAssemblyPayload({
|
|
||||||
items: Array.isArray(data?.items) ? data.items : [],
|
|
||||||
queue: Array.isArray(data?.queue) ? data.queue : []
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load assemblies:", err);
|
|
||||||
setAssemblyPayload({ items: [], queue: [] });
|
|
||||||
setAssembliesError(err?.message || "Failed to load assemblies");
|
|
||||||
} finally {
|
|
||||||
setAssembliesLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const assemblyIndex = useMemo(
|
|
||||||
() => buildAssemblyIndex(assemblyPayload.items, assemblyPayload.queue),
|
|
||||||
[assemblyPayload.items, assemblyPayload.queue]
|
|
||||||
);
|
|
||||||
|
|
||||||
const scriptTreeData = useMemo(
|
|
||||||
() => buildAssemblyTree(assemblyIndex.grouped?.scripts || [], { rootLabel: "Scripts" }),
|
|
||||||
[assemblyIndex]
|
|
||||||
);
|
|
||||||
|
|
||||||
const ansibleTreeData = useMemo(
|
|
||||||
() => buildAssemblyTree(assemblyIndex.grouped?.ansible || [], { rootLabel: "Ansible Playbooks" }),
|
|
||||||
[assemblyIndex]
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedAssembly = useMemo(() => {
|
|
||||||
if (!selectedAssemblyGuid) return null;
|
|
||||||
const guid = selectedAssemblyGuid.toLowerCase();
|
|
||||||
return assemblyIndex.byGuid?.get(guid) || null;
|
|
||||||
}, [selectedAssemblyGuid, 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(() => {
|
|
||||||
if (!open) {
|
|
||||||
setSelectedAssemblyGuid("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedAssemblyGuid("");
|
|
||||||
setError("");
|
|
||||||
setVariables([]);
|
|
||||||
setVariableValues({});
|
|
||||||
setVariableErrors({});
|
|
||||||
setVariableStatus({ loading: false, error: "" });
|
|
||||||
setUseSvcAccount(true);
|
|
||||||
setSelectedCredentialId("");
|
|
||||||
if (!assemblyPayload.items.length && !assembliesLoading) {
|
|
||||||
loadAssemblies();
|
|
||||||
}
|
|
||||||
}, [open, loadAssemblies, assemblyPayload.items.length, assembliesLoading]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
setSelectedAssemblyGuid("");
|
|
||||||
setVariables([]);
|
|
||||||
setVariableValues({});
|
|
||||||
setVariableErrors({});
|
|
||||||
setVariableStatus({ loading: false, error: "" });
|
|
||||||
}, [mode, open]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open || mode !== "ansible") return;
|
|
||||||
let canceled = false;
|
|
||||||
setCredentialsLoading(true);
|
|
||||||
setCredentialsError("");
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch("/api/credentials");
|
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
||||||
const data = await resp.json();
|
|
||||||
if (canceled) return;
|
|
||||||
const list = Array.isArray(data?.credentials)
|
|
||||||
? data.credentials.filter((cred) => {
|
|
||||||
const conn = String(cred.connection_type || "").toLowerCase();
|
|
||||||
return conn === "ssh" || conn === "winrm";
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
list.sort((a, b) => String(a?.name || "").localeCompare(String(b?.name || "")));
|
|
||||||
setCredentials(list);
|
|
||||||
} catch (err) {
|
|
||||||
if (!canceled) {
|
|
||||||
setCredentials([]);
|
|
||||||
setCredentialsError(String(err.message || err));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!canceled) setCredentialsLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
canceled = true;
|
|
||||||
};
|
|
||||||
}, [open, mode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
setSelectedCredentialId("");
|
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (mode !== "ansible" || useSvcAccount) return;
|
|
||||||
if (!credentials.length) {
|
|
||||||
setSelectedCredentialId("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!selectedCredentialId || !credentials.some((cred) => String(cred.id) === String(selectedCredentialId))) {
|
|
||||||
setSelectedCredentialId(String(credentials[0].id));
|
|
||||||
}
|
|
||||||
}, [mode, credentials, selectedCredentialId, useSvcAccount]);
|
|
||||||
|
|
||||||
const renderNodes = (nodes = []) =>
|
|
||||||
nodes.map((n) => (
|
|
||||||
<TreeItem
|
|
||||||
key={n.id}
|
|
||||||
itemId={n.id}
|
|
||||||
label={
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
|
||||||
{n.isFolder ? (
|
|
||||||
<FolderIcon fontSize="small" sx={{ mr: 1, color: "#ccc" }} />
|
|
||||||
) : (
|
|
||||||
<DescriptionIcon fontSize="small" sx={{ mr: 1, color: "#ccc" }} />
|
|
||||||
)}
|
|
||||||
<Typography variant="body2" sx={{ color: "#e6edf3" }}>{n.label}</Typography>
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{n.children && n.children.length ? renderNodes(n.children) : null}
|
|
||||||
</TreeItem>
|
|
||||||
));
|
|
||||||
|
|
||||||
const onItemSelect = useCallback(
|
|
||||||
(_e, itemId) => {
|
|
||||||
const treeData = mode === "ansible" ? ansibleTreeData : scriptTreeData;
|
|
||||||
const node = treeData.map[itemId];
|
|
||||||
if (node && !node.isFolder && node.assemblyGuid) {
|
|
||||||
setSelectedAssemblyGuid(node.assemblyGuid);
|
|
||||||
setError("");
|
|
||||||
setVariableErrors({});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[mode, ansibleTreeData, scriptTreeData]
|
|
||||||
);
|
|
||||||
|
|
||||||
const deriveInitialValue = (variable) => {
|
|
||||||
const { type, default: defaultValue } = variable;
|
|
||||||
if (type === "boolean") {
|
|
||||||
if (typeof defaultValue === "boolean") return defaultValue;
|
|
||||||
if (defaultValue == null) return false;
|
|
||||||
const str = String(defaultValue).trim().toLowerCase();
|
|
||||||
if (!str) return false;
|
|
||||||
return ["true", "1", "yes", "on"].includes(str);
|
|
||||||
}
|
|
||||||
if (type === "number") {
|
|
||||||
if (defaultValue == null || defaultValue === "") return "";
|
|
||||||
if (typeof defaultValue === "number" && Number.isFinite(defaultValue)) {
|
|
||||||
return String(defaultValue);
|
|
||||||
}
|
|
||||||
const parsed = Number(defaultValue);
|
|
||||||
return Number.isFinite(parsed) ? String(parsed) : "";
|
|
||||||
}
|
|
||||||
return defaultValue == null ? "" : String(defaultValue);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedAssemblyGuid) {
|
|
||||||
setVariables([]);
|
|
||||||
setVariableValues({});
|
|
||||||
setVariableErrors({});
|
|
||||||
setVariableStatus({ loading: false, error: "" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let canceled = false;
|
|
||||||
(async () => {
|
|
||||||
setVariableStatus({ loading: true, error: "" });
|
|
||||||
try {
|
|
||||||
const exportDoc = await loadAssemblyExport(selectedAssemblyGuid);
|
|
||||||
if (canceled) return;
|
|
||||||
const parsed = parseAssemblyExport(exportDoc);
|
|
||||||
const defs = Array.isArray(parsed.variables) ? parsed.variables : [];
|
|
||||||
setVariables(defs);
|
|
||||||
const initialValues = {};
|
|
||||||
defs.forEach((v) => {
|
|
||||||
if (!v || !v.name) return;
|
|
||||||
initialValues[v.name] = deriveInitialValue(v);
|
|
||||||
});
|
|
||||||
setVariableValues(initialValues);
|
|
||||||
setVariableErrors({});
|
|
||||||
setVariableStatus({ loading: false, error: "" });
|
|
||||||
} catch (err) {
|
|
||||||
if (canceled) return;
|
|
||||||
setVariables([]);
|
|
||||||
setVariableValues({});
|
|
||||||
setVariableErrors({});
|
|
||||||
setVariableStatus({ loading: false, error: err?.message || String(err) });
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
canceled = true;
|
|
||||||
};
|
|
||||||
}, [selectedAssemblyGuid, loadAssemblyExport]);
|
|
||||||
|
|
||||||
const handleVariableChange = (variable, rawValue) => {
|
|
||||||
const { name, type } = variable;
|
|
||||||
if (!name) return;
|
|
||||||
setVariableValues((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[name]: type === "boolean" ? Boolean(rawValue) : rawValue
|
|
||||||
}));
|
|
||||||
setVariableErrors((prev) => {
|
|
||||||
if (!prev[name]) return prev;
|
|
||||||
const next = { ...prev };
|
|
||||||
delete next[name];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildVariablePayload = () => {
|
|
||||||
const payload = {};
|
|
||||||
variables.forEach((variable) => {
|
|
||||||
if (!variable?.name) return;
|
|
||||||
const { name, type } = variable;
|
|
||||||
const hasOverride = Object.prototype.hasOwnProperty.call(variableValues, name);
|
|
||||||
const raw = hasOverride ? variableValues[name] : deriveInitialValue(variable);
|
|
||||||
if (type === "boolean") {
|
|
||||||
payload[name] = Boolean(raw);
|
|
||||||
} else if (type === "number") {
|
|
||||||
if (raw === "" || raw === null || raw === undefined) {
|
|
||||||
payload[name] = "";
|
|
||||||
} else {
|
|
||||||
const num = Number(raw);
|
|
||||||
payload[name] = Number.isFinite(num) ? num : "";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
payload[name] = raw == null ? "" : String(raw);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return payload;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onRun = async () => {
|
|
||||||
if (!selectedAssembly) {
|
|
||||||
setError(mode === "ansible" ? "Please choose a playbook to run." : "Please choose a script to run.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (mode === "ansible" && !useSvcAccount && !selectedCredentialId) {
|
|
||||||
setError("Select a credential to run this playbook.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (variables.length) {
|
|
||||||
const errors = {};
|
|
||||||
variables.forEach((variable) => {
|
|
||||||
if (!variable) return;
|
|
||||||
if (!variable.required) return;
|
|
||||||
if (variable.type === "boolean") return;
|
|
||||||
const hasOverride = Object.prototype.hasOwnProperty.call(variableValues, variable.name);
|
|
||||||
const raw = hasOverride ? variableValues[variable.name] : deriveInitialValue(variable);
|
|
||||||
if (raw == null || raw === "") {
|
|
||||||
errors[variable.name] = "Required";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (Object.keys(errors).length) {
|
|
||||||
setVariableErrors(errors);
|
|
||||||
setError("Please fill in all required variable values.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setRunning(true);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
let resp;
|
|
||||||
const variableOverrides = buildVariablePayload();
|
|
||||||
const normalizedPath = normalizeAssemblyPath(
|
|
||||||
mode === "ansible" ? "ansible" : "script",
|
|
||||||
selectedAssembly.path || "",
|
|
||||||
selectedAssembly.displayName
|
|
||||||
);
|
|
||||||
if (mode === "ansible") {
|
|
||||||
resp = await fetch("/api/ansible/quick_run", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
playbook_path: normalizedPath,
|
|
||||||
hostnames,
|
|
||||||
variable_values: variableOverrides,
|
|
||||||
credential_id: !useSvcAccount && selectedCredentialId ? Number(selectedCredentialId) : null,
|
|
||||||
use_service_account: Boolean(useSvcAccount)
|
|
||||||
})
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
resp = await fetch("/api/scripts/quick_run", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
script_path: normalizedPath,
|
|
||||||
hostnames,
|
|
||||||
run_mode: runAsCurrentUser ? "current_user" : "system",
|
|
||||||
variable_values: variableOverrides
|
|
||||||
})
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const contentType = String(resp.headers.get("content-type") || "");
|
|
||||||
let data = null;
|
|
||||||
if (contentType.includes("application/json")) {
|
|
||||||
data = await resp.json().catch(() => null);
|
|
||||||
} else {
|
|
||||||
const text = await resp.text().catch(() => "");
|
|
||||||
if (text && text.trim()) {
|
|
||||||
data = { error: text.trim() };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!resp.ok) {
|
|
||||||
const message = data?.error || data?.message || `HTTP ${resp.status}`;
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
onClose && onClose();
|
|
||||||
} catch (err) {
|
|
||||||
setError(String(err.message || err));
|
|
||||||
} finally {
|
|
||||||
setRunning(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const credentialRequired = mode === "ansible" && !useSvcAccount;
|
|
||||||
const disableRun =
|
|
||||||
running ||
|
|
||||||
!selectedAssembly ||
|
|
||||||
(credentialRequired && (!selectedCredentialId || !credentials.length));
|
|
||||||
const activeTreeData = mode === "ansible" ? ansibleTreeData : scriptTreeData;
|
|
||||||
const treeItems = Array.isArray(activeTreeData.root) ? activeTreeData.root : [];
|
|
||||||
const targetCount = hostnames.length;
|
|
||||||
const hostPreview = hostnames.slice(0, 3).join(", ");
|
|
||||||
const remainingHosts = Math.max(targetCount - 3, 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={open}
|
|
||||||
onClose={running ? undefined : onClose}
|
|
||||||
fullWidth
|
|
||||||
maxWidth="lg"
|
|
||||||
PaperProps={{ sx: DIALOG_SHELL_SX }}
|
|
||||||
>
|
|
||||||
<DialogTitle sx={{ pb: 0 }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: { xs: "column", sm: "row" },
|
|
||||||
justifyContent: "space-between",
|
|
||||||
gap: 2
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box>
|
|
||||||
<Typography sx={{ fontWeight: 600, letterSpacing: 0.4 }}>Quick Job</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: "rgba(226,232,240,0.78)" }}>
|
|
||||||
Dispatch {mode === "ansible" ? "playbooks" : "scripts"} through the runner.
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
|
|
||||||
<Paper sx={{ ...GLASS_PANEL_SX, px: 2, py: 1 }}>
|
|
||||||
<Typography variant="caption" sx={{ textTransform: "uppercase", color: "rgba(226,232,240,0.7)", letterSpacing: 1 }}>
|
|
||||||
Targets
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="h6">{hostnames.length || "—"}</Typography>
|
|
||||||
</Paper>
|
|
||||||
<Paper sx={{ ...GLASS_PANEL_SX, px: 2, py: 1 }}>
|
|
||||||
<Typography variant="caption" sx={{ textTransform: "uppercase", color: "rgba(226,232,240,0.7)", letterSpacing: 1 }}>
|
|
||||||
Mode
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="h6">{mode === "ansible" ? "Ansible" : "Script"}</Typography>
|
|
||||||
</Paper>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
|
||||||
<Button size="small" variant={mode === 'scripts' ? 'outlined' : 'text'} onClick={() => setMode('scripts')} sx={{ textTransform: 'none', color: '#58a6ff', borderColor: '#58a6ff' }}>Scripts</Button>
|
|
||||||
<Button size="small" variant={mode === 'ansible' ? 'outlined' : 'text'} onClick={() => setMode('ansible')} sx={{ textTransform: 'none', color: '#58a6ff', borderColor: '#58a6ff' }}>Ansible</Button>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="body2" sx={{ color: "#aaa", mb: 1 }}>
|
|
||||||
Select a {mode === 'ansible' ? 'playbook' : 'script'} to run on {hostnames.length} device{hostnames.length !== 1 ? "s" : ""}.
|
|
||||||
</Typography>
|
|
||||||
{assembliesError ? (
|
|
||||||
<Typography variant="body2" sx={{ color: "#ff8080", mb: 1 }}>{assembliesError}</Typography>
|
|
||||||
) : null}
|
|
||||||
{mode === 'ansible' && (
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap", mb: 2 }}>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={useSvcAccount}
|
|
||||||
onChange={(e) => {
|
|
||||||
const checked = e.target.checked;
|
|
||||||
setUseSvcAccount(checked);
|
|
||||||
if (checked) {
|
|
||||||
setSelectedCredentialId("");
|
|
||||||
} else if (!selectedCredentialId && credentials.length) {
|
|
||||||
setSelectedCredentialId(String(credentials[0].id));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Use Configured svcBorealis Account"
|
|
||||||
sx={{ mr: 2 }}
|
|
||||||
/>
|
|
||||||
<FormControl
|
|
||||||
size="small"
|
|
||||||
sx={{ minWidth: 260 }}
|
|
||||||
disabled={useSvcAccount || credentialsLoading || !credentials.length}
|
|
||||||
>
|
|
||||||
<InputLabel sx={{ color: "#aaa" }}>Credential</InputLabel>
|
|
||||||
<Select
|
|
||||||
value={selectedCredentialId}
|
|
||||||
label="Credential"
|
|
||||||
onChange={(e) => setSelectedCredentialId(e.target.value)}
|
|
||||||
sx={{ bgcolor: "#1f1f1f", color: "#fff" }}
|
|
||||||
>
|
|
||||||
{credentials.map((cred) => {
|
|
||||||
const conn = String(cred.connection_type || "").toUpperCase();
|
|
||||||
return (
|
|
||||||
<MenuItem key={cred.id} value={String(cred.id)}>
|
|
||||||
{cred.name}
|
|
||||||
{conn ? ` (${conn})` : ""}
|
|
||||||
</MenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
{useSvcAccount && (
|
|
||||||
<Typography variant="body2" sx={{ color: "#aaa" }}>
|
|
||||||
Runs with the agent's svcBorealis account.
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
{credentialsLoading && <CircularProgress size={18} sx={{ color: "#58a6ff" }} />}
|
|
||||||
{!credentialsLoading && credentialsError && (
|
|
||||||
<Typography variant="body2" sx={{ color: "#ff8080" }}>{credentialsError}</Typography>
|
|
||||||
)}
|
|
||||||
{!useSvcAccount && !credentialsLoading && !credentialsError && !credentials.length && (
|
|
||||||
<Typography variant="body2" sx={{ color: "#ff8080" }}>
|
|
||||||
No SSH or WinRM credentials available. Create one under Access Management.
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
<Box sx={{ display: "flex", gap: 2 }}>
|
|
||||||
<Paper sx={{ flex: 1, p: 1, bgcolor: "#1e1e1e", maxHeight: 400, overflow: "auto" }}>
|
|
||||||
<SimpleTreeView sx={{ color: "#e6edf3" }} onItemSelectionToggle={onItemSelect}>
|
|
||||||
{assembliesLoading ? (
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, px: 1, py: 0.5, color: "#7db7ff" }}>
|
|
||||||
<CircularProgress size={18} sx={{ color: "#58a6ff" }} />
|
|
||||||
<Typography variant="body2">Loading assemblies…</Typography>
|
|
||||||
</Box>
|
|
||||||
) : treeItems.length ? (
|
|
||||||
renderNodes(treeItems)
|
|
||||||
) : (
|
|
||||||
<Typography variant="body2" sx={{ color: "#888", p: 1 }}>
|
|
||||||
{mode === 'ansible' ? 'No playbooks found.' : 'No scripts found.'}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</SimpleTreeView>
|
|
||||||
</Paper>
|
|
||||||
<Box sx={{ width: 320 }}>
|
|
||||||
<Typography variant="subtitle2" sx={{ color: "#ccc", mb: 1 }}>Selection</Typography>
|
|
||||||
{selectedAssembly ? (
|
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
|
||||||
<Typography variant="body2" sx={{ color: "#e6edf3" }}>{selectedAssembly.displayName}</Typography>
|
|
||||||
<DomainBadge domain={selectedAssembly.domain} size="small" />
|
|
||||||
</Box>
|
|
||||||
<Typography variant="body2" sx={{ color: "#aaa" }}>{selectedAssembly.path}</Typography>
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<Typography variant="body2" sx={{ color: "#888" }}>
|
|
||||||
{mode === 'ansible' ? 'No playbook selected' : 'No script selected'}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
<Box sx={{ mt: 2 }}>
|
|
||||||
{mode !== 'ansible' && (
|
|
||||||
<>
|
|
||||||
<FormControlLabel
|
|
||||||
control={<Checkbox size="small" checked={runAsCurrentUser} onChange={(e) => setRunAsCurrentUser(e.target.checked)} />}
|
|
||||||
label={<Typography variant="body2">Run as currently logged-in user</Typography>}
|
|
||||||
/>
|
|
||||||
<Typography variant="caption" sx={{ color: "#888" }}>
|
|
||||||
Unchecked = Run-As BUILTIN\SYSTEM
|
|
||||||
</Typography>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ mt: 3 }}>
|
|
||||||
<Typography variant="subtitle2" sx={{ color: "#ccc", mb: 1 }}>Variables</Typography>
|
|
||||||
{variableStatus.loading ? (
|
|
||||||
<Typography variant="body2" sx={{ color: "#888" }}>Loading variables…</Typography>
|
|
||||||
) : variableStatus.error ? (
|
|
||||||
<Typography variant="body2" sx={{ color: "#ff4f4f" }}>{variableStatus.error}</Typography>
|
|
||||||
) : variables.length ? (
|
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
|
||||||
{variables.map((variable) => (
|
|
||||||
<Box key={variable.name}>
|
|
||||||
{variable.type === "boolean" ? (
|
|
||||||
<FormControlLabel
|
|
||||||
control={(
|
|
||||||
<Checkbox
|
|
||||||
size="small"
|
|
||||||
checked={Boolean(variableValues[variable.name])}
|
|
||||||
onChange={(e) => handleVariableChange(variable, e.target.checked)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
label={
|
|
||||||
<Typography variant="body2">
|
|
||||||
{variable.label}
|
|
||||||
{variable.required ? " *" : ""}
|
|
||||||
</Typography>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
label={`${variable.label}${variable.required ? " *" : ""}`}
|
|
||||||
type={variable.type === "number" ? "number" : variable.type === "credential" ? "password" : "text"}
|
|
||||||
value={variableValues[variable.name] ?? ""}
|
|
||||||
onChange={(e) => handleVariableChange(variable, e.target.value)}
|
|
||||||
InputLabelProps={{ shrink: true }}
|
|
||||||
sx={{
|
|
||||||
"& .MuiOutlinedInput-root": { bgcolor: "#1b1b1b", color: "#e6edf3" },
|
|
||||||
"& .MuiInputBase-input": { color: "#e6edf3" }
|
|
||||||
}}
|
|
||||||
error={Boolean(variableErrors[variable.name])}
|
|
||||||
helperText={variableErrors[variable.name] || variable.description || ""}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{variable.type === "boolean" && variable.description ? (
|
|
||||||
<Typography variant="caption" sx={{ color: "#888", ml: 3 }}>
|
|
||||||
{variable.description}
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<Typography variant="body2" sx={{ color: "#888" }}>No variables defined for this assembly.</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
{error && (
|
|
||||||
<Typography variant="body2" sx={{ color: "#ff4f4f", mt: 1 }}>{error}</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={onClose} disabled={running} sx={{ color: "#58a6ff" }}>Cancel</Button>
|
|
||||||
<Button onClick={onRun} disabled={disableRun}
|
|
||||||
sx={{ color: disableRun ? "#666" : "#58a6ff" }}
|
|
||||||
>
|
|
||||||
Run
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -87,6 +87,13 @@ const gradientButtonSx = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FILTER_OPTIONS = [
|
||||||
|
{ key: "all", label: "All" },
|
||||||
|
{ key: "immediate", label: "Immediate" },
|
||||||
|
{ key: "recurring", label: "Recurring" },
|
||||||
|
{ key: "completed", label: "Completed" },
|
||||||
|
];
|
||||||
|
|
||||||
function ResultsBar({ counts }) {
|
function ResultsBar({ counts }) {
|
||||||
const total = Math.max(1, Number(counts?.total_targets || 0));
|
const total = Math.max(1, Number(counts?.total_targets || 0));
|
||||||
const sections = [
|
const sections = [
|
||||||
@@ -159,6 +166,7 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
||||||
const [selectedIds, setSelectedIds] = useState(() => new Set());
|
const [selectedIds, setSelectedIds] = useState(() => new Set());
|
||||||
|
const [jobFilterMode, setJobFilterMode] = useState("all");
|
||||||
const [assembliesPayload, setAssembliesPayload] = useState({ items: [], queue: [] });
|
const [assembliesPayload, setAssembliesPayload] = useState({ items: [], queue: [] });
|
||||||
const [assembliesLoading, setAssembliesLoading] = useState(false);
|
const [assembliesLoading, setAssembliesLoading] = useState(false);
|
||||||
const [assembliesError, setAssembliesError] = useState("");
|
const [assembliesError, setAssembliesError] = useState("");
|
||||||
@@ -310,6 +318,17 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken
|
|||||||
if (resultsCounts && resultsCounts.total_targets == null) {
|
if (resultsCounts && resultsCounts.total_targets == null) {
|
||||||
resultsCounts.total_targets = Array.isArray(j.targets) ? j.targets.length : 0;
|
resultsCounts.total_targets = Array.isArray(j.targets) ? j.targets.length : 0;
|
||||||
}
|
}
|
||||||
|
const scheduleRaw = String(j.schedule_type || "").toLowerCase();
|
||||||
|
const isImmediateType = scheduleRaw === "immediately" || scheduleRaw === "once";
|
||||||
|
const hasNextRun = j.next_run_ts != null && Number(j.next_run_ts) > 0;
|
||||||
|
const hasLastRun = j.last_run_ts != null && Number(j.last_run_ts) > 0;
|
||||||
|
const isEnabled = Boolean(j.enabled);
|
||||||
|
const isCompleted = !isEnabled || (!hasNextRun && hasLastRun);
|
||||||
|
const categoryFlags = {
|
||||||
|
immediate: isImmediateType,
|
||||||
|
recurring: !isImmediateType,
|
||||||
|
completed: isCompleted
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
id: j.id,
|
id: j.id,
|
||||||
name: j.name,
|
name: j.name,
|
||||||
@@ -322,6 +341,7 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken
|
|||||||
result: j.last_status || (j.next_run_ts ? "Scheduled" : ""),
|
result: j.last_status || (j.next_run_ts ? "Scheduled" : ""),
|
||||||
resultsCounts,
|
resultsCounts,
|
||||||
enabled: Boolean(j.enabled),
|
enabled: Boolean(j.enabled),
|
||||||
|
categoryFlags,
|
||||||
raw: { ...j, components: normalizedComponents }
|
raw: { ...j, components: normalizedComponents }
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -380,17 +400,36 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken
|
|||||||
gridApiRef.current = params.api;
|
gridApiRef.current = params.api;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const filterCounts = useMemo(() => {
|
||||||
|
const totals = { all: rows.length, immediate: 0, recurring: 0, completed: 0 };
|
||||||
|
rows.forEach((row) => {
|
||||||
|
if (row?.categoryFlags?.immediate) totals.immediate += 1;
|
||||||
|
if (row?.categoryFlags?.recurring) totals.recurring += 1;
|
||||||
|
if (row?.categoryFlags?.completed) totals.completed += 1;
|
||||||
|
});
|
||||||
|
return totals;
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
const filteredRows = useMemo(() => {
|
||||||
|
if (jobFilterMode === "all") return rows;
|
||||||
|
return rows.filter((row) => row?.categoryFlags?.[jobFilterMode]);
|
||||||
|
}, [rows, jobFilterMode]);
|
||||||
|
const activeFilterLabel = useMemo(() => {
|
||||||
|
const match = FILTER_OPTIONS.find((option) => option.key === jobFilterMode);
|
||||||
|
return match ? match.label : jobFilterMode;
|
||||||
|
}, [jobFilterMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const api = gridApiRef.current;
|
const api = gridApiRef.current;
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
if (loading) {
|
if (loading) {
|
||||||
api.showLoadingOverlay();
|
api.showLoadingOverlay();
|
||||||
} else if (!rows.length) {
|
} else if (!filteredRows.length) {
|
||||||
api.showNoRowsOverlay();
|
api.showNoRowsOverlay();
|
||||||
} else {
|
} else {
|
||||||
api.hideOverlay();
|
api.hideOverlay();
|
||||||
}
|
}
|
||||||
}, [loading, rows]);
|
}, [loading, filteredRows]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const api = gridApiRef.current;
|
const api = gridApiRef.current;
|
||||||
@@ -401,7 +440,7 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken
|
|||||||
node.setSelected(shouldSelect);
|
node.setSelected(shouldSelect);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [rows, selectedIds]);
|
}, [filteredRows, selectedIds]);
|
||||||
|
|
||||||
const anySelected = selectedIds.size > 0;
|
const anySelected = selectedIds.size > 0;
|
||||||
|
|
||||||
@@ -659,6 +698,74 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken
|
|||||||
|
|
||||||
{/* Content area — a bit more top space below subtitle */}
|
{/* Content area — a bit more top space below subtitle */}
|
||||||
<Box sx={{ mt: "28px", px: 2, pb: 2, flexGrow: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
<Box sx={{ mt: "28px", px: 2, pb: 2, flexGrow: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||||
|
<Box sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", justifyContent: "space-between", gap: 1.5, mb: 2, px: 0.5 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 0.75,
|
||||||
|
background: "linear-gradient(120deg, rgba(8,12,24,0.92), rgba(4,7,17,0.85))",
|
||||||
|
borderRadius: 999,
|
||||||
|
border: "1px solid rgba(148,163,184,0.35)",
|
||||||
|
boxShadow: "0 18px 48px rgba(2,8,23,0.45)",
|
||||||
|
padding: "4px"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{FILTER_OPTIONS.map((option) => {
|
||||||
|
const active = jobFilterMode === option.key;
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={option.key}
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setJobFilterMode(option.key)}
|
||||||
|
sx={{
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
background: active ? "linear-gradient(135deg,#7dd3fc,#c084fc)" : "transparent",
|
||||||
|
color: active ? "#041224" : "#cbd5e1",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 13,
|
||||||
|
px: 2,
|
||||||
|
py: 0.5,
|
||||||
|
borderRadius: 999,
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 0.6,
|
||||||
|
boxShadow: active ? "0 0 18px rgba(125,211,252,0.35)" : "none",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box component="span" sx={{ userSelect: "none" }}>{option.label}</Box>
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
minWidth: 28,
|
||||||
|
textAlign: "center",
|
||||||
|
borderRadius: 999,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
px: 0.75,
|
||||||
|
py: 0.1,
|
||||||
|
color: active ? "#041224" : "#94a3b8",
|
||||||
|
backgroundColor: active ? "rgba(4,18,36,0.2)" : "rgba(15,23,42,0.65)",
|
||||||
|
border: active ? "1px solid rgba(4,18,36,0.3)" : "1px solid rgba(148,163,184,0.3)"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filterCounts[option.key] ?? 0}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" sx={{ color: AURORA_SHELL.subtext }}>
|
||||||
|
{jobFilterMode === "all"
|
||||||
|
? `Showing ${filterCounts.all || 0} jobs`
|
||||||
|
: `Showing ${filterCounts[jobFilterMode] || 0} ${activeFilterLabel} job${(filterCounts[jobFilterMode] || 0) === 1 ? "" : "s"}`}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
className={themeClassName}
|
className={themeClassName}
|
||||||
sx={{
|
sx={{
|
||||||
@@ -748,7 +855,7 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<AgGridReact
|
<AgGridReact
|
||||||
rowData={rows}
|
rowData={filteredRows}
|
||||||
columnDefs={columnDefs}
|
columnDefs={columnDefs}
|
||||||
defaultColDef={defaultColDef}
|
defaultColDef={defaultColDef}
|
||||||
animateRows
|
animateRows
|
||||||
|
|||||||
Reference in New Issue
Block a user