ENGINE: Web Asset Fixes

This commit is contained in:
2025-11-01 04:07:15 -06:00
parent 0e13358c7e
commit b815592639
55764 changed files with 22585 additions and 936312 deletions

View File

@@ -0,0 +1,219 @@
import React, { useEffect, useState } from "react";
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Button,
MenuItem,
Typography
} from "@mui/material";
const TYPE_OPTIONS = [
{ value: "ssh", label: "SSH" },
{ value: "winrm", label: "WinRM" }
];
const initialForm = {
hostname: "",
address: "",
description: "",
operating_system: ""
};
export default function AddDevice({
open,
onClose,
defaultType = null,
onCreated
}) {
const [type, setType] = useState(defaultType || "ssh");
const [form, setForm] = useState(initialForm);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (open) {
setType(defaultType || "ssh");
setForm(initialForm);
setError("");
}
}, [open, defaultType]);
const handleClose = () => {
if (submitting) return;
onClose && onClose();
};
const handleChange = (field) => (event) => {
const value = event.target.value;
setForm((prev) => ({ ...prev, [field]: value }));
};
const handleSubmit = async () => {
if (submitting) return;
const trimmedHostname = form.hostname.trim();
const trimmedAddress = form.address.trim();
if (!trimmedHostname) {
setError("Hostname is required.");
return;
}
if (!type) {
setError("Select a device type.");
return;
}
if (!trimmedAddress) {
setError("Address is required.");
return;
}
setSubmitting(true);
setError("");
const payload = {
hostname: trimmedHostname,
address: trimmedAddress,
description: form.description.trim(),
operating_system: form.operating_system.trim()
};
const apiBase = type === "winrm" ? "/api/winrm_devices" : "/api/ssh_devices";
try {
const resp = await fetch(apiBase, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`);
onCreated && onCreated(data.device || null);
onClose && onClose();
} catch (err) {
setError(String(err.message || err));
} finally {
setSubmitting(false);
}
};
const dialogTitle = defaultType
? `Add ${defaultType.toUpperCase()} Device`
: "Add Device";
const typeLabel = (TYPE_OPTIONS.find((opt) => opt.value === type) || TYPE_OPTIONS[0]).label;
return (
<Dialog
open={open}
onClose={handleClose}
fullWidth
maxWidth="sm"
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent sx={{ display: "flex", flexDirection: "column", gap: 2, mt: 1 }}>
{!defaultType && (
<TextField
select
label="Device Type"
size="small"
value={type}
onChange={(e) => setType(e.target.value)}
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#1f1f1f",
color: "#fff",
"& fieldset": { borderColor: "#555" },
"&:hover fieldset": { borderColor: "#888" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
>
{TYPE_OPTIONS.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
</TextField>
)}
<TextField
label="Hostname"
value={form.hostname}
onChange={handleChange("hostname")}
size="small"
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#1f1f1f",
color: "#fff",
"& fieldset": { borderColor: "#555" },
"&:hover fieldset": { borderColor: "#888" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
helperText="Name used inside Borealis."
/>
<TextField
label={`${typeLabel} Address`}
value={form.address}
onChange={handleChange("address")}
size="small"
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#1f1f1f",
color: "#fff",
"& fieldset": { borderColor: "#555" },
"&:hover fieldset": { borderColor: "#888" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
helperText="IP or FQDN reachable from the Borealis server."
/>
<TextField
label="Description"
value={form.description}
onChange={handleChange("description")}
size="small"
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#1f1f1f",
color: "#fff",
"& fieldset": { borderColor: "#555" },
"&:hover fieldset": { borderColor: "#888" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
/>
<TextField
label="Operating System"
value={form.operating_system}
onChange={handleChange("operating_system")}
size="small"
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#1f1f1f",
color: "#fff",
"& fieldset": { borderColor: "#555" },
"&:hover fieldset": { borderColor: "#888" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
/>
{error && (
<Typography variant="body2" sx={{ color: "#ff8080" }}>
{error}
</Typography>
)}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={handleClose} sx={{ color: "#58a6ff" }} disabled={submitting}>
Cancel
</Button>
<Button
onClick={handleSubmit}
variant="outlined"
sx={{ color: "#58a6ff", borderColor: "#58a6ff" }}
disabled={submitting}
>
{submitting ? "Saving..." : "Save"}
</Button>
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,13 @@
import React from "react";
import DeviceList from "./Device_List.jsx";
export default function AgentDevices(props) {
return (
<DeviceList
{...props}
filterMode="agent"
title="Agent Devices"
showAddButton={false}
/>
);
}

View File

@@ -0,0 +1,505 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/Server/WebUI/src/Admin/Device_Approvals.jsx
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
FormControl,
IconButton,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import {
CheckCircleOutline as ApproveIcon,
HighlightOff as DenyIcon,
Refresh as RefreshIcon,
Security as SecurityIcon,
} from "@mui/icons-material";
const STATUS_OPTIONS = [
{ value: "all", label: "All" },
{ value: "pending", label: "Pending" },
{ value: "approved", label: "Approved" },
{ value: "completed", label: "Completed" },
{ value: "denied", label: "Denied" },
{ value: "expired", label: "Expired" },
];
const statusChipColor = {
pending: "warning",
approved: "info",
completed: "success",
denied: "default",
expired: "default",
};
const formatDateTime = (value) => {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
};
const formatFingerprint = (fp) => {
if (!fp) return "—";
const normalized = fp.replace(/[^a-f0-9]/gi, "").toLowerCase();
if (!normalized) return fp;
return normalized.match(/.{1,4}/g)?.join(" ") ?? normalized;
};
const normalizeStatus = (status) => {
if (!status) return "pending";
if (status === "completed") return "completed";
return status.toLowerCase();
};
function DeviceApprovals() {
const [approvals, setApprovals] = useState([]);
const [statusFilter, setStatusFilter] = useState("all");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [feedback, setFeedback] = useState(null);
const [guidInputs, setGuidInputs] = useState({});
const [actioningId, setActioningId] = useState(null);
const [conflictPrompt, setConflictPrompt] = useState(null);
const loadApprovals = useCallback(async () => {
setLoading(true);
setError("");
try {
const query = statusFilter === "all" ? "" : `?status=${encodeURIComponent(statusFilter)}`;
const resp = await fetch(`/api/admin/device-approvals${query}`, { credentials: "include" });
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
throw new Error(body.error || `Request failed (${resp.status})`);
}
const data = await resp.json();
setApprovals(Array.isArray(data.approvals) ? data.approvals : []);
} catch (err) {
setError(err.message || "Unable to load device approvals");
} finally {
setLoading(false);
}
}, [statusFilter]);
useEffect(() => {
loadApprovals();
}, [loadApprovals]);
const dedupedApprovals = useMemo(() => {
const normalized = approvals
.map((record) => ({ ...record, status: normalizeStatus(record.status) }))
.sort((a, b) => {
const left = new Date(a.created_at || 0).getTime();
const right = new Date(b.created_at || 0).getTime();
return left - right;
});
if (statusFilter !== "pending") {
return normalized;
}
const seen = new Set();
const unique = [];
for (const record of normalized) {
const key = record.ssl_key_fingerprint_claimed || record.hostname_claimed || record.id;
if (seen.has(key)) continue;
seen.add(key);
unique.push(record);
}
return unique;
}, [approvals, statusFilter]);
const handleGuidChange = useCallback((id, value) => {
setGuidInputs((prev) => ({ ...prev, [id]: value }));
}, []);
const submitApproval = useCallback(
async (record, overrides = {}) => {
if (!record?.id) return;
setActioningId(record.id);
setFeedback(null);
setError("");
try {
const manualGuid = (guidInputs[record.id] || "").trim();
const payload = {};
const overrideGuidRaw = overrides.guid;
let overrideGuid = "";
if (typeof overrideGuidRaw === "string") {
overrideGuid = overrideGuidRaw.trim();
} else if (overrideGuidRaw != null) {
overrideGuid = String(overrideGuidRaw).trim();
}
if (overrideGuid) {
payload.guid = overrideGuid;
} else if (manualGuid) {
payload.guid = manualGuid;
}
const resolutionRaw = overrides.conflictResolution || overrides.resolution;
if (typeof resolutionRaw === "string" && resolutionRaw.trim()) {
payload.conflict_resolution = resolutionRaw.trim().toLowerCase();
}
const resp = await fetch(`/api/admin/device-approvals/${encodeURIComponent(record.id)}/approve`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(Object.keys(payload).length ? payload : {}),
});
const body = await resp.json().catch(() => ({}));
if (!resp.ok) {
if (resp.status === 409 && body.error === "conflict_resolution_required") {
const conflict = record.hostname_conflict;
const fallbackAlternate =
record.alternate_hostname ||
(record.hostname_claimed ? `${record.hostname_claimed}-1` : "");
if (conflict) {
setConflictPrompt({
record,
conflict,
alternate: fallbackAlternate || "",
});
}
return;
}
throw new Error(body.error || `Approval failed (${resp.status})`);
}
const appliedResolution = (body.conflict_resolution || payload.conflict_resolution || "").toLowerCase();
let successMessage = "Enrollment approved";
if (appliedResolution === "overwrite") {
successMessage = "Enrollment approved; existing device overwritten";
} else if (appliedResolution === "coexist") {
successMessage = "Enrollment approved; devices will co-exist";
} else if (appliedResolution === "auto_merge_fingerprint") {
successMessage = "Enrollment approved; device reconnected with its existing identity";
}
setFeedback({ type: "success", message: successMessage });
await loadApprovals();
} catch (err) {
setFeedback({ type: "error", message: err.message || "Unable to approve request" });
} finally {
setActioningId(null);
}
},
[guidInputs, loadApprovals]
);
const startApprove = useCallback(
(record) => {
if (!record?.id) return;
const status = normalizeStatus(record.status);
if (status !== "pending") return;
const manualGuid = (guidInputs[record.id] || "").trim();
const conflict = record.hostname_conflict;
const requiresPrompt = Boolean(conflict?.requires_prompt ?? record.conflict_requires_prompt);
if (requiresPrompt && !manualGuid) {
const fallbackAlternate =
record.alternate_hostname ||
(record.hostname_claimed ? `${record.hostname_claimed}-1` : "");
setConflictPrompt({
record,
conflict,
alternate: fallbackAlternate || "",
});
return;
}
submitApproval(record);
},
[guidInputs, submitApproval]
);
const handleConflictCancel = useCallback(() => {
setConflictPrompt(null);
}, []);
const handleConflictOverwrite = useCallback(() => {
if (!conflictPrompt?.record) {
setConflictPrompt(null);
return;
}
const { record, conflict } = conflictPrompt;
setConflictPrompt(null);
const conflictGuid = conflict?.guid != null ? String(conflict.guid).trim() : "";
submitApproval(record, {
guid: conflictGuid,
conflictResolution: "overwrite",
});
}, [conflictPrompt, submitApproval]);
const handleConflictCoexist = useCallback(() => {
if (!conflictPrompt?.record) {
setConflictPrompt(null);
return;
}
const { record } = conflictPrompt;
setConflictPrompt(null);
submitApproval(record, {
conflictResolution: "coexist",
});
}, [conflictPrompt, submitApproval]);
const conflictRecord = conflictPrompt?.record;
const conflictInfo = conflictPrompt?.conflict;
const conflictHostname = conflictRecord?.hostname_claimed || conflictRecord?.hostname || "";
const conflictSiteName = conflictInfo?.site_name || "";
const conflictSiteDescriptor = conflictInfo
? conflictSiteName
? `under site ${conflictSiteName}`
: "under site (not assigned)"
: "under site (not assigned)";
const conflictAlternate =
conflictPrompt?.alternate ||
(conflictHostname ? `${conflictHostname}-1` : "hostname-1");
const conflictGuidDisplay = conflictInfo?.guid || "";
const handleDeny = useCallback(
async (record) => {
if (!record?.id) return;
const confirmDeny = window.confirm("Deny this enrollment request?");
if (!confirmDeny) return;
setActioningId(record.id);
setFeedback(null);
setError("");
try {
const resp = await fetch(`/api/admin/device-approvals/${encodeURIComponent(record.id)}/deny`, {
method: "POST",
credentials: "include",
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
throw new Error(body.error || `Deny failed (${resp.status})`);
}
setFeedback({ type: "success", message: "Enrollment denied" });
await loadApprovals();
} catch (err) {
setFeedback({ type: "error", message: err.message || "Unable to deny request" });
} finally {
setActioningId(null);
}
},
[loadApprovals]
);
return (
<Box sx={{ p: 3, display: "flex", flexDirection: "column", gap: 3 }}>
<Stack direction="row" alignItems="center" spacing={2}>
<SecurityIcon color="primary" />
<Typography variant="h5">Device Approval Queue</Typography>
</Stack>
<Paper sx={{ p: 2, display: "flex", flexDirection: "column", gap: 2 }}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ xs: "stretch", sm: "center" }}>
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel id="approval-status-filter-label">Status</InputLabel>
<Select
labelId="approval-status-filter-label"
label="Status"
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value)}
>
{STATUS_OPTIONS.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={loadApprovals}
disabled={loading}
>
Refresh
</Button>
</Stack>
{feedback ? (
<Alert severity={feedback.type} variant="outlined" onClose={() => setFeedback(null)}>
{feedback.message}
</Alert>
) : null}
{error ? (
<Alert severity="error" variant="outlined">
{error}
</Alert>
) : null}
<TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 480 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>Status</TableCell>
<TableCell>Hostname</TableCell>
<TableCell>Fingerprint</TableCell>
<TableCell>Enrollment Code</TableCell>
<TableCell>Created</TableCell>
<TableCell>Updated</TableCell>
<TableCell>Approved By</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={8} align="center">
<Stack direction="row" spacing={1} alignItems="center" justifyContent="center">
<CircularProgress size={20} />
<Typography variant="body2">Loading approvals</Typography>
</Stack>
</TableCell>
</TableRow>
) : dedupedApprovals.length === 0 ? (
<TableRow>
<TableCell colSpan={8} align="center">
<Typography variant="body2" color="text.secondary">
No enrollment requests match this filter.
</Typography>
</TableCell>
</TableRow>
) : (
dedupedApprovals.map((record) => {
const status = normalizeStatus(record.status);
const showActions = status === "pending";
const guidValue = guidInputs[record.id] || "";
const approverDisplay = record.approved_by_username || record.approved_by_user_id;
return (
<TableRow hover key={record.id}>
<TableCell>
<Chip
size="small"
label={status}
color={statusChipColor[status] || "default"}
variant="outlined"
/>
</TableCell>
<TableCell>{record.hostname_claimed || "—"}</TableCell>
<TableCell sx={{ fontFamily: "monospace", whiteSpace: "nowrap" }}>
{formatFingerprint(record.ssl_key_fingerprint_claimed)}
</TableCell>
<TableCell sx={{ fontFamily: "monospace" }}>
{record.enrollment_code_id || "—"}
</TableCell>
<TableCell>{formatDateTime(record.created_at)}</TableCell>
<TableCell>{formatDateTime(record.updated_at)}</TableCell>
<TableCell>{approverDisplay || "—"}</TableCell>
<TableCell align="right">
{showActions ? (
<Stack direction={{ xs: "column", sm: "row" }} spacing={1} alignItems="center">
<TextField
size="small"
label="Optional GUID"
placeholder="Leave empty to auto-generate"
value={guidValue}
onChange={(event) => handleGuidChange(record.id, event.target.value)}
sx={{ minWidth: 200 }}
/>
<Stack direction="row" spacing={1}>
<Tooltip title="Approve enrollment">
<span>
<IconButton
color="success"
onClick={() => startApprove(record)}
disabled={actioningId === record.id}
>
{actioningId === record.id ? (
<CircularProgress color="success" size={20} />
) : (
<ApproveIcon fontSize="small" />
)}
</IconButton>
</span>
</Tooltip>
<Tooltip title="Deny enrollment">
<span>
<IconButton
color="error"
onClick={() => handleDeny(record)}
disabled={actioningId === record.id}
>
<DenyIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</Stack>
</Stack>
) : (
<Typography variant="body2" color="text.secondary">
No actions available
</Typography>
)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
</Paper>
<Dialog
open={Boolean(conflictPrompt)}
onClose={handleConflictCancel}
maxWidth="sm"
fullWidth
>
<DialogTitle>Hostname Conflict</DialogTitle>
<DialogContent dividers>
<Stack spacing={2}>
<DialogContentText>
{conflictHostname
? `Device ${conflictHostname} already exists in the database ${conflictSiteDescriptor}.`
: `A device with this hostname already exists in the database ${conflictSiteDescriptor}.`}
</DialogContentText>
<DialogContentText>
Do you want this device to overwrite the existing device, or allow both to co-exist?
</DialogContentText>
<DialogContentText>
{`Device will be renamed ${conflictAlternate} if you choose to allow both to co-exist.`}
</DialogContentText>
{conflictGuidDisplay ? (
<Typography variant="body2" color="text.secondary">
Existing device GUID: {conflictGuidDisplay}
</Typography>
) : null}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={handleConflictCancel}>Cancel</Button>
<Button onClick={handleConflictCoexist} color="info" variant="outlined">
Allow Both
</Button>
<Button
onClick={handleConflictOverwrite}
color="primary"
variant="contained"
disabled={!conflictGuidDisplay}
>
Overwrite Existing
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
export default React.memo(DeviceApprovals);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,371 @@
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/Server/WebUI/src/Admin/Enrollment_Codes.jsx
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
FormControl,
IconButton,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Tooltip,
Typography,
} from "@mui/material";
import {
ContentCopy as CopyIcon,
DeleteOutline as DeleteIcon,
Refresh as RefreshIcon,
Key as KeyIcon,
} from "@mui/icons-material";
const TTL_PRESETS = [
{ value: 1, label: "1 hour" },
{ value: 3, label: "3 hours" },
{ value: 6, label: "6 hours" },
{ value: 12, label: "12 hours" },
{ value: 24, label: "24 hours" },
];
const statusColor = {
active: "success",
used: "default",
expired: "warning",
};
const maskCode = (code) => {
if (!code) return "—";
const parts = code.split("-");
if (parts.length <= 1) {
const prefix = code.slice(0, 4);
return `${prefix}${"•".repeat(Math.max(0, code.length - prefix.length))}`;
}
return parts
.map((part, idx) => (idx === 0 || idx === parts.length - 1 ? part : "•".repeat(part.length)))
.join("-");
};
const formatDateTime = (value) => {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
};
const determineStatus = (record) => {
if (!record) return "expired";
const maxUses = Number.isFinite(record?.max_uses) ? record.max_uses : 1;
const useCount = Number.isFinite(record?.use_count) ? record.use_count : 0;
if (useCount >= Math.max(1, maxUses || 1)) return "used";
if (!record.expires_at) return "expired";
const expires = new Date(record.expires_at);
if (Number.isNaN(expires.getTime())) return "expired";
return expires.getTime() > Date.now() ? "active" : "expired";
};
function EnrollmentCodes() {
const [codes, setCodes] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [feedback, setFeedback] = useState(null);
const [statusFilter, setStatusFilter] = useState("all");
const [ttlHours, setTtlHours] = useState(6);
const [generating, setGenerating] = useState(false);
const [maxUses, setMaxUses] = useState(2);
const filteredCodes = useMemo(() => {
if (statusFilter === "all") return codes;
return codes.filter((code) => determineStatus(code) === statusFilter);
}, [codes, statusFilter]);
const fetchCodes = useCallback(async () => {
setLoading(true);
setError("");
try {
const query = statusFilter === "all" ? "" : `?status=${encodeURIComponent(statusFilter)}`;
const resp = await fetch(`/api/admin/enrollment-codes${query}`, {
credentials: "include",
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
throw new Error(body.error || `Request failed (${resp.status})`);
}
const data = await resp.json();
setCodes(Array.isArray(data.codes) ? data.codes : []);
} catch (err) {
setError(err.message || "Unable to load enrollment codes");
} finally {
setLoading(false);
}
}, [statusFilter]);
useEffect(() => {
fetchCodes();
}, [fetchCodes]);
const handleGenerate = useCallback(async () => {
setGenerating(true);
setError("");
try {
const resp = await fetch("/api/admin/enrollment-codes", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ttl_hours: ttlHours, max_uses: maxUses }),
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
throw new Error(body.error || `Request failed (${resp.status})`);
}
const created = await resp.json();
setFeedback({ type: "success", message: `Installer code ${created.code} created` });
await fetchCodes();
} catch (err) {
setFeedback({ type: "error", message: err.message || "Failed to create code" });
} finally {
setGenerating(false);
}
}, [fetchCodes, ttlHours, maxUses]);
const handleDelete = useCallback(
async (id) => {
if (!id) return;
const confirmDelete = window.confirm("Delete this unused installer code?");
if (!confirmDelete) return;
setError("");
try {
const resp = await fetch(`/api/admin/enrollment-codes/${encodeURIComponent(id)}`, {
method: "DELETE",
credentials: "include",
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
throw new Error(body.error || `Request failed (${resp.status})`);
}
setFeedback({ type: "success", message: "Installer code deleted" });
await fetchCodes();
} catch (err) {
setFeedback({ type: "error", message: err.message || "Failed to delete code" });
}
},
[fetchCodes]
);
const handleCopy = useCallback((code) => {
if (!code) return;
try {
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(code);
setFeedback({ type: "success", message: "Code copied to clipboard" });
} else {
const textArea = document.createElement("textarea");
textArea.value = code;
textArea.style.position = "fixed";
textArea.style.opacity = "0";
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
setFeedback({ type: "success", message: "Code copied to clipboard" });
}
} catch (err) {
setFeedback({ type: "error", message: err.message || "Unable to copy code" });
}
}, []);
const renderStatusChip = (record) => {
const status = determineStatus(record);
return <Chip size="small" label={status} color={statusColor[status] || "default"} variant="outlined" />;
};
return (
<Box sx={{ p: 3, display: "flex", flexDirection: "column", gap: 3 }}>
<Stack direction="row" alignItems="center" spacing={2}>
<KeyIcon color="primary" />
<Typography variant="h5">Enrollment Installer Codes</Typography>
</Stack>
<Paper sx={{ p: 2, display: "flex", flexDirection: "column", gap: 2 }}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ xs: "stretch", sm: "center" }}>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel id="status-filter-label">Filter</InputLabel>
<Select
labelId="status-filter-label"
label="Filter"
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value)}
>
<MenuItem value="all">All</MenuItem>
<MenuItem value="active">Active</MenuItem>
<MenuItem value="used">Used</MenuItem>
<MenuItem value="expired">Expired</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel id="ttl-select-label">Duration</InputLabel>
<Select
labelId="ttl-select-label"
label="Duration"
value={ttlHours}
onChange={(event) => setTtlHours(Number(event.target.value))}
>
{TTL_PRESETS.map((preset) => (
<MenuItem key={preset.value} value={preset.value}>
{preset.label}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel id="uses-select-label">Allowed Uses</InputLabel>
<Select
labelId="uses-select-label"
label="Allowed Uses"
value={maxUses}
onChange={(event) => setMaxUses(Number(event.target.value))}
>
{[1, 2, 3, 5].map((uses) => (
<MenuItem key={uses} value={uses}>
{uses === 1 ? "Single use" : `${uses} uses`}
</MenuItem>
))}
</Select>
</FormControl>
<Button
variant="contained"
color="primary"
onClick={handleGenerate}
disabled={generating}
startIcon={generating ? <CircularProgress size={16} color="inherit" /> : null}
>
{generating ? "Generating…" : "Generate Code"}
</Button>
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={fetchCodes}
disabled={loading}
>
Refresh
</Button>
</Stack>
{feedback ? (
<Alert
severity={feedback.type}
onClose={() => setFeedback(null)}
variant="outlined"
>
{feedback.message}
</Alert>
) : null}
{error ? (
<Alert severity="error" variant="outlined">
{error}
</Alert>
) : null}
<TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 420 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>Status</TableCell>
<TableCell>Installer Code</TableCell>
<TableCell>Expires At</TableCell>
<TableCell>Created By</TableCell>
<TableCell>Usage</TableCell>
<TableCell>Last Used</TableCell>
<TableCell>Consumed At</TableCell>
<TableCell>Used By GUID</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={7} align="center">
<Stack direction="row" spacing={1} alignItems="center" justifyContent="center">
<CircularProgress size={20} />
<Typography variant="body2">Loading installer codes</Typography>
</Stack>
</TableCell>
</TableRow>
) : filteredCodes.length === 0 ? (
<TableRow>
<TableCell colSpan={7} align="center">
<Typography variant="body2" color="text.secondary">
No installer codes match this filter.
</Typography>
</TableCell>
</TableRow>
) : (
filteredCodes.map((record) => {
const status = determineStatus(record);
const maxAllowed = Math.max(1, Number.isFinite(record?.max_uses) ? record.max_uses : 1);
const usageCount = Math.max(0, Number.isFinite(record?.use_count) ? record.use_count : 0);
const disableDelete = usageCount !== 0;
return (
<TableRow hover key={record.id}>
<TableCell>{renderStatusChip(record)}</TableCell>
<TableCell sx={{ fontFamily: "monospace" }}>{maskCode(record.code)}</TableCell>
<TableCell>{formatDateTime(record.expires_at)}</TableCell>
<TableCell>{record.created_by_user_id || "—"}</TableCell>
<TableCell sx={{ fontFamily: "monospace" }}>{`${usageCount} / ${maxAllowed}`}</TableCell>
<TableCell>{formatDateTime(record.last_used_at)}</TableCell>
<TableCell>{formatDateTime(record.used_at)}</TableCell>
<TableCell sx={{ fontFamily: "monospace" }}>
{record.used_by_guid || "—"}
</TableCell>
<TableCell align="right">
<Tooltip title="Copy code">
<span>
<IconButton
size="small"
onClick={() => handleCopy(record.code)}
disabled={!record.code}
>
<CopyIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title={disableDelete ? "Only unused codes can be deleted" : "Delete code"}>
<span>
<IconButton
size="small"
onClick={() => handleDelete(record.id)}
disabled={disableDelete}
>
<DeleteIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
</Paper>
</Box>
);
}
export default React.memo(EnrollmentCodes);

View File

@@ -0,0 +1,480 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Paper,
Box,
Typography,
Button,
IconButton,
Table,
TableHead,
TableBody,
TableRow,
TableCell,
TableSortLabel,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
CircularProgress
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import EditIcon from "@mui/icons-material/Edit";
import DeleteIcon from "@mui/icons-material/Delete";
import RefreshIcon from "@mui/icons-material/Refresh";
import { ConfirmDeleteDialog } from "../Dialogs.jsx";
import AddDevice from "./Add_Device.jsx";
const tableStyles = {
"& th, & td": {
color: "#ddd",
borderColor: "#2a2a2a",
fontSize: 13,
py: 0.75
},
"& th": {
fontWeight: 600
},
"& th .MuiTableSortLabel-root": { color: "#ddd" },
"& th .MuiTableSortLabel-root.Mui-active": { color: "#ddd" }
};
const defaultForm = {
hostname: "",
address: "",
description: "",
operating_system: ""
};
export default function SSHDevices({ type = "ssh" }) {
const typeLabel = type === "winrm" ? "WinRM" : "SSH";
const apiBase = type === "winrm" ? "/api/winrm_devices" : "/api/ssh_devices";
const pageTitle = `${typeLabel} Devices`;
const addButtonLabel = `Add ${typeLabel} Device`;
const addressLabel = `${typeLabel} Address`;
const loadingLabel = `Loading ${typeLabel} devices…`;
const emptyLabel = `No ${typeLabel} devices have been added yet.`;
const descriptionText = type === "winrm"
? "Manage remote endpoints reachable via WinRM for playbook execution."
: "Manage remote endpoints reachable via SSH for playbook execution.";
const editDialogTitle = `Edit ${typeLabel} Device`;
const newDialogTitle = `New ${typeLabel} Device`;
const [rows, setRows] = useState([]);
const [orderBy, setOrderBy] = useState("hostname");
const [order, setOrder] = useState("asc");
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState(defaultForm);
const [formError, setFormError] = useState("");
const [submitting, setSubmitting] = useState(false);
const [editTarget, setEditTarget] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleteBusy, setDeleteBusy] = useState(false);
const [addDeviceOpen, setAddDeviceOpen] = useState(false);
const isEdit = Boolean(editTarget);
const loadDevices = useCallback(async () => {
setLoading(true);
setError("");
try {
const resp = await fetch(apiBase);
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data?.error || `HTTP ${resp.status}`);
}
const data = await resp.json();
const list = Array.isArray(data?.devices) ? data.devices : [];
setRows(list);
} catch (err) {
setError(String(err.message || err));
setRows([]);
} finally {
setLoading(false);
}
}, [apiBase]);
useEffect(() => {
loadDevices();
}, [loadDevices]);
const sortedRows = useMemo(() => {
const list = [...rows];
list.sort((a, b) => {
const getKey = (row) => {
switch (orderBy) {
case "created_at":
return Number(row.created_at || 0);
case "address":
return (row.connection_endpoint || "").toLowerCase();
case "description":
return (row.description || "").toLowerCase();
default:
return (row.hostname || "").toLowerCase();
}
};
const aKey = getKey(a);
const bKey = getKey(b);
if (aKey < bKey) return order === "asc" ? -1 : 1;
if (aKey > bKey) return order === "asc" ? 1 : -1;
return 0;
});
return list;
}, [rows, order, orderBy]);
const handleSort = (column) => () => {
if (orderBy === column) {
setOrder((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setOrderBy(column);
setOrder("asc");
}
};
const openCreate = () => {
setAddDeviceOpen(true);
setFormError("");
};
const openEdit = (row) => {
setEditTarget(row);
setForm({
hostname: row.hostname || "",
address: row.connection_endpoint || "",
description: row.description || "",
operating_system: row.summary?.operating_system || ""
});
setDialogOpen(true);
setFormError("");
};
const handleDialogClose = () => {
if (submitting) return;
setDialogOpen(false);
setForm(defaultForm);
setEditTarget(null);
setFormError("");
};
const handleSubmit = async () => {
if (submitting) return;
const payload = {
hostname: form.hostname.trim(),
address: form.address.trim(),
description: form.description.trim(),
operating_system: form.operating_system.trim()
};
if (!payload.hostname) {
setFormError("Hostname is required.");
return;
}
if (!payload.address) {
setFormError("Address is required.");
return;
}
setSubmitting(true);
setFormError("");
try {
const endpoint = isEdit
? `${apiBase}/${encodeURIComponent(editTarget.hostname)}`
: apiBase;
const resp = await fetch(endpoint, {
method: isEdit ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
throw new Error(data?.error || `HTTP ${resp.status}`);
}
setDialogOpen(false);
setForm(defaultForm);
setEditTarget(null);
setFormError("");
setRows((prev) => {
const next = [...prev];
if (data?.device) {
const idx = next.findIndex((row) => row.hostname === data.device.hostname);
if (idx >= 0) next[idx] = data.device;
else next.push(data.device);
return next;
}
return prev;
});
// Ensure latest ordering by triggering refresh
loadDevices();
} catch (err) {
setFormError(String(err.message || err));
} finally {
setSubmitting(false);
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
setDeleteBusy(true);
try {
const resp = await fetch(`${apiBase}/${encodeURIComponent(deleteTarget.hostname)}`, {
method: "DELETE"
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`);
setRows((prev) => prev.filter((row) => row.hostname !== deleteTarget.hostname));
setDeleteTarget(null);
} catch (err) {
setError(String(err.message || err));
} finally {
setDeleteBusy(false);
}
};
return (
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
p: 2,
borderBottom: "1px solid #2a2a2a"
}}
>
<Box>
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>
{pageTitle}
</Typography>
<Typography variant="body2" sx={{ color: "#aaa" }}>
{descriptionText}
</Typography>
</Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Button
size="small"
variant="outlined"
startIcon={<RefreshIcon />}
sx={{ color: "#58a6ff", borderColor: "#58a6ff" }}
onClick={loadDevices}
disabled={loading}
>
Refresh
</Button>
<Button
size="small"
variant="contained"
startIcon={<AddIcon />}
sx={{ bgcolor: "#58a6ff", color: "#0b0f19" }}
onClick={openCreate}
>
{addButtonLabel}
</Button>
</Box>
</Box>
{error && (
<Box sx={{ px: 2, py: 1.5, color: "#ff8080" }}>
<Typography variant="body2">{error}</Typography>
</Box>
)}
{loading && (
<Box sx={{ px: 2, py: 1.5, display: "flex", alignItems: "center", gap: 1, color: "#7db7ff" }}>
<CircularProgress size={18} sx={{ color: "#58a6ff" }} />
<Typography variant="body2">{loadingLabel}</Typography>
</Box>
)}
<Table size="small" sx={tableStyles}>
<TableHead>
<TableRow>
<TableCell sortDirection={orderBy === "hostname" ? order : false}>
<TableSortLabel
active={orderBy === "hostname"}
direction={orderBy === "hostname" ? order : "asc"}
onClick={handleSort("hostname")}
>
Hostname
</TableSortLabel>
</TableCell>
<TableCell sortDirection={orderBy === "address" ? order : false}>
<TableSortLabel
active={orderBy === "address"}
direction={orderBy === "address" ? order : "asc"}
onClick={handleSort("address")}
>
{addressLabel}
</TableSortLabel>
</TableCell>
<TableCell sortDirection={orderBy === "description" ? order : false}>
<TableSortLabel
active={orderBy === "description"}
direction={orderBy === "description" ? order : "asc"}
onClick={handleSort("description")}
>
Description
</TableSortLabel>
</TableCell>
<TableCell sortDirection={orderBy === "created_at" ? order : false}>
<TableSortLabel
active={orderBy === "created_at"}
direction={orderBy === "created_at" ? order : "asc"}
onClick={handleSort("created_at")}
>
Added
</TableSortLabel>
</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sortedRows.map((row) => {
const createdTs = Number(row.created_at || 0) * 1000;
const createdDisplay = createdTs
? new Date(createdTs).toLocaleString()
: (row.summary?.created || "");
return (
<TableRow key={row.hostname}>
<TableCell>{row.hostname}</TableCell>
<TableCell>{row.connection_endpoint || ""}</TableCell>
<TableCell>{row.description || ""}</TableCell>
<TableCell>{createdDisplay}</TableCell>
<TableCell align="right">
<IconButton size="small" sx={{ color: "#7db7ff" }} onClick={() => openEdit(row)}>
<EditIcon fontSize="small" />
</IconButton>
<IconButton size="small" sx={{ color: "#ff8080" }} onClick={() => setDeleteTarget(row)}>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
);
})}
{!sortedRows.length && !loading && (
<TableRow>
<TableCell colSpan={5} sx={{ textAlign: "center", color: "#888" }}>
{emptyLabel}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<Dialog
open={dialogOpen}
onClose={handleDialogClose}
fullWidth
maxWidth="sm"
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
>
<DialogTitle>{isEdit ? editDialogTitle : newDialogTitle}</DialogTitle>
<DialogContent sx={{ display: "flex", flexDirection: "column", gap: 2, mt: 1 }}>
<TextField
label="Hostname"
value={form.hostname}
disabled={isEdit}
onChange={(e) => setForm((prev) => ({ ...prev, hostname: e.target.value }))}
fullWidth
size="small"
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#1f1f1f",
color: "#fff",
"& fieldset": { borderColor: "#555" },
"&:hover fieldset": { borderColor: "#888" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
helperText="Hostname used within Borealis (unique)."
/>
<TextField
label={addressLabel}
value={form.address}
onChange={(e) => setForm((prev) => ({ ...prev, address: e.target.value }))}
fullWidth
size="small"
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#1f1f1f",
color: "#fff",
"& fieldset": { borderColor: "#555" },
"&:hover fieldset": { borderColor: "#888" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
helperText={`IP or FQDN Borealis can reach over ${typeLabel}.`}
/>
<TextField
label="Description"
value={form.description}
onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))}
fullWidth
size="small"
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#1f1f1f",
color: "#fff",
"& fieldset": { borderColor: "#555" },
"&:hover fieldset": { borderColor: "#888" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
/>
<TextField
label="Operating System"
value={form.operating_system}
onChange={(e) => setForm((prev) => ({ ...prev, operating_system: e.target.value }))}
fullWidth
size="small"
sx={{
"& .MuiOutlinedInput-root": {
backgroundColor: "#1f1f1f",
color: "#fff",
"& fieldset": { borderColor: "#555" },
"&:hover fieldset": { borderColor: "#888" }
},
"& .MuiInputLabel-root": { color: "#aaa" }
}}
/>
{error && (
<Typography variant="body2" sx={{ color: "#ff8080" }}>
{error}
</Typography>
)}
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={handleDialogClose} sx={{ color: "#58a6ff" }} disabled={submitting}>
Cancel
</Button>
<Button
onClick={handleSubmit}
variant="outlined"
sx={{ color: "#58a6ff", borderColor: "#58a6ff" }}
disabled={submitting}
>
{submitting ? "Saving..." : "Save"}
</Button>
</DialogActions>
</Dialog>
<ConfirmDeleteDialog
open={Boolean(deleteTarget)}
message={
deleteTarget
? `Remove ${typeLabel} device '${deleteTarget.hostname}' from inventory?`
: ""
}
onCancel={() => setDeleteTarget(null)}
onConfirm={handleDelete}
confirmDisabled={deleteBusy}
/>
<AddDevice
open={addDeviceOpen}
defaultType={type}
onClose={() => setAddDeviceOpen(false)}
onCreated={() => {
setAddDeviceOpen(false);
loadDevices();
}}
/>
</Paper>
);
}

View File

@@ -0,0 +1,6 @@
import React from "react";
import SSHDevices from "./SSH_Devices.jsx";
export default function WinRMDevices(props) {
return <SSHDevices {...props} type="winrm" />;
}