mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-09-11 02:08:44 -06:00
Added Support for Assigning Devices to Sites.
This commit is contained in:
@@ -26,6 +26,7 @@ import StatusBar from "./Status_Bar";
|
||||
import NavigationSidebar from "./Navigation_Sidebar";
|
||||
import WorkflowList from "./Workflows/Workflow_List";
|
||||
import DeviceList from "./Devices/Device_List";
|
||||
import SiteList from "./Sites/Site_List";
|
||||
import ScriptEditor from "./Scripting/Script_Editor";
|
||||
import ScheduledJobsList from "./Scheduling/Scheduled_Jobs_List";
|
||||
import Login from "./Login.jsx";
|
||||
@@ -289,6 +290,17 @@ export default function App() {
|
||||
|
||||
const renderMainContent = () => {
|
||||
switch (currentPage) {
|
||||
case "sites":
|
||||
return (
|
||||
<SiteList
|
||||
onOpenDevicesForSite={(siteName) => {
|
||||
try {
|
||||
localStorage.setItem('device_list_initial_site_filter', String(siteName || ''));
|
||||
} catch {}
|
||||
setCurrentPage("devices");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case "devices":
|
||||
return (
|
||||
<DeviceList
|
||||
|
@@ -76,6 +76,7 @@ export default function DeviceList({ onSelectDevice }) {
|
||||
const COL_LABELS = useMemo(
|
||||
() => ({
|
||||
status: "Status",
|
||||
site: "Site",
|
||||
hostname: "Hostname",
|
||||
description: "Description",
|
||||
lastUser: "Last User",
|
||||
@@ -93,6 +94,7 @@ export default function DeviceList({ onSelectDevice }) {
|
||||
const defaultColumns = useMemo(
|
||||
() => [
|
||||
{ id: "status", label: COL_LABELS.status },
|
||||
{ id: "site", label: COL_LABELS.site },
|
||||
{ id: "hostname", label: COL_LABELS.hostname },
|
||||
{ id: "description", label: COL_LABELS.description },
|
||||
{ id: "lastUser", label: COL_LABELS.lastUser },
|
||||
@@ -111,6 +113,11 @@ export default function DeviceList({ onSelectDevice }) {
|
||||
|
||||
// Cache device details to avoid re-fetching every refresh
|
||||
const [detailsByHost, setDetailsByHost] = useState({}); // hostname -> cached fields
|
||||
const [siteMapping, setSiteMapping] = useState({}); // hostname -> { site_id, site_name }
|
||||
const [sites, setSites] = useState([]); // sites list for assignment
|
||||
const [assignDialogOpen, setAssignDialogOpen] = useState(false);
|
||||
const [assignSiteId, setAssignSiteId] = useState(null);
|
||||
const [assignTargets, setAssignTargets] = useState([]); // hostnames
|
||||
|
||||
const fetchAgents = useCallback(async () => {
|
||||
try {
|
||||
@@ -138,6 +145,16 @@ export default function DeviceList({ onSelectDevice }) {
|
||||
});
|
||||
setRows(arr);
|
||||
|
||||
// Fetch site mapping for these hostnames
|
||||
try {
|
||||
const hostCsv = arr.map((r) => r.hostname).filter(Boolean).map(encodeURIComponent).join(',');
|
||||
const resp = await fetch(`/api/sites/device_map?hostnames=${hostCsv}`);
|
||||
const mapData = await resp.json();
|
||||
const mapping = mapData?.mapping || {};
|
||||
setSiteMapping(mapping);
|
||||
setRows((prev) => prev.map((r) => ({ ...r, site: mapping[r.hostname]?.site_name || "Not Configured" })));
|
||||
} catch {}
|
||||
|
||||
// Fetch missing details (last_user, created) for hosts not cached yet
|
||||
const hostsToFetch = arr
|
||||
.map((r) => r.hostname)
|
||||
@@ -234,6 +251,33 @@ export default function DeviceList({ onSelectDevice }) {
|
||||
fetchViews();
|
||||
}, [fetchViews]);
|
||||
|
||||
// Sites helper fetch
|
||||
const fetchSites = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/sites');
|
||||
const data = await res.json();
|
||||
setSites(Array.isArray(data?.sites) ? data.sites : []);
|
||||
} catch { setSites([]); }
|
||||
}, []);
|
||||
|
||||
// Apply initial site filter from Sites page
|
||||
useEffect(() => {
|
||||
try {
|
||||
const site = localStorage.getItem('device_list_initial_site_filter');
|
||||
if (site && site.trim()) {
|
||||
setColumns((prev) => {
|
||||
const hasSite = prev.some((c) => c.id === 'site');
|
||||
if (hasSite) return prev;
|
||||
const next = [...prev];
|
||||
next.splice(1, 0, { id: 'site', label: COL_LABELS.site });
|
||||
return next;
|
||||
});
|
||||
setFilters((f) => ({ ...f, site }));
|
||||
localStorage.removeItem('device_list_initial_site_filter');
|
||||
}
|
||||
} catch {}
|
||||
}, [COL_LABELS.site]);
|
||||
|
||||
const applyView = useCallback((view) => {
|
||||
if (!view || view.id === "default") {
|
||||
setColumns(defaultColumns);
|
||||
@@ -263,6 +307,8 @@ export default function DeviceList({ onSelectDevice }) {
|
||||
switch (colId) {
|
||||
case "status":
|
||||
return row.status || "";
|
||||
case "site":
|
||||
return row.site || "Not Configured";
|
||||
case "hostname":
|
||||
return row.hostname || "";
|
||||
case "description":
|
||||
@@ -591,6 +637,8 @@ export default function DeviceList({ onSelectDevice }) {
|
||||
</Box>
|
||||
</TableCell>
|
||||
);
|
||||
case "site":
|
||||
return <TableCell key={col.id}>{r.site || "Not Configured"}</TableCell>;
|
||||
case "hostname":
|
||||
return (
|
||||
<TableCell
|
||||
@@ -752,6 +800,7 @@ export default function DeviceList({ onSelectDevice }) {
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, p: 1 }}>
|
||||
{[
|
||||
{ id: 'site', label: 'Site' },
|
||||
{ id: 'hostname', label: 'Hostname' },
|
||||
{ id: 'os', label: 'Operating System' },
|
||||
{ id: 'type', label: 'Device Type' },
|
||||
@@ -845,7 +894,29 @@ export default function DeviceList({ onSelectDevice }) {
|
||||
onClose={closeMenu}
|
||||
PaperProps={{ sx: { bgcolor: "#1e1e1e", color: "#fff", fontSize: "13px" } }}
|
||||
>
|
||||
<MenuItem onClick={confirmDelete}>Delete</MenuItem>
|
||||
<MenuItem onClick={async () => {
|
||||
closeMenu();
|
||||
await fetchSites();
|
||||
const targets = new Set(selectedIds);
|
||||
if (selected && !targets.has(selected.id)) targets.add(selected.id);
|
||||
const idToHost = new Map(rows.map((r) => [r.id, r.hostname]));
|
||||
const hostnames = Array.from(targets).map((id) => idToHost.get(id)).filter(Boolean);
|
||||
setAssignTargets(hostnames);
|
||||
setAssignSiteId(null);
|
||||
setAssignDialogOpen(true);
|
||||
}}>Add to Site</MenuItem>
|
||||
<MenuItem onClick={async () => {
|
||||
closeMenu();
|
||||
await fetchSites();
|
||||
const targets = new Set(selectedIds);
|
||||
if (selected && !targets.has(selected.id)) targets.add(selected.id);
|
||||
const idToHost = new Map(rows.map((r) => [r.id, r.hostname]));
|
||||
const hostnames = Array.from(targets).map((id) => idToHost.get(id)).filter(Boolean);
|
||||
setAssignTargets(hostnames);
|
||||
setAssignSiteId(null);
|
||||
setAssignDialogOpen(true);
|
||||
}}>Move to Another Site</MenuItem>
|
||||
<MenuItem onClick={confirmDelete} sx={{ color: '#ff8a8a' }}>Delete</MenuItem>
|
||||
</Menu>
|
||||
<DeleteDeviceDialog
|
||||
open={confirmOpen}
|
||||
@@ -860,6 +931,61 @@ export default function DeviceList({ onSelectDevice }) {
|
||||
hostnames={rows.filter((r) => selectedIds.has(r.id)).map((r) => r.hostname)}
|
||||
/>
|
||||
)}
|
||||
{assignDialogOpen && (
|
||||
<Popover
|
||||
open={assignDialogOpen}
|
||||
onClose={() => setAssignDialogOpen(false)}
|
||||
anchorReference="anchorPosition"
|
||||
anchorPosition={{ top: Math.max(Math.floor(window.innerHeight*0.5), 200), left: Math.max(Math.floor(window.innerWidth*0.5), 300) }}
|
||||
PaperProps={{ sx: { bgcolor: '#1e1e1e', color: '#fff', p: 2, minWidth: 360 } }}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Typography variant="subtitle1">Assign {assignTargets.length} device(s) to a site</Typography>
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
label="Select Site"
|
||||
value={assignSiteId ?? ''}
|
||||
onChange={(e) => setAssignSiteId(Number(e.target.value))}
|
||||
sx={{ '& .MuiOutlinedInput-root': { '& fieldset': { borderColor: '#444' }, '&:hover fieldset': { borderColor: '#666' } }, label: { color: '#aaa' } }}
|
||||
>
|
||||
{sites.map((s) => (
|
||||
<MenuItem key={s.id} value={s.id}>{s.name}</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
<Button variant="outlined" size="small" onClick={() => setAssignDialogOpen(false)} sx={{ textTransform: 'none', borderColor: '#555', color: '#bbb' }}>Cancel</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
disabled={!assignSiteId || assignTargets.length === 0}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await fetch('/api/sites/assign', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ site_id: assignSiteId, hostnames: assignTargets })
|
||||
});
|
||||
} catch {}
|
||||
setAssignDialogOpen(false);
|
||||
// Refresh mapping to update Site column
|
||||
try {
|
||||
const hostCsv = rows.map((r) => r.hostname).filter(Boolean).map(encodeURIComponent).join(',');
|
||||
const resp = await fetch(`/api/sites/device_map?hostnames=${hostCsv}`);
|
||||
const mapData = await resp.json();
|
||||
const mapping = mapData?.mapping || {};
|
||||
setSiteMapping(mapping);
|
||||
setRows((prev) => prev.map((r) => ({ ...r, site: mapping[r.hostname]?.site_name || 'Not Configured' })));
|
||||
} catch {}
|
||||
}}
|
||||
sx={{ textTransform: 'none', borderColor: '#58a6ff', color: '#58a6ff' }}
|
||||
>
|
||||
Assign
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Popover>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
@@ -1,5 +1,6 @@
|
||||
////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: <ProjectRoot>/Data/WebUI/src/Dialogs.jsx
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
@@ -366,3 +367,78 @@ export function RenameCustomViewDialog({ open, value, onChange, onCancel, onSave
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateSiteDialog({ open, onCancel, onCreate }) {
|
||||
const [name, setName] = React.useState("");
|
||||
const [description, setDescription] = React.useState("");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setName("");
|
||||
setDescription("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
|
||||
<DialogTitle>Create Site</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText sx={{ color: "#ccc", mb: 1 }}>
|
||||
Create a new site and optionally add a description.
|
||||
</DialogContentText>
|
||||
<TextField
|
||||
autoFocus
|
||||
fullWidth
|
||||
margin="dense"
|
||||
label="Site Name"
|
||||
variant="outlined"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "#2a2a2a",
|
||||
color: "#ccc",
|
||||
"& fieldset": { borderColor: "#444" },
|
||||
"&:hover fieldset": { borderColor: "#666" }
|
||||
},
|
||||
label: { color: "#aaa" },
|
||||
mt: 1
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={3}
|
||||
margin="dense"
|
||||
label="Description"
|
||||
variant="outlined"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
sx={{
|
||||
"& .MuiOutlinedInput-root": {
|
||||
backgroundColor: "#2a2a2a",
|
||||
color: "#ccc",
|
||||
"& fieldset": { borderColor: "#444" },
|
||||
"&:hover fieldset": { borderColor: "#666" }
|
||||
},
|
||||
label: { color: "#aaa" },
|
||||
mt: 2
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const nm = (name || '').trim();
|
||||
if (!nm) return;
|
||||
onCreate && onCreate(nm, description || '');
|
||||
}}
|
||||
sx={{ color: "#58a6ff" }}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
@@ -20,6 +20,7 @@ import {
|
||||
Code as ScriptIcon,
|
||||
PeopleOutline as CommunityIcon
|
||||
} from "@mui/icons-material";
|
||||
import { LocationCity as SitesIcon } from "@mui/icons-material";
|
||||
|
||||
function NavigationSidebar({ currentPage, onNavigate }) {
|
||||
const [expandedNav, setExpandedNav] = useState({
|
||||
@@ -75,6 +76,8 @@ function NavigationSidebar({ currentPage, onNavigate }) {
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1, overflowY: "auto" }}>
|
||||
{/* Top-level Sites */}
|
||||
<NavItem icon={<SitesIcon fontSize="small" />} label="Sites" pageKey="sites" />
|
||||
{/* Devices */}
|
||||
<Accordion
|
||||
expanded={expandedNav.devices}
|
||||
|
323
Data/Server/WebUI/src/Sites/Site_List.jsx
Normal file
323
Data/Server/WebUI/src/Sites/Site_List.jsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import React, { useEffect, useMemo, useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
Paper,
|
||||
Box,
|
||||
Typography,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableSortLabel,
|
||||
Checkbox,
|
||||
Button,
|
||||
IconButton,
|
||||
Popover,
|
||||
TextField,
|
||||
MenuItem
|
||||
} from "@mui/material";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import DeleteIcon from "@mui/icons-material/DeleteOutline";
|
||||
import FilterListIcon from "@mui/icons-material/FilterList";
|
||||
import ViewColumnIcon from "@mui/icons-material/ViewColumn";
|
||||
import { CreateSiteDialog, ConfirmDeleteDialog } from "../Dialogs.jsx";
|
||||
|
||||
export default function SiteList({ onOpenDevicesForSite }) {
|
||||
const [rows, setRows] = useState([]); // {id, name, description, device_count}
|
||||
const [orderBy, setOrderBy] = useState("name");
|
||||
const [order, setOrder] = useState("asc");
|
||||
const [selectedIds, setSelectedIds] = useState(() => new Set());
|
||||
|
||||
// Columns configuration (similar style to Device_List)
|
||||
const COL_LABELS = useMemo(() => ({
|
||||
name: "Name",
|
||||
description: "Description",
|
||||
device_count: "Devices",
|
||||
}), []);
|
||||
const defaultColumns = useMemo(
|
||||
() => [
|
||||
{ id: "name", label: COL_LABELS.name },
|
||||
{ id: "description", label: COL_LABELS.description },
|
||||
{ id: "device_count", label: COL_LABELS.device_count },
|
||||
],
|
||||
[COL_LABELS]
|
||||
);
|
||||
const [columns, setColumns] = useState(defaultColumns);
|
||||
const dragColId = useRef(null);
|
||||
const [colChooserAnchor, setColChooserAnchor] = useState(null);
|
||||
|
||||
const [filters, setFilters] = useState({});
|
||||
const [filterAnchor, setFilterAnchor] = useState(null); // { id, anchorEl }
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
const fetchSites = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/sites");
|
||||
const data = await res.json();
|
||||
setRows(Array.isArray(data?.sites) ? data.sites : []);
|
||||
} catch {
|
||||
setRows([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchSites(); }, [fetchSites]);
|
||||
|
||||
const handleSort = (col) => {
|
||||
if (orderBy === col) setOrder(order === "asc" ? "desc" : "asc");
|
||||
else { setOrderBy(col); setOrder("asc"); }
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!filters || Object.keys(filters).length === 0) return rows;
|
||||
return rows.filter((r) =>
|
||||
Object.entries(filters).every(([k, v]) => {
|
||||
const val = String(v || "").toLowerCase();
|
||||
if (!val) return true;
|
||||
return String(r[k] ?? "").toLowerCase().includes(val);
|
||||
})
|
||||
);
|
||||
}, [rows, filters]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const dir = order === "asc" ? 1 : -1;
|
||||
const arr = [...filtered];
|
||||
arr.sort((a, b) => {
|
||||
if (orderBy === "device_count") return ((a.device_count||0) - (b.device_count||0)) * dir;
|
||||
return String(a[orderBy] ?? "").localeCompare(String(b[orderBy] ?? "")) * dir;
|
||||
});
|
||||
return arr;
|
||||
}, [filtered, orderBy, order]);
|
||||
|
||||
const onHeaderDragStart = (colId) => (e) => { dragColId.current = colId; try { e.dataTransfer.setData("text/plain", colId); } catch {} };
|
||||
const onHeaderDragOver = (e) => { e.preventDefault(); };
|
||||
const onHeaderDrop = (targetColId) => (e) => {
|
||||
e.preventDefault();
|
||||
const fromId = dragColId.current; if (!fromId || fromId === targetColId) return;
|
||||
setColumns((prev) => {
|
||||
const cur = [...prev];
|
||||
const fromIdx = cur.findIndex((c) => c.id === fromId);
|
||||
const toIdx = cur.findIndex((c) => c.id === targetColId);
|
||||
if (fromIdx < 0 || toIdx < 0) return prev;
|
||||
const [moved] = cur.splice(fromIdx, 1);
|
||||
cur.splice(toIdx, 0, moved);
|
||||
return cur;
|
||||
});
|
||||
dragColId.current = null;
|
||||
};
|
||||
|
||||
const openFilter = (id) => (e) => setFilterAnchor({ id, anchorEl: e.currentTarget });
|
||||
const closeFilter = () => setFilterAnchor(null);
|
||||
const onFilterChange = (id) => (e) => setFilters((prev) => ({ ...prev, [id]: e.target.value }));
|
||||
|
||||
const isAllChecked = sorted.length > 0 && sorted.every((r) => selectedIds.has(r.id));
|
||||
const isIndeterminate = selectedIds.size > 0 && !isAllChecked;
|
||||
const toggleAll = (e) => {
|
||||
const checked = e.target.checked;
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) sorted.forEach((r) => next.add(r.id));
|
||||
else next.clear();
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const toggleOne = (id) => (e) => {
|
||||
const checked = e.target.checked;
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.add(id); else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}>
|
||||
<Box sx={{ p: 2, pb: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>Sites</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<DeleteIcon />}
|
||||
disabled={selectedIds.size === 0}
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
sx={{ color: selectedIds.size ? '#ff8a8a' : '#666', borderColor: selectedIds.size ? '#ff4f4f' : '#333', textTransform: 'none' }}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
sx={{ color: '#58a6ff', borderColor: '#58a6ff', textTransform: 'none' }}
|
||||
>
|
||||
Create Site
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Table size="small" sx={{ minWidth: 700 }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox indeterminate={isIndeterminate} checked={isAllChecked} onChange={toggleAll} sx={{ color: '#777' }} />
|
||||
</TableCell>
|
||||
{columns.map((col) => (
|
||||
<TableCell key={col.id} sortDirection={orderBy === col.id ? order : false} draggable onDragStart={onHeaderDragStart(col.id)} onDragOver={onHeaderDragOver} onDrop={onHeaderDrop(col.id)}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<TableSortLabel active={orderBy === col.id} direction={orderBy === col.id ? order : 'asc'} onClick={() => handleSort(col.id)}>
|
||||
{col.label}
|
||||
</TableSortLabel>
|
||||
<IconButton size="small" onClick={openFilter(col.id)} sx={{ color: filters[col.id] ? '#58a6ff' : '#888' }}>
|
||||
<FilterListIcon fontSize="inherit" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{sorted.map((r) => (
|
||||
<TableRow key={r.id} hover>
|
||||
<TableCell padding="checkbox" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={selectedIds.has(r.id)} onChange={toggleOne(r.id)} sx={{ color: '#777' }} />
|
||||
</TableCell>
|
||||
{columns.map((col) => {
|
||||
switch (col.id) {
|
||||
case 'name':
|
||||
return (
|
||||
<TableCell
|
||||
key={col.id}
|
||||
onClick={() => {
|
||||
if (onOpenDevicesForSite) onOpenDevicesForSite(r.name);
|
||||
}}
|
||||
sx={{ color: '#58a6ff', '&:hover': { cursor: 'pointer', textDecoration: 'underline' } }}
|
||||
>
|
||||
{r.name}
|
||||
</TableCell>
|
||||
);
|
||||
case 'description':
|
||||
return <TableCell key={col.id}>{r.description || ''}</TableCell>;
|
||||
case 'device_count':
|
||||
return <TableCell key={col.id}>{r.device_count ?? 0}</TableCell>;
|
||||
default:
|
||||
return <TableCell key={col.id} />;
|
||||
}
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
{sorted.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length + 1} sx={{ color: '#888' }}>No sites defined.</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* Column chooser */}
|
||||
<Popover
|
||||
open={Boolean(colChooserAnchor)}
|
||||
anchorEl={colChooserAnchor}
|
||||
onClose={() => setColChooserAnchor(null)}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
PaperProps={{ sx: { bgcolor: '#1e1e1e', color: '#fff', p: 1 } }}
|
||||
>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, p: 1 }}>
|
||||
{[
|
||||
{ id: 'name', label: 'Name' },
|
||||
{ id: 'description', label: 'Description' },
|
||||
{ id: 'device_count', label: 'Devices' },
|
||||
].map((opt) => (
|
||||
<MenuItem key={opt.id} disableRipple onClick={(e) => e.stopPropagation()} sx={{ gap: 1 }}>
|
||||
<Checkbox
|
||||
size="small"
|
||||
checked={columns.some((c) => c.id === opt.id)}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
setColumns((prev) => {
|
||||
const exists = prev.some((c) => c.id === opt.id);
|
||||
if (checked) {
|
||||
if (exists) return prev;
|
||||
return [...prev, { id: opt.id, label: opt.label }];
|
||||
}
|
||||
return prev.filter((c) => c.id !== opt.id);
|
||||
});
|
||||
}}
|
||||
sx={{ p: 0.3, color: '#bbb' }}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ color: '#ddd' }}>{opt.label}</Typography>
|
||||
</MenuItem>
|
||||
))}
|
||||
<Box sx={{ display: 'flex', gap: 1, pt: 0.5 }}>
|
||||
<Button size="small" variant="outlined" onClick={() => setColumns(defaultColumns)} sx={{ textTransform: 'none', borderColor: '#555', color: '#bbb' }}>
|
||||
Reset Default
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Popover>
|
||||
|
||||
{/* Filter popover */}
|
||||
<Popover
|
||||
open={Boolean(filterAnchor)}
|
||||
anchorEl={filterAnchor?.anchorEl || null}
|
||||
onClose={closeFilter}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
PaperProps={{ sx: { bgcolor: '#1e1e1e', p: 1 } }}
|
||||
>
|
||||
{filterAnchor && (
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
<TextField
|
||||
autoFocus
|
||||
size="small"
|
||||
placeholder={`Filter ${columns.find((c) => c.id === filterAnchor.id)?.label || ''}`}
|
||||
value={filters[filterAnchor.id] || ''}
|
||||
onChange={onFilterChange(filterAnchor.id)}
|
||||
onKeyDown={(e) => { if (e.key === 'Escape') closeFilter(); }}
|
||||
sx={{
|
||||
input: { color: '#fff' },
|
||||
minWidth: 220,
|
||||
'& .MuiOutlinedInput-root': { '& fieldset': { borderColor: '#555' }, '&:hover fieldset': { borderColor: '#888' } },
|
||||
}}
|
||||
/>
|
||||
<Button variant="outlined" size="small" onClick={() => { setFilters((prev) => ({ ...prev, [filterAnchor.id]: '' })); closeFilter(); }} sx={{ textTransform: 'none', borderColor: '#555', color: '#bbb' }}>
|
||||
Clear
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Popover>
|
||||
|
||||
{/* Create site dialog */}
|
||||
<CreateSiteDialog
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onCreate={async (name, description) => {
|
||||
try {
|
||||
const res = await fetch('/api/sites', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, description }) });
|
||||
if (!res.ok) return;
|
||||
setCreateOpen(false);
|
||||
await fetchSites();
|
||||
} catch {}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<ConfirmDeleteDialog
|
||||
open={deleteOpen}
|
||||
message={`Delete ${selectedIds.size} selected site(s)? This cannot be undone.`}
|
||||
onCancel={() => setDeleteOpen(false)}
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
const ids = Array.from(selectedIds);
|
||||
await fetch('/api/sites/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids }) });
|
||||
} catch {}
|
||||
setDeleteOpen(false);
|
||||
setSelectedIds(new Set());
|
||||
await fetchSites();
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
@@ -708,6 +708,231 @@ def init_views_db():
|
||||
|
||||
init_views_db()
|
||||
|
||||
# ---------------------------------------------
|
||||
# Sites database (site list + device assignments)
|
||||
# ---------------------------------------------
|
||||
SITES_DB_PATH = os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "Databases", "sites.db")
|
||||
)
|
||||
os.makedirs(os.path.dirname(SITES_DB_PATH), exist_ok=True)
|
||||
|
||||
def init_sites_db():
|
||||
conn = sqlite3.connect(SITES_DB_PATH)
|
||||
cur = conn.cursor()
|
||||
# Sites master table
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS sites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
created_at INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
# Device assignments. A device (hostname) can be assigned to at most one site.
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS device_sites (
|
||||
device_hostname TEXT UNIQUE NOT NULL,
|
||||
site_id INTEGER NOT NULL,
|
||||
assigned_at INTEGER,
|
||||
FOREIGN KEY(site_id) REFERENCES sites(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
init_sites_db()
|
||||
|
||||
# ---------------------------------------------
|
||||
# Sites API
|
||||
# ---------------------------------------------
|
||||
|
||||
def _row_to_site(row):
|
||||
# id, name, description, created_at, device_count
|
||||
return {
|
||||
"id": row[0],
|
||||
"name": row[1],
|
||||
"description": row[2] or "",
|
||||
"created_at": row[3] or 0,
|
||||
"device_count": row[4] or 0,
|
||||
}
|
||||
|
||||
|
||||
@app.route("/api/sites", methods=["GET"])
|
||||
def list_sites():
|
||||
try:
|
||||
conn = sqlite3.connect(SITES_DB_PATH)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT s.id, s.name, s.description, s.created_at,
|
||||
COALESCE(ds.cnt, 0) AS device_count
|
||||
FROM sites s
|
||||
LEFT JOIN (
|
||||
SELECT site_id, COUNT(*) AS cnt
|
||||
FROM device_sites
|
||||
GROUP BY site_id
|
||||
) ds ON ds.site_id = s.id
|
||||
ORDER BY LOWER(s.name) ASC
|
||||
"""
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
conn.close()
|
||||
return jsonify({"sites": [_row_to_site(r) for r in rows]})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/sites", methods=["POST"])
|
||||
def create_site():
|
||||
payload = request.get_json(silent=True) or {}
|
||||
name = (payload.get("name") or "").strip()
|
||||
description = (payload.get("description") or "").strip()
|
||||
if not name:
|
||||
return jsonify({"error": "name is required"}), 400
|
||||
now = int(time.time())
|
||||
try:
|
||||
conn = sqlite3.connect(SITES_DB_PATH)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"INSERT INTO sites(name, description, created_at) VALUES (?, ?, ?)",
|
||||
(name, description, now),
|
||||
)
|
||||
site_id = cur.lastrowid
|
||||
conn.commit()
|
||||
# Return created row with device_count = 0
|
||||
cur.execute(
|
||||
"SELECT id, name, description, created_at, 0 FROM sites WHERE id = ?",
|
||||
(site_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.close()
|
||||
return jsonify(_row_to_site(row))
|
||||
except sqlite3.IntegrityError:
|
||||
return jsonify({"error": "name already exists"}), 409
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/sites/delete", methods=["POST"])
|
||||
def delete_sites():
|
||||
payload = request.get_json(silent=True) or {}
|
||||
ids = payload.get("ids") or []
|
||||
if not isinstance(ids, list) or not all(isinstance(x, (int, str)) for x in ids):
|
||||
return jsonify({"error": "ids must be a list"}), 400
|
||||
# Normalize to ints where possible
|
||||
norm_ids = []
|
||||
for x in ids:
|
||||
try:
|
||||
norm_ids.append(int(x))
|
||||
except Exception:
|
||||
pass
|
||||
if not norm_ids:
|
||||
return jsonify({"status": "ok", "deleted": 0})
|
||||
try:
|
||||
conn = sqlite3.connect(SITES_DB_PATH)
|
||||
cur = conn.cursor()
|
||||
# Clean assignments first (in case FK ON DELETE CASCADE not enforced)
|
||||
cur.execute(
|
||||
f"DELETE FROM device_sites WHERE site_id IN ({','.join('?'*len(norm_ids))})",
|
||||
tuple(norm_ids),
|
||||
)
|
||||
cur.execute(
|
||||
f"DELETE FROM sites WHERE id IN ({','.join('?'*len(norm_ids))})",
|
||||
tuple(norm_ids),
|
||||
)
|
||||
deleted = cur.rowcount
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({"status": "ok", "deleted": deleted})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/sites/device_map", methods=["GET"])
|
||||
def sites_device_map():
|
||||
"""
|
||||
Map hostnames to assigned site.
|
||||
Optional query param: hostnames=comma,separated,list to filter.
|
||||
Returns: { mapping: { hostname: { site_id, site_name } } }
|
||||
"""
|
||||
try:
|
||||
host_param = (request.args.get("hostnames") or "").strip()
|
||||
filter_set = set()
|
||||
if host_param:
|
||||
for part in host_param.split(','):
|
||||
p = part.strip()
|
||||
if p:
|
||||
filter_set.add(p)
|
||||
conn = sqlite3.connect(SITES_DB_PATH)
|
||||
cur = conn.cursor()
|
||||
if filter_set:
|
||||
placeholders = ','.join('?' * len(filter_set))
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT ds.device_hostname, s.id, s.name
|
||||
FROM device_sites ds
|
||||
JOIN sites s ON s.id = ds.site_id
|
||||
WHERE ds.device_hostname IN ({placeholders})
|
||||
""",
|
||||
tuple(filter_set),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT ds.device_hostname, s.id, s.name
|
||||
FROM device_sites ds
|
||||
JOIN sites s ON s.id = ds.site_id
|
||||
"""
|
||||
)
|
||||
mapping = {}
|
||||
for hostname, site_id, site_name in cur.fetchall():
|
||||
mapping[str(hostname)] = {"site_id": site_id, "site_name": site_name}
|
||||
conn.close()
|
||||
return jsonify({"mapping": mapping})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/sites/assign", methods=["POST"])
|
||||
def assign_devices_to_site():
|
||||
payload = request.get_json(silent=True) or {}
|
||||
site_id = payload.get("site_id")
|
||||
hostnames = payload.get("hostnames") or []
|
||||
try:
|
||||
site_id = int(site_id)
|
||||
except Exception:
|
||||
return jsonify({"error": "invalid site_id"}), 400
|
||||
if not isinstance(hostnames, list) or not all(isinstance(x, str) and x.strip() for x in hostnames):
|
||||
return jsonify({"error": "hostnames must be a list of strings"}), 400
|
||||
now = int(time.time())
|
||||
try:
|
||||
conn = sqlite3.connect(SITES_DB_PATH)
|
||||
cur = conn.cursor()
|
||||
# Ensure site exists
|
||||
cur.execute("SELECT 1 FROM sites WHERE id = ?", (site_id,))
|
||||
if not cur.fetchone():
|
||||
conn.close()
|
||||
return jsonify({"error": "site not found"}), 404
|
||||
# Assign each hostname (replace existing assignment if present)
|
||||
for hn in hostnames:
|
||||
hn = hn.strip()
|
||||
if not hn:
|
||||
continue
|
||||
cur.execute(
|
||||
"INSERT INTO device_sites(device_hostname, site_id, assigned_at) VALUES (?, ?, ?)\n"
|
||||
"ON CONFLICT(device_hostname) DO UPDATE SET site_id=excluded.site_id, assigned_at=excluded.assigned_at",
|
||||
(hn, site_id, now),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({"status": "ok"})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
# ---------------------------------------------
|
||||
# Device List Views API
|
||||
|
Reference in New Issue
Block a user