|
|
|
@@ -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);
|
|
|
|
|
}
|
|
|
|
|