From 06ef351214ebec5457887c8a4575eca27b2b4f5f Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Sun, 7 Sep 2025 23:13:17 -0600 Subject: [PATCH] Added Support for Assigning Devices to Sites. --- Data/Server/WebUI/src/App.jsx | 12 + Data/Server/WebUI/src/Devices/Device_List.jsx | 128 ++++++- Data/Server/WebUI/src/Dialogs.jsx | 76 +++++ Data/Server/WebUI/src/Navigation_Sidebar.jsx | 3 + Data/Server/WebUI/src/Sites/Site_List.jsx | 323 ++++++++++++++++++ Data/Server/server.py | 225 ++++++++++++ 6 files changed, 766 insertions(+), 1 deletion(-) create mode 100644 Data/Server/WebUI/src/Sites/Site_List.jsx diff --git a/Data/Server/WebUI/src/App.jsx b/Data/Server/WebUI/src/App.jsx index 6686702..ed2ce6a 100644 --- a/Data/Server/WebUI/src/App.jsx +++ b/Data/Server/WebUI/src/App.jsx @@ -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 ( + { + try { + localStorage.setItem('device_list_initial_site_filter', String(siteName || '')); + } catch {} + setCurrentPage("devices"); + }} + /> + ); case "devices": return ( ({ 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 }) { ); + case "site": + return {r.site || "Not Configured"}; case "hostname": return ( {[ + { 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" } }} > - Delete + { + 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 + { + 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 + Delete selectedIds.has(r.id)).map((r) => r.hostname)} /> )} + {assignDialogOpen && ( + 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 } }} + > + + Assign {assignTargets.length} device(s) to a site + setAssignSiteId(Number(e.target.value))} + sx={{ '& .MuiOutlinedInput-root': { '& fieldset': { borderColor: '#444' }, '&:hover fieldset': { borderColor: '#666' } }, label: { color: '#aaa' } }} + > + {sites.map((s) => ( + {s.name} + ))} + + + + + + + + )} ); } diff --git a/Data/Server/WebUI/src/Dialogs.jsx b/Data/Server/WebUI/src/Dialogs.jsx index c813b35..1aab8e0 100644 --- a/Data/Server/WebUI/src/Dialogs.jsx +++ b/Data/Server/WebUI/src/Dialogs.jsx @@ -1,5 +1,6 @@ ////////// PROJECT FILE SEPARATION LINE ////////// CODE AFTER THIS LINE ARE FROM: /Data/WebUI/src/Dialogs.jsx +import React from "react"; import { Dialog, DialogTitle, @@ -366,3 +367,78 @@ export function RenameCustomViewDialog({ open, value, onChange, onCancel, onSave ); } + +export function CreateSiteDialog({ open, onCancel, onCreate }) { + const [name, setName] = React.useState(""); + const [description, setDescription] = React.useState(""); + + React.useEffect(() => { + if (open) { + setName(""); + setDescription(""); + } + }, [open]); + + return ( + + Create Site + + + Create a new site and optionally add a description. + + setName(e.target.value)} + sx={{ + "& .MuiOutlinedInput-root": { + backgroundColor: "#2a2a2a", + color: "#ccc", + "& fieldset": { borderColor: "#444" }, + "&:hover fieldset": { borderColor: "#666" } + }, + label: { color: "#aaa" }, + mt: 1 + }} + /> + setDescription(e.target.value)} + sx={{ + "& .MuiOutlinedInput-root": { + backgroundColor: "#2a2a2a", + color: "#ccc", + "& fieldset": { borderColor: "#444" }, + "&:hover fieldset": { borderColor: "#666" } + }, + label: { color: "#aaa" }, + mt: 2 + }} + /> + + + + + + + ); +} diff --git a/Data/Server/WebUI/src/Navigation_Sidebar.jsx b/Data/Server/WebUI/src/Navigation_Sidebar.jsx index d6e331a..4d4700d 100644 --- a/Data/Server/WebUI/src/Navigation_Sidebar.jsx +++ b/Data/Server/WebUI/src/Navigation_Sidebar.jsx @@ -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 }) { }} > + {/* Top-level Sites */} + } label="Sites" pageKey="sites" /> {/* Devices */} 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 ( + + + Sites + + + + + + + + + + + + + {columns.map((col) => ( + + + handleSort(col.id)}> + {col.label} + + + + + + + ))} + + + + {sorted.map((r) => ( + + e.stopPropagation()}> + + + {columns.map((col) => { + switch (col.id) { + case 'name': + return ( + { + if (onOpenDevicesForSite) onOpenDevicesForSite(r.name); + }} + sx={{ color: '#58a6ff', '&:hover': { cursor: 'pointer', textDecoration: 'underline' } }} + > + {r.name} + + ); + case 'description': + return {r.description || ''}; + case 'device_count': + return {r.device_count ?? 0}; + default: + return ; + } + })} + + ))} + {sorted.length === 0 && ( + + No sites defined. + + )} + +
+ + {/* Column chooser */} + setColChooserAnchor(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + PaperProps={{ sx: { bgcolor: '#1e1e1e', color: '#fff', p: 1 } }} + > + + {[ + { id: 'name', label: 'Name' }, + { id: 'description', label: 'Description' }, + { id: 'device_count', label: 'Devices' }, + ].map((opt) => ( + e.stopPropagation()} sx={{ gap: 1 }}> + 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' }} + /> + {opt.label} + + ))} + + + + + + + {/* Filter popover */} + + {filterAnchor && ( + + 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' } }, + }} + /> + + + )} + + + {/* Create site dialog */} + 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 */} + 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(); + }} + /> +
+ ); +} + diff --git a/Data/Server/server.py b/Data/Server/server.py index cd3d49d..1b87287 100644 --- a/Data/Server/server.py +++ b/Data/Server/server.py @@ -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