mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-16 07:25:48 -07:00
Merge pull request #88 from bunny-lab-io/codex/restructure-devices-table-in-sqlite
Refactor device inventory storage and API
This commit is contained in:
@@ -43,6 +43,7 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
const [tab, setTab] = useState(0);
|
const [tab, setTab] = useState(0);
|
||||||
const [agent, setAgent] = useState(device || {});
|
const [agent, setAgent] = useState(device || {});
|
||||||
const [details, setDetails] = useState({});
|
const [details, setDetails] = useState({});
|
||||||
|
const [meta, setMeta] = useState({});
|
||||||
const [softwareOrderBy, setSoftwareOrderBy] = useState("name");
|
const [softwareOrderBy, setSoftwareOrderBy] = useState("name");
|
||||||
const [softwareOrder, setSoftwareOrder] = useState("asc");
|
const [softwareOrder, setSoftwareOrder] = useState("asc");
|
||||||
const [softwareSearch, setSoftwareSearch] = useState("");
|
const [softwareSearch, setSoftwareSearch] = useState("");
|
||||||
@@ -94,27 +95,137 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// When navigating to a different device, take a fresh snapshot of its status
|
|
||||||
if (device) {
|
if (device) {
|
||||||
setLockedStatus(device.status || statusFromHeartbeat(device.lastSeen));
|
setLockedStatus(device.status || statusFromHeartbeat(device.lastSeen));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!device || !device.hostname) return;
|
const guid = device?.agent_guid || device?.guid || device?.agentGuid || device?.summary?.agent_guid;
|
||||||
|
const agentId = device?.agentId || device?.summary?.agent_id || device?.id;
|
||||||
|
const hostname = device?.hostname || device?.summary?.hostname;
|
||||||
|
if (!device || (!guid && !hostname)) return;
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const [agentsRes, detailsRes] = await Promise.all([
|
const agentsPromise = fetch("/api/agents").catch(() => null);
|
||||||
fetch("/api/agents"),
|
let detailResponse = null;
|
||||||
fetch(`/api/device/details/${device.hostname}`)
|
if (guid) {
|
||||||
]);
|
try {
|
||||||
const agentsData = await agentsRes.json();
|
detailResponse = await fetch(`/api/devices/${encodeURIComponent(guid)}`);
|
||||||
if (agentsData && agentsData[device.id]) {
|
} catch (err) {
|
||||||
setAgent({ id: device.id, ...agentsData[device.id] });
|
detailResponse = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((!detailResponse || !detailResponse.ok) && hostname) {
|
||||||
|
try {
|
||||||
|
detailResponse = await fetch(`/api/device/details/${encodeURIComponent(hostname)}`);
|
||||||
|
} catch (err) {
|
||||||
|
detailResponse = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!detailResponse || !detailResponse.ok) {
|
||||||
|
throw new Error(`Failed to load device record (${detailResponse ? detailResponse.status : 'no response'})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [agentsData, detailData] = await Promise.all([
|
||||||
|
agentsPromise?.then((r) => (r ? r.json() : {})).catch(() => ({})),
|
||||||
|
detailResponse.json(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (agentsData && agentId && agentsData[agentId]) {
|
||||||
|
setAgent({ id: agentId, ...agentsData[agentId] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary =
|
||||||
|
detailData?.summary && typeof detailData.summary === "object"
|
||||||
|
? detailData.summary
|
||||||
|
: (detailData?.details?.summary || {});
|
||||||
|
const normalizedSummary = { ...(summary || {}) };
|
||||||
|
if (detailData?.description) {
|
||||||
|
normalizedSummary.description = detailData.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = {
|
||||||
|
summary: normalizedSummary,
|
||||||
|
memory: Array.isArray(detailData?.memory)
|
||||||
|
? detailData.memory
|
||||||
|
: Array.isArray(detailData?.details?.memory)
|
||||||
|
? detailData.details.memory
|
||||||
|
: [],
|
||||||
|
network: Array.isArray(detailData?.network)
|
||||||
|
? detailData.network
|
||||||
|
: Array.isArray(detailData?.details?.network)
|
||||||
|
? detailData.details.network
|
||||||
|
: [],
|
||||||
|
software: Array.isArray(detailData?.software)
|
||||||
|
? detailData.software
|
||||||
|
: Array.isArray(detailData?.details?.software)
|
||||||
|
? detailData.details.software
|
||||||
|
: [],
|
||||||
|
storage: Array.isArray(detailData?.storage)
|
||||||
|
? detailData.storage
|
||||||
|
: Array.isArray(detailData?.details?.storage)
|
||||||
|
? detailData.details.storage
|
||||||
|
: [],
|
||||||
|
cpu: detailData?.cpu || detailData?.details?.cpu || {},
|
||||||
|
};
|
||||||
|
setDetails(normalized);
|
||||||
|
|
||||||
|
const toYmdHms = (dateObj) => {
|
||||||
|
if (!dateObj || Number.isNaN(dateObj.getTime())) return '';
|
||||||
|
const pad = (v) => String(v).padStart(2, '0');
|
||||||
|
return `${dateObj.getUTCFullYear()}-${pad(dateObj.getUTCMonth() + 1)}-${pad(dateObj.getUTCDate())} ${pad(dateObj.getUTCHours())}:${pad(dateObj.getUTCMinutes())}:${pad(dateObj.getUTCSeconds())}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
let createdDisplay = normalizedSummary.created || '';
|
||||||
|
if (!createdDisplay) {
|
||||||
|
if (detailData?.created_at && Number(detailData.created_at)) {
|
||||||
|
createdDisplay = toYmdHms(new Date(Number(detailData.created_at) * 1000));
|
||||||
|
} else if (detailData?.created_at_iso) {
|
||||||
|
createdDisplay = toYmdHms(new Date(detailData.created_at_iso));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaPayload = {
|
||||||
|
hostname: detailData?.hostname || normalizedSummary.hostname || hostname || "",
|
||||||
|
lastUser: detailData?.last_user || normalizedSummary.last_user || "",
|
||||||
|
deviceType: detailData?.device_type || normalizedSummary.device_type || "",
|
||||||
|
created: createdDisplay,
|
||||||
|
createdAtIso: detailData?.created_at_iso || "",
|
||||||
|
lastSeen: detailData?.last_seen || normalizedSummary.last_seen || 0,
|
||||||
|
lastReboot: detailData?.last_reboot || normalizedSummary.last_reboot || "",
|
||||||
|
operatingSystem:
|
||||||
|
detailData?.operating_system || normalizedSummary.operating_system || normalizedSummary.agent_operating_system || "",
|
||||||
|
agentId: detailData?.agent_id || normalizedSummary.agent_id || agentId || "",
|
||||||
|
agentGuid: detailData?.agent_guid || normalizedSummary.agent_guid || guid || "",
|
||||||
|
agentHash: detailData?.agent_hash || normalizedSummary.agent_hash || "",
|
||||||
|
internalIp: detailData?.internal_ip || normalizedSummary.internal_ip || "",
|
||||||
|
externalIp: detailData?.external_ip || normalizedSummary.external_ip || "",
|
||||||
|
siteId: detailData?.site_id,
|
||||||
|
siteName: detailData?.site_name || "",
|
||||||
|
siteDescription: detailData?.site_description || "",
|
||||||
|
status: detailData?.status || "",
|
||||||
|
};
|
||||||
|
setMeta(metaPayload);
|
||||||
|
setDescription(normalizedSummary.description || detailData?.description || "");
|
||||||
|
|
||||||
|
setAgent((prev) => ({
|
||||||
|
...(prev || {}),
|
||||||
|
id: agentId || prev?.id,
|
||||||
|
hostname: metaPayload.hostname || prev?.hostname,
|
||||||
|
agent_hash: metaPayload.agentHash || prev?.agent_hash,
|
||||||
|
agent_operating_system: metaPayload.operatingSystem || prev?.agent_operating_system,
|
||||||
|
device_type: metaPayload.deviceType || prev?.device_type,
|
||||||
|
last_seen: metaPayload.lastSeen || prev?.last_seen,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (metaPayload.status) {
|
||||||
|
setLockedStatus(metaPayload.status);
|
||||||
|
} else if (metaPayload.lastSeen) {
|
||||||
|
setLockedStatus(statusFromHeartbeat(metaPayload.lastSeen));
|
||||||
}
|
}
|
||||||
const detailData = await detailsRes.json();
|
|
||||||
setDetails(detailData || {});
|
|
||||||
setDescription(detailData?.summary?.description || "");
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Failed to load device info", e);
|
console.warn("Failed to load device info", e);
|
||||||
|
setMeta({});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
load();
|
load();
|
||||||
@@ -151,9 +262,10 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const saveDescription = async () => {
|
const saveDescription = async () => {
|
||||||
if (!details.summary?.hostname) return;
|
const targetHost = meta.hostname || details.summary?.hostname;
|
||||||
|
if (!targetHost) return;
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/device/description/${details.summary.hostname}`, {
|
await fetch(`/api/device/description/${targetHost}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ description })
|
body: JSON.stringify({ description })
|
||||||
@@ -162,6 +274,7 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
...d,
|
...d,
|
||||||
summary: { ...(d.summary || {}), description }
|
summary: { ...(d.summary || {}), description }
|
||||||
}));
|
}));
|
||||||
|
setMeta((m) => ({ ...(m || {}), hostname: targetHost }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Failed to save description", e);
|
console.warn("Failed to save description", e);
|
||||||
}
|
}
|
||||||
@@ -236,7 +349,7 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
const summary = details.summary || {};
|
const summary = details.summary || {};
|
||||||
// Build a best-effort CPU display from summary fields
|
// Build a best-effort CPU display from summary fields
|
||||||
const cpuInfo = useMemo(() => {
|
const cpuInfo = useMemo(() => {
|
||||||
const cpu = summary.cpu || {};
|
const cpu = details.cpu || summary.cpu || {};
|
||||||
const cores = cpu.logical_cores || cpu.cores || cpu.physical_cores;
|
const cores = cpu.logical_cores || cpu.cores || cpu.physical_cores;
|
||||||
let ghz = cpu.base_clock_ghz;
|
let ghz = cpu.base_clock_ghz;
|
||||||
if (!ghz && typeof (summary.processor || '') === 'string') {
|
if (!ghz && typeof (summary.processor || '') === 'string') {
|
||||||
@@ -253,24 +366,42 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
}, [summary]);
|
}, [summary]);
|
||||||
|
|
||||||
const summaryItems = [
|
const summaryItems = [
|
||||||
{ label: "Hostname", value: summary.hostname || agent.hostname || device?.hostname || "unknown" },
|
{ label: "Hostname", value: meta.hostname || summary.hostname || agent.hostname || device?.hostname || "unknown" },
|
||||||
{ label: "Operating System", value: summary.operating_system || agent.agent_operating_system || "unknown" },
|
{
|
||||||
{ label: "Processor", value: cpuInfo.display || "unknown" },
|
label: "Last User",
|
||||||
{ label: "Device Type", value: summary.device_type || "unknown" },
|
value: (
|
||||||
{ label: "Last User", value: (
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
<Box component="span" sx={{
|
<Box
|
||||||
display: 'inline-block', width: 10, height: 10, borderRadius: 10,
|
component="span"
|
||||||
|
sx={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 10,
|
||||||
bgcolor: agent?.collector_active ? '#00d18c' : '#ff4f4f'
|
bgcolor: agent?.collector_active ? '#00d18c' : '#ff4f4f'
|
||||||
}} />
|
}}
|
||||||
<span>{summary.last_user || 'unknown'}</span>
|
/>
|
||||||
|
<span>{meta.lastUser || summary.last_user || 'unknown'}</span>
|
||||||
</Box>
|
</Box>
|
||||||
) },
|
)
|
||||||
{ label: "Internal IP", value: summary.internal_ip || "unknown" },
|
},
|
||||||
{ label: "External IP", value: summary.external_ip || "unknown" },
|
{ label: "Device Type", value: meta.deviceType || summary.device_type || 'unknown' },
|
||||||
{ label: "Last Reboot", value: summary.last_reboot ? formatDateTime(summary.last_reboot) : "unknown" },
|
{
|
||||||
{ label: "Created", value: summary.created ? formatDateTime(summary.created) : "unknown" },
|
label: "Created",
|
||||||
{ label: "Last Seen", value: formatLastSeen(agent.last_seen || device?.lastSeen) }
|
value: meta.created ? formatDateTime(meta.created) : summary.created ? formatDateTime(summary.created) : 'unknown'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Last Seen",
|
||||||
|
value: formatLastSeen(meta.lastSeen || agent.last_seen || device?.lastSeen)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Last Reboot",
|
||||||
|
value: meta.lastReboot ? formatDateTime(meta.lastReboot) : summary.last_reboot ? formatDateTime(summary.last_reboot) : 'unknown'
|
||||||
|
},
|
||||||
|
{ label: "Operating System", value: meta.operatingSystem || summary.operating_system || agent.agent_operating_system || 'unknown' },
|
||||||
|
{ label: "Agent ID", value: meta.agentId || summary.agent_id || 'unknown' },
|
||||||
|
{ label: "Agent GUID", value: meta.agentGuid || summary.agent_guid || 'unknown' },
|
||||||
|
{ label: "Agent Hash", value: meta.agentHash || summary.agent_hash || 'unknown' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const MetricCard = ({ icon, title, main, sub, color }) => {
|
const MetricCard = ({ icon, title, main, sub, color }) => {
|
||||||
@@ -693,9 +824,29 @@ export default function DeviceDetails({ device, onBack }) {
|
|||||||
|
|
||||||
const renderNetwork = () => {
|
const renderNetwork = () => {
|
||||||
const rows = details.network || [];
|
const rows = details.network || [];
|
||||||
if (!rows.length) return placeholderTable(["Adapter", "IP Address", "MAC Address"]);
|
const internalIp = meta.internalIp || summary.internal_ip || "unknown";
|
||||||
|
const externalIp = meta.externalIp || summary.external_ip || "unknown";
|
||||||
|
const ipHeader = (
|
||||||
|
<Box sx={{ mb: rows.length ? 1.5 : 1 }}>
|
||||||
|
<Typography variant="body2" sx={{ display: 'block', opacity: 0.9 }}>
|
||||||
|
Internal IP: {internalIp || 'unknown'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ display: 'block', opacity: 0.9 }}>
|
||||||
|
External IP: {externalIp || 'unknown'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
if (!rows.length) {
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
{ipHeader}
|
||||||
|
{placeholderTable(["Adapter", "IP Address", "MAC Address"])}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{ipHeader}
|
||||||
<Table size="small">
|
<Table size="small">
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
|||||||
@@ -51,6 +51,21 @@ function statusFromHeartbeat(tsSec, offlineAfter = 300) {
|
|||||||
return now - tsSec <= offlineAfter ? "Online" : "Offline";
|
return now - tsSec <= offlineAfter ? "Online" : "Offline";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatUptime(seconds) {
|
||||||
|
const total = Number(seconds);
|
||||||
|
if (!Number.isFinite(total) || total <= 0) return "";
|
||||||
|
const parts = [];
|
||||||
|
const days = Math.floor(total / 86400);
|
||||||
|
if (days) parts.push(`${days}d`);
|
||||||
|
const hours = Math.floor((total % 86400) / 3600);
|
||||||
|
if (hours) parts.push(`${hours}h`);
|
||||||
|
const minutes = Math.floor((total % 3600) / 60);
|
||||||
|
if (minutes) parts.push(`${minutes}m`);
|
||||||
|
const secondsPart = Math.floor(total % 60);
|
||||||
|
if (!parts.length && secondsPart) parts.push(`${secondsPart}s`);
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
export default function DeviceList({ onSelectDevice }) {
|
export default function DeviceList({ onSelectDevice }) {
|
||||||
const [rows, setRows] = useState([]);
|
const [rows, setRows] = useState([]);
|
||||||
const [orderBy, setOrderBy] = useState("status");
|
const [orderBy, setOrderBy] = useState("status");
|
||||||
@@ -89,6 +104,17 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
lastReboot: "Last Reboot",
|
lastReboot: "Last Reboot",
|
||||||
created: "Created",
|
created: "Created",
|
||||||
lastSeen: "Last Seen",
|
lastSeen: "Last Seen",
|
||||||
|
agentId: "Agent ID",
|
||||||
|
agentHash: "Agent Hash",
|
||||||
|
agentGuid: "Agent GUID",
|
||||||
|
domain: "Domain",
|
||||||
|
uptime: "Uptime",
|
||||||
|
memory: "Memory",
|
||||||
|
network: "Network",
|
||||||
|
software: "Software",
|
||||||
|
storage: "Storage",
|
||||||
|
cpu: "CPU",
|
||||||
|
siteDescription: "Site Description",
|
||||||
}),
|
}),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
@@ -114,19 +140,26 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
const [filters, setFilters] = useState({});
|
const [filters, setFilters] = useState({});
|
||||||
const [filterAnchor, setFilterAnchor] = useState(null); // { id, anchorEl }
|
const [filterAnchor, setFilterAnchor] = useState(null); // { id, anchorEl }
|
||||||
|
|
||||||
// Cache device details to avoid re-fetching every refresh
|
|
||||||
const [detailsByHost, setDetailsByHost] = useState({}); // hostname -> cached fields
|
|
||||||
const [siteMapping, setSiteMapping] = useState({}); // hostname -> { site_id, site_name }
|
|
||||||
const [sites, setSites] = useState([]); // sites list for assignment
|
const [sites, setSites] = useState([]); // sites list for assignment
|
||||||
const [assignDialogOpen, setAssignDialogOpen] = useState(false);
|
const [assignDialogOpen, setAssignDialogOpen] = useState(false);
|
||||||
const [assignSiteId, setAssignSiteId] = useState(null);
|
const [assignSiteId, setAssignSiteId] = useState(null);
|
||||||
const [assignTargets, setAssignTargets] = useState([]); // hostnames
|
const [assignTargets, setAssignTargets] = useState([]); // hostnames
|
||||||
|
|
||||||
const [repoHash, setRepoHash] = useState(null);
|
const [repoHash, setRepoHash] = useState(null);
|
||||||
|
const lastRepoFetchRef = useRef(0);
|
||||||
|
|
||||||
const fetchLatestRepoHash = useCallback(async () => {
|
const fetchLatestRepoHash = useCallback(async (options = {}) => {
|
||||||
|
const { force = false } = options || {};
|
||||||
|
const now = Date.now();
|
||||||
|
const elapsed = now - lastRepoFetchRef.current;
|
||||||
|
if (!force && repoHash && elapsed >= 0 && elapsed < 60_000) {
|
||||||
|
return repoHash;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ repo: "bunny-lab-io/Borealis", branch: "main" });
|
const params = new URLSearchParams({ repo: "bunny-lab-io/Borealis", branch: "main" });
|
||||||
|
if (force) {
|
||||||
|
params.set("refresh", "1");
|
||||||
|
}
|
||||||
const resp = await fetch(`/api/repo/current_hash?${params.toString()}`);
|
const resp = await fetch(`/api/repo/current_hash?${params.toString()}`);
|
||||||
const json = await resp.json();
|
const json = await resp.json();
|
||||||
const sha = (json?.sha || "").trim();
|
const sha = (json?.sha || "").trim();
|
||||||
@@ -135,14 +168,19 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
err.response = json;
|
err.response = json;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
setRepoHash((prev) => sha || prev || null);
|
lastRepoFetchRef.current = now;
|
||||||
|
setRepoHash((prev) => (sha ? sha : prev || null));
|
||||||
return sha || null;
|
return sha || null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("Failed to fetch repository hash", err);
|
console.warn("Failed to fetch repository hash", err);
|
||||||
|
if (!force && repoHash) {
|
||||||
|
return repoHash;
|
||||||
|
}
|
||||||
|
lastRepoFetchRef.current = now;
|
||||||
setRepoHash((prev) => prev || null);
|
setRepoHash((prev) => prev || null);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, []);
|
}, [repoHash]);
|
||||||
|
|
||||||
const computeAgentVersion = useCallback((agentHashValue, repoHashValue) => {
|
const computeAgentVersion = useCallback((agentHashValue, repoHashValue) => {
|
||||||
const agentHash = (agentHashValue || "").trim();
|
const agentHash = (agentHashValue || "").trim();
|
||||||
@@ -152,14 +190,14 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
return agentHash === repo ? "Up-to-Date" : "Needs Updated";
|
return agentHash === repo ? "Up-to-Date" : "Needs Updated";
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchAgents = useCallback(async (options = {}) => {
|
const fetchDevices = useCallback(async (options = {}) => {
|
||||||
const { refreshRepo = false } = options || {};
|
const { refreshRepo = false } = options || {};
|
||||||
let repoSha = repoHash;
|
let repoSha = repoHash;
|
||||||
if (refreshRepo || !repoSha) {
|
if (refreshRepo || !repoSha) {
|
||||||
const fetched = await fetchLatestRepoHash();
|
const fetched = await fetchLatestRepoHash({ force: refreshRepo });
|
||||||
if (fetched) repoSha = fetched;
|
if (fetched) repoSha = fetched;
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
const hashById = new Map();
|
const hashById = new Map();
|
||||||
const hashByGuid = new Map();
|
const hashByGuid = new Map();
|
||||||
const hashByHost = new Map();
|
const hashByHost = new Map();
|
||||||
@@ -186,161 +224,148 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
hashByHost.set(hostKey, hash);
|
hashByHost.set(hostKey, hash);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
const errPayload = await hashResp.json();
|
|
||||||
if (errPayload?.error) {
|
|
||||||
console.warn('Failed to fetch agent hash list', errPayload.error);
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Failed to fetch agent hash list', err);
|
console.warn('Failed to fetch agent hash list', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch("/api/agents");
|
try {
|
||||||
const data = await res.json();
|
const res = await fetch('/api/devices');
|
||||||
const arr = Object.entries(data || {}).map(([id, a]) => {
|
if (!res.ok) {
|
||||||
const agentId = (id || '').trim();
|
const err = new Error(`Failed to fetch devices (${res.status})`);
|
||||||
const hostname = a.hostname || agentId || "unknown";
|
try {
|
||||||
const normalizedHostKey = (hostname || '').trim().toLowerCase();
|
err.response = await res.json();
|
||||||
const details = detailsByHost[hostname] || {};
|
} catch {}
|
||||||
const rawGuid = (a.agent_guid || a.agentGuid || a.guid || '').trim();
|
throw err;
|
||||||
const detailGuid = (details.agentGuid || details.agent_guid || '').trim();
|
|
||||||
const guidCandidates = [rawGuid, detailGuid].filter((g) => g && g.trim());
|
|
||||||
let agentHash = '';
|
|
||||||
if (agentId && hashById.has(agentId)) {
|
|
||||||
agentHash = hashById.get(agentId) || '';
|
|
||||||
}
|
}
|
||||||
if (!agentHash) {
|
const payload = await res.json();
|
||||||
for (const candidate of guidCandidates) {
|
const list = Array.isArray(payload?.devices) ? payload.devices : [];
|
||||||
const key = (candidate || '').trim().toLowerCase();
|
|
||||||
if (key && hashByGuid.has(key)) {
|
const normalizeJson = (value) => {
|
||||||
agentHash = hashByGuid.get(key) || '';
|
if (!value) return '';
|
||||||
break;
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalized = list.map((device, index) => {
|
||||||
|
const summary = device && typeof device.summary === 'object' ? { ...device.summary } : {};
|
||||||
|
const rawHostname = (device.hostname || summary.hostname || '').trim();
|
||||||
|
const hostname = rawHostname || `device-${index + 1}`;
|
||||||
|
const agentId = (device.agent_id || summary.agent_id || '').trim();
|
||||||
|
const guidRaw = (device.agent_guid || summary.agent_guid || '').trim();
|
||||||
|
const guidLookupKey = guidRaw.toLowerCase();
|
||||||
|
const rowKey = guidRaw || agentId || hostname || `device-${index + 1}`;
|
||||||
|
let agentHash = (device.agent_hash || summary.agent_hash || '').trim();
|
||||||
|
if (agentId && hashById.has(agentId)) agentHash = hashById.get(agentId) || agentHash;
|
||||||
|
if (!agentHash && guidLookupKey && hashByGuid.has(guidLookupKey)) {
|
||||||
|
agentHash = hashByGuid.get(guidLookupKey) || agentHash;
|
||||||
}
|
}
|
||||||
|
const hostKey = hostname.trim().toLowerCase();
|
||||||
|
if (!agentHash && hostKey && hashByHost.has(hostKey)) {
|
||||||
|
agentHash = hashByHost.get(hostKey) || agentHash;
|
||||||
}
|
}
|
||||||
if (!agentHash && normalizedHostKey && hashByHost.has(normalizedHostKey)) {
|
const lastSeen = Number(device.last_seen || summary.last_seen || 0) || 0;
|
||||||
agentHash = hashByHost.get(normalizedHostKey) || '';
|
const status = device.status || statusFromHeartbeat(lastSeen);
|
||||||
|
|
||||||
|
if (guidRaw && !summary.agent_guid) {
|
||||||
|
summary.agent_guid = guidRaw;
|
||||||
}
|
}
|
||||||
if (!agentHash) {
|
|
||||||
agentHash = (a.agent_hash || details.agentHash || '').trim();
|
let createdTs = Number(device.created_at || 0) || 0;
|
||||||
|
let createdDisplay = summary.created || '';
|
||||||
|
if (!createdTs && createdDisplay) {
|
||||||
|
const parsed = Date.parse(createdDisplay.replace(' ', 'T'));
|
||||||
|
if (!Number.isNaN(parsed)) createdTs = Math.floor(parsed / 1000);
|
||||||
}
|
}
|
||||||
const agentGuidValue = rawGuid || detailGuid || '';
|
if (!createdDisplay && device.created_at_iso) {
|
||||||
|
try {
|
||||||
|
createdDisplay = new Date(device.created_at_iso).toLocaleString();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const osName =
|
||||||
|
device.operating_system ||
|
||||||
|
summary.operating_system ||
|
||||||
|
summary.agent_operating_system ||
|
||||||
|
"-";
|
||||||
|
const type = (device.device_type || summary.device_type || '').trim();
|
||||||
|
const lastUser = (device.last_user || summary.last_user || '').trim();
|
||||||
|
const domain = (device.domain || summary.domain || '').trim();
|
||||||
|
const internalIp = (device.internal_ip || summary.internal_ip || '').trim();
|
||||||
|
const externalIp = (device.external_ip || summary.external_ip || '').trim();
|
||||||
|
const lastReboot = (device.last_reboot || summary.last_reboot || '').trim();
|
||||||
|
const uptimeSeconds = Number(
|
||||||
|
device.uptime ||
|
||||||
|
summary.uptime_sec ||
|
||||||
|
summary.uptime_seconds ||
|
||||||
|
summary.uptime ||
|
||||||
|
0
|
||||||
|
) || 0;
|
||||||
|
|
||||||
|
const memoryList = Array.isArray(device.memory) ? device.memory : [];
|
||||||
|
const networkList = Array.isArray(device.network) ? device.network : [];
|
||||||
|
const softwareList = Array.isArray(device.software) ? device.software : [];
|
||||||
|
const storageList = Array.isArray(device.storage) ? device.storage : [];
|
||||||
|
const cpuObj =
|
||||||
|
(device.cpu && typeof device.cpu === 'object' && device.cpu) ||
|
||||||
|
(summary.cpu && typeof summary.cpu === 'object' ? summary.cpu : {});
|
||||||
|
|
||||||
|
const memoryDisplay = memoryList.length ? `${memoryList.length} module(s)` : '';
|
||||||
|
const networkDisplay = networkList.length ? networkList.map((n) => n.adapter || n.name || '').filter(Boolean).join(', ') : '';
|
||||||
|
const softwareDisplay = softwareList.length ? `${softwareList.length} item(s)` : '';
|
||||||
|
const storageDisplay = storageList.length ? `${storageList.length} volume(s)` : '';
|
||||||
|
const cpuDisplay = cpuObj.name || summary.processor || '';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id: rowKey,
|
||||||
hostname,
|
hostname,
|
||||||
status: statusFromHeartbeat(a.last_seen),
|
status,
|
||||||
lastSeen: a.last_seen || 0,
|
lastSeen,
|
||||||
os: a.agent_operating_system || a.os || "-",
|
lastSeenDisplay: formatLastSeen(lastSeen),
|
||||||
// Enriched fields from details cache
|
os: osName,
|
||||||
lastUser: details.lastUser || "",
|
lastUser,
|
||||||
type: a.device_type || details.type || "",
|
type,
|
||||||
created: details.created || "",
|
site: device.site_name || 'Not Configured',
|
||||||
createdTs: details.createdTs || 0,
|
siteId: device.site_id || null,
|
||||||
internalIp: details.internalIp || "",
|
siteDescription: device.site_description || '',
|
||||||
externalIp: details.externalIp || "",
|
description: (device.description || summary.description || '').trim(),
|
||||||
lastReboot: details.lastReboot || "",
|
created: createdDisplay,
|
||||||
description: details.description || "",
|
createdTs,
|
||||||
agentGuid: agentGuidValue,
|
createdIso: device.created_at_iso || '',
|
||||||
|
agentGuid: guidRaw,
|
||||||
agentHash,
|
agentHash,
|
||||||
agentVersion: computeAgentVersion(agentHash, repoSha),
|
agentVersion: computeAgentVersion(agentHash, repoSha),
|
||||||
};
|
agentId,
|
||||||
});
|
domain,
|
||||||
setRows(arr);
|
|
||||||
|
|
||||||
// Fetch site mapping for these hostnames
|
|
||||||
try {
|
|
||||||
const hostCsv = arr.map((r) => r.hostname).filter(Boolean).map(encodeURIComponent).join(',');
|
|
||||||
const resp = await fetch(`/api/sites/device_map?hostnames=${hostCsv}`);
|
|
||||||
const mapData = await resp.json();
|
|
||||||
const mapping = mapData?.mapping || {};
|
|
||||||
setSiteMapping(mapping);
|
|
||||||
setRows((prev) => prev.map((r) => ({ ...r, site: mapping[r.hostname]?.site_name || "Not Configured" })));
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
// Fetch missing details (last_user, created) for hosts not cached yet
|
|
||||||
const hostsToFetch = arr
|
|
||||||
.map((r) => r.hostname)
|
|
||||||
.filter((h) => h && !detailsByHost[h]);
|
|
||||||
if (hostsToFetch.length) {
|
|
||||||
// Limit concurrency a bit
|
|
||||||
const chunks = [];
|
|
||||||
const size = 6;
|
|
||||||
for (let i = 0; i < hostsToFetch.length; i += size) {
|
|
||||||
chunks.push(hostsToFetch.slice(i, i + size));
|
|
||||||
}
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
await Promise.all(
|
|
||||||
chunk.map(async (h) => {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/api/device/details/${encodeURIComponent(h)}`);
|
|
||||||
const det = await resp.json();
|
|
||||||
const summary = det?.summary || {};
|
|
||||||
const lastUser = summary.last_user || "";
|
|
||||||
const createdRaw = summary.created || "";
|
|
||||||
// Try to parse created to epoch seconds for sorting; fallback to 0
|
|
||||||
let createdTs = 0;
|
|
||||||
if (createdRaw) {
|
|
||||||
const parsed = Date.parse(createdRaw.replace(" ", "T"));
|
|
||||||
createdTs = isNaN(parsed) ? 0 : Math.floor(parsed / 1000);
|
|
||||||
}
|
|
||||||
const deviceType = (summary.device_type || "").trim();
|
|
||||||
const internalIp = summary.internal_ip || "";
|
|
||||||
const externalIp = summary.external_ip || "";
|
|
||||||
const lastReboot = summary.last_reboot || "";
|
|
||||||
const description = summary.description || "";
|
|
||||||
const agentHashValue = (summary.agent_hash || "").trim();
|
|
||||||
const agentGuidValue = (summary.agent_guid || summary.agentGuid || "").trim();
|
|
||||||
const enriched = {
|
|
||||||
lastUser,
|
|
||||||
created: createdRaw,
|
|
||||||
createdTs,
|
|
||||||
type: deviceType,
|
|
||||||
internalIp,
|
internalIp,
|
||||||
externalIp,
|
externalIp,
|
||||||
lastReboot,
|
lastReboot,
|
||||||
description,
|
uptime: uptimeSeconds,
|
||||||
agentHash: agentHashValue,
|
uptimeDisplay: formatUptime(uptimeSeconds),
|
||||||
agentGuid: agentGuidValue,
|
memory: memoryDisplay,
|
||||||
|
memoryRaw: normalizeJson(memoryList),
|
||||||
|
network: networkDisplay,
|
||||||
|
networkRaw: normalizeJson(networkList),
|
||||||
|
software: softwareDisplay,
|
||||||
|
softwareRaw: normalizeJson(softwareList),
|
||||||
|
storage: storageDisplay,
|
||||||
|
storageRaw: normalizeJson(storageList),
|
||||||
|
cpu: cpuDisplay,
|
||||||
|
cpuRaw: normalizeJson(cpuObj),
|
||||||
|
summary,
|
||||||
|
details: device.details || {},
|
||||||
};
|
};
|
||||||
setDetailsByHost((prev) => ({
|
});
|
||||||
...prev,
|
|
||||||
[h]: enriched,
|
setRows(normalized);
|
||||||
}));
|
|
||||||
setRows((prev) =>
|
|
||||||
prev.map((r) => {
|
|
||||||
if (r.hostname !== h) return r;
|
|
||||||
const nextHash = agentHashValue || r.agentHash;
|
|
||||||
return {
|
|
||||||
...r,
|
|
||||||
lastUser: enriched.lastUser || r.lastUser,
|
|
||||||
type: enriched.type || r.type,
|
|
||||||
created: enriched.created || r.created,
|
|
||||||
createdTs: enriched.createdTs || r.createdTs,
|
|
||||||
internalIp: enriched.internalIp || r.internalIp,
|
|
||||||
externalIp: enriched.externalIp || r.externalIp,
|
|
||||||
lastReboot: enriched.lastReboot || r.lastReboot,
|
|
||||||
description: enriched.description || r.description,
|
|
||||||
agentGuid: agentGuidValue || r.agentGuid || "",
|
|
||||||
agentHash: nextHash,
|
|
||||||
agentVersion: computeAgentVersion(nextHash, repoSha),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// ignore per-host failure
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Failed to load agents:", e);
|
console.warn('Failed to load devices:', e);
|
||||||
setRows([]);
|
setRows([]);
|
||||||
}
|
}
|
||||||
}, [detailsByHost, repoHash, fetchLatestRepoHash, computeAgentVersion]);
|
}, [repoHash, fetchLatestRepoHash, computeAgentVersion]);
|
||||||
|
|
||||||
const fetchViews = useCallback(async () => {
|
const fetchViews = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -355,8 +380,8 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Initial load only; removed auto-refresh interval
|
// Initial load only; removed auto-refresh interval
|
||||||
fetchAgents({ refreshRepo: true });
|
fetchDevices({ refreshRepo: true });
|
||||||
}, [fetchAgents]);
|
}, [fetchDevices]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchViews();
|
fetchViews();
|
||||||
@@ -471,6 +496,28 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
return formatCreated(row.created, row.createdTs);
|
return formatCreated(row.created, row.createdTs);
|
||||||
case "lastSeen":
|
case "lastSeen":
|
||||||
return formatLastSeen(row.lastSeen);
|
return formatLastSeen(row.lastSeen);
|
||||||
|
case "agentId":
|
||||||
|
return row.agentId || "";
|
||||||
|
case "agentHash":
|
||||||
|
return row.agentHash || "";
|
||||||
|
case "agentGuid":
|
||||||
|
return row.agentGuid || "";
|
||||||
|
case "domain":
|
||||||
|
return row.domain || "";
|
||||||
|
case "uptime":
|
||||||
|
return row.uptimeDisplay || (row.uptime ? String(row.uptime) : "");
|
||||||
|
case "memory":
|
||||||
|
return row.memoryRaw || row.memory || "";
|
||||||
|
case "network":
|
||||||
|
return row.networkRaw || row.network || "";
|
||||||
|
case "software":
|
||||||
|
return row.softwareRaw || row.software || "";
|
||||||
|
case "storage":
|
||||||
|
return row.storageRaw || row.storage || "";
|
||||||
|
case "cpu":
|
||||||
|
return row.cpuRaw || row.cpu || "";
|
||||||
|
case "siteDescription":
|
||||||
|
return row.siteDescription || "";
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -485,9 +532,10 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
const dir = order === "asc" ? 1 : -1;
|
const dir = order === "asc" ? 1 : -1;
|
||||||
return [...filtered].sort((a, b) => {
|
return [...filtered].sort((a, b) => {
|
||||||
// Support numeric sort for created/lastSeen
|
// Support numeric sort for created/lastSeen/uptime
|
||||||
if (orderBy === "lastSeen") return ((a.lastSeen || 0) - (b.lastSeen || 0)) * dir;
|
if (orderBy === "lastSeen") return ((a.lastSeen || 0) - (b.lastSeen || 0)) * dir;
|
||||||
if (orderBy === "created") return ((a.createdTs || 0) - (b.createdTs || 0)) * dir;
|
if (orderBy === "created") return ((a.createdTs || 0) - (b.createdTs || 0)) * dir;
|
||||||
|
if (orderBy === "uptime") return ((a.uptime || 0) - (b.uptime || 0)) * dir;
|
||||||
const A = a[orderBy];
|
const A = a[orderBy];
|
||||||
const B = b[orderBy];
|
const B = b[orderBy];
|
||||||
return String(A || "").localeCompare(String(B || "")) * dir;
|
return String(A || "").localeCompare(String(B || "")) * dir;
|
||||||
@@ -518,8 +566,11 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
|
const targetAgentId = selected.agentId || selected.summary?.agent_id || selected.id;
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/agent/${selected.id}`, { method: "DELETE" });
|
if (targetAgentId) {
|
||||||
|
await fetch(`/api/agent/${encodeURIComponent(targetAgentId)}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Failed to remove agent", e);
|
console.warn("Failed to remove agent", e);
|
||||||
}
|
}
|
||||||
@@ -682,7 +733,7 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
<Tooltip title="Refresh Devices to Detect Changes">
|
<Tooltip title="Refresh Devices to Detect Changes">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => fetchAgents({ refreshRepo: true })}
|
onClick={() => fetchDevices({ refreshRepo: true })}
|
||||||
sx={{ color: "#bbb", mr: 1 }}
|
sx={{ color: "#bbb", mr: 1 }}
|
||||||
>
|
>
|
||||||
<CachedIcon fontSize="small" />
|
<CachedIcon fontSize="small" />
|
||||||
@@ -830,8 +881,30 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
return (
|
return (
|
||||||
<TableCell key={col.id}>{formatLastSeen(r.lastSeen)}</TableCell>
|
<TableCell key={col.id}>{formatLastSeen(r.lastSeen)}</TableCell>
|
||||||
);
|
);
|
||||||
|
case "agentId":
|
||||||
|
return <TableCell key={col.id}>{r.agentId || ""}</TableCell>;
|
||||||
|
case "agentHash":
|
||||||
|
return <TableCell key={col.id}>{r.agentHash || ""}</TableCell>;
|
||||||
|
case "agentGuid":
|
||||||
|
return <TableCell key={col.id}>{r.agentGuid || ""}</TableCell>;
|
||||||
|
case "domain":
|
||||||
|
return <TableCell key={col.id}>{r.domain || ""}</TableCell>;
|
||||||
|
case "uptime":
|
||||||
|
return <TableCell key={col.id}>{r.uptimeDisplay || ''}</TableCell>;
|
||||||
|
case "memory":
|
||||||
|
return <TableCell key={col.id}>{r.memory || ""}</TableCell>;
|
||||||
|
case "network":
|
||||||
|
return <TableCell key={col.id}>{r.network || ""}</TableCell>;
|
||||||
|
case "software":
|
||||||
|
return <TableCell key={col.id}>{r.software || ""}</TableCell>;
|
||||||
|
case "storage":
|
||||||
|
return <TableCell key={col.id}>{r.storage || ""}</TableCell>;
|
||||||
|
case "cpu":
|
||||||
|
return <TableCell key={col.id}>{r.cpu || ""}</TableCell>;
|
||||||
|
case "siteDescription":
|
||||||
|
return <TableCell key={col.id}>{r.siteDescription || ""}</TableCell>;
|
||||||
default:
|
default:
|
||||||
return <TableCell key={col.id} />;
|
return <TableCell key={col.id}>{String(r[col.id] || "")}</TableCell>;
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
@@ -952,42 +1025,28 @@ export default function DeviceList({ onSelectDevice }) {
|
|||||||
PaperProps={{ sx: { bgcolor: "#1e1e1e", color: '#fff', p: 1 } }}
|
PaperProps={{ sx: { bgcolor: "#1e1e1e", color: '#fff', p: 1 } }}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, p: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, p: 1 }}>
|
||||||
{[
|
{Object.entries(COL_LABELS)
|
||||||
{ id: 'agentVersion', label: 'Agent Version' },
|
.filter(([id]) => id !== 'status')
|
||||||
{ id: 'site', label: 'Site' },
|
.map(([id, label]) => (
|
||||||
{ id: 'hostname', label: 'Hostname' },
|
<MenuItem key={id} disableRipple onClick={(e) => e.stopPropagation()} sx={{ gap: 1 }}>
|
||||||
{ id: 'os', label: 'Operating System' },
|
|
||||||
{ id: 'type', label: 'Device Type' },
|
|
||||||
{ id: 'lastUser', label: 'Last User' },
|
|
||||||
{ id: 'internalIp', label: 'Internal IP' },
|
|
||||||
{ id: 'externalIp', label: 'External IP' },
|
|
||||||
{ id: 'lastReboot', label: 'Last Reboot' },
|
|
||||||
{ id: 'created', label: 'Created' },
|
|
||||||
{ id: 'lastSeen', label: 'Last Seen' },
|
|
||||||
{ id: 'description', label: 'Description' },
|
|
||||||
].map((opt) => (
|
|
||||||
<MenuItem key={opt.id} disableRipple onClick={(e) => e.stopPropagation()} sx={{ gap: 1 }}>
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
size="small"
|
size="small"
|
||||||
checked={columns.some((c) => c.id === opt.id)}
|
checked={columns.some((c) => c.id === id)}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const checked = e.target.checked;
|
const checked = e.target.checked;
|
||||||
setColumns((prev) => {
|
setColumns((prev) => {
|
||||||
// Keep 'status' always present; manage others per toggle
|
const exists = prev.some((c) => c.id === id);
|
||||||
const exists = prev.some((c) => c.id === opt.id);
|
|
||||||
if (checked) {
|
if (checked) {
|
||||||
if (exists) return prev;
|
if (exists) return prev;
|
||||||
// Append new column at the end with canonical label
|
const nextLabel = COL_LABELS[id] || label || id;
|
||||||
const label = COL_LABELS[opt.id] || opt.label || opt.id;
|
return [...prev, { id, label: nextLabel }];
|
||||||
return [...prev, { id: opt.id, label }];
|
|
||||||
}
|
}
|
||||||
// Remove column
|
return prev.filter((c) => c.id !== id);
|
||||||
return prev.filter((c) => c.id !== opt.id);
|
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
sx={{ p: 0.3, color: '#bbb' }}
|
sx={{ p: 0.3, color: '#bbb' }}
|
||||||
/>
|
/>
|
||||||
<Typography variant="body2" sx={{ color: '#ddd' }}>{opt.label}</Typography>
|
<Typography variant="body2" sx={{ color: '#ddd' }}>{label || id}</Typography>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
<Box sx={{ display: 'flex', gap: 1, pt: 0.5 }}>
|
<Box sx={{ display: 'flex', gap: 1, pt: 0.5 }}>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user