Update
This commit is contained in:
+261
-61
@@ -429,7 +429,7 @@ async function refreshDeploymentTruth(showErrors = false) {
|
||||
await refreshRepositories(false, true);
|
||||
showToast(
|
||||
"Server workloads discovered",
|
||||
`${adopted} running deployment${adopted === 1 ? " was" : "s were"} linked to Gitea automatically.`,
|
||||
`${adopted} workload${adopted === 1 ? " was" : "s were"} linked automatically from exact repository provenance.`,
|
||||
"success",
|
||||
);
|
||||
}
|
||||
@@ -874,11 +874,9 @@ function environmentState(profile) {
|
||||
const state = profile.state || {};
|
||||
if (state.healthy === false) return { label: "Unhealthy", tone: "danger" };
|
||||
if (state.healthy === true) return { label: "Healthy", tone: "success" };
|
||||
if (
|
||||
profile.provider === "ssh-unraid" ||
|
||||
state.statusConfigured ||
|
||||
state.healthConfigured
|
||||
)
|
||||
if (state.containerRunning === true) return { label: "Running · unverified", tone: "warning" };
|
||||
if (state.containerRunning === false) return { label: "Stopped", tone: "danger" };
|
||||
if (profile.provider === "ssh-unraid" || state.statusConfigured || state.healthConfigured)
|
||||
return { label: "Not checked", tone: "" };
|
||||
return { label: "Status not configured", tone: "" };
|
||||
}
|
||||
@@ -921,28 +919,41 @@ function deploymentIdentity(profile, repository) {
|
||||
function renderProfileCard(repository, profile, compact = false) {
|
||||
const state = profile.state || {};
|
||||
const health = environmentState(profile);
|
||||
const isSsh = profile.provider === "ssh-unraid";
|
||||
const mode = isSsh ? profile.deploymentMode || "server-git" : "gitea-actions";
|
||||
const ready =
|
||||
mode !== "monitor-only" &&
|
||||
repository.readyToDeploy &&
|
||||
repository.localStatus?.branch.head === profile.branch;
|
||||
const isSsh = profile.provider === "ssh-unraid";
|
||||
const modeLabel = {
|
||||
"push-bundle": "Push bundle",
|
||||
"server-git": "Server-side Git",
|
||||
"monitor-only": "Monitor only",
|
||||
}[mode] || mode;
|
||||
const providerDetail = isSsh
|
||||
? `SSH / Unraid · ${profile.remoteFolder || repository.name} · ${profile.branch}${profile.adoptedFromServer ? " · automatically discovered" : ""}`
|
||||
? `SSH / Unraid · ${modeLabel} · ${profile.remoteFolder || repository.name} · ${profile.branch}${profile.adoptedFromServer ? " · server-linked" : ""}`
|
||||
: `${profile.workflowFile} · ${profile.branch}`;
|
||||
const rollbackConfigured = isSsh || Boolean(profile.rollbackWorkflowFile);
|
||||
const rollbackConfigured = (isSsh && mode !== "monitor-only") || Boolean(profile.rollbackWorkflowFile);
|
||||
const dockerMan = dockerManIntegration(profile);
|
||||
const { templateReady, webUiReady, iconReady } = dockerMan;
|
||||
const dockerManReady = dockerMan.ready;
|
||||
const webUi =
|
||||
profile.webUiUrl || state.webUiUrl || state.dockerMan?.webUi || "";
|
||||
const managesDockerMan = isSsh && profile.manageDockerMan === true;
|
||||
const webUi = profile.webUiUrl || state.webUiUrl || state.dockerMan?.webUi || "";
|
||||
const identity = deploymentIdentity(profile, repository);
|
||||
const syncLabel = state.matchesGitea
|
||||
? `<span class="sync-proof success">${icon("check")}Live = Gitea · ${shortSha(state.liveSha)}</span>`
|
||||
: state.giteaSha && state.liveSha
|
||||
? `<span class="sync-proof warning">Live ${shortSha(state.liveSha)} · Gitea ${shortSha(state.giteaSha)}</span>`
|
||||
: "";
|
||||
return `<article class="deploy-card accent-${identity.accent} ${compact ? "compact-card" : ""}"><div class="container-identity"><span class="container-avatar">${escapeHtml(identity.initial)}</span><div><span>Container</span><strong>${escapeHtml(identity.name)}</strong><small>${escapeHtml(repository.fullName)} · ${escapeHtml(profile.environment)}</small></div>${syncLabel}</div><div class="deploy-card-header"><div><div class="eyebrow">${escapeHtml(isSsh ? "SSH / UNRAID" : "GITEA ACTIONS")}</div><h3>${escapeHtml(profile.name)}</h3><p>${escapeHtml(providerDetail)}</p></div><span class="status-pill ${health.tone}"><span class="state-dot ${health.tone}"></span>${health.label}</span></div><div class="deploy-card-body"><div class="deploy-metadata"><span>Live commit</span><strong>${state.liveSha ? shortSha(state.liveSha) : "Unknown"}</strong><span>Gitea commit</span><strong>${state.giteaSha ? shortSha(state.giteaSha) : "Refresh to compare"}</strong><span>Previous version</span><strong>${state.previousSha ? shortSha(state.previousSha) : "Unknown"}</strong><span>Last checked</span><strong>${state.checkedAt ? formatDate(state.checkedAt) : "Never"}</strong>${isSsh ? `<span>Runtime</span><strong>${state.containerRunning === false ? "Stopped" : state.containerRunning ? "Running" : "Unknown"}</strong><span>DockerMan</span><strong class="${dockerManReady ? "text-success" : "text-warning"}">${dockerManReady ? (templateReady ? "Labels/template active" : "WebUI/icon labels active") : `WebUI ${webUiReady ? "ready" : "missing"} · icon ${iconReady ? "ready" : "missing"}`}</strong>` : ""}<span>Rollback</span><strong>${rollbackConfigured ? "Available after first deploy" : "Not configured"}</strong></div><div class="card-actions"><button class="button" data-action="run-deployment-preflight" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("shield")}Preflight</button><button class="button" data-action="reconcile-deployment" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("refresh")}Refresh truth</button>${webUi ? `<button class="button" data-action="open-profile-webui" data-url="${attr(webUi)}">${icon("external")}Open Web UI</button>` : ""}${isSsh ? `<button class="button ${dockerManReady ? "ghost" : ""}" data-action="apply-dockerman-metadata" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}${dockerManReady ? "Reapply DockerMan metadata" : "Repair DockerMan integration"}</button>` : ""}${ready ? `<button class="button primary" data-action="deploy-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("rocket")}Deploy ${escapeHtml(repository.localStatus.shortHead)}</button>` : ""}<button class="button ghost" data-action="edit-deployment-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">Edit</button>${state.previousSha && rollbackConfigured ? `<button class="button danger" data-action="rollback-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("undo")}Rollback</button>` : ""}</div></div></article>`;
|
||||
const dockerManLabel = managesDockerMan
|
||||
? dockerManReady
|
||||
? templateReady
|
||||
? "Managed labels/template active"
|
||||
: "Managed labels active"
|
||||
: `Managed · WebUI ${webUiReady ? "ready" : "missing"} · icon ${iconReady ? "ready" : "missing"}`
|
||||
: "Existing DockerMan template preserved";
|
||||
return `<article class="deploy-card accent-${identity.accent} ${compact ? "compact-card" : ""}"><div class="container-identity"><span class="container-avatar">${escapeHtml(identity.initial)}</span><div><span>Container</span><strong>${escapeHtml(identity.name)}</strong><small>${escapeHtml(repository.fullName)} · ${escapeHtml(profile.environment)}</small></div>${syncLabel}</div><div class="deploy-card-header"><div><div class="eyebrow">${escapeHtml(isSsh ? "SSH / UNRAID" : "GITEA ACTIONS")}</div><h3>${escapeHtml(profile.name)}</h3><p>${escapeHtml(providerDetail)}</p></div><span class="status-pill ${health.tone}"><span class="state-dot ${health.tone}"></span>${health.label}</span></div><div class="deploy-card-body"><div class="deploy-metadata"><span>Live commit</span><strong>${state.liveSha ? shortSha(state.liveSha) : "Unknown"}</strong><span>Gitea commit</span><strong>${state.giteaSha ? shortSha(state.giteaSha) : "Refresh to compare"}</strong><span>Previous version</span><strong>${state.previousSha ? shortSha(state.previousSha) : "Unknown"}</strong><span>Last checked</span><strong>${state.checkedAt ? formatDate(state.checkedAt) : "Never"}</strong>${isSsh ? `<span>Deployment mode</span><strong>${escapeHtml(modeLabel)}</strong><span>Compose project</span><strong>${escapeHtml(profile.composeProject || "ForgeFlow-generated identity")}</strong><span>Runtime</span><strong>${state.containerRunning === false ? "Stopped" : state.containerRunning ? state.runtimeVerification === "running-unverified" ? "Running · unverified" : "Running" : "Unknown"}</strong><span>DockerMan</span><strong class="${managesDockerMan && !dockerManReady ? "text-warning" : "text-success"}">${escapeHtml(dockerManLabel)}</strong>` : ""}<span>Rollback</span><strong>${rollbackConfigured ? "Available after first deploy" : "Not configured"}</strong></div><div class="card-actions"><button class="button" data-action="run-deployment-preflight" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("shield")}Preflight</button><button class="button" data-action="reconcile-deployment" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("refresh")}Refresh truth</button>${webUi ? `<button class="button" data-action="open-profile-webui" data-url="${attr(webUi)}">${icon("external")}Open Web UI</button>` : ""}${managesDockerMan ? `<button class="button ${dockerManReady ? "ghost" : ""}" data-action="apply-dockerman-metadata" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("wrench")}${dockerManReady ? "Reapply DockerMan integration" : "Repair DockerMan integration"}</button>` : ""}${ready ? `<button class="button primary" data-action="deploy-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("rocket")}Deploy ${escapeHtml(repository.localStatus.shortHead)}</button>` : ""}<button class="button ghost" data-action="edit-deployment-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">Edit</button>${state.previousSha && rollbackConfigured ? `<button class="button danger" data-action="rollback-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("undo")}Rollback</button>` : ""}</div></div></article>`;
|
||||
}
|
||||
|
||||
function renderRepositoryDeployments(repository) {
|
||||
const profiles = repository.deploymentProfiles || [];
|
||||
const repoOps = repositoryOperations(repository).slice(0, 10);
|
||||
@@ -1093,34 +1104,44 @@ function renderActionPanel(repository) {
|
||||
return `<aside class="action-panel"><div class="action-panel-head"><div class="eyebrow">Next action</div><h2>${escapeHtml(action.title)}</h2><p>${escapeHtml(action.detail)}</p></div><div class="action-panel-body">${body}</div>${repository.localPath ? `<div class="action-panel-footer"><button class="button ghost" data-action="open-path">${icon("folder")}Open folder</button><button class="button ghost" data-action="load-git-tools">${icon("branch")}Git tools</button></div>` : ""}</aside>`;
|
||||
}
|
||||
|
||||
function renderDeployments() {
|
||||
const cards = ui.repositories.flatMap((repository) =>
|
||||
(repository.deploymentProfiles || []).map((profile) => ({
|
||||
repository,
|
||||
profile,
|
||||
})),
|
||||
);
|
||||
const active = operations().filter(
|
||||
(operation) => !isTerminalOperation(operation.status),
|
||||
);
|
||||
const missingDockerMan = cards.filter(
|
||||
({ profile }) =>
|
||||
profile.provider === "ssh-unraid" &&
|
||||
profile.state?.containerRunning &&
|
||||
!dockerManIntegration(profile).ready,
|
||||
);
|
||||
return `<div class="page"><div class="page-header"><div><div class="eyebrow">Server releases</div><h1>Deployments</h1><p>Exact commits, live container truth, DockerMan integration and controlled release recovery.</p></div><div class="stack horizontal compact"><button class="button" data-action="refresh-operations">${icon("refresh")}Refresh runs & servers</button>${missingDockerMan.length ? `<button class="button primary" data-action="repair-missing-dockerman">${icon("wrench")}Repair ${missingDockerMan.length} missing integration${missingDockerMan.length === 1 ? "" : "s"}</button>` : ""}</div></div>${active.length ? `<div class="notice warning">${icon("pulse")} ${active.length} deployment operation${active.length === 1 ? " is" : "s are"} still active. ForgeFlow reconciles these against the live server automatically.</div>` : ""}<div class="deploy-card-grid">${cards.length ? cards.map(({ repository, profile }) => renderProfileCard(repository, profile, true)).join("") : '<div class="empty-state panel"><h3>No deployment environments configured</h3><p>Open a repository and add an environment.</p></div>'}</div><section class="section-block"><div class="section-heading"><h2>All operations</h2><span class="meta">Newest first</span></div><div class="panel">${
|
||||
operations().length
|
||||
? `<table class="data-table"><thead><tr><th>Repository</th><th>Action</th><th>Environment</th><th>Commit</th><th>Status</th><th>Updated</th><th></th></tr></thead><tbody>${operations()
|
||||
.map(
|
||||
(operation) =>
|
||||
`<tr><td>${escapeHtml(operation.repository)}</td><td>${escapeHtml(operation.action || "deploy")}</td><td>${escapeHtml(operation.environment || "—")}</td><td class="mono">${escapeHtml(operation.shortSha || shortSha(operation.sha))}</td><td><span class="status-pill ${toneForStatus(operation.status)}">${escapeHtml(operation.status)}</span></td><td>${formatDate(operation.updatedAt || operation.createdAt)}</td><td><button class="button ghost" data-action="open-operation" data-operation-id="${attr(operation.id)}">Open</button></td></tr>`,
|
||||
)
|
||||
.join("")}</tbody></table>`
|
||||
: '<div class="empty-state compact"><p>No operations recorded.</p></div>'
|
||||
}</div></section></div>`;
|
||||
function renderServerInventory() {
|
||||
const servers = ui.serverDiscovery || [];
|
||||
const workloadCount = servers.reduce((total, server) => total + (server.workloads?.length || 0), 0);
|
||||
const reviewCount = servers.reduce((total, server) => total + Number(server.needsReview || 0), 0);
|
||||
return `<section class="section-block"><div class="section-heading"><div><h2>Server inventory</h2><span class="meta">Running and stopped Docker workloads, including installations without a Git checkout</span></div><button class="button ${reviewCount ? "primary" : ""}" data-action="scan-server-inventory">${icon("refresh")}Scan servers</button></div>${servers.length ? `<div class="stack">${servers.map((server) => {
|
||||
const capabilities = server.capabilities || {};
|
||||
const capabilityText = [capabilities.docker ? "Docker" : "Docker missing", capabilities.compose ? "Compose" : "Compose missing", capabilities.git ? "Git" : "Git optional/missing", capabilities.tar && capabilities.checksum ? "Push ready" : "Push tools incomplete"].join(" · ");
|
||||
return `<section class="panel"><div class="panel-header"><div><h3>${escapeHtml(server.serverName || server.server?.name || server.serverId)}</h3><span class="meta">${server.detected || 0} workloads · ${server.running || 0} running · ${server.linked || 0} linked · ${server.needsReview || 0} review</span></div><span class="status-pill ${server.error ? "danger" : capabilities.docker && capabilities.compose ? "success" : "warning"}">${server.error ? "Scan failed" : escapeHtml(capabilityText)}</span></div><div class="panel-body">${server.error ? `<div class="notice danger">${icon("error")}${escapeHtml(server.error)}</div>` : ""}${(server.warnings || []).map((warning) => `<div class="notice warning">${icon("warning")}${escapeHtml(warning)}</div>`).join("")}<div class="tool-list">${(server.workloads || []).length ? server.workloads.map((workload) => {
|
||||
const containers = (workload.containers || []).map((container) => container.name).join(", ");
|
||||
const topCandidate = workload.candidates?.[0];
|
||||
const linked = workload.status === "linked" || Boolean(workload.link);
|
||||
const statusTone = linked ? "success" : workload.status === "ambiguous" ? "danger" : workload.status === "unmatched" ? "warning" : "warning";
|
||||
const detail = workload.compose?.project
|
||||
? `Compose ${workload.compose.project} · ${(workload.compose.services || []).join(", ") || "services unknown"}`
|
||||
: `Container installation · ${containers || "unnamed"}`;
|
||||
const candidate = linked
|
||||
? `Linked to ${workload.link?.repositoryFullName || "repository"}`
|
||||
: topCandidate
|
||||
? `${topCandidate.repositoryFullName} suggested · ${topCandidate.confidence}`
|
||||
: "No repository candidate; select one manually";
|
||||
return `<div class="tool-row"><div><strong>${escapeHtml(workload.displayName)}</strong><span>${escapeHtml(detail)} · ${workload.runtime?.running ? "running" : "stopped"}</span><span>${escapeHtml(candidate)}</span></div><div class="stack horizontal compact"><span class="status-pill ${statusTone}">${escapeHtml(linked ? "Linked" : workload.status || "Review")}</span>${linked ? `<button class="button ghost" data-action="edit-deployment-profile" data-profile-id="${attr(workload.link?.profileId || "")}">Open link</button>` : `<button class="button primary" data-action="link-server-workload" data-server-id="${attr(server.serverId)}" data-workload-id="${attr(workload.workloadId)}">${icon("link")}Link deployment</button>`}</div></div>`;
|
||||
}).join("") : '<div class="empty-state compact"><p>No Docker workloads were returned by this server.</p></div>'}</div></div></section>`;
|
||||
}).join("")}</div>` : `<div class="empty-state panel"><h3>Server inventory not scanned</h3><p>Scan the configured servers to detect existing DockerMan, Docker and Compose installations.</p><button class="button primary" data-action="scan-server-inventory">Scan servers</button></div>`}<div class="notice" style="margin-top:12px">${icon("shield")}Only exact repository provenance is linked automatically. Name similarity remains a manual decision. Push bundle deployments reuse the desktop → Unraid SSH connection and do not require a Gitea key on Unraid.</div></section>`;
|
||||
}
|
||||
|
||||
function renderDeployments() {
|
||||
const cards = ui.repositories.flatMap((repository) =>
|
||||
(repository.deploymentProfiles || []).map((profile) => ({ repository, profile })),
|
||||
);
|
||||
const active = operations().filter((operation) => !isTerminalOperation(operation.status));
|
||||
const missingDockerMan = cards.filter(({ profile }) =>
|
||||
profile.provider === "ssh-unraid" &&
|
||||
profile.manageDockerMan === true &&
|
||||
profile.state?.containerRunning &&
|
||||
!dockerManIntegration(profile).ready,
|
||||
);
|
||||
return `<div class="page"><div class="page-header"><div><div class="eyebrow">Server releases</div><h1>Deployments</h1><p>Discover existing installations, link uncertain workloads and deploy exact commit bundles without server-side Gitea credentials.</p></div><div class="stack horizontal compact"><button class="button" data-action="refresh-operations">${icon("refresh")}Refresh runs & servers</button>${missingDockerMan.length ? `<button class="button primary" data-action="repair-missing-dockerman">${icon("wrench")}Repair ${missingDockerMan.length} managed integration${missingDockerMan.length === 1 ? "" : "s"}</button>` : ""}</div></div>${active.length ? `<div class="notice warning">${icon("pulse")} ${active.length} deployment operation${active.length === 1 ? " is" : "s are"} still active. ForgeFlow reconciles these against the live server automatically.</div>` : ""}${renderServerInventory()}<section class="section-block"><div class="section-heading"><div><h2>Linked deployment environments</h2><span class="meta">Manual links remain stable across container recreations through Compose/workload identity</span></div></div><div class="deploy-card-grid">${cards.length ? cards.map(({ repository, profile }) => renderProfileCard(repository, profile, true)).join("") : '<div class="empty-state panel"><h3>No deployment environments configured</h3><p>Scan a server and link an existing workload, or open a repository and add an environment.</p></div>'}</div></section><section class="section-block"><div class="section-heading"><h2>All operations</h2><span class="meta">Newest first</span></div><div class="panel">${operations().length ? `<table class="data-table"><thead><tr><th>Repository</th><th>Action</th><th>Environment</th><th>Commit</th><th>Status</th><th>Updated</th><th></th></tr></thead><tbody>${operations().map((operation) => `<tr><td>${escapeHtml(operation.repository)}</td><td>${escapeHtml(operation.action || "deploy")}</td><td>${escapeHtml(operation.environment || "—")}</td><td class="mono">${escapeHtml(operation.shortSha || shortSha(operation.sha))}</td><td><span class="status-pill ${toneForStatus(operation.status)}">${escapeHtml(operation.status)}</span></td><td>${formatDate(operation.updatedAt || operation.createdAt)}</td><td><button class="button ghost" data-action="open-operation" data-operation-id="${attr(operation.id)}">Open</button></td></tr>`).join("")}</tbody></table>` : '<div class="empty-state compact"><p>No operations recorded.</p></div>'}</div></section></div>`;
|
||||
}
|
||||
function renderSettings() {
|
||||
const state = ui.boot.state;
|
||||
const prefs = state.preferences || {};
|
||||
@@ -1246,6 +1267,45 @@ function renderModal() {
|
||||
ui.repositories.find(
|
||||
(repo) => repo.fullName === ui.modal.repositoryFullName,
|
||||
);
|
||||
if (ui.modal.type === "workload-link") {
|
||||
const serverResult = (ui.serverDiscovery || []).find(
|
||||
(item) => item.serverId === ui.modal.serverId,
|
||||
);
|
||||
const workload = serverResult?.workloads?.find(
|
||||
(item) => item.workloadId === ui.modal.workloadId,
|
||||
);
|
||||
if (!workload) {
|
||||
return `<div class="modal-backdrop" role="presentation"><section class="modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Link server workload</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="notice danger">${icon("error")}This workload is no longer present in the latest server inventory. Scan the servers again.</div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Close</button></footer></section></div>`;
|
||||
}
|
||||
const availableRepositories = ui.repositories.filter((item) => item.fullName);
|
||||
const suggestedRepository =
|
||||
ui.modal.repositoryFullName ||
|
||||
workload.candidates?.[0]?.repositoryFullName ||
|
||||
selectedRepository()?.fullName ||
|
||||
availableRepositories[0]?.fullName ||
|
||||
"";
|
||||
const selectedLinkRepository = availableRepositories.find(
|
||||
(item) => item.fullName === suggestedRepository,
|
||||
);
|
||||
const remoteFolder =
|
||||
ui.modal.remoteFolder ||
|
||||
workload.remoteFolderCandidate ||
|
||||
safeCloneFolderName(selectedLinkRepository);
|
||||
const candidateSummary = workload.candidates?.length
|
||||
? workload.candidates
|
||||
.slice(0, 4)
|
||||
.map(
|
||||
(candidate) =>
|
||||
`<div class="context-row"><span>${escapeHtml(candidate.repositoryFullName)}</span><strong>${escapeHtml(candidate.exact ? "Exact provenance" : `${candidate.score} confidence`)} · ${escapeHtml((candidate.reasons || []).join(", ") || "name similarity")}</strong></div>`,
|
||||
)
|
||||
.join("")
|
||||
: '<div class="context-row"><span>Repository candidates</span><strong>No confident match; choose manually.</strong></div>';
|
||||
const containerNames = (workload.containers || [])
|
||||
.map((container) => container.name)
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Link existing server workload</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero">${icon("link")}<div><strong>${escapeHtml(workload.displayName)}</strong><span>${escapeHtml(serverResult?.serverName || serverResult?.server?.name || ui.modal.serverId)} · ${workload.runtime?.running ? "running" : "stopped"}</span></div></div><div class="context-summary"><div class="context-row"><span>Containers</span><strong>${escapeHtml(containerNames || "Unknown")}</strong></div><div class="context-row"><span>Compose identity</span><strong>${escapeHtml(workload.compose?.project || "DockerMan / standalone container")} ${workload.compose?.services?.length ? `· ${escapeHtml(workload.compose.services.join(", "))}` : ""}</strong></div><div class="context-row"><span>Detected folder</span><strong class="mono">${escapeHtml(workload.compose?.workingDir || workload.dockerMan?.templatePath || "No Git checkout required")}</strong></div>${candidateSummary}</div><div class="form-grid" style="margin-top:14px"><div class="field full"><label>Repository to link</label><select id="workload-repository" class="select">${availableRepositories.map((item) => `<option value="${attr(item.fullName)}" ${item.fullName === suggestedRepository ? "selected" : ""}>${escapeHtml(item.fullName)}</option>`).join("") || '<option value="">No Gitea repositories available</option>'}</select></div><div class="field"><label>Deployment mode</label><select id="workload-deployment-mode" class="select"><option value="push-bundle" selected>Push bundle · recommended</option><option value="monitor-only">Monitor only</option><option value="server-git">Server-side Git · advanced</option></select></div><div class="field"><label>Managed server folder</label><input id="workload-remote-folder" class="input" value="${attr(remoteFolder)}"/></div></div><div class="notice success" style="margin-top:12px">${icon("shield")}Push bundle uploads the exact local Git commit over the already configured desktop → Unraid SSH connection. No Gitea SSH key is needed on Unraid. Linking does not recreate containers or rewrite an existing DockerMan template.</div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="confirm-link-server-workload" data-server-id="${attr(ui.modal.serverId)}" data-workload-id="${attr(ui.modal.workloadId)}" ${availableRepositories.length ? "" : "disabled"}>Link workload</button></footer></section></div>`;
|
||||
}
|
||||
if (ui.modal.type === "deployment-config") {
|
||||
const storedProfile =
|
||||
repository?.deploymentProfiles?.find(
|
||||
@@ -1265,30 +1325,35 @@ function renderModal() {
|
||||
const ssh = provider === "ssh-unraid";
|
||||
const remoteFolder =
|
||||
existing.remoteFolder || safeCloneFolderName(repository);
|
||||
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>${existing.id ? "Edit" : "Add"} deployment environment</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="form-grid"><div class="field full"><label>Deployment provider</label><select id="profile-provider" class="select"><option value="ssh-unraid" ${ssh ? "selected" : ""}>SSH / Unraid · direct controlled deployment</option><option value="gitea-actions" ${!ssh ? "selected" : ""}>Gitea Actions · runner workflow</option></select></div>${ssh ? `<div class="field full"><div class="notice ${discovery ? "success" : ""}">${icon(discovery ? "check" : "server")}<div><strong>${discovery ? "Existing deployment imported from server" : "Use the server as source of truth"}</strong><p>${discovery ? `${escapeHtml(discovery.runtime?.containers || 0)} container(s), ${escapeHtml(discovery.runtime?.services || 0)} service(s) and ${escapeHtml(discovery.runtime?.ports?.length || 0)} port mapping(s) detected. Every imported value remains editable as an explicit override.` : "Select the server and folder, then let ForgeFlow read Git, Compose, Docker and DockerMan instead of guessing values."}</p><button type="button" class="button ${discovery ? "" : "primary"}" data-action="discover-existing-deployment">${icon("refresh")}${discovery ? "Rescan server" : "Import existing deployment"}</button></div></div></div>` : ""}<div class="field"><label>Profile name</label><input id="profile-name" class="input" value="${attr(existing.name || "Production")}" /></div><div class="field"><label>Environment</label><input id="profile-environment" class="input" value="${attr(existing.environment || "production")}" /></div><div class="field"><label>Allowed branch</label><input id="profile-branch" class="input" value="${attr(existing.branch || repository?.defaultBranch || "main")}" /></div>${
|
||||
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>${existing.id ? "Edit" : "Add"} deployment environment</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="form-grid"><div class="field full"><label>Deployment provider</label><select id="profile-provider" class="select"><option value="ssh-unraid" ${ssh ? "selected" : ""}>SSH / Unraid · direct controlled deployment</option><option value="gitea-actions" ${!ssh ? "selected" : ""}>Gitea Actions · runner workflow</option></select></div>${ssh ? `<div class="field full"><div class="notice ${discovery ? "success" : ""}">${icon(discovery ? "check" : "server")}<div><strong>${discovery ? "Existing deployment imported from server" : "Import by folder or use Server inventory"}</strong><p>${discovery ? `${escapeHtml(discovery.runtime?.containers || 0)} container(s), ${escapeHtml(discovery.runtime?.services || 0)} service(s) and ${escapeHtml(discovery.runtime?.ports?.length || 0)} port mapping(s) detected. Every imported value remains editable as an explicit override.` : "For a known folder, ForgeFlow can read Docker, Compose and DockerMan metadata. For uncertain matches, use Server inventory and select the actual running workload."}</p><button type="button" class="button ${discovery ? "" : "primary"}" data-action="discover-existing-deployment">${icon("refresh")}${discovery ? "Rescan folder" : "Import known folder"}</button></div></div></div>` : ""}<div class="field"><label>Profile name</label><input id="profile-name" class="input" value="${attr(existing.name || "Production")}" /></div><div class="field"><label>Environment</label><input id="profile-environment" class="input" value="${attr(existing.environment || "production")}" /></div><div class="field"><label>Allowed branch</label><input id="profile-branch" class="input" value="${attr(existing.branch || repository?.defaultBranch || "main")}" /></div>${
|
||||
ssh
|
||||
? `
|
||||
<div class="field"><label>Unraid server</label><select id="profile-server" class="select">${servers.length ? servers.map((server) => `<option value="${attr(server.id)}" ${server.id === existing.serverId ? "selected" : ""}>${escapeHtml(server.name)} · ${escapeHtml(server.host)}</option>`).join("") : '<option value="">Configure a server first</option>'}</select></div>
|
||||
<div class="field"><label>Server folder name</label><input id="profile-remote-folder" class="input" value="${attr(remoteFolder)}"/></div>
|
||||
<div class="field"><label>Git clone URL used by Unraid</label><input id="profile-clone-url" class="input" value="${attr(existing.cloneUrl || repository?.sshUrl || "")}" placeholder="ssh://git@gitea:222/Jens/project.git"/></div>
|
||||
<label class="check-field"><input id="profile-align-remote" type="checkbox" ${existing.alignRemote === true ? "checked" : ""}/><span>Align an existing server origin to this URL</span></label>
|
||||
<div class="field"><label>Compose mode</label><select id="profile-generated-compose" class="select"><option value="false" ${existing.generatedCompose !== true ? "selected" : ""}>Use Compose file from repository/server</option><option value="true" ${existing.generatedCompose === true ? "selected" : ""}>Generate a basic ForgeFlow Compose file</option></select></div>
|
||||
<div class="field"><label>Compose file</label><input id="profile-compose-file" class="input" value="${attr(existing.composeFile || "docker-compose.yml")}"/></div>
|
||||
<div class="field"><label>Compose service (internal)</label><input id="profile-compose-service" class="input" value="${attr(existing.composeService || safeCloneFolderName(repository).toLowerCase())}"/><small>Must match the Compose service key and remain lowercase.</small></div><div class="field"><label>Visible container name</label><input id="profile-container-name" class="input" value="${attr(existing.containerName || remoteFolder)}"/><small>May remain Portfolio while internal image/service names are lowercase.</small></div>
|
||||
<div class="field"><label>Deployment mode</label><select id="profile-deployment-mode" class="select"><option value="push-bundle" ${(existing.deploymentMode || "push-bundle") === "push-bundle" ? "selected" : ""}>Push bundle · recommended</option><option value="monitor-only" ${existing.deploymentMode === "monitor-only" ? "selected" : ""}>Monitor only</option><option value="server-git" ${existing.deploymentMode === "server-git" ? "selected" : ""}>Server-side Git · advanced</option></select><small>Push bundle uses the desktop → Unraid key and does not require Gitea credentials on Unraid.</small></div>
|
||||
<div class="field"><label>Git clone URL · Server-side Git only</label><input id="profile-clone-url" class="input" value="${attr(existing.cloneUrl || repository?.sshUrl || "")}" placeholder="ssh://git@gitea:222/Jens/project.git"/></div>
|
||||
<label class="check-field"><input id="profile-align-remote" type="checkbox" ${existing.alignRemote === true ? "checked" : ""}/><span>Align an existing server origin in Server-side Git mode</span></label>
|
||||
<div class="field"><label>Compose mode</label><select id="profile-generated-compose" class="select"><option value="false" ${existing.generatedCompose !== true ? "selected" : ""}>Use existing Compose definition</option><option value="true" ${existing.generatedCompose === true ? "selected" : ""}>Generate a basic ForgeFlow Compose file</option></select></div>
|
||||
<div class="field"><label>Compose project identity</label><input id="profile-compose-project" class="input" value="${attr(existing.composeProject || "")}" placeholder="Existing docker compose project name"/><small>Kept stable to update the existing containers instead of creating duplicates.</small></div>
|
||||
<div class="field full"><label>Compose files</label><input id="profile-compose-files" class="input" value="${attr((existing.composeFiles?.length ? existing.composeFiles : [existing.composeFile || "docker-compose.yml"]).join(", "))}"/><small>Comma-separated, in the same order used by the existing deployment. ForgeFlow adds its metadata overlay last.</small></div>
|
||||
<div class="field full"><label>Compose services to verify</label><input id="profile-compose-services" class="input" value="${attr((existing.composeServices?.length ? existing.composeServices : [existing.composeService || safeCloneFolderName(repository).toLowerCase()]).join(", "))}"/><small>Compose service (internal) keys, comma-separated. All listed services must be running after deployment.</small></div><div class="field"><label>Visible container name</label><input id="profile-container-name" class="input" value="${attr(existing.containerName || remoteFolder)}"/><small>Used as an inventory hint; adopted Compose identity remains authoritative.</small></div>
|
||||
<div class="field"><label>Host port</label><input id="profile-host-port" class="input" type="number" min="1" max="65535" value="${attr(existing.hostPort || "")}" placeholder="1223"/></div>
|
||||
<div class="field"><label>Container port</label><input id="profile-container-port" class="input" type="number" min="1" max="65535" value="${attr(existing.containerPort || "")}" placeholder="8080"/></div>
|
||||
<div class="field full"><label>Unraid Web UI URL (optional)</label><input id="profile-web-ui" class="input" value="${attr(existing.webUiUrl || "")}" placeholder="http://[IP]:[PORT:1223]/"/></div>
|
||||
<div class="field"><label>DockerMan icon source</label><select id="profile-icon-mode" class="select"><option value="builtin" ${(existing.iconMode || (!existing.iconUrl && !existing.iconFilePath ? "builtin" : existing.iconFilePath ? "upload" : "url")) === "builtin" ? "selected" : ""}>Built-in high-contrast ITWorx mark</option><option value="upload" ${existing.iconMode === "upload" || (!existing.iconMode && existing.iconFilePath) ? "selected" : ""}>Upload local PNG</option><option value="url" ${existing.iconMode === "url" || (!existing.iconMode && existing.iconUrl) ? "selected" : ""}>Use icon URL</option><option value="none" ${existing.iconMode === "none" ? "selected" : ""}>No custom icon</option></select></div><div class="field"><label>Container shell</label><select id="profile-docker-shell" class="select"><option value="/bin/sh" ${(existing.dockerShell || "/bin/sh") === "/bin/sh" ? "selected" : ""}>/bin/sh</option><option value="/bin/bash" ${existing.dockerShell === "/bin/bash" ? "selected" : ""}>/bin/bash</option></select></div>
|
||||
<div class="field full"><label>DockerMan icon URL</label><input id="profile-icon-url" class="input" value="${attr(existing.iconUrl || "")}" placeholder="https://…/icon.png"/></div><div class="field full"><label>Local PNG</label><div class="inline-form"><input id="profile-icon-file" class="input mono" value="${attr(existing.iconFilePath || "")}" placeholder="Select a local transparent PNG" readonly/><button class="button" data-action="select-profile-icon">${icon("folder")}Browse</button><button class="button ghost" data-action="clear-profile-icon">Clear</button></div><small>Built-in or uploaded PNGs are copied to DockerMan's persistent image folder and referenced through a file:/// URL. ForgeFlow also refreshes the relevant Unraid icon cache after recreating the container.</small></div>
|
||||
<div class="field full"><label>Healthcheck URL from this desktop (optional)</label><input id="profile-healthcheck" class="input" value="${attr(existing.healthcheckUrl || "")}" placeholder="http://unraid:1223/health"/></div>
|
||||
<div class="field full"><label>Preserve server-only paths</label><input id="profile-preserve-paths" class="input" value="${attr((existing.preservePaths || [".env", "appdata", "data", "logs", "config", "compose.override.yml"]).join(", "))}"/><small>These untracked runtime paths remain untouched by Git deployments.</small></div>
|
||||
<div class="field full"><label>Preserve server-only paths</label><input id="profile-preserve-paths" class="input" value="${attr((existing.preservePaths || [".env", "appdata", "data", "logs", "config", "compose.override.yml"]).join(", "))}"/><small>Push bundle never replaces these paths and only removes files previously managed by ForgeFlow.</small></div>
|
||||
<label class="check-field"><input id="profile-manage-dockerman" type="checkbox" ${existing.manageDockerMan === true ? "checked" : ""}/><span>Manage a generated DockerMan template</span><small>Existing/imported DockerMan templates are always preserved. This applies only to ForgeFlow-generated Compose deployments.</small></label>
|
||||
<label class="check-field"><input id="profile-force-recreate" type="checkbox" ${existing.forceRecreate === true ? "checked" : ""}/><span>Force-recreate containers</span><small>Off by default for safely adopted deployments.</small></label>
|
||||
<label class="check-field"><input id="profile-remove-orphans" type="checkbox" ${existing.removeOrphans === true ? "checked" : ""}/><span>Remove Compose orphans</span><small>Enable only after reviewing the existing Compose project.</small></label>
|
||||
`
|
||||
: `
|
||||
<div class="field"><label>Deploy workflow file</label><input id="profile-workflow" class="input" value="${attr(existing.workflowFile || "deploy.yml")}" /></div>
|
||||
<div class="field full"><label>Rollback workflow file (optional)</label><input id="profile-rollback-workflow" class="input" value="${attr(existing.rollbackWorkflowFile || "")}" placeholder="rollback.yml" /></div>
|
||||
<div class="field full"><label>Application status URL</label><input id="profile-status-url" class="input" value="${attr(existing.statusUrl || "")}" required placeholder="https://app.example.com/.well-known/forgeflow" /></div>
|
||||
<div class="field full"><label>Healthcheck URL (optional)</label><input id="profile-healthcheck" class="input" value="${attr(existing.healthcheckUrl || "")}" placeholder="https://app.example.com/health" /></div>`
|
||||
}<label class="check-field full"><input id="profile-confirmation" type="checkbox" ${existing.confirmationRequired !== false ? "checked" : ""}/><span>Require an explicit confirmation before deployment</span></label></div><div class="notice" style="margin-top:13px">${icon("shield")}${ssh ? "ForgeFlow connects over pinned SSH, refuses tracked server-side changes, deploys the exact Git SHA and preserves untracked runtime data." : "ForgeFlow sends only controlled workflow inputs: environment, exact SHA and a unique request ID."}</div></div><footer class="modal-footer">${existing.id ? `<button class="button danger" data-action="delete-deployment-profile" data-profile-id="${attr(existing.id)}">Delete</button>` : ""}<span class="modal-spacer"></span><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="save-deployment-profile" data-profile-id="${attr(existing.id || "")}" ${ssh && !servers.length ? "disabled" : ""}>Save environment</button></footer></section></div>`;
|
||||
}<label class="check-field full"><input id="profile-confirmation" type="checkbox" ${existing.confirmationRequired !== false ? "checked" : ""}/><span>Require an explicit confirmation before deployment</span></label></div><div class="notice" style="margin-top:13px">${icon("shield")}${ssh ? "Push bundle is the safe default: ForgeFlow archives the exact local commit, uploads it over pinned SSH, validates Compose, verifies every selected service and only then promotes the live release. Server-side Git remains optional." : "ForgeFlow sends only controlled workflow inputs: environment, exact SHA and a unique request ID."}</div></div><footer class="modal-footer">${existing.id ? `<button class="button danger" data-action="delete-deployment-profile" data-profile-id="${attr(existing.id)}">Delete</button>` : ""}<span class="modal-spacer"></span><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="save-deployment-profile" data-profile-id="${attr(existing.id || "")}" ${ssh && !servers.length ? "disabled" : ""}>Save environment</button></footer></section></div>`;
|
||||
}
|
||||
if (ui.modal.type === "deployment-preflight") {
|
||||
const profile =
|
||||
@@ -1318,7 +1383,7 @@ function renderModal() {
|
||||
(item) => item.id === ui.modal.serverId,
|
||||
) || {};
|
||||
const authType = ui.modal.authType || server.authType || "privateKey";
|
||||
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>${server.id ? "Edit" : "Add"} SSH / Unraid server</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="form-grid"><div class="field"><label>Name</label><input id="server-name" class="input" value="${attr(server.name || "Unraid")}"/></div><div class="field"><label>Host or IP</label><input id="server-host" class="input" value="${attr(server.host || "")}" placeholder="192.168.1.10"/></div><div class="field"><label>SSH port</label><input id="server-port" class="input" type="number" min="1" max="65535" value="${attr(server.port || 22)}"/></div><div class="field"><label>Username</label><input id="server-username" class="input" value="${attr(server.username || "root")}"/></div><div class="field"><label>Authentication</label><select id="server-auth-type" class="select"><option value="privateKey" ${authType === "privateKey" ? "selected" : ""}>Private key · recommended</option><option value="password" ${authType === "password" ? "selected" : ""}>Password</option></select></div><div class="field"><label>Appdata base path</label><input id="server-base-path" class="input" value="${attr(server.basePath || "/mnt/user/appdata")}"/></div>${authType === "privateKey" ? `<div class="field full"><label>Private key file</label><div class="input-action"><input id="server-private-key" class="input" value="${attr(server.privateKeyPath || "")}" placeholder="C:\\Users\\Jens\\.ssh\\id_ed25519"/><button class="button" data-action="select-private-key">Browse</button></div></div><div class="field full"><label>Private key passphrase</label><input id="server-passphrase" class="input" type="password" placeholder="${server.hasPassphrase ? "Leave empty to keep stored passphrase" : "Only when the key is encrypted"}"/></div>` : `<div class="field full"><label>SSH password</label><input id="server-password" class="input" type="password" placeholder="${server.hasPassword ? "Leave empty to keep stored password" : "Password"}"/></div>`}<div class="field full"><label>Trusted host fingerprint</label><input id="server-fingerprint" class="input mono" value="${attr(server.hostFingerprint || "")}" readonly placeholder="Filled automatically after Test & trust"/></div></div><div class="notice warning" style="margin-top:12px">${icon("key")}The first connection records the SSH host-key fingerprint. Later deployments fail closed when the server presents a different key.</div></div><footer class="modal-footer">${server.id ? `<button class="button danger" data-action="delete-server" data-server-id="${attr(server.id)}">Delete</button>` : ""}<span class="modal-spacer"></span><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="save-server" data-server-id="${attr(server.id || "")}">Save server</button></footer></section></div>`;
|
||||
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>${server.id ? "Edit" : "Add"} SSH / Unraid server</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="form-grid"><div class="field"><label>Name</label><input id="server-name" class="input" value="${attr(server.name || "Unraid")}"/></div><div class="field"><label>Host or IP</label><input id="server-host" class="input" value="${attr(server.host || "")}" placeholder="192.168.1.10"/></div><div class="field"><label>SSH port</label><input id="server-port" class="input" type="number" min="1" max="65535" value="${attr(server.port || 22)}"/></div><div class="field"><label>Username</label><input id="server-username" class="input" value="${attr(server.username || "root")}"/></div><div class="field"><label>Authentication</label><select id="server-auth-type" class="select"><option value="privateKey" ${authType === "privateKey" ? "selected" : ""}>Private key · recommended</option><option value="password" ${authType === "password" ? "selected" : ""}>Password</option></select></div><div class="field"><label>Appdata base path</label><input id="server-base-path" class="input" value="${attr(server.basePath || "/mnt/user/appdata")}"/></div>${authType === "privateKey" ? `<div class="field full"><label>Private key file</label><div class="input-action"><input id="server-private-key" class="input" value="${attr(server.privateKeyPath || "")}" placeholder="C:\\Users\\Jens\\.ssh\\id_ed25519"/><button class="button" data-action="select-private-key">Browse</button></div></div><div class="field full"><label>Private key passphrase</label><input id="server-passphrase" class="input" type="password" placeholder="${server.hasPassphrase ? "Leave empty to keep stored passphrase" : "Only when the key is encrypted"}"/></div>` : `<div class="field full"><label>SSH password</label><input id="server-password" class="input" type="password" placeholder="${server.hasPassword ? "Leave empty to keep stored password" : "Password"}"/></div>`}<div class="field full"><label>Trusted host fingerprint</label><input id="server-fingerprint" class="input mono" value="${attr(server.hostFingerprint || "")}" readonly placeholder="Filled automatically after Test & trust"/></div></div><div class="notice warning" style="margin-top:12px">${icon("key")}This identity is used only for the desktop → Unraid connection. Push bundle deployments reuse it to upload files and do not require a separate Gitea key on Unraid. The first connection records the server host-key fingerprint; later connections fail closed if it changes.</div></div><footer class="modal-footer">${server.id ? `<button class="button danger" data-action="delete-server" data-server-id="${attr(server.id)}">Delete</button>` : ""}<span class="modal-spacer"></span><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="save-server" data-server-id="${attr(server.id || "")}">Save server</button></footer></section></div>`;
|
||||
}
|
||||
if (ui.modal.type === "hunk-staging") {
|
||||
const hunks = ui.diffHunks?.hunks || [];
|
||||
@@ -2143,6 +2208,98 @@ app.addEventListener("click", async (event) => {
|
||||
"success",
|
||||
);
|
||||
}
|
||||
} else if (action === "scan-server-inventory") {
|
||||
setLoading(true, "Scanning Docker, Compose and DockerMan workloads…");
|
||||
try {
|
||||
await refreshDeploymentTruth(true);
|
||||
const detected = (ui.serverDiscovery || []).reduce(
|
||||
(total, item) => total + Number(item.detected || 0),
|
||||
0,
|
||||
);
|
||||
const review = (ui.serverDiscovery || []).reduce(
|
||||
(total, item) => total + Number(item.needsReview || 0),
|
||||
0,
|
||||
);
|
||||
showToast(
|
||||
"Server inventory updated",
|
||||
`${detected} workload${detected === 1 ? "" : "s"} detected; ${review} require manual review.`,
|
||||
review ? "info" : "success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Server scan failed", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
} else if (action === "link-server-workload") {
|
||||
const serverResult = (ui.serverDiscovery || []).find(
|
||||
(item) => item.serverId === target.dataset.serverId,
|
||||
);
|
||||
const workload = serverResult?.workloads?.find(
|
||||
(item) => item.workloadId === target.dataset.workloadId,
|
||||
);
|
||||
if (!workload) {
|
||||
showToast(
|
||||
"Workload unavailable",
|
||||
"Scan the server inventory again before linking this workload.",
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
ui.modal = {
|
||||
type: "workload-link",
|
||||
serverId: target.dataset.serverId,
|
||||
workloadId: target.dataset.workloadId,
|
||||
repositoryFullName:
|
||||
workload.candidates?.[0]?.repositoryFullName ||
|
||||
repository?.fullName ||
|
||||
ui.repositories[0]?.fullName ||
|
||||
"",
|
||||
remoteFolder: workload.remoteFolderCandidate || "",
|
||||
};
|
||||
render();
|
||||
} else if (action === "confirm-link-server-workload") {
|
||||
const repositoryFullName = document
|
||||
.querySelector("#workload-repository")
|
||||
?.value.trim();
|
||||
const deploymentMode =
|
||||
document.querySelector("#workload-deployment-mode")?.value ||
|
||||
"push-bundle";
|
||||
const remoteFolder = document
|
||||
.querySelector("#workload-remote-folder")
|
||||
?.value.trim();
|
||||
const linkedRepository = ui.repositories.find(
|
||||
(item) => item.fullName === repositoryFullName,
|
||||
);
|
||||
if (!linkedRepository) {
|
||||
showToast(
|
||||
"Choose a repository",
|
||||
"The workload must be linked to a Gitea repository.",
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setLoading(true, "Saving the permanent server workload link…");
|
||||
try {
|
||||
const result = await window.forgeflow.linkServerWorkload(
|
||||
linkedRepository,
|
||||
target.dataset.serverId,
|
||||
target.dataset.workloadId,
|
||||
deploymentMode,
|
||||
remoteFolder,
|
||||
);
|
||||
if (result.state) ui.boot.state = result.state;
|
||||
ui.modal = null;
|
||||
ui.selectedProfileId = result.profile?.id || null;
|
||||
await refreshRepositories(false, true);
|
||||
await refreshDeploymentTruth(false);
|
||||
showToast(
|
||||
"Workload linked",
|
||||
`${linkedRepository.fullName} now uses ${deploymentMode === "push-bundle" ? "safe bundle upload" : deploymentMode === "monitor-only" ? "monitor-only mode" : "server-side Git"}.`,
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("Could not link workload", error.message, "error");
|
||||
}
|
||||
setLoading(false);
|
||||
} else if (action === "configure-deployment") {
|
||||
ui.deploymentDiscovery = null;
|
||||
ui.modal = {
|
||||
@@ -2155,7 +2312,7 @@ app.addEventListener("click", async (event) => {
|
||||
render();
|
||||
} else if (action === "edit-deployment-profile") {
|
||||
ui.deploymentDiscovery = null;
|
||||
if (!repository) repository = profileRepository(target.dataset.profileId);
|
||||
repository = profileRepository(target.dataset.profileId) || repository;
|
||||
if (repository && String(repository.id) !== String(ui.selectedRepoId))
|
||||
selectRepository(repository.id, false);
|
||||
ui.modal = {
|
||||
@@ -2248,6 +2405,20 @@ app.addEventListener("click", async (event) => {
|
||||
showToast("Could not save profile", error.message, "error");
|
||||
return;
|
||||
}
|
||||
const composeFiles =
|
||||
provider === "ssh-unraid"
|
||||
? (document.querySelector("#profile-compose-files")?.value || "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const composeServices =
|
||||
provider === "ssh-unraid"
|
||||
? (document.querySelector("#profile-compose-services")?.value || "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const profile = {
|
||||
id: target.dataset.profileId || undefined,
|
||||
provider,
|
||||
@@ -2275,18 +2446,27 @@ app.addEventListener("click", async (event) => {
|
||||
remoteFolder: document
|
||||
.querySelector("#profile-remote-folder")
|
||||
.value.trim(),
|
||||
deploymentMode:
|
||||
document.querySelector("#profile-deployment-mode")?.value ||
|
||||
"push-bundle",
|
||||
cloneUrl: document.querySelector("#profile-clone-url").value.trim(),
|
||||
alignRemote: document.querySelector("#profile-align-remote")
|
||||
.checked,
|
||||
generatedCompose:
|
||||
document.querySelector("#profile-generated-compose").value ===
|
||||
"true",
|
||||
composeFile: document
|
||||
.querySelector("#profile-compose-file")
|
||||
.value.trim(),
|
||||
composeService: document
|
||||
.querySelector("#profile-compose-service")
|
||||
.value.trim(),
|
||||
composeProject:
|
||||
document.querySelector("#profile-compose-project")?.value.trim() ||
|
||||
previousProfile.composeProject ||
|
||||
"",
|
||||
composeWorkingDir: previousProfile.composeWorkingDir || "",
|
||||
composeFiles: composeFiles.length ? composeFiles : ["docker-compose.yml"],
|
||||
composeFile: composeFiles[0] || "docker-compose.yml",
|
||||
composeServices: composeServices.length
|
||||
? composeServices
|
||||
: [safeCloneFolderName(repository).toLowerCase()],
|
||||
composeService:
|
||||
composeServices[0] || safeCloneFolderName(repository).toLowerCase(),
|
||||
containerName: document
|
||||
.querySelector("#profile-container-name")
|
||||
.value.trim(),
|
||||
@@ -2308,8 +2488,25 @@ app.addEventListener("click", async (event) => {
|
||||
.value.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
adoptedFromServer: Boolean(ui.deploymentDiscovery),
|
||||
serverSourceOfTruth: Boolean(ui.deploymentDiscovery),
|
||||
manageDockerMan:
|
||||
document.querySelector("#profile-manage-dockerman")?.checked ===
|
||||
true,
|
||||
forceRecreate:
|
||||
document.querySelector("#profile-force-recreate")?.checked ===
|
||||
true,
|
||||
removeOrphans:
|
||||
document.querySelector("#profile-remove-orphans")?.checked ===
|
||||
true,
|
||||
adoptedFromServer: Boolean(
|
||||
ui.deploymentDiscovery || previousProfile.adoptedFromServer,
|
||||
),
|
||||
serverSourceOfTruth: Boolean(
|
||||
ui.deploymentDiscovery || previousProfile.serverSourceOfTruth,
|
||||
),
|
||||
workloadIdentity:
|
||||
ui.deploymentDiscovery?.profile?.workloadIdentity ||
|
||||
previousProfile.workloadIdentity ||
|
||||
null,
|
||||
detectedAt:
|
||||
ui.deploymentDiscovery?.profile?.detectedAt ||
|
||||
previousProfile.detectedAt ||
|
||||
@@ -2759,15 +2956,18 @@ app.addEventListener("click", async (event) => {
|
||||
} else if (action === "test-server") {
|
||||
setLoading(
|
||||
true,
|
||||
"Connecting to Unraid and checking Git and Docker Compose…",
|
||||
"Checking SSH identity, Docker, Compose and optional Git capabilities…",
|
||||
);
|
||||
try {
|
||||
const result = await window.forgeflow.testServer(target.dataset.serverId);
|
||||
ui.boot.state = result.state;
|
||||
const capabilities = result.capabilities || {};
|
||||
const deploymentReady =
|
||||
capabilities.docker && capabilities.dockerReady && capabilities.compose;
|
||||
showToast(
|
||||
"SSH server ready",
|
||||
`${result.server.name} presented ${result.fingerprint}.`,
|
||||
"success",
|
||||
deploymentReady ? "SSH server ready" : "SSH connected with missing tools",
|
||||
`${result.server.name} presented ${result.fingerprint}. Docker ${capabilities.dockerReady ? "ready" : "unavailable"}; Compose ${capabilities.compose ? "ready" : "missing"}; server-side Git ${capabilities.git ? "available" : "not installed (optional)"}.`,
|
||||
deploymentReady ? "success" : "info",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast("SSH test failed", error.message, "error");
|
||||
|
||||
+103
-3
@@ -564,7 +564,7 @@
|
||||
await wait(80);
|
||||
snapshot();
|
||||
return {
|
||||
appVersion: "0.8.9-demo",
|
||||
appVersion: "0.9.0-demo",
|
||||
platform: "win32",
|
||||
state: clone(state),
|
||||
git: { available: true, version: "git version 2.47.3" },
|
||||
@@ -1204,7 +1204,7 @@
|
||||
)),
|
||||
...input,
|
||||
id: input.id || `profile-${Date.now()}`,
|
||||
provider: "gitea-actions",
|
||||
provider: input.provider || existing?.provider || "gitea-actions",
|
||||
inputs: existing?.inputs || {},
|
||||
state: existing?.state || {
|
||||
liveSha: null,
|
||||
@@ -1425,14 +1425,114 @@
|
||||
await wait(80);
|
||||
return [
|
||||
{
|
||||
serverId: "unraid-primary",
|
||||
serverId: "server-unraid",
|
||||
serverName: "Unraid",
|
||||
detected: 2,
|
||||
adopted: 0,
|
||||
verified: 1,
|
||||
linked: 1,
|
||||
unmatched: 0,
|
||||
needsReview: 1,
|
||||
running: 2,
|
||||
stopped: 0,
|
||||
capabilities: {
|
||||
docker: true,
|
||||
dockerReady: true,
|
||||
compose: true,
|
||||
git: false,
|
||||
tar: true,
|
||||
checksum: true,
|
||||
},
|
||||
warnings: [],
|
||||
workloads: [
|
||||
{
|
||||
workloadId: "workload-demo-linked",
|
||||
displayName: "Portfolio",
|
||||
status: "linked",
|
||||
runtime: { running: true, health: "healthy" },
|
||||
compose: {
|
||||
project: "portfolio",
|
||||
workingDir: "/mnt/user/appdata/portfolio",
|
||||
configFiles: ["/mnt/user/appdata/portfolio/docker-compose.yml"],
|
||||
services: ["web"],
|
||||
},
|
||||
containers: [{ name: "Portfolio", running: true }],
|
||||
candidates: [],
|
||||
link: {
|
||||
profileId: "profile-portfolio",
|
||||
repositoryFullName: "jens/portfolio",
|
||||
source: "manual",
|
||||
},
|
||||
},
|
||||
{
|
||||
workloadId: "workload-demo-review",
|
||||
displayName: "OmniRoute",
|
||||
status: "suggested",
|
||||
runtime: { running: true, health: "unverified" },
|
||||
compose: {
|
||||
project: "omniroute",
|
||||
workingDir: "/mnt/user/appdata/OmniRoute",
|
||||
configFiles: ["/mnt/user/appdata/OmniRoute/docker-compose.yml"],
|
||||
services: ["omniroute"],
|
||||
},
|
||||
containers: [{ name: "omniroute", running: true }],
|
||||
remoteFolderCandidate: "OmniRoute",
|
||||
candidates: repositories.slice(0, 1).map((repository) => ({
|
||||
repositoryFullName: repository.fullName,
|
||||
repositoryName: repository.name,
|
||||
score: 55,
|
||||
exact: false,
|
||||
reasons: ["container and repository names are similar"],
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
async linkServerWorkload(repository, serverId, workloadId, deploymentMode = "push-bundle", remoteFolder = "") {
|
||||
await wait(120);
|
||||
const repo = repositories.find((item) => item.fullName === repository.fullName);
|
||||
if (!repo) throw new Error("Repository not found.");
|
||||
const id = `profile-${workloadId}`;
|
||||
const saved = {
|
||||
id,
|
||||
name: `Unraid · ${remoteFolder || repo.name}`,
|
||||
environment: "production",
|
||||
provider: "ssh-unraid",
|
||||
branch: repo.defaultBranch || "main",
|
||||
serverId,
|
||||
remoteFolder: remoteFolder || repo.name,
|
||||
deploymentMode,
|
||||
composeFile: "docker-compose.yml",
|
||||
composeFiles: ["docker-compose.yml"],
|
||||
composeProject: String(remoteFolder || repo.name).toLowerCase(),
|
||||
composeService: String(remoteFolder || repo.name).toLowerCase(),
|
||||
composeServices: [String(remoteFolder || repo.name).toLowerCase()],
|
||||
containerName: remoteFolder || repo.name,
|
||||
preservePaths: [".env", "appdata", "data", "logs", "config"],
|
||||
generatedCompose: false,
|
||||
adoptedFromServer: true,
|
||||
serverSourceOfTruth: true,
|
||||
manageDockerMan: false,
|
||||
forceRecreate: false,
|
||||
removeOrphans: false,
|
||||
workloadIdentity: { workloadId, linkSource: "manual", linkedAt: iso() },
|
||||
confirmationRequired: true,
|
||||
state: {
|
||||
liveSha: null,
|
||||
healthy: null,
|
||||
containerRunning: true,
|
||||
runtimeVerification: "running-unverified",
|
||||
checkedAt: iso(),
|
||||
},
|
||||
};
|
||||
repo.deploymentProfiles = [
|
||||
...repo.deploymentProfiles.filter((item) => item.id !== id),
|
||||
saved,
|
||||
];
|
||||
syncState();
|
||||
return { profile: clone(saved), state: clone(state) };
|
||||
},
|
||||
async refreshOperations(operationId = null) {
|
||||
await wait(300);
|
||||
if (operationId) {
|
||||
|
||||
Reference in New Issue
Block a user