mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-09-11 04:58:41 -06:00
Added Persistent Device View Customization Menu
This commit is contained in:
@@ -23,7 +23,8 @@ import {
|
|||||||
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 ViewColumnIcon from "@mui/icons-material/ViewColumn";
|
import ViewColumnIcon from "@mui/icons-material/ViewColumn";
|
||||||
import { DeleteDeviceDialog } from "../Dialogs.jsx";
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
|
import { DeleteDeviceDialog, CreateCustomViewDialog, RenameCustomViewDialog } from "../Dialogs.jsx";
|
||||||
import QuickJob from "../Scheduling/Quick_Job.jsx";
|
import QuickJob from "../Scheduling/Quick_Job.jsx";
|
||||||
|
|
||||||
function formatLastSeen(tsSec, offlineAfter = 120) {
|
function formatLastSeen(tsSec, offlineAfter = 120) {
|
||||||
@@ -60,6 +61,17 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
const [selectedIds, setSelectedIds] = useState(() => new Set());
|
const [selectedIds, setSelectedIds] = useState(() => new Set());
|
||||||
const [quickJobOpen, setQuickJobOpen] = useState(false);
|
const [quickJobOpen, setQuickJobOpen] = useState(false);
|
||||||
|
|
||||||
|
// Saved custom views (from server)
|
||||||
|
const [views, setViews] = useState([]); // [{id, name, columns:[id], filters:{}}]
|
||||||
|
const [selectedViewId, setSelectedViewId] = useState("default");
|
||||||
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
|
const [newViewName, setNewViewName] = useState("");
|
||||||
|
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
|
||||||
|
const [renameViewName, setRenameViewName] = useState("");
|
||||||
|
const [renameTarget, setRenameTarget] = useState(null); // {id, name}
|
||||||
|
const [viewActionAnchor, setViewActionAnchor] = useState(null); // anchor for per-item actions
|
||||||
|
const [viewActionTarget, setViewActionTarget] = useState(null); // view object for actions
|
||||||
|
|
||||||
// Column configuration and rearranging state
|
// Column configuration and rearranging state
|
||||||
const COL_LABELS = useMemo(
|
const COL_LABELS = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -201,12 +213,48 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
}
|
}
|
||||||
}, [detailsByHost]);
|
}, [detailsByHost]);
|
||||||
|
|
||||||
|
const fetchViews = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/device_list_views");
|
||||||
|
const data = await res.json();
|
||||||
|
if (data && Array.isArray(data.views)) setViews(data.views);
|
||||||
|
else setViews([]);
|
||||||
|
} catch {
|
||||||
|
setViews([]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAgents();
|
fetchAgents();
|
||||||
const t = setInterval(fetchAgents, 5000);
|
const t = setInterval(fetchAgents, 5000);
|
||||||
return () => clearInterval(t);
|
return () => clearInterval(t);
|
||||||
}, [fetchAgents]);
|
}, [fetchAgents]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchViews();
|
||||||
|
}, [fetchViews]);
|
||||||
|
|
||||||
|
const applyView = useCallback((view) => {
|
||||||
|
if (!view || view.id === "default") {
|
||||||
|
setColumns(defaultColumns);
|
||||||
|
setFilters({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const ids = Array.isArray(view.columns) ? view.columns : [];
|
||||||
|
// Ensure status is present and first
|
||||||
|
const finalIds = ["status", ...ids.filter((x) => x !== "status")];
|
||||||
|
const mapped = finalIds
|
||||||
|
.filter((id) => COL_LABELS[id])
|
||||||
|
.map((id) => ({ id, label: COL_LABELS[id] }));
|
||||||
|
setColumns(mapped.length ? mapped : defaultColumns);
|
||||||
|
setFilters(view.filters && typeof view.filters === "object" ? view.filters : {});
|
||||||
|
} catch {
|
||||||
|
setColumns(defaultColumns);
|
||||||
|
setFilters({});
|
||||||
|
}
|
||||||
|
}, [COL_LABELS, defaultColumns]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
// Apply simple contains filter per column based on displayed string
|
// Apply simple contains filter per column based on displayed string
|
||||||
const activeFilters = Object.entries(filters).filter(([, v]) => (v || "").trim() !== "");
|
const activeFilters = Object.entries(filters).filter(([, v]) => (v || "").trim() !== "");
|
||||||
@@ -357,11 +405,92 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}>
|
<Paper sx={{ m: 2, p: 0, bgcolor: "#1e1e1e" }} elevation={2}>
|
||||||
<Box sx={{ p: 2, pb: 1, display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
{/* Header area with title on left and controls on right */}
|
||||||
|
<Box sx={{ p: 2, pb: 1, display: "flex", flexDirection: 'column', gap: 1 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>
|
<Typography variant="h6" sx={{ color: "#58a6ff", mb: 0 }}>
|
||||||
Devices
|
Devices
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
{/* Views dropdown + add button */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', mr: 1 }}>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
value={selectedViewId}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setSelectedViewId(val);
|
||||||
|
if (val === "default") applyView({ id: "default" });
|
||||||
|
else {
|
||||||
|
const v = views.find((x) => String(x.id) === String(val));
|
||||||
|
if (v) applyView(v);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
minWidth: 220,
|
||||||
|
mr: 0,
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
height: 32,
|
||||||
|
pr: 0,
|
||||||
|
borderTopRightRadius: 0,
|
||||||
|
borderBottomRightRadius: 0,
|
||||||
|
'& fieldset': { borderColor: '#555', borderRight: '1px solid #555' },
|
||||||
|
'&:hover fieldset': { borderColor: '#888' },
|
||||||
|
},
|
||||||
|
'& .MuiSelect-select': {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
py: 0,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
SelectProps={{
|
||||||
|
MenuProps: {
|
||||||
|
PaperProps: { sx: { bgcolor: '#1e1e1e', color: '#fff' } },
|
||||||
|
},
|
||||||
|
renderValue: (val) => {
|
||||||
|
if (val === "default") return "Default View";
|
||||||
|
const v = views.find((x) => String(x.id) === String(val));
|
||||||
|
return v ? v.name : "Default View";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuItem value="default">Default View</MenuItem>
|
||||||
|
{views.map((v) => (
|
||||||
|
<MenuItem key={v.id} value={v.id} disableRipple>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
|
||||||
|
<span>{v.name}</span>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setViewActionAnchor(e.currentTarget);
|
||||||
|
setViewActionTarget(v);
|
||||||
|
}}
|
||||||
|
sx={{ color: '#ccc' }}
|
||||||
|
>
|
||||||
|
<MoreVertIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => { setNewViewName(""); setCreateDialogOpen(true); }}
|
||||||
|
sx={{
|
||||||
|
ml: '-1px',
|
||||||
|
border: '1px solid #555',
|
||||||
|
borderLeft: '1px solid #555',
|
||||||
|
borderRadius: '0 4px 4px 0',
|
||||||
|
color: '#bbb',
|
||||||
|
height: 32,
|
||||||
|
width: 32,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AddIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
<Tooltip title="Column Chooser">
|
<Tooltip title="Column Chooser">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
@@ -371,13 +500,16 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
<ViewColumnIcon fontSize="small" />
|
<ViewColumnIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{/* Second row: Quick Job button aligned under header title */}
|
||||||
|
<Box sx={{ display: 'flex' }}>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="small"
|
size="small"
|
||||||
disabled={selectedIds.size === 0}
|
disabled={selectedIds.size === 0}
|
||||||
onClick={() => setQuickJobOpen(true)}
|
onClick={() => setQuickJobOpen(true)}
|
||||||
sx={{
|
sx={{
|
||||||
mr: 1,
|
|
||||||
color: selectedIds.size === 0 ? "#666" : "#58a6ff",
|
color: selectedIds.size === 0 ? "#666" : "#58a6ff",
|
||||||
borderColor: selectedIds.size === 0 ? "#333" : "#58a6ff",
|
borderColor: selectedIds.size === 0 ? "#333" : "#58a6ff",
|
||||||
textTransform: "none"
|
textTransform: "none"
|
||||||
@@ -524,6 +656,92 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
{/* View actions menu (rename/delete for custom views) */}
|
||||||
|
<Menu
|
||||||
|
anchorEl={viewActionAnchor}
|
||||||
|
open={Boolean(viewActionAnchor)}
|
||||||
|
onClose={() => { setViewActionAnchor(null); setViewActionTarget(null); }}
|
||||||
|
PaperProps={{ sx: { bgcolor: '#1e1e1e', color: '#fff', fontSize: '13px' } }}
|
||||||
|
>
|
||||||
|
<MenuItem onClick={() => {
|
||||||
|
const v = viewActionTarget;
|
||||||
|
setViewActionAnchor(null);
|
||||||
|
if (!v) return;
|
||||||
|
setRenameTarget(v);
|
||||||
|
setRenameViewName(v.name || "");
|
||||||
|
setRenameDialogOpen(true);
|
||||||
|
}}>Rename</MenuItem>
|
||||||
|
<MenuItem sx={{ color: '#ff4f4f' }} onClick={async () => {
|
||||||
|
const v = viewActionTarget;
|
||||||
|
setViewActionAnchor(null);
|
||||||
|
if (!v) return;
|
||||||
|
try {
|
||||||
|
await fetch(`/api/device_list_views/${encodeURIComponent(v.id)}`, { method: 'DELETE' });
|
||||||
|
} catch {}
|
||||||
|
setViews((prev) => prev.filter((x) => String(x.id) !== String(v.id)));
|
||||||
|
if (String(selectedViewId) === String(v.id)) {
|
||||||
|
setSelectedViewId('default');
|
||||||
|
applyView({ id: 'default' });
|
||||||
|
}
|
||||||
|
}}>Delete</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
{/* Create new custom view dialog */}
|
||||||
|
<CreateCustomViewDialog
|
||||||
|
open={createDialogOpen}
|
||||||
|
value={newViewName}
|
||||||
|
onChange={setNewViewName}
|
||||||
|
onCancel={() => setCreateDialogOpen(false)}
|
||||||
|
onSave={async () => {
|
||||||
|
const name = (newViewName || '').trim();
|
||||||
|
if (!name) return;
|
||||||
|
// Build current config
|
||||||
|
const cols = (columns || []).map((c) => c.id);
|
||||||
|
const cfg = { name, columns: cols, filters };
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/device_list_views', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(cfg)
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const created = await res.json();
|
||||||
|
setViews((prev) => [...prev, created].sort((a, b) => String(a.name).localeCompare(String(b.name))));
|
||||||
|
setSelectedViewId(String(created.id));
|
||||||
|
// Already applied in UI; we keep current state
|
||||||
|
setCreateDialogOpen(false);
|
||||||
|
setNewViewName('');
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Rename custom view dialog */}
|
||||||
|
<RenameCustomViewDialog
|
||||||
|
open={renameDialogOpen}
|
||||||
|
value={renameViewName}
|
||||||
|
onChange={setRenameViewName}
|
||||||
|
onCancel={() => setRenameDialogOpen(false)}
|
||||||
|
onSave={async () => {
|
||||||
|
const v = renameTarget;
|
||||||
|
const newName = (renameViewName || '').trim();
|
||||||
|
if (!v || !newName) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/device_list_views/${encodeURIComponent(v.id)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: newName })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const updated = await res.json();
|
||||||
|
setViews((prev) => prev.map((x) => String(x.id) === String(v.id) ? updated : x));
|
||||||
|
setRenameDialogOpen(false);
|
||||||
|
setRenameViewName('');
|
||||||
|
setRenameTarget(null);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{/* Column chooser popover */}
|
{/* Column chooser popover */}
|
||||||
<Popover
|
<Popover
|
||||||
open={Boolean(colChooserAnchor)}
|
open={Boolean(colChooserAnchor)}
|
||||||
|
@@ -296,3 +296,73 @@ export function TabContextMenu({ anchor, onClose, onRename, onCloseTab }) {
|
|||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function CreateCustomViewDialog({ open, value, onChange, onCancel, onSave }) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
|
||||||
|
<DialogTitle>Create a New Custom View</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText sx={{ color: "#ccc", mb: 1 }}>
|
||||||
|
Saving a view will save column order, visibility, and filters.
|
||||||
|
</DialogContentText>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
fullWidth
|
||||||
|
margin="dense"
|
||||||
|
label="View Name"
|
||||||
|
variant="outlined"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder="Add a name for this custom view"
|
||||||
|
sx={{
|
||||||
|
"& .MuiOutlinedInput-root": {
|
||||||
|
backgroundColor: "#2a2a2a",
|
||||||
|
color: "#ccc",
|
||||||
|
"& fieldset": { borderColor: "#444" },
|
||||||
|
"&:hover fieldset": { borderColor: "#666" }
|
||||||
|
},
|
||||||
|
label: { color: "#aaa" },
|
||||||
|
mt: 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
|
||||||
|
<Button onClick={onSave} sx={{ color: "#58a6ff" }}>Save</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RenameCustomViewDialog({ open, value, onChange, onCancel, onSave }) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onCancel} PaperProps={{ sx: { bgcolor: "#121212", color: "#fff" } }}>
|
||||||
|
<DialogTitle>Rename Custom View</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
fullWidth
|
||||||
|
margin="dense"
|
||||||
|
label="View Name"
|
||||||
|
variant="outlined"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
sx={{
|
||||||
|
"& .MuiOutlinedInput-root": {
|
||||||
|
backgroundColor: "#2a2a2a",
|
||||||
|
color: "#ccc",
|
||||||
|
"& fieldset": { borderColor: "#444" },
|
||||||
|
"&:hover fieldset": { borderColor: "#666" }
|
||||||
|
},
|
||||||
|
label: { color: "#aaa" },
|
||||||
|
mt: 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onCancel} sx={{ color: "#58a6ff" }}>Cancel</Button>
|
||||||
|
<Button onClick={onSave} sx={{ color: "#58a6ff" }}>Save</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@@ -681,6 +681,189 @@ def init_db():
|
|||||||
|
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
|
# Views database (device list saved views)
|
||||||
|
VIEWS_DB_PATH = os.path.abspath(
|
||||||
|
os.path.join(os.path.dirname(__file__), "..", "..", "Databases", "devices_list_views.db")
|
||||||
|
)
|
||||||
|
os.makedirs(os.path.dirname(VIEWS_DB_PATH), exist_ok=True)
|
||||||
|
|
||||||
|
def init_views_db():
|
||||||
|
conn = sqlite3.connect(VIEWS_DB_PATH)
|
||||||
|
cur = conn.cursor()
|
||||||
|
# Store name, ordered column ids as JSON, and filters as JSON
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS device_list_views (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
columns_json TEXT NOT NULL,
|
||||||
|
filters_json TEXT,
|
||||||
|
created_at INTEGER,
|
||||||
|
updated_at INTEGER
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
init_views_db()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------
|
||||||
|
# Device List Views API
|
||||||
|
# ---------------------------------------------
|
||||||
|
def _row_to_view(row):
|
||||||
|
return {
|
||||||
|
"id": row[0],
|
||||||
|
"name": row[1],
|
||||||
|
"columns": json.loads(row[2] or "[]"),
|
||||||
|
"filters": json.loads(row[3] or "{}"),
|
||||||
|
"created_at": row[4],
|
||||||
|
"updated_at": row[5],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/device_list_views", methods=["GET"])
|
||||||
|
def list_device_list_views():
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(VIEWS_DB_PATH)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, name, columns_json, filters_json, created_at, updated_at FROM device_list_views ORDER BY name COLLATE NOCASE ASC"
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"views": [_row_to_view(r) for r in rows]})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/device_list_views/<int:view_id>", methods=["GET"])
|
||||||
|
def get_device_list_view(view_id: int):
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(VIEWS_DB_PATH)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, name, columns_json, filters_json, created_at, updated_at FROM device_list_views WHERE id = ?",
|
||||||
|
(view_id,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
return jsonify(_row_to_view(row))
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/device_list_views", methods=["POST"])
|
||||||
|
def create_device_list_view():
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
name = (payload.get("name") or "").strip()
|
||||||
|
columns = payload.get("columns") or []
|
||||||
|
filters = payload.get("filters") or {}
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
return jsonify({"error": "name is required"}), 400
|
||||||
|
if name.lower() == "default view":
|
||||||
|
return jsonify({"error": "reserved name"}), 400
|
||||||
|
if not isinstance(columns, list) or not all(isinstance(x, str) for x in columns):
|
||||||
|
return jsonify({"error": "columns must be a list of strings"}), 400
|
||||||
|
if not isinstance(filters, dict):
|
||||||
|
return jsonify({"error": "filters must be an object"}), 400
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(VIEWS_DB_PATH)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO device_list_views(name, columns_json, filters_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(name, json.dumps(columns), json.dumps(filters), now, now),
|
||||||
|
)
|
||||||
|
vid = cur.lastrowid
|
||||||
|
conn.commit()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, name, columns_json, filters_json, created_at, updated_at FROM device_list_views WHERE id = ?",
|
||||||
|
(vid,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
return jsonify(_row_to_view(row)), 201
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return jsonify({"error": "name already exists"}), 409
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/device_list_views/<int:view_id>", methods=["PUT"])
|
||||||
|
def update_device_list_view(view_id: int):
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
name = payload.get("name")
|
||||||
|
columns = payload.get("columns")
|
||||||
|
filters = payload.get("filters")
|
||||||
|
if name is not None:
|
||||||
|
name = (name or "").strip()
|
||||||
|
if not name:
|
||||||
|
return jsonify({"error": "name cannot be empty"}), 400
|
||||||
|
if name.lower() == "default view":
|
||||||
|
return jsonify({"error": "reserved name"}), 400
|
||||||
|
if columns is not None:
|
||||||
|
if not isinstance(columns, list) or not all(isinstance(x, str) for x in columns):
|
||||||
|
return jsonify({"error": "columns must be a list of strings"}), 400
|
||||||
|
if filters is not None and not isinstance(filters, dict):
|
||||||
|
return jsonify({"error": "filters must be an object"}), 400
|
||||||
|
|
||||||
|
fields = []
|
||||||
|
params = []
|
||||||
|
if name is not None:
|
||||||
|
fields.append("name = ?")
|
||||||
|
params.append(name)
|
||||||
|
if columns is not None:
|
||||||
|
fields.append("columns_json = ?")
|
||||||
|
params.append(json.dumps(columns))
|
||||||
|
if filters is not None:
|
||||||
|
fields.append("filters_json = ?")
|
||||||
|
params.append(json.dumps(filters))
|
||||||
|
fields.append("updated_at = ?")
|
||||||
|
params.append(int(time.time()))
|
||||||
|
params.append(view_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(VIEWS_DB_PATH)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(f"UPDATE device_list_views SET {', '.join(fields)} WHERE id = ?", params)
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
conn.commit()
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, name, columns_json, filters_json, created_at, updated_at FROM device_list_views WHERE id = ?",
|
||||||
|
(view_id,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
conn.close()
|
||||||
|
return jsonify(_row_to_view(row))
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
return jsonify({"error": "name already exists"}), 409
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/device_list_views/<int:view_id>", methods=["DELETE"])
|
||||||
|
def delete_device_list_view(view_id: int):
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(VIEWS_DB_PATH)
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("DELETE FROM device_list_views WHERE id = ?", (view_id,))
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"status": "ok"})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
def _persist_last_seen(hostname: str, last_seen: int):
|
def _persist_last_seen(hostname: str, last_seen: int):
|
||||||
"""Persist the last_seen timestamp into the device_details.details JSON.
|
"""Persist the last_seen timestamp into the device_details.details JSON.
|
||||||
|
Reference in New Issue
Block a user