This commit is contained in:
NuklearRabbit
2026-07-27 23:15:35 +02:00
parent 03c2608a35
commit f990c26014
18 changed files with 2439 additions and 938 deletions
+58 -12
View File
@@ -4,10 +4,10 @@ const fs = require('node:fs/promises');
const path = require('node:path');
const crypto = require('node:crypto');
const { safeStorage } = require('electron');
const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePaths } = require('../shared/validation.cjs');
const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePath, assertRepositoryRelativePaths } = require('../shared/validation.cjs');
const DEFAULT_CONFIG = {
schemaVersion: 8,
schemaVersion: 9,
setupComplete: false,
appearance: 'dark',
gitea: { baseUrl: '', user: null, encryptedToken: null },
@@ -76,7 +76,28 @@ class ConfigStore {
: iconFilePath ? 'upload' : iconUrl && !/itworx\.tech\/assets\/itworx-icon\.png/i.test(iconUrl) ? 'url' : 'builtin';
const visibleName = String(profile.containerName || profile.remoteFolder || '').trim();
const internalService = String(profile.composeService || profile.remoteFolder || 'app').trim().toLowerCase().replace(/[^a-z0-9._-]/g, '-') || 'app';
return { ...profile, composeService: internalService, containerName: visibleName || internalService, iconMode };
const requestedDeploymentMode = String(profile.deploymentMode || '').trim();
const deploymentMode = ['push-bundle', 'server-git', 'monitor-only'].includes(requestedDeploymentMode)
? requestedDeploymentMode
: 'server-git';
const composeFiles = uniqueStrings(profile.composeFiles || [profile.composeFile || 'docker-compose.yml']);
const composeServices = uniqueStrings(profile.composeServices || [internalService]).map((value) => value.toLowerCase());
return {
...profile,
deploymentMode,
composeFile: composeFiles[0] || 'docker-compose.yml',
composeFiles: composeFiles.length ? composeFiles : ['docker-compose.yml'],
composeServices,
composeProject: String(profile.composeProject || '').trim(),
composeWorkingDir: String(profile.composeWorkingDir || '').trim(),
composeService: internalService,
containerName: visibleName || internalService,
iconMode,
manageDockerMan: profile.manageDockerMan === true,
forceRecreate: profile.forceRecreate === true,
removeOrphans: profile.removeOrphans === true,
workloadIdentity: profile.workloadIdentity && typeof profile.workloadIdentity === 'object' ? structuredClone(profile.workloadIdentity) : null
};
})]))
: {},
deploymentStates: source.deploymentStates && typeof source.deploymentStates === 'object' ? source.deploymentStates : {},
@@ -223,10 +244,13 @@ class ConfigStore {
async deleteServer(serverId) {
this.data.servers = this.data.servers.filter((item) => item.id !== serverId);
const removedProfileIds = new Set();
for (const [key, profiles] of Object.entries(this.data.deploymentProfiles)) {
for (const profile of profiles) if (profile.serverId === serverId) removedProfileIds.add(profile.id);
this.data.deploymentProfiles[key] = profiles.filter((profile) => profile.serverId !== serverId);
if (!this.data.deploymentProfiles[key].length) delete this.data.deploymentProfiles[key];
}
for (const profileId of removedProfileIds) delete this.data.deploymentStates[profileId];
await this.save();
}
@@ -357,21 +381,39 @@ class ConfigStore {
inputs: {}
};
if (provider === 'ssh-unraid') {
const remoteFolder = String(profile.remoteFolder || '').trim();
if (!remoteFolder || !/^[a-zA-Z0-9._-]+$/.test(remoteFolder)) throw new Error('Remote folder must contain only letters, numbers, dots, underscores and dashes.');
const remoteFolder = assertRepositoryRelativePath(String(profile.remoteFolder || '').trim());
if (!remoteFolder || remoteFolder === '.' || remoteFolder.split('/').some((part) => !part || part === '.')) throw new Error('Remote folder must be a safe path relative to the configured server base path.');
const preservePaths = assertRepositoryRelativePaths(uniqueStrings(profile.preservePaths || ['.env', 'appdata', 'data', 'logs', 'config', 'compose.override.yml']));
const composeFiles = assertRepositoryRelativePaths(uniqueStrings(profile.composeFiles || [profile.composeFile || 'docker-compose.yml']));
if (!composeFiles.length && profile.generatedCompose !== true) throw new Error('Select at least one Compose file.');
const composeService = (() => {
const value = String(profile.composeService || profile.composeServices?.[0] || remoteFolder.split('/').pop()).trim().toLowerCase();
if (!/^[a-z0-9._-]+$/.test(value)) throw new Error('Compose service must be lowercase and contain only letters, numbers, dots, underscores and dashes.');
return value;
})();
const composeServices = uniqueStrings(profile.composeServices || [composeService]).map((value) => {
const normalized = String(value).trim().toLowerCase();
if (!/^[a-z0-9._-]+$/.test(normalized)) throw new Error('Compose services must be lowercase and contain only letters, numbers, dots, underscores and dashes.');
return normalized;
});
const composeProject = String(profile.composeProject || '').trim();
if (composeProject && !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(composeProject)) throw new Error('Compose project name contains unsupported characters.');
const composeWorkingDir = String(profile.composeWorkingDir || '').trim();
if (composeWorkingDir && (!composeWorkingDir.startsWith('/') || /[\r\n\0]/.test(composeWorkingDir))) throw new Error('Compose working directory must be an absolute safe Unix path.');
const deploymentMode = ['push-bundle', 'server-git', 'monitor-only'].includes(profile.deploymentMode) ? profile.deploymentMode : 'push-bundle';
return {
...common,
serverId: String(profile.serverId || '').trim(),
remoteFolder,
composeFile: String(profile.composeFile || 'docker-compose.yml').trim(),
composeService: (() => {
const value = String(profile.composeService || remoteFolder).trim().toLowerCase();
if (!/^[a-z0-9._-]+$/.test(value)) throw new Error('Compose service must be lowercase and contain only letters, numbers, dots, underscores and dashes.');
return value;
})(),
deploymentMode,
composeFile: composeFiles[0] || 'docker-compose.yml',
composeFiles: composeFiles.length ? composeFiles : ['docker-compose.yml'],
composeProject,
composeWorkingDir,
composeService,
composeServices,
containerName: (() => {
const value = String(profile.containerName || remoteFolder).trim();
const value = String(profile.containerName || remoteFolder.split('/').pop()).trim();
if (!/^[A-Za-z0-9._-]+$/.test(value)) throw new Error('Container name must contain only letters, numbers, dots, underscores and dashes.');
return value;
})(),
@@ -390,6 +432,10 @@ class ConfigStore {
generatedCompose: profile.generatedCompose === true,
adoptedFromServer: profile.adoptedFromServer === true,
serverSourceOfTruth: profile.serverSourceOfTruth === true,
manageDockerMan: profile.manageDockerMan === true,
forceRecreate: profile.forceRecreate === true,
removeOrphans: profile.removeOrphans === true,
workloadIdentity: profile.workloadIdentity && typeof profile.workloadIdentity === 'object' ? structuredClone(profile.workloadIdentity) : null,
detectedAt: profile.detectedAt || null,
provenance: profile.provenance && typeof profile.provenance === 'object' ? structuredClone(profile.provenance) : {},
detectedMetadata: profile.detectedMetadata && typeof profile.detectedMetadata === 'object' ? structuredClone(profile.detectedMetadata) : {},
+18
View File
@@ -450,6 +450,7 @@ function registerIpc({
register(
"server:save",
async ({ server, password = "", passphrase = "" }) => {
await ssh.validateServerConfiguration(server, { password, passphrase });
const saved = await store.saveServer(server, { password, passphrase });
await diagnostics.info("server.saved", {
serverId: saved.id,
@@ -1175,6 +1176,17 @@ function registerIpc({
},
);
register("deployment:health", ({ url }) => deployments.checkHealth(url));
register("deployment:link-server-workload", async ({ repository, serverId, workloadId, deploymentMode = "push-bundle", remoteFolder = "" }) => {
const current = await resolveRepository(repository);
const result = await unraid.linkServerWorkload({
repository: current,
serverId,
workloadId,
deploymentMode,
remoteFolder,
});
return { ...result, state: store.getPublicState() };
});
register("deployment:discover-server-workloads", async () => {
const repositoryList = await repositories.refresh();
const remoteRepositories = repositoryList.filter(
@@ -1189,10 +1201,16 @@ function registerIpc({
} catch (error) {
results.push({
serverId: server.id,
serverName: server.name,
detected: 0,
adopted: 0,
verified: 0,
linked: 0,
unmatched: 0,
needsReview: 0,
capabilities: {},
warnings: [],
workloads: [],
error: error.message,
});
}
+403
View File
@@ -0,0 +1,403 @@
'use strict';
const crypto = require('node:crypto');
const path = require('node:path').posix;
const { normalizeRemoteUrl } = require('../shared/repository-match.cjs');
function decodeBase64(value) {
try { return Buffer.from(String(value || ''), 'base64').toString('utf8'); }
catch { return ''; }
}
function remoteIdentity(value) {
const normalized = normalizeRemoteUrl(value);
return normalized ? `${normalized.host}/${normalized.path}` : '';
}
function normalizedName(value) {
return String(value || '').toLowerCase().replace(/\.git$/i, '').replace(/[^a-z0-9]/g, '');
}
function safeJson(value, fallback) {
try { return JSON.parse(value); }
catch { return fallback; }
}
function sanitizeLegacyContainer(container) {
const labels = container?.Config?.Labels || {};
return {
id: container?.Id || '',
name: String(container?.Name || '').replace(/^\//, ''),
image: container?.Config?.Image || '',
imageId: container?.Image || '',
running: container?.State?.Running === true,
status: container?.State?.Status || '',
health: container?.State?.Health?.Status || null,
labels: {
'com.docker.compose.project': labels['com.docker.compose.project'] || '',
'com.docker.compose.project.working_dir': labels['com.docker.compose.project.working_dir'] || '',
'com.docker.compose.project.config_files': labels['com.docker.compose.project.config_files'] || '',
'com.docker.compose.service': labels['com.docker.compose.service'] || '',
'org.opencontainers.image.source': labels['org.opencontainers.image.source'] || '',
'org.opencontainers.image.revision': labels['org.opencontainers.image.revision'] || '',
'tech.itworx.forgeflow.repository': labels['tech.itworx.forgeflow.repository'] || '',
'tech.itworx.forgeflow.commit': labels['tech.itworx.forgeflow.commit'] || '',
'tech.itworx.forgeflow.branch': labels['tech.itworx.forgeflow.branch'] || '',
'net.unraid.docker.webui': labels['net.unraid.docker.webui'] || '',
'net.unraid.docker.icon': labels['net.unraid.docker.icon'] || '',
'net.unraid.docker.shell': labels['net.unraid.docker.shell'] || '',
'net.unraid.docker.managed': labels['net.unraid.docker.managed'] || '',
},
ports: container?.NetworkSettings?.Ports || {},
mounts: Array.isArray(container?.Mounts) ? container.Mounts : [],
networks: container?.NetworkSettings?.Networks || {},
restartPolicy: container?.HostConfig?.RestartPolicy?.Name || '',
};
}
function parseServerInventory(output) {
const marker = '__FORGEFLOW_INVENTORY__';
const index = String(output || '').lastIndexOf(marker);
if (index < 0) throw new Error('The server did not return a ForgeFlow workload inventory.');
const inventory = {
capabilities: {},
checkouts: [],
containers: [],
dockerMan: [],
warnings: [],
};
for (const line of String(output).slice(index + marker.length).trim().split(/\r?\n/)) {
if (!line) continue;
const [kind, ...parts] = line.split('\t');
if (kind === 'H') {
inventory.capabilities = {
docker: parts[0] === 'true',
compose: parts[1] === 'true',
git: parts[2] === 'true',
tar: parts[3] === 'true',
checksum: parts[4] === 'true',
baseWritable: parts[5] === 'true',
composeVersion: decodeBase64(parts[6]),
platform: decodeBase64(parts[7]),
};
} else if (kind === 'R' && parts.length >= 4) {
inventory.checkouts.push({
root: decodeBase64(parts[0]),
remote: decodeBase64(parts[1]),
liveSha: parts[2] || '',
branch: decodeBase64(parts[3]),
});
} else if (kind === 'C' && parts[0]) {
const parsed = safeJson(decodeBase64(parts[0]), null);
if (!parsed) continue;
if (Array.isArray(parsed)) {
if (parsed[0]) inventory.containers.push(sanitizeLegacyContainer(parsed[0]));
} else if (parsed.Config || parsed.State) inventory.containers.push(sanitizeLegacyContainer(parsed));
else inventory.containers.push({
...parsed,
name: String(parsed.name || '').replace(/^\//, ''),
labels: parsed.labels && typeof parsed.labels === 'object' ? parsed.labels : {},
mounts: Array.isArray(parsed.mounts) ? parsed.mounts : [],
ports: parsed.ports && typeof parsed.ports === 'object' ? parsed.ports : {},
networks: parsed.networks && typeof parsed.networks === 'object' ? parsed.networks : {},
});
} else if (kind === 'D' && parts[0]) {
inventory.dockerMan.push({
name: decodeBase64(parts[0]),
templatePath: decodeBase64(parts[1]),
webUiUrl: decodeBase64(parts[2]),
iconUrl: decodeBase64(parts[3]),
shell: decodeBase64(parts[4]),
repository: decodeBase64(parts[5]),
network: decodeBase64(parts[6]),
});
} else if (kind === 'W') inventory.warnings.push(decodeBase64(parts[0]));
}
return inventory;
}
function configFilesFor(container) {
return String(container?.labels?.['com.docker.compose.project.config_files'] || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
function containerPorts(container) {
const ports = [];
for (const [containerKey, bindings] of Object.entries(container?.ports || {})) {
const [containerPortText, protocol = 'tcp'] = containerKey.split('/');
const containerPort = Number(containerPortText) || null;
if (Array.isArray(bindings) && bindings.length) {
for (const binding of bindings) ports.push({
hostIp: binding?.HostIp || '',
hostPort: Number(binding?.HostPort) || null,
containerPort,
protocol,
});
} else ports.push({ hostIp: '', hostPort: null, containerPort, protocol });
}
return ports;
}
function safeRelativeToBase(basePath, candidate) {
const base = String(basePath || '').replace(/\/+$/, '');
const value = String(candidate || '').replace(/\/+$/, '');
if (!base || !value || !value.startsWith(`${base}/`)) return '';
const relative = value.slice(base.length + 1).replace(/^\/+|\/+$/g, '');
if (!relative || relative.split('/').some((part) => !part || part === '.' || part === '..')) return '';
return relative;
}
function topLevelRelativeToBase(basePath, candidate) {
const relative = safeRelativeToBase(basePath, candidate);
return relative ? relative.split('/')[0] : '';
}
function workloadSelector(group) {
if (group.composeProject) return {
kind: 'compose',
composeProject: group.composeProject,
workingDir: group.workingDir || '',
configFiles: group.configFiles,
};
const dockerMan = group.dockerMan || null;
if (dockerMan?.templatePath) return {
kind: 'dockerman-container',
templatePath: dockerMan.templatePath,
containerName: group.containers[0]?.name || '',
};
return { kind: 'docker-container', containerName: group.containers[0]?.name || '' };
}
function stableWorkloadId(serverId, selector) {
return `workload-${crypto.createHash('sha256').update(`${serverId}:${JSON.stringify(selector)}`).digest('hex').slice(0, 24)}`;
}
function profileMatchesWorkload(profile, workload) {
if (!profile || profile.provider !== 'ssh-unraid' || profile.serverId !== workload.serverId) return false;
const identity = profile.workloadIdentity || {};
if (identity.workloadId && identity.workloadId === workload.workloadId) return true;
if (identity.selector && JSON.stringify(identity.selector) === JSON.stringify(workload.selector)) return true;
if (profile.composeProject && workload.compose?.project && profile.composeProject === workload.compose.project) {
if (!profile.composeWorkingDir || !workload.compose.workingDir || profile.composeWorkingDir === workload.compose.workingDir) return true;
}
return workload.containers.some((container) => container.name === profile.containerName);
}
function repositoryRemoteMap(repositories) {
const map = new Map();
for (const repository of repositories || []) {
for (const value of [repository.cloneUrl, repository.sshUrl, repository.htmlUrl, repository.preferredCloneUrl]) {
const id = remoteIdentity(value);
if (id) map.set(id, repository);
}
}
return map;
}
function candidateRepositories(workload, repositories, checkouts) {
const candidates = new Map();
const add = (repository, points, reason, exact = false) => {
if (!repository?.fullName) return;
const current = candidates.get(repository.fullName) || { repositoryFullName: repository.fullName, repositoryName: repository.name, score: 0, exact: false, reasons: [] };
current.score += points;
current.exact ||= exact;
if (reason && !current.reasons.includes(reason)) current.reasons.push(reason);
candidates.set(repository.fullName, current);
};
const remotes = repositoryRemoteMap(repositories);
const exactRemoteHints = new Set();
for (const container of workload.containers) {
const labels = container.labels || {};
for (const value of [labels['tech.itworx.forgeflow.repository'], labels['org.opencontainers.image.source']]) {
const id = remoteIdentity(value);
if (id) exactRemoteHints.add(id);
}
}
for (const checkout of checkouts || []) {
const root = String(checkout.root || '').replace(/\/+$/, '');
const matchesPath = root && (root === workload.compose.workingDir || workload.containers.some((container) => (container.mounts || []).some((mount) => {
const source = String(mount?.Source || '').replace(/\/+$/, '');
return source === root || source.startsWith(`${root}/`);
})));
if (matchesPath) {
const id = remoteIdentity(checkout.remote);
if (id) exactRemoteHints.add(id);
}
}
for (const id of exactRemoteHints) {
const repository = remotes.get(id);
if (repository) add(repository, 100, 'Exact repository provenance from container or server checkout', true);
}
const names = new Set([
workload.compose.project,
path.basename(workload.compose.workingDir || ''),
...workload.containers.map((container) => container.name),
...workload.containers.map((container) => String(container.image || '').split('/').pop()?.split(':')[0]),
].filter(Boolean).map(normalizedName));
for (const repository of repositories || []) {
const repoName = normalizedName(repository.name);
if (!repoName || !names.has(repoName)) continue;
add(repository, workload.compose.project && normalizedName(workload.compose.project) === repoName ? 35 : 20, 'Name similarity only; manual confirmation required');
}
return [...candidates.values()].sort((a, b) => b.score - a.score || a.repositoryFullName.localeCompare(b.repositoryFullName)).map((candidate) => ({
...candidate,
confidence: candidate.exact ? 'exact' : candidate.score >= 35 ? 'strong' : 'weak',
}));
}
function buildWorkloadInventory({ inventory, server, repositories = [], profiles = [] }) {
const dockerManByName = new Map((inventory.dockerMan || []).map((item) => [item.name, item]));
const groups = new Map();
for (const container of inventory.containers || []) {
const labels = container.labels || {};
const composeProject = String(labels['com.docker.compose.project'] || '').trim();
const workingDir = String(labels['com.docker.compose.project.working_dir'] || '').replace(/\/+$/, '');
const configFiles = configFilesFor(container);
const key = composeProject
? `compose:${composeProject}:${workingDir}:${configFiles.join('|')}`
: `container:${container.name}`;
const group = groups.get(key) || {
composeProject,
workingDir,
configFiles,
services: [],
containers: [],
dockerMan: null,
};
group.containers.push(container);
const service = String(labels['com.docker.compose.service'] || '').trim();
if (service && !group.services.includes(service)) group.services.push(service);
group.dockerMan ||= dockerManByName.get(container.name) || null;
groups.set(key, group);
}
const workloads = [];
for (const group of groups.values()) {
const selector = workloadSelector(group);
const workloadId = stableWorkloadId(server.id, selector);
const primary = group.containers.find((item) => item.running) || group.containers[0];
const ports = group.containers.flatMap(containerPorts);
const mounts = group.containers.flatMap((container) => container.mounts || []);
const remoteFolderCandidate = safeRelativeToBase(server.basePath, group.workingDir)
|| mounts.map((mount) => topLevelRelativeToBase(server.basePath, mount?.Source)).find(Boolean)
|| '';
const workload = {
workloadId,
serverId: server.id,
serverName: server.name,
kind: selector.kind,
selector,
displayName: group.composeProject || primary?.name || 'Unnamed workload',
compose: {
project: group.composeProject,
workingDir: group.workingDir,
configFiles: group.configFiles,
services: group.services,
},
containers: group.containers.map((container) => ({
id: container.id,
name: container.name,
image: container.image,
imageId: container.imageId,
running: container.running === true,
status: container.status || '',
health: container.health || null,
service: container.labels?.['com.docker.compose.service'] || '',
ports: containerPorts(container),
mounts: (container.mounts || []).map((mount) => ({
type: mount?.Type || '',
source: mount?.Source || '',
target: mount?.Destination || '',
readOnly: mount?.RW === false,
})),
networks: Object.keys(container.networks || {}),
restartPolicy: container.restartPolicy || '',
})),
dockerMan: group.dockerMan,
metadata: {
webUiUrl: primary?.labels?.['net.unraid.docker.webui'] || group.dockerMan?.webUiUrl || '',
iconUrl: primary?.labels?.['net.unraid.docker.icon'] || group.dockerMan?.iconUrl || '',
shell: primary?.labels?.['net.unraid.docker.shell'] || group.dockerMan?.shell || '/bin/sh',
sourceRepository: primary?.labels?.['tech.itworx.forgeflow.repository'] || primary?.labels?.['org.opencontainers.image.source'] || '',
liveRevision: primary?.labels?.['tech.itworx.forgeflow.commit'] || primary?.labels?.['org.opencontainers.image.revision'] || '',
branch: primary?.labels?.['tech.itworx.forgeflow.branch'] || '',
},
runtime: {
running: group.containers.some((container) => container.running === true),
allRunning: group.containers.every((container) => container.running === true),
health: group.containers.some((container) => container.health === 'unhealthy')
? 'unhealthy'
: group.containers.length && group.containers.every((container) => container.health === 'healthy')
? 'healthy'
: 'unverified',
ports,
},
remoteFolderCandidate,
observedAt: new Date().toISOString(),
};
const matchingCheckout = (inventory.checkouts || []).find((checkout) => {
const root = String(checkout.root || '').replace(/\/+$/, '');
if (!root) return false;
if (root === workload.compose.workingDir) return true;
return mounts.some((mount) => {
const source = String(mount?.Source || '').replace(/\/+$/, '');
return source === root || source.startsWith(`${root}/`);
});
});
if (matchingCheckout) {
workload.metadata.sourceRepository ||= matchingCheckout.remote || '';
workload.metadata.liveRevision ||= matchingCheckout.liveSha || '';
workload.metadata.branch ||= matchingCheckout.branch || '';
}
workload.candidates = candidateRepositories(workload, repositories, inventory.checkouts || []);
const linked = profiles.find((profile) => profileMatchesWorkload(profile, workload));
if (linked) {
workload.link = {
status: 'linked',
profileId: linked.id,
repositoryFullName: linked.repositoryFullName || linked._repositoryFullName || '',
source: linked.workloadIdentity?.linkSource || (linked.adoptedFromServer ? 'automatic' : 'manual'),
};
workload.status = 'linked';
} else if (workload.candidates.length === 1 && workload.candidates[0].exact) workload.status = 'exact-match';
else if (workload.candidates.length) workload.status = workload.candidates[1]?.score === workload.candidates[0]?.score ? 'ambiguous' : 'suggested';
else workload.status = 'unmatched';
workloads.push(workload);
}
workloads.sort((a, b) => Number(b.runtime.running) - Number(a.runtime.running) || a.displayName.localeCompare(b.displayName));
return workloads;
}
function inventoryContainerMatch(checkout, repository, container) {
const safe = container?.Config || container?.State ? sanitizeLegacyContainer(container) : container;
if (!safe?.running) return 0;
const labels = safe.labels || {};
const workingDir = String(labels['com.docker.compose.project.working_dir'] || '').replace(/\/$/, '');
const source = remoteIdentity(labels['org.opencontainers.image.source'] || labels['tech.itworx.forgeflow.repository'] || '');
const mounts = Array.isArray(safe.mounts) ? safe.mounts : [];
const root = String(checkout.root || '').replace(/\/$/, '');
const name = String(safe.name || '').replace(/^\//, '');
const project = String(labels['com.docker.compose.project'] || '');
const expectedNames = new Set([repository.name, root.split('/').pop()].filter(Boolean).map(normalizedName));
if (workingDir && workingDir === root) return 100;
if (mounts.some((mount) => {
const mountSource = String(mount.Source || '').replace(/\/$/, '');
return mountSource === root || mountSource.startsWith(`${root}/`);
})) return 90;
if (source && source === remoteIdentity(checkout.remote)) return 85;
if (expectedNames.has(normalizedName(project))) return 70;
if (expectedNames.has(normalizedName(name))) return 60;
return 0;
}
module.exports = {
parseServerInventory,
buildWorkloadInventory,
inventoryContainerMatch,
remoteIdentity,
stableWorkloadId,
profileMatchesWorkload,
sanitizeLegacyContainer,
safeRelativeToBase,
};
+194 -66
View File
@@ -1,11 +1,12 @@
'use strict';
const fs = require('node:fs/promises');
const fs = require('node:fs');
const fsp = require('node:fs/promises');
const crypto = require('node:crypto');
const path = require('node:path').posix;
function loadSshClient() {
try { return require('ssh2').Client; }
function loadSshModule() {
try { return require('ssh2'); }
catch {
const error = new Error('The ssh2 dependency is not installed. Run npm install before configuring SSH deployments.');
error.code = 'SSH2_NOT_INSTALLED';
@@ -13,6 +14,10 @@ function loadSshClient() {
}
}
function loadSshClient() {
return loadSshModule().Client;
}
function fingerprintKey(key) {
const buffer = Buffer.isBuffer(key) ? key : Buffer.from(key);
return `SHA256:${crypto.createHash('sha256').update(buffer).digest('base64').replace(/=+$/, '')}`;
@@ -22,12 +27,65 @@ function shellQuote(value) {
return `'${String(value ?? '').replace(/'/g, `'\\''`)}'`;
}
function parseCapabilityOutput(output) {
const marker = '__FORGEFLOW_SERVER_TEST__';
const index = String(output || '').lastIndexOf(marker);
if (index < 0) return { platform: String(output || '').trim(), docker: false, dockerReady: false, compose: false, git: false, tar: false, checksum: false };
const fields = {};
for (const line of String(output).slice(index + marker.length).trim().split(/\r?\n/)) {
const separator = line.indexOf('=');
if (separator > 0) fields[line.slice(0, separator)] = line.slice(separator + 1);
}
const decode = (value) => {
try { return value ? Buffer.from(value, 'base64').toString('utf8') : ''; }
catch { return ''; }
};
return {
platform: decode(fields.platform),
docker: fields.docker === 'true',
dockerReady: fields.dockerReady === 'true',
compose: fields.compose === 'true',
composeVersion: decode(fields.composeVersion),
git: fields.git === 'true',
tar: fields.tar === 'true',
checksum: fields.checksum === 'true',
baseWritable: fields.baseWritable === 'true',
};
}
class SshService {
constructor({ store, diagnostics }) {
this.store = store;
this.diagnostics = diagnostics;
}
async validateServerConfiguration(server, secrets = {}) {
if (server?.authType !== 'privateKey') return { valid: true, method: 'password' };
const privateKeyPath = String(server.privateKeyPath || '').trim();
if (!privateKeyPath) throw new Error('Select a private key file.');
const stat = await fsp.stat(privateKeyPath).catch(() => null);
if (!stat?.isFile()) {
const error = new Error(`The SSH private key file was not found: ${privateKeyPath}`);
error.code = 'SSH_PRIVATE_KEY_NOT_FOUND';
throw error;
}
const existing = server.id ? this.store.getServer(server.id) : null;
const sameKey = existing && String(existing.privateKeyPath || '') === privateKeyPath;
const storedPassphrase = sameKey ? this.store.getServerCredentials(existing.id).passphrase : '';
const passphrase = Object.prototype.hasOwnProperty.call(secrets, 'passphrase') && String(secrets.passphrase || '')
? String(secrets.passphrase)
: storedPassphrase;
const key = await fsp.readFile(privateKeyPath);
const parsed = loadSshModule().utils.parseKey(key, passphrase || undefined);
const errorResult = Array.isArray(parsed) ? parsed.find((item) => item instanceof Error) : parsed instanceof Error ? parsed : null;
if (errorResult) {
const error = new Error(`The selected file is not a usable SSH private key${passphrase ? ' with the supplied passphrase' : ''}: ${errorResult.message}`);
error.code = /encrypted|passphrase|decrypt/i.test(errorResult.message) ? 'SSH_PRIVATE_KEY_PASSPHRASE_INVALID' : 'SSH_PRIVATE_KEY_INVALID';
throw error;
}
return { valid: true, method: 'privateKey', encrypted: Boolean(passphrase), privateKeyPath };
}
async connectionOptions(server, { trustOnFirstUse = false } = {}) {
const credentials = this.store.getServerCredentials(server.id);
let observedFingerprint = null;
@@ -41,12 +99,16 @@ class SshService {
hostVerifier: (key) => {
observedFingerprint = fingerprintKey(key);
return trustOnFirstUse || Boolean(server.hostFingerprint && observedFingerprint === server.hostFingerprint);
}
},
};
if (server.authType === 'password') {
options.password = credentials.password;
} else {
options.privateKey = await fs.readFile(server.privateKeyPath);
if (server.authType === 'password') options.password = credentials.password;
else {
try { options.privateKey = await fsp.readFile(server.privateKeyPath); }
catch (error) {
const wrapped = new Error(`Could not read SSH private key ${server.privateKeyPath}: ${error.message}`);
wrapped.code = 'SSH_PRIVATE_KEY_READ_FAILED';
throw wrapped;
}
if (credentials.passphrase) options.passphrase = credentials.passphrase;
}
return { options, getObservedFingerprint: () => observedFingerprint };
@@ -70,24 +132,20 @@ class SshService {
client.once('ready', async () => {
try {
const data = await action(client, server, connection.getObservedFingerprint());
await this.diagnostics?.debug('ssh.connection.completed', {
serverId,
host: server.host,
durationMs: Date.now() - started
});
await this.diagnostics?.debug('ssh.connection.completed', { serverId, host: server.host, durationMs: Date.now() - started });
finish(resolve, data);
} catch (error) { finish(reject, error); }
});
client.once('error', async (error) => {
const wrapped = new Error(`SSH connection failed: ${error.message}`);
wrapped.code = error.code || 'SSH_CONNECTION_FAILED';
await this.diagnostics?.warning('ssh.connection.failed', {
serverId,
host: server.host,
durationMs: Date.now() - started,
code: wrapped.code,
message: wrapped.message
});
const observed = connection.getObservedFingerprint();
const mismatch = Boolean(server.hostFingerprint && observed && server.hostFingerprint !== observed);
const wrapped = new Error(mismatch
? `SSH host identity changed. Expected ${server.hostFingerprint}, but the server presented ${observed}.`
: `SSH connection failed: ${error.message}`);
wrapped.code = mismatch ? 'SSH_HOST_KEY_MISMATCH' : (error.code || 'SSH_CONNECTION_FAILED');
wrapped.expectedFingerprint = mismatch ? server.hostFingerprint : undefined;
wrapped.observedFingerprint = mismatch ? observed : undefined;
await this.diagnostics?.warning('ssh.connection.failed', { serverId, host: server.host, durationMs: Date.now() - started, code: wrapped.code, message: wrapped.message });
finish(reject, wrapped);
});
client.connect(connection.options);
@@ -96,96 +154,166 @@ class SshService {
execClient(client, command, { timeout = 15 * 60_000, maxOutput = 2 * 1024 * 1024 } = {}) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('The SSH command timed out.')), timeout);
let completed = false;
const timer = setTimeout(() => {
if (completed) return;
completed = true;
reject(new Error('The SSH command timed out.'));
}, timeout);
client.exec(command, (error, stream) => {
if (error) {
clearTimeout(timer);
completed = true;
reject(error);
return;
}
let stdout = '';
let stderr = '';
stream.on('data', (chunk) => { if (stdout.length < maxOutput) stdout += chunk.toString(); });
stream.stderr.on('data', (chunk) => { if (stderr.length < maxOutput) stderr += chunk.toString(); });
let stdoutBytes = 0;
let stderrBytes = 0;
let truncated = false;
const append = (target, chunk) => {
const text = chunk.toString();
const bytes = Buffer.byteLength(text);
if (target === 'stdout') {
if (stdoutBytes + bytes <= maxOutput) stdout += text;
else truncated = true;
stdoutBytes += bytes;
} else {
if (stderrBytes + bytes <= maxOutput) stderr += text;
else truncated = true;
stderrBytes += bytes;
}
};
stream.on('data', (chunk) => append('stdout', chunk));
stream.stderr.on('data', (chunk) => append('stderr', chunk));
stream.on('close', (code, signal) => {
if (completed) return;
completed = true;
clearTimeout(timer);
if (code !== 0) {
if (truncated) {
const failure = new Error(`Remote command output exceeded the ${maxOutput}-byte safety limit. ForgeFlow refused to use an incomplete result.`);
failure.code = 'SSH_OUTPUT_TRUNCATED';
failure.stdoutBytes = stdoutBytes;
failure.stderrBytes = stderrBytes;
reject(failure);
} else if (code !== 0) {
const failure = new Error(`Remote command failed with exit code ${code}: ${(stderr || stdout).trim().slice(-4000)}`);
failure.code = 'SSH_COMMAND_FAILED';
failure.exitCode = code;
failure.signal = signal;
reject(failure);
} else resolve({ stdout, stderr, exitCode: code });
} else resolve({ stdout, stderr, exitCode: code, truncated: false });
});
});
});
}
async uploadBuffer(serverId, remotePath, content, { mode = 0o600 } = {}) {
ensureUploadTarget(target) {
const normalized = String(target || '').replace(/\\/g, '/');
if (!normalized.startsWith('/') || normalized.includes('\0') || normalized.split('/').includes('..')) throw new Error('Remote upload path must be an absolute safe Unix path.');
return normalized;
}
async withSftp(serverId, remotePath, action) {
const server = this.store.getServer(serverId);
if (!server?.hostFingerprint) {
const error = new Error('Test and trust the SSH server fingerprint before uploading deployment assets.');
error.code = 'SSH_HOST_NOT_TRUSTED';
throw error;
}
const target = String(remotePath || '').replace(/\\/g, '/');
if (!target.startsWith('/') || target.includes('\0') || target.split('/').includes('..')) throw new Error('Remote upload path must be an absolute safe Unix path.');
const data = Buffer.isBuffer(content) ? content : Buffer.from(content);
const target = this.ensureUploadTarget(remotePath);
return this.withClient(serverId, (client) => new Promise((resolve, reject) => {
client.sftp((sftpError, sftp) => {
if (sftpError) { reject(sftpError); return; }
const directory = path.dirname(target);
const mkdirParts = directory.split('/').filter(Boolean);
const parts = path.dirname(target).split('/').filter(Boolean);
let current = '';
const makeNext = (index) => {
if (index >= mkdirParts.length) {
const stream = sftp.createWriteStream(target, { mode });
stream.once('error', reject);
stream.once('close', () => resolve({ remotePath: target, size: data.length }));
stream.end(data);
const ensureNext = (index) => {
if (index >= parts.length) {
Promise.resolve(action(sftp, target)).then(resolve, reject);
return;
}
current += `/${mkdirParts[index]}`;
const ensureDirectory = () => {
sftp.stat(current, (statError, attributes) => {
if (!statError) {
if (typeof attributes?.isDirectory === 'function' && !attributes.isDirectory()) {
reject(new Error(`Remote upload parent exists but is not a directory: ${current}`));
return;
}
makeNext(index + 1);
return;
}
if (![2, 'ENOENT'].includes(statError.code)) { reject(statError); return; }
sftp.mkdir(current, { mode: 0o755 }, (mkdirError) => {
if (!mkdirError) { makeNext(index + 1); return; }
sftp.stat(current, (retryError, retryAttributes) => {
if (!retryError && (typeof retryAttributes?.isDirectory !== 'function' || retryAttributes.isDirectory())) makeNext(index + 1);
else reject(mkdirError);
});
current += `/${parts[index]}`;
sftp.stat(current, (statError, attributes) => {
if (!statError) {
if (typeof attributes?.isDirectory === 'function' && !attributes.isDirectory()) { reject(new Error(`Remote upload parent exists but is not a directory: ${current}`)); return; }
ensureNext(index + 1);
return;
}
if (![2, 'ENOENT'].includes(statError.code)) { reject(statError); return; }
sftp.mkdir(current, { mode: 0o755 }, (mkdirError) => {
if (!mkdirError) { ensureNext(index + 1); return; }
sftp.stat(current, (retryError, retryAttributes) => {
if (!retryError && (typeof retryAttributes?.isDirectory !== 'function' || retryAttributes.isDirectory())) ensureNext(index + 1);
else reject(mkdirError);
});
});
};
ensureDirectory();
});
};
makeNext(0);
ensureNext(0);
});
}), { trustOnFirstUse: false });
}
async uploadFile(serverId, localPath, remotePath, options = {}) {
const data = await fs.readFile(localPath);
return this.uploadBuffer(serverId, remotePath, data, options);
async uploadBuffer(serverId, remotePath, content, { mode = 0o600 } = {}) {
const data = Buffer.isBuffer(content) ? content : Buffer.from(content);
return this.withSftp(serverId, remotePath, (sftp, target) => new Promise((resolve, reject) => {
const stream = sftp.createWriteStream(target, { mode });
stream.once('error', reject);
stream.once('close', () => resolve({ remotePath: target, size: data.length }));
stream.end(data);
}));
}
async uploadFile(serverId, localPath, remotePath, { mode = 0o600, onProgress = null } = {}) {
const stat = await fsp.stat(localPath);
if (!stat.isFile()) throw new Error(`Local upload source is not a file: ${localPath}`);
return this.withSftp(serverId, remotePath, (sftp, target) => new Promise((resolve, reject) => {
const options = {
mode,
step: (totalTransferred, _chunk, total) => onProgress?.({ transferred: totalTransferred, total: total || stat.size }),
};
sftp.fastPut(localPath, target, options, (error) => {
if (error) { reject(error); return; }
resolve({ remotePath: target, size: stat.size });
});
}));
}
async test(serverId, { trustOnFirstUse = true } = {}) {
return this.withClient(serverId, async (client, server, fingerprint) => {
const result = await this.execClient(client, 'uname -srm && command -v git && (docker compose version || docker-compose version)', { timeout: 30_000 });
const script = `
platform=$(uname -srm 2>/dev/null || true)
docker=false; docker_ready=false; compose=false; compose_version=''; git=false; tar_ok=false; checksum=false; base_writable=false
command -v docker >/dev/null 2>&1 && docker=true
[ "$docker" = true ] && docker info >/dev/null 2>&1 && docker_ready=true
if [ "$docker" = true ]; then
if docker compose version >/dev/null 2>&1; then compose=true; compose_version=$(docker compose version 2>/dev/null | head -n1); elif command -v docker-compose >/dev/null 2>&1; then compose=true; compose_version=$(docker-compose version 2>/dev/null | head -n1); fi
fi
command -v git >/dev/null 2>&1 && git=true
command -v tar >/dev/null 2>&1 && tar_ok=true
(command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1) && checksum=true
base=${shellQuote(server.basePath)}
if [ -d "$base" ]; then [ -w "$base" ] && base_writable=true; else parent=$(dirname "$base"); [ -d "$parent" ] && [ -w "$parent" ] && base_writable=true; fi
printf '__FORGEFLOW_SERVER_TEST__\\n'
printf 'platform=%s\\n' "$(printf '%s' "$platform" | base64 | tr -d '\\r\\n')"
printf 'docker=%s\\n' "$docker"
printf 'dockerReady=%s\\n' "$docker_ready"
printf 'compose=%s\\n' "$compose"
printf 'composeVersion=%s\\n' "$(printf '%s' "$compose_version" | base64 | tr -d '\\r\\n')"
printf 'git=%s\\n' "$git"
printf 'tar=%s\\n' "$tar_ok"
printf 'checksum=%s\\n' "$checksum"
printf 'baseWritable=%s\\n' "$base_writable"
`;
const result = await this.execClient(client, script, { timeout: 30_000, maxOutput: 256 * 1024 });
const capabilities = parseCapabilityOutput(result.stdout);
return {
connected: true,
fingerprint,
server: { id: server.id, name: server.name, host: server.host, basePath: server.basePath },
output: result.stdout.trim()
capabilities,
output: [capabilities.platform, capabilities.composeVersion].filter(Boolean).join('\n'),
};
}, { trustOnFirstUse });
}
@@ -201,4 +329,4 @@ class SshService {
}
}
module.exports = { SshService, shellQuote, fingerprintKey };
module.exports = { SshService, shellQuote, fingerprintKey, parseCapabilityOutput };
File diff suppressed because it is too large Load Diff
+261 -61
View File
@@ -429,7 +429,7 @@ async function refreshDeploymentTruth(showErrors = false) {
await refreshRepositories(false, true);
showToast(
"Server workloads discovered",
`${adopted} running deployment${adopted === 1 ? " was" : "s were"} linked to Gitea automatically.`,
`${adopted} workload${adopted === 1 ? " was" : "s were"} linked automatically from exact repository provenance.`,
"success",
);
}
@@ -874,11 +874,9 @@ function environmentState(profile) {
const state = profile.state || {};
if (state.healthy === false) return { label: "Unhealthy", tone: "danger" };
if (state.healthy === true) return { label: "Healthy", tone: "success" };
if (
profile.provider === "ssh-unraid" ||
state.statusConfigured ||
state.healthConfigured
)
if (state.containerRunning === true) return { label: "Running · unverified", tone: "warning" };
if (state.containerRunning === false) return { label: "Stopped", tone: "danger" };
if (profile.provider === "ssh-unraid" || state.statusConfigured || state.healthConfigured)
return { label: "Not checked", tone: "" };
return { label: "Status not configured", tone: "" };
}
@@ -921,28 +919,41 @@ function deploymentIdentity(profile, repository) {
function renderProfileCard(repository, profile, compact = false) {
const state = profile.state || {};
const health = environmentState(profile);
const isSsh = profile.provider === "ssh-unraid";
const mode = isSsh ? profile.deploymentMode || "server-git" : "gitea-actions";
const ready =
mode !== "monitor-only" &&
repository.readyToDeploy &&
repository.localStatus?.branch.head === profile.branch;
const isSsh = profile.provider === "ssh-unraid";
const modeLabel = {
"push-bundle": "Push bundle",
"server-git": "Server-side Git",
"monitor-only": "Monitor only",
}[mode] || mode;
const providerDetail = isSsh
? `SSH / Unraid · ${profile.remoteFolder || repository.name} · ${profile.branch}${profile.adoptedFromServer ? " · automatically discovered" : ""}`
? `SSH / Unraid · ${modeLabel} · ${profile.remoteFolder || repository.name} · ${profile.branch}${profile.adoptedFromServer ? " · server-linked" : ""}`
: `${profile.workflowFile} · ${profile.branch}`;
const rollbackConfigured = isSsh || Boolean(profile.rollbackWorkflowFile);
const rollbackConfigured = (isSsh && mode !== "monitor-only") || Boolean(profile.rollbackWorkflowFile);
const dockerMan = dockerManIntegration(profile);
const { templateReady, webUiReady, iconReady } = dockerMan;
const dockerManReady = dockerMan.ready;
const webUi =
profile.webUiUrl || state.webUiUrl || state.dockerMan?.webUi || "";
const managesDockerMan = isSsh && profile.manageDockerMan === true;
const webUi = profile.webUiUrl || state.webUiUrl || state.dockerMan?.webUi || "";
const identity = deploymentIdentity(profile, repository);
const syncLabel = state.matchesGitea
? `<span class="sync-proof success">${icon("check")}Live = Gitea · ${shortSha(state.liveSha)}</span>`
: state.giteaSha && state.liveSha
? `<span class="sync-proof warning">Live ${shortSha(state.liveSha)} · Gitea ${shortSha(state.giteaSha)}</span>`
: "";
return `<article class="deploy-card accent-${identity.accent} ${compact ? "compact-card" : ""}"><div class="container-identity"><span class="container-avatar">${escapeHtml(identity.initial)}</span><div><span>Container</span><strong>${escapeHtml(identity.name)}</strong><small>${escapeHtml(repository.fullName)} · ${escapeHtml(profile.environment)}</small></div>${syncLabel}</div><div class="deploy-card-header"><div><div class="eyebrow">${escapeHtml(isSsh ? "SSH / UNRAID" : "GITEA ACTIONS")}</div><h3>${escapeHtml(profile.name)}</h3><p>${escapeHtml(providerDetail)}</p></div><span class="status-pill ${health.tone}"><span class="state-dot ${health.tone}"></span>${health.label}</span></div><div class="deploy-card-body"><div class="deploy-metadata"><span>Live commit</span><strong>${state.liveSha ? shortSha(state.liveSha) : "Unknown"}</strong><span>Gitea commit</span><strong>${state.giteaSha ? shortSha(state.giteaSha) : "Refresh to compare"}</strong><span>Previous version</span><strong>${state.previousSha ? shortSha(state.previousSha) : "Unknown"}</strong><span>Last checked</span><strong>${state.checkedAt ? formatDate(state.checkedAt) : "Never"}</strong>${isSsh ? `<span>Runtime</span><strong>${state.containerRunning === false ? "Stopped" : state.containerRunning ? "Running" : "Unknown"}</strong><span>DockerMan</span><strong class="${dockerManReady ? "text-success" : "text-warning"}">${dockerManReady ? (templateReady ? "Labels/template active" : "WebUI/icon labels active") : `WebUI ${webUiReady ? "ready" : "missing"} · icon ${iconReady ? "ready" : "missing"}`}</strong>` : ""}<span>Rollback</span><strong>${rollbackConfigured ? "Available after first deploy" : "Not configured"}</strong></div><div class="card-actions"><button class="button" data-action="run-deployment-preflight" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("shield")}Preflight</button><button class="button" data-action="reconcile-deployment" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("refresh")}Refresh truth</button>${webUi ? `<button class="button" data-action="open-profile-webui" data-url="${attr(webUi)}">${icon("external")}Open Web UI</button>` : ""}${isSsh ? `<button class="button ${dockerManReady ? "ghost" : ""}" data-action="apply-dockerman-metadata" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}${dockerManReady ? "Reapply DockerMan metadata" : "Repair DockerMan integration"}</button>` : ""}${ready ? `<button class="button primary" data-action="deploy-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("rocket")}Deploy ${escapeHtml(repository.localStatus.shortHead)}</button>` : ""}<button class="button ghost" data-action="edit-deployment-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">Edit</button>${state.previousSha && rollbackConfigured ? `<button class="button danger" data-action="rollback-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("undo")}Rollback</button>` : ""}</div></div></article>`;
const dockerManLabel = managesDockerMan
? dockerManReady
? templateReady
? "Managed labels/template active"
: "Managed labels active"
: `Managed · WebUI ${webUiReady ? "ready" : "missing"} · icon ${iconReady ? "ready" : "missing"}`
: "Existing DockerMan template preserved";
return `<article class="deploy-card accent-${identity.accent} ${compact ? "compact-card" : ""}"><div class="container-identity"><span class="container-avatar">${escapeHtml(identity.initial)}</span><div><span>Container</span><strong>${escapeHtml(identity.name)}</strong><small>${escapeHtml(repository.fullName)} · ${escapeHtml(profile.environment)}</small></div>${syncLabel}</div><div class="deploy-card-header"><div><div class="eyebrow">${escapeHtml(isSsh ? "SSH / UNRAID" : "GITEA ACTIONS")}</div><h3>${escapeHtml(profile.name)}</h3><p>${escapeHtml(providerDetail)}</p></div><span class="status-pill ${health.tone}"><span class="state-dot ${health.tone}"></span>${health.label}</span></div><div class="deploy-card-body"><div class="deploy-metadata"><span>Live commit</span><strong>${state.liveSha ? shortSha(state.liveSha) : "Unknown"}</strong><span>Gitea commit</span><strong>${state.giteaSha ? shortSha(state.giteaSha) : "Refresh to compare"}</strong><span>Previous version</span><strong>${state.previousSha ? shortSha(state.previousSha) : "Unknown"}</strong><span>Last checked</span><strong>${state.checkedAt ? formatDate(state.checkedAt) : "Never"}</strong>${isSsh ? `<span>Deployment mode</span><strong>${escapeHtml(modeLabel)}</strong><span>Compose project</span><strong>${escapeHtml(profile.composeProject || "ForgeFlow-generated identity")}</strong><span>Runtime</span><strong>${state.containerRunning === false ? "Stopped" : state.containerRunning ? state.runtimeVerification === "running-unverified" ? "Running · unverified" : "Running" : "Unknown"}</strong><span>DockerMan</span><strong class="${managesDockerMan && !dockerManReady ? "text-warning" : "text-success"}">${escapeHtml(dockerManLabel)}</strong>` : ""}<span>Rollback</span><strong>${rollbackConfigured ? "Available after first deploy" : "Not configured"}</strong></div><div class="card-actions"><button class="button" data-action="run-deployment-preflight" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("shield")}Preflight</button><button class="button" data-action="reconcile-deployment" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("refresh")}Refresh truth</button>${webUi ? `<button class="button" data-action="open-profile-webui" data-url="${attr(webUi)}">${icon("external")}Open Web UI</button>` : ""}${managesDockerMan ? `<button class="button ${dockerManReady ? "ghost" : ""}" data-action="apply-dockerman-metadata" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}${dockerManReady ? "Reapply DockerMan integration" : "Repair DockerMan integration"}</button>` : ""}${ready ? `<button class="button primary" data-action="deploy-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("rocket")}Deploy ${escapeHtml(repository.localStatus.shortHead)}</button>` : ""}<button class="button ghost" data-action="edit-deployment-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">Edit</button>${state.previousSha && rollbackConfigured ? `<button class="button danger" data-action="rollback-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("undo")}Rollback</button>` : ""}</div></div></article>`;
}
function renderRepositoryDeployments(repository) {
const profiles = repository.deploymentProfiles || [];
const repoOps = repositoryOperations(repository).slice(0, 10);
@@ -1093,34 +1104,44 @@ function renderActionPanel(repository) {
return `<aside class="action-panel"><div class="action-panel-head"><div class="eyebrow">Next action</div><h2>${escapeHtml(action.title)}</h2><p>${escapeHtml(action.detail)}</p></div><div class="action-panel-body">${body}</div>${repository.localPath ? `<div class="action-panel-footer"><button class="button ghost" data-action="open-path">${icon("folder")}Open folder</button><button class="button ghost" data-action="load-git-tools">${icon("branch")}Git tools</button></div>` : ""}</aside>`;
}
function renderDeployments() {
const cards = ui.repositories.flatMap((repository) =>
(repository.deploymentProfiles || []).map((profile) => ({
repository,
profile,
})),
);
const active = operations().filter(
(operation) => !isTerminalOperation(operation.status),
);
const missingDockerMan = cards.filter(
({ profile }) =>
profile.provider === "ssh-unraid" &&
profile.state?.containerRunning &&
!dockerManIntegration(profile).ready,
);
return `<div class="page"><div class="page-header"><div><div class="eyebrow">Server releases</div><h1>Deployments</h1><p>Exact commits, live container truth, DockerMan integration and controlled release recovery.</p></div><div class="stack horizontal compact"><button class="button" data-action="refresh-operations">${icon("refresh")}Refresh runs & servers</button>${missingDockerMan.length ? `<button class="button primary" data-action="repair-missing-dockerman">${icon("wrench")}Repair ${missingDockerMan.length} missing integration${missingDockerMan.length === 1 ? "" : "s"}</button>` : ""}</div></div>${active.length ? `<div class="notice warning">${icon("pulse")} ${active.length} deployment operation${active.length === 1 ? " is" : "s are"} still active. ForgeFlow reconciles these against the live server automatically.</div>` : ""}<div class="deploy-card-grid">${cards.length ? cards.map(({ repository, profile }) => renderProfileCard(repository, profile, true)).join("") : '<div class="empty-state panel"><h3>No deployment environments configured</h3><p>Open a repository and add an environment.</p></div>'}</div><section class="section-block"><div class="section-heading"><h2>All operations</h2><span class="meta">Newest first</span></div><div class="panel">${
operations().length
? `<table class="data-table"><thead><tr><th>Repository</th><th>Action</th><th>Environment</th><th>Commit</th><th>Status</th><th>Updated</th><th></th></tr></thead><tbody>${operations()
.map(
(operation) =>
`<tr><td>${escapeHtml(operation.repository)}</td><td>${escapeHtml(operation.action || "deploy")}</td><td>${escapeHtml(operation.environment || "—")}</td><td class="mono">${escapeHtml(operation.shortSha || shortSha(operation.sha))}</td><td><span class="status-pill ${toneForStatus(operation.status)}">${escapeHtml(operation.status)}</span></td><td>${formatDate(operation.updatedAt || operation.createdAt)}</td><td><button class="button ghost" data-action="open-operation" data-operation-id="${attr(operation.id)}">Open</button></td></tr>`,
)
.join("")}</tbody></table>`
: '<div class="empty-state compact"><p>No operations recorded.</p></div>'
}</div></section></div>`;
function renderServerInventory() {
const servers = ui.serverDiscovery || [];
const workloadCount = servers.reduce((total, server) => total + (server.workloads?.length || 0), 0);
const reviewCount = servers.reduce((total, server) => total + Number(server.needsReview || 0), 0);
return `<section class="section-block"><div class="section-heading"><div><h2>Server inventory</h2><span class="meta">Running and stopped Docker workloads, including installations without a Git checkout</span></div><button class="button ${reviewCount ? "primary" : ""}" data-action="scan-server-inventory">${icon("refresh")}Scan servers</button></div>${servers.length ? `<div class="stack">${servers.map((server) => {
const capabilities = server.capabilities || {};
const capabilityText = [capabilities.docker ? "Docker" : "Docker missing", capabilities.compose ? "Compose" : "Compose missing", capabilities.git ? "Git" : "Git optional/missing", capabilities.tar && capabilities.checksum ? "Push ready" : "Push tools incomplete"].join(" · ");
return `<section class="panel"><div class="panel-header"><div><h3>${escapeHtml(server.serverName || server.server?.name || server.serverId)}</h3><span class="meta">${server.detected || 0} workloads · ${server.running || 0} running · ${server.linked || 0} linked · ${server.needsReview || 0} review</span></div><span class="status-pill ${server.error ? "danger" : capabilities.docker && capabilities.compose ? "success" : "warning"}">${server.error ? "Scan failed" : escapeHtml(capabilityText)}</span></div><div class="panel-body">${server.error ? `<div class="notice danger">${icon("error")}${escapeHtml(server.error)}</div>` : ""}${(server.warnings || []).map((warning) => `<div class="notice warning">${icon("warning")}${escapeHtml(warning)}</div>`).join("")}<div class="tool-list">${(server.workloads || []).length ? server.workloads.map((workload) => {
const containers = (workload.containers || []).map((container) => container.name).join(", ");
const topCandidate = workload.candidates?.[0];
const linked = workload.status === "linked" || Boolean(workload.link);
const statusTone = linked ? "success" : workload.status === "ambiguous" ? "danger" : workload.status === "unmatched" ? "warning" : "warning";
const detail = workload.compose?.project
? `Compose ${workload.compose.project} · ${(workload.compose.services || []).join(", ") || "services unknown"}`
: `Container installation · ${containers || "unnamed"}`;
const candidate = linked
? `Linked to ${workload.link?.repositoryFullName || "repository"}`
: topCandidate
? `${topCandidate.repositoryFullName} suggested · ${topCandidate.confidence}`
: "No repository candidate; select one manually";
return `<div class="tool-row"><div><strong>${escapeHtml(workload.displayName)}</strong><span>${escapeHtml(detail)} · ${workload.runtime?.running ? "running" : "stopped"}</span><span>${escapeHtml(candidate)}</span></div><div class="stack horizontal compact"><span class="status-pill ${statusTone}">${escapeHtml(linked ? "Linked" : workload.status || "Review")}</span>${linked ? `<button class="button ghost" data-action="edit-deployment-profile" data-profile-id="${attr(workload.link?.profileId || "")}">Open link</button>` : `<button class="button primary" data-action="link-server-workload" data-server-id="${attr(server.serverId)}" data-workload-id="${attr(workload.workloadId)}">${icon("link")}Link deployment</button>`}</div></div>`;
}).join("") : '<div class="empty-state compact"><p>No Docker workloads were returned by this server.</p></div>'}</div></div></section>`;
}).join("")}</div>` : `<div class="empty-state panel"><h3>Server inventory not scanned</h3><p>Scan the configured servers to detect existing DockerMan, Docker and Compose installations.</p><button class="button primary" data-action="scan-server-inventory">Scan servers</button></div>`}<div class="notice" style="margin-top:12px">${icon("shield")}Only exact repository provenance is linked automatically. Name similarity remains a manual decision. Push bundle deployments reuse the desktop Unraid SSH connection and do not require a Gitea key on Unraid.</div></section>`;
}
function renderDeployments() {
const cards = ui.repositories.flatMap((repository) =>
(repository.deploymentProfiles || []).map((profile) => ({ repository, profile })),
);
const active = operations().filter((operation) => !isTerminalOperation(operation.status));
const missingDockerMan = cards.filter(({ profile }) =>
profile.provider === "ssh-unraid" &&
profile.manageDockerMan === true &&
profile.state?.containerRunning &&
!dockerManIntegration(profile).ready,
);
return `<div class="page"><div class="page-header"><div><div class="eyebrow">Server releases</div><h1>Deployments</h1><p>Discover existing installations, link uncertain workloads and deploy exact commit bundles without server-side Gitea credentials.</p></div><div class="stack horizontal compact"><button class="button" data-action="refresh-operations">${icon("refresh")}Refresh runs & servers</button>${missingDockerMan.length ? `<button class="button primary" data-action="repair-missing-dockerman">${icon("wrench")}Repair ${missingDockerMan.length} managed integration${missingDockerMan.length === 1 ? "" : "s"}</button>` : ""}</div></div>${active.length ? `<div class="notice warning">${icon("pulse")} ${active.length} deployment operation${active.length === 1 ? " is" : "s are"} still active. ForgeFlow reconciles these against the live server automatically.</div>` : ""}${renderServerInventory()}<section class="section-block"><div class="section-heading"><div><h2>Linked deployment environments</h2><span class="meta">Manual links remain stable across container recreations through Compose/workload identity</span></div></div><div class="deploy-card-grid">${cards.length ? cards.map(({ repository, profile }) => renderProfileCard(repository, profile, true)).join("") : '<div class="empty-state panel"><h3>No deployment environments configured</h3><p>Scan a server and link an existing workload, or open a repository and add an environment.</p></div>'}</div></section><section class="section-block"><div class="section-heading"><h2>All operations</h2><span class="meta">Newest first</span></div><div class="panel">${operations().length ? `<table class="data-table"><thead><tr><th>Repository</th><th>Action</th><th>Environment</th><th>Commit</th><th>Status</th><th>Updated</th><th></th></tr></thead><tbody>${operations().map((operation) => `<tr><td>${escapeHtml(operation.repository)}</td><td>${escapeHtml(operation.action || "deploy")}</td><td>${escapeHtml(operation.environment || "—")}</td><td class="mono">${escapeHtml(operation.shortSha || shortSha(operation.sha))}</td><td><span class="status-pill ${toneForStatus(operation.status)}">${escapeHtml(operation.status)}</span></td><td>${formatDate(operation.updatedAt || operation.createdAt)}</td><td><button class="button ghost" data-action="open-operation" data-operation-id="${attr(operation.id)}">Open</button></td></tr>`).join("")}</tbody></table>` : '<div class="empty-state compact"><p>No operations recorded.</p></div>'}</div></section></div>`;
}
function renderSettings() {
const state = ui.boot.state;
const prefs = state.preferences || {};
@@ -1246,6 +1267,45 @@ function renderModal() {
ui.repositories.find(
(repo) => repo.fullName === ui.modal.repositoryFullName,
);
if (ui.modal.type === "workload-link") {
const serverResult = (ui.serverDiscovery || []).find(
(item) => item.serverId === ui.modal.serverId,
);
const workload = serverResult?.workloads?.find(
(item) => item.workloadId === ui.modal.workloadId,
);
if (!workload) {
return `<div class="modal-backdrop" role="presentation"><section class="modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Link server workload</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="notice danger">${icon("error")}This workload is no longer present in the latest server inventory. Scan the servers again.</div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Close</button></footer></section></div>`;
}
const availableRepositories = ui.repositories.filter((item) => item.fullName);
const suggestedRepository =
ui.modal.repositoryFullName ||
workload.candidates?.[0]?.repositoryFullName ||
selectedRepository()?.fullName ||
availableRepositories[0]?.fullName ||
"";
const selectedLinkRepository = availableRepositories.find(
(item) => item.fullName === suggestedRepository,
);
const remoteFolder =
ui.modal.remoteFolder ||
workload.remoteFolderCandidate ||
safeCloneFolderName(selectedLinkRepository);
const candidateSummary = workload.candidates?.length
? workload.candidates
.slice(0, 4)
.map(
(candidate) =>
`<div class="context-row"><span>${escapeHtml(candidate.repositoryFullName)}</span><strong>${escapeHtml(candidate.exact ? "Exact provenance" : `${candidate.score} confidence`)} · ${escapeHtml((candidate.reasons || []).join(", ") || "name similarity")}</strong></div>`,
)
.join("")
: '<div class="context-row"><span>Repository candidates</span><strong>No confident match; choose manually.</strong></div>';
const containerNames = (workload.containers || [])
.map((container) => container.name)
.filter(Boolean)
.join(", ");
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Link existing server workload</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero">${icon("link")}<div><strong>${escapeHtml(workload.displayName)}</strong><span>${escapeHtml(serverResult?.serverName || serverResult?.server?.name || ui.modal.serverId)} · ${workload.runtime?.running ? "running" : "stopped"}</span></div></div><div class="context-summary"><div class="context-row"><span>Containers</span><strong>${escapeHtml(containerNames || "Unknown")}</strong></div><div class="context-row"><span>Compose identity</span><strong>${escapeHtml(workload.compose?.project || "DockerMan / standalone container")} ${workload.compose?.services?.length ? `· ${escapeHtml(workload.compose.services.join(", "))}` : ""}</strong></div><div class="context-row"><span>Detected folder</span><strong class="mono">${escapeHtml(workload.compose?.workingDir || workload.dockerMan?.templatePath || "No Git checkout required")}</strong></div>${candidateSummary}</div><div class="form-grid" style="margin-top:14px"><div class="field full"><label>Repository to link</label><select id="workload-repository" class="select">${availableRepositories.map((item) => `<option value="${attr(item.fullName)}" ${item.fullName === suggestedRepository ? "selected" : ""}>${escapeHtml(item.fullName)}</option>`).join("") || '<option value="">No Gitea repositories available</option>'}</select></div><div class="field"><label>Deployment mode</label><select id="workload-deployment-mode" class="select"><option value="push-bundle" selected>Push bundle · recommended</option><option value="monitor-only">Monitor only</option><option value="server-git">Server-side Git · advanced</option></select></div><div class="field"><label>Managed server folder</label><input id="workload-remote-folder" class="input" value="${attr(remoteFolder)}"/></div></div><div class="notice success" style="margin-top:12px">${icon("shield")}Push bundle uploads the exact local Git commit over the already configured desktop → Unraid SSH connection. No Gitea SSH key is needed on Unraid. Linking does not recreate containers or rewrite an existing DockerMan template.</div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="confirm-link-server-workload" data-server-id="${attr(ui.modal.serverId)}" data-workload-id="${attr(ui.modal.workloadId)}" ${availableRepositories.length ? "" : "disabled"}>Link workload</button></footer></section></div>`;
}
if (ui.modal.type === "deployment-config") {
const storedProfile =
repository?.deploymentProfiles?.find(
@@ -1265,30 +1325,35 @@ function renderModal() {
const ssh = provider === "ssh-unraid";
const remoteFolder =
existing.remoteFolder || safeCloneFolderName(repository);
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>${existing.id ? "Edit" : "Add"} deployment environment</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="form-grid"><div class="field full"><label>Deployment provider</label><select id="profile-provider" class="select"><option value="ssh-unraid" ${ssh ? "selected" : ""}>SSH / Unraid · direct controlled deployment</option><option value="gitea-actions" ${!ssh ? "selected" : ""}>Gitea Actions · runner workflow</option></select></div>${ssh ? `<div class="field full"><div class="notice ${discovery ? "success" : ""}">${icon(discovery ? "check" : "server")}<div><strong>${discovery ? "Existing deployment imported from server" : "Use the server as source of truth"}</strong><p>${discovery ? `${escapeHtml(discovery.runtime?.containers || 0)} container(s), ${escapeHtml(discovery.runtime?.services || 0)} service(s) and ${escapeHtml(discovery.runtime?.ports?.length || 0)} port mapping(s) detected. Every imported value remains editable as an explicit override.` : "Select the server and folder, then let ForgeFlow read Git, Compose, Docker and DockerMan instead of guessing values."}</p><button type="button" class="button ${discovery ? "" : "primary"}" data-action="discover-existing-deployment">${icon("refresh")}${discovery ? "Rescan server" : "Import existing deployment"}</button></div></div></div>` : ""}<div class="field"><label>Profile name</label><input id="profile-name" class="input" value="${attr(existing.name || "Production")}" /></div><div class="field"><label>Environment</label><input id="profile-environment" class="input" value="${attr(existing.environment || "production")}" /></div><div class="field"><label>Allowed branch</label><input id="profile-branch" class="input" value="${attr(existing.branch || repository?.defaultBranch || "main")}" /></div>${
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>${existing.id ? "Edit" : "Add"} deployment environment</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="form-grid"><div class="field full"><label>Deployment provider</label><select id="profile-provider" class="select"><option value="ssh-unraid" ${ssh ? "selected" : ""}>SSH / Unraid · direct controlled deployment</option><option value="gitea-actions" ${!ssh ? "selected" : ""}>Gitea Actions · runner workflow</option></select></div>${ssh ? `<div class="field full"><div class="notice ${discovery ? "success" : ""}">${icon(discovery ? "check" : "server")}<div><strong>${discovery ? "Existing deployment imported from server" : "Import by folder or use Server inventory"}</strong><p>${discovery ? `${escapeHtml(discovery.runtime?.containers || 0)} container(s), ${escapeHtml(discovery.runtime?.services || 0)} service(s) and ${escapeHtml(discovery.runtime?.ports?.length || 0)} port mapping(s) detected. Every imported value remains editable as an explicit override.` : "For a known folder, ForgeFlow can read Docker, Compose and DockerMan metadata. For uncertain matches, use Server inventory and select the actual running workload."}</p><button type="button" class="button ${discovery ? "" : "primary"}" data-action="discover-existing-deployment">${icon("refresh")}${discovery ? "Rescan folder" : "Import known folder"}</button></div></div></div>` : ""}<div class="field"><label>Profile name</label><input id="profile-name" class="input" value="${attr(existing.name || "Production")}" /></div><div class="field"><label>Environment</label><input id="profile-environment" class="input" value="${attr(existing.environment || "production")}" /></div><div class="field"><label>Allowed branch</label><input id="profile-branch" class="input" value="${attr(existing.branch || repository?.defaultBranch || "main")}" /></div>${
ssh
? `
<div class="field"><label>Unraid server</label><select id="profile-server" class="select">${servers.length ? servers.map((server) => `<option value="${attr(server.id)}" ${server.id === existing.serverId ? "selected" : ""}>${escapeHtml(server.name)} · ${escapeHtml(server.host)}</option>`).join("") : '<option value="">Configure a server first</option>'}</select></div>
<div class="field"><label>Server folder name</label><input id="profile-remote-folder" class="input" value="${attr(remoteFolder)}"/></div>
<div class="field"><label>Git clone URL used by Unraid</label><input id="profile-clone-url" class="input" value="${attr(existing.cloneUrl || repository?.sshUrl || "")}" placeholder="ssh://git@gitea:222/Jens/project.git"/></div>
<label class="check-field"><input id="profile-align-remote" type="checkbox" ${existing.alignRemote === true ? "checked" : ""}/><span>Align an existing server origin to this URL</span></label>
<div class="field"><label>Compose mode</label><select id="profile-generated-compose" class="select"><option value="false" ${existing.generatedCompose !== true ? "selected" : ""}>Use Compose file from repository/server</option><option value="true" ${existing.generatedCompose === true ? "selected" : ""}>Generate a basic ForgeFlow Compose file</option></select></div>
<div class="field"><label>Compose file</label><input id="profile-compose-file" class="input" value="${attr(existing.composeFile || "docker-compose.yml")}"/></div>
<div class="field"><label>Compose service (internal)</label><input id="profile-compose-service" class="input" value="${attr(existing.composeService || safeCloneFolderName(repository).toLowerCase())}"/><small>Must match the Compose service key and remain lowercase.</small></div><div class="field"><label>Visible container name</label><input id="profile-container-name" class="input" value="${attr(existing.containerName || remoteFolder)}"/><small>May remain Portfolio while internal image/service names are lowercase.</small></div>
<div class="field"><label>Deployment mode</label><select id="profile-deployment-mode" class="select"><option value="push-bundle" ${(existing.deploymentMode || "push-bundle") === "push-bundle" ? "selected" : ""}>Push bundle · recommended</option><option value="monitor-only" ${existing.deploymentMode === "monitor-only" ? "selected" : ""}>Monitor only</option><option value="server-git" ${existing.deploymentMode === "server-git" ? "selected" : ""}>Server-side Git · advanced</option></select><small>Push bundle uses the desktop → Unraid key and does not require Gitea credentials on Unraid.</small></div>
<div class="field"><label>Git clone URL · Server-side Git only</label><input id="profile-clone-url" class="input" value="${attr(existing.cloneUrl || repository?.sshUrl || "")}" placeholder="ssh://git@gitea:222/Jens/project.git"/></div>
<label class="check-field"><input id="profile-align-remote" type="checkbox" ${existing.alignRemote === true ? "checked" : ""}/><span>Align an existing server origin in Server-side Git mode</span></label>
<div class="field"><label>Compose mode</label><select id="profile-generated-compose" class="select"><option value="false" ${existing.generatedCompose !== true ? "selected" : ""}>Use existing Compose definition</option><option value="true" ${existing.generatedCompose === true ? "selected" : ""}>Generate a basic ForgeFlow Compose file</option></select></div>
<div class="field"><label>Compose project identity</label><input id="profile-compose-project" class="input" value="${attr(existing.composeProject || "")}" placeholder="Existing docker compose project name"/><small>Kept stable to update the existing containers instead of creating duplicates.</small></div>
<div class="field full"><label>Compose files</label><input id="profile-compose-files" class="input" value="${attr((existing.composeFiles?.length ? existing.composeFiles : [existing.composeFile || "docker-compose.yml"]).join(", "))}"/><small>Comma-separated, in the same order used by the existing deployment. ForgeFlow adds its metadata overlay last.</small></div>
<div class="field full"><label>Compose services to verify</label><input id="profile-compose-services" class="input" value="${attr((existing.composeServices?.length ? existing.composeServices : [existing.composeService || safeCloneFolderName(repository).toLowerCase()]).join(", "))}"/><small>Compose service (internal) keys, comma-separated. All listed services must be running after deployment.</small></div><div class="field"><label>Visible container name</label><input id="profile-container-name" class="input" value="${attr(existing.containerName || remoteFolder)}"/><small>Used as an inventory hint; adopted Compose identity remains authoritative.</small></div>
<div class="field"><label>Host port</label><input id="profile-host-port" class="input" type="number" min="1" max="65535" value="${attr(existing.hostPort || "")}" placeholder="1223"/></div>
<div class="field"><label>Container port</label><input id="profile-container-port" class="input" type="number" min="1" max="65535" value="${attr(existing.containerPort || "")}" placeholder="8080"/></div>
<div class="field full"><label>Unraid Web UI URL (optional)</label><input id="profile-web-ui" class="input" value="${attr(existing.webUiUrl || "")}" placeholder="http://[IP]:[PORT:1223]/"/></div>
<div class="field"><label>DockerMan icon source</label><select id="profile-icon-mode" class="select"><option value="builtin" ${(existing.iconMode || (!existing.iconUrl && !existing.iconFilePath ? "builtin" : existing.iconFilePath ? "upload" : "url")) === "builtin" ? "selected" : ""}>Built-in high-contrast ITWorx mark</option><option value="upload" ${existing.iconMode === "upload" || (!existing.iconMode && existing.iconFilePath) ? "selected" : ""}>Upload local PNG</option><option value="url" ${existing.iconMode === "url" || (!existing.iconMode && existing.iconUrl) ? "selected" : ""}>Use icon URL</option><option value="none" ${existing.iconMode === "none" ? "selected" : ""}>No custom icon</option></select></div><div class="field"><label>Container shell</label><select id="profile-docker-shell" class="select"><option value="/bin/sh" ${(existing.dockerShell || "/bin/sh") === "/bin/sh" ? "selected" : ""}>/bin/sh</option><option value="/bin/bash" ${existing.dockerShell === "/bin/bash" ? "selected" : ""}>/bin/bash</option></select></div>
<div class="field full"><label>DockerMan icon URL</label><input id="profile-icon-url" class="input" value="${attr(existing.iconUrl || "")}" placeholder="https://…/icon.png"/></div><div class="field full"><label>Local PNG</label><div class="inline-form"><input id="profile-icon-file" class="input mono" value="${attr(existing.iconFilePath || "")}" placeholder="Select a local transparent PNG" readonly/><button class="button" data-action="select-profile-icon">${icon("folder")}Browse</button><button class="button ghost" data-action="clear-profile-icon">Clear</button></div><small>Built-in or uploaded PNGs are copied to DockerMan's persistent image folder and referenced through a file:/// URL. ForgeFlow also refreshes the relevant Unraid icon cache after recreating the container.</small></div>
<div class="field full"><label>Healthcheck URL from this desktop (optional)</label><input id="profile-healthcheck" class="input" value="${attr(existing.healthcheckUrl || "")}" placeholder="http://unraid:1223/health"/></div>
<div class="field full"><label>Preserve server-only paths</label><input id="profile-preserve-paths" class="input" value="${attr((existing.preservePaths || [".env", "appdata", "data", "logs", "config", "compose.override.yml"]).join(", "))}"/><small>These untracked runtime paths remain untouched by Git deployments.</small></div>
<div class="field full"><label>Preserve server-only paths</label><input id="profile-preserve-paths" class="input" value="${attr((existing.preservePaths || [".env", "appdata", "data", "logs", "config", "compose.override.yml"]).join(", "))}"/><small>Push bundle never replaces these paths and only removes files previously managed by ForgeFlow.</small></div>
<label class="check-field"><input id="profile-manage-dockerman" type="checkbox" ${existing.manageDockerMan === true ? "checked" : ""}/><span>Manage a generated DockerMan template</span><small>Existing/imported DockerMan templates are always preserved. This applies only to ForgeFlow-generated Compose deployments.</small></label>
<label class="check-field"><input id="profile-force-recreate" type="checkbox" ${existing.forceRecreate === true ? "checked" : ""}/><span>Force-recreate containers</span><small>Off by default for safely adopted deployments.</small></label>
<label class="check-field"><input id="profile-remove-orphans" type="checkbox" ${existing.removeOrphans === true ? "checked" : ""}/><span>Remove Compose orphans</span><small>Enable only after reviewing the existing Compose project.</small></label>
`
: `
<div class="field"><label>Deploy workflow file</label><input id="profile-workflow" class="input" value="${attr(existing.workflowFile || "deploy.yml")}" /></div>
<div class="field full"><label>Rollback workflow file (optional)</label><input id="profile-rollback-workflow" class="input" value="${attr(existing.rollbackWorkflowFile || "")}" placeholder="rollback.yml" /></div>
<div class="field full"><label>Application status URL</label><input id="profile-status-url" class="input" value="${attr(existing.statusUrl || "")}" required placeholder="https://app.example.com/.well-known/forgeflow" /></div>
<div class="field full"><label>Healthcheck URL (optional)</label><input id="profile-healthcheck" class="input" value="${attr(existing.healthcheckUrl || "")}" placeholder="https://app.example.com/health" /></div>`
}<label class="check-field full"><input id="profile-confirmation" type="checkbox" ${existing.confirmationRequired !== false ? "checked" : ""}/><span>Require an explicit confirmation before deployment</span></label></div><div class="notice" style="margin-top:13px">${icon("shield")}${ssh ? "ForgeFlow connects over pinned SSH, refuses tracked server-side changes, deploys the exact Git SHA and preserves untracked runtime data." : "ForgeFlow sends only controlled workflow inputs: environment, exact SHA and a unique request ID."}</div></div><footer class="modal-footer">${existing.id ? `<button class="button danger" data-action="delete-deployment-profile" data-profile-id="${attr(existing.id)}">Delete</button>` : ""}<span class="modal-spacer"></span><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="save-deployment-profile" data-profile-id="${attr(existing.id || "")}" ${ssh && !servers.length ? "disabled" : ""}>Save environment</button></footer></section></div>`;
}<label class="check-field full"><input id="profile-confirmation" type="checkbox" ${existing.confirmationRequired !== false ? "checked" : ""}/><span>Require an explicit confirmation before deployment</span></label></div><div class="notice" style="margin-top:13px">${icon("shield")}${ssh ? "Push bundle is the safe default: ForgeFlow archives the exact local commit, uploads it over pinned SSH, validates Compose, verifies every selected service and only then promotes the live release. Server-side Git remains optional." : "ForgeFlow sends only controlled workflow inputs: environment, exact SHA and a unique request ID."}</div></div><footer class="modal-footer">${existing.id ? `<button class="button danger" data-action="delete-deployment-profile" data-profile-id="${attr(existing.id)}">Delete</button>` : ""}<span class="modal-spacer"></span><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="save-deployment-profile" data-profile-id="${attr(existing.id || "")}" ${ssh && !servers.length ? "disabled" : ""}>Save environment</button></footer></section></div>`;
}
if (ui.modal.type === "deployment-preflight") {
const profile =
@@ -1318,7 +1383,7 @@ function renderModal() {
(item) => item.id === ui.modal.serverId,
) || {};
const authType = ui.modal.authType || server.authType || "privateKey";
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>${server.id ? "Edit" : "Add"} SSH / Unraid server</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="form-grid"><div class="field"><label>Name</label><input id="server-name" class="input" value="${attr(server.name || "Unraid")}"/></div><div class="field"><label>Host or IP</label><input id="server-host" class="input" value="${attr(server.host || "")}" placeholder="192.168.1.10"/></div><div class="field"><label>SSH port</label><input id="server-port" class="input" type="number" min="1" max="65535" value="${attr(server.port || 22)}"/></div><div class="field"><label>Username</label><input id="server-username" class="input" value="${attr(server.username || "root")}"/></div><div class="field"><label>Authentication</label><select id="server-auth-type" class="select"><option value="privateKey" ${authType === "privateKey" ? "selected" : ""}>Private key · recommended</option><option value="password" ${authType === "password" ? "selected" : ""}>Password</option></select></div><div class="field"><label>Appdata base path</label><input id="server-base-path" class="input" value="${attr(server.basePath || "/mnt/user/appdata")}"/></div>${authType === "privateKey" ? `<div class="field full"><label>Private key file</label><div class="input-action"><input id="server-private-key" class="input" value="${attr(server.privateKeyPath || "")}" placeholder="C:\\Users\\Jens\\.ssh\\id_ed25519"/><button class="button" data-action="select-private-key">Browse</button></div></div><div class="field full"><label>Private key passphrase</label><input id="server-passphrase" class="input" type="password" placeholder="${server.hasPassphrase ? "Leave empty to keep stored passphrase" : "Only when the key is encrypted"}"/></div>` : `<div class="field full"><label>SSH password</label><input id="server-password" class="input" type="password" placeholder="${server.hasPassword ? "Leave empty to keep stored password" : "Password"}"/></div>`}<div class="field full"><label>Trusted host fingerprint</label><input id="server-fingerprint" class="input mono" value="${attr(server.hostFingerprint || "")}" readonly placeholder="Filled automatically after Test & trust"/></div></div><div class="notice warning" style="margin-top:12px">${icon("key")}The first connection records the SSH host-key fingerprint. Later deployments fail closed when the server presents a different key.</div></div><footer class="modal-footer">${server.id ? `<button class="button danger" data-action="delete-server" data-server-id="${attr(server.id)}">Delete</button>` : ""}<span class="modal-spacer"></span><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="save-server" data-server-id="${attr(server.id || "")}">Save server</button></footer></section></div>`;
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>${server.id ? "Edit" : "Add"} SSH / Unraid server</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="form-grid"><div class="field"><label>Name</label><input id="server-name" class="input" value="${attr(server.name || "Unraid")}"/></div><div class="field"><label>Host or IP</label><input id="server-host" class="input" value="${attr(server.host || "")}" placeholder="192.168.1.10"/></div><div class="field"><label>SSH port</label><input id="server-port" class="input" type="number" min="1" max="65535" value="${attr(server.port || 22)}"/></div><div class="field"><label>Username</label><input id="server-username" class="input" value="${attr(server.username || "root")}"/></div><div class="field"><label>Authentication</label><select id="server-auth-type" class="select"><option value="privateKey" ${authType === "privateKey" ? "selected" : ""}>Private key · recommended</option><option value="password" ${authType === "password" ? "selected" : ""}>Password</option></select></div><div class="field"><label>Appdata base path</label><input id="server-base-path" class="input" value="${attr(server.basePath || "/mnt/user/appdata")}"/></div>${authType === "privateKey" ? `<div class="field full"><label>Private key file</label><div class="input-action"><input id="server-private-key" class="input" value="${attr(server.privateKeyPath || "")}" placeholder="C:\\Users\\Jens\\.ssh\\id_ed25519"/><button class="button" data-action="select-private-key">Browse</button></div></div><div class="field full"><label>Private key passphrase</label><input id="server-passphrase" class="input" type="password" placeholder="${server.hasPassphrase ? "Leave empty to keep stored passphrase" : "Only when the key is encrypted"}"/></div>` : `<div class="field full"><label>SSH password</label><input id="server-password" class="input" type="password" placeholder="${server.hasPassword ? "Leave empty to keep stored password" : "Password"}"/></div>`}<div class="field full"><label>Trusted host fingerprint</label><input id="server-fingerprint" class="input mono" value="${attr(server.hostFingerprint || "")}" readonly placeholder="Filled automatically after Test & trust"/></div></div><div class="notice warning" style="margin-top:12px">${icon("key")}This identity is used only for the desktop → Unraid connection. Push bundle deployments reuse it to upload files and do not require a separate Gitea key on Unraid. The first connection records the server host-key fingerprint; later connections fail closed if it changes.</div></div><footer class="modal-footer">${server.id ? `<button class="button danger" data-action="delete-server" data-server-id="${attr(server.id)}">Delete</button>` : ""}<span class="modal-spacer"></span><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="save-server" data-server-id="${attr(server.id || "")}">Save server</button></footer></section></div>`;
}
if (ui.modal.type === "hunk-staging") {
const hunks = ui.diffHunks?.hunks || [];
@@ -2143,6 +2208,98 @@ app.addEventListener("click", async (event) => {
"success",
);
}
} else if (action === "scan-server-inventory") {
setLoading(true, "Scanning Docker, Compose and DockerMan workloads…");
try {
await refreshDeploymentTruth(true);
const detected = (ui.serverDiscovery || []).reduce(
(total, item) => total + Number(item.detected || 0),
0,
);
const review = (ui.serverDiscovery || []).reduce(
(total, item) => total + Number(item.needsReview || 0),
0,
);
showToast(
"Server inventory updated",
`${detected} workload${detected === 1 ? "" : "s"} detected; ${review} require manual review.`,
review ? "info" : "success",
);
} catch (error) {
showToast("Server scan failed", error.message, "error");
}
setLoading(false);
} else if (action === "link-server-workload") {
const serverResult = (ui.serverDiscovery || []).find(
(item) => item.serverId === target.dataset.serverId,
);
const workload = serverResult?.workloads?.find(
(item) => item.workloadId === target.dataset.workloadId,
);
if (!workload) {
showToast(
"Workload unavailable",
"Scan the server inventory again before linking this workload.",
"error",
);
return;
}
ui.modal = {
type: "workload-link",
serverId: target.dataset.serverId,
workloadId: target.dataset.workloadId,
repositoryFullName:
workload.candidates?.[0]?.repositoryFullName ||
repository?.fullName ||
ui.repositories[0]?.fullName ||
"",
remoteFolder: workload.remoteFolderCandidate || "",
};
render();
} else if (action === "confirm-link-server-workload") {
const repositoryFullName = document
.querySelector("#workload-repository")
?.value.trim();
const deploymentMode =
document.querySelector("#workload-deployment-mode")?.value ||
"push-bundle";
const remoteFolder = document
.querySelector("#workload-remote-folder")
?.value.trim();
const linkedRepository = ui.repositories.find(
(item) => item.fullName === repositoryFullName,
);
if (!linkedRepository) {
showToast(
"Choose a repository",
"The workload must be linked to a Gitea repository.",
"error",
);
return;
}
setLoading(true, "Saving the permanent server workload link…");
try {
const result = await window.forgeflow.linkServerWorkload(
linkedRepository,
target.dataset.serverId,
target.dataset.workloadId,
deploymentMode,
remoteFolder,
);
if (result.state) ui.boot.state = result.state;
ui.modal = null;
ui.selectedProfileId = result.profile?.id || null;
await refreshRepositories(false, true);
await refreshDeploymentTruth(false);
showToast(
"Workload linked",
`${linkedRepository.fullName} now uses ${deploymentMode === "push-bundle" ? "safe bundle upload" : deploymentMode === "monitor-only" ? "monitor-only mode" : "server-side Git"}.`,
"success",
);
} catch (error) {
showToast("Could not link workload", error.message, "error");
}
setLoading(false);
} else if (action === "configure-deployment") {
ui.deploymentDiscovery = null;
ui.modal = {
@@ -2155,7 +2312,7 @@ app.addEventListener("click", async (event) => {
render();
} else if (action === "edit-deployment-profile") {
ui.deploymentDiscovery = null;
if (!repository) repository = profileRepository(target.dataset.profileId);
repository = profileRepository(target.dataset.profileId) || repository;
if (repository && String(repository.id) !== String(ui.selectedRepoId))
selectRepository(repository.id, false);
ui.modal = {
@@ -2248,6 +2405,20 @@ app.addEventListener("click", async (event) => {
showToast("Could not save profile", error.message, "error");
return;
}
const composeFiles =
provider === "ssh-unraid"
? (document.querySelector("#profile-compose-files")?.value || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean)
: [];
const composeServices =
provider === "ssh-unraid"
? (document.querySelector("#profile-compose-services")?.value || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean)
: [];
const profile = {
id: target.dataset.profileId || undefined,
provider,
@@ -2275,18 +2446,27 @@ app.addEventListener("click", async (event) => {
remoteFolder: document
.querySelector("#profile-remote-folder")
.value.trim(),
deploymentMode:
document.querySelector("#profile-deployment-mode")?.value ||
"push-bundle",
cloneUrl: document.querySelector("#profile-clone-url").value.trim(),
alignRemote: document.querySelector("#profile-align-remote")
.checked,
generatedCompose:
document.querySelector("#profile-generated-compose").value ===
"true",
composeFile: document
.querySelector("#profile-compose-file")
.value.trim(),
composeService: document
.querySelector("#profile-compose-service")
.value.trim(),
composeProject:
document.querySelector("#profile-compose-project")?.value.trim() ||
previousProfile.composeProject ||
"",
composeWorkingDir: previousProfile.composeWorkingDir || "",
composeFiles: composeFiles.length ? composeFiles : ["docker-compose.yml"],
composeFile: composeFiles[0] || "docker-compose.yml",
composeServices: composeServices.length
? composeServices
: [safeCloneFolderName(repository).toLowerCase()],
composeService:
composeServices[0] || safeCloneFolderName(repository).toLowerCase(),
containerName: document
.querySelector("#profile-container-name")
.value.trim(),
@@ -2308,8 +2488,25 @@ app.addEventListener("click", async (event) => {
.value.split(",")
.map((item) => item.trim())
.filter(Boolean),
adoptedFromServer: Boolean(ui.deploymentDiscovery),
serverSourceOfTruth: Boolean(ui.deploymentDiscovery),
manageDockerMan:
document.querySelector("#profile-manage-dockerman")?.checked ===
true,
forceRecreate:
document.querySelector("#profile-force-recreate")?.checked ===
true,
removeOrphans:
document.querySelector("#profile-remove-orphans")?.checked ===
true,
adoptedFromServer: Boolean(
ui.deploymentDiscovery || previousProfile.adoptedFromServer,
),
serverSourceOfTruth: Boolean(
ui.deploymentDiscovery || previousProfile.serverSourceOfTruth,
),
workloadIdentity:
ui.deploymentDiscovery?.profile?.workloadIdentity ||
previousProfile.workloadIdentity ||
null,
detectedAt:
ui.deploymentDiscovery?.profile?.detectedAt ||
previousProfile.detectedAt ||
@@ -2759,15 +2956,18 @@ app.addEventListener("click", async (event) => {
} else if (action === "test-server") {
setLoading(
true,
"Connecting to Unraid and checking Git and Docker Compose…",
"Checking SSH identity, Docker, Compose and optional Git capabilities…",
);
try {
const result = await window.forgeflow.testServer(target.dataset.serverId);
ui.boot.state = result.state;
const capabilities = result.capabilities || {};
const deploymentReady =
capabilities.docker && capabilities.dockerReady && capabilities.compose;
showToast(
"SSH server ready",
`${result.server.name} presented ${result.fingerprint}.`,
"success",
deploymentReady ? "SSH server ready" : "SSH connected with missing tools",
`${result.server.name} presented ${result.fingerprint}. Docker ${capabilities.dockerReady ? "ready" : "unavailable"}; Compose ${capabilities.compose ? "ready" : "missing"}; server-side Git ${capabilities.git ? "available" : "not installed (optional)"}.`,
deploymentReady ? "success" : "info",
);
} catch (error) {
showToast("SSH test failed", error.message, "error");
+103 -3
View File
@@ -564,7 +564,7 @@
await wait(80);
snapshot();
return {
appVersion: "0.8.9-demo",
appVersion: "0.9.0-demo",
platform: "win32",
state: clone(state),
git: { available: true, version: "git version 2.47.3" },
@@ -1204,7 +1204,7 @@
)),
...input,
id: input.id || `profile-${Date.now()}`,
provider: "gitea-actions",
provider: input.provider || existing?.provider || "gitea-actions",
inputs: existing?.inputs || {},
state: existing?.state || {
liveSha: null,
@@ -1425,14 +1425,114 @@
await wait(80);
return [
{
serverId: "unraid-primary",
serverId: "server-unraid",
serverName: "Unraid",
detected: 2,
adopted: 0,
verified: 1,
linked: 1,
unmatched: 0,
needsReview: 1,
running: 2,
stopped: 0,
capabilities: {
docker: true,
dockerReady: true,
compose: true,
git: false,
tar: true,
checksum: true,
},
warnings: [],
workloads: [
{
workloadId: "workload-demo-linked",
displayName: "Portfolio",
status: "linked",
runtime: { running: true, health: "healthy" },
compose: {
project: "portfolio",
workingDir: "/mnt/user/appdata/portfolio",
configFiles: ["/mnt/user/appdata/portfolio/docker-compose.yml"],
services: ["web"],
},
containers: [{ name: "Portfolio", running: true }],
candidates: [],
link: {
profileId: "profile-portfolio",
repositoryFullName: "jens/portfolio",
source: "manual",
},
},
{
workloadId: "workload-demo-review",
displayName: "OmniRoute",
status: "suggested",
runtime: { running: true, health: "unverified" },
compose: {
project: "omniroute",
workingDir: "/mnt/user/appdata/OmniRoute",
configFiles: ["/mnt/user/appdata/OmniRoute/docker-compose.yml"],
services: ["omniroute"],
},
containers: [{ name: "omniroute", running: true }],
remoteFolderCandidate: "OmniRoute",
candidates: repositories.slice(0, 1).map((repository) => ({
repositoryFullName: repository.fullName,
repositoryName: repository.name,
score: 55,
exact: false,
reasons: ["container and repository names are similar"],
})),
},
],
},
];
},
async linkServerWorkload(repository, serverId, workloadId, deploymentMode = "push-bundle", remoteFolder = "") {
await wait(120);
const repo = repositories.find((item) => item.fullName === repository.fullName);
if (!repo) throw new Error("Repository not found.");
const id = `profile-${workloadId}`;
const saved = {
id,
name: `Unraid · ${remoteFolder || repo.name}`,
environment: "production",
provider: "ssh-unraid",
branch: repo.defaultBranch || "main",
serverId,
remoteFolder: remoteFolder || repo.name,
deploymentMode,
composeFile: "docker-compose.yml",
composeFiles: ["docker-compose.yml"],
composeProject: String(remoteFolder || repo.name).toLowerCase(),
composeService: String(remoteFolder || repo.name).toLowerCase(),
composeServices: [String(remoteFolder || repo.name).toLowerCase()],
containerName: remoteFolder || repo.name,
preservePaths: [".env", "appdata", "data", "logs", "config"],
generatedCompose: false,
adoptedFromServer: true,
serverSourceOfTruth: true,
manageDockerMan: false,
forceRecreate: false,
removeOrphans: false,
workloadIdentity: { workloadId, linkSource: "manual", linkedAt: iso() },
confirmationRequired: true,
state: {
liveSha: null,
healthy: null,
containerRunning: true,
runtimeVerification: "running-unverified",
checkedAt: iso(),
},
};
repo.deploymentProfiles = [
...repo.deploymentProfiles.filter((item) => item.id !== id),
saved,
];
syncState();
return { profile: clone(saved), state: clone(state) };
},
async refreshOperations(operationId = null) {
await wait(300);
if (operationId) {