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"; } from "@mui/material";
import UploadIcon from "@mui/icons-material/UploadFile"; import UploadIcon from "@mui/icons-material/UploadFile";
import ClearIcon from "@mui/icons-material/Clear"; 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 = [ const CREDENTIAL_TYPES = [
{ value: "machine", label: "Machine" }, { value: "machine", label: "Machine" },
@@ -280,7 +329,7 @@ export default function CredentialEditor({
}; };
const title = isEdit ? "Edit Credential" : "Create Credential"; 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 ( return (
<Dialog <Dialog
@@ -288,19 +337,40 @@ export default function CredentialEditor({
onClose={handleCancel} onClose={handleCancel}
maxWidth="md" maxWidth="md"
fullWidth 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> <DialogTitle sx={{ pb: 1, display: "flex", alignItems: "center", gap: 1.2 }}>
<DialogContent dividers sx={{ display: "flex", flexDirection: "column", gap: 2 }}> <VpnKeyIcon sx={{ color: MAGIC_UI.accentA }} />
{title}
</DialogTitle>
<DialogContent
dividers
sx={{ display: "flex", flexDirection: "column", gap: 2, borderColor: MAGIC_UI.panelBorder }}
>
{fetchingDetail && ( {fetchingDetail && (
<Box sx={{ display: "flex", alignItems: "center", gap: 1, color: "#aaa" }}> <Box sx={{ display: "flex", alignItems: "center", gap: 1, color: MAGIC_UI.textMuted }}>
<CircularProgress size={18} sx={{ color: "#58a6ff" }} /> <CircularProgress size={18} sx={{ color: MAGIC_UI.accentA }} />
<Typography variant="body2">Loading credential details</Typography> <Typography variant="body2">Loading credential details</Typography>
</Box> </Box>
)} )}
{error && ( {error && (
<Box sx={{ bgcolor: "#2c1c1c", border: "1px solid #663939", borderRadius: 1, p: 1 }}> <Box
<Typography variant="body2" sx={{ color: "#ff8080" }}>{error}</Typography> 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> </Box>
)} )}
<TextField <TextField
@@ -309,10 +379,7 @@ export default function CredentialEditor({
onChange={updateField("name")} onChange={updateField("name")}
required required
disabled={disableSave} disabled={disableSave}
sx={{ sx={INPUT_SX}
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
"& label": { color: "#888" }
}}
/> />
<TextField <TextField
label="Description" label="Description"
@@ -321,19 +388,16 @@ export default function CredentialEditor({
disabled={disableSave} disabled={disableSave}
multiline multiline
minRows={2} minRows={2}
sx={{ sx={INPUT_SX}
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
"& label": { color: "#888" }
}}
/> />
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 2 }}> <Box sx={{ display: "flex", flexWrap: "wrap", gap: 2 }}>
<FormControl sx={{ minWidth: 220 }} size="small" disabled={disableSave}> <FormControl sx={{ minWidth: 220 }} size="small" disabled={disableSave}>
<InputLabel sx={{ color: "#aaa" }}>Site</InputLabel> <InputLabel sx={{ color: MAGIC_UI.textMuted }}>Site</InputLabel>
<Select <Select
value={form.site_id} value={form.site_id}
label="Site" label="Site"
onChange={updateField("site_id")} onChange={updateField("site_id")}
sx={{ bgcolor: "#1f1f1f", color: "#fff" }} sx={INPUT_SX}
> >
<MenuItem value="">(None)</MenuItem> <MenuItem value="">(None)</MenuItem>
{sites.map((site) => ( {sites.map((site) => (
@@ -344,12 +408,12 @@ export default function CredentialEditor({
</Select> </Select>
</FormControl> </FormControl>
<FormControl sx={{ minWidth: 180 }} size="small" disabled={disableSave}> <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 <Select
value={form.credential_type} value={form.credential_type}
label="Credential Type" label="Credential Type"
onChange={updateField("credential_type")} onChange={updateField("credential_type")}
sx={{ bgcolor: "#1f1f1f", color: "#fff" }} sx={INPUT_SX}
> >
{CREDENTIAL_TYPES.map((opt) => ( {CREDENTIAL_TYPES.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>{opt.label}</MenuItem> <MenuItem key={opt.value} value={opt.value}>{opt.label}</MenuItem>
@@ -357,12 +421,12 @@ export default function CredentialEditor({
</Select> </Select>
</FormControl> </FormControl>
<FormControl sx={{ minWidth: 180 }} size="small" disabled={disableSave}> <FormControl sx={{ minWidth: 180 }} size="small" disabled={disableSave}>
<InputLabel sx={{ color: "#aaa" }}>Connection</InputLabel> <InputLabel sx={{ color: MAGIC_UI.textMuted }}>Connection</InputLabel>
<Select <Select
value={form.connection_type} value={form.connection_type}
label="Connection" label="Connection"
onChange={updateField("connection_type")} onChange={updateField("connection_type")}
sx={{ bgcolor: "#1f1f1f", color: "#fff" }} sx={INPUT_SX}
> >
{CONNECTION_TYPES.map((opt) => ( {CONNECTION_TYPES.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>{opt.label}</MenuItem> <MenuItem key={opt.value} value={opt.value}>{opt.label}</MenuItem>
@@ -375,10 +439,7 @@ export default function CredentialEditor({
value={form.username} value={form.username}
onChange={updateField("username")} onChange={updateField("username")}
disabled={disableSave} disabled={disableSave}
sx={{ sx={INPUT_SX}
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
"& label": { color: "#888" }
}}
/> />
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<TextField <TextField
@@ -387,15 +448,11 @@ export default function CredentialEditor({
value={form.password} value={form.password}
onChange={updateField("password")} onChange={updateField("password")}
disabled={disableSave} disabled={disableSave}
sx={{ sx={{ flex: 1, ...INPUT_SX }}
flex: 1,
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
"& label": { color: "#888" }
}}
/> />
{isEdit && currentCredentialFlags.hasPassword && !passwordDirty && !clearPassword && ( {isEdit && currentCredentialFlags.hasPassword && !passwordDirty && !clearPassword && (
<Tooltip title="Clear stored password"> <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" /> <ClearIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
@@ -405,7 +462,7 @@ export default function CredentialEditor({
<Typography sx={helperStyle}>Stored password will remain unless you change or clear it.</Typography> <Typography sx={helperStyle}>Stored password will remain unless you change or clear it.</Typography>
)} )}
{clearPassword && ( {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" }}> <Box sx={{ display: "flex", gap: 1, alignItems: "flex-start" }}>
@@ -419,8 +476,8 @@ export default function CredentialEditor({
maxRows={12} maxRows={12}
sx={{ sx={{
flex: 1, flex: 1,
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff", fontFamily: "monospace" }, ...INPUT_SX,
"& label": { color: "#888" } "& .MuiOutlinedInput-input": { fontFamily: "monospace" },
}} }}
/> />
<Button <Button
@@ -428,14 +485,14 @@ export default function CredentialEditor({
component="label" component="label"
startIcon={<UploadIcon />} startIcon={<UploadIcon />}
disabled={disableSave} disabled={disableSave}
sx={{ alignSelf: "center", borderColor: "#58a6ff", color: "#58a6ff" }} sx={{ alignSelf: "center", ...OUTLINE_BUTTON_SX }}
> >
Upload Upload
<input type="file" hidden accept=".pem,.key,.txt" onChange={handlePrivateKeyUpload} /> <input type="file" hidden accept=".pem,.key,.txt" onChange={handlePrivateKeyUpload} />
</Button> </Button>
{isEdit && currentCredentialFlags.hasPrivateKey && !privateKeyDirty && !clearPrivateKey && ( {isEdit && currentCredentialFlags.hasPrivateKey && !privateKeyDirty && !clearPrivateKey && (
<Tooltip title="Clear stored private key"> <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" /> <ClearIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </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> <Typography sx={helperStyle}>Private key is stored. Upload or paste a new one to replace, or clear it.</Typography>
)} )}
{clearPrivateKey && ( {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 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
@@ -455,15 +512,11 @@ export default function CredentialEditor({
value={form.private_key_passphrase} value={form.private_key_passphrase}
onChange={updateField("private_key_passphrase")} onChange={updateField("private_key_passphrase")}
disabled={disableSave} disabled={disableSave}
sx={{ sx={{ flex: 1, ...INPUT_SX }}
flex: 1,
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
"& label": { color: "#888" }
}}
/> />
{isEdit && currentCredentialFlags.hasPrivateKeyPassphrase && !passphraseDirty && !clearPassphrase && ( {isEdit && currentCredentialFlags.hasPrivateKeyPassphrase && !passphraseDirty && !clearPassphrase && (
<Tooltip title="Clear stored passphrase"> <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" /> <ClearIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
@@ -473,17 +526,17 @@ export default function CredentialEditor({
<Typography sx={helperStyle}>A passphrase is stored for this key.</Typography> <Typography sx={helperStyle}>A passphrase is stored for this key.</Typography>
)} )}
{clearPassphrase && ( {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" }}> <Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
<FormControl sx={{ minWidth: 180 }} size="small" disabled={disableSave}> <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 <Select
value={form.become_method} value={form.become_method}
label="Privilege Escalation" label="Privilege Escalation"
onChange={updateField("become_method")} onChange={updateField("become_method")}
sx={{ bgcolor: "#1f1f1f", color: "#fff" }} sx={INPUT_SX}
> >
{BECOME_METHODS.map((opt) => ( {BECOME_METHODS.map((opt) => (
<MenuItem key={opt.value || "none"} value={opt.value}>{opt.label}</MenuItem> <MenuItem key={opt.value || "none"} value={opt.value}>{opt.label}</MenuItem>
@@ -495,12 +548,7 @@ export default function CredentialEditor({
value={form.become_username} value={form.become_username}
onChange={updateField("become_username")} onChange={updateField("become_username")}
disabled={disableSave} disabled={disableSave}
sx={{ sx={{ flex: 1, minWidth: 200, ...INPUT_SX }}
flex: 1,
minWidth: 200,
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
"& label": { color: "#888" }
}}
/> />
</Box> </Box>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
@@ -510,15 +558,11 @@ export default function CredentialEditor({
value={form.become_password} value={form.become_password}
onChange={updateField("become_password")} onChange={updateField("become_password")}
disabled={disableSave} disabled={disableSave}
sx={{ sx={{ flex: 1, ...INPUT_SX }}
flex: 1,
"& .MuiInputBase-root": { bgcolor: "#1f1f1f", color: "#fff" },
"& label": { color: "#888" }
}}
/> />
{isEdit && currentCredentialFlags.hasBecomePassword && !becomePasswordDirty && !clearBecomePassword && ( {isEdit && currentCredentialFlags.hasBecomePassword && !becomePasswordDirty && !clearBecomePassword && (
<Tooltip title="Clear stored escalation password"> <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" /> <ClearIcon fontSize="small" />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
@@ -528,20 +572,20 @@ export default function CredentialEditor({
<Typography sx={helperStyle}>Escalation password is stored.</Typography> <Typography sx={helperStyle}>Escalation password is stored.</Typography>
)} )}
{clearBecomePassword && ( {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> </DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}> <DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={handleCancel} sx={{ color: "#58a6ff" }} disabled={loading}> <Button onClick={handleCancel} sx={OUTLINE_BUTTON_SX} disabled={loading}>
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={handleSave} onClick={handleSave}
variant="outlined" variant="contained"
sx={{ color: "#58a6ff", borderColor: "#58a6ff" }} sx={GRADIENT_BUTTON_SX}
disabled={disableSave} disabled={disableSave}
> >
{loading ? <CircularProgress size={18} sx={{ color: "#58a6ff" }} /> : "Save"} {loading ? <CircularProgress size={18} sx={{ color: "#041224" }} /> : "Save"}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>

View File

@@ -41,6 +41,18 @@ const myTheme = themeQuartz.withParams({
const themeClassName = myTheme.themeName || "ag-theme-quartz"; const themeClassName = myTheme.themeName || "ag-theme-quartz";
const gridFontFamily = '"IBM Plex Sans", "Helvetica Neue", Arial, sans-serif'; const gridFontFamily = '"IBM Plex Sans", "Helvetica Neue", Arial, sans-serif';
const iconFontFamily = '"Quartz Regular"'; 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) { function formatTs(ts) {
if (!ts) return "-"; if (!ts) return "-";
@@ -62,7 +74,7 @@ function connectionIcon(connection) {
return <ComputerIcon fontSize="small" sx={{ mr: 0.6, color: "#58a6ff" }} />; 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 [rows, setRows] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -216,6 +228,15 @@ export default function CredentialList({ isAdmin = false }) {
fetchCredentials(); fetchCredentials();
}, [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 = () => { const handleCreate = () => {
setEditorMode("create"); setEditorMode("create");
setEditingCredential(null); setEditingCredential(null);
@@ -291,9 +312,11 @@ export default function CredentialList({ isAdmin = false }) {
<> <>
<Paper <Paper
sx={{ sx={{
m: 2, m: 0,
p: 0, p: 0,
bgcolor: "transparent", bgcolor: "transparent",
border: "none",
boxShadow: "none",
fontFamily: gridFontFamily, fontFamily: gridFontFamily,
color: "#f5f7fa", color: "#f5f7fa",
display: "flex", display: "flex",
@@ -302,31 +325,32 @@ export default function CredentialList({ isAdmin = false }) {
minWidth: 0, minWidth: 0,
minHeight: 420 minHeight: 420
}} }}
elevation={2} elevation={0}
> >
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
p: 2, px: 2,
borderBottom: "1px solid #2a2a2a" 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 }}> <Box sx={{ display: "flex", gap: 1 }}>
<Button <Button
variant="outlined" variant="outlined"
size="small" size="small"
startIcon={<RefreshIcon />} 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} onClick={fetchCredentials}
disabled={loading} disabled={loading}
> >
@@ -336,7 +360,7 @@ export default function CredentialList({ isAdmin = false }) {
variant="contained" variant="contained"
size="small" size="small"
startIcon={<AddIcon />} startIcon={<AddIcon />}
sx={{ bgcolor: "#58a6ff", color: "#0b0f19" }} sx={gradientButtonSx}
onClick={handleCreate} onClick={handleCreate}
> >
New Credential New Credential
@@ -352,7 +376,6 @@ export default function CredentialList({ isAdmin = false }) {
color: "#7db7ff", color: "#7db7ff",
px: 2, px: 2,
py: 1.5, py: 1.5,
borderBottom: "1px solid #2a2a2a"
}} }}
> >
<CircularProgress size={18} sx={{ color: "#58a6ff" }} /> <CircularProgress size={18} sx={{ color: "#58a6ff" }} />
@@ -360,7 +383,7 @@ export default function CredentialList({ isAdmin = false }) {
</Box> </Box>
)} )}
{error && ( {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> <Typography variant="body2">{error}</Typography>
</Box> </Box>
)} )}

View File

@@ -13,11 +13,14 @@ import RefreshIcon from "@mui/icons-material/Refresh";
import SaveIcon from "@mui/icons-material/Save"; import SaveIcon from "@mui/icons-material/Save";
import VisibilityIcon from "@mui/icons-material/Visibility"; import VisibilityIcon from "@mui/icons-material/Visibility";
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"; import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
import GitHubIcon from "@mui/icons-material/GitHub";
const paperSx = { const paperSx = {
m: 2, m: 0,
p: 0, p: 0,
bgcolor: "transparent", bgcolor: "transparent",
border: "none",
boxShadow: "none",
color: "#f5f7fa", color: "#f5f7fa",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
@@ -29,17 +32,31 @@ const paperSx = {
const fieldSx = { const fieldSx = {
mt: 2, mt: 2,
"& .MuiOutlinedInput-root": { "& .MuiOutlinedInput-root": {
bgcolor: "#181818", bgcolor: "rgba(255,255,255,0.04)",
color: "#f5f7fa", color: "#f5f7fa",
"& fieldset": { borderColor: "#2a2a2a" }, borderRadius: 1,
"&:hover fieldset": { borderColor: "#58a6ff" }, "& fieldset": { borderColor: "rgba(148,163,184,0.35)" },
"&.Mui-focused fieldset": { borderColor: "#58a6ff" } "&:hover fieldset": { borderColor: "rgba(148,163,184,0.55)" },
"&.Mui-focused fieldset": { borderColor: "#7dd3fc" }
}, },
"& .MuiInputLabel-root": { color: "#bbb" }, "& .MuiInputLabel-root": { color: "#bbb" },
"& .MuiInputLabel-root.Mui-focused": { color: "#7db7ff" } "& .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 [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [token, setToken] = useState(""); const [token, setToken] = useState("");
@@ -145,6 +162,15 @@ export default function GithubAPIToken({ isAdmin = false }) {
setShowToken((prev) => !prev); 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) { if (!isAdmin) {
return ( return (
<Paper sx={{ m: 2, p: 3, bgcolor: "transparent" }}> <Paper sx={{ m: 2, p: 3, bgcolor: "transparent" }}>
@@ -159,23 +185,10 @@ export default function GithubAPIToken({ isAdmin = false }) {
} }
return ( return (
<Paper sx={paperSx} elevation={2}> <Paper sx={paperSx} elevation={0}>
<Box <Box sx={{ px: 2, py: 2, display: "flex", flexDirection: "column", gap: 1.5 }}>
sx={{ <Typography variant="body2" sx={{ color: "#ccc" }}>
display: "flex", Using a GitHub Personal Access Token raises rate limits from 60/hr to 5,000/hr. Generate one at{" "}
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{' '}
<Link <Link
href="https://github.com/settings/tokens" href="https://github.com/settings/tokens"
target="_blank" target="_blank"
@@ -183,32 +196,26 @@ export default function GithubAPIToken({ isAdmin = false }) {
sx={{ color: "#7db7ff" }} sx={{ color: "#7db7ff" }}
> >
https://github.com/settings/tokens https://github.com/settings/tokens
</Link>{' '} </Link>{" "}
&#10095; <b>Personal Access Tokens &#10095; Tokens (Classic) &#10095; Generate New Token &#10095; New Personal Access Token (Classic)</b> under <b>Personal Access Tokens Tokens (Classic)</b>.
</Typography> </Typography>
<br></br>
<Typography variant="body2" sx={{ color: "#ccc" }}> <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" }}> <Box component="code" sx={{ bgcolor: "#222", px: 0.75, py: 0.25, borderRadius: 1, fontSize: "0.85rem" }}>
Borealis Automation Platform Borealis Automation Platform
</Box> </Box>
</Typography> {" "}
<Typography variant="body2" sx={{ color: "#ccc" }}> <Box component="span" sx={{ fontWeight: 600, ml: 2 }}>Scope:</Box>{" "}
<Box component="span" sx={{ fontWeight: 600 }}>Scope:</Box>{' '}
<Box component="code" sx={{ bgcolor: "#222", px: 0.75, py: 0.25, borderRadius: 1, fontSize: "0.85rem" }}> <Box component="code" sx={{ bgcolor: "#222", px: 0.75, py: 0.25, borderRadius: 1, fontSize: "0.85rem" }}>
public_repo public_repo
</Box> </Box>
</Typography> {" "}
<Typography variant="body2" sx={{ color: "#ccc" }}> <Box component="span" sx={{ fontWeight: 600, ml: 2 }}>Expiration:</Box>{" "}
<Box component="span" sx={{ fontWeight: 600 }}>Expiration:</Box>{' '}
<Box component="code" sx={{ bgcolor: "#222", px: 0.75, py: 0.25, borderRadius: 1, fontSize: "0.85rem" }}> <Box component="code" sx={{ bgcolor: "#222", px: 0.75, py: 0.25, borderRadius: 1, fontSize: "0.85rem" }}>
No Expiration No Expiration
</Box> </Box>
</Typography> </Typography>
</Box>
</Box>
<Box sx={{ px: 2, py: 2, display: "flex", flexDirection: "column", gap: 1.5 }}>
<TextField <TextField
label="Personal Access Token" label="Personal Access Token"
value={inputValue} value={inputValue}
@@ -246,13 +253,7 @@ export default function GithubAPIToken({ isAdmin = false }) {
onClick={handleSave} onClick={handleSave}
disabled={saving || loading} disabled={saving || loading}
startIcon={!saving ? <SaveIcon /> : null} startIcon={!saving ? <SaveIcon /> : null}
sx={{ sx={gradientButtonSx}
bgcolor: "#58a6ff",
color: "#0b0f19",
minWidth: 88,
mr: 1,
"&:hover": { bgcolor: "#7db7ff" }
}}
> >
{saving ? <CircularProgress size={16} sx={{ color: "#0b0f19" }} /> : "Save"} {saving ? <CircularProgress size={16} sx={{ color: "#0b0f19" }} /> : "Save"}
</Button> </Button>
@@ -273,7 +274,15 @@ export default function GithubAPIToken({ isAdmin = false }) {
variant="outlined" variant="outlined"
size="small" size="small"
startIcon={<RefreshIcon />} 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} onClick={hydrate}
disabled={loading || saving} disabled={loading || saving}
> >

View File

@@ -27,10 +27,11 @@ import {
} from "@mui/material"; } from "@mui/material";
import MoreVertIcon from "@mui/icons-material/MoreVert"; import MoreVertIcon from "@mui/icons-material/MoreVert";
import FilterListIcon from "@mui/icons-material/FilterList"; import FilterListIcon from "@mui/icons-material/FilterList";
import GroupIcon from "@mui/icons-material/Group";
import { ConfirmDeleteDialog } from "../Dialogs.jsx"; import { ConfirmDeleteDialog } from "../Dialogs.jsx";
/* ---------- Formatting helpers to keep this page in lockstep with Device_List ---------- */ /* ---------- 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 = { const tableSx = {
minWidth: 820, minWidth: 820,
"& th, & td": { "& th, & td": {
@@ -51,6 +52,19 @@ const filterFieldSx = {
"&:hover fieldset": { borderColor: "#888" } "&: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) { function formatTs(tsSec) {
@@ -69,7 +83,7 @@ async function sha512(text) {
return arr.map((b) => b.toString(16).padStart(2, "0")).join(""); 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 [rows, setRows] = useState([]); // {username, display_name, role, last_login}
const [orderBy, setOrderBy] = useState("username"); const [orderBy, setOrderBy] = useState("username");
const [order, setOrder] = useState("asc"); const [order, setOrder] = useState("asc");
@@ -91,6 +105,7 @@ export default function UserManagement({ isAdmin = false }) {
const [mfaBusyUser, setMfaBusyUser] = useState(null); const [mfaBusyUser, setMfaBusyUser] = useState(null);
const [resetMfaOpen, setResetMfaOpen] = useState(false); const [resetMfaOpen, setResetMfaOpen] = useState(false);
const [resetMfaTarget, setResetMfaTarget] = useState(null); const [resetMfaTarget, setResetMfaTarget] = useState(null);
const useGlobalHeader = Boolean(onPageMetaChange);
// Columns and filters // Columns and filters
const columns = useMemo(() => ([ const columns = useMemo(() => ([
@@ -140,6 +155,15 @@ export default function UserManagement({ isAdmin = false }) {
fetchUsers(); fetchUsers();
}, [fetchUsers, isAdmin]); }, [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) => { const handleSort = (col) => {
if (orderBy === col) setOrder(order === "asc" ? "desc" : "asc"); if (orderBy === col) setOrder(order === "asc" ? "desc" : "asc");
else { setOrderBy(col); setOrder("asc"); } else { setOrderBy(col); setOrder("asc"); }
@@ -397,21 +421,13 @@ export default function UserManagement({ isAdmin = false }) {
return ( return (
<> <>
<Paper sx={tablePaperSx} elevation={2}> <Paper sx={tablePaperSx} elevation={0}>
<Box sx={{ p: 2, pb: 1, display: "flex", alignItems: "center", justifyContent: "space-between" }}> <Box sx={{ p: 2, pb: 1, display: "flex", alignItems: "center", justifyContent: "flex-end", gap: 1 }}>
<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>
<Button <Button
variant="outlined" variant="contained"
size="small" size="small"
onClick={openCreate} onClick={openCreate}
sx={{ color: "#58a6ff", borderColor: "#58a6ff", textTransform: "none" }} sx={gradientButtonSx}
> >
Create User Create User
</Button> </Button>

View File

@@ -109,7 +109,7 @@ function formatTimestamp(ts) {
return ts; return ts;
} }
export default function LogManagement({ isAdmin = false }) { export default function LogManagement({ isAdmin = false, onPageMetaChange }) {
const [logs, setLogs] = useState([]); const [logs, setLogs] = useState([]);
const [defaultRetention, setDefaultRetention] = useState(30); const [defaultRetention, setDefaultRetention] = useState(30);
const [selectedDomain, setSelectedDomain] = useState(null); const [selectedDomain, setSelectedDomain] = useState(null);
@@ -124,6 +124,7 @@ export default function LogManagement({ isAdmin = false }) {
const [actionMessage, setActionMessage] = useState(null); const [actionMessage, setActionMessage] = useState(null);
const [quickFilter, setQuickFilter] = useState(""); const [quickFilter, setQuickFilter] = useState("");
const gridRef = useRef(null); const gridRef = useRef(null);
const useGlobalHeader = Boolean(onPageMetaChange);
const logMap = useMemo(() => { const logMap = useMemo(() => {
const map = new Map(); const map = new Map();
@@ -214,6 +215,15 @@ const defaultColDef = useMemo(
if (isAdmin) fetchLogs(); if (isAdmin) fetchLogs();
}, [fetchLogs, isAdmin]); }, [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(() => { useEffect(() => {
if (!logs.length) { if (!logs.length) {
setSelectedDomain(null); setSelectedDomain(null);
@@ -317,7 +327,7 @@ const defaultColDef = useMemo(
return ( return (
<Paper <Paper
elevation={3} elevation={0}
sx={{ sx={{
m: 0, m: 0,
p: 0, p: 0,
@@ -329,38 +339,55 @@ const defaultColDef = useMemo(
flexDirection: "column", flexDirection: "column",
}} }}
> >
<Box sx={{ px: 3, pt: 3, pb: 1, borderBottom: `1px solid ${AURORA_SHELL.border}` }}> {!useGlobalHeader && (
<Stack direction="row" spacing={1.25} alignItems="center"> <Box sx={{ px: 3, pt: 3, pb: 1, borderBottom: `1px solid ${AURORA_SHELL.border}` }}>
<LogsIcon sx={{ fontSize: 22, color: AURORA_SHELL.accent, mt: 0.25 }} /> <Stack direction="row" spacing={1.25} alignItems="center">
<Typography <LogsIcon sx={{ fontSize: 22, color: AURORA_SHELL.accent, mt: 0.25 }} />
variant="h6" <Typography
sx={{ variant="h6"
fontWeight: 700, sx={{
letterSpacing: 0.5, fontWeight: 700,
color: AURORA_SHELL.text, 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);
}} }}
sx={gradientButtonSx}
> >
Refresh Log Management
</Button> </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>
</Stack> <Typography variant="body2" sx={{ color: AURORA_SHELL.muted, mt: 0.75, mb: 2.5 }}>
<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.
Analyze engine logs and adjust log retention periods for different engine services. </Typography>
</Typography> </Box>
</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 && ( {error && (
<Box sx={{ px: 3, pt: 2 }}> <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 { import {
Paper, Paper,
Box, Box,
@@ -215,7 +215,7 @@ const iconFontFamily = "'Quartz Regular'";
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Page Template Component // Page Template Component
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export default function PageTemplate() { export default function PageTemplate({ onPageMetaChange }) {
const gridRef = useRef(null); const gridRef = useRef(null);
const columnDefs = useMemo(() => sampleColumnDefs, []); const columnDefs = useMemo(() => sampleColumnDefs, []);
const getRowId = useCallback((params) => params.data?.id || String(params.rowIndex ?? ""), []); 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)."); 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 ( return (
<Paper <Paper
sx={{ sx={{
@@ -243,68 +252,66 @@ export default function PageTemplate() {
}} }}
elevation={0} elevation={0}
> >
{/* Page header (keep padding: top 24px, left/right 24px) */} <Box
<Box sx={{ px: 3, pt: 3, pb: 1 }}> sx={{
<Stack direction="row" alignItems="center" spacing={1.25}> px: 2,
<TemplateIcon sx={{ fontSize: 22, color: AURORA_SHELL.accent }} /> display: "flex",
<Typography variant="h6" sx={{ fontWeight: 700, letterSpacing: 0.5 }}> alignItems: "center",
Page Template justifyContent: "space-between",
</Typography> gap: 1,
<Box sx={{ flexGrow: 1 }} /> mt: 1,
<Stack direction="row" spacing={1}> }}
<Tooltip title="Refresh"> >
<span> <Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<IconButton <Tooltip title="Refresh">
size="small" <span>
onClick={handleRefresh} <IconButton
sx={{ size="small"
color: "#cbd5e1", onClick={handleRefresh}
borderRadius: 1, sx={{
"&:hover": { color: "#ffffff" }, color: "#cbd5e1",
}} borderRadius: 1,
> "&:hover": { color: "#ffffff" },
<CachedIcon fontSize="small" /> }}
</IconButton> >
</span> <CachedIcon fontSize="small" />
</Tooltip> </IconButton>
<Tooltip title="New (example)"> </span>
<span> </Tooltip>
<Button size="small" startIcon={<AddIcon />} sx={gradientButtonSx}> </Box>
New Item <Stack direction="row" spacing={1}>
</Button> <Tooltip title="New (example)">
</span> <span>
</Tooltip> <Button size="small" startIcon={<AddIcon />} sx={gradientButtonSx}>
<Tooltip title="Settings (example)"> New Item
<span> </Button>
<Button </span>
size="small" </Tooltip>
variant="outlined" <Tooltip title="Settings (example)">
startIcon={<TuneIcon />} <span>
sx={{ <Button
borderColor: "rgba(148,163,184,0.35)", size="small"
color: "#e2e8f0", variant="outlined"
textTransform: "none", startIcon={<TuneIcon />}
borderRadius: 999, sx={{
px: 1.7, // ~5% wider than previous borderColor: "rgba(148,163,184,0.35)",
minWidth: 86, color: "#e2e8f0",
"&:hover": { borderColor: "rgba(148,163,184,0.55)" }, textTransform: "none",
}} borderRadius: 999,
> px: 1.7, // ~5% wider than previous
Settings minWidth: 86,
</Button> "&:hover": { borderColor: "rgba(148,163,184,0.55)" },
</span> }}
</Tooltip> >
</Stack> 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 }}>
Page Styling Guide and Template use as a baseline when designing new pages.
</Typography>
</Box> </Box>
{/* Content area — add a little more top-space below subtitle */} {/* Content area — offset a little below the shared header */}
<Box sx={{ mt: "28px", px: 2, pb: 2, flexGrow: 1, minHeight: 0, display: "flex", flexDirection: "column" }}> <Box sx={{ mt: "10px", px: 2, pb: 2, flexGrow: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
<Box <Box
className={themeClassName} className={themeClassName}
sx={{ 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 { GitHub as GitHubIcon, InfoOutlined as InfoIcon } from "@mui/icons-material";
import { CreditsDialog } from "../Dialogs.jsx"; 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 [serverTime, setServerTime] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [aboutOpen, setAboutOpen] = useState(false); const [aboutOpen, setAboutOpen] = useState(false);
@@ -29,13 +42,23 @@ export default function ServerInfo({ isAdmin = false }) {
return () => { isMounted = false; clearInterval(id); }; return () => { isMounted = false; clearInterval(id); };
}, [isAdmin]); }, [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; if (!isAdmin) return null;
return ( 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 }}> <Box sx={{ p: 2 }}>
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 1 }}>Server Info</Typography> <Typography sx={{ color: '#aaa', mb: 1 }}>
<Typography sx={{ color: '#aaa', mb: 1 }}>Basic server information will appear here for informative and debug purposes.</Typography> Basic server information for debug and support. Server time updates automatically every minute.
</Typography>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'baseline' }}> <Box sx={{ display: 'flex', gap: 2, alignItems: 'baseline' }}>
<Typography sx={{ color: '#ccc', fontWeight: 600, minWidth: 120 }}>Server Time</Typography> <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' }}> <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>
<Box sx={{ mt: 3 }}> <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' }}> <Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<Button <Button
variant="outlined" variant="outlined"
color="primary"
startIcon={<GitHubIcon />} startIcon={<GitHubIcon />}
onClick={() => window.open("https://github.com/bunny-lab-io/Borealis", "_blank")} 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 GitHub Project
</Button> </Button>
<Button <Button
variant="outlined" variant="contained"
color="inherit"
startIcon={<InfoIcon />} startIcon={<InfoIcon />}
onClick={() => setAboutOpen(true)} onClick={() => setAboutOpen(true)}
sx={{ borderColor: '#3a3a3a', color: '#ddd' }} sx={gradientButtonSx}
> >
About Borealis About Borealis
</Button> </Button>

View File

@@ -130,6 +130,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
const pendingPathRef = useRef(null); const pendingPathRef = useRef(null);
const quickJobSeedRef = useRef(0); const quickJobSeedRef = useRef(0);
const [notAuthorizedOpen, setNotAuthorizedOpen] = useState(false); const [notAuthorizedOpen, setNotAuthorizedOpen] = useState(false);
const [pageHeader, setPageHeader] = useState({ title: "", subtitle: "", Icon: null });
// Top-bar search state // Top-bar search state
const SEARCH_CATEGORIES = [ 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( const pageToPath = useCallback(
(page, options = {}) => { (page, options = {}) => {
switch (page) { switch (page) {
@@ -1076,6 +1092,10 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
[navigateTo, setTabs, setActiveTabId] [navigateTo, setTabs, setActiveTabId]
); );
useEffect(() => {
setPageHeader({ title: "", subtitle: "", Icon: null });
}, [currentPage]);
const isAdmin = (String(userRole || '').toLowerCase() === 'admin'); const isAdmin = (String(userRole || '').toLowerCase() === 'admin');
useEffect(() => { useEffect(() => {
@@ -1100,6 +1120,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
case "sites": case "sites":
return ( return (
<SiteList <SiteList
onPageMetaChange={handlePageMetaChange}
onOpenDevicesForSite={(siteName) => { onOpenDevicesForSite={(siteName) => {
try { try {
localStorage.setItem('device_list_initial_site_filter', String(siteName || '')); localStorage.setItem('device_list_initial_site_filter', String(siteName || ''));
@@ -1111,6 +1132,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
case "devices": case "devices":
return ( return (
<DeviceList <DeviceList
onPageMetaChange={handlePageMetaChange}
onSelectDevice={(d) => { onSelectDevice={(d) => {
navigateTo("device_details", { device: d }); navigateTo("device_details", { device: d });
}} }}
@@ -1120,6 +1142,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
case "agent_devices": case "agent_devices":
return ( return (
<AgentDevices <AgentDevices
onPageMetaChange={handlePageMetaChange}
onSelectDevice={(d) => { onSelectDevice={(d) => {
navigateTo("device_details", { device: d }); navigateTo("device_details", { device: d });
}} }}
@@ -1127,13 +1150,14 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
/> />
); );
case "ssh_devices": case "ssh_devices":
return <SSHDevices onQuickJobLaunch={handleQuickJobLaunch} />; return <SSHDevices onQuickJobLaunch={handleQuickJobLaunch} onPageMetaChange={handlePageMetaChange} />;
case "winrm_devices": case "winrm_devices":
return <WinRMDevices onQuickJobLaunch={handleQuickJobLaunch} />; return <WinRMDevices onQuickJobLaunch={handleQuickJobLaunch} onPageMetaChange={handlePageMetaChange} />;
case "filters": case "filters":
return ( return (
<DeviceFilterList <DeviceFilterList
onPageMetaChange={handlePageMetaChange}
refreshToken={filtersRefreshToken} refreshToken={filtersRefreshToken}
onCreateFilter={() => { onCreateFilter={() => {
setFilterEditorState(null); setFilterEditorState(null);
@@ -1149,6 +1173,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
case "filter_editor": case "filter_editor":
return ( return (
<DeviceFilterEditor <DeviceFilterEditor
onPageMetaChange={handlePageMetaChange}
initialFilter={filterEditorState} initialFilter={filterEditorState}
onCancel={() => { onCancel={() => {
setFilterEditorState(null); setFilterEditorState(null);
@@ -1165,6 +1190,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
case "device_details": case "device_details":
return ( return (
<DeviceDetails <DeviceDetails
onPageMetaChange={handlePageMetaChange}
device={selectedDevice} device={selectedDevice}
onQuickJobLaunch={handleQuickJobLaunch} onQuickJobLaunch={handleQuickJobLaunch}
onBack={() => { onBack={() => {
@@ -1177,6 +1203,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
case "jobs": case "jobs":
return ( return (
<ScheduledJobsList <ScheduledJobsList
onPageMetaChange={handlePageMetaChange}
onCreateJob={() => { setEditingJob(null); navigateTo("create_job"); }} onCreateJob={() => { setEditingJob(null); navigateTo("create_job"); }}
onEditJob={(job) => { setEditingJob(job); navigateTo("create_job"); }} onEditJob={(job) => { setEditingJob(job); navigateTo("create_job"); }}
refreshToken={jobsRefreshToken} refreshToken={jobsRefreshToken}
@@ -1186,6 +1213,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
case "create_job": case "create_job":
return ( return (
<CreateJob <CreateJob
onPageMetaChange={handlePageMetaChange}
initialJob={editingJob} initialJob={editingJob}
quickJobDraft={quickJobDraft} quickJobDraft={quickJobDraft}
onConsumeQuickJobDraft={handleConsumeQuickJobDraft} onConsumeQuickJobDraft={handleConsumeQuickJobDraft}
@@ -1206,6 +1234,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
case "workflows": case "workflows":
return ( return (
<AssemblyList <AssemblyList
onPageMetaChange={handlePageMetaChange}
onOpenWorkflow={openWorkflowFromList} onOpenWorkflow={openWorkflowFromList}
onOpenScript={openScriptFromList} onOpenScript={openScriptFromList}
userRole={userRole || 'User'} userRole={userRole || 'User'}
@@ -1215,6 +1244,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
case "assemblies": case "assemblies":
return ( return (
<AssemblyList <AssemblyList
onPageMetaChange={handlePageMetaChange}
onOpenWorkflow={openWorkflowFromList} onOpenWorkflow={openWorkflowFromList}
onOpenScript={openScriptFromList} onOpenScript={openScriptFromList}
userRole={userRole || 'User'} userRole={userRole || 'User'}
@@ -1225,6 +1255,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
return ( return (
<AssemblyEditor <AssemblyEditor
mode="script" mode="script"
onPageMetaChange={handlePageMetaChange}
initialAssembly={assemblyEditorState && assemblyEditorState.mode === 'script' ? assemblyEditorState : null} initialAssembly={assemblyEditorState && assemblyEditorState.mode === 'script' ? assemblyEditorState : null}
onConsumeInitialData={() => { onConsumeInitialData={() => {
setAssemblyEditorState((prev) => (prev && prev.mode === 'script' ? null : prev)); setAssemblyEditorState((prev) => (prev && prev.mode === 'script' ? null : prev));
@@ -1238,6 +1269,7 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
return ( return (
<AssemblyEditor <AssemblyEditor
mode="ansible" mode="ansible"
onPageMetaChange={handlePageMetaChange}
initialAssembly={assemblyEditorState && assemblyEditorState.mode === 'ansible' ? assemblyEditorState : null} initialAssembly={assemblyEditorState && assemblyEditorState.mode === 'ansible' ? assemblyEditorState : null}
onConsumeInitialData={() => { onConsumeInitialData={() => {
setAssemblyEditorState((prev) => (prev && prev.mode === 'ansible' ? null : prev)); setAssemblyEditorState((prev) => (prev && prev.mode === 'ansible' ? null : prev));
@@ -1248,24 +1280,24 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
); );
case "access_credentials": case "access_credentials":
return <CredentialList isAdmin={isAdmin} />; return <CredentialList isAdmin={isAdmin} onPageMetaChange={handlePageMetaChange} />;
case "access_github_token": case "access_github_token":
return <GithubAPIToken isAdmin={isAdmin} />; return <GithubAPIToken isAdmin={isAdmin} onPageMetaChange={handlePageMetaChange} />;
case "access_users": case "access_users":
return <UserManagement isAdmin={isAdmin} />; return <UserManagement isAdmin={isAdmin} onPageMetaChange={handlePageMetaChange} />;
case "server_info": case "server_info":
return <ServerInfo isAdmin={isAdmin} />; return <ServerInfo isAdmin={isAdmin} onPageMetaChange={handlePageMetaChange} />;
case "log_management": case "log_management":
return <LogManagement isAdmin={isAdmin} />; return <LogManagement isAdmin={isAdmin} onPageMetaChange={handlePageMetaChange} />;
case "page_template": case "page_template":
return <PageTemplate isAdmin={isAdmin} />; return <PageTemplate isAdmin={isAdmin} onPageMetaChange={handlePageMetaChange} />;
case "admin_device_approvals": case "admin_device_approvals":
return <DeviceApprovals />; return <DeviceApprovals onPageMetaChange={handlePageMetaChange} />;
case "workflow-editor": case "workflow-editor":
return ( return (
@@ -1335,6 +1367,9 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
); );
} }
const HeaderIcon = pageHeader.Icon;
const hasPageHeader = Boolean(pageHeader.title);
return ( return (
<ThemeProvider theme={darkTheme}> <ThemeProvider theme={darkTheme}>
<CssBaseline /> <CssBaseline />
@@ -1484,18 +1519,45 @@ const LOCAL_STORAGE_KEY = "borealis_persistent_state";
<Box <Box
sx={{ sx={{
flexGrow: 1, flexGrow: 1,
display: 'flex', display: "flex",
flexDirection: 'column', flexDirection: "column",
overflow: 'auto', overflow: "auto",
minHeight: 0, 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> </Box>
</Box> </Box>

View File

@@ -17,7 +17,12 @@ import {
DialogActions, DialogActions,
ListItemText ListItemText
} from "@mui/material"; } 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 Prism from "prismjs";
import "prismjs/components/prism-yaml"; import "prismjs/components/prism-yaml";
import "prismjs/components/prism-bash"; import "prismjs/components/prism-bash";
@@ -119,6 +124,12 @@ const SECTION_CARD_SX = {
border: "1px solid #262f3d", 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 = { const MENU_PROPS = {
PaperProps: { PaperProps: {
sx: { sx: {
@@ -341,6 +352,7 @@ export default function AssemblyEditor({
onConsumeInitialData, onConsumeInitialData,
onSaved, onSaved,
userRole = "User", userRole = "User",
onPageMetaChange,
}) { }) {
const normalizedMode = mode === "ansible" ? "ansible" : "script"; const normalizedMode = mode === "ansible" ? "ansible" : "script";
const isAnsible = normalizedMode === "ansible"; const isAnsible = normalizedMode === "ansible";
@@ -365,6 +377,24 @@ export default function AssemblyEditor({
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const isAdmin = (userRole || "").toLowerCase() === "admin"; 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( const TYPE_OPTIONS = useMemo(
() => (isAnsible ? TYPE_OPTIONS_ALL.filter((o) => o.key === "ansible") : TYPE_OPTIONS_ALL.filter((o) => o.key !== "ansible")), () => (isAnsible ? TYPE_OPTIONS_ALL.filter((o) => o.key === "ansible") : TYPE_OPTIONS_ALL.filter((o) => o.key !== "ansible")),
[isAnsible] [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 gridRef = useRef(null);
const [rows, setRows] = useState([]); const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(false); 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 [cloneDialog, setCloneDialog] = useState({ open: false, row: null, targetDomain: "user" });
const isAdmin = (userRole || "").toLowerCase() === "admin"; 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 () => { const fetchAssemblies = useCallback(async () => {
setLoading(true); setLoading(true);
setError(""); setError("");
@@ -602,18 +611,7 @@ export default function AssemblyList({ onOpenWorkflow, onOpenScript, userRole =
}} }}
elevation={0} elevation={0}
> >
<Box sx={{ px: 3, pt: 3, pb: 1 }}> <Box sx={{ px: 2, mt: 1, display: "flex", alignItems: "center", justifyContent: "space-between", gap: 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={{ display: "flex", alignItems: "center", gap: 1 }}> <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<IconButton <IconButton
size="small" size="small"

View File

@@ -90,7 +90,7 @@ const normalizeStatus = (status) => {
return status.toLowerCase(); return status.toLowerCase();
}; };
export default function DeviceApprovals() { export default function DeviceApprovals({ onPageMetaChange }) {
const [approvals, setApprovals] = useState([]); const [approvals, setApprovals] = useState([]);
const [statusFilter, setStatusFilter] = useState("all"); const [statusFilter, setStatusFilter] = useState("all");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -100,6 +100,7 @@ export default function DeviceApprovals() {
const [actioningId, setActioningId] = useState(null); const [actioningId, setActioningId] = useState(null);
const [conflictPrompt, setConflictPrompt] = useState(null); const [conflictPrompt, setConflictPrompt] = useState(null);
const gridRef = useRef(null); const gridRef = useRef(null);
const useGlobalHeader = Boolean(onPageMetaChange);
const loadApprovals = useCallback(async () => { const loadApprovals = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -122,6 +123,15 @@ export default function DeviceApprovals() {
useEffect(() => { loadApprovals(); }, [loadApprovals]); 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 dedupedApprovals = useMemo(() => {
const normalized = approvals const normalized = approvals
.map((record) => ({ ...record, status: normalizeStatus(record.status) })) .map((record) => ({ ...record, status: normalizeStatus(record.status) }))
@@ -414,22 +424,49 @@ export default function DeviceApprovals() {
minWidth: 0, minWidth: 0,
height: "100%", height: "100%",
borderRadius: 0, borderRadius: 0,
border: `1px solid ${MAGIC_UI.panelBorder}`, border: "none",
background: "transparent", background: "transparent",
boxShadow: "0 25px 80px rgba(6, 12, 30, 0.8)", boxShadow: "none",
overflow: "hidden", overflow: "hidden",
}} }}
elevation={0} elevation={0}
> >
{/* Page header (no solid backdrop so gradient reaches the top) */} {!useGlobalHeader && (
<Box sx={{ p: 3 }}> <Box sx={{ p: 3 }}>
<Stack direction="row" alignItems="center" justifyContent="space-between"> <Stack direction="row" alignItems="center" justifyContent="space-between">
<Stack direction="row" spacing={1} alignItems="center"> <Stack direction="row" spacing={1} alignItems="center">
<SecurityIcon sx={{ color: MAGIC_UI.accentA }} /> <SecurityIcon sx={{ color: MAGIC_UI.accentA }} />
<Typography variant="h6" sx={{ color: MAGIC_UI.textBright, fontWeight: 700 }}> <Typography variant="h6" sx={{ color: MAGIC_UI.textBright, fontWeight: 700 }}>
Device Approval Queue Device Approval Queue
</Typography> </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> </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" }}> <Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems={{ xs: "stretch", sm: "center" }}>
<FormControl size="small" sx={{ minWidth: 200 }}> <FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel id="approval-status-filter-label">Status</InputLabel> <InputLabel id="approval-status-filter-label">Status</InputLabel>
@@ -450,8 +487,8 @@ export default function DeviceApprovals() {
Refresh Refresh
</Button> </Button>
</Stack> </Stack>
</Stack> </Box>
</Box> )}
{/* Feedback */} {/* Feedback */}
{feedback && ( {feedback && (

View File

@@ -49,6 +49,8 @@ const MAGIC_UI = {
accentC: "#34d399", 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 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'; const gridFontFamily = '"IBM Plex Sans", "Helvetica Neue", Arial, sans-serif';
@@ -249,7 +251,7 @@ const GRID_COMPONENTS = {
HistoryActionsCell, HistoryActionsCell,
}; };
export default function DeviceDetails({ device, onBack, onQuickJobLaunch }) { export default function DeviceDetails({ device, onBack, onQuickJobLaunch, onPageMetaChange }) {
const [tab, setTab] = useState(0); const [tab, setTab] = useState(0);
const [agent, setAgent] = useState(device || {}); const [agent, setAgent] = useState(device || {});
const [details, setDetails] = useState({}); const [details, setDetails] = useState({});
@@ -956,7 +958,7 @@ export default function DeviceDetails({ device, onBack, onQuickJobLaunch }) {
sx={{ sx={{
p: 2, p: 2,
borderRadius: 3, borderRadius: 3,
border: `1px solid ${MAGIC_UI.panelBorder}`, border: "none",
background: 'transparent', background: 'transparent',
boxShadow: "0 18px 40px rgba(2,6,23,0.55)", boxShadow: "0 18px 40px rgba(2,6,23,0.55)",
mb: 1.5, mb: 1.5,
@@ -1494,6 +1496,26 @@ export default function DeviceDetails({ device, onBack, onQuickJobLaunch }) {
const status = lockedStatus || statusFromHeartbeat(agent.last_seen || device?.lastSeen); 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 = [ const topTabRenderers = [
renderDeviceSummaryTab, renderDeviceSummaryTab,
renderStorageTab, renderStorageTab,
@@ -1512,7 +1534,7 @@ export default function DeviceDetails({ device, onBack, onQuickJobLaunch }) {
borderRadius: 0, borderRadius: 0,
background: "transparent", background: "transparent",
border: `1px solid ${MAGIC_UI.panelBorder}`, border: `1px solid ${MAGIC_UI.panelBorder}`,
boxShadow: MAGIC_UI.glow, boxShadow: "none",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
flexGrow: 1, flexGrow: 1,
@@ -1529,7 +1551,7 @@ export default function DeviceDetails({ device, onBack, onQuickJobLaunch }) {
gap: 2, 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 && ( {onBack && (
<Button <Button
variant="outlined" variant="outlined"
@@ -1546,27 +1568,19 @@ export default function DeviceDetails({ device, onBack, onQuickJobLaunch }) {
Back Back
</Button> </Button>
)} )}
<Box> <Box
<Typography component="span"
variant="h6" sx={{
sx={{ color: MAGIC_UI.textBright, display: "flex", alignItems: "center", gap: 1 }} width: 10,
> height: 10,
<Box borderRadius: 10,
component="span" backgroundColor: statusColor(status),
sx={{ boxShadow: `0 0 12px ${statusColor(status)}`,
width: 10, }}
height: 10, />
borderRadius: 10, <Typography variant="body2" sx={{ color: MAGIC_UI.textMuted }}>
backgroundColor: statusColor(status), GUID: {meta.agentGuid || summary.agent_guid || "unknown"}
boxShadow: `0 0 12px ${statusColor(status)}`, </Typography>
}}
/>
{agent.hostname || "Device Details"}
</Typography>
<Typography variant="body2" sx={{ color: MAGIC_UI.textMuted }}>
GUID: {meta.agentGuid || summary.agent_guid || "unknown"}
</Typography>
</Box>
</Box> </Box>
<Box <Box

View File

@@ -18,6 +18,7 @@ import MoreVertIcon from "@mui/icons-material/MoreVert";
import ViewColumnIcon from "@mui/icons-material/ViewColumn"; import ViewColumnIcon from "@mui/icons-material/ViewColumn";
import AddIcon from "@mui/icons-material/Add"; import AddIcon from "@mui/icons-material/Add";
import CachedIcon from "@mui/icons-material/Cached"; import CachedIcon from "@mui/icons-material/Cached";
import DevicesOtherIcon from "@mui/icons-material/DevicesOther";
import { AgGridReact } from "ag-grid-react"; import { AgGridReact } from "ag-grid-react";
import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community"; import { ModuleRegistry, AllCommunityModule, themeQuartz } from "ag-grid-community";
import { DeleteDeviceDialog, CreateCustomViewDialog, RenameCustomViewDialog } from "../Dialogs.jsx"; import { DeleteDeviceDialog, CreateCustomViewDialog, RenameCustomViewDialog } from "../Dialogs.jsx";
@@ -64,6 +65,8 @@ const MAGIC_UI = {
surfaceOverlay: "rgba(15, 23, 42, 0.72)", surfaceOverlay: "rgba(15, 23, 42, 0.72)",
}; };
const PAGE_ICON = DevicesOtherIcon;
const StatTile = React.memo(function StatTile({ label, value, meta, gradient }) { const StatTile = React.memo(function StatTile({ label, value, meta, gradient }) {
return ( return (
<Box <Box
@@ -344,6 +347,7 @@ export default function DeviceList({
showAddButton, showAddButton,
addButtonLabel, addButtonLabel,
defaultAddType, defaultAddType,
onPageMetaChange,
}) { }) {
const [rows, setRows] = useState([]); const [rows, setRows] = useState([]);
const [menuAnchor, setMenuAnchor] = useState(null); const [menuAnchor, setMenuAnchor] = useState(null);
@@ -692,6 +696,15 @@ export default function DeviceList({
return `Monitoring ${heroStats.total} managed endpoint(s) ${sitePart}.`; return `Monitoring ${heroStats.total} managed endpoint(s) ${sitePart}.`;
}, [heroStats]); }, [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 fetchDevices = useCallback(async (options = {}) => {
const { refreshRepo = false } = options || {}; const { refreshRepo = false } = options || {};
let repoSha = repoHash; let repoSha = repoHash;
@@ -1577,16 +1590,21 @@ export default function DeviceList({
minWidth: 0, minWidth: 0,
height: "100%", height: "100%",
borderRadius: 0, borderRadius: 0,
border: `1px solid ${MAGIC_UI.panelBorder}`, border: "none",
background: "transparent", background: "transparent",
boxShadow: MAGIC_UI.glow, boxShadow: "none",
position: "relative", position: "relative",
overflow: "hidden", overflow: "hidden",
}} }}
elevation={0} elevation={0}
> >
<Box sx={{ position: "relative", zIndex: 1, p: { xs: 2, md: 0 }, pb: 2 }}> <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 }, p: { xs: 2, md: 3 },
display: "flex", display: "flex",
flexWrap: "wrap", flexWrap: "wrap",
@@ -1596,47 +1614,23 @@ export default function DeviceList({
isolation: "isolate", isolation: "isolate",
}} }}
> >
<Box <Box sx={{ flex: "1 1 320px", minWidth: 0, display: "flex", flexWrap: "wrap", gap: 1 }}>
sx={{ {hasActiveFilters ? (
flex: "1 1 320px", <Box sx={HERO_BADGE_SX}>
minWidth: 0, <span>Filters</span>
display: "flex", <Typography component="span" sx={{ fontWeight: 700, fontSize: "0.8rem", color: MAGIC_UI.accentA }}>
flexDirection: "column", {activeFilterCount}
gap: 1.25, </Typography>
position: "relative", </Box>
zIndex: 1, ) : null}
}} {selectedIds.size > 0 ? (
> <Box sx={HERO_BADGE_SX}>
<Typography <span>Selected</span>
variant="h5" <Typography component="span" sx={{ fontWeight: 700, fontSize: "0.8rem", color: MAGIC_UI.accentB }}>
sx={{ {selectedIds.size}
fontWeight: 700, </Typography>
letterSpacing: 0.6, </Box>
color: MAGIC_UI.textBright, ) : null}
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> </Box>
<Box sx={{ flex: "1 1 320px", minWidth: 0, position: "relative", zIndex: 1 }}> <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 }}> <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", 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 = { const gradientButtonSx = {
backgroundImage: "linear-gradient(135deg,#7dd3fc,#c084fc)", backgroundImage: "linear-gradient(135deg,#7dd3fc,#c084fc)",
color: "#0b1220", 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 gridRef = useRef(null);
const [rows, setRows] = useState([]); const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(false); 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(() => { useEffect(() => {
loadFilters(); loadFilters();
}, [loadFilters, refreshToken]); }, [loadFilters, refreshToken]);
@@ -328,32 +342,7 @@ export default function DeviceFilterList({ onCreateFilter, onEditFilter, refresh
overflow: "hidden", overflow: "hidden",
}} }}
> >
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", mb: 2.5 }}> <Box sx={{ display: "flex", justifyContent: "flex-end", alignItems: "center", 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>
<Stack direction="row" gap={1}> <Stack direction="row" gap={1}>
<Tooltip title="Refresh"> <Tooltip title="Refresh">
<IconButton <IconButton

View File

@@ -16,27 +16,84 @@ import {
DialogContent, DialogContent,
DialogActions, DialogActions,
TextField, TextField,
CircularProgress CircularProgress,
Stack,
Tooltip,
} from "@mui/material"; } from "@mui/material";
import AddIcon from "@mui/icons-material/Add"; import AddIcon from "@mui/icons-material/Add";
import EditIcon from "@mui/icons-material/Edit"; import EditIcon from "@mui/icons-material/Edit";
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import RefreshIcon from "@mui/icons-material/Refresh"; 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 { ConfirmDeleteDialog } from "../Dialogs.jsx";
import AddDevice from "./Add_Device.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 = { const tableStyles = {
minWidth: "100%",
"& th, & td": { "& th, & td": {
color: "#ddd", color: MAGIC_UI.textBright,
borderColor: "#2a2a2a", borderColor: "rgba(148,163,184,0.18)",
fontSize: 13, fontSize: 13,
py: 0.75 py: 1,
px: 1.5,
backgroundColor: "transparent",
}, },
"& th": { "& 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 = { const defaultForm = {
@@ -46,17 +103,16 @@ const defaultForm = {
operating_system: "" operating_system: ""
}; };
export default function SSHDevices({ type = "ssh" }) { export default function SSHDevices({ type = "ssh", onPageMetaChange, onQuickJobLaunch }) {
const typeLabel = type === "winrm" ? "WinRM" : "SSH"; 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 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 addButtonLabel = `Add ${typeLabel} Device`;
const addressLabel = `${typeLabel} Address`; const addressLabel = `${typeLabel} Address`;
const loadingLabel = `Loading ${typeLabel} devices…`; const loadingLabel = `Loading ${typeLabel} devices…`;
const emptyLabel = `No ${typeLabel} devices have been added yet.`; 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 editDialogTitle = `Edit ${typeLabel} Device`;
const newDialogTitle = `New ${typeLabel} Device`; const newDialogTitle = `New ${typeLabel} Device`;
const [rows, setRows] = useState([]); const [rows, setRows] = useState([]);
@@ -99,6 +155,16 @@ export default function SSHDevices({ type = "ssh" }) {
loadDevices(); loadDevices();
}, [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 sortedRows = useMemo(() => {
const list = [...rows]; const list = [...rows];
list.sort((a, b) => { 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 ( 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 <Box
sx={{ sx={{
px: 3,
pt: 3,
pb: 1,
display: "flex", display: "flex",
flexWrap: "wrap",
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
p: 2, gap: 2,
borderBottom: "1px solid #2a2a2a"
}} }}
> >
<Box> <Box sx={{ flexGrow: 1 }} />
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}> <Stack direction="row" spacing={1} alignItems="center">
{pageTitle} <Tooltip title="Refresh list">
</Typography> <span>
<Typography variant="body2" sx={{ color: "#aaa" }}> <IconButton
{descriptionText} size="small"
</Typography> onClick={loadDevices}
</Box> disabled={loading}
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> sx={{
<Button color: MAGIC_UI.textBright,
size="small" border: `1px solid ${MAGIC_UI.panelBorder}`,
variant="outlined" borderRadius: 2,
startIcon={<RefreshIcon />} background: "rgba(12,18,35,0.65)",
sx={{ color: "#58a6ff", borderColor: "#58a6ff" }} "&:hover": { borderColor: MAGIC_UI.accentA },
onClick={loadDevices} }}
disabled={loading} >
> <RefreshIcon fontSize="small" />
Refresh </IconButton>
</Button> </span>
</Tooltip>
<Button <Button
size="small" size="small"
variant="contained" variant="contained"
startIcon={<AddIcon />} startIcon={<AddIcon />}
sx={{ bgcolor: "#58a6ff", color: "#0b0f19" }} sx={gradientButtonSx}
onClick={openCreate} onClick={openCreate}
> >
{addButtonLabel} {addButtonLabel}
</Button> </Button>
</Box> </Stack>
</Box> </Box>
{error && ( {error && (
<Box sx={{ px: 2, py: 1.5, color: "#ff8080" }}> <Box sx={{ px: 3, pb: 1 }}>
<Typography variant="body2">{error}</Typography> <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> </Box>
)} )}
{loading && ( {loading && (
<Box sx={{ px: 2, py: 1.5, display: "flex", alignItems: "center", gap: 1, color: "#7db7ff" }}> <Box sx={{ px: 3, pb: 1 }}>
<CircularProgress size={18} sx={{ color: "#58a6ff" }} /> <Box
<Typography variant="body2">{loadingLabel}</Typography> 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> </Box>
)} )}
<Table size="small" sx={tableStyles}> <Box sx={{ px: 3, pb: 3, flexGrow: 1, minHeight: 0 }}>
<TableHead> <Box
<TableRow> sx={{
<TableCell sortDirection={orderBy === "hostname" ? order : false}> borderRadius: 3,
<TableSortLabel border: `1px solid ${MAGIC_UI.panelBorder}`,
active={orderBy === "hostname"} background: MAGIC_UI.panelBg,
direction={orderBy === "hostname" ? order : "asc"} boxShadow: MAGIC_UI.glow,
onClick={handleSort("hostname")} overflow: "hidden",
> }}
Hostname >
</TableSortLabel> <Table size="small" sx={tableStyles}>
</TableCell> <TableHead>
<TableCell sortDirection={orderBy === "address" ? order : false}> <TableRow>
<TableSortLabel <TableCell sortDirection={orderBy === "hostname" ? order : false}>
active={orderBy === "address"} <TableSortLabel
direction={orderBy === "address" ? order : "asc"} active={orderBy === "hostname"}
onClick={handleSort("address")} direction={orderBy === "hostname" ? order : "asc"}
> onClick={handleSort("hostname")}
{addressLabel} >
</TableSortLabel> Hostname
</TableCell> </TableSortLabel>
<TableCell sortDirection={orderBy === "description" ? order : false}>
<TableSortLabel
active={orderBy === "description"}
direction={orderBy === "description" ? order : "asc"}
onClick={handleSort("description")}
>
Description
</TableSortLabel>
</TableCell>
<TableCell sortDirection={orderBy === "created_at" ? order : false}>
<TableSortLabel
active={orderBy === "created_at"}
direction={orderBy === "created_at" ? order : "asc"}
onClick={handleSort("created_at")}
>
Added
</TableSortLabel>
</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sortedRows.map((row) => {
const createdTs = Number(row.created_at || 0) * 1000;
const createdDisplay = createdTs
? new Date(createdTs).toLocaleString()
: (row.summary?.created || "");
return (
<TableRow key={row.hostname}>
<TableCell>{row.hostname}</TableCell>
<TableCell>{row.connection_endpoint || ""}</TableCell>
<TableCell>{row.description || ""}</TableCell>
<TableCell>{createdDisplay}</TableCell>
<TableCell align="right">
<IconButton size="small" sx={{ color: "#7db7ff" }} onClick={() => openEdit(row)}>
<EditIcon fontSize="small" />
</IconButton>
<IconButton size="small" sx={{ color: "#ff8080" }} onClick={() => setDeleteTarget(row)}>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell> </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> </TableRow>
); </TableHead>
})} <TableBody>
{!sortedRows.length && !loading && ( {sortedRows.map((row) => {
<TableRow> const createdTs = Number(row.created_at || 0) * 1000;
<TableCell colSpan={5} sx={{ textAlign: "center", color: "#888" }}> const createdDisplay = createdTs
{emptyLabel} ? new Date(createdTs).toLocaleString()
</TableCell> : row.summary?.created || "";
</TableRow> return (
)} <TableRow key={row.hostname}>
</TableBody> <TableCell>{row.hostname}</TableCell>
</Table> <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 <Dialog
open={dialogOpen} open={dialogOpen}
onClose={handleDialogClose} onClose={handleDialogClose}
fullWidth fullWidth
maxWidth="sm" 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> <DialogTitle>{isEdit ? editDialogTitle : newDialogTitle}</DialogTitle>
<DialogContent sx={{ display: "flex", flexDirection: "column", gap: 2, mt: 1 }}> <DialogContent sx={{ display: "flex", flexDirection: "column", gap: 2, mt: 1 }}>
@@ -376,12 +515,13 @@ export default function SSHDevices({ type = "ssh" }) {
size="small" size="small"
sx={{ sx={{
"& .MuiOutlinedInput-root": { "& .MuiOutlinedInput-root": {
backgroundColor: "#1f1f1f", backgroundColor: "rgba(12,18,35,0.75)",
color: "#fff", color: MAGIC_UI.textBright,
"& fieldset": { borderColor: "#555" }, "& fieldset": { borderColor: MAGIC_UI.panelBorder },
"&:hover fieldset": { borderColor: "#888" } "&: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)." helperText="Hostname used within Borealis (unique)."
/> />
@@ -393,12 +533,13 @@ export default function SSHDevices({ type = "ssh" }) {
size="small" size="small"
sx={{ sx={{
"& .MuiOutlinedInput-root": { "& .MuiOutlinedInput-root": {
backgroundColor: "#1f1f1f", backgroundColor: "rgba(12,18,35,0.75)",
color: "#fff", color: MAGIC_UI.textBright,
"& fieldset": { borderColor: "#555" }, "& fieldset": { borderColor: MAGIC_UI.panelBorder },
"&:hover fieldset": { borderColor: "#888" } "&: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}.`} helperText={`IP or FQDN Borealis can reach over ${typeLabel}.`}
/> />
@@ -410,12 +551,13 @@ export default function SSHDevices({ type = "ssh" }) {
size="small" size="small"
sx={{ sx={{
"& .MuiOutlinedInput-root": { "& .MuiOutlinedInput-root": {
backgroundColor: "#1f1f1f", backgroundColor: "rgba(12,18,35,0.75)",
color: "#fff", color: MAGIC_UI.textBright,
"& fieldset": { borderColor: "#555" }, "& fieldset": { borderColor: MAGIC_UI.panelBorder },
"&:hover fieldset": { borderColor: "#888" } "&:hover fieldset": { borderColor: accentColor },
"&.Mui-focused fieldset": { borderColor: accentColor },
}, },
"& .MuiInputLabel-root": { color: "#aaa" } "& .MuiInputLabel-root": { color: MAGIC_UI.textMuted },
}} }}
/> />
<TextField <TextField
@@ -426,28 +568,33 @@ export default function SSHDevices({ type = "ssh" }) {
size="small" size="small"
sx={{ sx={{
"& .MuiOutlinedInput-root": { "& .MuiOutlinedInput-root": {
backgroundColor: "#1f1f1f", backgroundColor: "rgba(12,18,35,0.75)",
color: "#fff", color: MAGIC_UI.textBright,
"& fieldset": { borderColor: "#555" }, "& fieldset": { borderColor: MAGIC_UI.panelBorder },
"&:hover fieldset": { borderColor: "#888" } "&:hover fieldset": { borderColor: accentColor },
"&.Mui-focused fieldset": { borderColor: accentColor },
}, },
"& .MuiInputLabel-root": { color: "#aaa" } "& .MuiInputLabel-root": { color: MAGIC_UI.textMuted },
}} }}
/> />
{error && ( {error && (
<Typography variant="body2" sx={{ color: "#ff8080" }}> <Typography variant="body2" sx={{ color: "#ffb4b4" }}>
{error} {error}
</Typography> </Typography>
)} )}
</DialogContent> </DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}> <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 Cancel
</Button> </Button>
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
variant="outlined" variant="contained"
sx={{ color: "#58a6ff", borderColor: "#58a6ff" }} sx={gradientButtonSx}
disabled={submitting} disabled={submitting}
> >
{submitting ? "Saving..." : "Save"} {submitting ? "Saving..." : "Save"}

View File

@@ -71,6 +71,10 @@ const MAGIC_UI = {
glow: "0 30px 70px rgba(2,6,23,0.85)", 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({ const gridTheme = themeQuartz.withParams({
accentColor: "#8b5cf6", accentColor: "#8b5cf6",
backgroundColor: "#070b1a", 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 [tab, setTab] = useState(0);
const [jobName, setJobName] = useState(""); const [jobName, setJobName] = useState("");
const [pageTitleJobName, setPageTitleJobName] = useState(""); const [pageTitleJobName, setPageTitleJobName] = useState("");
@@ -839,6 +850,26 @@ export default function CreateJob({ onCancel, onCreated, initialJob = null, quic
const [credentialLoading, setCredentialLoading] = useState(false); const [credentialLoading, setCredentialLoading] = useState(false);
const [credentialError, setCredentialError] = useState(""); const [credentialError, setCredentialError] = useState("");
const [selectedCredentialId, setSelectedCredentialId] = 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 [useSvcAccount, setUseSvcAccount] = useState(true);
const [assembliesPayload, setAssembliesPayload] = useState({ items: [], queue: [] }); const [assembliesPayload, setAssembliesPayload] = useState({ items: [], queue: [] });
const [assembliesLoading, setAssembliesLoading] = useState(false); const [assembliesLoading, setAssembliesLoading] = useState(false);
@@ -2863,8 +2894,8 @@ const heroTiles = useMemo(() => {
gap: 3, gap: 3,
borderRadius: 0, borderRadius: 0,
background: "transparent", background: "transparent",
border: `1px solid ${MAGIC_UI.panelBorder}`, border: "none",
boxShadow: MAGIC_UI.glow, boxShadow: "none",
}} }}
> >
<Box <Box
@@ -2873,25 +2904,9 @@ const heroTiles = useMemo(() => {
flexWrap: "wrap", flexWrap: "wrap",
gap: 2, gap: 2,
alignItems: "center", 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" }}> <Box sx={{ display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap" }}>
<Button onClick={onCancel} sx={OUTLINE_BUTTON_SX}> <Button onClick={onCancel} sx={OUTLINE_BUTTON_SX}>
Cancel Cancel

View File

@@ -62,6 +62,10 @@ const AURORA_SHELL = {
accent: "#7dd3fc", 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 // Gradient button styling
const gradientButtonSx = { const gradientButtonSx = {
backgroundImage: "linear-gradient(135deg,#7dd3fc,#c084fc)", 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 [rows, setRows] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -171,6 +175,16 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken
const [assembliesLoading, setAssembliesLoading] = useState(false); const [assembliesLoading, setAssembliesLoading] = useState(false);
const [assembliesError, setAssembliesError] = useState(""); const [assembliesError, setAssembliesError] = useState("");
const gridApiRef = useRef(null); 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 autoSizeTrackedColumns = useCallback(() => {
const api = gridApiRef.current; const api = gridApiRef.current;
if (!api) return; if (!api) return;
@@ -714,64 +728,48 @@ export default function ScheduledJobsList({ onCreateJob, onEditJob, refreshToken
}} }}
elevation={0} elevation={0}
> >
{/* Page header (keep padding: top 24px, left/right 24px) */} <Box sx={{ px: 3, pt: 3, pb: 1, display: "flex", justifyContent: "flex-end", gap: 1.5 }}>
<Box sx={{ px: 3, pt: 3, pb: 1 }}> <Tooltip title="Refresh">
<Stack direction="row" alignItems="center" spacing={1.25}> <span>
<HeaderIcon sx={{ fontSize: 22, color: AURORA_SHELL.accent }} /> <IconButton
<Typography variant="h6" sx={{ fontWeight: 700, letterSpacing: 0.5 }}> size="small"
Scheduled Jobs onClick={handleRefreshClick}
</Typography> sx={{ color: "#cbd5e1", borderRadius: 1, "&:hover": { color: "#ffffff" } }}
<Box sx={{ flexGrow: 1 }} /> >
<Stack direction="row" spacing={1}> <CachedIcon fontSize="small" />
<Tooltip title="Refresh"> </IconButton>
<span> </span>
<IconButton </Tooltip>
size="small" <Tooltip title="Create Job">
onClick={handleRefreshClick} <span>
sx={{ color: "#cbd5e1", borderRadius: 1, "&:hover": { color: "#ffffff" } }} <Button size="small" startIcon={<AddIcon />} sx={gradientButtonSx} onClick={() => onCreateJob && onCreateJob()}>
> Create Job
<CachedIcon fontSize="small" /> </Button>
</IconButton> </span>
</span> </Tooltip>
</Tooltip> <Tooltip title="Settings">
<Tooltip title="Create Job"> <span>
<span> <Button
<Button size="small" startIcon={<AddIcon />} sx={gradientButtonSx} onClick={() => onCreateJob && onCreateJob()}> size="small"
Create Job variant="outlined"
</Button> startIcon={<TuneIcon />}
</span> sx={{
</Tooltip> borderColor: "rgba(148,163,184,0.35)",
<Tooltip title="Settings"> color: "#e2e8f0",
<span> textTransform: "none",
<Button borderRadius: 999,
size="small" px: 1.7,
variant="outlined" minWidth: 86,
startIcon={<TuneIcon />} "&:hover": { borderColor: "rgba(148,163,184,0.55)" },
sx={{ }}
borderColor: "rgba(148,163,184,0.35)", >
color: "#e2e8f0", Settings
textTransform: "none", </Button>
borderRadius: 999, </span>
px: 1.7, </Tooltip>
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> </Box>
{/* Content area — a bit more top space below subtitle */} <Box sx={{ mt: 2, px: 2, pb: 2, flexGrow: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
<Box sx={{ mt: "28px", 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={{ display: "flex", flexWrap: "wrap", alignItems: "center", justifyContent: "space-between", gap: 1.5, mb: 2, px: 0.5 }}>
<Box <Box
sx={{ sx={{

View File

@@ -48,6 +48,10 @@ const MAGIC_UI = {
success: "#34d399", 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 = { const RAINBOW_BUTTON_SX = {
borderRadius: 999, borderRadius: 999,
textTransform: "none", 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 [rows, setRows] = useState([]);
const [selectedIds, setSelectedIds] = useState(() => new Set()); const [selectedIds, setSelectedIds] = useState(() => new Set());
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
@@ -75,6 +79,15 @@ export default function SiteList({ onOpenDevicesForSite }) {
const [rotatingId, setRotatingId] = useState(null); const [rotatingId, setRotatingId] = useState(null);
const gridRef = useRef(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 () => { const fetchSites = useCallback(async () => {
try { try {
const res = await fetch("/api/sites"); const res = await fetch("/api/sites");
@@ -216,31 +229,19 @@ export default function SiteList({ onOpenDevicesForSite }) {
minWidth: 0, minWidth: 0,
height: "100%", height: "100%",
borderRadius: 0, borderRadius: 0,
border: `1px solid ${MAGIC_UI.panelBorder}`, border: "none",
background: "transparent", background: "transparent",
boxShadow: "0 25px 80px rgba(6, 12, 30, 0.8)", boxShadow: "none",
overflow: "hidden", overflow: "hidden",
}} }}
elevation={0} elevation={0}
> >
{/* Hero Section Removed — integrated header and buttons */} <Box sx={{ p: { xs: 2, md: 3 }, pb: 1, display: "flex", alignItems: "center", justifyContent: "flex-end", flexWrap: "wrap", gap: 1 }}>
<Box sx={{ p: { xs: 2, md: 3 }, pb: 1, display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap" }}> {heroStats.selected > 0 ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}> <Typography sx={{ color: MAGIC_UI.accentA, fontSize: "0.85rem", fontWeight: 600, mr: 1 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> {heroStats.selected} selected
<LocationCityIcon sx={{ color: MAGIC_UI.accentA }} />
<Typography variant="h6" sx={{ color: MAGIC_UI.textBright, fontWeight: 700, fontSize: "1.3rem" }}>
Managed Sites
</Typography> </Typography>
</Box> ) : null}
<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>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap", justifyContent: "flex-end" }}> <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)}> <Button variant="contained" size="small" startIcon={<AddIcon />} sx={RAINBOW_BUTTON_SX} onClick={() => setCreateOpen(true)}>
Create Site Create Site

View File

@@ -5,12 +5,11 @@ Applies to all Borealis frontends. Use `Data/Engine/web-interface/src/Admin/Page
## Page Template Reference ## Page Template Reference
- Purpose: visual-only baseline for new pages; copy structure but wire your data in real pages. - 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. - 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. - 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. - Typography/buttons: IBM Plex Sans, gradient primary buttons, rounded corners (~8px), themed Quartz grid wrapper.
## MagicUI Styling Language (Visual System) ## 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. - 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. - 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. - Hero storytelling: start views with stat-forward heroes—gradient StatTiles (min 160px) and uppercase pills (HERO_BADGE_SX) summarizing live signals/filters.