Added Persistent Device View Customization Menu

This commit is contained in:
2025-09-05 20:41:16 -06:00
parent 7950a8117b
commit d85494e5c7
3 changed files with 488 additions and 17 deletions

View File

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

View File

@@ -296,3 +296,73 @@ export function TabContextMenu({ anchor, onClose, onRename, onCloseTab }) {
</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>
);
}

View File

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