feat: harden server pull deployments and git hygiene

This commit is contained in:
NuklearRabbit
2026-07-28 08:27:09 +02:00
parent d4d77c827a
commit 56efd1a00c
33 changed files with 2390 additions and 633 deletions
+13 -4
View File
@@ -7,7 +7,7 @@ const { safeStorage } = require('electron');
const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePath, assertRepositoryRelativePaths } = require('../shared/validation.cjs');
const DEFAULT_CONFIG = {
schemaVersion: 9,
schemaVersion: 11,
setupComplete: false,
appearance: 'dark',
gitea: { baseUrl: '', user: null, encryptedToken: null },
@@ -79,7 +79,7 @@ class ConfigStore {
const requestedDeploymentMode = String(profile.deploymentMode || '').trim();
const deploymentMode = ['push-bundle', 'server-git', 'monitor-only'].includes(requestedDeploymentMode)
? requestedDeploymentMode
: 'server-git';
: 'push-bundle';
const composeFiles = uniqueStrings(profile.composeFiles || [profile.composeFile || 'docker-compose.yml']);
const composeServices = uniqueStrings(profile.composeServices || [internalService]).map((value) => value.toLowerCase());
return {
@@ -198,7 +198,7 @@ class ConfigStore {
const port = Math.min(Math.max(Number(source.port || existing?.port || 22), 1), 65535);
const username = String(source.username || existing?.username || '').trim();
if (!username || /[\s@]/.test(username)) throw new Error('Enter a valid SSH username.');
const authType = ['password', 'privateKey'].includes(source.authType) ? source.authType : (existing?.authType || 'privateKey');
const authType = ['password', 'privateKey'].includes(source.authType) ? source.authType : (existing?.authType || 'password');
const basePath = String(source.basePath || existing?.basePath || '/mnt/user/appdata').trim().replace(/\/+$/, '');
if (!basePath.startsWith('/') || /[\r\n\0]/.test(basePath)) throw new Error('The server base path must be an absolute Unix path.');
const privateKeyPath = String(source.privateKeyPath || existing?.privateKeyPath || '').trim();
@@ -400,7 +400,9 @@ class ConfigStore {
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';
const deploymentMode = ['push-bundle', 'server-git', 'monitor-only'].includes(profile.deploymentMode)
? profile.deploymentMode
: 'push-bundle';
return {
...common,
serverId: String(profile.serverId || '').trim(),
@@ -436,6 +438,13 @@ class ConfigStore {
forceRecreate: profile.forceRecreate === true,
removeOrphans: profile.removeOrphans === true,
workloadIdentity: profile.workloadIdentity && typeof profile.workloadIdentity === 'object' ? structuredClone(profile.workloadIdentity) : null,
serverGitAccess: profile.serverGitAccess && typeof profile.serverGitAccess === 'object' ? {
configured: profile.serverGitAccess.configured === true,
deployKeyId: Number.isFinite(Number(profile.serverGitAccess.deployKeyId)) ? Number(profile.serverGitAccess.deployKeyId) : null,
keyFingerprint: String(profile.serverGitAccess.keyFingerprint || '').trim().slice(0, 200) || null,
hostFingerprint: String(profile.serverGitAccess.hostFingerprint || '').trim().slice(0, 200) || null,
configuredAt: profile.serverGitAccess.configuredAt || null
} : 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) : {},
+68
View File
@@ -24,6 +24,32 @@ coverage/
Thumbs.db
`;
const RECOMMENDED_GITATTRIBUTES = `* text=auto eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.zip binary
`;
const RECOMMENDED_EDITORCONFIG = `root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.{bat,cmd,ps1}]
end_of_line = crlf
`;
function sameRemote(left, right) {
const a = normalizeRemoteUrl(left);
const b = normalizeRemoteUrl(right);
@@ -275,6 +301,36 @@ class GitValidatorService {
},
),
);
for (const [id, title, filename, action] of [
["gitattributes", ".gitattributes normalizes text and binary files", ".gitattributes", "add-gitattributes"],
["editorconfig", ".editorconfig keeps editors consistent", ".editorconfig", "add-editorconfig"],
]) {
const present = lowerFiles.includes(filename);
checks.push(result(id, "Repository hygiene", title, present ? "pass" : "warning",
present ? `${filename} is versioned.` : `No tracked ${filename} was found.`, {
weight: 5,
fixAction: present ? null : action,
safe: false,
confirmation: `Create a recommended ${filename} in the working tree for review?`,
}));
}
const packageManagers = [
{ manifests: ["package.json"], locks: ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"] },
{ manifests: ["pyproject.toml", "requirements.in", "pipfile"], locks: ["uv.lock", "poetry.lock", "requirements.txt", "pipfile.lock"] },
{ manifests: ["composer.json"], locks: ["composer.lock"] },
{ manifests: ["gemfile"], locks: ["gemfile.lock"] },
];
const lockCheck = packageManagers.find((entry) => entry.manifests.some((name) => lowerFiles.includes(name)));
if (lockCheck) {
const lockfile = lockCheck.locks.find((name) => lowerFiles.includes(name));
checks.push(result("dependency-lock", "Supply chain", "Dependencies are reproducibly locked", lockfile ? "pass" : "warning",
lockfile ? `${lockfile} is versioned.` : "A dependency manifest exists without a recognized lockfile.", { weight: 9 }));
}
const hasCi = lowerFiles.some((file) => /^\.gitea\/workflows\/[^/]+\.ya?ml$/.test(file));
checks.push(result("continuous-integration", "Gitea governance", "Automated checks run on Gitea", hasCi ? "pass" : "warning",
hasCi ? "At least one Gitea Actions workflow is versioned." : "No .gitea/workflows YAML file was found.", { weight: 8 }));
const sensitive = tracked.filter(isSensitiveTrackedPath);
checks.push(
@@ -397,6 +453,16 @@ class GitValidatorService {
});
return { created: ".gitignore" };
}
if (["add-gitattributes", "add-editorconfig"].includes(check.fixAction)) {
const definition = check.fixAction === "add-gitattributes"
? { name: ".gitattributes", content: RECOMMENDED_GITATTRIBUTES }
: { name: ".editorconfig", content: RECOMMENDED_EDITORCONFIG };
const target = path.join(root, definition.name);
if (await fs.stat(target).catch(() => null))
throw new Error(`${definition.name} already exists; rescan before repairing.`);
await fs.writeFile(target, definition.content, { encoding: "utf8", flag: "wx" });
return { created: definition.name };
}
if (check.fixAction === "protect-default-branch") {
return this.gitea.createBranchProtection(
repository.owner.login,
@@ -411,6 +477,8 @@ class GitValidatorService {
module.exports = {
GitValidatorService,
RECOMMENDED_GITIGNORE,
RECOMMENDED_GITATTRIBUTES,
RECOMMENDED_EDITORCONFIG,
sameRemote,
isSensitiveTrackedPath,
};
+38
View File
@@ -226,6 +226,44 @@ class GiteaService {
).data;
}
async listDeployKeys(owner, repo) {
const result = await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/keys?limit=100`,
);
return Array.isArray(result.data) ? result.data : [];
}
async ensureReadOnlyDeployKey({ owner, repo, title, publicKey }) {
const key = String(publicKey || "").trim();
if (!/^ssh-(ed25519|rsa)\s+[A-Za-z0-9+/=]+(?:\s+.*)?$/.test(key))
throw new Error("The server did not return a valid SSH public key.");
const keys = await this.listDeployKeys(owner, repo);
const keyMaterial = key.split(/\s+/).slice(0, 2).join(" ");
const existing = keys.find((item) =>
String(item?.key || "").trim().split(/\s+/).slice(0, 2).join(" ") === keyMaterial,
);
if (existing) {
if (existing.read_only !== true) {
const error = new Error("The matching Gitea deploy key has write access. Revoke it before ForgeFlow configures a read-only server key.");
error.code = "DEPLOY_KEY_NOT_READ_ONLY";
throw error;
}
return { ...existing, created: false };
}
const result = await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/keys`,
{
method: "POST",
body: {
title: String(title || "ForgeFlow server deploy key").trim().slice(0, 255),
key,
read_only: true,
},
},
);
return { ...result.data, created: true };
}
async listPullRequests({ owner, repo, state = "open", limit = 30 } = {}) {
const query = new URLSearchParams({
state,
+28 -1
View File
@@ -859,6 +859,8 @@ function registerIpc({
"align-origin",
"configure-local-safety",
"add-gitignore",
"add-gitattributes",
"add-editorconfig",
"protect-default-branch",
]);
if (!allowed.has(check?.fixAction))
@@ -1121,6 +1123,20 @@ function registerIpc({
return unraid.preflight({ repository: current, profileId });
return preflight.runDeployment({ repository: current, profileId });
});
register("deployment:repair-write-access", async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
const profile = store.getDeploymentProfile(current.fullName, profileId);
if (profile?.provider !== "ssh-unraid")
throw new Error("Write-access repair is available only for SSH / Unraid deployment profiles.");
const result = await unraid.repairWriteAccess({ repository: current, profileId });
await audit.append("deployment.write-access.repaired", {
repository: current.fullName,
profileId,
changed: result.changed,
remotePath: result.after?.remotePath || result.before?.remotePath || null,
});
return result;
});
register(
"deployment:dispatch",
async ({
@@ -1176,7 +1192,7 @@ function registerIpc({
},
);
register("deployment:health", ({ url }) => deployments.checkHealth(url));
register("deployment:link-server-workload", async ({ repository, serverId, workloadId, deploymentMode = "push-bundle", remoteFolder = "" }) => {
register("deployment:link-server-workload", async ({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) => {
const current = await resolveRepository(repository);
const result = await unraid.linkServerWorkload({
repository: current,
@@ -1187,6 +1203,17 @@ function registerIpc({
});
return { ...result, state: store.getPublicState() };
});
register("deployment:configure-server-git-access", async ({ repository, profileId }) => {
const current = await resolveRepository(repository);
const result = await unraid.configureServerGitAccess({ repository: current, profileId });
await audit.append("deployment.server-git-access-configured", {
repository: current.fullName,
profileId,
keyFingerprint: result.keyFingerprint,
hostFingerprint: result.hostFingerprint,
});
return { ...result, state: store.getPublicState() };
});
register("deployment:discover-server-workloads", async () => {
const repositoryList = await repositories.refresh();
const remoteRepositories = repositoryList.filter(
+192 -18
View File
@@ -64,6 +64,8 @@ function parseServerInventory(output) {
checkouts: [],
containers: [],
dockerMan: [],
composeProjects: [],
composeDefinitions: [],
warnings: [],
};
for (const line of String(output).slice(index + marker.length).trim().split(/\r?\n/)) {
@@ -91,7 +93,7 @@ function parseServerInventory(output) {
const parsed = safeJson(decodeBase64(parts[0]), null);
if (!parsed) continue;
if (Array.isArray(parsed)) {
if (parsed[0]) inventory.containers.push(sanitizeLegacyContainer(parsed[0]));
for (const item of parsed) if (item) inventory.containers.push(sanitizeLegacyContainer(item));
} else if (parsed.Config || parsed.State) inventory.containers.push(sanitizeLegacyContainer(parsed));
else inventory.containers.push({
...parsed,
@@ -111,6 +113,32 @@ function parseServerInventory(output) {
repository: decodeBase64(parts[5]),
network: decodeBase64(parts[6]),
});
} else if (kind === 'P' && parts[0]) {
const parsed = safeJson(decodeBase64(parts[0]), []);
const projects = Array.isArray(parsed) ? parsed : parsed ? [parsed] : [];
for (const project of projects) {
const name = String(project?.Name || project?.name || '').trim();
if (!name) continue;
const rawFiles = project?.ConfigFiles || project?.configFiles || project?.config_files || [];
const configFiles = (Array.isArray(rawFiles) ? rawFiles : String(rawFiles || '').split(','))
.map((item) => String(item || '').trim())
.filter(Boolean);
inventory.composeProjects.push({
name,
status: String(project?.Status || project?.status || ''),
configFiles,
});
}
} else if (kind === 'Y' && parts[0]) {
inventory.composeDefinitions.push({
workingDir: decodeBase64(parts[0]).replace(/\/+$/, ''),
configFiles: decodeBase64(parts[1]).split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
projectName: decodeBase64(parts[2]).trim(),
services: decodeBase64(parts[3]).split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
images: decodeBase64(parts[4]).split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
valid: parts[5] === 'true',
error: decodeBase64(parts[6]).trim(),
});
} else if (kind === 'W') inventory.warnings.push(decodeBase64(parts[0]));
}
return inventory;
@@ -154,6 +182,44 @@ function topLevelRelativeToBase(basePath, candidate) {
return relative ? relative.split('/')[0] : '';
}
function canonicalServerAppdataPath(basePath, candidate) {
const value = String(candidate || '').replace(/\\/g, '/').replace(/\/+$/, '');
if (!value) return '';
const bases = [...new Set([
String(basePath || '').replace(/\/+$/, ''),
'/mnt/user/appdata',
'/mnt/cache/appdata',
].filter(Boolean))];
for (const base of bases) {
const relative = safeRelativeToBase(base, value);
if (relative) return `${String(basePath || base).replace(/\/+$/, '')}/${relative}`;
if (value === base) return String(basePath || base).replace(/\/+$/, '');
}
const diskMatch = value.match(/^\/mnt\/disk\d+\/appdata\/(.+)$/i);
if (diskMatch) return `${String(basePath || '/mnt/user/appdata').replace(/\/+$/, '')}/${diskMatch[1]}`;
return value;
}
function isDeploymentBackupPath(value) {
const segments = String(value || '').replace(/\\/g, '/').split('/').filter(Boolean);
return segments.some((segment) =>
/^source-pre-[0-9a-f]{7,64}$/i.test(segment)
|| /^forgeflow-(backup|staging|rollback)(?:[-_.].*)?$/i.test(segment)
|| ['.forgeflow', 'releases', 'backups', 'staging', 'incoming', '_audit_quarantine', 'devrunbook-validation'].includes(segment.toLowerCase()),
);
}
function deploymentRootCandidate(relativePath) {
const segments = String(relativePath || '').replace(/\\/g, '/').split('/').filter(Boolean);
const forgeFlowIndex = segments.indexOf('.forgeflow');
if (forgeFlowIndex > 0) return segments.slice(0, forgeFlowIndex).join('/');
const releasesIndex = segments.indexOf('releases');
if (releasesIndex > 0 && segments.length > releasesIndex + 1) return segments.slice(0, releasesIndex).join('/');
const backupIndex = segments.findIndex((segment) => /^source-pre-[0-9a-f]{7,64}$/i.test(segment));
if (backupIndex > 0) return segments.slice(0, backupIndex).join('/');
return segments.join('/');
}
function workloadSelector(group) {
if (group.composeProject) return {
kind: 'compose',
@@ -182,6 +248,7 @@ function profileMatchesWorkload(profile, workload) {
if (profile.composeProject && workload.compose?.project && profile.composeProject === workload.compose.project) {
if (!profile.composeWorkingDir || !workload.compose.workingDir || profile.composeWorkingDir === workload.compose.workingDir) return true;
}
if (profile.remoteFolder && workload.remoteFolderCandidate && profile.remoteFolder === workload.remoteFolderCandidate) return true;
return workload.containers.some((container) => container.name === profile.containerName);
}
@@ -198,11 +265,12 @@ function repositoryRemoteMap(repositories) {
function candidateRepositories(workload, repositories, checkouts) {
const candidates = new Map();
const add = (repository, points, reason, exact = false) => {
const add = (repository, points, reason, exact = false, identityExact = false) => {
if (!repository?.fullName) return;
const current = candidates.get(repository.fullName) || { repositoryFullName: repository.fullName, repositoryName: repository.name, score: 0, exact: false, reasons: [] };
const current = candidates.get(repository.fullName) || { repositoryFullName: repository.fullName, repositoryName: repository.name, score: 0, exact: false, identityExact: false, reasons: [] };
current.score += points;
current.exact ||= exact;
current.identityExact ||= identityExact;
if (reason && !current.reasons.includes(reason)) current.reasons.push(reason);
candidates.set(repository.fullName, current);
};
@@ -230,31 +298,42 @@ function candidateRepositories(workload, repositories, checkouts) {
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),
const composeProjectName = normalizedName(workload.compose.project);
const composeFolderName = normalizedName(path.basename(workload.compose.workingDir || ''));
const deploymentFolderName = normalizedName(String(workload.remoteFolderCandidate || '').split('/')[0]);
const serviceNames = new Set((workload.compose.services || []).map(normalizedName).filter(Boolean));
const containerNames = new Set(workload.containers.map((container) => normalizedName(container.name)).filter(Boolean));
const imageNames = new Set([
...workload.containers.map((container) => String(container.image || '').split('/').pop()?.split(':')[0]),
].filter(Boolean).map(normalizedName));
...(workload.metadata?.images || []).map((image) => String(image || '').split('/').pop()?.split(':')[0]),
].map(normalizedName).filter(Boolean));
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');
if (!repoName) continue;
if (composeProjectName && composeProjectName === repoName) add(repository, 55, 'Compose project name matches repository', false, true);
if (deploymentFolderName && deploymentFolderName === repoName) add(repository, 70, 'Top-level appdata folder exactly matches repository', false, true);
if (composeFolderName && composeFolderName === repoName) add(repository, 50, 'Compose file folder matches repository');
if (serviceNames.has(repoName)) add(repository, 25, 'Compose service name matches repository');
if (containerNames.has(repoName)) add(repository, 70, 'Container name exactly matches repository', false, true);
if (imageNames.has(repoName)) add(repository, 20, 'Container image name matches repository');
}
return [...candidates.values()].sort((a, b) => b.score - a.score || a.repositoryFullName.localeCompare(b.repositoryFullName)).map((candidate) => ({
...candidate,
reasons: candidate.exact
? candidate.reasons
: [...candidate.reasons, 'Manual confirmation is reduced to one click; Compose identity and paths are prefilled from the server.'],
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 dockerManByName = new Map((inventory.dockerMan || []).map((item) => [String(item.name || '').toLowerCase(), 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 workingDir = canonicalServerAppdataPath(server.basePath, labels['com.docker.compose.project.working_dir']);
const configFiles = [...new Set(configFilesFor(container).map((file) => canonicalServerAppdataPath(server.basePath, file)))];
const key = composeProject
? `compose:${composeProject}:${workingDir}:${configFiles.join('|')}`
: `container:${container.name}`;
@@ -263,15 +342,103 @@ function buildWorkloadInventory({ inventory, server, repositories = [], profiles
workingDir,
configFiles,
services: [],
images: [],
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;
group.dockerMan ||= dockerManByName.get(String(container.name || '').toLowerCase()) || null;
groups.set(key, group);
}
for (const project of inventory.composeProjects || []) {
const configFiles = [...new Set((project.configFiles || []).filter(Boolean).map((file) => canonicalServerAppdataPath(server.basePath, file)))];
const workingDir = configFiles.length ? canonicalServerAppdataPath(server.basePath, path.dirname(configFiles[0])) : '';
const key = `compose:${project.name}:${workingDir}:${configFiles.join('|')}`;
if (groups.has(key)) continue;
const existingByProject = [...groups.values()].find((group) => group.composeProject === project.name);
if (existingByProject) {
if (!existingByProject.configFiles.length && configFiles.length) existingByProject.configFiles = configFiles;
if (!existingByProject.workingDir && workingDir) existingByProject.workingDir = workingDir;
continue;
}
groups.set(key, {
composeProject: project.name,
workingDir,
configFiles,
services: [],
images: [],
containers: [],
dockerMan: dockerManByName.get(String(project.name || '').toLowerCase()) || null,
composeStatus: project.status || '',
});
}
for (const definition of inventory.composeDefinitions || []) {
const configFiles = [...new Set((definition.configFiles || []).filter(Boolean).map((file) => canonicalServerAppdataPath(server.basePath, file)))];
const workingDir = canonicalServerAppdataPath(server.basePath, definition.workingDir || (configFiles[0] ? path.dirname(configFiles[0]) : ''));
if (isDeploymentBackupPath(workingDir) || configFiles.some(isDeploymentBackupPath)) continue;
const projectName = String(definition.projectName || path.basename(workingDir || '')).trim();
const existing = [...groups.values()].find((group) => {
if (workingDir && group.workingDir && group.workingDir === workingDir) return true;
if (configFiles.length && (group.configFiles || []).some((file) => configFiles.includes(file))) return true;
return Boolean(projectName && group.composeProject === projectName && (!workingDir || !group.workingDir));
});
if (existing) {
existing.composeProject ||= projectName;
existing.workingDir ||= workingDir;
existing.configFiles = [...new Set([...(existing.configFiles || []), ...configFiles])];
existing.services = [...new Set([...(existing.services || []), ...(definition.services || [])])];
existing.images = [...new Set([...(existing.images || []), ...(definition.images || [])])];
existing.composeDefinitionValid = definition.valid;
existing.composeDefinitionError = definition.error || '';
existing.composeSource = 'server-compose-file';
continue;
}
const key = `compose-file:${projectName}:${workingDir}:${configFiles.join('|')}`;
groups.set(key, {
composeProject: projectName,
workingDir,
configFiles,
services: [...new Set(definition.services || [])],
images: [...new Set(definition.images || [])],
containers: [],
dockerMan: dockerManByName.get(projectName.toLowerCase()) || null,
composeStatus: '',
composeDefinitionValid: definition.valid,
composeDefinitionError: definition.error || '',
composeSource: 'server-compose-file',
});
}
const containerNames = new Set((inventory.containers || []).map((container) => String(container.name || '').toLowerCase()));
for (const dockerMan of inventory.dockerMan || []) {
const normalized = String(dockerMan.name || '').toLowerCase();
if (!normalized || containerNames.has(normalized)) continue;
const key = `container:${dockerMan.name}`;
if (groups.has(key)) continue;
groups.set(key, {
composeProject: '',
workingDir: '',
configFiles: [],
services: [],
images: dockerMan.repository ? [dockerMan.repository] : [],
containers: [{
id: '',
name: dockerMan.name,
image: dockerMan.repository || '',
imageId: '',
running: false,
status: 'template-only',
health: null,
labels: {},
ports: {},
mounts: [],
networks: dockerMan.network ? { [dockerMan.network]: {} } : {},
restartPolicy: '',
}],
dockerMan,
});
}
const workloads = [];
for (const group of groups.values()) {
const selector = workloadSelector(group);
@@ -279,8 +446,8 @@ function buildWorkloadInventory({ inventory, server, repositories = [], profiles
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 remoteFolderCandidate = deploymentRootCandidate(safeRelativeToBase(server.basePath, canonicalServerAppdataPath(server.basePath, group.workingDir)))
|| mounts.map((mount) => topLevelRelativeToBase(server.basePath, canonicalServerAppdataPath(server.basePath, mount?.Source))).find(Boolean)
|| '';
const workload = {
workloadId,
@@ -288,7 +455,7 @@ function buildWorkloadInventory({ inventory, server, repositories = [], profiles
serverName: server.name,
kind: selector.kind,
selector,
displayName: group.composeProject || primary?.name || 'Unnamed workload',
displayName: group.composeProject || primary?.name || group.dockerMan?.name || 'Unnamed workload',
compose: {
project: group.composeProject,
workingDir: group.workingDir,
@@ -322,10 +489,15 @@ function buildWorkloadInventory({ inventory, server, repositories = [], profiles
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'] || '',
composeStatus: group.composeStatus || '',
images: [...new Set(group.images || [])],
composeSource: group.composeSource || (group.configFiles?.length ? 'docker-compose-runtime' : ''),
composeDefinitionValid: group.composeDefinitionValid !== false,
composeDefinitionError: group.composeDefinitionError || '',
},
runtime: {
running: group.containers.some((container) => container.running === true),
allRunning: group.containers.every((container) => container.running === true),
allRunning: group.containers.length > 0 && 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')
@@ -400,4 +572,6 @@ module.exports = {
profileMatchesWorkload,
sanitizeLegacyContainer,
safeRelativeToBase,
canonicalServerAppdataPath,
deploymentRootCandidate,
};
File diff suppressed because it is too large Load Diff
+346 -96
View File
@@ -207,6 +207,51 @@ function selectedProfile(repository = selectedRepository()) {
repository.deploymentProfiles[0]
);
}
function canDirectPushDeploy(repository, profile = selectedProfile(repository)) {
const status = repository?.localStatus;
const mode = deploymentMode(profile);
return Boolean(
repository?.localPath
&& status?.head
&& !status?.counts?.changed
&& !status?.counts?.conflicts
&& profile
&& mode === "push-bundle"
&& profile.branch === status.branch?.head,
);
}
function deploymentMode(profile) {
if (profile?.provider !== "ssh-unraid") return "gitea-actions";
return ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode)
? profile.deploymentMode
: "push-bundle";
}
function deploymentTargetSha(repository, profile = selectedProfile(repository)) {
return deploymentMode(profile) === "server-git"
? profile?.state?.giteaSha || null
: repository?.localStatus?.head || null;
}
function canServerGitDeploy(repository, profile = selectedProfile(repository)) {
const target = deploymentTargetSha(repository, profile);
return Boolean(
profile
&& deploymentMode(profile) === "server-git"
&& target
&& profile.branch
&& !(profile.state?.liveSha === target && profile.state?.healthy !== false),
);
}
function canDeploy(repository, profile = selectedProfile(repository)) {
const mode = deploymentMode(profile);
if (mode === "server-git") return canServerGitDeploy(repository, profile);
if (mode === "push-bundle") return canDirectPushDeploy(repository, profile);
return profile?.provider === "gitea-actions" && repository?.readyToDeploy;
}
function operations() {
return ui.boot?.state?.operations || [];
}
@@ -234,6 +279,13 @@ function showToast(title, message, type = "info") {
setTimeout(() => toast.remove(), 5600);
}
function isSshCredentialError(error) {
const code = String(error?.code || "");
const message = String(error?.message || "");
return ["SSH_PRIVATE_KEY_READ_FAILED", "SSH_PRIVATE_KEY_NOT_FOUND", "SSH_CONNECTION_FAILED"].includes(code)
|| /private key|publickey|authentication methods failed|permission denied|authentication failed/i.test(message);
}
function setLoading(loading, message = "") {
ui.loading = loading;
ui.loadingMessage = message;
@@ -547,14 +599,13 @@ function repositoryAction(repository) {
return {
kind: "error",
title: "Local repository unavailable",
detail:
repository.attentionReason || "The linked folder could not be read.",
detail: repository.attentionReason || "The linked folder could not be read.",
};
if (status.counts.conflicts)
return {
kind: "conflict",
title: "Resolve merge conflicts",
detail: `${status.counts.conflicts} conflicted file${status.counts.conflicts === 1 ? "" : "s"} block synchronization.`,
detail: `${status.counts.conflicts} conflicted file${status.counts.conflicts === 1 ? "" : "s"} block deployment.`,
};
if (status.counts.changed)
return {
@@ -562,11 +613,30 @@ function repositoryAction(repository) {
title: "Commit local changes",
detail: `${status.counts.changed} changed file${status.counts.changed === 1 ? "" : "s"} detected.`,
};
if (!repository.deploymentProfiles?.length)
return {
kind: "configure",
title: "Configure deployment",
detail: "Connect an Unraid server or a Gitea Actions workflow before deploying.",
};
const profile = selectedProfile(repository);
if (profile?.branch !== status.branch.head)
return {
kind: "branch-profile",
title: "No deployment for this branch",
detail: `The selected profile accepts ${profile.branch}; you are on ${status.branch.head}.`,
};
if (canDirectPushDeploy(repository, profile))
return {
kind: "deploy",
title: "Ready for direct redeploy",
detail: `ForgeFlow will copy committed HEAD ${status.shortHead} directly to ${profile.environment} over the configured desktop → Unraid connection.`,
};
if (status.branch.behind && status.branch.ahead)
return {
kind: "diverged",
title: "Branches have diverged",
detail: `Local is ${status.branch.ahead} ahead and ${status.branch.behind} behind. ForgeFlow can create a safety branch and repair this from Git tools.`,
detail: `Local is ${status.branch.ahead} ahead and ${status.branch.behind} behind.`,
};
if (status.branch.behind)
return {
@@ -580,19 +650,6 @@ function repositoryAction(repository) {
title: "Push local commits",
detail: `${status.branch.ahead} commit${status.branch.ahead === 1 ? "" : "s"} ready to push.`,
};
if (!repository.deploymentProfiles?.length)
return {
kind: "configure",
title: "Configure deployment",
detail: "Connect a predefined Gitea Actions workflow before deploying.",
};
const profile = selectedProfile(repository);
if (profile?.branch !== status.branch.head)
return {
kind: "branch-profile",
title: "No deployment for this branch",
detail: `The selected profile accepts ${profile.branch}; you are on ${status.branch.head}.`,
};
if (repository.readyToDeploy)
return {
kind: "deploy",
@@ -920,14 +977,12 @@ 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 mode = deploymentMode(profile);
const targetSha = deploymentTargetSha(repository, profile);
const ready = canDeploy(repository, profile);
const modeLabel = {
"push-bundle": "Push bundle",
"server-git": "Server-side Git",
"push-bundle": "Direct copy",
"server-git": "Server pull from Gitea",
"monitor-only": "Monitor only",
}[mode] || mode;
const providerDetail = isSsh
@@ -940,11 +995,17 @@ function renderProfileCard(repository, profile, compact = false) {
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>`
: "";
const syncLabel = isSsh
? state.matchesGitea
? `<span class="sync-proof success">${icon("check")}Live = Gitea · ${shortSha(state.liveSha)}</span>`
: state.liveSha && state.giteaSha
? `<span class="sync-proof warning">Live ${shortSha(state.liveSha)} · Gitea ${shortSha(state.giteaSha)}</span>`
: state.liveSha ? `<span class="sync-proof success">${icon("check")}Live · ${shortSha(state.liveSha)}</span>` : ""
: 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>`
: "";
const dockerManLabel = managesDockerMan
? dockerManReady
? templateReady
@@ -952,7 +1013,13 @@ function renderProfileCard(repository, profile, compact = false) {
: "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>`;
const sourceLabel = isSsh
? mode === "server-git" ? `Gitea ${state.giteaSha ? shortSha(state.giteaSha) : "refresh required"}` : "Committed local HEAD"
: state.giteaSha ? shortSha(state.giteaSha) : "Refresh to compare";
const serverAccessAction = isSsh && mode === "server-git"
? `<button class="button" data-action="configure-server-git-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("key")}Configure Gitea access</button>`
: "";
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>Deploy source</span><strong>${escapeHtml(sourceLabel)}</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>${isSsh ? `<button class="button" data-action="repair-deployment-write-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}Check / fix write access</button>` : ""}${serverAccessAction}<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(shortSha(targetSha))}</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 || [];
@@ -1106,27 +1173,54 @@ function renderActionPanel(repository) {
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 configuredServers = ui.boot?.state?.servers || [];
const visibleForServer = (server) => (server.workloads || []).filter((workload) =>
workload.link || (workload.runtime?.running && workload.status !== "unmatched"),
);
const reviewCount = servers.reduce((total, server) => total + visibleForServer(server).filter((workload) => !workload.link).length, 0);
const serverCards = 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>`;
const capabilityText = [
capabilities.docker ? "Docker" : "Docker missing",
capabilities.compose ? "Compose" : "Compose missing",
capabilities.git ? "Git available" : "Git optional",
capabilities.tar && capabilities.checksum ? "Push ready" : "Push tools incomplete",
].join(" · ");
const errorBlock = server.error
? `<div class="notice danger">${icon("error")}<div><strong>Server scan failed</strong><p>${escapeHtml(server.error)}</p><div class="stack horizontal compact" style="margin-top:8px"><button class="button primary" data-action="use-server-password" data-server-id="${attr(server.serverId)}" data-retry="scan">Use server password instead</button><button class="button" data-action="test-server" data-server-id="${attr(server.serverId)}">Test connection</button></div></div></div>`
: "";
const warnings = (server.warnings || []).map((warning) => `<div class="notice warning">${icon("warning")}${escapeHtml(warning)}</div>`).join("");
const visibleWorkloads = visibleForServer(server);
const hiddenCount = Math.max(0, (server.workloads || []).length - visibleWorkloads.length);
const workloads = visibleWorkloads.length
? visibleWorkloads.map((workload) => {
const containers = (workload.containers || []).map((container) => container.name).filter(Boolean).join(", ");
const topCandidate = workload.candidates?.[0];
const linked = workload.status === "linked" || Boolean(workload.link);
const statusTone = linked ? "success" : workload.status === "ambiguous" ? "danger" : "warning";
const detail = workload.compose?.project
? `Compose ${workload.compose.project} · ${(workload.compose.services || []).join(", ") || "services unknown"}`
: workload.dockerMan?.templatePath
? `DockerMan ${workload.dockerMan.name || workload.displayName} · ${containers || "template only"}`
: `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";
const canQuickLink = !linked && topCandidate && ["exact", "strong"].includes(topCandidate.confidence) && Boolean(workload.remoteFolderCandidate);
const linkButton = canQuickLink
? `<button class="button primary" data-action="quick-link-server-workload" data-server-id="${attr(server.serverId)}" data-workload-id="${attr(workload.workloadId)}" data-repository="${attr(topCandidate.repositoryFullName)}">${icon("link")}Link to ${escapeHtml(topCandidate.repositoryName || topCandidate.repositoryFullName)}</button>`
: `<button class="button primary" data-action="link-server-workload" data-server-id="${attr(server.serverId)}" data-workload-id="${attr(workload.workloadId)}">${icon("link")}Review & link</button>`;
return `<div class="tool-row"><div><strong>${escapeHtml(workload.displayName)}</strong><span>${escapeHtml(detail)} · ${workload.runtime?.running ? "running" : "stopped"}</span><span>${escapeHtml(candidate)}</span>${workload.metadata?.composeDefinitionError ? `<span class="text-warning">Compose file found; validation warning: ${escapeHtml(workload.metadata.composeDefinitionError)}</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>` : linkButton}</div></div>`;
}).join("")
: `<div class="empty-state compact"><p>${server.error ? "No inventory could be read until the SSH connection works." : "Docker returned no containers, Compose projects or DockerMan templates."}</p></div>`;
return `<section class="panel server-inventory-panel"><div class="panel-header"><div><h3>${escapeHtml(server.serverName || server.server?.name || server.serverId)}</h3><span class="meta">${server.running || 0} running · ${server.linked || 0} repository links · ${visibleWorkloads.filter((workload) => !workload.link).length} to review${hiddenCount ? ` · ${hiddenCount} unrelated/system workloads hidden` : ""}</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">${errorBlock}${warnings}<div class="tool-list">${workloads}</div></div></section>`;
}).join("");
const empty = configuredServers.length
? `<div class="empty-state panel"><h3>Server inventory has not completed</h3><p>ForgeFlow will query Docker directly. A failed connection is shown explicitly instead of being reported as zero deployments.</p><button class="button primary" data-action="scan-server-inventory">Scan servers now</button></div>`
: `<div class="empty-state panel"><h3>No Unraid server configured</h3><p>Add the server with password authentication and ForgeFlow can copy and deploy projects directly.</p><button class="button primary" data-action="open-add-server">Add server</button></div>`;
return `<section class="section-block"><div class="section-heading"><div><h2>Server inventory</h2><span class="meta">Live Docker, Compose and DockerMan discovery, linked to Gitea</span></div><button class="button ${reviewCount ? "primary" : ""}" data-action="scan-server-inventory">${icon("refresh")}Scan servers</button></div>${servers.length ? `<div class="stack">${serverCards}</div>` : empty}<div class="notice" style="margin-top:12px">${icon("shield")}Server pull fetches an exact Gitea commit through a repository-scoped read-only deploy key, validates Compose and only then promotes the release. Direct copy remains an explicit fallback.</div></section>`;
}
function renderDeployments() {
@@ -1140,7 +1234,7 @@ function renderDeployments() {
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>`;
return `<div class="page"><div class="page-header visual-page-header"><div><div class="eyebrow">Server releases</div><h1>Deployments</h1><p>Discover live Unraid workloads, verify them against Gitea and release an exact commit through a protected server pull.</p></div>${projectIllustration("deploy")}<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">Stable Compose identity, live container health and exact Gitea commit parity</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;
@@ -1177,7 +1271,7 @@ function renderPreflightChecks(
) {
if (!report?.checks?.length)
return `<div class="empty-state compact"><p>${escapeHtml(emptyMessage)}</p></div>`;
return `<div class="preflight-list">${report.checks.map((item) => `<div class="preflight-row"><span class="preflight-state ${preflightTone(item.status)}">${item.status === "pass" ? icon("check") : item.status === "fail" ? icon("error") : icon("warning")}</span><div><strong>${escapeHtml(item.label)}</strong><span>${escapeHtml(item.detail)}</span>${item.help ? `<small>${escapeHtml(item.help)}</small>` : ""}</div><span class="status-pill ${preflightTone(item.status)}">${escapeHtml(item.status)}</span></div>`).join("")}</div>`;
return `<div class="preflight-list">${report.checks.map((item) => `<div class="preflight-row"><span class="preflight-state ${preflightTone(item.status)}">${item.status === "pass" ? icon("check") : item.status === "fail" ? icon("error") : icon("warning")}</span><div><strong>${escapeHtml(item.label)}</strong><span>${escapeHtml(item.detail)}</span>${item.help ? `<small>${escapeHtml(item.help)}</small>` : ""}${item.repairAction ? `<button class="button primary compact-button" data-action="${attr(item.repairAction)}" data-profile-id="${attr(ui.selectedProfileId || "")}">${icon("wrench")}${escapeHtml(item.repairLabel || "Repair")}</button>` : ""}</div><span class="status-pill ${preflightTone(item.status)}">${escapeHtml(item.status)}</span></div>`).join("")}</div>`;
}
function renderDiagnostics() {
@@ -1267,6 +1361,12 @@ function renderModal() {
ui.repositories.find(
(repo) => repo.fullName === ui.modal.repositoryFullName,
);
if (ui.modal.type === "server-password") {
const server = (ui.boot?.state?.servers || []).find((item) => item.id === ui.modal.serverId);
if (!server) return `<div class="modal-backdrop"><section class="modal"><header class="modal-header"><h2>Server password</h2></header><div class="modal-body"><div class="notice danger">${icon("error")}The selected server no longer exists.</div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Close</button></footer></section></div>`;
const retryText = ui.modal.retry?.type === "deploy" ? "Save password & redeploy" : "Save password & rescan";
return `<div class="modal-backdrop" role="presentation"><section class="modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Use password for ${escapeHtml(server.name)}</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="notice success">${icon("shield")}ForgeFlow stores the server password with Windows protected storage and uses it only for the desktop → Unraid connection.</div><div class="field" style="margin-top:14px"><label>SSH password for ${escapeHtml(server.username)}@${escapeHtml(server.host)}</label><input id="quick-server-password" class="input" type="password" autocomplete="current-password" autofocus placeholder="Server password"/></div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="confirm-server-password" data-server-id="${attr(server.id)}">${escapeHtml(retryText)}</button></footer></section></div>`;
}
if (ui.modal.type === "workload-link") {
const serverResult = (ui.serverDiscovery || []).find(
(item) => item.serverId === ui.modal.serverId,
@@ -1304,7 +1404,7 @@ function renderModal() {
.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>`;
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 repositories available</option>'}</select></div><div class="field"><label>Deployment source</label><select id="workload-deployment-mode" class="select"><option value="server-git" selected>Server pull from Gitea</option><option value="push-bundle">Direct copy fallback</option><option value="monitor-only">Monitor only</option></select></div><div class="field"><label>Detected deployment folder</label><input id="workload-remote-folder" class="input" value="${attr(remoteFolder)}" readonly/></div></div><div class="notice success" style="margin-top:12px">${icon("shield")}ForgeFlow preserves the detected Compose project, services and container identity. Server pull provisions a repository-scoped read-only key and activates only the selected Gitea commit.</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 =
@@ -1330,13 +1430,11 @@ function renderModal() {
? `
<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>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>Deployment mode</label><select id="profile-deployment-mode" class="select"><option value="server-git" ${(existing.deploymentMode || "server-git") === "server-git" ? "selected" : ""}>Server pull from Gitea</option><option value="push-bundle" ${existing.deploymentMode === "push-bundle" ? "selected" : ""}>Direct copy & deploy</option><option value="monitor-only" ${existing.deploymentMode === "monitor-only" ? "selected" : ""}>Monitor only</option></select><small>Server pull uses an automatically managed repository-scoped read-only deploy key. Direct copy remains available as a fallback and never requires Gitea credentials on Unraid.</small></div>
<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 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. These real server Compose files remain authoritative; ForgeFlow does not inject a synthetic service overlay.</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>Discovery hints only. At deployment time ForgeFlow reads the actual service keys from docker compose config and verifies every active service.</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>
@@ -1345,15 +1443,15 @@ function renderModal() {
<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>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>
<label class="check-field"><input id="profile-force-recreate" type="checkbox" disabled/><span>Destructive force-recreate disabled</span><small>ForgeFlow builds first and lets Compose replace only services whose image or configuration actually changed.</small></label>
<label class="check-field"><input id="profile-remove-orphans" type="checkbox" disabled/><span>Orphan removal disabled</span><small>ForgeFlow never removes unrelated or orphaned containers during a deployment.</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 ? "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>`;
}<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 ? "Server pull fetches the exact selected Gitea commit with a repository-scoped read-only key, validates Compose and services, then promotes atomically with rollback protection." : "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 =
@@ -1368,7 +1466,8 @@ function renderModal() {
repository?.deploymentProfiles?.find(
(item) => item.id === ui.modal.profileId,
) || selectedProfile(repository);
return `<div class="modal-backdrop" role="presentation"><section class="modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Confirm production action</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero">${icon("rocket")}<div><strong>Deploy ${escapeHtml(repository.localStatus.shortHead)}${escapeHtml(profile.environment)}</strong><span>${escapeHtml(repository.fullName)}</span></div></div><div class="confirm-grid"><span>Exact commit</span><strong class="mono">${escapeHtml(repository.localStatus.head)}</strong><span>Branch</span><strong>${escapeHtml(profile.branch)}</strong><span>Provider</span><strong>${profile.provider === "ssh-unraid" ? `SSH → ${escapeHtml(profile.remoteFolder)}` : escapeHtml(profile.workflowFile)}</strong><span>Healthcheck</span><strong>${escapeHtml(profile.healthcheckUrl || "Not configured")}</strong></div>${ui.deploymentPreflight ? `<div class="notice success" style="margin-top:12px">${icon("shield")}Preflight passed with ${ui.deploymentPreflight.summary.counts.warning} warning(s). Backend safety checks run again at dispatch time.</div>` : ""}</div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button success" data-action="confirm-deploy" data-profile-id="${attr(profile.id)}">Deploy exact commit</button></footer></section></div>`;
const targetSha = deploymentTargetSha(repository, profile);
return `<div class="modal-backdrop" role="presentation"><section class="modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Confirm production action</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero">${icon("rocket")}<div><strong>Deploy ${escapeHtml(shortSha(targetSha))}${escapeHtml(profile.environment)}</strong><span>${escapeHtml(repository.fullName)}</span></div></div><div class="confirm-grid"><span>Exact commit</span><strong class="mono">${escapeHtml(targetSha || "Unavailable")}</strong><span>Branch</span><strong>${escapeHtml(profile.branch)}</strong><span>Provider</span><strong>${profile.provider === "ssh-unraid" ? `${deploymentMode(profile) === "server-git" ? "Gitea → Unraid" : "Desktop → Unraid"} · ${escapeHtml(profile.remoteFolder)}` : escapeHtml(profile.workflowFile)}</strong><span>Healthcheck</span><strong>${escapeHtml(profile.healthcheckUrl || "Not configured")}</strong></div>${ui.deploymentPreflight ? `<div class="notice success" style="margin-top:12px">${icon("shield")}Preflight passed with ${ui.deploymentPreflight.summary.counts.warning} warning(s). Backend safety checks run again at dispatch time.</div>` : ""}</div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button success" data-action="confirm-deploy" data-profile-id="${attr(profile.id)}" ${targetSha ? "" : "disabled"}>Deploy exact commit</button></footer></section></div>`;
}
if (ui.modal.type === "rollback-confirm") {
const profile = repository?.deploymentProfiles?.find(
@@ -1382,8 +1481,8 @@ function renderModal() {
(ui.boot.state.servers || []).find(
(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")}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>`;
const authType = ui.modal.authType || server.authType || "password";
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 · optional</option><option value="password" ${authType === "password" ? "selected" : ""}>Password · no key</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 login secures the desktop → Unraid connection. Server pull separately creates one read-only deploy key per repository and pins the Gitea SSH host key. No reusable Gitea token is stored on Unraid.</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 || [];
@@ -1455,11 +1554,11 @@ function paletteCommands() {
{
id: "deploy-selected",
label: "Deploy selected repository",
detail: repository?.readyToDeploy
? `${repository.name} ${repository.localStatus.shortHead}`
detail: canDeploy(repository)
? `${repository.name} ${shortSha(deploymentTargetSha(repository))}`
: "Not ready",
icon: "rocket",
enabled: Boolean(repository?.readyToDeploy),
enabled: canDeploy(repository),
},
];
}
@@ -1577,6 +1676,11 @@ async function executeDeployment(profileId) {
repository?.deploymentProfiles?.find((item) => item.id === profileId) ||
selectedProfile(repository);
if (!repository || !profile) return;
const targetSha = deploymentTargetSha(repository, profile);
if (!targetSha) {
showToast("Refresh required", "Refresh Gitea and server truth before deploying this environment.", "error");
return;
}
const deploymentOptions = {
note: document.querySelector("#deployment-note")?.value.trim() || "",
override: document.querySelector("#deployment-override")?.checked === true,
@@ -1594,19 +1698,33 @@ async function executeDeployment(profileId) {
ui.activeDeployment = await window.forgeflow.deploy(
repository,
profile.id,
repository.localStatus.head,
targetSha,
deploymentOptions,
);
updateOperationInState(ui.activeDeployment);
ui.currentView = "deployment-run";
showToast(
"Deployment started",
`${repository.name} ${repository.localStatus.shortHead}${profile.environment}`,
`${repository.name} ${shortSha(targetSha)}${profile.environment}`,
"success",
);
startOperationPolling();
} catch (error) {
showToast("Deployment failed to start", error.message, "error");
if (profile.provider === "ssh-unraid" && isSshCredentialError(error)) {
ui.modal = {
type: "server-password",
serverId: profile.serverId,
retry: {
type: "deploy",
repositoryFullName: repository.fullName,
profileId: profile.id,
},
};
showToast("SSH key rejected", "Enter the Unraid server password once; ForgeFlow will retry the direct desktop → Unraid connection.", "error");
render();
} else {
showToast("Deployment failed to start", error.message, "error");
}
}
setLoading(false);
}
@@ -1742,6 +1860,11 @@ app.addEventListener("click", async (event) => {
ui.currentView = target.dataset.view;
ui.modal = null;
render();
if (ui.currentView === "deployments" && (ui.boot?.state?.servers || []).length && !(ui.serverDiscovery || []).length) {
setLoading(true, "Reading Docker, Compose and DockerMan inventory from Unraid…");
await refreshDeploymentTruth(true);
setLoading(false);
}
} else if (action === "select-repo") selectRepository(target.dataset.id);
else if (action === "refresh") {
await refreshRepositories(true);
@@ -2220,15 +2343,60 @@ app.addEventListener("click", async (event) => {
(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",
);
const failures = (ui.serverDiscovery || []).filter((item) => item.error);
if (failures.length) {
showToast(
"Server scan failed",
failures.map((item) => `${item.serverName || item.serverId}: ${item.error}`).join(" · "),
"error",
);
} else {
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 === "quick-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,
);
const linkedRepository = ui.repositories.find(
(item) => item.fullName === target.dataset.repository,
);
if (!workload || !linkedRepository || !workload.remoteFolderCandidate) {
showToast("Automatic link unavailable", "Scan the server again and use Review & link.", "error");
return;
}
setLoading(true, `Linking ${workload.displayName} to ${linkedRepository.fullName}`);
try {
const result = await window.forgeflow.linkServerWorkload(
linkedRepository,
target.dataset.serverId,
target.dataset.workloadId,
"server-git",
workload.remoteFolderCandidate,
);
if (result.state) ui.boot.state = result.state;
ui.selectedProfileId = result.profile?.id || null;
await refreshRepositories(false, true);
await refreshDeploymentTruth(false);
showToast(
"Deployment linked",
`${linkedRepository.fullName} is linked to ${workload.compose?.workingDir || workload.remoteFolderCandidate}. Compose values were read from the server.`,
"success",
);
} catch (error) {
showToast("Could not link deployment", error.message, "error");
}
setLoading(false);
} else if (action === "link-server-workload") {
const serverResult = (ui.serverDiscovery || []).find(
(item) => item.serverId === target.dataset.serverId,
@@ -2260,9 +2428,7 @@ app.addEventListener("click", async (event) => {
const repositoryFullName = document
.querySelector("#workload-repository")
?.value.trim();
const deploymentMode =
document.querySelector("#workload-deployment-mode")?.value ||
"push-bundle";
const deploymentMode = document.querySelector("#workload-deployment-mode")?.value || "server-git";
const remoteFolder = document
.querySelector("#workload-remote-folder")
?.value.trim();
@@ -2272,7 +2438,7 @@ app.addEventListener("click", async (event) => {
if (!linkedRepository) {
showToast(
"Choose a repository",
"The workload must be linked to a Gitea repository.",
"The workload must be linked to a ForgeFlow project.",
"error",
);
return;
@@ -2293,7 +2459,7 @@ app.addEventListener("click", async (event) => {
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"}.`,
`${linkedRepository.fullName} now uses direct desktop-to-Unraid copy and the Compose configuration detected on the server.`,
"success",
);
} catch (error) {
@@ -2446,12 +2612,11 @@ 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,
deploymentMode: ["server-git", "push-bundle", "monitor-only"].includes(
document.querySelector("#profile-deployment-mode")?.value,
) ? document.querySelector("#profile-deployment-mode").value : "server-git",
cloneUrl: previousProfile.cloneUrl || "",
alignRemote: false,
generatedCompose:
document.querySelector("#profile-generated-compose").value ===
"true",
@@ -2491,12 +2656,8 @@ app.addEventListener("click", async (event) => {
manageDockerMan:
document.querySelector("#profile-manage-dockerman")?.checked ===
true,
forceRecreate:
document.querySelector("#profile-force-recreate")?.checked ===
true,
removeOrphans:
document.querySelector("#profile-remove-orphans")?.checked ===
true,
forceRecreate: false,
removeOrphans: false,
adoptedFromServer: Boolean(
ui.deploymentDiscovery || previousProfile.adoptedFromServer,
),
@@ -2578,6 +2739,57 @@ app.addEventListener("click", async (event) => {
} else if (action === "run-deployment-preflight") {
if (!repository) repository = profileRepository(target.dataset.profileId);
await runDeploymentPreflight(repository, target.dataset.profileId);
} else if (action === "configure-server-git-access") {
const profileId = target.dataset.profileId || ui.selectedProfileId;
if (!repository) repository = profileRepository(profileId);
if (!repository || !profileId) return;
const approved = confirm(
`Configure read-only Gitea access for ${repository.fullName}?\n\nForgeFlow creates a dedicated SSH deploy key on the selected server, adds only its public key to this Gitea repository and pins the observed Gitea SSH host key. The private key never leaves the server.`,
);
if (!approved) return;
setLoading(true, "Configuring repository-scoped Gitea access…");
try {
const result = await window.forgeflow.configureServerGitAccess(repository, profileId);
if (result.state) ui.boot.state = result.state;
await refreshRepositories(false, true);
await refreshDeploymentTruth(false);
showToast(
"Server pull ready",
`Read-only Gitea access verified at ${shortSha(result.remoteSha)}.`,
"success",
);
await runDeploymentPreflight(repository, profileId, { showModal: true });
} catch (error) {
showToast("Could not configure Gitea access", error.message, "error");
} finally {
setLoading(false);
}
} else if (action === "repair-deployment-write-access") {
const profileId = target.dataset.profileId || ui.selectedProfileId;
if (!repository) repository = profileRepository(profileId);
if (!repository || !profileId) return;
const profile = repository.deploymentProfiles?.find((item) => item.id === profileId);
const approved = confirm(
`Repair write access for ${repository.fullName} on ${profile?.name || profile?.environment || "the linked Unraid deployment"}?\n\nForgeFlow will only adjust the linked project source tree and its .forgeflow state folders. Preserved runtime paths such as appdata, data, config and logs are excluded. No container will be stopped, removed or recreated.`,
);
if (!approved) return;
setLoading(true, "Repairing scoped Unraid write access…");
try {
const result = await window.forgeflow.repairDeploymentWriteAccess(
repository,
profileId,
);
showToast(
"Write access normalized",
"Project source and ForgeFlow upload folders now use safe shared write permissions. Preserved runtime data was not changed.",
"success",
);
await runDeploymentPreflight(repository, profileId, { showModal: true });
} catch (error) {
showToast("Write-access repair failed", error.message, "error");
} finally {
setLoading(false);
}
} else if (action === "deploy-profile") {
if (repository && String(repository.id) !== String(ui.selectedRepoId))
selectRepository(repository.id, false);
@@ -2892,11 +3104,49 @@ app.addEventListener("click", async (event) => {
showToast("Could not launch update", error.message, "error");
setLoading(false);
}
} else if (action === "use-server-password") {
ui.modal = {
type: "server-password",
serverId: target.dataset.serverId,
retry: { type: target.dataset.retry || "scan" },
};
render();
} else if (action === "confirm-server-password") {
const server = (ui.boot?.state?.servers || []).find((item) => item.id === target.dataset.serverId);
const password = document.querySelector("#quick-server-password")?.value || "";
if (!server || !password) {
showToast("Password required", "Enter the Unraid SSH password.", "error");
return;
}
const retry = ui.modal?.retry || { type: "scan" };
setLoading(true, "Switching the server connection to password authentication…");
try {
const saved = await window.forgeflow.saveServer(
{ ...server, authType: "password", privateKeyPath: "" },
password,
"",
);
ui.boot.state = saved.state;
const tested = await window.forgeflow.testServer(server.id);
ui.boot.state = tested.state;
ui.modal = null;
showToast("Server password saved", "ForgeFlow will no longer use an SSH key for this server.", "success");
if (retry.type === "deploy") {
const retryRepository = ui.repositories.find((item) => item.fullName === retry.repositoryFullName);
if (retryRepository) ui.selectedRepoId = retryRepository.id;
await executeDeployment(retry.profileId);
} else {
await refreshDeploymentTruth(true);
}
} catch (error) {
showToast("Server authentication failed", error.message, "error");
}
setLoading(false);
} else if (action === "open-add-server") {
ui.modal = {
type: "server-config",
serverId: null,
authType: "privateKey",
authType: "password",
};
render();
} else if (action === "edit-server") {
@@ -2906,7 +3156,7 @@ app.addEventListener("click", async (event) => {
ui.modal = {
type: "server-config",
serverId: target.dataset.serverId,
authType: server?.authType || "privateKey",
authType: server?.authType || "password",
};
render();
} else if (action === "select-private-key") {
@@ -2966,7 +3216,7 @@ app.addEventListener("click", async (event) => {
capabilities.docker && capabilities.dockerReady && capabilities.compose;
showToast(
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)"}.`,
`${result.server.name} presented ${result.fingerprint}. Docker ${capabilities.dockerReady ? "ready" : "unavailable"}; Compose ${capabilities.compose ? "ready" : "missing"}.`,
deploymentReady ? "success" : "info",
);
} catch (error) {
@@ -3499,7 +3749,7 @@ Force repair after you have closed all Git tools for this repository?`)
await window.forgeflow.openPath(repository.localPath);
else if (command === "git-tools" && repository)
await loadGitTools(repository);
else if (command === "deploy-selected" && repository?.readyToDeploy) {
else if (command === "deploy-selected" && canDeploy(repository)) {
const profile = selectedProfile(repository);
if (profile) await runDeploymentPreflight(repository, profile.id);
}
+1
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark light" />
<title>ForgeFlow</title>
<link rel="icon" type="image/png" href="./assets/itworx-mark.png" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
+34 -3
View File
@@ -112,6 +112,24 @@
},
});
const sshProfile = (id, name, environment, options = {}) => ({
id, name, environment, provider: "ssh-unraid", branch: options.branch || "main",
serverId: "demo-unraid", remoteFolder: options.remoteFolder || name,
deploymentMode: "server-git", composeFiles: ["compose.yml"],
composeProject: options.composeProject || String(options.remoteFolder || name).toLowerCase(),
composeServices: options.composeServices || [String(options.remoteFolder || name).toLowerCase()],
containerName: options.containerName || options.remoteFolder || name,
generatedCompose: false, adoptedFromServer: true, serverSourceOfTruth: true,
confirmationRequired: true,
serverGitAccess: { configured: options.accessConfigured !== false, keyFingerprint: "SHA256:demo", hostFingerprint: "SHA256:gitea", configuredAt: iso(-3600000) },
state: {
liveSha: options.liveSha || null, giteaSha: options.giteaSha || options.liveSha || null,
previousSha: options.previousSha || null, healthy: options.healthy ?? true,
containerRunning: true, runtimeVerification: "verified", matchesGitea: options.matchesGitea ?? true,
checkedAt: iso(-120000), dockerMan: { templateExists: true, webUi: true, icon: true },
},
});
const now = iso();
const defaultPreferences = {
autoRefresh: true,
@@ -360,10 +378,14 @@
}),
linkState: "linked",
deploymentProfiles: [
profile("profile-portfolio", "Production", "production", {
sshProfile("profile-portfolio", "Production", "production", {
remoteFolder: "Portfolio",
containerName: "Portfolio",
liveSha: "4c20dd11bb6147fc8b6633d2b08500c93402a719",
giteaSha: "a7f2e1c1bb6147fc8b6633d2b08500c93402a719",
previousSha: "31adfe11bb6147fc8b6633d2b08500c93402a719",
healthy: false,
matchesGitea: false,
}),
],
},
@@ -564,7 +586,7 @@
await wait(80);
snapshot();
return {
appVersion: "0.9.0-demo",
appVersion: "0.10.0-demo",
platform: "win32",
state: clone(state),
git: { available: true, version: "git version 2.47.3" },
@@ -1489,7 +1511,7 @@
},
];
},
async linkServerWorkload(repository, serverId, workloadId, deploymentMode = "push-bundle", remoteFolder = "") {
async linkServerWorkload(repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "") {
await wait(120);
const repo = repositories.find((item) => item.fullName === repository.fullName);
if (!repo) throw new Error("Repository not found.");
@@ -1533,6 +1555,15 @@
syncState();
return { profile: clone(saved), state: clone(state) };
},
async configureServerGitAccess(repository, profileId) {
const repo = repositories.find((item) => item.fullName === repository.fullName);
const target = repo?.deploymentProfiles.find((item) => item.id === profileId);
if (!target) throw new Error("Deployment profile not found.");
target.deploymentMode = "server-git";
target.serverGitAccess = { configured: true, keyFingerprint: "SHA256:demo", hostFingerprint: "SHA256:gitea", configuredAt: iso() };
syncState();
return { profile: clone(target), created: true, remoteSha: target.state?.giteaSha || repo.localStatus?.head };
},
async refreshOperations(operationId = null) {
await wait(300);
if (operationId) {
+25
View File
@@ -2612,6 +2612,27 @@ kbd {
color: var(--text-faint);
font: 10px var(--font-mono);
}
.server-inventory-panel {
position: relative;
overflow: hidden;
border-color: color-mix(in srgb, var(--primary) 22%, var(--line));
background:
radial-gradient(circle at 94% 0%, color-mix(in srgb, var(--primary) 10%, transparent), transparent 34%),
var(--surface-1);
}
.server-inventory-panel::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 3px;
background: linear-gradient(180deg, var(--primary), var(--success));
}
.server-inventory-panel .tool-row {
transition: background 150ms ease, transform 150ms ease;
}
.server-inventory-panel .tool-row:hover {
transform: translateX(2px);
}
.deploy-card-header h3 {
margin: 4px 0 3px;
font-size: 14px;
@@ -2915,6 +2936,10 @@ kbd {
.preflight-row small {
color: var(--text-muted);
}
.preflight-row .compact-button {
width: fit-content;
margin-top: 9px;
}
.preflight-state {
width: 25px;
height: 25px;
+1 -1
View File
@@ -70,7 +70,7 @@ function validateShellScriptStructure(scriptText) {
'flock -n 9',
'git -C "$APP_DIR" fetch',
'git -C "$APP_DIR" reset --hard "$SHA"',
'docker compose -f "$COMPOSE_FILE" up -d --build --remove-orphans',
'docker compose -f "$COMPOSE_FILE" up -d --build',
'write_status "healthy"',
'write_status "unhealthy"'
]) {