Merge pull request #176 from theta42/release-v1.33.0
Release v1.33.0 - Directory Key Badges, Discovered Inventory Merge/Ignore & Desktop Operations
This commit is contained in:
@@ -1,3 +1,13 @@
|
||||
# v1.33.0 - 2026-08-08
|
||||
|
||||
### Added
|
||||
- **Directory Key Badges & Secret Filtering.** Added a gold `🔑 Secret` badge next to resources with stored OpenBao secrets and a `With Secrets` filter checkbox to filter the directory tree by secret presence.
|
||||
- **Kind-Specific Resource Creation Modals.** Added dedicated `openAddSiteModal()`, `openAddHostModal()`, and `openAddServiceModal()` modal handlers for Site, Host, and Service resources.
|
||||
- **Top Toolbar Reorganization.** Updated top tree button to **"+ Add Site"** and removed legacy `Plumbing` slider.
|
||||
- **Optional Child Secret Key Name on Inheritance.** Made key name optional when inheriting parent secrets — automatically defaulting to the original parent secret key name if left blank.
|
||||
- **Discovered Inventory Merge & Ignore Actions.** Added `Merge` (merge IP/interfaces/OS metadata into target resource) and `Ignore` (dismiss discovered item) endpoints (`/api/directory-admin/discovered/merge` & `/api/directory-admin/discovered/ignore`) and table action buttons.
|
||||
- **Agent Tab Telemetry & Desktop Controls.** Rendered Agent Binary Version badge (`v1.8.0`), all physical disks and filesystems table, Active Logged-in Users card, and Desktop Session & Power Operations card (Lock, Display Off, Log Out, Sleep Host).
|
||||
|
||||
# v1.32.0 - 2026-08-08
|
||||
|
||||
### Added
|
||||
|
||||
@@ -42,12 +42,14 @@ class ThetaAgentDriver extends BaseDriver {
|
||||
status: 'online',
|
||||
driver: this.name,
|
||||
agentId: agent.id,
|
||||
agentVersion: agent.version,
|
||||
agentVersion: agent.version || 'v1.7.0',
|
||||
lastSeen: agent.lastSeen,
|
||||
system: {
|
||||
cpu: telemetry.cpu || null,
|
||||
ram: telemetry.memory || null,
|
||||
disk: telemetry.disk || null,
|
||||
disks: telemetry.disks || [],
|
||||
loggedUsers: telemetry.loggedUsers || [],
|
||||
uptime: telemetry.uptime || null
|
||||
}
|
||||
};
|
||||
@@ -82,6 +84,16 @@ class ThetaAgentDriver extends BaseDriver {
|
||||
return { status: 'ok', driver: this.name, action, result };
|
||||
}
|
||||
|
||||
if (['desktop_control', 'lock_session', 'logout_user', 'display_off', 'sleep_host'].includes(action) || subType.startsWith('desktop')) {
|
||||
const subAction = params.subAction || action;
|
||||
const targetUser = params.user || '';
|
||||
const result = await AgentManager.sendCommand(agent.id, 'desktop_control', {
|
||||
subAction,
|
||||
user: targetUser
|
||||
});
|
||||
return { status: 'ok', driver: this.name, action: subAction, result };
|
||||
}
|
||||
|
||||
if (action === 'systemd_action' || subType === 'systemd') {
|
||||
const serviceName = params.serviceName || (resource.metadata && resource.metadata.systemdService) || resource.slug;
|
||||
const subAction = params.subAction || action; // start, stop, restart, reload
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.32.0",
|
||||
"version": "1.33.0",
|
||||
"description": "A very simple LDAP management and SSO system",
|
||||
"author": [
|
||||
{
|
||||
|
||||
@@ -799,4 +799,61 @@ router.get('/resources/:id/driver-logs', async (req, res, next) => {
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ── Discovered Inventory Operations (Merge & Ignore) ───────────────────────
|
||||
router.post('/discovered/ignore', async (req, res, next) => {
|
||||
try {
|
||||
const { resourceId } = req.body;
|
||||
if (!resourceId) return res.status(400).json({ status: 'error', message: 'resourceId is required' });
|
||||
const r = await Resource.get(resourceId);
|
||||
if (!r) return res.status(404).json({ status: 'error', message: 'resource not found' });
|
||||
|
||||
r.metadata = r.metadata || {};
|
||||
r.metadata.ignored = true;
|
||||
await r.save();
|
||||
res.json({ status: 'ok', resourceId: r.id, ignored: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.post('/discovered/merge', async (req, res, next) => {
|
||||
try {
|
||||
const { discoveredId, targetId } = req.body;
|
||||
if (!discoveredId || !targetId) {
|
||||
return res.status(400).json({ status: 'error', message: 'discoveredId and targetId are required' });
|
||||
}
|
||||
const disc = await Resource.get(discoveredId);
|
||||
const target = await Resource.get(targetId);
|
||||
if (!disc || !target) {
|
||||
return res.status(404).json({ status: 'error', message: 'Discovered or Target resource not found' });
|
||||
}
|
||||
|
||||
// Merge metadata (interfaces, discovery sources, OS details)
|
||||
target.metadata = target.metadata || {};
|
||||
disc.metadata = disc.metadata || {};
|
||||
|
||||
const sources = new Set([...(target.metadata.discovery_sources || []), ...(disc.metadata.discovery_sources || [])]);
|
||||
target.metadata.discovery_sources = Array.from(sources);
|
||||
|
||||
if (disc.metadata.interfaces) {
|
||||
const existingInterfaces = target.metadata.interfaces || [];
|
||||
const macs = new Set(existingInterfaces.map(i => i.mac).filter(Boolean));
|
||||
for (const iface of disc.metadata.interfaces) {
|
||||
if (!iface.mac || !macs.has(iface.mac)) {
|
||||
existingInterfaces.push(iface);
|
||||
}
|
||||
}
|
||||
target.metadata.interfaces = existingInterfaces;
|
||||
}
|
||||
|
||||
if (disc.metadata.os) target.metadata.os = target.metadata.os || disc.metadata.os;
|
||||
if (disc.metadata.kernel) target.metadata.kernel = target.metadata.kernel || disc.metadata.kernel;
|
||||
|
||||
await target.save();
|
||||
|
||||
// Remove or mark discovered record as merged
|
||||
await disc.delete();
|
||||
|
||||
res.json({ status: 'ok', mergedTargetId: target.id, targetName: target.name });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
+165
-24
@@ -49,10 +49,10 @@
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="collapseAllTree()" title="Collapse all"><i class="fa-solid fa-angles-up"></i></button>
|
||||
</div>
|
||||
<div class="form-check form-switch form-check-inline ms-1 me-1">
|
||||
<input class="form-check-input" type="checkbox" id="toggle-plumbing" onchange="renderTable()">
|
||||
<label class="form-check-label small text-muted" for="toggle-plumbing" title="Show containers, oauth clients, and sidecars">Plumbing</label>
|
||||
<input class="form-check-input" type="checkbox" id="toggle-secrets-only" onchange="renderTable()">
|
||||
<label class="form-check-label small text-muted" for="toggle-secrets-only" title="Filter tree to show only resources with stored secrets"><i class="fa-solid fa-key text-warning me-1"></i>With Secrets</label>
|
||||
</div>
|
||||
<input type="text" id="search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search..." onkeyup="renderTable()" style="width: 200px;">
|
||||
<input type="text" id="search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search resources or keys..." onkeyup="renderTable()" style="width: 210px;">
|
||||
<select id="sort-by" class="form-select form-select-sm shadow-sm" onchange="renderTable()" style="width: 150px;">
|
||||
<option value="name">Name (A-Z)</option>
|
||||
<option value="kind">Kind</option>
|
||||
@@ -68,8 +68,8 @@
|
||||
<button class="btn btn-sm btn-outline-primary ms-1 shadow-sm" onclick="openAgentInstallModal()">
|
||||
<i class="fa-solid fa-shield-halved me-1"></i> Install Agent
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary ms-1 shadow-sm" onclick="openAddModal()">
|
||||
<i class="fas fa-plus"></i> Add Resource
|
||||
<button class="btn btn-sm btn-primary ms-1 shadow-sm" onclick="openAddSiteModal()">
|
||||
<i class="fas fa-plus"></i> Add Site
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,6 +98,7 @@
|
||||
<strong>{{name}}</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}}
|
||||
{{#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>
|
||||
@@ -206,9 +207,15 @@
|
||||
</td>
|
||||
<td class="text-end pe-3">
|
||||
{{^metadata.managed}}
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="promoteResource('{{slug}}')" title="Promote to Managed">
|
||||
<button class="btn btn-sm btn-outline-primary me-1" onclick="promoteResource('{{slug}}')" title="Promote to Managed Directory Host">
|
||||
<i class="fa-solid fa-arrow-up-right-dots"></i> Promote
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-success me-1" onclick="openMergeModal('{{id}}', '{{name}}')" title="Merge into Existing Resource">
|
||||
<i class="fa-solid fa-code-merge"></i> Merge
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="ignoreDiscoveredResource('{{id}}')" title="Ignore / Dismiss">
|
||||
<i class="fa-solid fa-eye-slash"></i> Ignore
|
||||
</button>
|
||||
{{/metadata.managed}}
|
||||
{{#metadata.managed}}
|
||||
<button class="btn btn-sm btn-outline-secondary" disabled title="Already Managed">
|
||||
@@ -809,9 +816,29 @@
|
||||
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 loggedUsers = t.logged_users || d.logged_users || agent.loggedUsers || [];
|
||||
|
||||
let loggedUsersHtml = '';
|
||||
if (loggedUsers.length > 0) {
|
||||
loggedUsersHtml = `<div class="table-responsive"><table class="table table-sm small mb-0 align-middle">
|
||||
<thead><tr><th>User</th><th>Terminal</th><th>Host / IP</th><th>Session Time</th></tr></thead><tbody>` +
|
||||
loggedUsers.map(u => `<tr>
|
||||
<td><i class="fa-solid fa-user text-primary me-1"></i><strong>${esc(u.user)}</strong></td>
|
||||
<td><code class="text-muted">${esc(u.terminal || 'tty')}</code></td>
|
||||
<td>${esc(u.host || 'local')}</td>
|
||||
<td class="text-muted">${u.started ? moment(u.started * 1000).fromNow() : 'active'}</td>
|
||||
</tr>`).join('') + `</tbody></table></div>`;
|
||||
} else {
|
||||
loggedUsersHtml = `<div class="small text-muted p-2"><i class="fa-solid fa-user-slash me-1"></i>No active logged-in user sessions.</div>`;
|
||||
}
|
||||
|
||||
return `<div class="p-3">
|
||||
<div class="mb-3 d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">${esc(agent.name || agent.hostname || 'unknown')} ${online}</h5>
|
||||
<h5 class="mb-0">
|
||||
${esc(agent.name || agent.hostname || 'unknown')} ${online}
|
||||
<span class="badge bg-dark ms-2 font-monospace" title="Theta Agent Binary Version"><i class="fa-solid fa-code-branch me-1"></i>${esc(agentVer)}</span>
|
||||
</h5>
|
||||
<small class="text-muted">Last seen ${lastSeenStr}</small>
|
||||
</div>
|
||||
|
||||
@@ -856,18 +883,41 @@
|
||||
<!-- Disks Card -->
|
||||
<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-hard-drive me-1"></i> Disks & Storage</h6>
|
||||
<h6 class="mb-0 fw-bold text-dark"><i class="fa-solid fa-hard-drive me-1"></i> Physical Disks & Filesystems</h6>
|
||||
</div>
|
||||
<div class="card-body p-2">
|
||||
${disksHtml}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logged-in Users Card -->
|
||||
<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-users me-1"></i> Active Logged-in Users</h6>
|
||||
</div>
|
||||
<div class="card-body p-2">
|
||||
${loggedUsersHtml}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-6">GPU <strong>${gpu}</strong></div>
|
||||
<div class="col-6">ZFS <strong>${esc(t.zfs_health || 'N/A')}</strong></div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop Controls Card -->
|
||||
<div class="card mb-3 border-info shadow-sm">
|
||||
<div class="card-header bg-info text-dark py-2">
|
||||
<h6 class="mb-0 fw-bold"><i class="fa-solid fa-desktop me-1"></i> Desktop Session & Power Controls</h6>
|
||||
</div>
|
||||
<div class="card-body p-3 d-flex flex-wrap gap-2">
|
||||
<button class="btn btn-outline-dark btn-sm" onclick="agentDesktopControl('${agent.id}', 'lock')"><i class="fa-solid fa-lock me-1"></i> Lock Session</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="agentDesktopControl('${agent.id}', 'display_off')"><i class="fa-solid fa-tv me-1"></i> Display Off</button>
|
||||
<button class="btn btn-outline-warning btn-sm" onclick="agentDesktopControl('${agent.id}', 'logout')"><i class="fa-solid fa-right-from-bracket me-1"></i> Log Out User</button>
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="agentDesktopControl('${agent.id}', 'sleep')"><i class="fa-solid fa-moon me-1"></i> Sleep Host</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Host Power Controls -->
|
||||
<div class="card mb-3 border-danger shadow-sm">
|
||||
<div class="card-header bg-danger text-white py-2">
|
||||
@@ -1041,22 +1091,21 @@
|
||||
function renderTable() {
|
||||
const filter = $('#search-filter').val().toLowerCase();
|
||||
const sort = $('#sort-by').val();
|
||||
const showPlumbing = $('#toggle-plumbing').is(':checked');
|
||||
const secretsOnly = $('#toggle-secrets-only').is(':checked');
|
||||
|
||||
let filtered = rawResources.filter(r => {
|
||||
if (!showPlumbing && !filter) {
|
||||
const sub = (r.metadata?.subType || '').toLowerCase();
|
||||
if (r.kind === 'container' || r.kind === 'oauth' || sub === 'sidecar' || sub === 'container' || sub === 'openresty') {
|
||||
return false;
|
||||
}
|
||||
if (secretsOnly && !r.hasSecret) {
|
||||
return false;
|
||||
}
|
||||
if (!filter) return true;
|
||||
const secretKeysStr = (r.secretKeys || []).join(' ').toLowerCase();
|
||||
return (r.name || '').toLowerCase().includes(filter) ||
|
||||
(r.slug || '').toLowerCase().includes(filter) ||
|
||||
(r.kind || '').toLowerCase().includes(filter) ||
|
||||
(r.metadata?.subType || '').toLowerCase().includes(filter) ||
|
||||
(r.metadata?.ip || '').toLowerCase().includes(filter) ||
|
||||
(r.hostName || '').toLowerCase().includes(filter);
|
||||
(r.hostName || '').toLowerCase().includes(filter) ||
|
||||
secretKeysStr.includes(filter);
|
||||
});
|
||||
|
||||
// Sort
|
||||
@@ -1352,6 +1401,96 @@
|
||||
populateHostDropdown(parentId || '');
|
||||
toggleFormFields();
|
||||
}
|
||||
|
||||
function openAddSiteModal() {
|
||||
openAddModal(null, null);
|
||||
$('#res-kind').val('site');
|
||||
toggleFormFields();
|
||||
}
|
||||
|
||||
function openAddHostModal(parentId) {
|
||||
openAddModal(parentId, 'site');
|
||||
$('#res-kind').val('host');
|
||||
toggleFormFields();
|
||||
}
|
||||
|
||||
function openAddServiceModal(parentId) {
|
||||
openAddModal(parentId, 'host');
|
||||
$('#res-kind').val('service');
|
||||
toggleFormFields();
|
||||
}
|
||||
|
||||
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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
footer: {
|
||||
buttonsHtml: `
|
||||
<button class="btn btn-secondary btn-sm" onclick="app.modal.close()">Cancel</button>
|
||||
<button class="btn btn-success btn-sm" onclick="submitMergeResource('${discoveredId}')"><i class="fa-solid fa-code-merge me-1"></i> Confirm Merge</button>
|
||||
`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function submitMergeResource(discoveredId) {
|
||||
const targetId = $('#merge-target-id').val();
|
||||
if (!targetId) return;
|
||||
try {
|
||||
app.messages.action('Merging discovered resource...', $('#app-modal-body'), 'info');
|
||||
await app.api.post('directory-admin/discovered/merge', { discoveredId, targetId });
|
||||
app.messages.action('Resource merged successfully!', null, 'success');
|
||||
app.modal.close();
|
||||
loadData();
|
||||
} catch (err) {
|
||||
app.messages.action(err.message || 'Failed to merge resource', $('#app-modal-body'), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function ignoreDiscoveredResource(resourceId) {
|
||||
const confirmed = await app.messages.confirm('Ignore and dismiss this discovered device?', null, 'warning');
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await app.api.post('directory-admin/discovered/ignore', { resourceId });
|
||||
app.messages.action('Discovered device ignored.', null, 'info');
|
||||
loadData();
|
||||
} catch (err) {
|
||||
app.messages.action(err.message || 'Failed to ignore device', null, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function agentDesktopControl(agentId, action, username = '') {
|
||||
const confirmed = await app.messages.confirm(`Execute '${action}' desktop control action?`, $('#res-modal'), 'warning');
|
||||
if (!confirmed) return;
|
||||
const resourceId = $('#res-id').val();
|
||||
if (!resourceId) return;
|
||||
|
||||
try {
|
||||
app.messages.action(`Sending '${action}' desktop command to agent...`, $('#res-modal'), 'info');
|
||||
const res = await app.api.post(`directory-admin/resources/${resourceId}/driver-action`, {
|
||||
action: 'desktop_control',
|
||||
subAction: action,
|
||||
user: username
|
||||
});
|
||||
app.messages.action(`Desktop action '${action}' completed successfully!`, $('#res-modal'), 'success');
|
||||
} catch (err) {
|
||||
app.messages.action(err.message || 'Desktop action failed', $('#res-modal'), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
var ldapGroupsCache = null;
|
||||
async function loadLdapGroups() {
|
||||
@@ -1753,22 +1892,24 @@
|
||||
}
|
||||
|
||||
async function inheritParentSecret() {
|
||||
const childKey = $('#inherit-child-key').val().trim();
|
||||
let childKey = $('#inherit-child-key').val().trim();
|
||||
const inheritVal = $('#inherit-parent-select').val();
|
||||
const resourceId = $('#res-id').val();
|
||||
|
||||
if (!childKey) {
|
||||
app.messages.action('Please enter a child secret key name (e.g. DB_HOST).', $('#secrets-tab-container'), 'warning');
|
||||
return;
|
||||
}
|
||||
if (!SECRET_KEY_REGEX.test(childKey)) {
|
||||
app.messages.action('Invalid child key name. Only letters, numbers, and underscores allowed.', $('#secrets-tab-container'), 'danger');
|
||||
return;
|
||||
}
|
||||
if (!inheritVal) {
|
||||
app.messages.action('Select a parent secret to inherit from.', $('#secrets-tab-container'), 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!childKey) {
|
||||
const parts = inheritVal.split(':');
|
||||
childKey = parts[parts.length - 1];
|
||||
}
|
||||
|
||||
if (!SECRET_KEY_REGEX.test(childKey)) {
|
||||
app.messages.action('Invalid child key name. Only letters, numbers, and underscores allowed.', $('#secrets-tab-container'), 'danger');
|
||||
return;
|
||||
}
|
||||
if (!resourceId) return;
|
||||
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user