mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-14 21:15:47 -07:00
Standardized Page Title, Subtitle, and Icon Across All Borealis Pages
This commit is contained in:
@@ -18,6 +18,55 @@ import {
|
||||
} from "@mui/material";
|
||||
import UploadIcon from "@mui/icons-material/UploadFile";
|
||||
import ClearIcon from "@mui/icons-material/Clear";
|
||||
import VpnKeyIcon from "@mui/icons-material/VpnKey";
|
||||
|
||||
const MAGIC_UI = {
|
||||
panelBg: "rgba(8,12,24,0.96)",
|
||||
panelBorder: "rgba(148,163,184,0.35)",
|
||||
textBright: "#e2e8f0",
|
||||
textMuted: "#94a3b8",
|
||||
accentA: "#7dd3fc",
|
||||
accentB: "#c084fc",
|
||||
danger: "#ff8a8a",
|
||||
};
|
||||
|
||||
const GRADIENT_BUTTON_SX = {
|
||||
backgroundImage: "linear-gradient(135deg,#7dd3fc,#c084fc)",
|
||||
color: "#041224",
|
||||
borderRadius: 999,
|
||||
textTransform: "none",
|
||||
fontWeight: 700,
|
||||
boxShadow: "0 12px 30px rgba(124,58,237,0.32)",
|
||||
"&:hover": {
|
||||
backgroundImage: "linear-gradient(135deg,#86e1ff,#d1a6ff)",
|
||||
boxShadow: "0 14px 38px rgba(124,58,237,0.42)",
|
||||
},
|
||||
};
|
||||
|
||||
const OUTLINE_BUTTON_SX = {
|
||||
textTransform: "none",
|
||||
borderRadius: 2,
|
||||
borderColor: MAGIC_UI.panelBorder,
|
||||
color: MAGIC_UI.textBright,
|
||||
"&:hover": { borderColor: MAGIC_UI.accentA },
|
||||
};
|
||||
|
||||
const INPUT_SX = {
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "rgba(12,18,35,0.75)",
|
||||
color: MAGIC_UI.textBright,
|
||||
borderRadius: 2,
|
||||
"& fieldset": { borderColor: MAGIC_UI.panelBorder },
|
||||
"&:hover fieldset": { borderColor: MAGIC_UI.accentA },
|
||||
"&.Mui-focused fieldset": { borderColor: MAGIC_UI.accentA },
|
||||
},
|
||||
"& .MuiOutlinedInput-input": {
|
||||
padding: "10px 12px",
|
||||
},
|
||||
"& .MuiInputLabel-root": {
|
||||
color: MAGIC_UI.textMuted,
|
||||
},
|
||||
};
|
||||
|
||||
const CREDENTIAL_TYPES = [
|
||||
{ value: "machine", label: "Machine" },
|
||||
@@ -280,7 +329,7 @@ export default function CredentialEditor({
|
||||
};
|
||||
|
||||
const title = isEdit ? "Edit Credential" : "Create Credential";
|
||||
const helperStyle = { fontSize: 12, color: "#8a8a8a", mt: 0.5 };
|
||||
const helperStyle = { fontSize: 12, color: MAGIC_UI.textMuted, mt: 0.5 };
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -288,19 +337,40 @@ export default function CredentialEditor({
|
||||
onClose={handleCancel}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
bgcolor: MAGIC_UI.panelBg,
|
||||
color: MAGIC_UI.textBright,
|
||||
border: `1px solid ${MAGIC_UI.panelBorder}`,
|
||||
boxShadow: "0 22px 60px rgba(2,6,23,0.75)",
|
||||
backdropFilter: "blur(14px)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ pb: 1 }}>{title}</DialogTitle>
|
||||
<DialogContent dividers sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<DialogTitle sx={{ pb: 1, display: "flex", alignItems: "center", gap: 1.2 }}>
|
||||
<VpnKeyIcon sx={{ color: MAGIC_UI.accentA }} />
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogContent
|
||||
dividers
|
||||
sx={{ display: "flex", flexDirection: "column", gap: 2, borderColor: MAGIC_UI.panelBorder }}
|
||||
>
|
||||
{fetchingDetail && (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, color: "#aaa" }}>
|
||||
<CircularProgress size={18} sx={{ color: "#58a6ff" }} />
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, color: MAGIC_UI.textMuted }}>
|
||||
<CircularProgress size={18} sx={{ color: MAGIC_UI.accentA }} />
|
||||
<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
|
||||
sx={{
|
||||
bgcolor: "rgba(255,124,124,0.1)",
|
||||
border: `1px solid ${MAGIC_UI.panelBorder}`,
|
||||
borderRadius: 2,
|
||||
p: 1.5,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: MAGIC_UI.danger }}>{error}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<TextField
|
||||
@@ -309,10 +379,7 @@ export default function CredentialEditor({
|
||||
onChange={updateField("name")}
|
||||
required
|
||||
disabled={disableSave}
|
||||
sx={{
|
||||
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
|
||||
"& label": { color: "#888" }
|
||||
}}
|
||||
sx={INPUT_SX}
|
||||
/>
|
||||
<TextField
|
||||
label="Description"
|
||||
@@ -321,19 +388,16 @@ export default function CredentialEditor({
|
||||
disabled={disableSave}
|
||||
multiline
|
||||
minRows={2}
|
||||
sx={{
|
||||
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
|
||||
"& label": { color: "#888" }
|
||||
}}
|
||||
sx={INPUT_SX}
|
||||
/>
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 2 }}>
|
||||
<FormControl sx={{ minWidth: 220 }} size="small" disabled={disableSave}>
|
||||
<InputLabel sx={{ color: "#aaa" }}>Site</InputLabel>
|
||||
<InputLabel sx={{ color: MAGIC_UI.textMuted }}>Site</InputLabel>
|
||||
<Select
|
||||
value={form.site_id}
|
||||
label="Site"
|
||||
onChange={updateField("site_id")}
|
||||
sx={{ bgcolor: "#1f1f1f", color: "#fff" }}
|
||||
sx={INPUT_SX}
|
||||
>
|
||||
<MenuItem value="">(None)</MenuItem>
|
||||
{sites.map((site) => (
|
||||
@@ -344,12 +408,12 @@ export default function CredentialEditor({
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl sx={{ minWidth: 180 }} size="small" disabled={disableSave}>
|
||||
<InputLabel sx={{ color: "#aaa" }}>Credential Type</InputLabel>
|
||||
<InputLabel sx={{ color: MAGIC_UI.textMuted }}>Credential Type</InputLabel>
|
||||
<Select
|
||||
value={form.credential_type}
|
||||
label="Credential Type"
|
||||
onChange={updateField("credential_type")}
|
||||
sx={{ bgcolor: "#1f1f1f", color: "#fff" }}
|
||||
sx={INPUT_SX}
|
||||
>
|
||||
{CREDENTIAL_TYPES.map((opt) => (
|
||||
<MenuItem key={opt.value} value={opt.value}>{opt.label}</MenuItem>
|
||||
@@ -357,12 +421,12 @@ export default function CredentialEditor({
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl sx={{ minWidth: 180 }} size="small" disabled={disableSave}>
|
||||
<InputLabel sx={{ color: "#aaa" }}>Connection</InputLabel>
|
||||
<InputLabel sx={{ color: MAGIC_UI.textMuted }}>Connection</InputLabel>
|
||||
<Select
|
||||
value={form.connection_type}
|
||||
label="Connection"
|
||||
onChange={updateField("connection_type")}
|
||||
sx={{ bgcolor: "#1f1f1f", color: "#fff" }}
|
||||
sx={INPUT_SX}
|
||||
>
|
||||
{CONNECTION_TYPES.map((opt) => (
|
||||
<MenuItem key={opt.value} value={opt.value}>{opt.label}</MenuItem>
|
||||
@@ -375,10 +439,7 @@ export default function CredentialEditor({
|
||||
value={form.username}
|
||||
onChange={updateField("username")}
|
||||
disabled={disableSave}
|
||||
sx={{
|
||||
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
|
||||
"& label": { color: "#888" }
|
||||
}}
|
||||
sx={INPUT_SX}
|
||||
/>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<TextField
|
||||
@@ -387,15 +448,11 @@ export default function CredentialEditor({
|
||||
value={form.password}
|
||||
onChange={updateField("password")}
|
||||
disabled={disableSave}
|
||||
sx={{
|
||||
flex: 1,
|
||||
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
|
||||
"& label": { color: "#888" }
|
||||
}}
|
||||
sx={{ flex: 1, ...INPUT_SX }}
|
||||
/>
|
||||
{isEdit && currentCredentialFlags.hasPassword && !passwordDirty && !clearPassword && (
|
||||
<Tooltip title="Clear stored password">
|
||||
<IconButton size="small" onClick={() => setClearPassword(true)} sx={{ color: "#ff8080" }}>
|
||||
<IconButton size="small" onClick={() => setClearPassword(true)} sx={{ color: MAGIC_UI.danger }}>
|
||||
<ClearIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
@@ -405,7 +462,7 @@ export default function CredentialEditor({
|
||||
<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>
|
||||
<Typography sx={{ ...helperStyle, color: MAGIC_UI.danger }}>Password will be removed when saving.</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: "flex", gap: 1, alignItems: "flex-start" }}>
|
||||
@@ -419,8 +476,8 @@ export default function CredentialEditor({
|
||||
maxRows={12}
|
||||
sx={{
|
||||
flex: 1,
|
||||
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff", fontFamily: "monospace" },
|
||||
"& label": { color: "#888" }
|
||||
...INPUT_SX,
|
||||
"& .MuiOutlinedInput-input": { fontFamily: "monospace" },
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
@@ -428,14 +485,14 @@ export default function CredentialEditor({
|
||||
component="label"
|
||||
startIcon={<UploadIcon />}
|
||||
disabled={disableSave}
|
||||
sx={{ alignSelf: "center", borderColor: "#58a6ff", color: "#58a6ff" }}
|
||||
sx={{ alignSelf: "center", ...OUTLINE_BUTTON_SX }}
|
||||
>
|
||||
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" }}>
|
||||
<IconButton size="small" onClick={() => setClearPrivateKey(true)} sx={{ color: MAGIC_UI.danger }}>
|
||||
<ClearIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
@@ -445,7 +502,7 @@ export default function CredentialEditor({
|
||||
<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>
|
||||
<Typography sx={{ ...helperStyle, color: MAGIC_UI.danger }}>Private key will be removed when saving.</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
@@ -455,15 +512,11 @@ export default function CredentialEditor({
|
||||
value={form.private_key_passphrase}
|
||||
onChange={updateField("private_key_passphrase")}
|
||||
disabled={disableSave}
|
||||
sx={{
|
||||
flex: 1,
|
||||
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
|
||||
"& label": { color: "#888" }
|
||||
}}
|
||||
sx={{ flex: 1, ...INPUT_SX }}
|
||||
/>
|
||||
{isEdit && currentCredentialFlags.hasPrivateKeyPassphrase && !passphraseDirty && !clearPassphrase && (
|
||||
<Tooltip title="Clear stored passphrase">
|
||||
<IconButton size="small" onClick={() => setClearPassphrase(true)} sx={{ color: "#ff8080" }}>
|
||||
<IconButton size="small" onClick={() => setClearPassphrase(true)} sx={{ color: MAGIC_UI.danger }}>
|
||||
<ClearIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
@@ -473,17 +526,17 @@ export default function CredentialEditor({
|
||||
<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>
|
||||
<Typography sx={{ ...helperStyle, color: MAGIC_UI.danger }}>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>
|
||||
<InputLabel sx={{ color: MAGIC_UI.textMuted }}>Privilege Escalation</InputLabel>
|
||||
<Select
|
||||
value={form.become_method}
|
||||
label="Privilege Escalation"
|
||||
onChange={updateField("become_method")}
|
||||
sx={{ bgcolor: "#1f1f1f", color: "#fff" }}
|
||||
sx={INPUT_SX}
|
||||
>
|
||||
{BECOME_METHODS.map((opt) => (
|
||||
<MenuItem key={opt.value || "none"} value={opt.value}>{opt.label}</MenuItem>
|
||||
@@ -495,12 +548,7 @@ export default function CredentialEditor({
|
||||
value={form.become_username}
|
||||
onChange={updateField("become_username")}
|
||||
disabled={disableSave}
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 200,
|
||||
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
|
||||
"& label": { color: "#888" }
|
||||
}}
|
||||
sx={{ flex: 1, minWidth: 200, ...INPUT_SX }}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
@@ -510,15 +558,11 @@ export default function CredentialEditor({
|
||||
value={form.become_password}
|
||||
onChange={updateField("become_password")}
|
||||
disabled={disableSave}
|
||||
sx={{
|
||||
flex: 1,
|
||||
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
|
||||
"& label": { color: "#888" }
|
||||
}}
|
||||
sx={{ flex: 1, ...INPUT_SX }}
|
||||
/>
|
||||
{isEdit && currentCredentialFlags.hasBecomePassword && !becomePasswordDirty && !clearBecomePassword && (
|
||||
<Tooltip title="Clear stored escalation password">
|
||||
<IconButton size="small" onClick={() => setClearBecomePassword(true)} sx={{ color: "#ff8080" }}>
|
||||
<IconButton size="small" onClick={() => setClearBecomePassword(true)} sx={{ color: MAGIC_UI.danger }}>
|
||||
<ClearIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
@@ -528,20 +572,20 @@ export default function CredentialEditor({
|
||||
<Typography sx={helperStyle}>Escalation password is stored.</Typography>
|
||||
)}
|
||||
{clearBecomePassword && (
|
||||
<Typography sx={{ ...helperStyle, color: "#ffaaaa" }}>Escalation password will be removed when saving.</Typography>
|
||||
<Typography sx={{ ...helperStyle, color: MAGIC_UI.danger }}>Escalation password will be removed when saving.</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||
<Button onClick={handleCancel} sx={{ color: "#58a6ff" }} disabled={loading}>
|
||||
<Button onClick={handleCancel} sx={OUTLINE_BUTTON_SX} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
variant="outlined"
|
||||
sx={{ color: "#58a6ff", borderColor: "#58a6ff" }}
|
||||
variant="contained"
|
||||
sx={GRADIENT_BUTTON_SX}
|
||||
disabled={disableSave}
|
||||
>
|
||||
{loading ? <CircularProgress size={18} sx={{ color: "#58a6ff" }} /> : "Save"}
|
||||
{loading ? <CircularProgress size={18} sx={{ color: "#041224" }} /> : "Save"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
@@ -41,6 +41,18 @@ const myTheme = themeQuartz.withParams({
|
||||
const themeClassName = myTheme.themeName || "ag-theme-quartz";
|
||||
const gridFontFamily = '"IBM Plex Sans", "Helvetica Neue", Arial, sans-serif';
|
||||
const iconFontFamily = '"Quartz Regular"';
|
||||
const gradientButtonSx = {
|
||||
backgroundImage: "linear-gradient(135deg,#7dd3fc,#c084fc)",
|
||||
color: "#0b1220",
|
||||
borderRadius: 999,
|
||||
textTransform: "none",
|
||||
boxShadow: "0 10px 26px rgba(124,58,237,0.28)",
|
||||
"&:hover": {
|
||||
backgroundImage: "linear-gradient(135deg,#86e1ff,#d1a6ff)",
|
||||
boxShadow: "0 12px 34px rgba(124,58,237,0.38)",
|
||||
filter: "none",
|
||||
},
|
||||
};
|
||||
|
||||
function formatTs(ts) {
|
||||
if (!ts) return "-";
|
||||
@@ -62,7 +74,7 @@ function connectionIcon(connection) {
|
||||
return <ComputerIcon fontSize="small" sx={{ mr: 0.6, color: "#58a6ff" }} />;
|
||||
}
|
||||
|
||||
export default function CredentialList({ isAdmin = false }) {
|
||||
export default function CredentialList({ isAdmin = false, onPageMetaChange }) {
|
||||
const [rows, setRows] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -216,6 +228,15 @@ export default function CredentialList({ isAdmin = false }) {
|
||||
fetchCredentials();
|
||||
}, [fetchCredentials]);
|
||||
|
||||
useEffect(() => {
|
||||
onPageMetaChange?.({
|
||||
page_title: "Credentials",
|
||||
page_subtitle: "Stored credentials for remote automation tasks and Ansible playbook runs.",
|
||||
page_icon: LockIcon,
|
||||
});
|
||||
return () => onPageMetaChange?.(null);
|
||||
}, [onPageMetaChange]);
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditorMode("create");
|
||||
setEditingCredential(null);
|
||||
@@ -291,9 +312,11 @@ export default function CredentialList({ isAdmin = false }) {
|
||||
<>
|
||||
<Paper
|
||||
sx={{
|
||||
m: 2,
|
||||
m: 0,
|
||||
p: 0,
|
||||
bgcolor: "transparent",
|
||||
border: "none",
|
||||
boxShadow: "none",
|
||||
fontFamily: gridFontFamily,
|
||||
color: "#f5f7fa",
|
||||
display: "flex",
|
||||
@@ -302,31 +325,32 @@ export default function CredentialList({ isAdmin = false }) {
|
||||
minWidth: 0,
|
||||
minHeight: 420
|
||||
}}
|
||||
elevation={2}
|
||||
elevation={0}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
p: 2,
|
||||
borderBottom: "1px solid #2a2a2a"
|
||||
px: 2,
|
||||
pt: 1,
|
||||
pb: 1,
|
||||
}}
|
||||
>
|
||||
<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" }}
|
||||
sx={{
|
||||
borderColor: "rgba(148,163,184,0.35)",
|
||||
color: "#e2e8f0",
|
||||
textTransform: "none",
|
||||
borderRadius: 999,
|
||||
px: 1.7,
|
||||
minWidth: 86,
|
||||
"&:hover": { borderColor: "rgba(148,163,184,0.55)" },
|
||||
}}
|
||||
onClick={fetchCredentials}
|
||||
disabled={loading}
|
||||
>
|
||||
@@ -336,7 +360,7 @@ export default function CredentialList({ isAdmin = false }) {
|
||||
variant="contained"
|
||||
size="small"
|
||||
startIcon={<AddIcon />}
|
||||
sx={{ bgcolor: "#58a6ff", color: "#0b0f19" }}
|
||||
sx={gradientButtonSx}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
New Credential
|
||||
@@ -352,7 +376,6 @@ export default function CredentialList({ isAdmin = false }) {
|
||||
color: "#7db7ff",
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
borderBottom: "1px solid #2a2a2a"
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={18} sx={{ color: "#58a6ff" }} />
|
||||
@@ -360,7 +383,7 @@ export default function CredentialList({ isAdmin = false }) {
|
||||
</Box>
|
||||
)}
|
||||
{error && (
|
||||
<Box sx={{ px: 2, py: 1.5, color: "#ff8080", borderBottom: "1px solid #2a2a2a" }}>
|
||||
<Box sx={{ px: 2, py: 1.5, color: "#ff8080" }}>
|
||||
<Typography variant="body2">{error}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -13,11 +13,14 @@ import RefreshIcon from "@mui/icons-material/Refresh";
|
||||
import SaveIcon from "@mui/icons-material/Save";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
|
||||
import GitHubIcon from "@mui/icons-material/GitHub";
|
||||
|
||||
const paperSx = {
|
||||
m: 2,
|
||||
m: 0,
|
||||
p: 0,
|
||||
bgcolor: "transparent",
|
||||
border: "none",
|
||||
boxShadow: "none",
|
||||
color: "#f5f7fa",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
@@ -29,17 +32,31 @@ const paperSx = {
|
||||
const fieldSx = {
|
||||
mt: 2,
|
||||
"& .MuiOutlinedInput-root": {
|
||||
bgcolor: "#181818",
|
||||
bgcolor: "rgba(255,255,255,0.04)",
|
||||
color: "#f5f7fa",
|
||||
"& fieldset": { borderColor: "#2a2a2a" },
|
||||
"&:hover fieldset": { borderColor: "#58a6ff" },
|
||||
"&.Mui-focused fieldset": { borderColor: "#58a6ff" }
|
||||
borderRadius: 1,
|
||||
"& fieldset": { borderColor: "rgba(148,163,184,0.35)" },
|
||||
"&:hover fieldset": { borderColor: "rgba(148,163,184,0.55)" },
|
||||
"&.Mui-focused fieldset": { borderColor: "#7dd3fc" }
|
||||
},
|
||||
"& .MuiInputLabel-root": { color: "#bbb" },
|
||||
"& .MuiInputLabel-root.Mui-focused": { color: "#7db7ff" }
|
||||
};
|
||||
|
||||
export default function GithubAPIToken({ isAdmin = false }) {
|
||||
const gradientButtonSx = {
|
||||
backgroundImage: "linear-gradient(135deg,#7dd3fc,#c084fc)",
|
||||
color: "#0b1220",
|
||||
borderRadius: 999,
|
||||
textTransform: "none",
|
||||
boxShadow: "0 10px 26px rgba(124,58,237,0.28)",
|
||||
"&:hover": {
|
||||
backgroundImage: "linear-gradient(135deg,#86e1ff,#d1a6ff)",
|
||||
boxShadow: "0 12px 34px rgba(124,58,237,0.38)",
|
||||
filter: "none",
|
||||
},
|
||||
};
|
||||
|
||||
export default function GithubAPIToken({ isAdmin = false, onPageMetaChange }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [token, setToken] = useState("");
|
||||
@@ -145,6 +162,15 @@ export default function GithubAPIToken({ isAdmin = false }) {
|
||||
setShowToken((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onPageMetaChange?.({
|
||||
page_title: "GitHub API Token",
|
||||
page_subtitle: "Increase GitHub API rate limits for Borealis by storing a personal access token.",
|
||||
page_icon: GitHubIcon,
|
||||
});
|
||||
return () => onPageMetaChange?.(null);
|
||||
}, [onPageMetaChange]);
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<Paper sx={{ m: 2, p: 3, bgcolor: "transparent" }}>
|
||||
@@ -159,23 +185,10 @@ export default function GithubAPIToken({ isAdmin = false }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper sx={paperSx} 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.3 }}>
|
||||
Github API Token
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#aaa" }}>
|
||||
Using a Github "Personal Access Token" increases the Github API rate limits from 60/hr to 5,000/hr. This is important for production Borealis usage as it likes to hit its unauthenticated API limits sometimes despite my best efforts.
|
||||
<br></br>Navigate to{' '}
|
||||
<Paper sx={paperSx} elevation={0}>
|
||||
<Box sx={{ px: 2, py: 2, display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||
<Typography variant="body2" sx={{ color: "#ccc" }}>
|
||||
Using a GitHub Personal Access Token raises rate limits from 60/hr to 5,000/hr. Generate one at{" "}
|
||||
<Link
|
||||
href="https://github.com/settings/tokens"
|
||||
target="_blank"
|
||||
@@ -183,32 +196,26 @@ export default function GithubAPIToken({ isAdmin = false }) {
|
||||
sx={{ color: "#7db7ff" }}
|
||||
>
|
||||
https://github.com/settings/tokens
|
||||
</Link>{' '}
|
||||
❯ <b>Personal Access Tokens ❯ Tokens (Classic) ❯ Generate New Token ❯ New Personal Access Token (Classic)</b>
|
||||
</Typography>
|
||||
|
||||
<br></br>
|
||||
</Link>{" "}
|
||||
under <b>Personal Access Tokens → Tokens (Classic)</b>.
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#ccc" }}>
|
||||
<Box component="span" sx={{ fontWeight: 600 }}>Note:</Box>{' '}
|
||||
<Box component="span" sx={{ fontWeight: 600 }}>Note:</Box>{" "}
|
||||
<Box component="code" sx={{ bgcolor: "#222", px: 0.75, py: 0.25, borderRadius: 1, fontSize: "0.85rem" }}>
|
||||
Borealis Automation Platform
|
||||
</Box>
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#ccc" }}>
|
||||
<Box component="span" sx={{ fontWeight: 600 }}>Scope:</Box>{' '}
|
||||
{" "}
|
||||
<Box component="span" sx={{ fontWeight: 600, ml: 2 }}>Scope:</Box>{" "}
|
||||
<Box component="code" sx={{ bgcolor: "#222", px: 0.75, py: 0.25, borderRadius: 1, fontSize: "0.85rem" }}>
|
||||
public_repo
|
||||
</Box>
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#ccc" }}>
|
||||
<Box component="span" sx={{ fontWeight: 600 }}>Expiration:</Box>{' '}
|
||||
{" "}
|
||||
<Box component="span" sx={{ fontWeight: 600, ml: 2 }}>Expiration:</Box>{" "}
|
||||
<Box component="code" sx={{ bgcolor: "#222", px: 0.75, py: 0.25, borderRadius: 1, fontSize: "0.85rem" }}>
|
||||
No Expiration
|
||||
</Box>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ px: 2, py: 2, display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||
|
||||
<TextField
|
||||
label="Personal Access Token"
|
||||
value={inputValue}
|
||||
@@ -246,13 +253,7 @@ export default function GithubAPIToken({ isAdmin = false }) {
|
||||
onClick={handleSave}
|
||||
disabled={saving || loading}
|
||||
startIcon={!saving ? <SaveIcon /> : null}
|
||||
sx={{
|
||||
bgcolor: "#58a6ff",
|
||||
color: "#0b0f19",
|
||||
minWidth: 88,
|
||||
mr: 1,
|
||||
"&:hover": { bgcolor: "#7db7ff" }
|
||||
}}
|
||||
sx={gradientButtonSx}
|
||||
>
|
||||
{saving ? <CircularProgress size={16} sx={{ color: "#0b0f19" }} /> : "Save"}
|
||||
</Button>
|
||||
@@ -273,7 +274,15 @@ export default function GithubAPIToken({ isAdmin = false }) {
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<RefreshIcon />}
|
||||
sx={{ borderColor: "#58a6ff", color: "#58a6ff" }}
|
||||
sx={{
|
||||
borderColor: "rgba(148,163,184,0.35)",
|
||||
color: "#e2e8f0",
|
||||
textTransform: "none",
|
||||
borderRadius: 999,
|
||||
px: 1.7,
|
||||
minWidth: 86,
|
||||
"&:hover": { borderColor: "rgba(148,163,184,0.55)" },
|
||||
}}
|
||||
onClick={hydrate}
|
||||
disabled={loading || saving}
|
||||
>
|
||||
|
||||
@@ -27,10 +27,11 @@ import {
|
||||
} from "@mui/material";
|
||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
import FilterListIcon from "@mui/icons-material/FilterList";
|
||||
import GroupIcon from "@mui/icons-material/Group";
|
||||
import { ConfirmDeleteDialog } from "../Dialogs.jsx";
|
||||
|
||||
/* ---------- Formatting helpers to keep this page in lockstep with Device_List ---------- */
|
||||
const tablePaperSx = { m: 2, p: 0, bgcolor: "transparent" };
|
||||
const tablePaperSx = { m: 0, p: 0, bgcolor: "transparent", border: "none", boxShadow: "none" };
|
||||
const tableSx = {
|
||||
minWidth: 820,
|
||||
"& th, & td": {
|
||||
@@ -51,6 +52,19 @@ const filterFieldSx = {
|
||||
"&:hover fieldset": { borderColor: "#888" }
|
||||
}
|
||||
};
|
||||
|
||||
const gradientButtonSx = {
|
||||
backgroundImage: "linear-gradient(135deg,#7dd3fc,#c084fc)",
|
||||
color: "#0b1220",
|
||||
borderRadius: 999,
|
||||
textTransform: "none",
|
||||
boxShadow: "0 10px 26px rgba(124,58,237,0.28)",
|
||||
"&:hover": {
|
||||
backgroundImage: "linear-gradient(135deg,#86e1ff,#d1a6ff)",
|
||||
boxShadow: "0 12px 34px rgba(124,58,237,0.38)",
|
||||
filter: "none",
|
||||
},
|
||||
};
|
||||
/* -------------------------------------------------------------------- */
|
||||
|
||||
function formatTs(tsSec) {
|
||||
@@ -69,7 +83,7 @@ async function sha512(text) {
|
||||
return arr.map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export default function UserManagement({ isAdmin = false }) {
|
||||
export default function UserManagement({ isAdmin = false, onPageMetaChange }) {
|
||||
const [rows, setRows] = useState([]); // {username, display_name, role, last_login}
|
||||
const [orderBy, setOrderBy] = useState("username");
|
||||
const [order, setOrder] = useState("asc");
|
||||
@@ -91,6 +105,7 @@ export default function UserManagement({ isAdmin = false }) {
|
||||
const [mfaBusyUser, setMfaBusyUser] = useState(null);
|
||||
const [resetMfaOpen, setResetMfaOpen] = useState(false);
|
||||
const [resetMfaTarget, setResetMfaTarget] = useState(null);
|
||||
const useGlobalHeader = Boolean(onPageMetaChange);
|
||||
|
||||
// Columns and filters
|
||||
const columns = useMemo(() => ([
|
||||
@@ -140,6 +155,15 @@ export default function UserManagement({ isAdmin = false }) {
|
||||
fetchUsers();
|
||||
}, [fetchUsers, isAdmin]);
|
||||
|
||||
useEffect(() => {
|
||||
onPageMetaChange?.({
|
||||
page_title: "User Management",
|
||||
page_subtitle: "Manage platform users, roles, MFA, and credentials.",
|
||||
page_icon: GroupIcon,
|
||||
});
|
||||
return () => onPageMetaChange?.(null);
|
||||
}, [onPageMetaChange]);
|
||||
|
||||
const handleSort = (col) => {
|
||||
if (orderBy === col) setOrder(order === "asc" ? "desc" : "asc");
|
||||
else { setOrderBy(col); setOrder("asc"); }
|
||||
@@ -397,21 +421,13 @@ export default function UserManagement({ isAdmin = false }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Paper sx={tablePaperSx} elevation={2}>
|
||||
<Box sx={{ p: 2, pb: 1, display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>
|
||||
User Management
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: "#aaa" }}>
|
||||
Manage authorized users of the Borealis Automation Platform.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Paper sx={tablePaperSx} elevation={0}>
|
||||
<Box sx={{ p: 2, pb: 1, display: "flex", alignItems: "center", justifyContent: "flex-end", gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={openCreate}
|
||||
sx={{ color: "#58a6ff", borderColor: "#58a6ff", textTransform: "none" }}
|
||||
sx={gradientButtonSx}
|
||||
>
|
||||
Create User
|
||||
</Button>
|
||||
|
||||
@@ -109,7 +109,7 @@ function formatTimestamp(ts) {
|
||||
return ts;
|
||||
}
|
||||
|
||||
export default function LogManagement({ isAdmin = false }) {
|
||||
export default function LogManagement({ isAdmin = false, onPageMetaChange }) {
|
||||
const [logs, setLogs] = useState([]);
|
||||
const [defaultRetention, setDefaultRetention] = useState(30);
|
||||
const [selectedDomain, setSelectedDomain] = useState(null);
|
||||
@@ -124,6 +124,7 @@ export default function LogManagement({ isAdmin = false }) {
|
||||
const [actionMessage, setActionMessage] = useState(null);
|
||||
const [quickFilter, setQuickFilter] = useState("");
|
||||
const gridRef = useRef(null);
|
||||
const useGlobalHeader = Boolean(onPageMetaChange);
|
||||
|
||||
const logMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
@@ -214,6 +215,15 @@ const defaultColDef = useMemo(
|
||||
if (isAdmin) fetchLogs();
|
||||
}, [fetchLogs, isAdmin]);
|
||||
|
||||
useEffect(() => {
|
||||
onPageMetaChange?.({
|
||||
page_title: "Log Management",
|
||||
page_subtitle: "Analyze engine logs and adjust retention across services.",
|
||||
page_icon: LogsIcon,
|
||||
});
|
||||
return () => onPageMetaChange?.(null);
|
||||
}, [onPageMetaChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!logs.length) {
|
||||
setSelectedDomain(null);
|
||||
@@ -317,7 +327,7 @@ const defaultColDef = useMemo(
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={3}
|
||||
elevation={0}
|
||||
sx={{
|
||||
m: 0,
|
||||
p: 0,
|
||||
@@ -329,38 +339,55 @@ const defaultColDef = useMemo(
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ px: 3, pt: 3, pb: 1, borderBottom: `1px solid ${AURORA_SHELL.border}` }}>
|
||||
<Stack direction="row" spacing={1.25} alignItems="center">
|
||||
<LogsIcon sx={{ fontSize: 22, color: AURORA_SHELL.accent, mt: 0.25 }} />
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.5,
|
||||
color: AURORA_SHELL.text,
|
||||
}}
|
||||
>
|
||||
Log Management
|
||||
</Typography>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={() => {
|
||||
fetchLogs();
|
||||
if (selectedFile) fetchEntries(selectedFile);
|
||||
{!useGlobalHeader && (
|
||||
<Box sx={{ px: 3, pt: 3, pb: 1, borderBottom: `1px solid ${AURORA_SHELL.border}` }}>
|
||||
<Stack direction="row" spacing={1.25} alignItems="center">
|
||||
<LogsIcon sx={{ fontSize: 22, color: AURORA_SHELL.accent, mt: 0.25 }} />
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.5,
|
||||
color: AURORA_SHELL.text,
|
||||
}}
|
||||
sx={gradientButtonSx}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
Log Management
|
||||
</Typography>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={() => {
|
||||
fetchLogs();
|
||||
if (selectedFile) fetchEntries(selectedFile);
|
||||
}}
|
||||
sx={gradientButtonSx}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ color: AURORA_SHELL.muted, mt: 0.75, mb: 2.5 }}>
|
||||
Analyze engine logs and adjust log retention periods for different engine services.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: AURORA_SHELL.muted, mt: 0.75, mb: 2.5 }}>
|
||||
Analyze engine logs and adjust log retention periods for different engine services.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{useGlobalHeader && (
|
||||
<Box sx={{ px: 3, pt: 2, pb: 1, display: "flex", justifyContent: "flex-end" }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={() => {
|
||||
fetchLogs();
|
||||
if (selectedFile) fetchEntries(selectedFile);
|
||||
}}
|
||||
sx={gradientButtonSx}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Box sx={{ px: 3, pt: 2 }}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useRef, useCallback } from "react";
|
||||
import React, { useMemo, useRef, useCallback, useEffect } from "react";
|
||||
import {
|
||||
Paper,
|
||||
Box,
|
||||
@@ -215,7 +215,7 @@ const iconFontFamily = "'Quartz Regular'";
|
||||
// -----------------------------------------------------------------------------
|
||||
// Page Template Component
|
||||
// -----------------------------------------------------------------------------
|
||||
export default function PageTemplate() {
|
||||
export default function PageTemplate({ onPageMetaChange }) {
|
||||
const gridRef = useRef(null);
|
||||
const columnDefs = useMemo(() => sampleColumnDefs, []);
|
||||
const getRowId = useCallback((params) => params.data?.id || String(params.rowIndex ?? ""), []);
|
||||
@@ -224,6 +224,15 @@ export default function PageTemplate() {
|
||||
console.log("Refresh clicked (template; no-op).");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onPageMetaChange?.({
|
||||
page_title: "Page Template",
|
||||
page_subtitle: "Page Styling Guide and Template — use as a baseline when designing new pages.",
|
||||
page_icon: TemplateIcon,
|
||||
});
|
||||
return () => onPageMetaChange?.(null);
|
||||
}, [onPageMetaChange]);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
@@ -243,68 +252,66 @@ export default function PageTemplate() {
|
||||
}}
|
||||
elevation={0}
|
||||
>
|
||||
{/* Page header (keep padding: top 24px, left/right 24px) */}
|
||||
<Box sx={{ px: 3, pt: 3, pb: 1 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1.25}>
|
||||
<TemplateIcon sx={{ fontSize: 22, color: AURORA_SHELL.accent }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, letterSpacing: 0.5 }}>
|
||||
Page Template
|
||||
</Typography>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Tooltip title="Refresh">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleRefresh}
|
||||
sx={{
|
||||
color: "#cbd5e1",
|
||||
borderRadius: 1,
|
||||
"&:hover": { color: "#ffffff" },
|
||||
}}
|
||||
>
|
||||
<CachedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="New (example)">
|
||||
<span>
|
||||
<Button size="small" startIcon={<AddIcon />} sx={gradientButtonSx}>
|
||||
New Item
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Settings (example)">
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<TuneIcon />}
|
||||
sx={{
|
||||
borderColor: "rgba(148,163,184,0.35)",
|
||||
color: "#e2e8f0",
|
||||
textTransform: "none",
|
||||
borderRadius: 999,
|
||||
px: 1.7, // ~5% wider than previous
|
||||
minWidth: 86,
|
||||
"&:hover": { borderColor: "rgba(148,163,184,0.55)" },
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<Box
|
||||
sx={{
|
||||
px: 2,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 1,
|
||||
mt: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||
<Tooltip title="Refresh">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleRefresh}
|
||||
sx={{
|
||||
color: "#cbd5e1",
|
||||
borderRadius: 1,
|
||||
"&:hover": { color: "#ffffff" },
|
||||
}}
|
||||
>
|
||||
<CachedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Tooltip title="New (example)">
|
||||
<span>
|
||||
<Button size="small" startIcon={<AddIcon />} sx={gradientButtonSx}>
|
||||
New Item
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Settings (example)">
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<TuneIcon />}
|
||||
sx={{
|
||||
borderColor: "rgba(148,163,184,0.35)",
|
||||
color: "#e2e8f0",
|
||||
textTransform: "none",
|
||||
borderRadius: 999,
|
||||
px: 1.7, // ~5% wider than previous
|
||||
minWidth: 86,
|
||||
"&:hover": { borderColor: "rgba(148,163,184,0.55)" },
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
|
||||
{/* Subtitle directly under title (muted color, small size) */}
|
||||
<Typography variant="body2" sx={{ color: AURORA_SHELL.subtext, mt: 0.75 }}>
|
||||
Page Styling Guide and Template — use as a baseline when designing new pages.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Content area — add a little more top-space below subtitle */}
|
||||
<Box sx={{ mt: "28px", px: 2, pb: 2, flexGrow: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||
{/* Content area — offset a little below the shared header */}
|
||||
<Box sx={{ mt: "10px", px: 2, pb: 2, flexGrow: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||
<Box
|
||||
className={themeClassName}
|
||||
sx={{
|
||||
|
||||
@@ -3,7 +3,20 @@ import { Paper, Box, Typography, Button } from "@mui/material";
|
||||
import { GitHub as GitHubIcon, InfoOutlined as InfoIcon } from "@mui/icons-material";
|
||||
import { CreditsDialog } from "../Dialogs.jsx";
|
||||
|
||||
export default function ServerInfo({ isAdmin = false }) {
|
||||
const gradientButtonSx = {
|
||||
backgroundImage: "linear-gradient(135deg,#7dd3fc,#c084fc)",
|
||||
color: "#0b1220",
|
||||
borderRadius: 999,
|
||||
textTransform: "none",
|
||||
boxShadow: "0 10px 26px rgba(124,58,237,0.28)",
|
||||
"&:hover": {
|
||||
backgroundImage: "linear-gradient(135deg,#86e1ff,#d1a6ff)",
|
||||
boxShadow: "0 12px 34px rgba(124,58,237,0.38)",
|
||||
filter: "none",
|
||||
},
|
||||
};
|
||||
|
||||
export default function ServerInfo({ isAdmin = false, onPageMetaChange }) {
|
||||
const [serverTime, setServerTime] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [aboutOpen, setAboutOpen] = useState(false);
|
||||
@@ -29,13 +42,23 @@ export default function ServerInfo({ isAdmin = false }) {
|
||||
return () => { isMounted = false; clearInterval(id); };
|
||||
}, [isAdmin]);
|
||||
|
||||
useEffect(() => {
|
||||
onPageMetaChange?.({
|
||||
page_title: "Server Info",
|
||||
page_subtitle: "Basic server information and project links for debugging and support.",
|
||||
page_icon: InfoIcon,
|
||||
});
|
||||
return () => onPageMetaChange?.(null);
|
||||
}, [onPageMetaChange]);
|
||||
|
||||
if (!isAdmin) return null;
|
||||
|
||||
return (
|
||||
<Paper sx={{ m: 2, p: 0, bgcolor: "transparent" }} elevation={2}>
|
||||
<Paper sx={{ m: 0, p: 0, bgcolor: "transparent", border: "none", boxShadow: "none" }} elevation={0}>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 1 }}>Server Info</Typography>
|
||||
<Typography sx={{ color: '#aaa', mb: 1 }}>Basic server information will appear here for informative and debug purposes.</Typography>
|
||||
<Typography sx={{ color: '#aaa', mb: 1 }}>
|
||||
Basic server information for debug and support. Server time updates automatically every minute.
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'baseline' }}>
|
||||
<Typography sx={{ color: '#ccc', fontWeight: 600, minWidth: 120 }}>Server Time</Typography>
|
||||
<Typography sx={{ color: error ? '#ff6b6b' : '#ddd', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' }}>
|
||||
@@ -44,23 +67,29 @@ export default function ServerInfo({ isAdmin = false }) {
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="subtitle1" sx={{ color: "#58a6ff", mb: 1 }}>Project Links</Typography>
|
||||
<Typography variant="subtitle1" sx={{ color: "#e2e8f0", mb: 1, fontWeight: 600 }}>Project Links</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon={<GitHubIcon />}
|
||||
onClick={() => window.open("https://github.com/bunny-lab-io/Borealis", "_blank")}
|
||||
sx={{ borderColor: '#3a3a3a', color: '#7db7ff' }}
|
||||
sx={{
|
||||
borderColor: "rgba(148,163,184,0.35)",
|
||||
color: "#e2e8f0",
|
||||
textTransform: "none",
|
||||
borderRadius: 999,
|
||||
px: 1.7,
|
||||
minWidth: 86,
|
||||
"&:hover": { borderColor: "rgba(148,163,184,0.55)" },
|
||||
}}
|
||||
>
|
||||
GitHub Project
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
variant="contained"
|
||||
startIcon={<InfoIcon />}
|
||||
onClick={() => setAboutOpen(true)}
|
||||
sx={{ borderColor: '#3a3a3a', color: '#ddd' }}
|
||||
sx={gradientButtonSx}
|
||||
>
|
||||
About Borealis
|
||||
</Button>
|
||||
|
||||
@@ -130,6 +130,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
||||
const pendingPathRef = useRef(null);
|
||||
const quickJobSeedRef = useRef(0);
|
||||
const [notAuthorizedOpen, setNotAuthorizedOpen] = useState(false);
|
||||
const [pageHeader, setPageHeader] = useState({ title: "", subtitle: "", Icon: null });
|
||||
|
||||
// Top-bar search state
|
||||
const SEARCH_CATEGORIES = [
|
||||
@@ -171,6 +172,21 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePageMetaChange = useCallback((meta) => {
|
||||
if (!meta) {
|
||||
setPageHeader({ title: "", subtitle: "", Icon: null });
|
||||
return;
|
||||
}
|
||||
const titleValue = typeof meta.page_title === "string" ? meta.page_title : meta.title;
|
||||
const subtitleValue = typeof meta.page_subtitle === "string" ? meta.page_subtitle : meta.subtitle;
|
||||
const iconValue = meta.page_icon || meta.Icon || null;
|
||||
setPageHeader({
|
||||
title: typeof titleValue === "string" ? titleValue : "",
|
||||
subtitle: typeof subtitleValue === "string" ? subtitleValue : "",
|
||||
Icon: iconValue || null,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const pageToPath = useCallback(
|
||||
(page, options = {}) => {
|
||||
switch (page) {
|
||||
@@ -1076,6 +1092,10 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
||||
[navigateTo, setTabs, setActiveTabId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader({ title: "", subtitle: "", Icon: null });
|
||||
}, [currentPage]);
|
||||
|
||||
const isAdmin = (String(userRole || '').toLowerCase() === 'admin');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1100,6 +1120,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
||||
case "sites":
|
||||
return (
|
||||
<SiteList
|
||||
onPageMetaChange={handlePageMetaChange}
|
||||
onOpenDevicesForSite={(siteName) => {
|
||||
try {
|
||||
localStorage.setItem('device_list_initial_site_filter', String(siteName || ''));
|
||||
@@ -1111,6 +1132,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
||||
case "devices":
|
||||
return (
|
||||
<DeviceList
|
||||
onPageMetaChange={handlePageMetaChange}
|
||||
onSelectDevice={(d) => {
|
||||
navigateTo("device_details", { device: d });
|
||||
}}
|
||||
@@ -1120,6 +1142,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
||||
case "agent_devices":
|
||||
return (
|
||||
<AgentDevices
|
||||
onPageMetaChange={handlePageMetaChange}
|
||||
onSelectDevice={(d) => {
|
||||
navigateTo("device_details", { device: d });
|
||||
}}
|
||||
@@ -1127,13 +1150,14 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
||||
/>
|
||||
);
|
||||
case "ssh_devices":
|
||||
return <SSHDevices onQuickJobLaunch={handleQuickJobLaunch} />;
|
||||
return <SSHDevices onQuickJobLaunch={handleQuickJobLaunch} onPageMetaChange={handlePageMetaChange} />;
|
||||
case "winrm_devices":
|
||||
return <WinRMDevices onQuickJobLaunch={handleQuickJobLaunch} />;
|
||||
return <WinRMDevices onQuickJobLaunch={handleQuickJobLaunch} onPageMetaChange={handlePageMetaChange} />;
|
||||
|
||||
case "filters":
|
||||
return (
|
||||
<DeviceFilterList
|
||||
onPageMetaChange={handlePageMetaChange}
|
||||
refreshToken={filtersRefreshToken}
|
||||
onCreateFilter={() => {
|
||||
setFilterEditorState(null);
|
||||
@@ -1149,6 +1173,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
||||
case "filter_editor":
|
||||
return (
|
||||
<DeviceFilterEditor
|
||||
onPageMetaChange={handlePageMetaChange}
|
||||
initialFilter={filterEditorState}
|
||||
onCancel={() => {
|
||||
setFilterEditorState(null);
|
||||
@@ -1165,6 +1190,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
||||
case "device_details":
|
||||
return (
|
||||
<DeviceDetails
|
||||
onPageMetaChange={handlePageMetaChange}
|
||||
device={selectedDevice}
|
||||
onQuickJobLaunch={handleQuickJobLaunch}
|
||||
onBack={() => {
|
||||
@@ -1177,6 +1203,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
||||
case "jobs":
|
||||
return (
|
||||
<ScheduledJobsList
|
||||
onPageMetaChange={handlePageMetaChange}
|
||||
onCreateJob={() => { setEditingJob(null); navigateTo("create_job"); }}
|
||||
onEditJob={(job) => { setEditingJob(job); navigateTo("create_job"); }}
|
||||
refreshToken={jobsRefreshToken}
|
||||
@@ -1186,6 +1213,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
||||
case "create_job":
|
||||
return (
|
||||
<CreateJob
|
||||
onPageMetaChange={handlePageMetaChange}
|
||||
initialJob={editingJob}
|
||||
quickJobDraft={quickJobDraft}
|
||||
onConsumeQuickJobDraft={handleConsumeQuickJobDraft}
|
||||
@@ -1206,6 +1234,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
||||
case "workflows":
|
||||
return (
|
||||
<AssemblyList
|
||||
onPageMetaChange={handlePageMetaChange}
|
||||
onOpenWorkflow={openWorkflowFromList}
|
||||
onOpenScript={openScriptFromList}
|
||||
userRole={userRole || 'User'}
|
||||
@@ -1215,6 +1244,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
||||
case "assemblies":
|
||||
return (
|
||||
<AssemblyList
|
||||
onPageMetaChange={handlePageMetaChange}
|
||||
onOpenWorkflow={openWorkflowFromList}
|
||||
onOpenScript={openScriptFromList}
|
||||
userRole={userRole || 'User'}
|
||||
@@ -1225,6 +1255,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
||||
return (
|
||||
<AssemblyEditor
|
||||
mode="script"
|
||||
onPageMetaChange={handlePageMetaChange}
|
||||
initialAssembly={assemblyEditorState && assemblyEditorState.mode === 'script' ? assemblyEditorState : null}
|
||||
onConsumeInitialData={() => {
|
||||
setAssemblyEditorState((prev) => (prev && prev.mode === 'script' ? null : prev));
|
||||
@@ -1238,6 +1269,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
||||
return (
|
||||
<AssemblyEditor
|
||||
mode="ansible"
|
||||
onPageMetaChange={handlePageMetaChange}
|
||||
initialAssembly={assemblyEditorState && assemblyEditorState.mode === 'ansible' ? assemblyEditorState : null}
|
||||
onConsumeInitialData={() => {
|
||||
setAssemblyEditorState((prev) => (prev && prev.mode === 'ansible' ? null : prev));
|
||||
@@ -1248,24 +1280,24 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
||||
);
|
||||
|
||||
case "access_credentials":
|
||||
return <CredentialList isAdmin={isAdmin} />;
|
||||
return <CredentialList isAdmin={isAdmin} onPageMetaChange={handlePageMetaChange} />;
|
||||
|
||||
case "access_github_token":
|
||||
return <GithubAPIToken isAdmin={isAdmin} />;
|
||||
return <GithubAPIToken isAdmin={isAdmin} onPageMetaChange={handlePageMetaChange} />;
|
||||
|
||||
case "access_users":
|
||||
return <UserManagement isAdmin={isAdmin} />;
|
||||
return <UserManagement isAdmin={isAdmin} onPageMetaChange={handlePageMetaChange} />;
|
||||
|
||||
case "server_info":
|
||||
return <ServerInfo isAdmin={isAdmin} />;
|
||||
return <ServerInfo isAdmin={isAdmin} onPageMetaChange={handlePageMetaChange} />;
|
||||
case "log_management":
|
||||
return <LogManagement isAdmin={isAdmin} />;
|
||||
return <LogManagement isAdmin={isAdmin} onPageMetaChange={handlePageMetaChange} />;
|
||||
|
||||
case "page_template":
|
||||
return <PageTemplate isAdmin={isAdmin} />;
|
||||
return <PageTemplate isAdmin={isAdmin} onPageMetaChange={handlePageMetaChange} />;
|
||||
|
||||
case "admin_device_approvals":
|
||||
return <DeviceApprovals />;
|
||||
return <DeviceApprovals onPageMetaChange={handlePageMetaChange} />;
|
||||
|
||||
case "workflow-editor":
|
||||
return (
|
||||
@@ -1335,6 +1367,9 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
||||
);
|
||||
}
|
||||
|
||||
const HeaderIcon = pageHeader.Icon;
|
||||
const hasPageHeader = Boolean(pageHeader.title);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={darkTheme}>
|
||||
<CssBaseline />
|
||||
@@ -1484,18 +1519,45 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'auto',
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "auto",
|
||||
minHeight: 0,
|
||||
// Ensure primary page container (usually a Paper with m:2) fills to the bottom
|
||||
'& > *': {
|
||||
alignSelf: 'stretch',
|
||||
minHeight: 'calc(100% - 32px)' // account for typical m:2 top+bottom margins
|
||||
}
|
||||
}}
|
||||
>
|
||||
{renderMainContent()}
|
||||
{hasPageHeader ? (
|
||||
<Box sx={{ px: 3, pt: 3, pb: 1, flexShrink: 0 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.25 }}>
|
||||
{HeaderIcon ? <HeaderIcon sx={{ fontSize: 22, color: "#7dd3fc" }} /> : null}
|
||||
<Typography variant="h6" sx={{ color: "#e2e8f0", fontWeight: 700, letterSpacing: 0.5 }}>
|
||||
{pageHeader.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
{pageHeader.subtitle ? (
|
||||
<Typography variant="body2" sx={{ color: "#aaa", mt: 0.5, mb: 2 }}>
|
||||
{pageHeader.subtitle}
|
||||
</Typography>
|
||||
) : (
|
||||
<Box sx={{ mb: 2 }} />
|
||||
)}
|
||||
</Box>
|
||||
) : null}
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "auto",
|
||||
minHeight: 0,
|
||||
// Ensure primary page container (usually a Paper with m:2) fills to the bottom
|
||||
"& > *": {
|
||||
alignSelf: "stretch",
|
||||
minHeight: "calc(100% - 32px)", // account for typical m:2 top+bottom margins
|
||||
},
|
||||
}}
|
||||
>
|
||||
{renderMainContent()}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -17,7 +17,12 @@ import {
|
||||
DialogActions,
|
||||
ListItemText
|
||||
} from "@mui/material";
|
||||
import { Add as AddIcon, Delete as DeleteIcon, UploadFile as UploadFileIcon } from "@mui/icons-material";
|
||||
import {
|
||||
Add as AddIcon,
|
||||
Delete as DeleteIcon,
|
||||
UploadFile as UploadFileIcon,
|
||||
Code as CodeIcon,
|
||||
} from "@mui/icons-material";
|
||||
import Prism from "prismjs";
|
||||
import "prismjs/components/prism-yaml";
|
||||
import "prismjs/components/prism-bash";
|
||||
@@ -119,6 +124,12 @@ const SECTION_CARD_SX = {
|
||||
border: "1px solid #262f3d",
|
||||
};
|
||||
|
||||
const PAGE_ICON = CodeIcon;
|
||||
const PAGE_TITLE_SCRIPT = "Assembly Editor";
|
||||
const PAGE_TITLE_ANSIBLE = "Ansible Assembly Editor";
|
||||
const PAGE_SUBTITLE_SCRIPT = "Edit Borealis script assemblies, variables, and payloads before scheduling.";
|
||||
const PAGE_SUBTITLE_ANSIBLE = "Author Ansible playbooks with Borealis variables, inventory, and credential bindings.";
|
||||
|
||||
const MENU_PROPS = {
|
||||
PaperProps: {
|
||||
sx: {
|
||||
@@ -341,6 +352,7 @@ export default function AssemblyEditor({
|
||||
onConsumeInitialData,
|
||||
onSaved,
|
||||
userRole = "User",
|
||||
onPageMetaChange,
|
||||
}) {
|
||||
const normalizedMode = mode === "ansible" ? "ansible" : "script";
|
||||
const isAnsible = normalizedMode === "ansible";
|
||||
@@ -365,6 +377,24 @@ export default function AssemblyEditor({
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const isAdmin = (userRole || "").toLowerCase() === "admin";
|
||||
|
||||
const pageTitle = useMemo(
|
||||
() => (isAnsible ? PAGE_TITLE_ANSIBLE : PAGE_TITLE_SCRIPT),
|
||||
[isAnsible]
|
||||
);
|
||||
const pageSubtitle = useMemo(
|
||||
() => (isAnsible ? PAGE_SUBTITLE_ANSIBLE : PAGE_SUBTITLE_SCRIPT),
|
||||
[isAnsible]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onPageMetaChange?.({
|
||||
page_title: pageTitle,
|
||||
page_subtitle: pageSubtitle,
|
||||
page_icon: PAGE_ICON,
|
||||
});
|
||||
return () => onPageMetaChange?.(null);
|
||||
}, [onPageMetaChange, pageSubtitle, pageTitle]);
|
||||
|
||||
const TYPE_OPTIONS = useMemo(
|
||||
() => (isAnsible ? TYPE_OPTIONS_ALL.filter((o) => o.key === "ansible") : TYPE_OPTIONS_ALL.filter((o) => o.key !== "ansible")),
|
||||
[isAnsible]
|
||||
|
||||
@@ -232,7 +232,7 @@ const normalizeRow = (item, queueEntry) => {
|
||||
};
|
||||
};
|
||||
|
||||
export default function AssemblyList({ onOpenWorkflow, onOpenScript, userRole = "User" }) {
|
||||
export default function AssemblyList({ onOpenWorkflow, onOpenScript, userRole = "User", onPageMetaChange }) {
|
||||
const gridRef = useRef(null);
|
||||
const [rows, setRows] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -253,6 +253,15 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript, userRole =
|
||||
const [cloneDialog, setCloneDialog] = useState({ open: false, row: null, targetDomain: "user" });
|
||||
const isAdmin = (userRole || "").toLowerCase() === "admin";
|
||||
|
||||
useEffect(() => {
|
||||
onPageMetaChange?.({
|
||||
page_title: "Assemblies",
|
||||
page_subtitle: "Collections of scripts, workflows, and playbooks used to automate tasks across devices.",
|
||||
page_icon: AppsIcon,
|
||||
});
|
||||
return () => onPageMetaChange?.(null);
|
||||
}, [onPageMetaChange]);
|
||||
|
||||
const fetchAssemblies = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
@@ -602,18 +611,7 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript, userRole =
|
||||
}}
|
||||
elevation={0}
|
||||
>
|
||||
<Box sx={{ px: 3, pt: 3, pb: 1 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.25 }}>
|
||||
<AppsIcon sx={{ fontSize: 22, color: "#7dd3fc" }} />
|
||||
<Typography variant="h6" sx={{ color: "#e2e8f0", fontWeight: 700, letterSpacing: 0.5 }}>
|
||||
Assemblies
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: "#aaa", mt: 0.5, mb: 2 }}>
|
||||
Collections of scripts, workflows, and playbooks used to automate tasks across devices.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ px: 2, display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1 }}>
|
||||
<Box sx={{ px: 2, mt: 1, display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
|
||||
@@ -90,7 +90,7 @@ const normalizeStatus = (status) => {
|
||||
return status.toLowerCase();
|
||||
};
|
||||
|
||||
export default function DeviceApprovals() {
|
||||
export default function DeviceApprovals({ onPageMetaChange }) {
|
||||
const [approvals, setApprovals] = useState([]);
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -100,6 +100,7 @@ export default function DeviceApprovals() {
|
||||
const [actioningId, setActioningId] = useState(null);
|
||||
const [conflictPrompt, setConflictPrompt] = useState(null);
|
||||
const gridRef = useRef(null);
|
||||
const useGlobalHeader = Boolean(onPageMetaChange);
|
||||
|
||||
const loadApprovals = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -122,6 +123,15 @@ export default function DeviceApprovals() {
|
||||
|
||||
useEffect(() => { loadApprovals(); }, [loadApprovals]);
|
||||
|
||||
useEffect(() => {
|
||||
onPageMetaChange?.({
|
||||
page_title: "Device Approval Queue",
|
||||
page_subtitle: "Review pending device enrollments and resolve conflicts with existing records.",
|
||||
page_icon: SecurityIcon,
|
||||
});
|
||||
return () => onPageMetaChange?.(null);
|
||||
}, [onPageMetaChange]);
|
||||
|
||||
const dedupedApprovals = useMemo(() => {
|
||||
const normalized = approvals
|
||||
.map((record) => ({ ...record, status: normalizeStatus(record.status) }))
|
||||
@@ -414,22 +424,49 @@ export default function DeviceApprovals() {
|
||||
minWidth: 0,
|
||||
height: "100%",
|
||||
borderRadius: 0,
|
||||
border: `1px solid ${MAGIC_UI.panelBorder}`,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
boxShadow: "0 25px 80px rgba(6, 12, 30, 0.8)",
|
||||
boxShadow: "none",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
elevation={0}
|
||||
>
|
||||
{/* Page header (no solid backdrop so gradient reaches the top) */}
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<SecurityIcon sx={{ color: MAGIC_UI.accentA }} />
|
||||
<Typography variant="h6" sx={{ color: MAGIC_UI.textBright, fontWeight: 700 }}>
|
||||
Device Approval Queue
|
||||
</Typography>
|
||||
{!useGlobalHeader && (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<SecurityIcon sx={{ color: MAGIC_UI.accentA }} />
|
||||
<Typography variant="h6" sx={{ color: MAGIC_UI.textBright, fontWeight: 700 }}>
|
||||
Device Approval Queue
|
||||
</Typography>
|
||||
</Stack>
|
||||
<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={(e) => setStatusFilter(e.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>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Filters under shared header */}
|
||||
{useGlobalHeader && (
|
||||
<Box sx={{ px: 3, pt: 2, pb: 1 }}>
|
||||
<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>
|
||||
@@ -450,8 +487,8 @@ export default function DeviceApprovals() {
|
||||
Refresh
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Feedback */}
|
||||
{feedback && (
|
||||
|
||||
@@ -49,6 +49,8 @@ const MAGIC_UI = {
|
||||
accentC: "#34d399",
|
||||
};
|
||||
|
||||
const PAGE_ICON = DeveloperBoardRoundedIcon;
|
||||
|
||||
const TAB_HOVER_GRADIENT = "linear-gradient(120deg, rgba(125,211,252,0.18), rgba(192,132,252,0.22))";
|
||||
|
||||
const gridFontFamily = '"IBM Plex Sans", "Helvetica Neue", Arial, sans-serif';
|
||||
@@ -249,7 +251,7 @@ const GRID_COMPONENTS = {
|
||||
HistoryActionsCell,
|
||||
};
|
||||
|
||||
export default function DeviceDetails({ device, onBack, onQuickJobLaunch }) {
|
||||
export default function DeviceDetails({ device, onBack, onQuickJobLaunch, onPageMetaChange }) {
|
||||
const [tab, setTab] = useState(0);
|
||||
const [agent, setAgent] = useState(device || {});
|
||||
const [details, setDetails] = useState({});
|
||||
@@ -956,7 +958,7 @@ export default function DeviceDetails({ device, onBack, onQuickJobLaunch }) {
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${MAGIC_UI.panelBorder}`,
|
||||
border: "none",
|
||||
background: 'transparent',
|
||||
boxShadow: "0 18px 40px rgba(2,6,23,0.55)",
|
||||
mb: 1.5,
|
||||
@@ -1494,6 +1496,26 @@ export default function DeviceDetails({ device, onBack, onQuickJobLaunch }) {
|
||||
|
||||
const status = lockedStatus || statusFromHeartbeat(agent.last_seen || device?.lastSeen);
|
||||
|
||||
const displayHostname = meta.hostname || summary.hostname || agent.hostname || device?.hostname || "Device Details";
|
||||
const guidForSubtitle =
|
||||
meta.agentGuid || summary.agent_guid || device?.agent_guid || device?.guid || device?.agentGuid || "";
|
||||
const osLabel =
|
||||
meta.operatingSystem || summary.operating_system || agent.agent_operating_system || agent.operating_system || "";
|
||||
const subtitleParts = [];
|
||||
if (status) subtitleParts.push(`Status: ${status}`);
|
||||
if (osLabel) subtitleParts.push(osLabel);
|
||||
if (guidForSubtitle) subtitleParts.push(`GUID ${guidForSubtitle}`);
|
||||
const pageSubtitle = subtitleParts.join(" | ");
|
||||
|
||||
useEffect(() => {
|
||||
onPageMetaChange?.({
|
||||
page_title: displayHostname,
|
||||
page_subtitle: pageSubtitle,
|
||||
page_icon: PAGE_ICON,
|
||||
});
|
||||
return () => onPageMetaChange?.(null);
|
||||
}, [displayHostname, onPageMetaChange, pageSubtitle]);
|
||||
|
||||
const topTabRenderers = [
|
||||
renderDeviceSummaryTab,
|
||||
renderStorageTab,
|
||||
@@ -1512,7 +1534,7 @@ export default function DeviceDetails({ device, onBack, onQuickJobLaunch }) {
|
||||
borderRadius: 0,
|
||||
background: "transparent",
|
||||
border: `1px solid ${MAGIC_UI.panelBorder}`,
|
||||
boxShadow: MAGIC_UI.glow,
|
||||
boxShadow: "none",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flexGrow: 1,
|
||||
@@ -1529,7 +1551,7 @@ export default function DeviceDetails({ device, onBack, onQuickJobLaunch }) {
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap", minWidth: 0 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap", minWidth: 0 }}>
|
||||
{onBack && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
@@ -1546,27 +1568,19 @@ export default function DeviceDetails({ device, onBack, onQuickJobLaunch }) {
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
<Box>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ color: MAGIC_UI.textBright, display: "flex", alignItems: "center", gap: 1 }}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 10,
|
||||
backgroundColor: statusColor(status),
|
||||
boxShadow: `0 0 12px ${statusColor(status)}`,
|
||||
}}
|
||||
/>
|
||||
{agent.hostname || "Device Details"}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: MAGIC_UI.textMuted }}>
|
||||
GUID: {meta.agentGuid || summary.agent_guid || "unknown"}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 10,
|
||||
backgroundColor: statusColor(status),
|
||||
boxShadow: `0 0 12px ${statusColor(status)}`,
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ color: MAGIC_UI.textMuted }}>
|
||||
GUID: {meta.agentGuid || summary.agent_guid || "unknown"}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
|
||||
@@ -18,6 +18,7 @@ import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
import ViewColumnIcon from "@mui/icons-material/ViewColumn";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import CachedIcon from "@mui/icons-material/Cached";
|
||||
import DevicesOtherIcon from "@mui/icons-material/DevicesOther";
|
||||
import { AgGridReact } from "ag-grid-react";
|
||||
import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community";
|
||||
import { DeleteDeviceDialog, CreateCustomViewDialog, RenameCustomViewDialog } from "../Dialogs.jsx";
|
||||
@@ -64,6 +65,8 @@ const MAGIC_UI = {
|
||||
surfaceOverlay: "rgba(15, 23, 42, 0.72)",
|
||||
};
|
||||
|
||||
const PAGE_ICON = DevicesOtherIcon;
|
||||
|
||||
const StatTile = React.memo(function StatTile({ label, value, meta, gradient }) {
|
||||
return (
|
||||
<Box
|
||||
@@ -344,6 +347,7 @@ export default function DeviceList({
|
||||
showAddButton,
|
||||
addButtonLabel,
|
||||
defaultAddType,
|
||||
onPageMetaChange,
|
||||
}) {
|
||||
const [rows, setRows] = useState([]);
|
||||
const [menuAnchor, setMenuAnchor] = useState(null);
|
||||
@@ -692,6 +696,15 @@ export default function DeviceList({
|
||||
return `Monitoring ${heroStats.total} managed endpoint(s) ${sitePart}.`;
|
||||
}, [heroStats]);
|
||||
|
||||
useEffect(() => {
|
||||
onPageMetaChange?.({
|
||||
page_title: computedTitle,
|
||||
page_subtitle: heroSubtitle,
|
||||
page_icon: PAGE_ICON,
|
||||
});
|
||||
return () => onPageMetaChange?.(null);
|
||||
}, [computedTitle, heroSubtitle, onPageMetaChange]);
|
||||
|
||||
const fetchDevices = useCallback(async (options = {}) => {
|
||||
const { refreshRepo = false } = options || {};
|
||||
let repoSha = repoHash;
|
||||
@@ -1577,16 +1590,21 @@ export default function DeviceList({
|
||||
minWidth: 0,
|
||||
height: "100%",
|
||||
borderRadius: 0,
|
||||
border: `1px solid ${MAGIC_UI.panelBorder}`,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
boxShadow: MAGIC_UI.glow,
|
||||
boxShadow: "none",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
elevation={0}
|
||||
>
|
||||
<Box sx={{ position: "relative", zIndex: 1, p: { xs: 2, md: 0 }, pb: 2 }}>
|
||||
<Box sx={{ borderRadius: 0, border: 'none', background: 'transparent', boxShadow: 'none',
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 0,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
boxShadow: "none",
|
||||
p: { xs: 2, md: 3 },
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
@@ -1596,47 +1614,23 @@ export default function DeviceList({
|
||||
isolation: "isolate",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
flex: "1 1 320px",
|
||||
minWidth: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 1.25,
|
||||
position: "relative",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.6,
|
||||
color: MAGIC_UI.textBright,
|
||||
textShadow: "0 10px 30px rgba(0,0,0,0.45)",
|
||||
}}
|
||||
>
|
||||
{computedTitle}
|
||||
</Typography>
|
||||
<Typography sx={{ color: MAGIC_UI.textMuted, maxWidth: 560 }}>{heroSubtitle}</Typography>
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1 }}>
|
||||
{hasActiveFilters ? (
|
||||
<Box sx={HERO_BADGE_SX}>
|
||||
<span>Filters</span>
|
||||
<Typography component="span" sx={{ fontWeight: 700, fontSize: "0.8rem", color: MAGIC_UI.accentA }}>
|
||||
{activeFilterCount}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
{selectedIds.size > 0 ? (
|
||||
<Box sx={HERO_BADGE_SX}>
|
||||
<span>Selected</span>
|
||||
<Typography component="span" sx={{ fontWeight: 700, fontSize: "0.8rem", color: MAGIC_UI.accentB }}>
|
||||
{selectedIds.size}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
<Box sx={{ flex: "1 1 320px", minWidth: 0, display: "flex", flexWrap: "wrap", gap: 1 }}>
|
||||
{hasActiveFilters ? (
|
||||
<Box sx={HERO_BADGE_SX}>
|
||||
<span>Filters</span>
|
||||
<Typography component="span" sx={{ fontWeight: 700, fontSize: "0.8rem", color: MAGIC_UI.accentA }}>
|
||||
{activeFilterCount}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
{selectedIds.size > 0 ? (
|
||||
<Box sx={HERO_BADGE_SX}>
|
||||
<span>Selected</span>
|
||||
<Typography component="span" sx={{ fontWeight: 700, fontSize: "0.8rem", color: MAGIC_UI.accentB }}>
|
||||
{selectedIds.size}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
<Box sx={{ flex: "1 1 320px", minWidth: 0, position: "relative", zIndex: 1 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(150px, 1fr))", gap: 1.2 }}>
|
||||
|
||||
@@ -38,6 +38,11 @@ const AURORA_SHELL = {
|
||||
accent: "#7dd3fc",
|
||||
};
|
||||
|
||||
const PAGE_TITLE = "Device Filters";
|
||||
const PAGE_SUBTITLE =
|
||||
"Build reusable filter definitions to target devices and assemblies without per-site duplication.";
|
||||
const PAGE_ICON = HeaderIcon;
|
||||
|
||||
const gradientButtonSx = {
|
||||
backgroundImage: "linear-gradient(135deg,#7dd3fc,#c084fc)",
|
||||
color: "#0b1220",
|
||||
@@ -157,7 +162,7 @@ function normalizeFilters(raw) {
|
||||
}));
|
||||
}
|
||||
|
||||
export default function DeviceFilterList({ onCreateFilter, onEditFilter, refreshToken }) {
|
||||
export default function DeviceFilterList({ onCreateFilter, onEditFilter, refreshToken, onPageMetaChange }) {
|
||||
const gridRef = useRef(null);
|
||||
const [rows, setRows] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -188,6 +193,15 @@ export default function DeviceFilterList({ onCreateFilter, onEditFilter, refresh
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onPageMetaChange?.({
|
||||
page_title: PAGE_TITLE,
|
||||
page_subtitle: PAGE_SUBTITLE,
|
||||
page_icon: PAGE_ICON,
|
||||
});
|
||||
return () => onPageMetaChange?.(null);
|
||||
}, [onPageMetaChange]);
|
||||
|
||||
useEffect(() => {
|
||||
loadFilters();
|
||||
}, [loadFilters, refreshToken]);
|
||||
@@ -328,32 +342,7 @@ export default function DeviceFilterList({ onCreateFilter, onEditFilter, refresh
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", mb: 2.5 }}>
|
||||
<Box sx={{ display: "flex", gap: 1.5, alignItems: "flex-start" }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 2,
|
||||
background: "linear-gradient(135deg, rgba(125,211,252,0.28), rgba(192,132,252,0.32))",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "#0f172a",
|
||||
}}
|
||||
>
|
||||
<HeaderIcon fontSize="small" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: "1.35rem", fontWeight: 700, lineHeight: 1.2 }}>
|
||||
Device Filters
|
||||
</Typography>
|
||||
<Typography sx={{ color: AURORA_SHELL.subtext, mt: 0.2 }}>
|
||||
Build reusable filter definitions to target devices and assemblies without per-site duplication.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", alignItems: "center", mb: 2.5 }}>
|
||||
<Stack direction="row" gap={1}>
|
||||
<Tooltip title="Refresh">
|
||||
<IconButton
|
||||
|
||||
@@ -16,27 +16,84 @@ import {
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
CircularProgress
|
||||
CircularProgress,
|
||||
Stack,
|
||||
Tooltip,
|
||||
} 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 LanIcon from "@mui/icons-material/Lan";
|
||||
import DesktopWindowsIcon from "@mui/icons-material/DesktopWindows";
|
||||
import { ConfirmDeleteDialog } from "../Dialogs.jsx";
|
||||
import AddDevice from "./Add_Device.jsx";
|
||||
|
||||
const MAGIC_UI = {
|
||||
panelBg: "linear-gradient(160deg, rgba(7,11,24,0.92), rgba(5,9,20,0.94))",
|
||||
panelBorder: "rgba(148,163,184,0.32)",
|
||||
glass: "rgba(12,18,35,0.8)",
|
||||
textBright: "#e2e8f0",
|
||||
textMuted: "#94a3b8",
|
||||
accentA: "#7dd3fc",
|
||||
accentB: "#c084fc",
|
||||
accentSuccess: "#34d399",
|
||||
accentWarn: "#f97316",
|
||||
glow: "0 24px 60px rgba(2,6,23,0.7)",
|
||||
};
|
||||
|
||||
const gradientButtonSx = {
|
||||
backgroundImage: "linear-gradient(135deg,#7dd3fc,#c084fc)",
|
||||
color: "#0b1220",
|
||||
borderRadius: 999,
|
||||
textTransform: "none",
|
||||
fontWeight: 700,
|
||||
boxShadow: "0 12px 32px rgba(124,58,237,0.32)",
|
||||
px: 2.2,
|
||||
"&:hover": {
|
||||
backgroundImage: "linear-gradient(135deg,#86e1ff,#d1a6ff)",
|
||||
boxShadow: "0 16px 40px rgba(124,58,237,0.42)",
|
||||
},
|
||||
};
|
||||
|
||||
const tableStyles = {
|
||||
minWidth: "100%",
|
||||
"& th, & td": {
|
||||
color: "#ddd",
|
||||
borderColor: "#2a2a2a",
|
||||
color: MAGIC_UI.textBright,
|
||||
borderColor: "rgba(148,163,184,0.18)",
|
||||
fontSize: 13,
|
||||
py: 0.75
|
||||
py: 1,
|
||||
px: 1.5,
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
"& th": {
|
||||
fontWeight: 600
|
||||
fontWeight: 700,
|
||||
backgroundColor: "rgba(5,8,18,0.85)",
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
"& tbody tr": {
|
||||
"&:nth-of-type(odd)": {
|
||||
backgroundColor: "rgba(255,255,255,0.02)",
|
||||
},
|
||||
"&:hover": {
|
||||
backgroundColor: "rgba(125,183,255,0.08)",
|
||||
},
|
||||
},
|
||||
"& th .MuiTableSortLabel-root": { color: MAGIC_UI.textBright },
|
||||
"& th .MuiTableSortLabel-root.Mui-active": { color: MAGIC_UI.textBright },
|
||||
};
|
||||
|
||||
const TYPE_META = {
|
||||
ssh: {
|
||||
label: "SSH Devices",
|
||||
description: "Manage remote endpoints reachable via SSH for playbook execution.",
|
||||
icon: LanIcon,
|
||||
},
|
||||
winrm: {
|
||||
label: "WinRM Devices",
|
||||
description: "Manage remote endpoints reachable via WinRM for playbook execution.",
|
||||
icon: DesktopWindowsIcon,
|
||||
},
|
||||
"& th .MuiTableSortLabel-root": { color: "#ddd" },
|
||||
"& th .MuiTableSortLabel-root.Mui-active": { color: "#ddd" }
|
||||
};
|
||||
|
||||
const defaultForm = {
|
||||
@@ -46,17 +103,16 @@ const defaultForm = {
|
||||
operating_system: ""
|
||||
};
|
||||
|
||||
export default function SSHDevices({ type = "ssh" }) {
|
||||
export default function SSHDevices({ type = "ssh", onPageMetaChange, onQuickJobLaunch }) {
|
||||
const typeLabel = type === "winrm" ? "WinRM" : "SSH";
|
||||
const meta = TYPE_META[type] || TYPE_META.ssh;
|
||||
const apiBase = type === "winrm" ? "/api/winrm_devices" : "/api/ssh_devices";
|
||||
const pageTitle = `${typeLabel} Devices`;
|
||||
const pageTitle = meta.label;
|
||||
const pageSubtitle = meta.description;
|
||||
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([]);
|
||||
@@ -99,6 +155,16 @@ export default function SSHDevices({ type = "ssh" }) {
|
||||
loadDevices();
|
||||
}, [loadDevices]);
|
||||
|
||||
useEffect(() => {
|
||||
const IconComponent = meta.icon || LanIcon;
|
||||
onPageMetaChange?.({
|
||||
page_title: pageTitle,
|
||||
page_subtitle: pageSubtitle,
|
||||
page_icon: IconComponent,
|
||||
});
|
||||
return () => onPageMetaChange?.(null);
|
||||
}, [meta.icon, onPageMetaChange, pageSubtitle, pageTitle]);
|
||||
|
||||
const sortedRows = useMemo(() => {
|
||||
const list = [...rows];
|
||||
list.sort((a, b) => {
|
||||
@@ -229,141 +295,214 @@ export default function SSHDevices({ type = "ssh" }) {
|
||||
}
|
||||
};
|
||||
|
||||
const accentColor = type === "winrm" ? MAGIC_UI.accentB : MAGIC_UI.accentA;
|
||||
const HeaderIconComponent = meta.icon || LanIcon;
|
||||
|
||||
return (
|
||||
<Paper sx={{ m: 2, p: 0, bgcolor: "transparent" }} elevation={2}>
|
||||
<Paper
|
||||
sx={{
|
||||
m: 0,
|
||||
p: 0,
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
boxShadow: "none",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flexGrow: 1,
|
||||
minHeight: 0,
|
||||
}}
|
||||
elevation={0}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
px: 3,
|
||||
pt: 3,
|
||||
pb: 1,
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
p: 2,
|
||||
borderBottom: "1px solid #2a2a2a"
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Tooltip title="Refresh list">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={loadDevices}
|
||||
disabled={loading}
|
||||
sx={{
|
||||
color: MAGIC_UI.textBright,
|
||||
border: `1px solid ${MAGIC_UI.panelBorder}`,
|
||||
borderRadius: 2,
|
||||
background: "rgba(12,18,35,0.65)",
|
||||
"&:hover": { borderColor: MAGIC_UI.accentA },
|
||||
}}
|
||||
>
|
||||
<RefreshIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
sx={{ bgcolor: "#58a6ff", color: "#0b0f19" }}
|
||||
sx={gradientButtonSx}
|
||||
onClick={openCreate}
|
||||
>
|
||||
{addButtonLabel}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Box sx={{ px: 2, py: 1.5, color: "#ff8080" }}>
|
||||
<Typography variant="body2">{error}</Typography>
|
||||
<Box sx={{ px: 3, pb: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${MAGIC_UI.panelBorder}`,
|
||||
background: "rgba(255,124,124,0.08)",
|
||||
color: "#ffb4b4",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2">{error}</Typography>
|
||||
</Box>
|
||||
</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 sx={{ px: 3, pb: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
px: 2,
|
||||
py: 1.25,
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${MAGIC_UI.panelBorder}`,
|
||||
background: "rgba(12,18,35,0.7)",
|
||||
color: MAGIC_UI.textBright,
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={18} sx={{ color: accentColor }} />
|
||||
<Typography variant="body2">{loadingLabel}</Typography>
|
||||
</Box>
|
||||
</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>
|
||||
<Box sx={{ px: 3, pb: 3, flexGrow: 1, minHeight: 0 }}>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
border: `1px solid ${MAGIC_UI.panelBorder}`,
|
||||
background: MAGIC_UI.panelBg,
|
||||
boxShadow: MAGIC_UI.glow,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
{!sortedRows.length && !loading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} sx={{ textAlign: "center", color: "#888" }}>
|
||||
{emptyLabel}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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: MAGIC_UI.accentA }}
|
||||
onClick={() => openEdit(row)}
|
||||
>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
sx={{ color: "#ff8a8a" }}
|
||||
onClick={() => setDeleteTarget(row)}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{!sortedRows.length && !loading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} sx={{ textAlign: "center", color: MAGIC_UI.textMuted }}>
|
||||
{emptyLabel}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Dialog
|
||||
open={dialogOpen}
|
||||
onClose={handleDialogClose}
|
||||
fullWidth
|
||||
maxWidth="sm"
|
||||
PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
bgcolor: "rgba(8,12,24,0.95)",
|
||||
color: MAGIC_UI.textBright,
|
||||
border: `1px solid ${MAGIC_UI.panelBorder}`,
|
||||
boxShadow: MAGIC_UI.glow,
|
||||
backdropFilter: "blur(14px)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle>{isEdit ? editDialogTitle : newDialogTitle}</DialogTitle>
|
||||
<DialogContent sx={{ display: "flex", flexDirection: "column", gap: 2, mt: 1 }}>
|
||||
@@ -376,12 +515,13 @@ export default function SSHDevices({ type = "ssh" }) {
|
||||
size="small"
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "#1f1f1f",
|
||||
color: "#fff",
|
||||
"& fieldset": { borderColor: "#555" },
|
||||
"&:hover fieldset": { borderColor: "#888" }
|
||||
backgroundColor: "rgba(12,18,35,0.75)",
|
||||
color: MAGIC_UI.textBright,
|
||||
"& fieldset": { borderColor: MAGIC_UI.panelBorder },
|
||||
"&:hover fieldset": { borderColor: accentColor },
|
||||
"&.Mui-focused fieldset": { borderColor: accentColor },
|
||||
},
|
||||
"& .MuiInputLabel-root": { color: "#aaa" }
|
||||
"& .MuiInputLabel-root": { color: MAGIC_UI.textMuted },
|
||||
}}
|
||||
helperText="Hostname used within Borealis (unique)."
|
||||
/>
|
||||
@@ -393,12 +533,13 @@ export default function SSHDevices({ type = "ssh" }) {
|
||||
size="small"
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "#1f1f1f",
|
||||
color: "#fff",
|
||||
"& fieldset": { borderColor: "#555" },
|
||||
"&:hover fieldset": { borderColor: "#888" }
|
||||
backgroundColor: "rgba(12,18,35,0.75)",
|
||||
color: MAGIC_UI.textBright,
|
||||
"& fieldset": { borderColor: MAGIC_UI.panelBorder },
|
||||
"&:hover fieldset": { borderColor: accentColor },
|
||||
"&.Mui-focused fieldset": { borderColor: accentColor },
|
||||
},
|
||||
"& .MuiInputLabel-root": { color: "#aaa" }
|
||||
"& .MuiInputLabel-root": { color: MAGIC_UI.textMuted },
|
||||
}}
|
||||
helperText={`IP or FQDN Borealis can reach over ${typeLabel}.`}
|
||||
/>
|
||||
@@ -410,12 +551,13 @@ export default function SSHDevices({ type = "ssh" }) {
|
||||
size="small"
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "#1f1f1f",
|
||||
color: "#fff",
|
||||
"& fieldset": { borderColor: "#555" },
|
||||
"&:hover fieldset": { borderColor: "#888" }
|
||||
backgroundColor: "rgba(12,18,35,0.75)",
|
||||
color: MAGIC_UI.textBright,
|
||||
"& fieldset": { borderColor: MAGIC_UI.panelBorder },
|
||||
"&:hover fieldset": { borderColor: accentColor },
|
||||
"&.Mui-focused fieldset": { borderColor: accentColor },
|
||||
},
|
||||
"& .MuiInputLabel-root": { color: "#aaa" }
|
||||
"& .MuiInputLabel-root": { color: MAGIC_UI.textMuted },
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
@@ -426,28 +568,33 @@ export default function SSHDevices({ type = "ssh" }) {
|
||||
size="small"
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "#1f1f1f",
|
||||
color: "#fff",
|
||||
"& fieldset": { borderColor: "#555" },
|
||||
"&:hover fieldset": { borderColor: "#888" }
|
||||
backgroundColor: "rgba(12,18,35,0.75)",
|
||||
color: MAGIC_UI.textBright,
|
||||
"& fieldset": { borderColor: MAGIC_UI.panelBorder },
|
||||
"&:hover fieldset": { borderColor: accentColor },
|
||||
"&.Mui-focused fieldset": { borderColor: accentColor },
|
||||
},
|
||||
"& .MuiInputLabel-root": { color: "#aaa" }
|
||||
"& .MuiInputLabel-root": { color: MAGIC_UI.textMuted },
|
||||
}}
|
||||
/>
|
||||
{error && (
|
||||
<Typography variant="body2" sx={{ color: "#ff8080" }}>
|
||||
<Typography variant="body2" sx={{ color: "#ffb4b4" }}>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={handleDialogClose} sx={{ color: "#58a6ff" }} disabled={submitting}>
|
||||
<Button
|
||||
onClick={handleDialogClose}
|
||||
sx={{ color: MAGIC_UI.textMuted, textTransform: "none" }}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
variant="outlined"
|
||||
sx={{ color: "#58a6ff", borderColor: "#58a6ff" }}
|
||||
variant="contained"
|
||||
sx={gradientButtonSx}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? "Saving..." : "Save"}
|
||||
|
||||
@@ -71,6 +71,10 @@ const MAGIC_UI = {
|
||||
glow: "0 30px 70px rgba(2,6,23,0.85)",
|
||||
};
|
||||
|
||||
const PAGE_ICON = PendingActionsIcon;
|
||||
const PAGE_TITLE = "Create Job";
|
||||
const PAGE_SUBTITLE = "Configure scheduled or immediate jobs against targeted devices or filters.";
|
||||
|
||||
const gridTheme = themeQuartz.withParams({
|
||||
accentColor: "#8b5cf6",
|
||||
backgroundColor: "#070b1a",
|
||||
@@ -793,7 +797,14 @@ function ComponentCard({ comp, onRemove, onVariableChange, errors = {} }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function CreateJob({ onCancel, onCreated, initialJob = null, quickJobDraft = null, onConsumeQuickJobDraft }) {
|
||||
export default function CreateJob({
|
||||
onCancel,
|
||||
onCreated,
|
||||
initialJob = null,
|
||||
quickJobDraft = null,
|
||||
onConsumeQuickJobDraft,
|
||||
onPageMetaChange,
|
||||
}) {
|
||||
const [tab, setTab] = useState(0);
|
||||
const [jobName, setJobName] = useState("");
|
||||
const [pageTitleJobName, setPageTitleJobName] = useState("");
|
||||
@@ -839,6 +850,26 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null, quic
|
||||
const [credentialLoading, setCredentialLoading] = useState(false);
|
||||
const [credentialError, setCredentialError] = useState("");
|
||||
const [selectedCredentialId, setSelectedCredentialId] = useState("");
|
||||
|
||||
const resolvedPageTitle = useMemo(
|
||||
() => (pageTitleJobName ? `Scheduled Job: ${pageTitleJobName}` : PAGE_TITLE),
|
||||
[pageTitleJobName]
|
||||
);
|
||||
const resolvedPageSubtitle = useMemo(() => {
|
||||
if (scheduleType === "immediately") {
|
||||
return "Launch immediately or save as a quick job with your selected assemblies.";
|
||||
}
|
||||
return PAGE_SUBTITLE;
|
||||
}, [scheduleType]);
|
||||
|
||||
useEffect(() => {
|
||||
onPageMetaChange?.({
|
||||
page_title: resolvedPageTitle,
|
||||
page_subtitle: resolvedPageSubtitle,
|
||||
page_icon: PAGE_ICON,
|
||||
});
|
||||
return () => onPageMetaChange?.(null);
|
||||
}, [onPageMetaChange, resolvedPageSubtitle, resolvedPageTitle]);
|
||||
const [useSvcAccount, setUseSvcAccount] = useState(true);
|
||||
const [assembliesPayload, setAssembliesPayload] = useState({ items: [], queue: [] });
|
||||
const [assembliesLoading, setAssembliesLoading] = useState(false);
|
||||
@@ -2863,8 +2894,8 @@ const heroTiles = useMemo(() => {
|
||||
gap: 3,
|
||||
borderRadius: 0,
|
||||
background: "transparent",
|
||||
border: `1px solid ${MAGIC_UI.panelBorder}`,
|
||||
boxShadow: MAGIC_UI.glow,
|
||||
border: "none",
|
||||
boxShadow: "none",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
@@ -2873,25 +2904,9 @@ const heroTiles = useMemo(() => {
|
||||
flexWrap: "wrap",
|
||||
gap: 2,
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.75 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<PendingActionsIcon sx={{ color: MAGIC_UI.accentA }} />
|
||||
<Typography variant="h6" sx={{ color: MAGIC_UI.textBright, fontWeight: 700 }}>
|
||||
Scheduled Job
|
||||
{pageTitleJobName ? (
|
||||
<Box component="span" sx={{ color: "rgba(226,232,240,0.65)", fontWeight: 500 }}>
|
||||
{`: "${pageTitleJobName}"`}
|
||||
</Box>
|
||||
) : null}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ color: MAGIC_UI.textMuted }}>
|
||||
Configure advanced scheduled jobs against one or several targeted devices or device filters.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap" }}>
|
||||
<Button onClick={onCancel} sx={OUTLINE_BUTTON_SX}>
|
||||
Cancel
|
||||
|
||||
@@ -62,6 +62,10 @@ const AURORA_SHELL = {
|
||||
accent: "#7dd3fc",
|
||||
};
|
||||
|
||||
const PAGE_TITLE = "Scheduled Jobs";
|
||||
const PAGE_SUBTITLE = "Monitor scheduled, recurring, and completed Borealis jobs with live status.";
|
||||
const PAGE_ICON = HeaderIcon;
|
||||
|
||||
// Gradient button styling
|
||||
const gradientButtonSx = {
|
||||
backgroundImage: "linear-gradient(135deg,#7dd3fc,#c084fc)",
|
||||
@@ -160,7 +164,7 @@ function ResultsBar({ counts }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken }) {
|
||||
export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken, onPageMetaChange }) {
|
||||
const [rows, setRows] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -171,6 +175,16 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken
|
||||
const [assembliesLoading, setAssembliesLoading] = useState(false);
|
||||
const [assembliesError, setAssembliesError] = useState("");
|
||||
const gridApiRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
onPageMetaChange?.({
|
||||
page_title: PAGE_TITLE,
|
||||
page_subtitle: PAGE_SUBTITLE,
|
||||
page_icon: PAGE_ICON,
|
||||
});
|
||||
return () => onPageMetaChange?.(null);
|
||||
}, [onPageMetaChange]);
|
||||
|
||||
const autoSizeTrackedColumns = useCallback(() => {
|
||||
const api = gridApiRef.current;
|
||||
if (!api) return;
|
||||
@@ -714,64 +728,48 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken
|
||||
}}
|
||||
elevation={0}
|
||||
>
|
||||
{/* Page header (keep padding: top 24px, left/right 24px) */}
|
||||
<Box sx={{ px: 3, pt: 3, pb: 1 }}>
|
||||
<Stack direction="row" alignItems="center" spacing={1.25}>
|
||||
<HeaderIcon sx={{ fontSize: 22, color: AURORA_SHELL.accent }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, letterSpacing: 0.5 }}>
|
||||
Scheduled Jobs
|
||||
</Typography>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Tooltip title="Refresh">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleRefreshClick}
|
||||
sx={{ color: "#cbd5e1", borderRadius: 1, "&:hover": { color: "#ffffff" } }}
|
||||
>
|
||||
<CachedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Create Job">
|
||||
<span>
|
||||
<Button size="small" startIcon={<AddIcon />} sx={gradientButtonSx} onClick={() => onCreateJob && onCreateJob()}>
|
||||
Create Job
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Settings">
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<TuneIcon />}
|
||||
sx={{
|
||||
borderColor: "rgba(148,163,184,0.35)",
|
||||
color: "#e2e8f0",
|
||||
textTransform: "none",
|
||||
borderRadius: 999,
|
||||
px: 1.7,
|
||||
minWidth: 86,
|
||||
"&:hover": { borderColor: "rgba(148,163,184,0.55)" },
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Subtitle directly under title (muted color, small size) */}
|
||||
<Typography variant="body2" sx={{ color: AURORA_SHELL.subtext, mt: 0.75 }}>
|
||||
List of automation jobs with schedules, results, and actions.
|
||||
</Typography>
|
||||
<Box sx={{ px: 3, pt: 3, pb: 1, display: "flex", justifyContent: "flex-end", gap: 1.5 }}>
|
||||
<Tooltip title="Refresh">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleRefreshClick}
|
||||
sx={{ color: "#cbd5e1", borderRadius: 1, "&:hover": { color: "#ffffff" } }}
|
||||
>
|
||||
<CachedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Create Job">
|
||||
<span>
|
||||
<Button size="small" startIcon={<AddIcon />} sx={gradientButtonSx} onClick={() => onCreateJob && onCreateJob()}>
|
||||
Create Job
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Settings">
|
||||
<span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<TuneIcon />}
|
||||
sx={{
|
||||
borderColor: "rgba(148,163,184,0.35)",
|
||||
color: "#e2e8f0",
|
||||
textTransform: "none",
|
||||
borderRadius: 999,
|
||||
px: 1.7,
|
||||
minWidth: 86,
|
||||
"&:hover": { borderColor: "rgba(148,163,184,0.55)" },
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* Content area — a bit more top space below subtitle */}
|
||||
<Box sx={{ mt: "28px", px: 2, pb: 2, flexGrow: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||
<Box sx={{ mt: 2, px: 2, pb: 2, flexGrow: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||
<Box sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", justifyContent: "space-between", gap: 1.5, mb: 2, px: 0.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
|
||||
@@ -48,6 +48,10 @@ const MAGIC_UI = {
|
||||
success: "#34d399",
|
||||
};
|
||||
|
||||
const PAGE_TITLE = "Sites";
|
||||
const PAGE_SUBTITLE = "Manage site enrollment codes, rotate secrets, and open device inventories by site.";
|
||||
const PAGE_ICON = LocationCityIcon;
|
||||
|
||||
const RAINBOW_BUTTON_SX = {
|
||||
borderRadius: 999,
|
||||
textTransform: "none",
|
||||
@@ -65,7 +69,7 @@ const RAINBOW_BUTTON_SX = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function SiteList({ onOpenDevicesForSite }) {
|
||||
export default function SiteList({ onOpenDevicesForSite, onPageMetaChange }) {
|
||||
const [rows, setRows] = useState([]);
|
||||
const [selectedIds, setSelectedIds] = useState(() => new Set());
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
@@ -75,6 +79,15 @@ export default function SiteList({ onOpenDevicesForSite }) {
|
||||
const [rotatingId, setRotatingId] = useState(null);
|
||||
const gridRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
onPageMetaChange?.({
|
||||
page_title: PAGE_TITLE,
|
||||
page_subtitle: PAGE_SUBTITLE,
|
||||
page_icon: PAGE_ICON,
|
||||
});
|
||||
return () => onPageMetaChange?.(null);
|
||||
}, [onPageMetaChange]);
|
||||
|
||||
const fetchSites = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/sites");
|
||||
@@ -216,31 +229,19 @@ export default function SiteList({ onOpenDevicesForSite }) {
|
||||
minWidth: 0,
|
||||
height: "100%",
|
||||
borderRadius: 0,
|
||||
border: `1px solid ${MAGIC_UI.panelBorder}`,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
boxShadow: "0 25px 80px rgba(6, 12, 30, 0.8)",
|
||||
boxShadow: "none",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
elevation={0}
|
||||
>
|
||||
{/* Hero Section Removed — integrated header and buttons */}
|
||||
<Box sx={{ p: { xs: 2, md: 3 }, pb: 1, display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap" }}>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<LocationCityIcon sx={{ color: MAGIC_UI.accentA }} />
|
||||
<Typography variant="h6" sx={{ color: MAGIC_UI.textBright, fontWeight: 700, fontSize: "1.3rem" }}>
|
||||
Managed Sites
|
||||
<Box sx={{ p: { xs: 2, md: 3 }, pb: 1, display: "flex", alignItems: "center", justifyContent: "flex-end", flexWrap: "wrap", gap: 1 }}>
|
||||
{heroStats.selected > 0 ? (
|
||||
<Typography sx={{ color: MAGIC_UI.accentA, fontSize: "0.85rem", fontWeight: 600, mr: 1 }}>
|
||||
{heroStats.selected} selected
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ color: MAGIC_UI.textMuted }}>
|
||||
{`Monitoring ${heroStats.totalDevices} devices across ${heroStats.totalSites} site(s)`}
|
||||
</Typography>
|
||||
{heroStats.selected > 0 && (
|
||||
<Typography sx={{ color: MAGIC_UI.accentA, fontSize: "0.85rem", fontWeight: 600 }}>
|
||||
{heroStats.selected} selected
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
) : null}
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
||||
<Button variant="contained" size="small" startIcon={<AddIcon />} sx={RAINBOW_BUTTON_SX} onClick={() => setCreateOpen(true)}>
|
||||
Create Site
|
||||
|
||||
@@ -5,12 +5,11 @@ Applies to all Borealis frontends. Use `Data/Engine/web-interface/src/Admin/Page
|
||||
## Page Template Reference
|
||||
- Purpose: visual-only baseline for new pages; copy structure but wire your data in real pages.
|
||||
- Header: small Material icon left of the title, subtitle beneath, utility buttons on the top-right.
|
||||
- Shell: full-bleed aurora gradient container; avoid gutters on the Paper.
|
||||
- Shell: avoid gutters on the Paper.
|
||||
- Selection column (for bulk actions): pinned left, square checkboxes, header checkbox enabled, ~52px fixed width, no menu/sort/resize; rely on AG Grid built-ins.
|
||||
- Typography/buttons: IBM Plex Sans, gradient primary buttons, rounded corners (~8px), themed Quartz grid wrapper.
|
||||
|
||||
## MagicUI Styling Language (Visual System)
|
||||
- Aurora shells: gradient backgrounds blending deep navy (#040711) with soft cyan/violet blooms, subtle borders (`rgba(148,163,184,0.35)`), and low, velvety shadows.
|
||||
- Full-bleed canvas: hero shells run edge-to-edge; inset padding lives inside cards so gradients feel immersive.
|
||||
- Glass panels: glassmorphic layers (`rgba(15,23,42,0.7)`), rounded 16–24px corners, blurred backdrops, micro borders, optional radial flares for motion.
|
||||
- Hero storytelling: start views with stat-forward heroes—gradient StatTiles (min 160px) and uppercase pills (HERO_BADGE_SX) summarizing live signals/filters.
|
||||
|
||||
Reference in New Issue
Block a user