fix: align deployment links across repository views
ForgeFlow quality gate / quality (push) Canceled after 0s
ForgeFlow quality gate / quality (push) Canceled after 0s
This commit is contained in:
@@ -9,6 +9,18 @@ async function handleShellActions(event, target, action, repository) {
|
||||
setLoading(false);
|
||||
}
|
||||
} else if (action === "select-repo") selectRepository(target.dataset.id);
|
||||
else if (action === "open-deployment-link") {
|
||||
if (!repository) return true;
|
||||
selectRepository(repository.id, false);
|
||||
ui.selectedProfileId = target.dataset.profileId || selectedProfile(repository)?.id || null;
|
||||
ui.repositoryTab = "deployments";
|
||||
ui.currentView = "repository";
|
||||
render();
|
||||
} else if (action === "select-deployment-profile") {
|
||||
ui.selectedProfileId = target.dataset.profileId || null;
|
||||
ui.repositoryTab = "deployments";
|
||||
render();
|
||||
}
|
||||
else if (action === "refresh") {
|
||||
await refreshRepositories(true);
|
||||
await refreshActiveOperations(false);
|
||||
|
||||
@@ -238,13 +238,13 @@ function createMockDeploymentBridge(context) {
|
||||
{
|
||||
serverId: "server-unraid",
|
||||
serverName: "Unraid",
|
||||
detected: 2,
|
||||
detected: 3,
|
||||
adopted: 0,
|
||||
verified: 1,
|
||||
linked: 1,
|
||||
linked: 2,
|
||||
unmatched: 0,
|
||||
needsReview: 1,
|
||||
running: 2,
|
||||
needsReview: 2,
|
||||
running: 3,
|
||||
stopped: 0,
|
||||
capabilities: {
|
||||
docker: true,
|
||||
@@ -296,6 +296,19 @@ function createMockDeploymentBridge(context) {
|
||||
reasons: ["container and repository names are similar"],
|
||||
})),
|
||||
},
|
||||
{
|
||||
workloadId: "workload-demo-unresolved",
|
||||
displayName: "Legacy Worker",
|
||||
status: "linked",
|
||||
runtime: { running: true, health: "healthy" },
|
||||
containers: [{ name: "legacy-worker", running: true }],
|
||||
candidates: [],
|
||||
link: {
|
||||
profileId: "profile-that-no-longer-exists",
|
||||
repositoryFullName: "jens/removed-repository",
|
||||
source: "manual",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -849,6 +849,64 @@ select:focus-visible {
|
||||
.release-node:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
.repository-deployment-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 48px;
|
||||
padding: 7px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: linear-gradient(90deg, color-mix(in srgb, var(--accent) 7%, var(--surface-1)), var(--surface-1) 42%);
|
||||
}
|
||||
.repository-deployment-summary-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.repository-deployment-summary-label svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.repository-deployment-chips {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.repository-deployment-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 0 0 auto;
|
||||
max-width: 280px;
|
||||
padding: 6px 9px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--surface-2) 88%, transparent);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.repository-deployment-chip:hover {
|
||||
border-color: color-mix(in srgb, var(--accent) 48%, var(--line));
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--surface-2));
|
||||
}
|
||||
.repository-deployment-chip strong,
|
||||
.repository-deployment-chip span:last-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.repository-deployment-chip strong { font-size: 11px; }
|
||||
.repository-deployment-chip span:last-child { color: var(--text-muted); font-size: 10px; }
|
||||
.release-node:not(:last-child)::after {
|
||||
content: "›";
|
||||
position: absolute;
|
||||
|
||||
+49
-5
@@ -188,6 +188,16 @@ function releaseNode(label, value, description, tone = "") {
|
||||
return `<div class="release-node"><div class="release-label">${label}</div><div class="release-value"><span class="state-dot ${tone}"></span><strong>${escapeHtml(value)}</strong><span>${escapeHtml(description)}</span></div></div>`;
|
||||
}
|
||||
|
||||
function linkedWorkloadsForRepository(repository) {
|
||||
const fullName = String(repository?.fullName || "").toLowerCase();
|
||||
if (!fullName) return [];
|
||||
return (ui.serverDiscovery || []).flatMap((server) =>
|
||||
(server.workloads || [])
|
||||
.filter((workload) => String(workload.link?.repositoryFullName || "").toLowerCase() === fullName)
|
||||
.map((workload) => ({ ...workload, serverId: server.serverId, serverName: server.serverName || server.server?.name || "Server" })),
|
||||
);
|
||||
}
|
||||
|
||||
function diffAtmosphere(diff) {
|
||||
if (!ui.selectedFile) return "";
|
||||
const lines = String(diff || "").split("\n");
|
||||
@@ -359,8 +369,15 @@ function renderProfileCard(repository, profile, compact = false) {
|
||||
}
|
||||
function renderRepositoryDeployments(repository) {
|
||||
const profiles = repository.deploymentProfiles || [];
|
||||
const workloads = linkedWorkloadsForRepository(repository);
|
||||
const profileIds = new Set(profiles.map((profile) => profile.id));
|
||||
const workloadRows = workloads.map((workload) => {
|
||||
const containers = (workload.containers || []).map((container) => container.name).filter(Boolean);
|
||||
const profileResolved = Boolean(workload.link?.profileId && profileIds.has(workload.link.profileId));
|
||||
return `<div class="tool-row repository-workload-row"><div><strong>${escapeHtml(workload.displayName || containers[0] || "Server workload")}</strong><span>${escapeHtml(workload.serverName)} · ${containers.length ? escapeHtml(containers.join(", ")) : "container identity unavailable"} · ${workload.runtime?.running ? "running" : "stopped"}</span><span>${escapeHtml(workload.compose?.project ? `Compose ${workload.compose.project}` : workload.remoteFolderCandidate || "Docker workload")}</span></div><div class="stack horizontal compact"><span class="status-pill ${profileResolved ? "success" : "danger"}">${profileResolved ? "Repository linked" : "Link needs reconciliation"}</span>${profileResolved ? `<button class="button ghost" data-action="select-deployment-profile" data-profile-id="${attr(workload.link.profileId)}">Open profile</button>` : `<button class="button" data-action="navigate" data-view="deployments">Review inventory</button>`}</div></div>`;
|
||||
}).join("");
|
||||
const repoOps = repositoryOperations(repository).slice(0, 10);
|
||||
return `<div class="tab-page"><div class="section-heading"><div><h2>Deployment environments</h2><span class="meta">Exact-commit Gitea Actions or pinned SSH / Unraid deployments</span></div><button class="button primary" data-action="configure-deployment">${icon("plus")}Add environment</button></div>${profiles.length ? `<div class="deploy-card-grid">${profiles.map((profile) => renderProfileCard(repository, profile)).join("")}</div>` : '<div class="empty-state panel"><div class="empty-icon">↗</div><h3>No deployment profile</h3><p>Connect a Gitea Actions workflow or a trusted SSH / Unraid server.</p><button class="button primary" data-action="configure-deployment">Configure deployment</button></div>'}<section class="section-block"><div class="section-heading"><h2>Release history</h2></div><div class="panel">${repoOps.length ? `<table class="data-table"><thead><tr><th>Action</th><th>Environment</th><th>Commit</th><th>Status</th><th>Updated</th><th></th></tr></thead><tbody>${repoOps.map((operation) => `<tr><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 releases for this repository yet.</p></div>'}</div></section></div>`;
|
||||
return `<div class="tab-page"><div class="section-heading"><div><h2>Deployment environments</h2><span class="meta">${profiles.length} configured profile${profiles.length === 1 ? "" : "s"} · ${workloads.length} server workload${workloads.length === 1 ? "" : "s"} linked to this repository</span></div><button class="button primary" data-action="configure-deployment">${icon("plus")}Add environment</button></div>${workloads.length ? `<section class="panel repository-workloads"><div class="panel-header"><div><h3>Detected on server</h3><span class="meta">Live Docker / Compose identities resolved back to this repository</span></div></div><div class="panel-body"><div class="tool-list">${workloadRows}</div></div></section>` : ""}${profiles.length ? `<div class="deploy-card-grid">${profiles.map((profile) => renderProfileCard(repository, profile)).join("")}</div>` : '<div class="empty-state panel"><div class="empty-icon">↗</div><h3>No deployment profile</h3><p>Connect a Gitea Actions workflow or a trusted SSH / Unraid server.</p><button class="button primary" data-action="configure-deployment">Configure deployment</button></div>'}<section class="section-block"><div class="section-heading"><h2>Release history</h2></div><div class="panel">${repoOps.length ? `<table class="data-table"><thead><tr><th>Action</th><th>Environment</th><th>Commit</th><th>Status</th><th>Updated</th><th></th></tr></thead><tbody>${repoOps.map((operation) => `<tr><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 releases for this repository yet.</p></div>'}</div></section></div>`;
|
||||
}
|
||||
|
||||
function renderGitTools(repository) {
|
||||
@@ -412,6 +429,8 @@ function renderGitValidator(repository) {
|
||||
|
||||
function renderRepositoryWorkspace(repository) {
|
||||
const status = repository.localStatus;
|
||||
const profiles = repository.deploymentProfiles || [];
|
||||
const linkedWorkloads = linkedWorkloadsForRepository(repository);
|
||||
const profile = selectedProfile(repository);
|
||||
const serverState = profile?.state || {};
|
||||
const localTone = status?.counts.conflicts
|
||||
@@ -444,9 +463,19 @@ function renderRepositoryWorkspace(repository) {
|
||||
settings: renderRepositorySettings,
|
||||
}[ui.repositoryTab] || renderChanges
|
||||
)(repository);
|
||||
const deploymentLinks = profiles.length
|
||||
? `<div class="repository-deployment-summary"><span class="repository-deployment-summary-label">${icon("server")}Linked deployments</span><div class="repository-deployment-chips">${profiles.map((item) => {
|
||||
const workload = linkedWorkloads.find((candidate) => candidate.link?.profileId === item.id);
|
||||
const itemState = item.state || {};
|
||||
const tone = itemState.healthy === false ? "danger" : itemState.healthy === true ? "success" : workload?.runtime?.running ? "success" : "warning";
|
||||
const identity = workload?.displayName || item.containerName || item.remoteFolder || item.environment;
|
||||
return `<button class="repository-deployment-chip" data-action="select-deployment-profile" data-profile-id="${attr(item.id)}"><span class="state-dot ${tone}"></span><strong>${escapeHtml(identity)}</strong><span>${escapeHtml(item.environment)}${workload?.serverName ? ` · ${escapeHtml(workload.serverName)}` : ""}</span></button>`;
|
||||
}).join("")}</div><button class="button ghost" data-action="select-deployment-profile" data-profile-id="${attr(profile?.id || profiles[0].id)}">View all</button></div>`
|
||||
: "";
|
||||
return `<div class="repo-workspace"><header class="repo-header illustrated-repo-header"><div class="repo-heading"><h1><button class="favorite-button ${repository.favorite ? "active" : ""}" data-action="toggle-favorite" title="Toggle favorite">${icon("star")}</button>${escapeHtml(repository.fullName)}</h1><p>${escapeHtml(repository.localPath || "No local working tree linked")}</p></div>${projectIllustration("repo")}<div class="repo-header-actions"><button class="button" data-action="fetch" ${!repository.localPath ? "disabled" : ""}>${icon("refresh")}Fetch</button><button class="button" data-action="open-path" ${!repository.localPath ? "disabled" : ""}>${icon("folder")}Folder</button><button class="button" data-action="open-gitea" ${!repository.htmlUrl ? "disabled" : ""}>${icon("external")}Gitea</button></div></header>
|
||||
${repository.localPath ? `<div class="repo-quick-actions"><button class="button" data-action="open-editor">${icon("external")}Open in editor</button><button class="button" data-action="open-terminal">${icon("terminal")}Open terminal</button><button class="button" data-action="check-branch-protection">${icon("shield")}Check branch protection</button><button class="button primary" data-action="open-pull-request">${icon("git")}Create pull request</button>${ui.branchProtection ? `<span class="status-pill ${ui.branchProtection.protected ? "warning" : "success"}">${ui.branchProtection.protected ? `Protected · ${ui.branchProtection.requiredApprovals || 0} approval(s)` : "Direct pushes allowed"}</span>` : ""}</div>` : ""}
|
||||
<div class="release-rail">${releaseNode("Local", status?.shortHead || "Not linked", status ? `${status.counts.changed} changes · ${status.branch.head}` : "No working tree", localTone)}${releaseNode("Gitea", status?.shortHead || "Unknown", status?.branch.upstream ? `${status.branch.ahead} ahead · ${status.branch.behind} behind` : "Branch not published", remoteTone)}${releaseNode(`Server${profile ? ` · ${profile.environment}` : ""}`, serverState.liveSha ? shortSha(serverState.liveSha) : "Unknown", profile ? (serverState.checkedAt ? `checked ${formatDate(serverState.checkedAt)}` : "not checked") : "No deployment profile", serverTone)}</div>
|
||||
${deploymentLinks}
|
||||
<nav class="tabs">${[
|
||||
["changes", "Changes"],
|
||||
["history", "History"],
|
||||
@@ -531,9 +560,13 @@ function renderServerInventory() {
|
||||
? 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)) && workload.classification?.type !== "stale-link";
|
||||
const linkedRepository = ui.repositories.find((repository) => String(repository.fullName).toLowerCase() === String(workload.link?.repositoryFullName || "").toLowerCase());
|
||||
const linkedProfile = linkedRepository?.deploymentProfiles?.find((profile) => profile.id === workload.link?.profileId);
|
||||
const claimsLink = workload.status === "linked" || Boolean(workload.link);
|
||||
const linked = Boolean(claimsLink && linkedRepository && linkedProfile) && workload.classification?.type !== "stale-link";
|
||||
const inconsistentLink = claimsLink && !linked;
|
||||
const classification = workload.classification?.type || workload.status || "review";
|
||||
const statusTone = linked && !workload.reviewDecisionStale ? "success" : ["ambiguous", "duplicate", "orphan-container"].includes(classification) || workload.reviewDecisionStale ? "danger" : "warning";
|
||||
const statusTone = linked && !workload.reviewDecisionStale ? "success" : inconsistentLink || ["ambiguous", "duplicate", "orphan-container"].includes(classification) || workload.reviewDecisionStale ? "danger" : "warning";
|
||||
const detail = workload.compose?.project
|
||||
? `Compose ${workload.compose.project} · ${(workload.compose.services || []).join(", ") || "services unknown"}`
|
||||
: workload.dockerMan?.templatePath
|
||||
@@ -541,6 +574,8 @@ function renderServerInventory() {
|
||||
: `Container installation · ${containers || "unnamed"}`;
|
||||
const candidate = linked
|
||||
? `Linked to ${workload.link?.repositoryFullName || "repository"}`
|
||||
: inconsistentLink
|
||||
? `Stored link cannot be resolved to a loaded repository profile`
|
||||
: topCandidate
|
||||
? `${topCandidate.repositoryFullName} suggested · ${topCandidate.confidence || topCandidate.status || "review required"}`
|
||||
: "No repository candidate; select one manually";
|
||||
@@ -549,10 +584,19 @@ function renderServerInventory() {
|
||||
? `<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>`;
|
||||
const evidenceNote = workload.reviewDecisionStale ? "Saved decision is stale because server evidence changed" : workload.classification?.reason || "Awaiting review";
|
||||
return `<div class="tool-row"><div><strong>${escapeHtml(workload.displayName)}</strong><span>${escapeHtml(detail)} · ${workload.runtime?.running ? "running" : "stopped"}</span><span>${escapeHtml(candidate)}</span><span class="${workload.reviewDecisionStale ? "text-warning" : "meta"}">${escapeHtml(evidenceNote)}</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(workload.reviewDecisionStale ? "Decision stale" : linked ? "Linked" : classification)}</span>${linked ? `<button class="button ghost" data-action="edit-deployment-profile" data-profile-id="${attr(workload.link?.profileId || "")}">Open link</button>` : linkButton}</div></div>`;
|
||||
return `<div class="tool-row"><div><strong>${escapeHtml(workload.displayName)}</strong><span>${escapeHtml(detail)} · ${workload.runtime?.running ? "running" : "stopped"}</span><span>${escapeHtml(candidate)}</span><span class="${workload.reviewDecisionStale || inconsistentLink ? "text-warning" : "meta"}">${escapeHtml(inconsistentLink ? "Reconcile this inventory link before deployment" : evidenceNote)}</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(workload.reviewDecisionStale ? "Decision stale" : linked ? "Linked" : inconsistentLink ? "Link unresolved" : classification)}</span>${linked ? `<button class="button ghost" data-action="open-deployment-link" data-repository-id="${attr(linkedRepository.id)}" data-profile-id="${attr(linkedProfile.id)}">Open in repository</button>` : inconsistentLink ? `<button class="button" data-action="plan-server-reconciliation" data-server-id="${attr(server.serverId)}">Reconcile</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><div class="stack horizontal compact"><span class="status-pill ${server.error ? "danger" : capabilities.docker && capabilities.compose ? "success" : "warning"}">${server.error ? "Scan failed" : escapeHtml(capabilityText)}</span>${server.error ? "" : `<button class="button" data-action="plan-server-reconciliation" data-server-id="${attr(server.serverId)}">${icon("shield")}Review reconciliation</button>`}</div></div><div class="panel-body">${errorBlock}${warnings}<div class="tool-list">${workloads}</div></div></section>`;
|
||||
const resolvedLinks = visibleWorkloads.filter((workload) => {
|
||||
const repository = ui.repositories.find((item) => String(item.fullName).toLowerCase() === String(workload.link?.repositoryFullName || "").toLowerCase());
|
||||
return repository?.deploymentProfiles?.some((profile) => profile.id === workload.link?.profileId);
|
||||
}).length;
|
||||
const unresolvedLinks = visibleWorkloads.filter((workload) => {
|
||||
if (!(workload.status === "linked" || workload.link)) return false;
|
||||
const repository = ui.repositories.find((item) => String(item.fullName).toLowerCase() === String(workload.link?.repositoryFullName || "").toLowerCase());
|
||||
return !repository?.deploymentProfiles?.some((profile) => profile.id === workload.link?.profileId);
|
||||
}).length;
|
||||
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 · ${resolvedLinks} visible repository links${unresolvedLinks ? ` · ${unresolvedLinks} unresolved` : ""} · ${visibleWorkloads.filter((workload) => !workload.link).length} to review${hiddenCount ? ` · ${hiddenCount} unrelated/system workloads hidden` : ""}</span></div><div class="stack horizontal compact"><span class="status-pill ${server.error ? "danger" : capabilities.docker && capabilities.compose ? "success" : "warning"}">${server.error ? "Scan failed" : escapeHtml(capabilityText)}</span>${server.error ? "" : `<button class="button" data-action="plan-server-reconciliation" data-server-id="${attr(server.serverId)}">${icon("shield")}Review reconciliation</button>`}</div></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>`
|
||||
|
||||
Reference in New Issue
Block a user