Standardized Page Title, Subtitle, and Icon Across All Borealis Pages

This commit is contained in:
2025-11-26 06:36:03 -07:00
parent 6634ecc15f
commit d05877a08f
19 changed files with 1060 additions and 621 deletions

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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>{' '}
&#10095; <b>Personal Access Tokens &#10095; Tokens (Classic) &#10095; Generate New Token &#10095; 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}
>

View File

@@ -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>

View File

@@ -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 }}>

View File

@@ -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={{

View File

@@ -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>

View File

@@ -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>

View File

@@ -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]

View File

@@ -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"

View File

@@ -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 && (

View File

@@ -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

View File

@@ -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 }}>

View File

@@ -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

View File

@@ -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"}

View File

@@ -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

View File

@@ -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={{

View File

@@ -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

View File

@@ -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 1624px 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.