diff --git a/Data/Engine/web-interface/src/App.jsx b/Data/Engine/web-interface/src/App.jsx index 4305e53f..b2caeeca 100644 --- a/Data/Engine/web-interface/src/App.jsx +++ b/Data/Engine/web-interface/src/App.jsx @@ -115,10 +115,12 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state"; const [userDisplayName, setUserDisplayName] = useState(null); const [editingJob, setEditingJob] = useState(null); const [jobsRefreshToken, setJobsRefreshToken] = useState(0); + const [quickJobDraft, setQuickJobDraft] = useState(null); const [assemblyEditorState, setAssemblyEditorState] = useState(null); // { mode: 'script'|'ansible', row, nonce } const [sessionResolved, setSessionResolved] = useState(false); const initialPathRef = useRef(window.location.pathname + window.location.search); const pendingPathRef = useRef(null); + const quickJobSeedRef = useRef(0); const [notAuthorizedOpen, setNotAuthorizedOpen] = useState(false); // Top-bar search state @@ -380,6 +382,45 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state"; navigateByPathRef.current = 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 const breadcrumbs = React.useMemo(() => { const items = []; @@ -1039,6 +1080,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state"; onSelectDevice={(d) => { navigateTo("device_details", { device: d }); }} + onQuickJobLaunch={handleQuickJobLaunch} /> ); case "agent_devices": @@ -1047,17 +1089,19 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state"; onSelectDevice={(d) => { navigateTo("device_details", { device: d }); }} + onQuickJobLaunch={handleQuickJobLaunch} /> ); case "ssh_devices": - return ; + return ; case "winrm_devices": - return ; + return ; case "device_details": return ( { navigateTo("devices"); setSelectedDevice(null); @@ -1078,8 +1122,19 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state"; return ( { navigateTo("jobs"); setEditingJob(null); }} - onCreated={() => { navigateTo("jobs"); setEditingJob(null); setJobsRefreshToken(Date.now()); }} + quickJobDraft={quickJobDraft} + onConsumeQuickJobDraft={handleConsumeQuickJobDraft} + onCancel={() => { + navigateTo("jobs"); + setEditingJob(null); + setQuickJobDraft(null); + }} + onCreated={() => { + navigateTo("jobs"); + setEditingJob(null); + setJobsRefreshToken(Date.now()); + setQuickJobDraft(null); + }} /> ); diff --git a/Data/Engine/web-interface/src/Devices/Device_Details.jsx b/Data/Engine/web-interface/src/Devices/Device_Details.jsx index b2cc4b5b..1e0c38e7 100644 --- a/Data/Engine/web-interface/src/Devices/Device_Details.jsx +++ b/Data/Engine/web-interface/src/Devices/Device_Details.jsx @@ -29,7 +29,6 @@ import "prismjs/components/prism-powershell"; import "prismjs/components/prism-batch"; import "prismjs/themes/prism-okaidia.css"; import Editor from "react-simple-code-editor"; -import QuickJob from "../Scheduling/Quick_Job.jsx"; import { AgGridReact } from "ag-grid-react"; import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community"; @@ -248,7 +247,7 @@ const GRID_COMPONENTS = { HistoryActionsCell, }; -export default function DeviceDetails({ device, onBack }) { +export default function DeviceDetails({ device, onBack, onQuickJobLaunch }) { const [tab, setTab] = useState(0); const [agent, setAgent] = useState(device || {}); const [details, setDetails] = useState({}); @@ -266,7 +265,6 @@ export default function DeviceDetails({ device, onBack }) { const [outputTitle, setOutputTitle] = useState(""); const [outputContent, setOutputContent] = useState(""); const [outputLang, setOutputLang] = useState("powershell"); - const [quickJobOpen, setQuickJobOpen] = useState(false); const [menuAnchor, setMenuAnchor] = useState(null); const [clearDialogOpen, setClearDialogOpen] = useState(false); const [assemblyNameMap, setAssemblyNameMap] = useState({}); @@ -281,6 +279,18 @@ export default function DeviceDetails({ device, onBack }) { const now = Date.now() / 1000; 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(() => { setConnectionError(""); @@ -1626,11 +1636,11 @@ export default function DeviceDetails({ device, onBack }) { > - setMenuAnchor(null)} - PaperProps={{ + setMenuAnchor(null)} + PaperProps={{ sx: { bgcolor: "rgba(8,12,24,0.96)", color: "#fff", @@ -1639,9 +1649,11 @@ export default function DeviceDetails({ device, onBack }) { }} > { setMenuAnchor(null); - setQuickJobOpen(true); + if (!canLaunchQuickJob) return; + onQuickJobLaunch && onQuickJobLaunch(quickJobTargets); }} > Quick Job @@ -1748,13 +1760,6 @@ export default function DeviceDetails({ device, onBack }) { }} /> - {quickJobOpen && ( - setQuickJobOpen(false)} - hostnames={[agent?.hostname || device?.hostname].filter(Boolean)} - /> - )} ); } diff --git a/Data/Engine/web-interface/src/Devices/Device_List.jsx b/Data/Engine/web-interface/src/Devices/Device_List.jsx index 202c758d..587de520 100644 --- a/Data/Engine/web-interface/src/Devices/Device_List.jsx +++ b/Data/Engine/web-interface/src/Devices/Device_List.jsx @@ -21,7 +21,6 @@ import CachedIcon from "@mui/icons-material/Cached"; import { AgGridReact } from "ag-grid-react"; import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community"; import { DeleteDeviceDialog, CreateCustomViewDialog, RenameCustomViewDialog } from "../Dialogs.jsx"; -import QuickJob from "../Scheduling/Quick_Job.jsx"; import AddDevice from "./Add_Device.jsx"; ModuleRegistry.registerModules([AllCommunityModule]); @@ -339,6 +338,7 @@ function formatUptime(seconds) { export default function DeviceList({ onSelectDevice, + onQuickJobLaunch, filterMode = "all", title, showAddButton, @@ -351,7 +351,7 @@ export default function DeviceList({ const [confirmOpen, setConfirmOpen] = useState(false); // Track selection by agent id to avoid duplicate hostname collisions 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 [addDeviceType, setAddDeviceType] = useState(null); const computedTitle = useMemo(() => { @@ -1739,18 +1739,26 @@ export default function DeviceList({ - - - - Select a {mode === 'ansible' ? 'playbook' : 'script'} to run on {hostnames.length} device{hostnames.length !== 1 ? "s" : ""}. - - {assembliesError ? ( - {assembliesError} - ) : null} - {mode === 'ansible' && ( - - { - 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 }} - /> - - Credential - - - {useSvcAccount && ( - - Runs with the agent's svcBorealis account. - - )} - {credentialsLoading && } - {!credentialsLoading && credentialsError && ( - {credentialsError} - )} - {!useSvcAccount && !credentialsLoading && !credentialsError && !credentials.length && ( - - No SSH or WinRM credentials available. Create one under Access Management. - - )} - - )} - - - - {assembliesLoading ? ( - - - Loading assemblies… - - ) : treeItems.length ? ( - renderNodes(treeItems) - ) : ( - - {mode === 'ansible' ? 'No playbooks found.' : 'No scripts found.'} - - )} - - - - Selection - {selectedAssembly ? ( - - - {selectedAssembly.displayName} - - - {selectedAssembly.path} - - ) : ( - - {mode === 'ansible' ? 'No playbook selected' : 'No script selected'} - - )} - - {mode !== 'ansible' && ( - <> - setRunAsCurrentUser(e.target.checked)} />} - label={Run as currently logged-in user} - /> - - Unchecked = Run-As BUILTIN\SYSTEM - - - )} - - - Variables - {variableStatus.loading ? ( - Loading variables… - ) : variableStatus.error ? ( - {variableStatus.error} - ) : variables.length ? ( - - {variables.map((variable) => ( - - {variable.type === "boolean" ? ( - handleVariableChange(variable, e.target.checked)} - /> - )} - label={ - - {variable.label} - {variable.required ? " *" : ""} - - } - /> - ) : ( - 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 ? ( - - {variable.description} - - ) : null} - - ))} - - ) : ( - No variables defined for this assembly. - )} - - {error && ( - {error} - )} - - - - - - - - - ); -} diff --git a/Data/Engine/web-interface/src/Scheduling/Scheduled_Jobs_List.jsx b/Data/Engine/web-interface/src/Scheduling/Scheduled_Jobs_List.jsx index 8e8b7803..b727f3f8 100644 --- a/Data/Engine/web-interface/src/Scheduling/Scheduled_Jobs_List.jsx +++ b/Data/Engine/web-interface/src/Scheduling/Scheduled_Jobs_List.jsx @@ -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 }) { const total = Math.max(1, Number(counts?.total_targets || 0)); const sections = [ @@ -159,6 +166,7 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken const [error, setError] = useState(""); const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); const [selectedIds, setSelectedIds] = useState(() => new Set()); + const [jobFilterMode, setJobFilterMode] = useState("all"); const [assembliesPayload, setAssembliesPayload] = useState({ items: [], queue: [] }); const [assembliesLoading, setAssembliesLoading] = useState(false); const [assembliesError, setAssembliesError] = useState(""); @@ -310,6 +318,17 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken if (resultsCounts && resultsCounts.total_targets == null) { 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 { id: j.id, name: j.name, @@ -322,6 +341,7 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken result: j.last_status || (j.next_run_ts ? "Scheduled" : ""), resultsCounts, enabled: Boolean(j.enabled), + categoryFlags, raw: { ...j, components: normalizedComponents } }; }); @@ -380,17 +400,36 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken 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(() => { const api = gridApiRef.current; if (!api) return; if (loading) { api.showLoadingOverlay(); - } else if (!rows.length) { + } else if (!filteredRows.length) { api.showNoRowsOverlay(); } else { api.hideOverlay(); } - }, [loading, rows]); + }, [loading, filteredRows]); useEffect(() => { const api = gridApiRef.current; @@ -401,7 +440,7 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken node.setSelected(shouldSelect); } }); - }, [rows, selectedIds]); + }, [filteredRows, selectedIds]); 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 */} + + + {FILTER_OPTIONS.map((option) => { + const active = jobFilterMode === option.key; + return ( + 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", + }} + > + {option.label} + + {filterCounts[option.key] ?? 0} + + + ); + })} + + + {jobFilterMode === "all" + ? `Showing ${filterCounts.all || 0} jobs` + : `Showing ${filterCounts[jobFilterMode] || 0} ${activeFilterLabel} job${(filterCounts[jobFilterMode] || 0) === 1 ? "" : "s"}`} + + +