mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-27 00:01:58 -06:00
Fleshing-Out Implementation of Credential Management for Ansible Playbooks
This commit is contained in:
549
Data/Server/WebUI/src/Access_Management/Credential_Editor.jsx
Normal file
549
Data/Server/WebUI/src/Access_Management/Credential_Editor.jsx
Normal file
@@ -0,0 +1,549 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
IconButton,
|
||||||
|
Tooltip,
|
||||||
|
CircularProgress
|
||||||
|
} from "@mui/material";
|
||||||
|
import UploadIcon from "@mui/icons-material/UploadFile";
|
||||||
|
import ClearIcon from "@mui/icons-material/Clear";
|
||||||
|
|
||||||
|
const CREDENTIAL_TYPES = [
|
||||||
|
{ value: "machine", label: "Machine" },
|
||||||
|
{ value: "domain", label: "Domain" },
|
||||||
|
{ value: "token", label: "Token" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const CONNECTION_TYPES = [
|
||||||
|
{ value: "ssh", label: "SSH" },
|
||||||
|
{ value: "winrm", label: "WinRM" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const BECOME_METHODS = [
|
||||||
|
{ value: "", label: "None" },
|
||||||
|
{ value: "sudo", label: "sudo" },
|
||||||
|
{ value: "su", label: "su" },
|
||||||
|
{ value: "runas", label: "runas" },
|
||||||
|
{ value: "enable", label: "enable" }
|
||||||
|
];
|
||||||
|
|
||||||
|
function emptyForm() {
|
||||||
|
return {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
site_id: "",
|
||||||
|
credential_type: "machine",
|
||||||
|
connection_type: "ssh",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
private_key: "",
|
||||||
|
private_key_passphrase: "",
|
||||||
|
become_method: "",
|
||||||
|
become_username: "",
|
||||||
|
become_password: ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSiteId(value) {
|
||||||
|
if (value === null || typeof value === "undefined" || value === "") return "";
|
||||||
|
const num = Number(value);
|
||||||
|
if (Number.isNaN(num)) return "";
|
||||||
|
return String(num);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CredentialEditor({
|
||||||
|
open,
|
||||||
|
mode = "create",
|
||||||
|
credential,
|
||||||
|
onClose,
|
||||||
|
onSaved
|
||||||
|
}) {
|
||||||
|
const isEdit = mode === "edit" && credential && credential.id;
|
||||||
|
const [form, setForm] = useState(emptyForm);
|
||||||
|
const [sites, setSites] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [passwordDirty, setPasswordDirty] = useState(false);
|
||||||
|
const [privateKeyDirty, setPrivateKeyDirty] = useState(false);
|
||||||
|
const [passphraseDirty, setPassphraseDirty] = useState(false);
|
||||||
|
const [becomePasswordDirty, setBecomePasswordDirty] = useState(false);
|
||||||
|
const [clearPassword, setClearPassword] = useState(false);
|
||||||
|
const [clearPrivateKey, setClearPrivateKey] = useState(false);
|
||||||
|
const [clearPassphrase, setClearPassphrase] = useState(false);
|
||||||
|
const [clearBecomePassword, setClearBecomePassword] = useState(false);
|
||||||
|
const [fetchingDetail, setFetchingDetail] = useState(false);
|
||||||
|
|
||||||
|
const credentialId = credential?.id;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
let canceled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/api/sites");
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const data = await resp.json();
|
||||||
|
if (canceled) return;
|
||||||
|
const parsed = Array.isArray(data?.sites)
|
||||||
|
? data.sites
|
||||||
|
.filter((s) => s && s.id)
|
||||||
|
.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name || `Site ${s.id}`
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
parsed.sort((a, b) => String(a.name || "").localeCompare(String(b.name || "")));
|
||||||
|
setSites(parsed);
|
||||||
|
} catch {
|
||||||
|
if (!canceled) setSites([]);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
canceled = true;
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setError("");
|
||||||
|
setPasswordDirty(false);
|
||||||
|
setPrivateKeyDirty(false);
|
||||||
|
setPassphraseDirty(false);
|
||||||
|
setBecomePasswordDirty(false);
|
||||||
|
setClearPassword(false);
|
||||||
|
setClearPrivateKey(false);
|
||||||
|
setClearPassphrase(false);
|
||||||
|
setClearBecomePassword(false);
|
||||||
|
if (isEdit && credentialId) {
|
||||||
|
const applyData = (detail) => {
|
||||||
|
const next = emptyForm();
|
||||||
|
next.name = detail?.name || "";
|
||||||
|
next.description = detail?.description || "";
|
||||||
|
next.site_id = normalizeSiteId(detail?.site_id);
|
||||||
|
next.credential_type = (detail?.credential_type || "machine").toLowerCase();
|
||||||
|
next.connection_type = (detail?.connection_type || "ssh").toLowerCase();
|
||||||
|
next.username = detail?.username || "";
|
||||||
|
next.become_method = (detail?.become_method || "").toLowerCase();
|
||||||
|
next.become_username = detail?.become_username || "";
|
||||||
|
setForm(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (credential?.name) {
|
||||||
|
applyData(credential);
|
||||||
|
} else {
|
||||||
|
setFetchingDetail(true);
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/credentials/${credentialId}`);
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = await resp.json();
|
||||||
|
applyData(data?.credential || {});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
} finally {
|
||||||
|
setFetchingDetail(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setForm(emptyForm());
|
||||||
|
}
|
||||||
|
}, [open, isEdit, credentialId, credential]);
|
||||||
|
|
||||||
|
const currentCredentialFlags = useMemo(() => ({
|
||||||
|
hasPassword: Boolean(credential?.has_password),
|
||||||
|
hasPrivateKey: Boolean(credential?.has_private_key),
|
||||||
|
hasPrivateKeyPassphrase: Boolean(credential?.has_private_key_passphrase),
|
||||||
|
hasBecomePassword: Boolean(credential?.has_become_password)
|
||||||
|
}), [credential]);
|
||||||
|
|
||||||
|
const disableSave = loading || fetchingDetail;
|
||||||
|
|
||||||
|
const updateField = (key) => (event) => {
|
||||||
|
const value = event?.target?.value ?? "";
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
if (key === "password") {
|
||||||
|
setPasswordDirty(true);
|
||||||
|
setClearPassword(false);
|
||||||
|
} else if (key === "private_key") {
|
||||||
|
setPrivateKeyDirty(true);
|
||||||
|
setClearPrivateKey(false);
|
||||||
|
} else if (key === "private_key_passphrase") {
|
||||||
|
setPassphraseDirty(true);
|
||||||
|
setClearPassphrase(false);
|
||||||
|
} else if (key === "become_password") {
|
||||||
|
setBecomePasswordDirty(true);
|
||||||
|
setClearBecomePassword(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrivateKeyUpload = async (event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
setForm((prev) => ({ ...prev, private_key: text }));
|
||||||
|
setPrivateKeyDirty(true);
|
||||||
|
setClearPrivateKey(false);
|
||||||
|
} catch {
|
||||||
|
setError("Unable to read private key file.");
|
||||||
|
} finally {
|
||||||
|
event.target.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (loading) return;
|
||||||
|
onClose && onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
setError("Credential name is required.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setError("");
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPayload = () => {
|
||||||
|
const payload = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
description: form.description.trim(),
|
||||||
|
credential_type: (form.credential_type || "machine").toLowerCase(),
|
||||||
|
connection_type: (form.connection_type || "ssh").toLowerCase(),
|
||||||
|
username: form.username.trim(),
|
||||||
|
become_method: form.become_method.trim(),
|
||||||
|
become_username: form.become_username.trim()
|
||||||
|
};
|
||||||
|
const siteId = normalizeSiteId(form.site_id);
|
||||||
|
if (siteId) {
|
||||||
|
payload.site_id = Number(siteId);
|
||||||
|
} else {
|
||||||
|
payload.site_id = null;
|
||||||
|
}
|
||||||
|
if (passwordDirty) {
|
||||||
|
payload.password = form.password;
|
||||||
|
}
|
||||||
|
if (privateKeyDirty) {
|
||||||
|
payload.private_key = form.private_key;
|
||||||
|
}
|
||||||
|
if (passphraseDirty) {
|
||||||
|
payload.private_key_passphrase = form.private_key_passphrase;
|
||||||
|
}
|
||||||
|
if (becomePasswordDirty) {
|
||||||
|
payload.become_password = form.become_password;
|
||||||
|
}
|
||||||
|
if (clearPassword) payload.clear_password = true;
|
||||||
|
if (clearPrivateKey) payload.clear_private_key = true;
|
||||||
|
if (clearPassphrase) payload.clear_private_key_passphrase = true;
|
||||||
|
if (clearBecomePassword) payload.clear_become_password = true;
|
||||||
|
return payload;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!validate()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
const payload = buildPayload();
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
isEdit ? `/api/credentials/${credentialId}` : "/api/credentials",
|
||||||
|
{
|
||||||
|
method: isEdit ? "PUT" : "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(data?.error || `Request failed (${resp.status})`);
|
||||||
|
}
|
||||||
|
onSaved && onSaved(data?.credential || null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err.message || err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const title = isEdit ? "Edit Credential" : "Create Credential";
|
||||||
|
const helperStyle = { fontSize: 12, color: "#8a8a8a", mt: 0.5 };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={handleCancel}
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
|
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{ pb: 1 }}>{title}</DialogTitle>
|
||||||
|
<DialogContent dividers sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
|
{fetchingDetail && (
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1, color: "#aaa" }}>
|
||||||
|
<CircularProgress size={18} sx={{ color: "#58a6ff" }} />
|
||||||
|
<Typography variant="body2">Loading credential details…</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<Box sx={{ bgcolor: "#2c1c1c", border: "1px solid #663939", borderRadius: 1, p: 1 }}>
|
||||||
|
<Typography variant="body2" sx={{ color: "#ff8080" }}>{error}</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<TextField
|
||||||
|
label="Name"
|
||||||
|
value={form.name}
|
||||||
|
onChange={updateField("name")}
|
||||||
|
required
|
||||||
|
disabled={disableSave}
|
||||||
|
sx={{
|
||||||
|
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
|
||||||
|
"& label": { color: "#888" }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Description"
|
||||||
|
value={form.description}
|
||||||
|
onChange={updateField("description")}
|
||||||
|
disabled={disableSave}
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
sx={{
|
||||||
|
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
|
||||||
|
"& label": { color: "#888" }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 2 }}>
|
||||||
|
<FormControl sx={{ minWidth: 220 }} size="small" disabled={disableSave}>
|
||||||
|
<InputLabel sx={{ color: "#aaa" }}>Site</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={form.site_id}
|
||||||
|
label="Site"
|
||||||
|
onChange={updateField("site_id")}
|
||||||
|
sx={{ bgcolor: "#1f1f1f", color: "#fff" }}
|
||||||
|
>
|
||||||
|
<MenuItem value="">(None)</MenuItem>
|
||||||
|
{sites.map((site) => (
|
||||||
|
<MenuItem key={site.id} value={String(site.id)}>
|
||||||
|
{site.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl sx={{ minWidth: 180 }} size="small" disabled={disableSave}>
|
||||||
|
<InputLabel sx={{ color: "#aaa" }}>Credential Type</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={form.credential_type}
|
||||||
|
label="Credential Type"
|
||||||
|
onChange={updateField("credential_type")}
|
||||||
|
sx={{ bgcolor: "#1f1f1f", color: "#fff" }}
|
||||||
|
>
|
||||||
|
{CREDENTIAL_TYPES.map((opt) => (
|
||||||
|
<MenuItem key={opt.value} value={opt.value}>{opt.label}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl sx={{ minWidth: 180 }} size="small" disabled={disableSave}>
|
||||||
|
<InputLabel sx={{ color: "#aaa" }}>Connection</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={form.connection_type}
|
||||||
|
label="Connection"
|
||||||
|
onChange={updateField("connection_type")}
|
||||||
|
sx={{ bgcolor: "#1f1f1f", color: "#fff" }}
|
||||||
|
>
|
||||||
|
{CONNECTION_TYPES.map((opt) => (
|
||||||
|
<MenuItem key={opt.value} value={opt.value}>{opt.label}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Box>
|
||||||
|
<TextField
|
||||||
|
label="Username"
|
||||||
|
value={form.username}
|
||||||
|
onChange={updateField("username")}
|
||||||
|
disabled={disableSave}
|
||||||
|
sx={{
|
||||||
|
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
|
||||||
|
"& label": { color: "#888" }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<TextField
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={updateField("password")}
|
||||||
|
disabled={disableSave}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
|
||||||
|
"& label": { color: "#888" }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{isEdit && currentCredentialFlags.hasPassword && !passwordDirty && !clearPassword && (
|
||||||
|
<Tooltip title="Clear stored password">
|
||||||
|
<IconButton size="small" onClick={() => setClearPassword(true)} sx={{ color: "#ff8080" }}>
|
||||||
|
<ClearIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{isEdit && currentCredentialFlags.hasPassword && !passwordDirty && !clearPassword && (
|
||||||
|
<Typography sx={helperStyle}>Stored password will remain unless you change or clear it.</Typography>
|
||||||
|
)}
|
||||||
|
{clearPassword && (
|
||||||
|
<Typography sx={{ ...helperStyle, color: "#ffaaaa" }}>Password will be removed when saving.</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", gap: 1, alignItems: "flex-start" }}>
|
||||||
|
<TextField
|
||||||
|
label="SSH Private Key"
|
||||||
|
value={form.private_key}
|
||||||
|
onChange={updateField("private_key")}
|
||||||
|
disabled={disableSave}
|
||||||
|
multiline
|
||||||
|
minRows={4}
|
||||||
|
maxRows={12}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff", fontFamily: "monospace" },
|
||||||
|
"& label": { color: "#888" }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
component="label"
|
||||||
|
startIcon={<UploadIcon />}
|
||||||
|
disabled={disableSave}
|
||||||
|
sx={{ alignSelf: "center", borderColor: "#58a6ff", color: "#58a6ff" }}
|
||||||
|
>
|
||||||
|
Upload
|
||||||
|
<input type="file" hidden accept=".pem,.key,.txt" onChange={handlePrivateKeyUpload} />
|
||||||
|
</Button>
|
||||||
|
{isEdit && currentCredentialFlags.hasPrivateKey && !privateKeyDirty && !clearPrivateKey && (
|
||||||
|
<Tooltip title="Clear stored private key">
|
||||||
|
<IconButton size="small" onClick={() => setClearPrivateKey(true)} sx={{ color: "#ff8080" }}>
|
||||||
|
<ClearIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{isEdit && currentCredentialFlags.hasPrivateKey && !privateKeyDirty && !clearPrivateKey && (
|
||||||
|
<Typography sx={helperStyle}>Private key is stored. Upload or paste a new one to replace, or clear it.</Typography>
|
||||||
|
)}
|
||||||
|
{clearPrivateKey && (
|
||||||
|
<Typography sx={{ ...helperStyle, color: "#ffaaaa" }}>Private key will be removed when saving.</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<TextField
|
||||||
|
label="Private Key Passphrase"
|
||||||
|
type="password"
|
||||||
|
value={form.private_key_passphrase}
|
||||||
|
onChange={updateField("private_key_passphrase")}
|
||||||
|
disabled={disableSave}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
|
||||||
|
"& label": { color: "#888" }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{isEdit && currentCredentialFlags.hasPrivateKeyPassphrase && !passphraseDirty && !clearPassphrase && (
|
||||||
|
<Tooltip title="Clear stored passphrase">
|
||||||
|
<IconButton size="small" onClick={() => setClearPassphrase(true)} sx={{ color: "#ff8080" }}>
|
||||||
|
<ClearIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{isEdit && currentCredentialFlags.hasPrivateKeyPassphrase && !passphraseDirty && !clearPassphrase && (
|
||||||
|
<Typography sx={helperStyle}>A passphrase is stored for this key.</Typography>
|
||||||
|
)}
|
||||||
|
{clearPassphrase && (
|
||||||
|
<Typography sx={{ ...helperStyle, color: "#ffaaaa" }}>Key passphrase will be removed when saving.</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
|
||||||
|
<FormControl sx={{ minWidth: 180 }} size="small" disabled={disableSave}>
|
||||||
|
<InputLabel sx={{ color: "#aaa" }}>Privilege Escalation</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={form.become_method}
|
||||||
|
label="Privilege Escalation"
|
||||||
|
onChange={updateField("become_method")}
|
||||||
|
sx={{ bgcolor: "#1f1f1f", color: "#fff" }}
|
||||||
|
>
|
||||||
|
{BECOME_METHODS.map((opt) => (
|
||||||
|
<MenuItem key={opt.value || "none"} value={opt.value}>{opt.label}</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<TextField
|
||||||
|
label="Escalation Username"
|
||||||
|
value={form.become_username}
|
||||||
|
onChange={updateField("become_username")}
|
||||||
|
disabled={disableSave}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 200,
|
||||||
|
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
|
||||||
|
"& label": { color: "#888" }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
|
<TextField
|
||||||
|
label="Escalation Password"
|
||||||
|
type="password"
|
||||||
|
value={form.become_password}
|
||||||
|
onChange={updateField("become_password")}
|
||||||
|
disabled={disableSave}
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
|
||||||
|
"& label": { color: "#888" }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{isEdit && currentCredentialFlags.hasBecomePassword && !becomePasswordDirty && !clearBecomePassword && (
|
||||||
|
<Tooltip title="Clear stored escalation password">
|
||||||
|
<IconButton size="small" onClick={() => setClearBecomePassword(true)} sx={{ color: "#ff8080" }}>
|
||||||
|
<ClearIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{isEdit && currentCredentialFlags.hasBecomePassword && !becomePasswordDirty && !clearBecomePassword && (
|
||||||
|
<Typography sx={helperStyle}>Escalation password is stored.</Typography>
|
||||||
|
)}
|
||||||
|
{clearBecomePassword && (
|
||||||
|
<Typography sx={{ ...helperStyle, color: "#ffaaaa" }}>Escalation password will be removed when saving.</Typography>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||||
|
<Button onClick={handleCancel} sx={{ color: "#58a6ff" }} disabled={loading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ color: "#58a6ff", borderColor: "#58a6ff" }}
|
||||||
|
disabled={disableSave}
|
||||||
|
>
|
||||||
|
{loading ? <CircularProgress size={18} sx={{ color: "#58a6ff" }} /> : "Save"}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
320
Data/Server/WebUI/src/Access_Management/Credential_List.jsx
Normal file
320
Data/Server/WebUI/src/Access_Management/Credential_List.jsx
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
IconButton,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableSortLabel,
|
||||||
|
Typography,
|
||||||
|
CircularProgress
|
||||||
|
} from "@mui/material";
|
||||||
|
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||||
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
|
import RefreshIcon from "@mui/icons-material/Refresh";
|
||||||
|
import LockIcon from "@mui/icons-material/Lock";
|
||||||
|
import WifiIcon from "@mui/icons-material/Wifi";
|
||||||
|
import ComputerIcon from "@mui/icons-material/Computer";
|
||||||
|
import CredentialEditor from "./Credential_Editor.jsx";
|
||||||
|
import { ConfirmDeleteDialog } from "../Dialogs.jsx";
|
||||||
|
|
||||||
|
const tablePaperSx = { m: 2, p: 0, bgcolor: "#1e1e1e", borderRadius: 2 };
|
||||||
|
const tableSx = {
|
||||||
|
minWidth: 840,
|
||||||
|
"& th, & td": {
|
||||||
|
color: "#ddd",
|
||||||
|
borderColor: "#2a2a2a",
|
||||||
|
fontSize: 13,
|
||||||
|
py: 0.9
|
||||||
|
},
|
||||||
|
"& th .MuiTableSortLabel-root": { color: "#ddd" },
|
||||||
|
"& th .MuiTableSortLabel-root.Mui-active": { color: "#ddd" }
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ id: "name", label: "Name" },
|
||||||
|
{ id: "credential_type", label: "Credential Type" },
|
||||||
|
{ id: "connection_type", label: "Connection" },
|
||||||
|
{ id: "site_name", label: "Site" },
|
||||||
|
{ id: "username", label: "Username" },
|
||||||
|
{ id: "updated_at", label: "Updated" },
|
||||||
|
{ id: "actions", label: "" }
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatTs(ts) {
|
||||||
|
if (!ts) return "-";
|
||||||
|
const date = new Date(Number(ts) * 1000);
|
||||||
|
if (Number.isNaN(date?.getTime())) return "-";
|
||||||
|
return `${date.toLocaleDateString()} ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleCase(value) {
|
||||||
|
if (!value) return "-";
|
||||||
|
const lower = String(value).toLowerCase();
|
||||||
|
return lower.replace(/(^|\s)\w/g, (c) => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectionIcon(connection) {
|
||||||
|
const val = (connection || "").toLowerCase();
|
||||||
|
if (val === "ssh") return <LockIcon fontSize="small" sx={{ mr: 0.6, color: "#58a6ff" }} />;
|
||||||
|
if (val === "winrm") return <WifiIcon fontSize="small" sx={{ mr: 0.6, color: "#58a6ff" }} />;
|
||||||
|
return <ComputerIcon fontSize="small" sx={{ mr: 0.6, color: "#58a6ff" }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CredentialList({ isAdmin = false }) {
|
||||||
|
const [rows, setRows] = useState([]);
|
||||||
|
const [orderBy, setOrderBy] = useState("name");
|
||||||
|
const [order, setOrder] = useState("asc");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [menuAnchor, setMenuAnchor] = useState(null);
|
||||||
|
const [menuRow, setMenuRow] = useState(null);
|
||||||
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
|
const [editorMode, setEditorMode] = useState("create");
|
||||||
|
const [editingCredential, setEditingCredential] = useState(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||||
|
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||||
|
|
||||||
|
const sortedRows = useMemo(() => {
|
||||||
|
const sorted = [...rows];
|
||||||
|
sorted.sort((a, b) => {
|
||||||
|
const aVal = (a?.[orderBy] ?? "").toString().toLowerCase();
|
||||||
|
const bVal = (b?.[orderBy] ?? "").toString().toLowerCase();
|
||||||
|
if (aVal < bVal) return order === "asc" ? -1 : 1;
|
||||||
|
if (aVal > bVal) return order === "asc" ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
return sorted;
|
||||||
|
}, [rows, order, orderBy]);
|
||||||
|
|
||||||
|
const fetchCredentials = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/api/credentials");
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
const list = Array.isArray(data?.credentials) ? data.credentials : [];
|
||||||
|
list.sort((a, b) => String(a?.name || "").localeCompare(String(b?.name || "")));
|
||||||
|
setRows(list);
|
||||||
|
} catch (err) {
|
||||||
|
setRows([]);
|
||||||
|
setError(String(err.message || err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCredentials();
|
||||||
|
}, [fetchCredentials]);
|
||||||
|
|
||||||
|
const handleSort = (columnId) => () => {
|
||||||
|
if (orderBy === columnId) {
|
||||||
|
setOrder((prev) => (prev === "asc" ? "desc" : "asc"));
|
||||||
|
} else {
|
||||||
|
setOrderBy(columnId);
|
||||||
|
setOrder("asc");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openMenu = (event, row) => {
|
||||||
|
setMenuAnchor(event.currentTarget);
|
||||||
|
setMenuRow(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeMenu = () => {
|
||||||
|
setMenuAnchor(null);
|
||||||
|
setMenuRow(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
setEditorMode("create");
|
||||||
|
setEditingCredential(null);
|
||||||
|
setEditorOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (row) => {
|
||||||
|
closeMenu();
|
||||||
|
setEditorMode("edit");
|
||||||
|
setEditingCredential(row);
|
||||||
|
setEditorOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (row) => {
|
||||||
|
closeMenu();
|
||||||
|
setDeleteTarget(row);
|
||||||
|
};
|
||||||
|
|
||||||
|
const doDelete = async () => {
|
||||||
|
if (!deleteTarget?.id) return;
|
||||||
|
setDeleteBusy(true);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/credentials/${deleteTarget.id}`, { method: "DELETE" });
|
||||||
|
if (!resp.ok) {
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(data?.error || `HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
setDeleteTarget(null);
|
||||||
|
await fetchCredentials();
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err.message || err));
|
||||||
|
} finally {
|
||||||
|
setDeleteBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditorSaved = async () => {
|
||||||
|
setEditorOpen(false);
|
||||||
|
setEditingCredential(null);
|
||||||
|
await fetchCredentials();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return (
|
||||||
|
<Paper sx={{ m: 2, p: 3, bgcolor: "#1e1e1e" }}>
|
||||||
|
<Typography variant="h6" sx={{ color: "#ff8080" }}>
|
||||||
|
Access denied
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "#bbb" }}>
|
||||||
|
You do not have permission to manage credentials.
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Paper sx={tablePaperSx} elevation={3}>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", p: 2, borderBottom: "1px solid #2a2a2a" }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0.3 }}>
|
||||||
|
Credentials
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "#aaa" }}>
|
||||||
|
Stored credentials for remote automation tasks and Ansible playbook runs.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: "flex", gap: 1 }}>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
startIcon={<RefreshIcon />}
|
||||||
|
sx={{ borderColor: "#58a6ff", color: "#58a6ff" }}
|
||||||
|
onClick={fetchCredentials}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
sx={{ bgcolor: "#58a6ff", color: "#0b0f19" }}
|
||||||
|
onClick={handleCreate}
|
||||||
|
>
|
||||||
|
New Credential
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{loading && (
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1, color: "#7db7ff", px: 2, py: 1.5 }}>
|
||||||
|
<CircularProgress size={18} sx={{ color: "#58a6ff" }} />
|
||||||
|
<Typography variant="body2">Loading credentials…</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<Box sx={{ px: 2, py: 1.5, color: "#ff8080" }}>
|
||||||
|
<Typography variant="body2">{error}</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Table size="small" sx={tableSx}>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<TableCell key={col.id} align={col.id === "actions" ? "right" : "left"}>
|
||||||
|
{col.id === "actions" ? null : (
|
||||||
|
<TableSortLabel
|
||||||
|
active={orderBy === col.id}
|
||||||
|
direction={orderBy === col.id ? order : "asc"}
|
||||||
|
onClick={handleSort(col.id)}
|
||||||
|
>
|
||||||
|
{col.label}
|
||||||
|
</TableSortLabel>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{!sortedRows.length && !loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={columns.length} sx={{ color: "#888", textAlign: "center", py: 4 }}>
|
||||||
|
No credentials have been created yet.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
sortedRows.map((row) => (
|
||||||
|
<TableRow key={row.id} hover>
|
||||||
|
<TableCell>{row.name || "-"}</TableCell>
|
||||||
|
<TableCell>{titleCase(row.credential_type)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||||
|
{connectionIcon(row.connection_type)}
|
||||||
|
{titleCase(row.connection_type)}
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{row.site_name || "-"}</TableCell>
|
||||||
|
<TableCell>{row.username || "-"}</TableCell>
|
||||||
|
<TableCell>{formatTs(row.updated_at || row.created_at)}</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<IconButton size="small" onClick={(e) => openMenu(e, row)} sx={{ color: "#7db7ff" }}>
|
||||||
|
<MoreVertIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Menu anchorEl={menuAnchor} open={Boolean(menuAnchor)} onClose={closeMenu} elevation={2} PaperProps={{ sx: { bgcolor: "#1f1f1f", color: "#f5f5f5" } }}>
|
||||||
|
<MenuItem onClick={() => handleEdit(menuRow)}>Edit</MenuItem>
|
||||||
|
<MenuItem onClick={() => handleDelete(menuRow)} sx={{ color: "#ff8080" }}>
|
||||||
|
Delete
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
<CredentialEditor
|
||||||
|
open={editorOpen}
|
||||||
|
mode={editorMode}
|
||||||
|
credential={editingCredential}
|
||||||
|
onClose={() => {
|
||||||
|
setEditorOpen(false);
|
||||||
|
setEditingCredential(null);
|
||||||
|
}}
|
||||||
|
onSaved={handleEditorSaved}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open={Boolean(deleteTarget)}
|
||||||
|
onCancel={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={doDelete}
|
||||||
|
confirmDisabled={deleteBusy}
|
||||||
|
message={
|
||||||
|
deleteTarget
|
||||||
|
? `Delete credential '${deleteTarget.name || ""}'? Any jobs referencing it will require an update.`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -39,7 +39,8 @@ import AssemblyList from "./Assemblies/Assembly_List";
|
|||||||
import AssemblyEditor from "./Assemblies/Assembly_Editor";
|
import AssemblyEditor from "./Assemblies/Assembly_Editor";
|
||||||
import ScheduledJobsList from "./Scheduling/Scheduled_Jobs_List";
|
import ScheduledJobsList from "./Scheduling/Scheduled_Jobs_List";
|
||||||
import CreateJob from "./Scheduling/Create_Job.jsx";
|
import CreateJob from "./Scheduling/Create_Job.jsx";
|
||||||
import UserManagement from "./Admin/User_Management.jsx";
|
import CredentialList from "./Access_Management/Credential_List.jsx";
|
||||||
|
import UserManagement from "./Access_Management/Users.jsx";
|
||||||
import ServerInfo from "./Admin/Server_Info.jsx";
|
import ServerInfo from "./Admin/Server_Info.jsx";
|
||||||
|
|
||||||
// Networking Imports
|
// Networking Imports
|
||||||
@@ -201,12 +202,16 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
|||||||
items.push({ label: "Automation", page: "jobs" });
|
items.push({ label: "Automation", page: "jobs" });
|
||||||
items.push({ label: "Community Content", page: "community" });
|
items.push({ label: "Community Content", page: "community" });
|
||||||
break;
|
break;
|
||||||
case "admin_users":
|
case "access_credentials":
|
||||||
items.push({ label: "Admin Settings", page: "admin_users" });
|
items.push({ label: "Access Management", page: "access_credentials" });
|
||||||
items.push({ label: "User Management", page: "admin_users" });
|
items.push({ label: "Credentials", page: "access_credentials" });
|
||||||
|
break;
|
||||||
|
case "access_users":
|
||||||
|
items.push({ label: "Access Management", page: "access_credentials" });
|
||||||
|
items.push({ label: "Users", page: "access_users" });
|
||||||
break;
|
break;
|
||||||
case "server_info":
|
case "server_info":
|
||||||
items.push({ label: "Admin Settings", page: "admin_users" });
|
items.push({ label: "Admin Settings" });
|
||||||
items.push({ label: "Server Info", page: "server_info" });
|
items.push({ label: "Server Info", page: "server_info" });
|
||||||
break;
|
break;
|
||||||
case "filters":
|
case "filters":
|
||||||
@@ -551,7 +556,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
|||||||
const isAdmin = (String(userRole || '').toLowerCase() === 'admin');
|
const isAdmin = (String(userRole || '').toLowerCase() === 'admin');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAdmin && (currentPage === 'admin_users' || currentPage === 'server_info')) {
|
if (!isAdmin && (currentPage === 'server_info' || currentPage === 'access_credentials' || currentPage === 'access_users')) {
|
||||||
setNotAuthorizedOpen(true);
|
setNotAuthorizedOpen(true);
|
||||||
setCurrentPage('devices');
|
setCurrentPage('devices');
|
||||||
}
|
}
|
||||||
@@ -705,7 +710,10 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
case "admin_users":
|
case "access_credentials":
|
||||||
|
return <CredentialList isAdmin={isAdmin} />;
|
||||||
|
|
||||||
|
case "access_users":
|
||||||
return <UserManagement isAdmin={isAdmin} />;
|
return <UserManagement isAdmin={isAdmin} />;
|
||||||
|
|
||||||
case "server_info":
|
case "server_info":
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
Apps as AssembliesIcon
|
Apps as AssembliesIcon
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import { LocationCity as SitesIcon } from "@mui/icons-material";
|
import { LocationCity as SitesIcon } from "@mui/icons-material";
|
||||||
import { ManageAccounts as AdminUsersIcon, Dns as ServerInfoIcon } from "@mui/icons-material";
|
import { Dns as ServerInfoIcon, VpnKey as CredentialIcon, PersonOutline as UserIcon } from "@mui/icons-material";
|
||||||
|
|
||||||
function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) {
|
function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) {
|
||||||
const [expandedNav, setExpandedNav] = useState({
|
const [expandedNav, setExpandedNav] = useState({
|
||||||
@@ -30,6 +30,7 @@ function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) {
|
|||||||
devices: true,
|
devices: true,
|
||||||
automation: true,
|
automation: true,
|
||||||
filters: true,
|
filters: true,
|
||||||
|
access: true,
|
||||||
admin: true
|
admin: true
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -289,10 +290,57 @@ function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) {
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
{/* Access Management */}
|
||||||
|
{(() => {
|
||||||
|
if (!isAdmin) return null;
|
||||||
|
const groupActive = currentPage === "access_credentials" || currentPage === "access_users";
|
||||||
|
return (
|
||||||
|
<Accordion
|
||||||
|
expanded={expandedNav.access}
|
||||||
|
onChange={(_, e) => setExpandedNav((s) => ({ ...s, access: e }))}
|
||||||
|
square
|
||||||
|
disableGutters
|
||||||
|
sx={{ "&:before": { display: "none" }, margin: 0, border: 0 }}
|
||||||
|
>
|
||||||
|
<AccordionSummary
|
||||||
|
expandIcon={<ExpandMoreIcon />}
|
||||||
|
sx={{
|
||||||
|
position: "relative",
|
||||||
|
background: groupActive
|
||||||
|
? "linear-gradient(90deg, rgba(88,166,255,0.08) 0%, rgba(88,166,255,0.00) 100%)"
|
||||||
|
: "#2c2c2c",
|
||||||
|
minHeight: "36px",
|
||||||
|
"& .MuiAccordionSummary-content": { margin: 0 },
|
||||||
|
"&::before": {
|
||||||
|
content: '""',
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: groupActive ? 3 : 0,
|
||||||
|
bgcolor: "#58a6ff",
|
||||||
|
borderTopRightRadius: 2,
|
||||||
|
borderBottomRightRadius: 2,
|
||||||
|
transition: "width 160ms ease"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography sx={{ fontSize: "0.85rem", color: "#58a6ff" }}>
|
||||||
|
<b>Access Management</b>
|
||||||
|
</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
|
||||||
|
<NavItem icon={<CredentialIcon fontSize="small" />} label="Credentials" pageKey="access_credentials" />
|
||||||
|
<NavItem icon={<UserIcon fontSize="small" />} label="Users" pageKey="access_users" />
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Admin */}
|
{/* Admin */}
|
||||||
{(() => {
|
{(() => {
|
||||||
if (!isAdmin) return null;
|
if (!isAdmin) return null;
|
||||||
const groupActive = currentPage === "admin_users" || currentPage === "server_info";
|
const groupActive = currentPage === "server_info";
|
||||||
return (
|
return (
|
||||||
<Accordion
|
<Accordion
|
||||||
expanded={expandedNav.admin}
|
expanded={expandedNav.admin}
|
||||||
@@ -329,7 +377,6 @@ function NavigationSidebar({ currentPage, onNavigate, isAdmin = false }) {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</AccordionSummary>
|
</AccordionSummary>
|
||||||
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
|
<AccordionDetails sx={{ p: 0, bgcolor: "#232323" }}>
|
||||||
<NavItem icon={<AdminUsersIcon fontSize="small" />} label="User Management" pageKey="admin_users" />
|
|
||||||
<NavItem icon={<ServerInfoIcon fontSize="small" />} label="Server Info" pageKey="server_info" />
|
<NavItem icon={<ServerInfoIcon fontSize="small" />} label="Server Info" pageKey="server_info" />
|
||||||
</AccordionDetails>
|
</AccordionDetails>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
IconButton,
|
IconButton,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
|
FormControl,
|
||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
Select,
|
Select,
|
||||||
|
InputLabel,
|
||||||
Menu,
|
Menu,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Divider,
|
Divider,
|
||||||
@@ -24,7 +26,8 @@ import {
|
|||||||
TableCell,
|
TableCell,
|
||||||
TableBody,
|
TableBody,
|
||||||
TableSortLabel,
|
TableSortLabel,
|
||||||
GlobalStyles
|
GlobalStyles,
|
||||||
|
CircularProgress
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import {
|
import {
|
||||||
Add as AddIcon,
|
Add as AddIcon,
|
||||||
@@ -34,7 +37,8 @@ import {
|
|||||||
Sync as SyncIcon,
|
Sync as SyncIcon,
|
||||||
Timer as TimerIcon,
|
Timer as TimerIcon,
|
||||||
Check as CheckIcon,
|
Check as CheckIcon,
|
||||||
Error as ErrorIcon
|
Error as ErrorIcon,
|
||||||
|
Refresh as RefreshIcon
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import { SimpleTreeView, TreeItem } from "@mui/x-tree-view";
|
import { SimpleTreeView, TreeItem } from "@mui/x-tree-view";
|
||||||
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
||||||
@@ -421,6 +425,52 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
|||||||
const [stopAfterEnabled, setStopAfterEnabled] = useState(false);
|
const [stopAfterEnabled, setStopAfterEnabled] = useState(false);
|
||||||
const [expiration, setExpiration] = useState("no_expire");
|
const [expiration, setExpiration] = useState("no_expire");
|
||||||
const [execContext, setExecContext] = useState("system");
|
const [execContext, setExecContext] = useState("system");
|
||||||
|
const [credentials, setCredentials] = useState([]);
|
||||||
|
const [credentialLoading, setCredentialLoading] = useState(false);
|
||||||
|
const [credentialError, setCredentialError] = useState("");
|
||||||
|
const [selectedCredentialId, setSelectedCredentialId] = useState("");
|
||||||
|
|
||||||
|
const loadCredentials = useCallback(async () => {
|
||||||
|
setCredentialLoading(true);
|
||||||
|
setCredentialError("");
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/api/credentials");
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
const list = Array.isArray(data?.credentials) ? data.credentials : [];
|
||||||
|
list.sort((a, b) => String(a?.name || "").localeCompare(String(b?.name || "")));
|
||||||
|
setCredentials(list);
|
||||||
|
} catch (err) {
|
||||||
|
setCredentials([]);
|
||||||
|
setCredentialError(String(err.message || err));
|
||||||
|
} finally {
|
||||||
|
setCredentialLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCredentials();
|
||||||
|
}, [loadCredentials]);
|
||||||
|
|
||||||
|
const remoteExec = useMemo(() => execContext === "ssh" || execContext === "winrm", [execContext]);
|
||||||
|
const filteredCredentials = useMemo(() => {
|
||||||
|
if (!remoteExec) return credentials;
|
||||||
|
const target = execContext === "winrm" ? "winrm" : "ssh";
|
||||||
|
return credentials.filter((cred) => String(cred.connection_type || "").toLowerCase() === target);
|
||||||
|
}, [credentials, remoteExec, execContext]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!remoteExec) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!filteredCredentials.length) {
|
||||||
|
setSelectedCredentialId("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedCredentialId || !filteredCredentials.some((cred) => String(cred.id) === String(selectedCredentialId))) {
|
||||||
|
setSelectedCredentialId(String(filteredCredentials[0].id));
|
||||||
|
}
|
||||||
|
}, [remoteExec, filteredCredentials, selectedCredentialId]);
|
||||||
|
|
||||||
// dialogs state
|
// dialogs state
|
||||||
const [addCompOpen, setAddCompOpen] = useState(false);
|
const [addCompOpen, setAddCompOpen] = useState(false);
|
||||||
@@ -827,11 +877,12 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
|||||||
const isValid = useMemo(() => {
|
const isValid = useMemo(() => {
|
||||||
const base = jobName.trim().length > 0 && components.length > 0 && targets.length > 0;
|
const base = jobName.trim().length > 0 && components.length > 0 && targets.length > 0;
|
||||||
if (!base) return false;
|
if (!base) return false;
|
||||||
|
if (remoteExec && !selectedCredentialId) return false;
|
||||||
if (scheduleType !== "immediately") {
|
if (scheduleType !== "immediately") {
|
||||||
return !!startDateTime;
|
return !!startDateTime;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}, [jobName, components.length, targets.length, scheduleType, startDateTime]);
|
}, [jobName, components.length, targets.length, scheduleType, startDateTime, remoteExec, selectedCredentialId]);
|
||||||
|
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const editing = !!(initialJob && initialJob.id);
|
const editing = !!(initialJob && initialJob.id);
|
||||||
@@ -1306,6 +1357,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
|||||||
setStopAfterEnabled(Boolean(initialJob.duration_stop_enabled));
|
setStopAfterEnabled(Boolean(initialJob.duration_stop_enabled));
|
||||||
setExpiration(initialJob.expiration || "no_expire");
|
setExpiration(initialJob.expiration || "no_expire");
|
||||||
setExecContext(initialJob.execution_context || "system");
|
setExecContext(initialJob.execution_context || "system");
|
||||||
|
setSelectedCredentialId(initialJob.credential_id ? String(initialJob.credential_id) : "");
|
||||||
const comps = Array.isArray(initialJob.components) ? initialJob.components : [];
|
const comps = Array.isArray(initialJob.components) ? initialJob.components : [];
|
||||||
const hydrated = await hydrateExistingComponents(comps);
|
const hydrated = await hydrateExistingComponents(comps);
|
||||||
if (!canceled) {
|
if (!canceled) {
|
||||||
@@ -1316,6 +1368,7 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
|||||||
setPageTitleJobName("");
|
setPageTitleJobName("");
|
||||||
setComponents([]);
|
setComponents([]);
|
||||||
setComponentVarErrors({});
|
setComponentVarErrors({});
|
||||||
|
setSelectedCredentialId("");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
hydrate();
|
hydrate();
|
||||||
@@ -1411,6 +1464,10 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
|
if (remoteExec && !selectedCredentialId) {
|
||||||
|
alert("Please select a credential for this execution context.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const requiredErrors = {};
|
const requiredErrors = {};
|
||||||
components.forEach((comp) => {
|
components.forEach((comp) => {
|
||||||
if (!comp || !comp.localId) return;
|
if (!comp || !comp.localId) return;
|
||||||
@@ -1438,7 +1495,8 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
|||||||
targets,
|
targets,
|
||||||
schedule: { type: scheduleType, start: scheduleType !== "immediately" ? (() => { try { const d = startDateTime?.toDate?.() || new Date(startDateTime); d.setSeconds(0,0); return d.toISOString(); } catch { return startDateTime; } })() : null },
|
schedule: { type: scheduleType, start: scheduleType !== "immediately" ? (() => { try { const d = startDateTime?.toDate?.() || new Date(startDateTime); d.setSeconds(0,0); return d.toISOString(); } catch { return startDateTime; } })() : null },
|
||||||
duration: { stopAfterEnabled, expiration },
|
duration: { stopAfterEnabled, expiration },
|
||||||
execution_context: execContext
|
execution_context: execContext,
|
||||||
|
credential_id: remoteExec && selectedCredentialId ? Number(selectedCredentialId) : null
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(initialJob && initialJob.id ? `/api/scheduled_jobs/${initialJob.id}` : "/api/scheduled_jobs", {
|
const resp = await fetch(initialJob && initialJob.id ? `/api/scheduled_jobs/${initialJob.id}` : "/api/scheduled_jobs", {
|
||||||
@@ -1665,10 +1723,61 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null }) {
|
|||||||
{tab === 4 && (
|
{tab === 4 && (
|
||||||
<Box>
|
<Box>
|
||||||
<SectionHeader title="Execution Context" />
|
<SectionHeader title="Execution Context" />
|
||||||
<Select size="small" value={execContext} onChange={(e) => setExecContext(e.target.value)} sx={{ minWidth: 280 }}>
|
<Select
|
||||||
<MenuItem value="system">Run as SYSTEM Account</MenuItem>
|
size="small"
|
||||||
<MenuItem value="current_user">Run as the Logged-In User</MenuItem>
|
value={execContext}
|
||||||
|
onChange={(e) => setExecContext(e.target.value)}
|
||||||
|
sx={{ minWidth: 320 }}
|
||||||
|
>
|
||||||
|
<MenuItem value="system">Run on agent as SYSTEM (device-local)</MenuItem>
|
||||||
|
<MenuItem value="current_user">Run on agent as logged-in user (device-local)</MenuItem>
|
||||||
|
<MenuItem value="ssh">Run from server via SSH (remote)</MenuItem>
|
||||||
|
<MenuItem value="winrm">Run from server via WinRM (remote)</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
|
{remoteExec && (
|
||||||
|
<Box sx={{ mt: 2, display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap" }}>
|
||||||
|
<FormControl
|
||||||
|
size="small"
|
||||||
|
sx={{ minWidth: 320 }}
|
||||||
|
disabled={credentialLoading || !filteredCredentials.length}
|
||||||
|
>
|
||||||
|
<InputLabel sx={{ color: "#aaa" }}>Credential</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={selectedCredentialId}
|
||||||
|
label="Credential"
|
||||||
|
onChange={(e) => setSelectedCredentialId(e.target.value)}
|
||||||
|
sx={{ bgcolor: "#1f1f1f", color: "#fff" }}
|
||||||
|
>
|
||||||
|
{filteredCredentials.map((cred) => (
|
||||||
|
<MenuItem key={cred.id} value={String(cred.id)}>
|
||||||
|
{cred.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<RefreshIcon fontSize="small" />}
|
||||||
|
onClick={loadCredentials}
|
||||||
|
disabled={credentialLoading}
|
||||||
|
sx={{ color: "#58a6ff", borderColor: "#58a6ff" }}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
{credentialLoading && <CircularProgress size={18} sx={{ color: "#58a6ff" }} />}
|
||||||
|
{!credentialLoading && credentialError && (
|
||||||
|
<Typography variant="body2" sx={{ color: "#ff8080" }}>
|
||||||
|
{credentialError}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{!credentialLoading && !credentialError && !filteredCredentials.length && (
|
||||||
|
<Typography variant="body2" sx={{ color: "#ff8080" }}>
|
||||||
|
No {execContext === "winrm" ? "WinRM" : "SSH"} credentials available. Create one under Access Management > Credentials.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ import {
|
|||||||
Paper,
|
Paper,
|
||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
TextField
|
TextField,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
CircularProgress
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import { Folder as FolderIcon, Description as DescriptionIcon } from "@mui/icons-material";
|
import { Folder as FolderIcon, Description as DescriptionIcon } from "@mui/icons-material";
|
||||||
import { SimpleTreeView, TreeItem } from "@mui/x-tree-view";
|
import { SimpleTreeView, TreeItem } from "@mui/x-tree-view";
|
||||||
@@ -82,6 +87,10 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [runAsCurrentUser, setRunAsCurrentUser] = useState(false);
|
const [runAsCurrentUser, setRunAsCurrentUser] = useState(false);
|
||||||
const [mode, setMode] = useState("scripts"); // 'scripts' | 'ansible'
|
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 [variables, setVariables] = useState([]);
|
const [variables, setVariables] = useState([]);
|
||||||
const [variableValues, setVariableValues] = useState({});
|
const [variableValues, setVariableValues] = useState({});
|
||||||
const [variableErrors, setVariableErrors] = useState({});
|
const [variableErrors, setVariableErrors] = useState({});
|
||||||
@@ -115,6 +124,53 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
|
|||||||
}
|
}
|
||||||
}, [open, loadTree]);
|
}, [open, loadTree]);
|
||||||
|
|
||||||
|
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) => String(cred.connection_type || "").toLowerCase() === "ssh")
|
||||||
|
: [];
|
||||||
|
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") return;
|
||||||
|
if (!credentials.length) {
|
||||||
|
setSelectedCredentialId("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedCredentialId || !credentials.some((cred) => String(cred.id) === String(selectedCredentialId))) {
|
||||||
|
setSelectedCredentialId(String(credentials[0].id));
|
||||||
|
}
|
||||||
|
}, [mode, credentials, selectedCredentialId]);
|
||||||
|
|
||||||
const renderNodes = (nodes = []) =>
|
const renderNodes = (nodes = []) =>
|
||||||
nodes.map((n) => (
|
nodes.map((n) => (
|
||||||
<TreeItem
|
<TreeItem
|
||||||
@@ -286,6 +342,10 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
|
|||||||
setError(mode === 'ansible' ? "Please choose a playbook to run." : "Please choose a script to run.");
|
setError(mode === 'ansible' ? "Please choose a playbook to run." : "Please choose a script to run.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (mode === 'ansible' && !selectedCredentialId) {
|
||||||
|
setError("Select a credential to run this playbook.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (variables.length) {
|
if (variables.length) {
|
||||||
const errors = {};
|
const errors = {};
|
||||||
variables.forEach((variable) => {
|
variables.forEach((variable) => {
|
||||||
@@ -314,7 +374,12 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
|
|||||||
resp = await fetch("/api/ansible/quick_run", {
|
resp = await fetch("/api/ansible/quick_run", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ playbook_path, hostnames, variable_values: variableOverrides })
|
body: JSON.stringify({
|
||||||
|
playbook_path,
|
||||||
|
hostnames,
|
||||||
|
variable_values: variableOverrides,
|
||||||
|
credential_id: selectedCredentialId ? Number(selectedCredentialId) : null
|
||||||
|
})
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// quick_run expects a path relative to Assemblies root with 'Scripts/' prefix
|
// quick_run expects a path relative to Assemblies root with 'Scripts/' prefix
|
||||||
@@ -340,6 +405,9 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const credentialRequired = mode === "ansible";
|
||||||
|
const disableRun = running || !selectedPath || (credentialRequired && (!selectedCredentialId || !credentials.length));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={running ? undefined : onClose} fullWidth maxWidth="md"
|
<Dialog open={open} onClose={running ? undefined : onClose} fullWidth maxWidth="md"
|
||||||
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
|
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
|
||||||
@@ -353,6 +421,38 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
|
|||||||
<Typography variant="body2" sx={{ color: "#aaa", mb: 1 }}>
|
<Typography variant="body2" sx={{ color: "#aaa", mb: 1 }}>
|
||||||
Select a {mode === 'ansible' ? 'playbook' : 'script'} to run on {hostnames.length} device{hostnames.length !== 1 ? "s" : ""}.
|
Select a {mode === 'ansible' ? 'playbook' : 'script'} to run on {hostnames.length} device{hostnames.length !== 1 ? "s" : ""}.
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{mode === 'ansible' && (
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap", mb: 2 }}>
|
||||||
|
<FormControl
|
||||||
|
size="small"
|
||||||
|
sx={{ minWidth: 260 }}
|
||||||
|
disabled={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) => (
|
||||||
|
<MenuItem key={cred.id} value={String(cred.id)}>
|
||||||
|
{cred.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
{credentialsLoading && <CircularProgress size={18} sx={{ color: "#58a6ff" }} />}
|
||||||
|
{!credentialsLoading && credentialsError && (
|
||||||
|
<Typography variant="body2" sx={{ color: "#ff8080" }}>{credentialsError}</Typography>
|
||||||
|
)}
|
||||||
|
{!credentialsLoading && !credentialsError && !credentials.length && (
|
||||||
|
<Typography variant="body2" sx={{ color: "#ff8080" }}>
|
||||||
|
No SSH credentials available. Create one under Access Management.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
<Box sx={{ display: "flex", gap: 2 }}>
|
<Box sx={{ display: "flex", gap: 2 }}>
|
||||||
<Paper sx={{ flex: 1, p: 1, bgcolor: "#1e1e1e", maxHeight: 400, overflow: "auto" }}>
|
<Paper sx={{ flex: 1, p: 1, bgcolor: "#1e1e1e", maxHeight: 400, overflow: "auto" }}>
|
||||||
<SimpleTreeView sx={{ color: "#e6edf3" }} onItemSelectionToggle={onItemSelect}>
|
<SimpleTreeView sx={{ color: "#e6edf3" }} onItemSelectionToggle={onItemSelect}>
|
||||||
@@ -444,8 +544,8 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={onClose} disabled={running} sx={{ color: "#58a6ff" }}>Cancel</Button>
|
<Button onClick={onClose} disabled={running} sx={{ color: "#58a6ff" }}>Cancel</Button>
|
||||||
<Button onClick={onRun} disabled={running || !selectedPath}
|
<Button onClick={onRun} disabled={disableRun}
|
||||||
sx={{ color: running || !selectedPath ? "#666" : "#58a6ff" }}
|
sx={{ color: disableRun ? "#666" : "#58a6ff" }}
|
||||||
>
|
>
|
||||||
Run
|
Run
|
||||||
</Button>
|
</Button>
|
||||||
@@ -453,4 +553,3 @@ export default function QuickJob({ open, onClose, hostnames = [] }) {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -309,6 +309,8 @@ class JobScheduler:
|
|||||||
self.RETENTION_DAYS = int(os.environ.get("BOREALIS_JOB_HISTORY_DAYS", "30"))
|
self.RETENTION_DAYS = int(os.environ.get("BOREALIS_JOB_HISTORY_DAYS", "30"))
|
||||||
# Callback to retrieve current set of online hostnames
|
# Callback to retrieve current set of online hostnames
|
||||||
self._online_lookup: Optional[Callable[[], List[str]]] = None
|
self._online_lookup: Optional[Callable[[], List[str]]] = None
|
||||||
|
# Optional callback to execute Ansible directly from the server
|
||||||
|
self._server_ansible_runner: Optional[Callable[..., str]] = None
|
||||||
|
|
||||||
# Ensure run-history table exists
|
# Ensure run-history table exists
|
||||||
self._init_tables()
|
self._init_tables()
|
||||||
@@ -475,7 +477,15 @@ class JobScheduler:
|
|||||||
os.path.join(os.path.dirname(__file__), "..", "..", "Assemblies", "Ansible_Playbooks")
|
os.path.join(os.path.dirname(__file__), "..", "..", "Assemblies", "Ansible_Playbooks")
|
||||||
)
|
)
|
||||||
|
|
||||||
def _dispatch_ansible(self, hostname: str, component: Dict[str, Any], scheduled_job_id: int, scheduled_run_id: int) -> Optional[Dict[str, Any]]:
|
def _dispatch_ansible(
|
||||||
|
self,
|
||||||
|
hostname: str,
|
||||||
|
component: Dict[str, Any],
|
||||||
|
scheduled_job_id: int,
|
||||||
|
scheduled_run_row_id: int,
|
||||||
|
run_mode: str,
|
||||||
|
credential_id: Optional[int] = None,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
try:
|
try:
|
||||||
import os, uuid
|
import os, uuid
|
||||||
ans_root = self._ansible_root()
|
ans_root = self._ansible_root()
|
||||||
@@ -511,6 +521,8 @@ class JobScheduler:
|
|||||||
encoded_content = _encode_script_content(content)
|
encoded_content = _encode_script_content(content)
|
||||||
variables = doc.get("variables") or []
|
variables = doc.get("variables") or []
|
||||||
files = doc.get("files") or []
|
files = doc.get("files") or []
|
||||||
|
run_mode_norm = (run_mode or "system").strip().lower()
|
||||||
|
server_run = run_mode_norm in ("ssh", "winrm")
|
||||||
|
|
||||||
# Record in activity_history for UI parity
|
# Record in activity_history for UI parity
|
||||||
now = _now_ts()
|
now = _now_ts()
|
||||||
@@ -539,6 +551,50 @@ class JobScheduler:
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
if server_run:
|
||||||
|
if not credential_id:
|
||||||
|
raise RuntimeError("Remote execution requires a credential_id")
|
||||||
|
if not callable(self._server_ansible_runner):
|
||||||
|
raise RuntimeError("Server-side Ansible runner is not configured")
|
||||||
|
try:
|
||||||
|
self._server_ansible_runner(
|
||||||
|
hostname=str(hostname),
|
||||||
|
playbook_abs_path=abs_path,
|
||||||
|
playbook_rel_path=rel_norm,
|
||||||
|
playbook_name=doc.get("name") or os.path.basename(abs_path),
|
||||||
|
credential_id=int(credential_id),
|
||||||
|
variable_values=overrides_map,
|
||||||
|
source="scheduled_job",
|
||||||
|
activity_id=act_id,
|
||||||
|
scheduled_job_id=scheduled_job_id,
|
||||||
|
scheduled_run_id=scheduled_run_row_id,
|
||||||
|
scheduled_job_run_row_id=scheduled_run_row_id,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
try:
|
||||||
|
self.app.logger.warning(
|
||||||
|
"[Scheduler] Server-side Ansible queue failed job=%s run=%s host=%s err=%s",
|
||||||
|
scheduled_job_id,
|
||||||
|
scheduled_run_row_id,
|
||||||
|
hostname,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
print(f"[Scheduler] Server-side Ansible queue failed job={scheduled_job_id} host={hostname} err={exc}")
|
||||||
|
if act_id:
|
||||||
|
try:
|
||||||
|
conn_fail = self._conn()
|
||||||
|
cur_fail = conn_fail.cursor()
|
||||||
|
cur_fail.execute(
|
||||||
|
"UPDATE activity_history SET status='Failed', stderr=?, ran_at=? WHERE id=?",
|
||||||
|
(str(exc), _now_ts(), act_id),
|
||||||
|
)
|
||||||
|
conn_fail.commit()
|
||||||
|
conn_fail.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
else:
|
||||||
payload = {
|
payload = {
|
||||||
"run_id": uuid.uuid4().hex,
|
"run_id": uuid.uuid4().hex,
|
||||||
"target_hostname": str(hostname),
|
"target_hostname": str(hostname),
|
||||||
@@ -547,7 +603,7 @@ class JobScheduler:
|
|||||||
"playbook_encoding": "base64",
|
"playbook_encoding": "base64",
|
||||||
"activity_job_id": act_id,
|
"activity_job_id": act_id,
|
||||||
"scheduled_job_id": int(scheduled_job_id),
|
"scheduled_job_id": int(scheduled_job_id),
|
||||||
"scheduled_run_id": int(scheduled_run_id),
|
"scheduled_run_id": int(scheduled_run_row_id),
|
||||||
"connection": "winrm",
|
"connection": "winrm",
|
||||||
"variables": variables,
|
"variables": variables,
|
||||||
"files": files,
|
"files": files,
|
||||||
@@ -898,7 +954,7 @@ class JobScheduler:
|
|||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT id, components_json, targets_json, schedule_type, start_ts, expiration, execution_context, created_at FROM scheduled_jobs WHERE enabled=1 ORDER BY id ASC"
|
"SELECT id, components_json, targets_json, schedule_type, start_ts, expiration, execution_context, credential_id, created_at FROM scheduled_jobs WHERE enabled=1 ORDER BY id ASC"
|
||||||
)
|
)
|
||||||
jobs = cur.fetchall()
|
jobs = cur.fetchall()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -916,7 +972,7 @@ class JobScheduler:
|
|||||||
five_min = 300
|
five_min = 300
|
||||||
now_min = _now_minute()
|
now_min = _now_minute()
|
||||||
|
|
||||||
for (job_id, components_json, targets_json, schedule_type, start_ts, expiration, execution_context, created_at) in jobs:
|
for (job_id, components_json, targets_json, schedule_type, start_ts, expiration, execution_context, credential_id, created_at) in jobs:
|
||||||
try:
|
try:
|
||||||
# Targets list for this job
|
# Targets list for this job
|
||||||
try:
|
try:
|
||||||
@@ -951,6 +1007,11 @@ class JobScheduler:
|
|||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
run_mode = (execution_context or "system").strip().lower()
|
run_mode = (execution_context or "system").strip().lower()
|
||||||
|
job_credential_id = None
|
||||||
|
try:
|
||||||
|
job_credential_id = int(credential_id) if credential_id is not None else None
|
||||||
|
except Exception:
|
||||||
|
job_credential_id = None
|
||||||
|
|
||||||
exp_seconds = _parse_expiration(expiration)
|
exp_seconds = _parse_expiration(expiration)
|
||||||
|
|
||||||
@@ -1037,6 +1098,15 @@ class JobScheduler:
|
|||||||
run_row_id = c2.lastrowid or 0
|
run_row_id = c2.lastrowid or 0
|
||||||
conn2.commit()
|
conn2.commit()
|
||||||
activity_links: List[Dict[str, Any]] = []
|
activity_links: List[Dict[str, Any]] = []
|
||||||
|
remote_requires_cred = run_mode in ("ssh", "winrm")
|
||||||
|
if remote_requires_cred and not job_credential_id:
|
||||||
|
err_msg = "Credential required for remote execution"
|
||||||
|
c2.execute(
|
||||||
|
"UPDATE scheduled_job_runs SET status='Failed', finished_ts=?, updated_at=?, error=? WHERE id=?",
|
||||||
|
(ts_now, ts_now, err_msg, run_row_id),
|
||||||
|
)
|
||||||
|
conn2.commit()
|
||||||
|
else:
|
||||||
# Dispatch all script components for this job to the target host
|
# Dispatch all script components for this job to the target host
|
||||||
for comp in script_components:
|
for comp in script_components:
|
||||||
try:
|
try:
|
||||||
@@ -1055,7 +1125,14 @@ class JobScheduler:
|
|||||||
# Dispatch ansible playbooks for this job to the target host
|
# Dispatch ansible playbooks for this job to the target host
|
||||||
for comp in ansible_components:
|
for comp in ansible_components:
|
||||||
try:
|
try:
|
||||||
link = self._dispatch_ansible(host, comp, job_id, run_row_id)
|
link = self._dispatch_ansible(
|
||||||
|
host,
|
||||||
|
comp,
|
||||||
|
job_id,
|
||||||
|
run_row_id,
|
||||||
|
run_mode,
|
||||||
|
job_credential_id,
|
||||||
|
)
|
||||||
if link and link.get("activity_id"):
|
if link and link.get("activity_id"):
|
||||||
activity_links.append({
|
activity_links.append({
|
||||||
"run_id": run_row_id,
|
"run_id": run_row_id,
|
||||||
@@ -1065,7 +1142,15 @@ class JobScheduler:
|
|||||||
"component_path": link.get("component_path") or "",
|
"component_path": link.get("component_path") or "",
|
||||||
"component_name": link.get("component_name") or "",
|
"component_name": link.get("component_name") or "",
|
||||||
})
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
try:
|
||||||
|
c2.execute(
|
||||||
|
"UPDATE scheduled_job_runs SET status='Failed', finished_ts=?, updated_at=?, error=? WHERE id=?",
|
||||||
|
(ts_now, ts_now, str(exc)[:512], run_row_id),
|
||||||
|
)
|
||||||
|
conn2.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
pass
|
||||||
continue
|
continue
|
||||||
if activity_links:
|
if activity_links:
|
||||||
try:
|
try:
|
||||||
@@ -1157,9 +1242,10 @@ class JobScheduler:
|
|||||||
"duration_stop_enabled": bool(r[6] or 0),
|
"duration_stop_enabled": bool(r[6] or 0),
|
||||||
"expiration": r[7] or "no_expire",
|
"expiration": r[7] or "no_expire",
|
||||||
"execution_context": r[8] or "system",
|
"execution_context": r[8] or "system",
|
||||||
"enabled": bool(r[9] or 0),
|
"credential_id": r[9],
|
||||||
"created_at": r[10] or 0,
|
"enabled": bool(r[10] or 0),
|
||||||
"updated_at": r[11] or 0,
|
"created_at": r[11] or 0,
|
||||||
|
"updated_at": r[12] or 0,
|
||||||
}
|
}
|
||||||
# Attach computed status summary for latest occurrence
|
# Attach computed status summary for latest occurrence
|
||||||
try:
|
try:
|
||||||
@@ -1236,7 +1322,7 @@ class JobScheduler:
|
|||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, name, components_json, targets_json, schedule_type, start_ts,
|
SELECT id, name, components_json, targets_json, schedule_type, start_ts,
|
||||||
duration_stop_enabled, expiration, execution_context, enabled, created_at, updated_at
|
duration_stop_enabled, expiration, execution_context, credential_id, enabled, created_at, updated_at
|
||||||
FROM scheduled_jobs
|
FROM scheduled_jobs
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
"""
|
"""
|
||||||
@@ -1259,6 +1345,11 @@ class JobScheduler:
|
|||||||
duration_stop_enabled = int(bool((data.get("duration") or {}).get("stopAfterEnabled") or data.get("duration_stop_enabled")))
|
duration_stop_enabled = int(bool((data.get("duration") or {}).get("stopAfterEnabled") or data.get("duration_stop_enabled")))
|
||||||
expiration = (data.get("duration") or {}).get("expiration") or data.get("expiration") or "no_expire"
|
expiration = (data.get("duration") or {}).get("expiration") or data.get("expiration") or "no_expire"
|
||||||
execution_context = (data.get("execution_context") or "system").strip().lower()
|
execution_context = (data.get("execution_context") or "system").strip().lower()
|
||||||
|
credential_id = data.get("credential_id")
|
||||||
|
try:
|
||||||
|
credential_id = int(credential_id) if credential_id is not None else None
|
||||||
|
except Exception:
|
||||||
|
credential_id = None
|
||||||
enabled = int(bool(data.get("enabled", True)))
|
enabled = int(bool(data.get("enabled", True)))
|
||||||
if not name or not components or not targets:
|
if not name or not components or not targets:
|
||||||
return json.dumps({"error": "name, components, targets required"}), 400, {"Content-Type": "application/json"}
|
return json.dumps({"error": "name, components, targets required"}), 400, {"Content-Type": "application/json"}
|
||||||
@@ -1269,8 +1360,8 @@ class JobScheduler:
|
|||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO scheduled_jobs
|
INSERT INTO scheduled_jobs
|
||||||
(name, components_json, targets_json, schedule_type, start_ts, duration_stop_enabled, expiration, execution_context, enabled, created_at, updated_at)
|
(name, components_json, targets_json, schedule_type, start_ts, duration_stop_enabled, expiration, execution_context, credential_id, enabled, created_at, updated_at)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
name,
|
name,
|
||||||
@@ -1281,6 +1372,7 @@ class JobScheduler:
|
|||||||
duration_stop_enabled,
|
duration_stop_enabled,
|
||||||
expiration,
|
expiration,
|
||||||
execution_context,
|
execution_context,
|
||||||
|
credential_id,
|
||||||
enabled,
|
enabled,
|
||||||
now,
|
now,
|
||||||
now,
|
now,
|
||||||
@@ -1291,7 +1383,7 @@ class JobScheduler:
|
|||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, name, components_json, targets_json, schedule_type, start_ts,
|
SELECT id, name, components_json, targets_json, schedule_type, start_ts,
|
||||||
duration_stop_enabled, expiration, execution_context, enabled, created_at, updated_at
|
duration_stop_enabled, expiration, execution_context, credential_id, enabled, created_at, updated_at
|
||||||
FROM scheduled_jobs WHERE id=?
|
FROM scheduled_jobs WHERE id=?
|
||||||
""",
|
""",
|
||||||
(job_id,),
|
(job_id,),
|
||||||
@@ -1310,7 +1402,7 @@ class JobScheduler:
|
|||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, name, components_json, targets_json, schedule_type, start_ts,
|
SELECT id, name, components_json, targets_json, schedule_type, start_ts,
|
||||||
duration_stop_enabled, expiration, execution_context, enabled, created_at, updated_at
|
duration_stop_enabled, expiration, execution_context, credential_id, enabled, created_at, updated_at
|
||||||
FROM scheduled_jobs WHERE id=?
|
FROM scheduled_jobs WHERE id=?
|
||||||
""",
|
""",
|
||||||
(job_id,),
|
(job_id,),
|
||||||
@@ -1344,6 +1436,15 @@ class JobScheduler:
|
|||||||
fields["expiration"] = (data.get("duration") or {}).get("expiration") or data.get("expiration") or "no_expire"
|
fields["expiration"] = (data.get("duration") or {}).get("expiration") or data.get("expiration") or "no_expire"
|
||||||
if "execution_context" in data:
|
if "execution_context" in data:
|
||||||
fields["execution_context"] = (data.get("execution_context") or "system").strip().lower()
|
fields["execution_context"] = (data.get("execution_context") or "system").strip().lower()
|
||||||
|
if "credential_id" in data:
|
||||||
|
cred_val = data.get("credential_id")
|
||||||
|
if cred_val in (None, "", "null"):
|
||||||
|
fields["credential_id"] = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
fields["credential_id"] = int(cred_val)
|
||||||
|
except Exception:
|
||||||
|
fields["credential_id"] = None
|
||||||
if "enabled" in data:
|
if "enabled" in data:
|
||||||
fields["enabled"] = int(bool(data.get("enabled")))
|
fields["enabled"] = int(bool(data.get("enabled")))
|
||||||
if not fields:
|
if not fields:
|
||||||
@@ -1361,7 +1462,7 @@ class JobScheduler:
|
|||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, name, components_json, targets_json, schedule_type, start_ts,
|
SELECT id, name, components_json, targets_json, schedule_type, start_ts,
|
||||||
duration_stop_enabled, expiration, execution_context, enabled, created_at, updated_at
|
duration_stop_enabled, expiration, execution_context, credential_id, enabled, created_at, updated_at
|
||||||
FROM scheduled_jobs WHERE id=?
|
FROM scheduled_jobs WHERE id=?
|
||||||
""",
|
""",
|
||||||
(job_id,),
|
(job_id,),
|
||||||
@@ -1385,7 +1486,7 @@ class JobScheduler:
|
|||||||
return json.dumps({"error": "not found"}), 404, {"Content-Type": "application/json"}
|
return json.dumps({"error": "not found"}), 404, {"Content-Type": "application/json"}
|
||||||
conn.commit()
|
conn.commit()
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT id, name, components_json, targets_json, schedule_type, start_ts, duration_stop_enabled, expiration, execution_context, enabled, created_at, updated_at FROM scheduled_jobs WHERE id=?",
|
"SELECT id, name, components_json, targets_json, schedule_type, start_ts, duration_stop_enabled, expiration, execution_context, credential_id, enabled, created_at, updated_at FROM scheduled_jobs WHERE id=?",
|
||||||
(job_id,),
|
(job_id,),
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
@@ -1633,3 +1734,7 @@ def register(app, socketio, db_path: str) -> JobScheduler:
|
|||||||
|
|
||||||
def set_online_lookup(scheduler: JobScheduler, fn: Callable[[], List[str]]):
|
def set_online_lookup(scheduler: JobScheduler, fn: Callable[[], List[str]]):
|
||||||
scheduler._online_lookup = fn
|
scheduler._online_lookup = fn
|
||||||
|
|
||||||
|
|
||||||
|
def set_server_ansible_runner(scheduler: JobScheduler, fn: Callable[..., str]):
|
||||||
|
scheduler._server_ansible_runner = fn
|
||||||
|
|||||||
@@ -30,3 +30,10 @@ Pillow # Image processing (Windows)
|
|||||||
# WebRTC Video Libraries
|
# WebRTC Video Libraries
|
||||||
###aiortc # Python library for WebRTC in async environments
|
###aiortc # Python library for WebRTC in async environments
|
||||||
###av # Required by aiortc for video/audio codecs
|
###av # Required by aiortc for video/audio codecs
|
||||||
|
|
||||||
|
# Ansible Execution (server-side playbooks)
|
||||||
|
ansible-core
|
||||||
|
ansible-compat
|
||||||
|
ansible-runner
|
||||||
|
paramiko
|
||||||
|
pywinrm
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user