mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-09-11 03:08:42 -06:00
First Basic Implementation of Remote Script Execution Functionality
This commit is contained in:
57
Data/Server/Python_API_Endpoints/script_engines.py
Normal file
57
Data/Server/Python_API_Endpoints/script_engines.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import platform
|
||||
|
||||
|
||||
def run_powershell_script(script_path: str):
|
||||
"""
|
||||
Execute a PowerShell script with ExecutionPolicy Bypass.
|
||||
|
||||
Returns (returncode, stdout, stderr)
|
||||
"""
|
||||
if not script_path or not os.path.isfile(script_path):
|
||||
raise FileNotFoundError(f"Script not found: {script_path}")
|
||||
|
||||
if not script_path.lower().endswith(".ps1"):
|
||||
raise ValueError("run_powershell_script only accepts .ps1 files")
|
||||
|
||||
system = platform.system()
|
||||
|
||||
# Choose powershell binary
|
||||
ps_bin = None
|
||||
if system == "Windows":
|
||||
# Prefer Windows PowerShell
|
||||
ps_bin = os.path.expandvars(r"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe")
|
||||
if not os.path.isfile(ps_bin):
|
||||
ps_bin = "powershell.exe"
|
||||
else:
|
||||
# PowerShell Core (pwsh) may exist cross-platform
|
||||
ps_bin = "pwsh"
|
||||
|
||||
# Build command
|
||||
# -ExecutionPolicy Bypass (Windows only), -NoProfile, -File "script"
|
||||
cmd = [ps_bin]
|
||||
if system == "Windows":
|
||||
cmd += ["-ExecutionPolicy", "Bypass"]
|
||||
cmd += ["-NoProfile", "-File", script_path]
|
||||
|
||||
# Hide window on Windows
|
||||
creationflags = 0
|
||||
startupinfo = None
|
||||
if system == "Windows":
|
||||
creationflags = 0x08000000 # CREATE_NO_WINDOW
|
||||
startupinfo = subprocess.STARTUPINFO()
|
||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
universal_newlines=True,
|
||||
creationflags=creationflags,
|
||||
startupinfo=startupinfo,
|
||||
)
|
||||
out, err = proc.communicate()
|
||||
return proc.returncode, out or "", err or ""
|
||||
|
@@ -1,6 +1,6 @@
|
||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Device_Details.js
|
||||
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import {
|
||||
Paper,
|
||||
Box,
|
||||
@@ -15,8 +15,19 @@ import {
|
||||
Button,
|
||||
LinearProgress,
|
||||
TableSortLabel,
|
||||
TextField
|
||||
TextField,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions
|
||||
} from "@mui/material";
|
||||
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";
|
||||
|
||||
export default function DeviceDetails({ device, onBack }) {
|
||||
const [tab, setTab] = useState(0);
|
||||
@@ -26,6 +37,13 @@ export default function DeviceDetails({ device, onBack }) {
|
||||
const [softwareOrder, setSoftwareOrder] = useState("asc");
|
||||
const [softwareSearch, setSoftwareSearch] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [historyRows, setHistoryRows] = useState([]);
|
||||
const [historyOrderBy, setHistoryOrderBy] = useState("ran_at");
|
||||
const [historyOrder, setHistoryOrder] = useState("desc");
|
||||
const [outputOpen, setOutputOpen] = useState(false);
|
||||
const [outputTitle, setOutputTitle] = useState("");
|
||||
const [outputContent, setOutputContent] = useState("");
|
||||
const [outputLang, setOutputLang] = useState("powershell");
|
||||
// Snapshotted status for the lifetime of this page
|
||||
const [lockedStatus, setLockedStatus] = useState(() => {
|
||||
// Prefer status provided by the device list row if available
|
||||
@@ -89,6 +107,21 @@ export default function DeviceDetails({ device, onBack }) {
|
||||
load();
|
||||
}, [device]);
|
||||
|
||||
const loadHistory = useCallback(async () => {
|
||||
if (!device?.hostname) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/device/activity/${encodeURIComponent(device.hostname)}`);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
setHistoryRows(data.history || []);
|
||||
} catch (e) {
|
||||
console.warn("Failed to load activity history", e);
|
||||
setHistoryRows([]);
|
||||
}
|
||||
}, [device]);
|
||||
|
||||
useEffect(() => { loadHistory(); }, [loadHistory]);
|
||||
|
||||
const saveDescription = async () => {
|
||||
if (!details.summary?.hostname) return;
|
||||
try {
|
||||
@@ -136,6 +169,20 @@ export default function DeviceDetails({ device, onBack }) {
|
||||
return `${num.toFixed(1)} ${units[i]}`;
|
||||
};
|
||||
|
||||
const formatTimestamp = (epochSec) => {
|
||||
const ts = Number(epochSec || 0);
|
||||
if (!ts) return "unknown";
|
||||
const d = new Date(ts * 1000);
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
const yyyy = d.getFullYear();
|
||||
let hh = d.getHours();
|
||||
const ampm = hh >= 12 ? "PM" : "AM";
|
||||
hh = hh % 12 || 12;
|
||||
const min = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${mm}/${dd}/${yyyy} @ ${hh}:${min} ${ampm}`;
|
||||
};
|
||||
|
||||
const handleSoftwareSort = (col) => {
|
||||
if (softwareOrderBy === col) {
|
||||
setSoftwareOrder(softwareOrder === "asc" ? "desc" : "asc");
|
||||
@@ -162,7 +209,15 @@ export default function DeviceDetails({ device, onBack }) {
|
||||
const summaryItems = [
|
||||
{ label: "Device Name", value: summary.hostname || agent.hostname || device?.hostname || "unknown" },
|
||||
{ label: "Operating System", value: summary.operating_system || agent.agent_operating_system || "unknown" },
|
||||
{ label: "Last User", value: summary.last_user || "unknown" },
|
||||
{ label: "Last User", value: (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box component="span" sx={{
|
||||
display: 'inline-block', width: 10, height: 10, borderRadius: 10,
|
||||
bgcolor: agent?.collector_active ? '#00d18c' : '#ff4f4f'
|
||||
}} />
|
||||
<span>{summary.last_user || 'unknown'}</span>
|
||||
</Box>
|
||||
) },
|
||||
{ label: "Internal IP", value: summary.internal_ip || "unknown" },
|
||||
{ label: "External IP", value: summary.external_ip || "unknown" },
|
||||
{ label: "Last Reboot", value: summary.last_reboot ? formatDateTime(summary.last_reboot) : "unknown" },
|
||||
@@ -469,12 +524,147 @@ export default function DeviceDetails({ device, onBack }) {
|
||||
);
|
||||
};
|
||||
|
||||
const jobStatusColor = (s) => {
|
||||
const val = String(s || "").toLowerCase();
|
||||
if (val === "running") return "#58a6ff"; // borealis blue
|
||||
if (val === "success") return "#00d18c";
|
||||
if (val === "failed") return "#ff4f4f";
|
||||
return "#666";
|
||||
};
|
||||
|
||||
const highlightCode = (code, lang) => {
|
||||
try {
|
||||
return Prism.highlight(code ?? "", Prism.languages[lang] || Prism.languages.markup, lang);
|
||||
} catch {
|
||||
return String(code || "");
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewOutput = async (row, which) => {
|
||||
try {
|
||||
const resp = await fetch(`/api/device/activity/job/${row.id}`);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
const lang = ((data.script_path || "").toLowerCase().endsWith(".ps1")) ? "powershell"
|
||||
: ((data.script_path || "").toLowerCase().endsWith(".bat")) ? "batch"
|
||||
: ((data.script_path || "").toLowerCase().endsWith(".sh")) ? "bash"
|
||||
: ((data.script_path || "").toLowerCase().endsWith(".yml")) ? "yaml" : "powershell";
|
||||
setOutputLang(lang);
|
||||
setOutputTitle(`${which === 'stderr' ? 'StdErr' : 'StdOut'} - ${data.script_name}`);
|
||||
setOutputContent(which === 'stderr' ? (data.stderr || "") : (data.stdout || ""));
|
||||
setOutputOpen(true);
|
||||
} catch (e) {
|
||||
console.warn("Failed to load output", e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHistorySort = (col) => {
|
||||
if (historyOrderBy === col) setHistoryOrder(historyOrder === "asc" ? "desc" : "asc");
|
||||
else {
|
||||
setHistoryOrderBy(col);
|
||||
setHistoryOrder("asc");
|
||||
}
|
||||
};
|
||||
|
||||
const sortedHistory = useMemo(() => {
|
||||
const dir = historyOrder === "asc" ? 1 : -1;
|
||||
return [...historyRows].sort((a, b) => {
|
||||
const A = a[historyOrderBy];
|
||||
const B = b[historyOrderBy];
|
||||
if (historyOrderBy === "ran_at") return ((A || 0) - (B || 0)) * dir;
|
||||
return String(A ?? "").localeCompare(String(B ?? "")) * dir;
|
||||
});
|
||||
}, [historyRows, historyOrderBy, historyOrder]);
|
||||
|
||||
const renderHistory = () => (
|
||||
<Box sx={{ maxHeight: 400, overflowY: "auto" }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sortDirection={historyOrderBy === "script_name" ? historyOrder : false}>
|
||||
<TableSortLabel
|
||||
active={historyOrderBy === "script_name"}
|
||||
direction={historyOrderBy === "script_name" ? historyOrder : "asc"}
|
||||
onClick={() => handleHistorySort("script_name")}
|
||||
>
|
||||
Script Executed
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
<TableCell sortDirection={historyOrderBy === "ran_at" ? historyOrder : false}>
|
||||
<TableSortLabel
|
||||
active={historyOrderBy === "ran_at"}
|
||||
direction={historyOrderBy === "ran_at" ? historyOrder : "asc"}
|
||||
onClick={() => handleHistorySort("ran_at")}
|
||||
>
|
||||
Ran On
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
<TableCell sortDirection={historyOrderBy === "status" ? historyOrder : false}>
|
||||
<TableSortLabel
|
||||
active={historyOrderBy === "status"}
|
||||
direction={historyOrderBy === "status" ? historyOrder : "asc"}
|
||||
onClick={() => handleHistorySort("status")}
|
||||
>
|
||||
Job Status
|
||||
</TableSortLabel>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
StdOut / StdErr
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{sortedHistory.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell>{r.script_name}</TableCell>
|
||||
<TableCell>{formatTimestamp(r.ran_at)}</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{
|
||||
display: "inline-block",
|
||||
px: 1.2,
|
||||
py: 0.25,
|
||||
borderRadius: 999,
|
||||
bgcolor: jobStatusColor(r.status),
|
||||
color: "#000",
|
||||
fontWeight: 600,
|
||||
fontSize: "12px"
|
||||
}}>
|
||||
{r.status}
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: "flex", gap: 1 }}>
|
||||
{r.has_stdout ? (
|
||||
<Button size="small" onClick={() => handleViewOutput(r, 'stdout')} sx={{ color: "#58a6ff", textTransform: "none", minWidth: 0, p: 0 }}>
|
||||
StdOut
|
||||
</Button>
|
||||
) : null}
|
||||
{r.has_stderr ? (
|
||||
<Button size="small" onClick={() => handleViewOutput(r, 'stderr')} sx={{ color: "#ff4f4f", textTransform: "none", minWidth: 0, p: 0 }}>
|
||||
StdErr
|
||||
</Button>
|
||||
) : null}
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{sortedHistory.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} sx={{ color: "#888" }}>No activity yet.</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const tabs = [
|
||||
{ label: "Summary", content: renderSummary() },
|
||||
{ label: "Software", content: renderSoftware() },
|
||||
{ label: "Memory", content: renderMemory() },
|
||||
{ label: "Storage", content: renderStorage() },
|
||||
{ label: "Network", content: renderNetwork() }
|
||||
{ label: "Network", content: renderNetwork() },
|
||||
{ label: "Activity History", content: renderHistory() }
|
||||
];
|
||||
// Use the snapshotted status so it stays static while on this page
|
||||
const status = lockedStatus || statusFromHeartbeat(agent.last_seen || device?.lastSeen);
|
||||
@@ -514,6 +704,32 @@ export default function DeviceDetails({ device, onBack }) {
|
||||
))}
|
||||
</Tabs>
|
||||
<Box sx={{ mt: 2 }}>{tabs[tab].content}</Box>
|
||||
|
||||
<Dialog open={outputOpen} onClose={() => setOutputOpen(false)} fullWidth maxWidth="md"
|
||||
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
|
||||
>
|
||||
<DialogTitle>{outputTitle}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ border: "1px solid #333", borderRadius: 1, bgcolor: "#1e1e1e", maxHeight: 500, overflow: "auto" }}>
|
||||
<Editor
|
||||
value={outputContent}
|
||||
onValueChange={() => {}}
|
||||
highlight={(code) => highlightCode(code, outputLang)}
|
||||
padding={12}
|
||||
style={{
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
fontSize: 12,
|
||||
color: "#e6edf3",
|
||||
minHeight: 200
|
||||
}}
|
||||
textareaProps={{ readOnly: true }}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOutputOpen(false)} sx={{ color: "#58a6ff" }}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
@@ -11,12 +11,15 @@ import {
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableSortLabel,
|
||||
Checkbox,
|
||||
Button,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem
|
||||
} from "@mui/material";
|
||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
import { DeleteDeviceDialog } from "../Dialogs.jsx";
|
||||
import QuickJob from "../Scheduling/Quick_Job.jsx";
|
||||
|
||||
function formatLastSeen(tsSec, offlineAfter = 120) {
|
||||
if (!tsSec) return "unknown";
|
||||
@@ -48,6 +51,8 @@ export default function DeviceList({ onSelectDevice }) {
|
||||
const [menuAnchor, setMenuAnchor] = useState(null);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [selectedHosts, setSelectedHosts] = useState(() => new Set());
|
||||
const [quickJobOpen, setQuickJobOpen] = useState(false);
|
||||
|
||||
const fetchAgents = useCallback(async () => {
|
||||
try {
|
||||
@@ -117,19 +122,65 @@ export default function DeviceList({ onSelectDevice }) {
|
||||
setSelected(null);
|
||||
};
|
||||
|
||||
const isAllChecked = sorted.length > 0 && sorted.every((r) => selectedHosts.has(r.hostname));
|
||||
const isIndeterminate = selectedHosts.size > 0 && !isAllChecked;
|
||||
const toggleAll = (e) => {
|
||||
const checked = e.target.checked;
|
||||
setSelectedHosts((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) {
|
||||
sorted.forEach((r) => next.add(r.hostname));
|
||||
} else {
|
||||
next.clear();
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleOne = (hostname) => (e) => {
|
||||
const checked = e.target.checked;
|
||||
setSelectedHosts((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.add(hostname);
|
||||
else next.delete(hostname);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}>
|
||||
<Box sx={{ p: 2, pb: 1 }}>
|
||||
<Box sx={{ p: 2, pb: 1, display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>
|
||||
Devices
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#aaa" }}>
|
||||
Devices connected to Borealis via Agent and their last check-ins.
|
||||
</Typography>
|
||||
<Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
disabled={selectedHosts.size === 0}
|
||||
onClick={() => setQuickJobOpen(true)}
|
||||
sx={{
|
||||
mr: 1,
|
||||
color: selectedHosts.size === 0 ? "#666" : "#58a6ff",
|
||||
borderColor: selectedHosts.size === 0 ? "#333" : "#58a6ff",
|
||||
textTransform: "none"
|
||||
}}
|
||||
>
|
||||
Quick Job
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
<Table size="small" sx={{ minWidth: 680 }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox
|
||||
indeterminate={isIndeterminate}
|
||||
checked={isAllChecked}
|
||||
onChange={toggleAll}
|
||||
sx={{ color: "#777" }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell sortDirection={orderBy === "status" ? order : false}>
|
||||
<TableSortLabel
|
||||
active={orderBy === "status"}
|
||||
@@ -172,6 +223,13 @@ export default function DeviceList({ onSelectDevice }) {
|
||||
<TableBody>
|
||||
{sorted.map((r, i) => (
|
||||
<TableRow key={r.id || i} hover>
|
||||
<TableCell padding="checkbox" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={selectedHosts.has(r.hostname)}
|
||||
onChange={toggleOne(r.hostname)}
|
||||
sx={{ color: "#777" }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
<Box
|
||||
@@ -218,7 +276,7 @@ export default function DeviceList({ onSelectDevice }) {
|
||||
))}
|
||||
{sorted.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} sx={{ color: "#888" }}>
|
||||
<TableCell colSpan={6} sx={{ color: "#888" }}>
|
||||
No agents connected.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -238,6 +296,14 @@ export default function DeviceList({ onSelectDevice }) {
|
||||
onCancel={() => setConfirmOpen(false)}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
|
||||
{quickJobOpen && (
|
||||
<QuickJob
|
||||
open={quickJobOpen}
|
||||
onClose={() => setQuickJobOpen(false)}
|
||||
hostnames={[...selectedHosts]}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
207
Data/Server/WebUI/src/Scheduling/Quick_Job.jsx
Normal file
207
Data/Server/WebUI/src/Scheduling/Quick_Job.jsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import React, { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
FormControlLabel,
|
||||
Checkbox
|
||||
} from "@mui/material";
|
||||
import { Folder as FolderIcon, Description as DescriptionIcon } from "@mui/icons-material";
|
||||
import { SimpleTreeView, TreeItem } from "@mui/x-tree-view";
|
||||
|
||||
function buildTree(scripts, folders) {
|
||||
const map = {};
|
||||
const rootNode = {
|
||||
id: "root",
|
||||
label: "Scripts",
|
||||
path: "",
|
||||
isFolder: true,
|
||||
children: []
|
||||
};
|
||||
map[rootNode.id] = rootNode;
|
||||
|
||||
(folders || []).forEach((f) => {
|
||||
const parts = (f || "").split("/");
|
||||
let children = rootNode.children;
|
||||
let parentPath = "";
|
||||
parts.forEach((part) => {
|
||||
const path = parentPath ? `${parentPath}/${part}` : part;
|
||||
let node = children.find((n) => n.id === path);
|
||||
if (!node) {
|
||||
node = { id: path, label: part, path, isFolder: true, children: [] };
|
||||
children.push(node);
|
||||
map[path] = node;
|
||||
}
|
||||
children = node.children;
|
||||
parentPath = path;
|
||||
});
|
||||
});
|
||||
|
||||
(scripts || []).forEach((s) => {
|
||||
const parts = (s.rel_path || "").split("/");
|
||||
let children = rootNode.children;
|
||||
let parentPath = "";
|
||||
parts.forEach((part, idx) => {
|
||||
const path = parentPath ? `${parentPath}/${part}` : part;
|
||||
const isFile = idx === parts.length - 1;
|
||||
let node = children.find((n) => n.id === path);
|
||||
if (!node) {
|
||||
node = {
|
||||
id: path,
|
||||
label: isFile ? s.file_name : part,
|
||||
path,
|
||||
isFolder: !isFile,
|
||||
fileName: s.file_name,
|
||||
script: isFile ? s : null,
|
||||
children: []
|
||||
};
|
||||
children.push(node);
|
||||
map[path] = node;
|
||||
}
|
||||
if (!isFile) {
|
||||
children = node.children;
|
||||
parentPath = path;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { root: [rootNode], map };
|
||||
}
|
||||
|
||||
export default function QuickJob({ open, onClose, hostnames = [] }) {
|
||||
const [tree, setTree] = useState([]);
|
||||
const [nodeMap, setNodeMap] = useState({});
|
||||
const [selectedPath, setSelectedPath] = useState("");
|
||||
const [running, setRunning] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [runAsCurrentUser, setRunAsCurrentUser] = useState(false);
|
||||
|
||||
const loadTree = useCallback(async () => {
|
||||
try {
|
||||
const resp = await fetch("/api/scripts/list");
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
const { root, map } = buildTree(data.scripts || [], data.folders || []);
|
||||
setTree(root);
|
||||
setNodeMap(map);
|
||||
} catch (err) {
|
||||
console.error("Failed to load scripts:", err);
|
||||
setTree([]);
|
||||
setNodeMap({});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedPath("");
|
||||
setError("");
|
||||
loadTree();
|
||||
}
|
||||
}, [open, loadTree]);
|
||||
|
||||
const renderNodes = (nodes = []) =>
|
||||
nodes.map((n) => (
|
||||
<TreeItem
|
||||
key={n.id}
|
||||
itemId={n.id}
|
||||
label={
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
{n.isFolder ? (
|
||||
<FolderIcon fontSize="small" sx={{ mr: 1, color: "#ccc" }} />
|
||||
) : (
|
||||
<DescriptionIcon fontSize="small" sx={{ mr: 1, color: "#ccc" }} />
|
||||
)}
|
||||
<Typography variant="body2" sx={{ color: "#e6edf3" }}>{n.label}</Typography>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{n.children && n.children.length ? renderNodes(n.children) : null}
|
||||
</TreeItem>
|
||||
));
|
||||
|
||||
const onItemSelect = (_e, itemId) => {
|
||||
const node = nodeMap[itemId];
|
||||
if (node && !node.isFolder) {
|
||||
setSelectedPath(node.path);
|
||||
setError("");
|
||||
}
|
||||
};
|
||||
|
||||
const onRun = async () => {
|
||||
if (!selectedPath) {
|
||||
setError("Please choose a script to run.");
|
||||
return;
|
||||
}
|
||||
setRunning(true);
|
||||
setError("");
|
||||
try {
|
||||
const resp = await fetch("/api/scripts/quick_run", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ script_path: selectedPath, hostnames, run_mode: runAsCurrentUser ? "current_user" : "system" })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || `HTTP ${resp.status}`);
|
||||
onClose && onClose();
|
||||
} catch (err) {
|
||||
setError(String(err.message || err));
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={running ? undefined : onClose} fullWidth maxWidth="md"
|
||||
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
|
||||
>
|
||||
<DialogTitle>Quick Job</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" sx={{ color: "#aaa", mb: 1 }}>
|
||||
Select a script to run on {hostnames.length} device{hostnames.length !== 1 ? "s" : ""}.
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
<Paper sx={{ flex: 1, p: 1, bgcolor: "#1e1e1e", maxHeight: 400, overflow: "auto" }}>
|
||||
<SimpleTreeView sx={{ color: "#e6edf3" }} onItemSelectionToggle={onItemSelect}>
|
||||
{tree.length ? renderNodes(tree) : (
|
||||
<Typography variant="body2" sx={{ color: "#888", p: 1 }}>
|
||||
No scripts found.
|
||||
</Typography>
|
||||
)}
|
||||
</SimpleTreeView>
|
||||
</Paper>
|
||||
<Box sx={{ width: 320 }}>
|
||||
<Typography variant="subtitle2" sx={{ color: "#ccc", mb: 1 }}>Selection</Typography>
|
||||
<Typography variant="body2" sx={{ color: selectedPath ? "#e6edf3" : "#888" }}>
|
||||
{selectedPath || "No script selected"}
|
||||
</Typography>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<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 SYSTEM (requires agent service)
|
||||
</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={running || !selectedPath}
|
||||
sx={{ color: running || !selectedPath ? "#666" : "#58a6ff" }}
|
||||
>
|
||||
Run
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
@@ -19,6 +19,7 @@ import io
|
||||
|
||||
# Borealis Python API Endpoints
|
||||
from Python_API_Endpoints.ocr_engines import run_ocr_on_base64
|
||||
from Python_API_Endpoints.script_engines import run_powershell_script
|
||||
|
||||
# ---------------------------------------------
|
||||
# Flask + WebSocket Server Configuration
|
||||
@@ -659,6 +660,22 @@ def init_db():
|
||||
cur.execute(
|
||||
"CREATE TABLE IF NOT EXISTS device_details (hostname TEXT PRIMARY KEY, description TEXT, details TEXT)"
|
||||
)
|
||||
# Activity history table for script/job runs
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS activity_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
hostname TEXT,
|
||||
script_path TEXT,
|
||||
script_name TEXT,
|
||||
script_type TEXT,
|
||||
ran_at INTEGER,
|
||||
status TEXT,
|
||||
stdout TEXT,
|
||||
stderr TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -738,10 +755,20 @@ load_agents_from_db()
|
||||
|
||||
@app.route("/api/agents")
|
||||
def get_agents():
|
||||
"""
|
||||
Return a dict keyed by agent_id with hostname, os, last_seen, status.
|
||||
"""
|
||||
return jsonify(registered_agents)
|
||||
"""Return agents with collector activity indicator."""
|
||||
now = time.time()
|
||||
out = {}
|
||||
for aid, info in (registered_agents or {}).items():
|
||||
# Hide script-execution agents from the public list
|
||||
if aid and isinstance(aid, str) and aid.lower().endswith('-script'):
|
||||
continue
|
||||
if info.get('is_script_agent'):
|
||||
continue
|
||||
d = dict(info)
|
||||
ts = d.get('collector_active_ts') or 0
|
||||
d['collector_active'] = bool(ts and (now - float(ts) < 130))
|
||||
out[aid] = d
|
||||
return jsonify(out)
|
||||
|
||||
|
||||
@app.route("/api/agent/details", methods=["POST"])
|
||||
@@ -840,6 +867,230 @@ def set_device_description(hostname: str):
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
# ---------------------------------------------
|
||||
# Quick Job Execution + Activity History
|
||||
# ---------------------------------------------
|
||||
def _detect_script_type(fn: str) -> str:
|
||||
fn = (fn or "").lower()
|
||||
if fn.endswith(".yml"):
|
||||
return "ansible"
|
||||
if fn.endswith(".ps1"):
|
||||
return "powershell"
|
||||
if fn.endswith(".bat"):
|
||||
return "batch"
|
||||
if fn.endswith(".sh"):
|
||||
return "bash"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _safe_filename(rel_path: str) -> str:
|
||||
try:
|
||||
return os.path.basename(rel_path or "")
|
||||
except Exception:
|
||||
return rel_path or ""
|
||||
|
||||
|
||||
@app.route("/api/scripts/quick_run", methods=["POST"])
|
||||
def scripts_quick_run():
|
||||
"""Queue a Quick Job to agents via WebSocket and record Running status.
|
||||
|
||||
Payload: { script_path: str, hostnames: [str], run_mode?: 'current_user'|'admin'|'system', admin_user?, admin_pass? }
|
||||
"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
rel_path = (data.get("script_path") or "").strip()
|
||||
hostnames = data.get("hostnames") or []
|
||||
run_mode = (data.get("run_mode") or "system").strip().lower()
|
||||
admin_user = ""
|
||||
admin_pass = ""
|
||||
|
||||
if not rel_path or not isinstance(hostnames, list) or not hostnames:
|
||||
return jsonify({"error": "Missing script_path or hostnames[]"}), 400
|
||||
|
||||
scripts_root = _scripts_root()
|
||||
abs_path = os.path.abspath(os.path.join(scripts_root, rel_path))
|
||||
if not abs_path.startswith(scripts_root) or not os.path.isfile(abs_path):
|
||||
return jsonify({"error": "Script not found"}), 404
|
||||
|
||||
script_type = _detect_script_type(abs_path)
|
||||
if script_type != "powershell":
|
||||
return jsonify({"error": f"Unsupported script type '{script_type}'. Only powershell is supported for Quick Job currently."}), 400
|
||||
|
||||
try:
|
||||
with open(abs_path, "r", encoding="utf-8", errors="replace") as fh:
|
||||
content = fh.read()
|
||||
except Exception as e:
|
||||
return jsonify({"error": f"Failed to read script: {e}"}), 500
|
||||
|
||||
now = int(time.time())
|
||||
results = []
|
||||
for host in hostnames:
|
||||
job_id = None
|
||||
try:
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO activity_history(hostname, script_path, script_name, script_type, ran_at, status, stdout, stderr)
|
||||
VALUES(?,?,?,?,?,?,?,?)
|
||||
""",
|
||||
(
|
||||
host,
|
||||
rel_path.replace(os.sep, "/"),
|
||||
_safe_filename(rel_path),
|
||||
script_type,
|
||||
now,
|
||||
"Running",
|
||||
"",
|
||||
"",
|
||||
),
|
||||
)
|
||||
job_id = cur.lastrowid
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception as db_err:
|
||||
return jsonify({"error": f"DB insert failed: {db_err}"}), 500
|
||||
|
||||
payload = {
|
||||
"job_id": job_id,
|
||||
"target_hostname": host,
|
||||
"script_type": script_type,
|
||||
"script_name": _safe_filename(rel_path),
|
||||
"script_path": rel_path.replace(os.sep, "/"),
|
||||
"script_content": content,
|
||||
"run_mode": run_mode,
|
||||
"admin_user": admin_user,
|
||||
"admin_pass": admin_pass,
|
||||
}
|
||||
# Broadcast to all connected clients; no broadcast kw in python-socketio v5
|
||||
socketio.emit("quick_job_run", payload)
|
||||
results.append({"hostname": host, "job_id": job_id, "status": "Running"})
|
||||
|
||||
return jsonify({"results": results})
|
||||
|
||||
|
||||
@app.route("/api/device/activity/<hostname>", methods=["GET"])
|
||||
def device_activity(hostname: str):
|
||||
try:
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT id, script_name, script_path, script_type, ran_at, status, LENGTH(stdout), LENGTH(stderr) FROM activity_history WHERE hostname = ? ORDER BY ran_at DESC, id DESC",
|
||||
(hostname,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
conn.close()
|
||||
out = []
|
||||
for (jid, name, path, stype, ran_at, status, so_len, se_len) in rows:
|
||||
out.append({
|
||||
"id": jid,
|
||||
"script_name": name,
|
||||
"script_path": path,
|
||||
"script_type": stype,
|
||||
"ran_at": ran_at,
|
||||
"status": status,
|
||||
"has_stdout": bool(so_len or 0),
|
||||
"has_stderr": bool(se_len or 0),
|
||||
})
|
||||
return jsonify({"history": out})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/device/activity/job/<int:job_id>", methods=["GET"])
|
||||
def device_activity_job(job_id: int):
|
||||
try:
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT id, hostname, script_name, script_path, script_type, ran_at, status, stdout, stderr FROM activity_history WHERE id = ?",
|
||||
(job_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
if not row:
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
(jid, hostname, name, path, stype, ran_at, status, stdout, stderr) = row
|
||||
return jsonify({
|
||||
"id": jid,
|
||||
"hostname": hostname,
|
||||
"script_name": name,
|
||||
"script_path": path,
|
||||
"script_type": stype,
|
||||
"ran_at": ran_at,
|
||||
"status": status,
|
||||
"stdout": stdout or "",
|
||||
"stderr": stderr or "",
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@socketio.on("quick_job_result")
|
||||
def handle_quick_job_result(data):
|
||||
"""Agent reports back stdout/stderr/status for a job."""
|
||||
try:
|
||||
job_id = int(data.get("job_id"))
|
||||
except Exception:
|
||||
return
|
||||
status = (data.get("status") or "").strip() or "Failed"
|
||||
stdout = data.get("stdout") or ""
|
||||
stderr = data.get("stderr") or ""
|
||||
try:
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"UPDATE activity_history SET status=?, stdout=?, stderr=? WHERE id=?",
|
||||
(status, stdout, stderr, job_id),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f"[ERROR] quick_job_result DB update failed for job {job_id}: {e}")
|
||||
|
||||
|
||||
@socketio.on("collector_status")
|
||||
def handle_collector_status(data):
|
||||
"""Collector agent reports activity and optional last_user."""
|
||||
agent_id = (data or {}).get('agent_id')
|
||||
hostname = (data or {}).get('hostname')
|
||||
active = bool((data or {}).get('active'))
|
||||
last_user = (data or {}).get('last_user')
|
||||
if not agent_id:
|
||||
return
|
||||
rec = registered_agents.setdefault(agent_id, {})
|
||||
rec['agent_id'] = agent_id
|
||||
if hostname:
|
||||
rec['hostname'] = hostname
|
||||
if active:
|
||||
rec['collector_active_ts'] = time.time()
|
||||
if last_user and (hostname or rec.get('hostname')):
|
||||
try:
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT details, description FROM device_details WHERE hostname = ?",
|
||||
(hostname or rec.get('hostname'),),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
details = {}
|
||||
if row and row[0]:
|
||||
try:
|
||||
details = json.loads(row[0])
|
||||
except Exception:
|
||||
details = {}
|
||||
summary = details.get('summary') or {}
|
||||
summary['last_user'] = last_user
|
||||
details['summary'] = summary
|
||||
cur.execute(
|
||||
"REPLACE INTO device_details (hostname, description, details) VALUES (?, COALESCE((SELECT description FROM device_details WHERE hostname=?), ''), ?)",
|
||||
((hostname or rec.get('hostname')), (hostname or rec.get('hostname')), json.dumps(details))
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@app.route("/api/agent/<agent_id>", methods=["DELETE"])
|
||||
def delete_agent(agent_id: str):
|
||||
"""Remove an agent from the registry and database."""
|
||||
@@ -979,7 +1230,8 @@ def receive_screenshot_task(data):
|
||||
"timestamp": time.time()
|
||||
}
|
||||
|
||||
emit("agent_screenshot_task", data, broadcast=True)
|
||||
# Relay to all connected clients; use server-level emit
|
||||
socketio.emit("agent_screenshot_task", data)
|
||||
|
||||
@socketio.on("connect_agent")
|
||||
def connect_agent(data):
|
||||
@@ -998,6 +1250,12 @@ def connect_agent(data):
|
||||
rec["agent_operating_system"] = rec.get("agent_operating_system", "-")
|
||||
rec["last_seen"] = int(time.time())
|
||||
rec["status"] = "provisioned" if agent_id in agent_configurations else "orphaned"
|
||||
# Flag script agents so they can be filtered out elsewhere if desired
|
||||
try:
|
||||
if isinstance(agent_id, str) and agent_id.lower().endswith('-script'):
|
||||
rec['is_script_agent'] = True
|
||||
except Exception:
|
||||
pass
|
||||
# If we already know the hostname for this agent, persist last_seen so it
|
||||
# can be restored after server restarts.
|
||||
try:
|
||||
@@ -1056,7 +1314,8 @@ def receive_screenshot(data):
|
||||
"image_base64": image,
|
||||
"timestamp": time.time()
|
||||
}
|
||||
emit("new_screenshot", {"agent_id": agent_id, "image_base64": image}, broadcast=True)
|
||||
# Broadcast to all clients; use server-level emit
|
||||
socketio.emit("new_screenshot", {"agent_id": agent_id, "image_base64": image})
|
||||
|
||||
@socketio.on("disconnect")
|
||||
def on_disconnect():
|
||||
@@ -1076,21 +1335,24 @@ def receive_macro_status(data):
|
||||
}
|
||||
"""
|
||||
print(f"[Macro Status] Agent {data.get('agent_id')} Node {data.get('node_id')} Success: {data.get('success')} Msg: {data.get('message')}")
|
||||
emit("macro_status", data, broadcast=True)
|
||||
# Broadcast to all; use server-level emit for v5 API
|
||||
socketio.emit("macro_status", data)
|
||||
|
||||
@socketio.on("list_agent_windows")
|
||||
def handle_list_agent_windows(data):
|
||||
"""
|
||||
Forwards list_agent_windows event to all agents (or filter for a specific agent_id).
|
||||
"""
|
||||
emit("list_agent_windows", data, broadcast=True)
|
||||
# Forward to all agents/clients
|
||||
socketio.emit("list_agent_windows", data)
|
||||
|
||||
@socketio.on("agent_window_list")
|
||||
def handle_agent_window_list(data):
|
||||
"""
|
||||
Relay the list of windows from the agent back to all connected clients.
|
||||
"""
|
||||
emit("agent_window_list", data, broadcast=True)
|
||||
# Relay the list to all interested clients
|
||||
socketio.emit("agent_window_list", data)
|
||||
|
||||
# ---------------------------------------------
|
||||
# Server Launch
|
||||
|
Reference in New Issue
Block a user