diff --git a/Data/Server/WebUI/src/Devices/Device_List.jsx b/Data/Server/WebUI/src/Devices/Device_List.jsx index 93c80f3..63e80dc 100644 --- a/Data/Server/WebUI/src/Devices/Device_List.jsx +++ b/Data/Server/WebUI/src/Devices/Device_List.jsx @@ -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 ( - - - Devices - - - - setColChooserAnchor(e.currentTarget)} - sx={{ color: "#bbb", mr: 1 }} - > - - - + {/* Header area with title on left and controls on right */} + + + + Devices + + + {/* Views dropdown + add button */} + + { + 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"; + } + }} + > + Default View + {views.map((v) => ( + + + {v.name} + { + e.stopPropagation(); + setViewActionAnchor(e.currentTarget); + setViewActionTarget(v); + }} + sx={{ color: '#ccc' }} + > + + + + + ))} + + { 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, + }} + > + + + + + setColChooserAnchor(e.currentTarget)} + sx={{ color: "#bbb", mr: 1 }} + > + + + + + + {/* Second row: Quick Job button aligned under header title */} + + + + + ); +} + +export function RenameCustomViewDialog({ open, value, onChange, onCancel, onSave }) { + return ( + + Rename Custom View + + onChange(e.target.value)} + sx={{ + "& .MuiOutlinedInput-root": { + backgroundColor: "#2a2a2a", + color: "#ccc", + "& fieldset": { borderColor: "#444" }, + "&:hover fieldset": { borderColor: "#666" } + }, + label: { color: "#aaa" }, + mt: 1 + }} + /> + + + + + + + ); +} diff --git a/Data/Server/server.py b/Data/Server/server.py index 375101d..931f922 100644 --- a/Data/Server/server.py +++ b/Data/Server/server.py @@ -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/", 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/", 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/", 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.