import React, { useCallback, useEffect, useMemo, useState } from "react"; import { Paper, Box, Typography, Button, IconButton, Table, TableHead, TableBody, TableRow, TableCell, TableSortLabel, Dialog, DialogTitle, DialogContent, DialogActions, TextField, CircularProgress } from "@mui/material"; import AddIcon from "@mui/icons-material/Add"; import EditIcon from "@mui/icons-material/Edit"; import DeleteIcon from "@mui/icons-material/Delete"; import RefreshIcon from "@mui/icons-material/Refresh"; import { ConfirmDeleteDialog } from "../Dialogs.jsx"; import AddDevice from "./Add_Device.jsx"; const tableStyles = { "& th, & td": { color: "#ddd", borderColor: "#2a2a2a", fontSize: 13, py: 0.75 }, "& th": { fontWeight: 600 }, "& th .MuiTableSortLabel-root": { color: "#ddd" }, "& th .MuiTableSortLabel-root.Mui-active": { color: "#ddd" } }; const defaultForm = { hostname: "", address: "", description: "", operating_system: "" }; export default function SSHDevices({ type = "ssh" }) { const typeLabel = type === "winrm" ? "WinRM" : "SSH"; const apiBase = type === "winrm" ? "/api/winrm_devices" : "/api/ssh_devices"; const pageTitle = `${typeLabel} Devices`; const addButtonLabel = `Add ${typeLabel} Device`; const addressLabel = `${typeLabel} Address`; const loadingLabel = `Loading ${typeLabel} devices…`; const emptyLabel = `No ${typeLabel} devices have been added yet.`; const descriptionText = type === "winrm" ? "Manage remote endpoints reachable via WinRM for playbook execution." : "Manage remote endpoints reachable via SSH for playbook execution."; const editDialogTitle = `Edit ${typeLabel} Device`; const newDialogTitle = `New ${typeLabel} Device`; const [rows, setRows] = useState([]); const [orderBy, setOrderBy] = useState("hostname"); const [order, setOrder] = useState("asc"); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [dialogOpen, setDialogOpen] = useState(false); const [form, setForm] = useState(defaultForm); const [formError, setFormError] = useState(""); const [submitting, setSubmitting] = useState(false); const [editTarget, setEditTarget] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [deleteBusy, setDeleteBusy] = useState(false); const [addDeviceOpen, setAddDeviceOpen] = useState(false); const isEdit = Boolean(editTarget); const loadDevices = useCallback(async () => { setLoading(true); setError(""); try { const resp = await fetch(apiBase); if (!resp.ok) { const data = await resp.json().catch(() => ({})); throw new Error(data?.error || `HTTP ${resp.status}`); } const data = await resp.json(); const list = Array.isArray(data?.devices) ? data.devices : []; setRows(list); } catch (err) { setError(String(err.message || err)); setRows([]); } finally { setLoading(false); } }, [apiBase]); useEffect(() => { loadDevices(); }, [loadDevices]); const sortedRows = useMemo(() => { const list = [...rows]; list.sort((a, b) => { const getKey = (row) => { switch (orderBy) { case "created_at": return Number(row.created_at || 0); case "address": return (row.connection_endpoint || "").toLowerCase(); case "description": return (row.description || "").toLowerCase(); default: return (row.hostname || "").toLowerCase(); } }; const aKey = getKey(a); const bKey = getKey(b); if (aKey < bKey) return order === "asc" ? -1 : 1; if (aKey > bKey) return order === "asc" ? 1 : -1; return 0; }); return list; }, [rows, order, orderBy]); const handleSort = (column) => () => { if (orderBy === column) { setOrder((prev) => (prev === "asc" ? "desc" : "asc")); } else { setOrderBy(column); setOrder("asc"); } }; const openCreate = () => { setAddDeviceOpen(true); setFormError(""); }; const openEdit = (row) => { setEditTarget(row); setForm({ hostname: row.hostname || "", address: row.connection_endpoint || "", description: row.description || "", operating_system: row.summary?.operating_system || "" }); setDialogOpen(true); setFormError(""); }; const handleDialogClose = () => { if (submitting) return; setDialogOpen(false); setForm(defaultForm); setEditTarget(null); setFormError(""); }; const handleSubmit = async () => { if (submitting) return; const payload = { hostname: form.hostname.trim(), address: form.address.trim(), description: form.description.trim(), operating_system: form.operating_system.trim() }; if (!payload.hostname) { setFormError("Hostname is required."); return; } if (!payload.address) { setFormError("Address is required."); return; } setSubmitting(true); setFormError(""); try { const endpoint = isEdit ? `${apiBase}/${encodeURIComponent(editTarget.hostname)}` : apiBase; const resp = await fetch(endpoint, { method: isEdit ? "PUT" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); const data = await resp.json().catch(() => ({})); if (!resp.ok) { throw new Error(data?.error || `HTTP ${resp.status}`); } setDialogOpen(false); setForm(defaultForm); setEditTarget(null); setFormError(""); setRows((prev) => { const next = [...prev]; if (data?.device) { const idx = next.findIndex((row) => row.hostname === data.device.hostname); if (idx >= 0) next[idx] = data.device; else next.push(data.device); return next; } return prev; }); // Ensure latest ordering by triggering refresh loadDevices(); } catch (err) { setFormError(String(err.message || err)); } finally { setSubmitting(false); } }; const handleDelete = async () => { if (!deleteTarget) return; setDeleteBusy(true); try { const resp = await fetch(`${apiBase}/${encodeURIComponent(deleteTarget.hostname)}`, { method: "DELETE" }); const data = await resp.json().catch(() => ({})); if (!resp.ok) throw new Error(data?.error || `HTTP ${resp.status}`); setRows((prev) => prev.filter((row) => row.hostname !== deleteTarget.hostname)); setDeleteTarget(null); } catch (err) { setError(String(err.message || err)); } finally { setDeleteBusy(false); } }; return ( {pageTitle} {descriptionText} {error && ( {error} )} {loading && ( {loadingLabel} )} Hostname {addressLabel} Description Added Actions {sortedRows.map((row) => { const createdTs = Number(row.created_at || 0) * 1000; const createdDisplay = createdTs ? new Date(createdTs).toLocaleString() : (row.summary?.created || ""); return ( {row.hostname} {row.connection_endpoint || ""} {row.description || ""} {createdDisplay} openEdit(row)}> setDeleteTarget(row)}> ); })} {!sortedRows.length && !loading && ( {emptyLabel} )}
{isEdit ? editDialogTitle : newDialogTitle} setForm((prev) => ({ ...prev, hostname: e.target.value }))} fullWidth size="small" sx={{ "& .MuiOutlinedInput-root": { backgroundColor: "#1f1f1f", color: "#fff", "& fieldset": { borderColor: "#555" }, "&:hover fieldset": { borderColor: "#888" } }, "& .MuiInputLabel-root": { color: "#aaa" } }} helperText="Hostname used within Borealis (unique)." /> setForm((prev) => ({ ...prev, address: e.target.value }))} fullWidth size="small" sx={{ "& .MuiOutlinedInput-root": { backgroundColor: "#1f1f1f", color: "#fff", "& fieldset": { borderColor: "#555" }, "&:hover fieldset": { borderColor: "#888" } }, "& .MuiInputLabel-root": { color: "#aaa" } }} helperText={`IP or FQDN Borealis can reach over ${typeLabel}.`} /> setForm((prev) => ({ ...prev, description: e.target.value }))} fullWidth size="small" sx={{ "& .MuiOutlinedInput-root": { backgroundColor: "#1f1f1f", color: "#fff", "& fieldset": { borderColor: "#555" }, "&:hover fieldset": { borderColor: "#888" } }, "& .MuiInputLabel-root": { color: "#aaa" } }} /> setForm((prev) => ({ ...prev, operating_system: e.target.value }))} fullWidth size="small" sx={{ "& .MuiOutlinedInput-root": { backgroundColor: "#1f1f1f", color: "#fff", "& fieldset": { borderColor: "#555" }, "&:hover fieldset": { borderColor: "#888" } }, "& .MuiInputLabel-root": { color: "#aaa" } }} /> {error && ( {error} )} setDeleteTarget(null)} onConfirm={handleDelete} confirmDisabled={deleteBusy} /> setAddDeviceOpen(false)} onCreated={() => { setAddDeviceOpen(false); loadDevices(); }} />
); }