This commit is contained in:
2026-06-25 14:43:13 -05:00
parent 6f77fac58e
commit aaa6a9ee26
25 changed files with 1542 additions and 67 deletions

View File

@@ -0,0 +1,185 @@
import { db, id, json, now, parseJson, visibleClause } from '../db.js';
import { hostGroupSchema } from '../forms/schemas.js';
import { camelHostGroup } from './mappers.js';
import { filterVisibleHostIds } from './hostModel.js';
function scopedGroupId(payload) {
return payload.visibility === 'group' ? payload.groupId || null : null;
}
function normalizeHostGroup(body) {
const parsed = hostGroupSchema.parse(body || {});
return {
...parsed,
groupId: scopedGroupId(parsed),
rules: parsed.mode === 'dynamic' ? parsed.rules : [],
hostIds: parsed.mode === 'manual' ? parsed.hostIds : []
};
}
export function listHostGroups(userId) {
return db.prepare(`
SELECT hg.*, g.name AS group_name,
COALESCE((SELECT COUNT(*) FROM host_group_members hgm WHERE hgm.host_group_id = hg.id), 0) AS member_count,
COALESCE((SELECT json_group_array(host_id) FROM host_group_members hgm WHERE hgm.host_group_id = hg.id), '[]') AS host_ids
FROM host_groups hg
LEFT JOIN groups g ON g.id = hg.group_id
WHERE ${visibleClause('hg')}
ORDER BY hg.name
`).all(userId, userId).map(camelHostGroup);
}
export function getVisibleHostGroup(groupId, userId) {
const row = db.prepare(`
SELECT hg.*, g.name AS group_name,
COALESCE((SELECT COUNT(*) FROM host_group_members hgm WHERE hgm.host_group_id = hg.id), 0) AS member_count,
COALESCE((SELECT json_group_array(host_id) FROM host_group_members hgm WHERE hgm.host_group_id = hg.id), '[]') AS host_ids
FROM host_groups hg
LEFT JOIN groups g ON g.id = hg.group_id
WHERE hg.id = ? AND ${visibleClause('hg')}
`).get(groupId, userId, userId);
return row ? camelHostGroup(row) : null;
}
export function createHostGroup(body, userId) {
const payload = normalizeHostGroup(body);
if (payload.mode === 'dynamic' && !payload.rules.length) throw new Error('Dynamic host groups require at least one rule.');
const groupId = id('hgrp');
db.prepare(`
INSERT INTO host_groups (
id, name, description, mode, match_mode, rules, visibility, owner_user_id,
group_id, created_by, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
groupId,
payload.name,
payload.description,
payload.mode,
payload.matchMode,
json(payload.rules),
payload.visibility,
userId,
payload.groupId,
userId,
now(),
now()
);
if (payload.mode === 'manual') replaceHostGroupMembers(groupId, filterVisibleHostIds(payload.hostIds, userId), 'manual');
else syncHostGroup(groupId);
return getVisibleHostGroup(groupId, userId);
}
export function updateHostGroup(groupId, body, userId) {
const existing = db.prepare(`SELECT * FROM host_groups WHERE id = ? AND ${visibleClause()}`).get(groupId, userId, userId);
if (!existing) return null;
const payload = normalizeHostGroup({
name: existing.name,
description: existing.description || '',
mode: existing.mode,
matchMode: existing.match_mode,
rules: parseJson(existing.rules, []),
visibility: existing.visibility,
groupId: existing.group_id,
...body
});
if (payload.mode === 'dynamic' && !payload.rules.length) throw new Error('Dynamic host groups require at least one rule.');
db.prepare(`
UPDATE host_groups
SET name = ?, description = ?, mode = ?, match_mode = ?, rules = ?,
visibility = ?, group_id = ?, updated_at = ?
WHERE id = ?
`).run(
payload.name,
payload.description,
payload.mode,
payload.matchMode,
json(payload.rules),
payload.visibility,
payload.groupId,
now(),
groupId
);
if (payload.mode === 'manual') replaceHostGroupMembers(groupId, filterVisibleHostIds(payload.hostIds, userId), 'manual');
else syncHostGroup(groupId);
return getVisibleHostGroup(groupId, userId);
}
export function deleteHostGroup(groupId, userId) {
db.prepare(`DELETE FROM host_groups WHERE id = ? AND ${visibleClause()}`).run(groupId, userId, userId);
}
export function filterVisibleHostGroupIds(groupIds = [], userId) {
if (!groupIds.length) return [];
const visible = new Set(
db.prepare(`SELECT id FROM host_groups WHERE ${visibleClause()}`).all(userId, userId).map((row) => row.id)
);
return groupIds.filter((groupId) => visible.has(groupId));
}
export function syncHostGroup(groupId) {
const group = db.prepare('SELECT * FROM host_groups WHERE id = ?').get(groupId);
if (!group || group.mode !== 'dynamic') return { groupId, matched: 0, skipped: true };
const ownerId = group.owner_user_id || group.created_by;
const hosts = ownerId
? db.prepare(`SELECT * FROM hosts WHERE ${visibleClause()} ORDER BY name`).all(ownerId, ownerId)
: db.prepare("SELECT * FROM hosts WHERE visibility = 'shared' ORDER BY name").all();
const rules = parseJson(group.rules, []);
const matched = hosts.filter((host) => hostMatchesRules(host, rules, group.match_mode)).map((host) => host.id);
replaceHostGroupMembers(groupId, matched, 'rule');
db.prepare('UPDATE host_groups SET last_synced_at = ?, updated_at = ? WHERE id = ?').run(now(), now(), groupId);
return { groupId, matched: matched.length, skipped: false };
}
export function syncDynamicHostGroups() {
const groups = db.prepare("SELECT id FROM host_groups WHERE mode = 'dynamic'").all();
const summary = { evaluated: 0, synced: 0, matched: 0, errors: 0 };
for (const group of groups) {
summary.evaluated += 1;
try {
const result = syncHostGroup(group.id);
if (!result.skipped) {
summary.synced += 1;
summary.matched += result.matched;
}
} catch {
summary.errors += 1;
}
}
return summary;
}
function replaceHostGroupMembers(groupId, hostIds, source) {
db.prepare('DELETE FROM host_group_members WHERE host_group_id = ?').run(groupId);
const stmt = db.prepare(`
INSERT OR IGNORE INTO host_group_members (host_group_id, host_id, source, created_at)
VALUES (?, ?, ?, ?)
`);
for (const hostId of [...new Set(hostIds)]) stmt.run(groupId, hostId, source, now());
}
export function hostMatchesRules(host, rules = [], matchMode = 'all') {
if (!rules.length) return false;
const results = rules.map((rule) => hostMatchesRule(host, rule));
return matchMode === 'any' ? results.some(Boolean) : results.every(Boolean);
}
function hostMatchesRule(host, rule) {
const values = hostRuleValues(host, rule.field);
const needle = String(rule.value || '').toLowerCase();
return values.some((value) => compareRuleValue(String(value || '').toLowerCase(), needle, rule.operator));
}
function hostRuleValues(host, field) {
if (field === 'tags') return parseJson(host.tags, []);
if (field === 'sourceType') return [host.source_type || 'manual'];
if (field === 'osFamily') return [host.os_family || ''];
return [host[field] || ''];
}
function compareRuleValue(value, needle, operator = 'contains') {
if (operator === 'equals') return value === needle;
if (operator === 'startsWith') return value.startsWith(needle);
if (operator === 'endsWith') return value.endsWith(needle);
return value.includes(needle);
}

View File

@@ -3,10 +3,11 @@ import { camelHost } from './mappers.js';
export function listHosts(userId) {
return db.prepare(`
SELECT h.*, c.name AS credential_name, g.name AS group_name
SELECT h.*, c.name AS credential_name, g.name AS group_name, vc.name AS source_connection_name
FROM hosts h
LEFT JOIN credentials c ON c.id = h.credential_id
LEFT JOIN groups g ON g.id = h.group_id
LEFT JOIN vcenter_connections vc ON vc.id = h.source_connection_id
WHERE ${visibleClause('h')}
ORDER BY h.name
`).all(userId, userId).map(camelHost);
@@ -20,8 +21,9 @@ export function createHost(payload, userId) {
const hostId = id('hst');
db.prepare(`
INSERT INTO hosts (id, name, fqdn, address, os_family, transport, port, credential_id, tags, notes,
visibility, owner_user_id, group_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
visibility, owner_user_id, group_id, source_type, source_connection_id, source_ref,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
hostId,
payload.name,
@@ -36,6 +38,9 @@ export function createHost(payload, userId) {
payload.visibility || 'shared',
userId,
scopedGroupId(payload),
payload.sourceType || 'manual',
payload.sourceConnectionId || null,
payload.sourceRef || '',
now(),
now()
);
@@ -69,7 +74,7 @@ export function updateHost(hostId, payload, userId) {
db.prepare(`
UPDATE hosts
SET name = ?, fqdn = ?, address = ?, os_family = ?, transport = ?, port = ?, credential_id = ?,
tags = ?, notes = ?, visibility = ?, group_id = ?, updated_at = ?
tags = ?, notes = ?, visibility = ?, group_id = ?, source_type = ?, source_connection_id = ?, source_ref = ?, updated_at = ?
WHERE id = ?
`).run(
payload.name || existing.name,
@@ -83,6 +88,9 @@ export function updateHost(hostId, payload, userId) {
payload.notes ?? existing.notes,
visibility,
visibility === 'group' ? (payload.groupId ?? existing.group_id) : null,
payload.sourceType ?? existing.source_type ?? 'manual',
payload.sourceConnectionId ?? existing.source_connection_id,
payload.sourceRef ?? existing.source_ref ?? '',
now(),
hostId
);

View File

@@ -37,6 +37,53 @@ export function camelHost(row) {
ownerUserId: row.owner_user_id || null,
groupId: row.group_id || null,
groupName: row.group_name || null,
sourceType: row.source_type || 'manual',
sourceConnectionId: row.source_connection_id || null,
sourceConnectionName: row.source_connection_name || null,
sourceRef: row.source_ref || '',
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
export function camelVCenterConnection(row) {
return {
id: row.id,
name: row.name,
baseUrl: row.base_url,
username: row.username,
hasPassword: Boolean(row.secret_cipher),
apiMode: row.api_mode || 'auto',
requestTimeoutMs: row.request_timeout_ms || 20000,
allowUntrustedTls: Boolean(row.allow_untrusted_tls),
enabled: Boolean(row.enabled),
lastTestStatus: row.last_test_status || '',
lastTestMessage: row.last_test_message || '',
lastTestAt: row.last_test_at || '',
visibility: row.visibility || 'shared',
ownerUserId: row.owner_user_id || null,
groupId: row.group_id || null,
groupName: row.group_name || null,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
export function camelHostGroup(row) {
return {
id: row.id,
name: row.name,
description: row.description || '',
mode: row.mode || 'manual',
matchMode: row.match_mode || 'all',
rules: parseJson(row.rules, []),
hostIds: parseJson(row.host_ids, []),
memberCount: row.member_count || 0,
visibility: row.visibility || 'shared',
ownerUserId: row.owner_user_id || null,
groupId: row.group_id || null,
groupName: row.group_name || null,
lastSyncedAt: row.last_synced_at || '',
createdAt: row.created_at,
updatedAt: row.updated_at
};
@@ -142,6 +189,7 @@ export function camelRunPlan(row) {
groupName: row.group_name || null,
parallel: Boolean(row.parallel),
hostCount: row.host_count || 0,
hostGroupCount: row.host_group_count || 0,
createdAt: row.created_at,
updatedAt: row.updated_at
};

View File

@@ -2,11 +2,20 @@ import { db, id, now, visibleClause } from '../db.js';
import { normalizeRunPlanPayload } from '../forms/schemas.js';
import { camelRunPlan } from './mappers.js';
import { filterVisibleHostIds } from './hostModel.js';
import { filterVisibleHostGroupIds } from './hostGroupModel.js';
export function listRunPlans(userId) {
return db.prepare(`
SELECT r.*, s.name AS script_name, g.name AS group_name,
(SELECT COUNT(*) FROM runplan_hosts rh WHERE rh.runplan_id = r.id) AS host_count
(SELECT COUNT(DISTINCT host_id) FROM (
SELECT rh.host_id AS host_id FROM runplan_hosts rh WHERE rh.runplan_id = r.id
UNION
SELECT hgm.host_id AS host_id
FROM runplan_host_groups rhg
JOIN host_group_members hgm ON hgm.host_group_id = rhg.host_group_id
WHERE rhg.runplan_id = r.id
)) AS host_count,
(SELECT COUNT(*) FROM runplan_host_groups rhg WHERE rhg.runplan_id = r.id) AS host_group_count
FROM runplans r
JOIN scripts s ON s.id = r.script_id
LEFT JOIN groups g ON g.id = r.group_id
@@ -23,13 +32,22 @@ export function createRunPlan(body, userId) {
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(runplanId, payload.name, payload.description, payload.scriptId, payload.visibility, userId, payload.groupId, payload.parallel ? 1 : 0, now(), now());
replaceRunPlanHosts(runplanId, filterVisibleHostIds(payload.hostIds, userId));
replaceRunPlanHostGroups(runplanId, filterVisibleHostGroupIds(payload.hostGroupIds, userId));
return loadRunPlan(runplanId);
}
export function loadRunPlan(runplanId) {
const runplan = db.prepare(`
SELECT r.*, s.name AS script_name, g.name AS group_name,
(SELECT COUNT(*) FROM runplan_hosts rh WHERE rh.runplan_id = r.id) AS host_count
(SELECT COUNT(DISTINCT host_id) FROM (
SELECT rh.host_id AS host_id FROM runplan_hosts rh WHERE rh.runplan_id = r.id
UNION
SELECT hgm.host_id AS host_id
FROM runplan_host_groups rhg
JOIN host_group_members hgm ON hgm.host_group_id = rhg.host_group_id
WHERE rhg.runplan_id = r.id
)) AS host_count,
(SELECT COUNT(*) FROM runplan_host_groups rhg WHERE rhg.runplan_id = r.id) AS host_group_count
FROM runplans r
JOIN scripts s ON s.id = r.script_id
LEFT JOIN groups g ON g.id = r.group_id
@@ -37,7 +55,8 @@ export function loadRunPlan(runplanId) {
`).get(runplanId);
if (!runplan) return null;
const hostIds = db.prepare('SELECT host_id FROM runplan_hosts WHERE runplan_id = ? ORDER BY host_id').all(runplanId).map((row) => row.host_id);
return { ...camelRunPlan(runplan), hostIds };
const hostGroupIds = db.prepare('SELECT host_group_id FROM runplan_host_groups WHERE runplan_id = ? ORDER BY host_group_id').all(runplanId).map((row) => row.host_group_id);
return { ...camelRunPlan(runplan), hostIds, hostGroupIds };
}
export function loadVisibleRunPlan(runplanId, userId) {
@@ -48,12 +67,13 @@ export function loadVisibleRunPlan(runplanId, userId) {
export function updateRunPlan(runplanId, body, userId) {
const existing = db.prepare(`SELECT * FROM runplans WHERE id = ? AND ${visibleClause()}`).get(runplanId, userId, userId);
if (!existing) return null;
const payload = normalizeRunPlanPayload({ ...existing, ...body, hostIds: body.hostIds || undefined });
const payload = normalizeRunPlanPayload({ ...existing, ...body, hostIds: body.hostIds || undefined, hostGroupIds: body.hostGroupIds || undefined });
db.prepare(`
UPDATE runplans SET name = ?, description = ?, script_id = ?, visibility = ?, group_id = ?, parallel = ?, updated_at = ?
WHERE id = ?
`).run(payload.name, payload.description, payload.scriptId, payload.visibility, payload.groupId, payload.parallel ? 1 : 0, now(), runplanId);
if (body.hostIds) replaceRunPlanHosts(runplanId, filterVisibleHostIds(payload.hostIds, userId));
if (body.hostGroupIds) replaceRunPlanHostGroups(runplanId, filterVisibleHostGroupIds(payload.hostGroupIds, userId));
return loadRunPlan(runplanId);
}
@@ -66,3 +86,9 @@ function replaceRunPlanHosts(runplanId, hostIds) {
const stmt = db.prepare('INSERT OR IGNORE INTO runplan_hosts (runplan_id, host_id) VALUES (?, ?)');
for (const hostId of hostIds) stmt.run(runplanId, hostId);
}
function replaceRunPlanHostGroups(runplanId, hostGroupIds) {
db.prepare('DELETE FROM runplan_host_groups WHERE runplan_id = ?').run(runplanId);
const stmt = db.prepare('INSERT OR IGNORE INTO runplan_host_groups (runplan_id, host_group_id) VALUES (?, ?)');
for (const groupId of hostGroupIds) stmt.run(runplanId, groupId);
}

View File

@@ -0,0 +1,135 @@
import { db, id, now, visibleClause } from '../db.js';
import { vcenterConnectionSchema } from '../forms/schemas.js';
import { decryptSecret, encryptSecret } from '../services/cryptoStore.js';
import { camelVCenterConnection } from './mappers.js';
function normalizeConnection(body, existing = {}) {
const parsed = vcenterConnectionSchema.parse({
...existing,
...body,
password: body.password || ''
});
return {
...parsed,
baseUrl: String(parsed.baseUrl || '').trim().replace(/\/+$/, ''),
groupId: parsed.visibility === 'group' ? parsed.groupId || null : null
};
}
export function listVCenterConnections(userId) {
return db.prepare(`
SELECT c.*, g.name AS group_name
FROM vcenter_connections c
LEFT JOIN groups g ON g.id = c.group_id
WHERE ${visibleClause('c')}
ORDER BY c.name
`).all(userId, userId).map(camelVCenterConnection);
}
export function getVCenterConnection(connectionId, userId, includeSecret = false) {
if (!connectionId) return null;
const row = db.prepare(`
SELECT c.*, g.name AS group_name
FROM vcenter_connections c
LEFT JOIN groups g ON g.id = c.group_id
WHERE c.id = ? AND ${visibleClause('c')}
`).get(connectionId, userId, userId);
if (!row) return null;
return includeSecret ? { ...row, password: decryptSecret(row) } : camelVCenterConnection(row);
}
export function getVCenterConnectionSystem(connectionId, includeSecret = false) {
if (!connectionId) return null;
const row = db.prepare('SELECT * FROM vcenter_connections WHERE id = ?').get(connectionId);
if (!row) return null;
return includeSecret ? { ...row, password: decryptSecret(row) } : camelVCenterConnection(row);
}
export function createVCenterConnection(body, userId) {
const payload = normalizeConnection(body);
const encrypted = encryptSecret(payload.password);
const connectionId = id('vc');
db.prepare(`
INSERT INTO vcenter_connections (
id, name, base_url, username, secret_cipher, secret_iv, secret_tag,
api_mode, request_timeout_ms, allow_untrusted_tls, enabled,
visibility, owner_user_id, group_id, created_by, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
connectionId,
payload.name,
payload.baseUrl,
payload.username,
encrypted.cipher,
encrypted.iv,
encrypted.tag,
payload.apiMode,
payload.requestTimeoutMs,
payload.allowUntrustedTls ? 1 : 0,
payload.enabled ? 1 : 0,
payload.visibility,
userId,
payload.groupId,
userId,
now(),
now()
);
return getVCenterConnection(connectionId, userId);
}
export function updateVCenterConnection(connectionId, body, userId) {
const existing = getVCenterConnection(connectionId, userId, true);
if (!existing) return null;
const payload = normalizeConnection(body, {
name: existing.name,
baseUrl: existing.base_url,
username: existing.username,
apiMode: existing.api_mode,
requestTimeoutMs: existing.request_timeout_ms,
allowUntrustedTls: Boolean(existing.allow_untrusted_tls),
enabled: Boolean(existing.enabled),
visibility: existing.visibility,
groupId: existing.group_id
});
const encrypted = payload.password ? encryptSecret(payload.password) : {
cipher: existing.secret_cipher,
iv: existing.secret_iv,
tag: existing.secret_tag
};
db.prepare(`
UPDATE vcenter_connections
SET name = ?, base_url = ?, username = ?, secret_cipher = ?, secret_iv = ?, secret_tag = ?,
api_mode = ?, request_timeout_ms = ?, allow_untrusted_tls = ?, enabled = ?,
visibility = ?, group_id = ?, updated_at = ?
WHERE id = ?
`).run(
payload.name,
payload.baseUrl,
payload.username,
encrypted.cipher,
encrypted.iv,
encrypted.tag,
payload.apiMode,
payload.requestTimeoutMs,
payload.allowUntrustedTls ? 1 : 0,
payload.enabled ? 1 : 0,
payload.visibility,
payload.groupId,
now(),
connectionId
);
return getVCenterConnection(connectionId, userId);
}
export function deleteVCenterConnection(connectionId, userId) {
db.prepare(`DELETE FROM vcenter_connections WHERE id = ? AND ${visibleClause()}`).run(connectionId, userId, userId);
}
export function recordVCenterConnectionTest(connectionId, status, message) {
db.prepare(`
UPDATE vcenter_connections
SET last_test_status = ?, last_test_message = ?, last_test_at = ?, updated_at = ?
WHERE id = ?
`).run(status, message || '', now(), now(), connectionId);
}