mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-09-11 03:08:42 -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 FilterListIcon from "@mui/icons-material/FilterList";
|
||||
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";
|
||||
|
||||
function formatLastSeen(tsSec, offlineAfter = 120) {
|
||||
@@ -60,6 +61,17 @@ export default function DeviceList({ onSelectDevice }) {
|
||||
const [selectedIds, setSelectedIds] = useState(() => new Set());
|
||||
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
|
||||
const COL_LABELS = useMemo(
|
||||
() => ({
|
||||
@@ -201,12 +213,48 @@ export default function DeviceList({ onSelectDevice }) {
|
||||
}
|
||||
}, [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(() => {
|
||||
fetchAgents();
|
||||
const t = setInterval(fetchAgents, 5000);
|
||||
return () => clearInterval(t);
|
||||
}, [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(() => {
|
||||
// Apply simple contains filter per column based on displayed string
|
||||
const activeFilters = Object.entries(filters).filter(([, v]) => (v || "").trim() !== "");
|
||||
@@ -357,27 +405,111 @@ export default function DeviceList({ onSelectDevice }) {
|
||||
|
||||
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 }}>
|
||||
Devices
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Tooltip title="Column Chooser">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => setColChooserAnchor(e.currentTarget)}
|
||||
sx={{ color: "#bbb", mr: 1 }}
|
||||
>
|
||||
<ViewColumnIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{/* 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 }}>
|
||||
Devices
|
||||
</Typography>
|
||||
<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">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => setColChooserAnchor(e.currentTarget)}
|
||||
sx={{ color: "#bbb", mr: 1 }}
|
||||
>
|
||||
<ViewColumnIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
{/* Second row: Quick Job button aligned under header title */}
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
disabled={selectedIds.size === 0}
|
||||
onClick={() => setQuickJobOpen(true)}
|
||||
sx={{
|
||||
mr: 1,
|
||||
color: selectedIds.size === 0 ? "#666" : "#58a6ff",
|
||||
borderColor: selectedIds.size === 0 ? "#333" : "#58a6ff",
|
||||
textTransform: "none"
|
||||
@@ -524,6 +656,92 @@ export default function DeviceList({ onSelectDevice }) {
|
||||
)}
|
||||
</TableBody>
|
||||
</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 */}
|
||||
<Popover
|
||||
open={Boolean(colChooserAnchor)}
|
||||
|
@@ -295,4 +295,74 @@ export function TabContextMenu({ anchor, onClose, onRename, onCloseTab }) {
|
||||
<MenuItem onClick={onCloseTab}>Close Workflow</MenuItem>
|
||||
</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()
|
||||
|
||||
# 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):
|
||||
"""Persist the last_seen timestamp into the device_details.details JSON.
|
||||
|
Reference in New Issue
Block a user