Added Support for Assigning Devices to Sites.

This commit is contained in:
2025-09-07 23:13:17 -06:00
parent f7d285a9e8
commit 06ef351214
6 changed files with 766 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View 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>
);
}

View File

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