feat(directory): add subtype templates picker, fuzzy merge search, auto-ignore openbao, inherit parent host agent, truncate resource names, key badge positioning (#177)

This commit is contained in:
2026-08-08 19:38:42 -04:00
committed by GitHub
parent 9025feef1b
commit 583b822e4a
4 changed files with 133 additions and 17 deletions
Binary file not shown.
+5 -1
View File
@@ -78,6 +78,8 @@ module.exports = {
const ports = (c.Ports || []).map(p => p.PublicPort ? `${p.PublicPort}:${p.PrivatePort}` : `${p.PrivatePort}`).join(', ');
const isOwnStack = !!(stackProject && composeProject === stackProject);
const isIgnored = name.includes('openbao') || name.includes('bao-renewer') || composeService.includes('openbao') || composeService.includes('bao-renewer');
resources.push({
kind: 'container',
name: composeService || name,
@@ -87,13 +89,15 @@ module.exports = {
state: c.State,
status: c.Status,
ports: ports,
subType: 'docker',
composeProject: composeProject || undefined,
composeService: composeService || undefined,
containerName: name,
sourceId: stableKey,
ignored: isIgnored ? true : undefined,
// Part of the deployment we are running inside: already
// accounted for, not something to promote.
managed: isOwnStack ? true : undefined
managed: (isOwnStack || isIgnored) ? true : undefined
}
});
+21 -2
View File
@@ -290,11 +290,30 @@ class AgentManager {
};
}
// Find connected/enrolled agent bound to a resource ID.
// Find connected/enrolled agent bound to a resource ID (or inherited from parent Host).
async getAgentForResource(resourceId) {
if (!resourceId) return null;
const rows = await Agent.list().catch(() => []);
const agent = rows.find(a => a.resourceId === resourceId);
let agent = rows.find(a => a.resourceId === resourceId);
if (!agent) {
try {
const { Resource } = require('../models/resource');
const { ResourceEdge } = require('../models/resource');
const res = await Resource.get(resourceId);
if (res && res.kind === 'service') {
const edges = await ResourceEdge.list({ where: { childId: resourceId } });
for (const edge of edges) {
const parentRes = await Resource.get(edge.parentId);
if (parentRes && parentRes.kind === 'host') {
agent = rows.find(a => a.resourceId === parentRes.id || (parentRes.metadata && parentRes.metadata.agentId === a.id));
if (agent) break;
}
}
}
} catch (err) {
console.error('[AgentManager] parent agent lookup error:', err.message);
}
}
if (!agent) return null;
return agent.toPublic(this.liveState(agent.id));
}
+107 -14
View File
@@ -94,11 +94,11 @@
{{{indentHtml}}}
{{{caretHtml}}}
{{#isHost}}<span class="d-inline-block rounded-circle me-1" style="width:10px;height:10px;background:{{agentColor}};" title="{{agentStatusTitle}}"></span>{{/isHost}}
<a href="#" class="text-reset text-decoration-none me-2" onclick="openEditModal('{{id}}'); return false;" title="View details">
<strong>{{name}}</strong>
<a href="#" class="text-reset text-decoration-none me-1" onclick="openEditModal('{{id}}'); return false;" title="{{name}}">
<strong>{{displayName}}</strong>
</a>
<span class="badge bg-secondary me-1">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
{{#hasSecret}}<span class="badge bg-warning text-dark me-1" title="Has stored secrets in OpenBao"><i class="fa-solid fa-key me-1"></i>Secret</span>{{/hasSecret}}
<span class="badge bg-secondary me-1">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
{{#metadata.isProduction}}<span class="badge bg-danger me-1">Prod</span>{{/metadata.isProduction}}
{{^metadata.isProduction}}<span class="badge bg-info me-1">Dev</span>{{/metadata.isProduction}}
</td>
@@ -304,8 +304,20 @@
</select>
</div>
<div class="col-6">
<label class="form-label font-weight-bold"><i class="fa-solid fa-wand-magic-sparkles me-1 text-primary"></i>Preset Subtype Template</label>
<select id="res-subtype-template" class="form-select shadow-sm me-2 mb-2" onchange="applySubtypeTemplate()">
<option value="">-- Custom / Manual --</option>
<option value="docker">📦 Docker Engine / Container</option>
<option value="systemd">⚙️ Linux Systemd Service</option>
<option value="postgresql">🗄️ PostgreSQL Database</option>
<option value="redis">🔴 Redis In-Memory Cache</option>
<option value="proxmox">🖥️ Proxmox VE Hypervisor</option>
<option value="wireguard">🛡️ WireGuard VPN Tunnel</option>
<option value="unifi">📶 UniFi Network Controller</option>
<option value="k8s">☸️ Kubernetes Cluster Node</option>
</select>
<label class="form-label">Sub Type</label>
<input type="text" id="res-subtype" class="form-control shadow-sm" placeholder="e.g. proxmox_node, web, etc.">
<input type="text" id="res-subtype" class="form-control shadow-sm" placeholder="e.g. docker, systemd, postgresql...">
</div>
</div>
@@ -804,19 +816,48 @@
let disksHtml = '';
if (disks.length > 0) {
disksHtml = `<div class="table-responsive"><table class="table table-sm text-center small mb-0"><thead><tr><th>Mount</th><th>Type</th><th>FS</th><th>Usage</th><th>Total</th></tr></thead><tbody>` +
disksHtml = `<div class="table-responsive"><table class="table table-sm text-center small mb-0 align-middle"><thead><tr><th>Device</th><th>Mount</th><th>Type</th><th>FS</th><th>Usage</th><th>Used / Total</th></tr></thead><tbody>` +
disks.map(dk => `<tr>
<td><code class="text-dark fw-bold">${esc(dk.device || dk.mountpoint)}</code></td>
<td><code>${esc(dk.mountpoint)}</code></td>
<td><span class="badge bg-outline-secondary border text-dark">${esc(dk.drivetype || 'Disk')}</span></td>
<td><span class="badge bg-light text-dark border">${esc(dk.fstype || 'N/A')}</span></td>
<td>${fmtNum(dk.usage_percent)}%</td>
<td>${fmtSize(dk.total_bytes)}</td>
<td style="min-width: 120px;">
<div class="d-flex align-items-center gap-1">
<span class="small font-monospace me-1">${fmtNum(dk.usage_percent)}%</span>
<div class="progress flex-grow-1" style="height:6px"><div class="progress-bar ${dk.usage_percent > 85 ? 'bg-danger' : 'bg-primary'}" style="width:${Math.max(0, Math.min(100, dk.usage_percent))}%"></div></div>
</div>
</td>
<td>${fmtSize(dk.used_bytes || (dk.total_bytes * (dk.usage_percent || 0) / 100))} / ${fmtSize(dk.total_bytes)}</td>
</tr>`).join('') + `</tbody></table></div>`;
} else {
disksHtml = `<div class="small">Disk Usage: <strong>${fmtNum(t.disk_usage_percent ?? 0)}%</strong> ${bar(t.disk_usage_percent)}</div>`;
}
const agentVer = d.version || t.version || agent.version || 'v1.7.0';
const hd = t.host_details || d.host_details || agent.hostDetails || {};
let hostDetailsHtml = '';
if (hd.os || hd.kernel || hd.hardware_model || hd.static_hostname) {
hostDetailsHtml = `
<div class="card mb-3 shadow-sm border">
<div class="card-header bg-light py-2">
<h6 class="mb-0 fw-bold text-dark"><i class="fa-solid fa-server me-1"></i> System & Hardware Details</h6>
</div>
<div class="card-body p-3 small">
<div class="row g-2">
<div class="col-md-6"><strong>Static Hostname:</strong> ${esc(hd.static_hostname || d.hostname || 'N/A')}</div>
<div class="col-md-6"><strong>Operating System:</strong> ${esc(hd.os || d.os || 'N/A')}</div>
<div class="col-md-6"><strong>Kernel / Arch:</strong> ${esc(hd.kernel || d.kernel || 'N/A')} (${esc(hd.arch || 'x86_64')})</div>
<div class="col-md-6"><strong>Hardware Model:</strong> ${esc(hd.hardware_vendor || '')} ${esc(hd.hardware_model || 'N/A')}</div>
<div class="col-md-6"><strong>Chassis:</strong> ${esc(hd.chassis || 'N/A')}</div>
<div class="col-md-6"><strong>Firmware:</strong> ${esc(hd.firmware_version || 'N/A')} ${hd.firmware_date ? '(' + esc(hd.firmware_date) + ')' : ''}</div>
<div class="col-md-6"><strong>Machine ID:</strong> <code class="text-muted">${esc(hd.machine_id || 'N/A')}</code></div>
<div class="col-md-6"><strong>Boot ID:</strong> <code class="text-muted">${esc(hd.boot_id || 'N/A')}</code></div>
</div>
</div>
</div>`;
}
const agentVer = d.version || t.version || agent.version || 'v1.8.0';
const loggedUsers = t.logged_users || d.logged_users || agent.loggedUsers || [];
let loggedUsersHtml = '';
@@ -842,6 +883,8 @@
<small class="text-muted">Last seen ${lastSeenStr}</small>
</div>
${hostDetailsHtml} </div>
<!-- Memory Card (Matching user screenshot design) -->
<div class="card mb-3 shadow-sm border">
<div class="card-header bg-light py-2">
@@ -1150,6 +1193,7 @@
}
n.indentHtml = indentHtml;
n.depth = depth;
n.displayName = (n.name && n.name.length > 16) ? n.name.substring(0, 16) + '…' : (n.name || '');
// A leaf gets a spacer of the same width, so names stay aligned down
// the column instead of jittering by whether a row has children.
n.caretHtml = n.children.length
@@ -1420,21 +1464,52 @@
toggleFormFields();
}
function applySubtypeTemplate() {
const tmpl = $('#res-subtype-template').val();
if (!tmpl) return;
$('#res-subtype').val(tmpl);
const presets = {
docker: { icon: 'fa-brands fa-docker', tagline: 'Docker Engine Container Workload', port: '2375' },
systemd: { icon: 'fa-solid fa-gears', tagline: 'Linux Systemd Daemon Service' },
postgresql: { icon: 'fa-solid fa-database', tagline: 'PostgreSQL Relational Database', port: '5432' },
redis: { icon: 'fa-solid fa-cubes', tagline: 'Redis In-Memory Key-Value Cache', port: '6379' },
proxmox: { icon: 'fa-solid fa-server', tagline: 'Proxmox VE Hypervisor Host', port: '8006' },
wireguard: { icon: 'fa-solid fa-shield-halved', tagline: 'WireGuard VPN Encrypted Tunnel', port: '51820' },
unifi: { icon: 'fa-solid fa-wifi', tagline: 'UniFi Network Controller', port: '8443' },
k8s: { icon: 'fa-solid fa-dharmachakra', tagline: 'Kubernetes Cluster Worker Node', port: '6443' }
};
if (presets[tmpl]) {
const p = presets[tmpl];
if (p.icon) $('#res-icon').val(p.icon);
if (p.tagline) $('#res-tagline').val(p.tagline);
if (p.port && !$('#res-port').val()) $('#res-port').val(p.port);
updateIconPreview();
}
}
async function openMergeModal(discoveredId, discName) {
const targets = rawResources.filter(r => r.kind === 'host' || r.kind === 'site' || r.kind === 'service');
let optionsHtml = targets.map(r => `<option value="${r.id}">${esc(r.name)} (${esc(r.kind)}${r.metadata?.subType ? ' - ' + esc(r.metadata.subType) : ''})</option>`).join('');
app.modal.open({
title: `Merge '${esc(discName)}' into Existing Resource`,
size: 'md',
bodyHtml: `
<div class="p-3">
<p class="small text-muted">Select an existing Directory resource to merge IP addresses, network interfaces, and OS telemetry into:</p>
<p class="small text-muted mb-2">Search and select an existing Directory resource to merge IP addresses, network interfaces, and OS telemetry into:</p>
<div class="mb-3">
<label class="form-label font-weight-bold">Target Directory Resource</label>
<select class="form-select" id="merge-target-id">
${optionsHtml}
</select>
<input type="text" class="form-control mb-2 shadow-sm" id="merge-search-input" placeholder="🔍 Type to filter resources..." oninput="filterMergeTargets()">
<input type="hidden" id="merge-target-id" value="${targets[0] ? targets[0].id : ''}">
<div class="list-group overflow-auto border rounded shadow-sm" id="merge-targets-list" style="max-height: 240px;">
${targets.map((r, i) => `
<button type="button" class="list-group-item list-group-item-action p-2 merge-item-btn ${i===0 ? 'active' : ''}" data-id="${r.id}" onclick="selectMergeTarget(this)">
<div class="d-flex justify-content-between align-items-center">
<strong>${esc(r.name)}</strong>
<span class="badge bg-secondary">${esc(r.kind)}${r.metadata?.subType ? ' · ' + esc(r.metadata.subType) : ''}</span>
</div>
${r.metadata?.ip ? `<small class="font-monospace text-muted d-block">${esc(r.metadata.ip)}</small>` : ''}
</button>
`).join('')}
</div>
</div>
</div>
`,
@@ -1447,6 +1522,24 @@
});
}
function filterMergeTargets() {
const q = ($('#merge-search-input').val() || '').toLowerCase();
$('#merge-targets-list button').each(function() {
const text = $(this).text().toLowerCase();
if (text.includes(q)) {
$(this).removeClass('d-none');
} else {
$(this).addClass('d-none');
}
});
}
function selectMergeTarget(btn) {
$('#merge-targets-list button').removeClass('active');
$(btn).addClass('active');
$('#merge-target-id').val($(btn).data('id'));
}
async function submitMergeResource(discoveredId) {
const targetId = $('#merge-target-id').val();
if (!targetId) return;