747 lines
99 KiB
JavaScript
747 lines
99 KiB
JavaScript
function navButton(view, label, iconName, count = "") {
|
||
return `<button class="nav-button ${ui.currentView === view ? "active" : ""}" data-action="navigate" data-view="${view}">${icon(iconName)}<span>${label}</span>${count !== "" ? `<span class="nav-count">${count}</span>` : ""}</button>`;
|
||
}
|
||
|
||
function renderTitlebar() {
|
||
const state = ui.boot?.state;
|
||
const user = state?.gitea?.user;
|
||
const connected = Boolean(state?.gitea?.hasToken);
|
||
const repository = selectedRepository();
|
||
const title =
|
||
ui.currentView === "repository" && repository
|
||
? repository.fullName
|
||
: {
|
||
overview: "Release overview",
|
||
deployments: "Deployments",
|
||
diagnostics: "Diagnostics",
|
||
settings: "Settings",
|
||
"deployment-run": "Deployment run",
|
||
}[ui.currentView] || "Workspace";
|
||
return `<header class="titlebar">
|
||
<div class="titlebar-left"><div class="wordmark"><img class="brand-logo" src="./assets/itworx-mark.png" alt="ITWorx.tech"/><span>ForgeFlow</span><small>by ITWorx.tech</small></div><span class="workspace-name">${escapeHtml(title)}</span></div>
|
||
<div class="titlebar-right">
|
||
<button class="command-trigger" data-action="open-palette" aria-label="Open command palette">${icon("search")}<span>Commands</span><kbd>Ctrl K</kbd></button>
|
||
<div class="search-wrap">${icon("search")}<input id="global-search" class="global-search" value="${attr(ui.search)}" placeholder="Search repositories…" aria-label="Search repositories" /></div>
|
||
<span class="connection-chip" title="${connected ? `Connected as ${attr(user?.login || "user")}` : "Not connected"}"><span class="dot" style="${connected ? "" : "background:var(--danger)"}"></span>${connected ? escapeHtml(user?.login || "Gitea") : "Offline"}</span>
|
||
<button class="icon-button" data-action="refresh" title="Refresh repositories">${icon("refresh")}</button>
|
||
<button class="icon-button" data-action="toggle-theme" title="Toggle theme">${icon(document.documentElement.dataset.theme === "dark" ? "sun" : "moon")}</button>
|
||
</div>
|
||
</header>`;
|
||
}
|
||
|
||
function renderRepositoryRow(repository) {
|
||
const status = repository.localStatus;
|
||
const profiles = repository.deploymentProfiles || [];
|
||
const workloads = linkedWorkloadsForRepository(repository);
|
||
const runningWorkloads = workloads.filter((workload) => workload.runtime?.running);
|
||
const badges = [];
|
||
if (status?.counts.conflicts)
|
||
badges.push('<span class="mini-badge danger" title="Conflicts">!</span>');
|
||
else if (status?.counts.changed)
|
||
badges.push(
|
||
`<span class="mini-badge warning" title="Changed files">${status.counts.changed}</span>`,
|
||
);
|
||
if (status?.branch.ahead)
|
||
badges.push(
|
||
`<span class="mini-badge" title="Commits ahead">↑${status.branch.ahead}</span>`,
|
||
);
|
||
if (status?.branch.behind)
|
||
badges.push(
|
||
`<span class="mini-badge danger" title="Commits behind">↓${status.branch.behind}</span>`,
|
||
);
|
||
if (repository.readyToDeploy)
|
||
badges.push(
|
||
'<span class="mini-badge success" title="Ready to deploy">↗</span>',
|
||
);
|
||
if (profiles.length)
|
||
badges.push(
|
||
`<span class="mini-badge ${runningWorkloads.length ? "success" : "warning"} deployment-badge" title="${attr(`${profiles.length} server deployment${profiles.length === 1 ? "" : "s"} linked${runningWorkloads.length ? ` · ${runningWorkloads.length} running` : ""}`)}">S${profiles.length}</span>`,
|
||
);
|
||
if (!repository.localPath)
|
||
badges.push('<span class="mini-badge" title="No local folder">—</span>');
|
||
const branch = status?.branch.head || repository.defaultBranch || "remote";
|
||
return `<button class="repo-row ${String(repository.id) === String(ui.selectedRepoId) ? "active" : ""} ${repository.attention ? "attention" : ""}" data-action="select-repo" data-id="${attr(repository.id)}" data-deployment-count="${profiles.length}">
|
||
<span class="repo-icon">${repository.favorite ? icon("star") : icon(repository.localPath ? "git" : "cloud")}</span>
|
||
<span class="repo-main"><span class="repo-name">${escapeHtml(repository.name)}</span><span class="repo-sub"><span>${escapeHtml(branch)}</span>${status?.shortHead ? `<span>• ${escapeHtml(status.shortHead)}</span>` : ""}</span></span>
|
||
<span class="repo-badges">${badges.join("")}</span>
|
||
</button>`;
|
||
}
|
||
|
||
function renderSidebar() {
|
||
const query = `${ui.search} ${ui.repoSearch}`.trim().toLowerCase();
|
||
const repositories = ui.repositories.filter(
|
||
(repository) =>
|
||
!query ||
|
||
`${repository.name} ${repository.fullName} ${repository.description}`
|
||
.toLowerCase()
|
||
.includes(query),
|
||
);
|
||
const favorites = repositories.filter((repository) => repository.favorite);
|
||
const others = repositories.filter((repository) => !repository.favorite);
|
||
const attention = ui.repositories.filter(
|
||
(repository) =>
|
||
repository.attention ||
|
||
repository.localStatus?.counts.changed ||
|
||
repository.localStatus?.branch.ahead ||
|
||
repository.readyToDeploy,
|
||
).length;
|
||
const rows = (list) => list.map(renderRepositoryRow).join("");
|
||
return `<aside class="sidebar">
|
||
<nav class="primary-nav">${navButton("overview", "Overview", "overview", attention || "")}${navButton("deployments", "Deployments", "deploy", operations().filter((item) => item.type === "deployment" && !isTerminalOperation(item.status)).length || "")}${navButton("diagnostics", "Diagnostics", "shield", ui.diagnosticsStatus?.lastWriteError ? "!" : "")}${navButton("settings", "Settings", "settings")}</nav>
|
||
<div class="sidebar-section"><span>Repositories</span><button data-action="refresh" title="Refresh">${icon("refresh")}</button></div>
|
||
<input class="repo-filter" id="repo-filter" value="${attr(ui.repoSearch)}" placeholder="Filter projects" aria-label="Filter projects" />
|
||
<div class="repo-list">
|
||
${favorites.length ? `<div class="repo-group-label">Favorites</div>${rows(favorites)}` : ""}
|
||
${favorites.length && others.length ? '<div class="repo-group-label">All repositories</div>' : ""}
|
||
${others.length ? rows(others) : !favorites.length ? '<div class="empty-state compact"><p>No matching repositories.</p></div>' : ""}
|
||
</div>
|
||
<div class="sidebar-footer"><div class="sidebar-diagnostic-state"><span class="state-dot ${ui.diagnosticsStatus?.lastWriteError ? "danger" : ui.diagnosticsStatus?.enabled === false ? "" : "success"}"></span><div><strong>${ui.diagnosticsStatus?.lastWriteError ? "Diagnostic write error" : ui.diagnosticsStatus?.enabled === false ? "Diagnostics disabled" : "Safe diagnostics active"}</strong><span>${ui.diagnosticsStatus?.lastWriteError ? "Open Diagnostics for details" : "Credentials are redacted locally"}</span></div></div></div>
|
||
</aside>`;
|
||
}
|
||
|
||
function renderSummaryCard(label, value, note, iconName, tone = "") {
|
||
return `<div class="summary-card ${tone}">${icon(iconName)}<div class="eyebrow">${label}</div><div class="summary-value">${value}</div><div class="summary-label">${note}</div></div>`;
|
||
}
|
||
|
||
function queueActionFor(repository) {
|
||
const action = repositoryAction(repository);
|
||
const mapping = {
|
||
link: ["folder", "Link folder", "Local project is not connected", ""],
|
||
error: ["error", "Inspect problem", action.detail, "danger"],
|
||
conflict: ["warning", "Resolve conflicts", action.detail, "danger"],
|
||
commit: ["file", "Review & commit", action.detail, "warning"],
|
||
diverged: ["warning", "Resolve divergence", action.detail, "danger"],
|
||
pull: ["arrowDown", "Synchronize", action.detail, "warning"],
|
||
push: ["arrowUp", "Push commits", action.detail, ""],
|
||
configure: ["settings", "Configure deploy", action.detail, ""],
|
||
"branch-profile": ["branch", "Select profile", action.detail, ""],
|
||
deploy: ["rocket", "Deploy release", action.detail, "success"],
|
||
clean: ["check", "Synchronized", action.detail, "success"],
|
||
};
|
||
return mapping[action.kind] || mapping.clean;
|
||
}
|
||
|
||
function projectIllustration(kind = "flow") {
|
||
return `<div class="project-illustration ${attr(kind)}" data-project-illustration aria-hidden="true">
|
||
<div class="illustration-glow"></div><svg viewBox="0 0 260 150" role="presentation">
|
||
<path class="orbit orbit-a" d="M32 92 C72 20 190 18 230 82"/><path class="orbit orbit-b" d="M42 116 C98 150 190 136 222 60"/>
|
||
<g class="illustration-core"><rect x="83" y="39" width="94" height="74" rx="17"/><path d="M103 66h54M103 79h36M103 92h45"/><circle cx="160" cy="92" r="5"/></g>
|
||
<g class="illustration-node node-a"><circle cx="37" cy="92" r="12"/><path d="m32 92 4 4 7-9"/></g>
|
||
<g class="illustration-node node-b"><circle cx="226" cy="82" r="12"/><path d="M221 82h10M226 77v10"/></g>
|
||
<g class="illustration-node node-c"><circle cx="74" cy="31" r="8"/></g>
|
||
<circle class="signal signal-a" cx="0" cy="0" r="4"/><circle class="signal signal-b" cx="0" cy="0" r="3"/>
|
||
</svg><span class="illustration-label">${kind === "deploy" ? "Live release topology" : kind === "repo" ? "Project signal" : "Release flow"}</span>
|
||
</div>`;
|
||
}
|
||
|
||
function renderOverview() {
|
||
const changed = ui.repositories.filter(
|
||
(repository) => repository.localStatus?.counts.changed,
|
||
).length;
|
||
const unpushed = ui.repositories.filter(
|
||
(repository) => repository.localStatus?.branch.ahead,
|
||
).length;
|
||
const deployable = ui.repositories.filter(
|
||
(repository) => repository.readyToDeploy,
|
||
).length;
|
||
const unhealthy = ui.repositories
|
||
.flatMap((repository) => repository.deploymentProfiles || [])
|
||
.filter((profile) => profile.state?.healthy === false).length;
|
||
const queue = ui.repositories
|
||
.filter((repository) => repositoryAction(repository).kind !== "clean")
|
||
.slice(0, 8);
|
||
const recent = operations().slice(0, 7);
|
||
const active = recent.filter(
|
||
(operation) => !isTerminalOperation(operation.status),
|
||
);
|
||
return `<div class="page">
|
||
<div class="page-header visual-page-header"><div><div class="eyebrow">Coding flow</div><h1>Release overview</h1><p>One decision surface for local work, Gitea synchronization and the exact version running on your server.</p></div>${projectIllustration("flow")}<button class="button" data-action="refresh">${icon("refresh")}Refresh all</button></div>
|
||
${ui.refreshError ? `<div class="notice danger">${icon("error")} ${escapeHtml(ui.refreshError)}</div>` : ""}
|
||
<div class="summary-grid">
|
||
${renderSummaryCard("Local work", changed, changed === 1 ? "repository has changes" : "repositories have changes", "file", changed ? "warning" : "success")}
|
||
${renderSummaryCard("Unpushed", unpushed, "repositories ahead of Gitea", "arrowUp", unpushed ? "warning" : "success")}
|
||
${renderSummaryCard("Ready", deployable, "exact commits ready to deploy", "rocket", deployable ? "success" : "")}
|
||
${renderSummaryCard("Health", unhealthy || active.length, unhealthy ? "unhealthy environments" : active.length ? "operations in progress" : "all checked environments healthy", "pulse", unhealthy ? "danger" : active.length ? "warning" : "success")}
|
||
</div>
|
||
<section class="section-block"><div class="section-heading"><h2>Action queue</h2><span class="meta">Sorted by required attention</span></div><div class="action-queue">
|
||
${
|
||
queue.length
|
||
? queue
|
||
.map((repository) => {
|
||
const [iconName, label, reason, tone] =
|
||
queueActionFor(repository);
|
||
return `<div class="queue-row"><span class="queue-icon ${tone}">${icon(iconName)}</span><div><div class="queue-title">${escapeHtml(repository.name)}</div><div class="queue-sub">${escapeHtml(repository.localStatus?.branch.head || repository.defaultBranch || "remote")} ${repository.localStatus?.shortHead ? `• ${repository.localStatus.shortHead}` : ""}</div></div><div class="queue-reason"><strong>${escapeHtml(label)}</strong><span>${escapeHtml(reason)}</span></div><button class="button" data-action="select-repo" data-id="${attr(repository.id)}">Open ${icon("arrowRight")}</button></div>`;
|
||
})
|
||
.join("")
|
||
: '<div class="empty-state"><div class="empty-icon">✓</div><h3>Everything is synchronized</h3><p>No repository needs immediate attention.</p></div>'
|
||
}
|
||
</div></section>
|
||
<section class="section-block two-column">
|
||
<div class="panel"><div class="panel-header"><h2>Recent deployments</h2><button class="button ghost" data-action="navigate" data-view="deployments">View all</button></div><div class="activity-list">${recent.length ? recent.map((operation) => `<div class="activity-item"><span class="activity-dot ${toneForStatus(operation.status)}"></span><div><div class="activity-title">${escapeHtml(operation.repository)} → ${escapeHtml(operation.environment || "environment")}</div><div class="activity-sub">${escapeHtml(operation.action === "rollback" ? "Rollback" : "Deploy")} ${escapeHtml(operation.shortSha || shortSha(operation.sha))} · ${escapeHtml(operation.status)}</div></div><span class="activity-time">${formatDate(operation.updatedAt || operation.createdAt)}</span></div>`).join("") : '<div class="empty-state compact"><p>No deployment history yet.</p></div>'}</div></div>
|
||
<div class="panel"><div class="panel-header"><h2>Workspace readiness</h2></div><div class="panel-body readiness-list">
|
||
${readinessRow("Git executable", ui.boot.git.available, ui.boot.git.version || ui.boot.git.error)}
|
||
${readinessRow("Gitea connection", ui.boot.state.gitea.hasToken, ui.boot.state.gitea.baseUrl || "Not configured")}
|
||
${readinessRow("Workspace folders", ui.boot.state.workspaceRoots.length > 0, `${ui.boot.state.workspaceRoots.length} configured`)}
|
||
${readinessRow("Automatic awareness", ui.boot.state.preferences?.autoRefresh !== false, ui.boot.state.preferences?.autoRefresh === false ? "Manual refresh only" : `Every ${ui.boot.state.preferences?.repositoryPollSeconds || 4}s`)}
|
||
</div></div>
|
||
</section>
|
||
</div>`;
|
||
}
|
||
|
||
function readinessRow(label, ok, detail) {
|
||
return `<div class="readiness-row"><span class="state-dot ${ok ? "success" : "danger"}"></span><div><strong>${escapeHtml(label)}</strong><span>${escapeHtml(detail)}</span></div></div>`;
|
||
}
|
||
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");
|
||
const additions = lines.filter(
|
||
(line) => line.startsWith("+") && !line.startsWith("+++"),
|
||
).length;
|
||
const removals = lines.filter(
|
||
(line) => line.startsWith("-") && !line.startsWith("---"),
|
||
).length;
|
||
const extension =
|
||
String(ui.selectedFile).split(".").pop()?.slice(0, 8).toUpperCase() ||
|
||
"FILE";
|
||
return `<div class="diff-atmosphere ${lines.length > 34 ? "dense" : ""}" data-diff-atmosphere aria-hidden="true"><svg viewBox="0 0 360 260" role="presentation"><path class="code-route route-a" d="M38 195 C92 84 178 214 318 74"/><path class="code-route route-b" d="M52 74 C132 8 230 34 310 156"/><g class="code-card"><rect x="110" y="75" width="142" height="106" rx="18"/><path d="M136 108h90M136 128h58M136 148h76"/></g><g class="code-node node-one"><circle cx="48" cy="190" r="15"/><path d="m41 190 5 5 9-12"/></g><g class="code-node node-two"><circle cx="315" cy="76" r="13"/><path d="M308 76h14M315 69v14"/></g><circle class="code-packet packet-one" cx="0" cy="0" r="5"/><circle class="code-packet packet-two" cx="0" cy="0" r="4"/></svg><div class="diff-atmosphere-caption"><span>${escapeHtml(extension)} change map</span><strong><i>+${additions}</i><i>−${removals}</i></strong></div></div>`;
|
||
}
|
||
|
||
function renderDiff(diff) {
|
||
if (!diff)
|
||
return '<div class="empty-state"><div class="empty-icon">↔</div><h3>No textual diff</h3><p>Select another file or open the project folder for binary changes.</p></div>';
|
||
const rendered = escapeHtml(diff)
|
||
.split("\n")
|
||
.map((line) => {
|
||
const type =
|
||
line.startsWith("+") && !line.startsWith("+++")
|
||
? "add"
|
||
: line.startsWith("-") && !line.startsWith("---")
|
||
? "remove"
|
||
: line.startsWith("@@")
|
||
? "hunk"
|
||
: "";
|
||
return `<span class="diff-line ${type}">${line || " "}</span>`;
|
||
})
|
||
.join("");
|
||
return `${rendered}${diffAtmosphere(diff)}`;
|
||
}
|
||
function fileStatusCode(file) {
|
||
if (file.conflict) return "U";
|
||
if (file.untracked) return "?";
|
||
return (
|
||
{
|
||
modified: "M",
|
||
added: "A",
|
||
deleted: "D",
|
||
renamed: "R",
|
||
copied: "C",
|
||
"type-changed": "T",
|
||
}[file.status] || "M"
|
||
);
|
||
}
|
||
|
||
function renderChanges(repository) {
|
||
const status = repository.localStatus;
|
||
if (!repository.localPath) {
|
||
const target = displayCloneTarget(repository);
|
||
return `<div class="empty-state full"><div class="empty-icon">${icon("link")}</div><h3>Connect a local project</h3><p>Clone directly into your default project root, or link an existing working tree.</p>${target ? `<div class="notice"><span>${icon("folder")}Automatic destination</span><strong class="mono">${escapeHtml(target)}</strong></div>` : '<div class="notice warning">No default project root is configured. ForgeFlow will ask for one.</div>'}<div class="stack horizontal"><button class="button primary" data-action="clone-repo">${icon("cloud")}${escapeHtml(clonePrimaryLabel(repository))}</button><button class="button" data-action="link-repo">${icon("link")}Link existing folder</button><button class="button ghost" data-action="clone-repo-custom">Choose another location</button></div></div>`;
|
||
}
|
||
if (!status)
|
||
return `<div class="empty-state full"><div class="empty-icon">${icon("error")}</div><h3>Repository unavailable</h3><p>${escapeHtml(repository.attentionReason || "The local working tree could not be read.")}</p></div>`;
|
||
if (!status.files.length)
|
||
return `<div class="empty-state full"><div class="empty-icon">${icon("check")}</div><h3>Working tree clean</h3><p>Local ${escapeHtml(status.branch.head)} is at ${escapeHtml(status.shortHead)} with no uncommitted files.</p><div class="stack horizontal"><button class="button" data-action="fetch">${icon("refresh")}Fetch remote state</button><button class="button" data-action="open-path">${icon("folder")}Open project</button></div></div>`;
|
||
const selected = status.files.find((file) => file.path === ui.selectedFile);
|
||
const conflictActions = selected?.conflict
|
||
? `<div class="notice danger"><div><strong>Conflicted file</strong><p>Choose one side, or edit the file and mark it resolved.</p></div><div class="stack horizontal compact"><button class="button" data-action="resolve-conflict" data-resolution="ours">Use ours</button><button class="button" data-action="resolve-conflict" data-resolution="theirs">Use theirs</button><button class="button primary" data-action="resolve-conflict" data-resolution="resolved">Mark resolved</button></div></div>`
|
||
: "";
|
||
return `<div class="changes-layout"><section class="file-panel"><div class="file-panel-tools"><span><strong>${ui.selectedFiles.size}</strong> selected · ${status.counts.changed} changed · ${status.counts.staged} staged</span><button class="button ghost small" data-action="toggle-all-files">${ui.selectedFiles.size === status.files.length ? "Clear" : "Select all"}</button></div><div class="file-list" tabindex="0" aria-label="Changed files">${status.files.map((file) => `<div class="file-row ${ui.selectedFile === file.path ? "active" : ""}" role="button" tabindex="0" data-action="select-file" data-path="${attr(file.path)}"><input type="checkbox" data-file-select="${attr(file.path)}" ${ui.selectedFiles.has(file.path) ? "checked" : ""} aria-label="Include ${attr(file.path)}"/><span class="file-status ${attr(file.status)}">${fileStatusCode(file)}</span><span class="file-path" title="${attr(file.path)}">${escapeHtml(file.path)}</span><span title="${file.staged ? "Staged" : "Unstaged"}">${file.staged ? "●" : "○"}</span></div>`).join("")}</div>${status.counts.conflicts ? `<div class="card-actions"><button class="button danger" data-action="load-conflicts">${icon("warning")}Conflict guide</button></div>` : ""}</section><section class="diff-panel">${conflictActions}<div class="diff-toolbar"><span class="diff-title">${escapeHtml(ui.selectedFile || "Select a file")}</span><div class="stack horizontal compact">${ui.diffHunks?.partialSupported ? `<button class="button small" data-action="open-hunk-staging">Stage hunks</button>` : ""}${ui.selectedFile ? `<button class="button ghost small" data-action="open-file-editor">${icon("external")}Editor</button>` : ""}<span class="status-pill">${ui.selectedFile ? escapeHtml(selected?.status || "") : ""}</span><button class="icon-button" data-action="copy-diff" title="Copy diff">${icon("copy")}</button></div></div><div class="diff-view">${renderDiff(ui.diff)}</div></section></div>`;
|
||
}
|
||
|
||
function renderHistory(repository) {
|
||
if (!repository.localPath)
|
||
return '<div class="empty-state full"><p>Link a local repository to view commit history.</p></div>';
|
||
if (!ui.history.length)
|
||
return `<div class="empty-state full"><div class="empty-icon">${icon("history")}</div><h3>Load local commit history</h3><p>Review the last commits from this working tree.</p><button class="button primary" data-action="load-history">Load history</button></div>`;
|
||
return `<div class="tab-page"><div class="panel"><table class="data-table"><thead><tr><th>Commit</th><th>Message</th><th>Author</th><th>Date</th></tr></thead><tbody>${ui.history.map((commit) => `<tr><td class="mono">${escapeHtml(commit.shortSha)}</td><td>${escapeHtml(commit.subject)}</td><td>${escapeHtml(commit.author)}</td><td>${formatDate(commit.date)}</td></tr>`).join("")}</tbody></table></div></div>`;
|
||
}
|
||
|
||
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 (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: "" };
|
||
}
|
||
|
||
function dockerManIntegration(profile) {
|
||
const state = profile.state || {};
|
||
const iconMode =
|
||
profile.iconMode ||
|
||
(profile.iconFilePath ? "upload" : profile.iconUrl ? "url" : "builtin");
|
||
const webUiExpected = Boolean(profile.webUiUrl || profile.hostPort);
|
||
const iconExpected = iconMode !== "none";
|
||
const templateReady = Boolean(state.dockerMan?.templateExists);
|
||
const webUiReady =
|
||
!webUiExpected || Boolean(state.dockerMan?.webUi) || templateReady;
|
||
const iconReady =
|
||
!iconExpected || Boolean(state.dockerMan?.icon) || templateReady;
|
||
return {
|
||
iconMode,
|
||
templateReady,
|
||
webUiReady,
|
||
iconReady,
|
||
ready: Boolean(state.containerRunning && webUiReady && iconReady),
|
||
};
|
||
}
|
||
|
||
function deploymentIdentity(profile, repository) {
|
||
const name = String(
|
||
profile.state?.containerName ||
|
||
profile.containerName ||
|
||
profile.remoteFolder ||
|
||
repository.name ||
|
||
"container",
|
||
);
|
||
let hash = 0;
|
||
for (const character of name)
|
||
hash = (hash * 31 + character.charCodeAt(0)) >>> 0;
|
||
return { name, initial: name.slice(0, 1).toUpperCase(), accent: hash % 6 };
|
||
}
|
||
|
||
function renderProfileCard(repository, profile, compact = false) {
|
||
const state = profile.state || {};
|
||
const health = environmentState(profile);
|
||
const isSsh = profile.provider === "ssh-unraid";
|
||
const mode = deploymentMode(profile);
|
||
const verification = ui.serverGitVerifications[profile.id];
|
||
const targetSha = deploymentTargetSha(repository, profile);
|
||
const ready = canDeploy(repository, profile);
|
||
const modeLabel = {
|
||
"push-bundle": "Direct copy",
|
||
"server-git": "Server pull from Gitea",
|
||
"monitor-only": "Monitor only",
|
||
}[mode] || mode;
|
||
const providerDetail = isSsh
|
||
? `SSH / Unraid · ${modeLabel} · ${profile.remoteFolder || repository.name} · ${profile.branch}${profile.adoptedFromServer ? " · server-linked" : ""}`
|
||
: `${profile.workflowFile} · ${profile.branch}`;
|
||
const rollbackConfigured = (isSsh && mode !== "monitor-only") || Boolean(profile.rollbackWorkflowFile);
|
||
const dockerMan = dockerManIntegration(profile);
|
||
const { templateReady, webUiReady, iconReady } = dockerMan;
|
||
const dockerManReady = dockerMan.ready;
|
||
const managesDockerMan = isSsh && profile.manageDockerMan === true;
|
||
const webUi = profile.webUiUrl || state.webUiUrl || state.dockerMan?.webUi || "";
|
||
const identity = deploymentIdentity(profile, repository);
|
||
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
|
||
? "Managed labels/template active"
|
||
: "Managed labels active"
|
||
: `Managed · WebUI ${webUiReady ? "ready" : "missing"} · icon ${iconReady ? "ready" : "missing"}`
|
||
: "Existing DockerMan template preserved";
|
||
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="verify-server-git-access" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("shield")}Verify server pull</button><button class="button" data-action="manage-deploy-key" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon("key")}Deploy key lifecycle</button><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 || [];
|
||
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">${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) {
|
||
if (!repository.localPath)
|
||
return '<div class="empty-state full"><p>Link a local repository to manage branches and stashes.</p></div>';
|
||
const recovery = ui.gitRecovery;
|
||
const locks = recovery?.lockReport?.locks || [];
|
||
const activeProcesses = recovery?.lockReport?.processes?.active || [];
|
||
const recommendations = recovery?.recommendations || [];
|
||
return `<div class="tab-page git-tools-grid"><section class="panel"><div class="panel-header"><h2>Branches</h2><button class="button ghost" data-action="load-git-tools">${icon("refresh")}Refresh</button></div><div class="panel-body"><div class="inline-form"><input id="new-branch-name" class="input" placeholder="feature/name"/><button class="button" data-action="create-branch">${icon("plus")}Create & switch</button></div><div class="tool-list">${ui.branches.length ? ui.branches.map((branch) => `<div class="tool-row"><div><strong>${escapeHtml(branch.name)}</strong><span>${escapeHtml(branch.shortSha)}${branch.upstream ? ` · ${escapeHtml(branch.upstream)}` : " · unpublished"}</span></div>${branch.current ? '<span class="status-pill success">Current</span>' : `<button class="button" data-action="checkout-branch" data-branch="${attr(branch.name)}">Switch</button>`}</div>`).join("") : '<div class="empty-state compact"><p>Load branch information.</p></div>'}</div></div></section><section class="panel"><div class="panel-header"><h2>Stashes</h2><button class="button" data-action="stash-changes" ${repository.localStatus?.clean ? "disabled" : ""}>${icon("archive")}Stash changes</button></div><div class="panel-body"><div class="tool-list">${ui.stashes.length ? ui.stashes.map((stash) => `<div class="tool-row"><div><strong>${escapeHtml(stash.ref)}</strong><span>${escapeHtml(stash.subject)} · ${formatDate(stash.date)}</span></div><button class="button" data-action="pop-stash" data-stash-ref="${attr(stash.ref)}">Apply & drop</button></div>`).join("") : '<div class="empty-state compact"><p>No stashes, or Git tools have not been loaded.</p></div>'}</div></div></section><section class="panel troubleshooting-panel"><div class="panel-header"><div><h2>Repository troubleshooting</h2><span class="meta">Safe, repository-specific recovery actions</span></div><button class="button primary" data-action="scan-git-recovery">${icon("pulse")}Scan</button></div><div class="panel-body">${recovery ? `<div class="troubleshooting-summary"><span class="status-pill ${locks.length ? "warning" : "success"}">${locks.length ? `${locks.length} lock${locks.length === 1 ? "" : "s"}` : "No Git locks"}</span><span>${activeProcesses.length ? `${activeProcesses.length} active Git process(es)` : "No matching active Git process detected"}</span></div>${locks.length ? `<div class="tool-list">${locks.map((lock) => `<div class="tool-row"><div><strong>${escapeHtml(lock.name)}</strong><span>${Math.round(lock.ageMs / 1000)}s old · ${escapeHtml(lock.modifiedAt)}</span></div></div>`).join("")}</div>` : ""}${recommendations.length ? `<div class="tool-list recovery-actions">${recommendations.map((item) => `<div class="tool-row"><div><strong>${escapeHtml(item.label)}</strong><span>${item.safe ? "Safe automated action" : item.action ? "Creates a safety branch before changing history" : "Review required"}</span></div>${item.action ? `<button class="button ${item.safe ? "" : "danger"}" data-action="repair-repository-sync" data-strategy="${attr(item.action)}">Run</button>` : ""}</div>`).join("")}</div>` : ""}` : '<div class="empty-state compact"><p>Scan before repairing. ForgeFlow checks every .lock file in the actual Git directory, not only index.lock.</p></div>'}<div class="card-actions"><button class="button" data-action="repair-git-locks">${icon("wrench")}Repair proven stale locks</button><button class="button" data-action="reconcile-repository">${icon("refresh")}Refresh Git state</button>${repository.sshUrl && repository.localStatus?.remoteUrl !== repository.sshUrl ? `<button class="button" data-action="repair-origin">${icon("link")}Repair origin</button>` : ""}</div><div class="notice warning">Lock repair refuses to run while a matching Git process is active. A force option is shown only when process detection itself is unavailable.</div></div></section></div>`;
|
||
}
|
||
|
||
function renderRepositorySettings(repository) {
|
||
const automaticTarget = displayCloneTarget(repository);
|
||
const currentOrigin = repository.localStatus?.remoteUrl || "Unavailable";
|
||
const desiredOrigin = repository.sshUrl || repository.preferredCloneUrl || "";
|
||
const originNeedsRepair = Boolean(
|
||
repository.localPath && desiredOrigin && currentOrigin !== desiredOrigin,
|
||
);
|
||
const pullRequests = ui.pullRequests || [];
|
||
return `<div class="tab-page"><section class="settings-group"><h2>Repository identity</h2><div class="form-grid"><div class="field full"><label>Gitea repository</label><input class="input" value="${attr(repository.fullName)}" readonly/></div><div class="field full"><label>Local working tree</label><input class="input mono" value="${attr(repository.localPath || automaticTarget || "Not linked")}" readonly/></div><div class="field full"><label>Current origin</label><input class="input mono" value="${attr(currentOrigin)}" readonly/></div>${desiredOrigin ? `<div class="field full"><label>Current Gitea SSH origin</label><input class="input mono" value="${attr(desiredOrigin)}" readonly/></div>` : ""}</div><div class="card-actions"><button class="button" data-action="${repository.localPath ? "open-path" : "link-repo"}">${icon("folder")}${repository.localPath ? "Open project folder" : "Link local folder"}</button>${originNeedsRepair ? `<button class="button primary" data-action="repair-origin">${icon("link")}Use current Gitea origin</button>` : ""}${repository.localPath ? `<button class="button" data-action="scan-git-recovery">${icon("pulse")}Scan Git health</button><button class="button danger" data-action="unlink-repo">${icon("link")}Remove link</button>` : `<button class="button primary" data-action="clone-repo">${icon("cloud")}${escapeHtml(clonePrimaryLabel(repository))}</button><button class="button ghost" data-action="clone-repo-custom">Choose another location</button>`}</div></section><section class="settings-group"><div class="section-heading"><div><h2>Open pull requests</h2><span class="meta">Live from Gitea</span></div><button class="button" data-action="load-pull-requests">${icon("refresh")}Refresh</button></div>${pullRequests.length ? `<div class="tool-list">${pullRequests.map((pull) => `<div class="tool-row"><div><strong>#${pull.number} · ${escapeHtml(pull.title)}</strong><span>${escapeHtml(pull.head?.ref || pull.head?.label || "source")} → ${escapeHtml(pull.base?.ref || pull.base?.label || "target")} · ${formatDate(pull.updated_at || pull.created_at)}</span></div><button class="button" data-action="open-pull-request-url" data-url="${attr(pull.html_url || "")}">Open</button></div>`).join("")}</div>` : '<div class="empty-state compact"><p>No open pull requests.</p></div>'}</section><section class="settings-group"><h2>Repository behavior</h2><div class="notice">${icon("shield")}Origin repair changes only the Git remote URL. Git health scans the actual Git directory, repairs only proven stale lock files and never changes source files or commits.</div></section></div>`;
|
||
}
|
||
|
||
function renderGitValidator(repository) {
|
||
const report = ui.gitValidation;
|
||
if (!report)
|
||
return `<div class="validator-empty panel">${projectIllustration("diagnostics")}<div><div class="eyebrow">Repository assurance</div><h2>Validate Git best practices</h2><p>Inspect repository identity, branch governance, tracked secrets, file hygiene and safe local synchronization settings.</p><button class="button primary" data-action="git-validator-scan">${icon("shield")}Run Git Validator</button></div></div>`;
|
||
const tone =
|
||
report.score >= 90 ? "success" : report.score >= 70 ? "warning" : "danger";
|
||
const groups = report.checks.reduce((grouped, check) => {
|
||
(grouped[check.category] ||= []).push(check);
|
||
return grouped;
|
||
}, {});
|
||
const trend = report.trend || {};
|
||
return `<div class="validator-page"><section class="validator-hero panel ${tone}"><div class="validator-score"><strong>${report.score}</strong><span>/ 100</span></div><div><div class="eyebrow">${escapeHtml(report.policy?.label || "Standard")} policy · ${report.ready ? "release-ready" : "review required"}</div><h2>${escapeHtml(report.grade)}</h2><p>${report.summary.passed} passed · ${report.summary.warnings} recommendations · ${report.summary.errors} critical</p><p class="meta">${trend.newlyFound?.length || 0} new · ${trend.resolved?.length || 0} resolved · ${trend.regressions?.length || 0} regressions · ${report.expiredSuppressions?.length || 0} expired exceptions</p></div>${projectIllustration("diagnostics")}<div class="validator-actions"><label class="sr-only" for="validator-policy">Assurance policy</label><select id="validator-policy" class="select" data-action="git-validator-policy">${["minimal", "standard", "strict", "production"].map((policy) => `<option value="${policy}" ${report.policy?.id === policy ? "selected" : ""}>${policy[0].toUpperCase() + policy.slice(1)}</option>`).join("")}</select><button class="button" data-action="git-validator-scan">${icon("refresh")}Scan again</button><button class="button" data-action="git-validator-export" data-format="markdown">Export report</button></div></section><div class="validator-groups">${Object.entries(
|
||
groups,
|
||
)
|
||
.map(
|
||
([category, checks]) =>
|
||
`<section class="panel validator-group"><div class="panel-header"><h3>${escapeHtml(category)}</h3><span class="meta">${checks.filter((check) => check.status === "pass").length}/${checks.length} passed</span></div><div class="validator-checks">${checks
|
||
.map((check) => {
|
||
const checkIndex = report.checks.indexOf(check);
|
||
return `<article class="validator-check ${check.status}"><span class="validator-check-icon">${icon(check.status === "pass" ? "check" : check.status === "error" ? "error" : "warning")}</span><div><strong>${escapeHtml(check.title)}</strong><p>${escapeHtml(check.detail)}</p>${check.suppressed ? `<small>Suppressed until ${formatDate(check.suppression.expiresAt)} · ${escapeHtml(check.suppression.reason)}</small>` : check.expiredSuppression ? `<small>Exception expired; finding is active again.</small>` : ""}</div>${check.fixAction ? `<button class="button ${check.safe ? "" : "primary"}" data-action="git-validator-repair" data-check-index="${checkIndex}">${icon("wrench")}Preview fix</button>` : check.status !== "pass" && !check.suppressed ? `<button class="button" data-action="git-validator-suppress" data-check-index="${checkIndex}">Document exception</button>` : `<span class="status-pill ${check.suppressed ? "warning" : check.status === "pass" ? "success" : check.status === "error" ? "danger" : "warning"}">${check.suppressed ? "Suppressed" : check.status === "pass" ? "Best practice" : "Review"}</span>`}</article>`;
|
||
})
|
||
.join("")}</div></section>`,
|
||
)
|
||
.join("")}</div></div>`;
|
||
}
|
||
|
||
function renderRepositoryWorkspace(repository) {
|
||
const status = repository.localStatus;
|
||
const profiles = repository.deploymentProfiles || [];
|
||
const linkedWorkloads = linkedWorkloadsForRepository(repository);
|
||
const profile = selectedProfile(repository);
|
||
const profileWorkload = linkedWorkloads.find((workload) => workload.link?.profileId === profile?.id);
|
||
const serverState = profile?.state || {};
|
||
const localTone = status?.counts.conflicts
|
||
? "danger"
|
||
: status?.counts.changed
|
||
? "warning"
|
||
: status
|
||
? "success"
|
||
: "";
|
||
const remoteTone = status?.branch.behind
|
||
? "danger"
|
||
: status?.branch.ahead
|
||
? "warning"
|
||
: status?.branch.upstream
|
||
? "success"
|
||
: "";
|
||
const serverTone =
|
||
serverState.healthy === false
|
||
? "danger"
|
||
: serverState.healthy === true
|
||
? "success"
|
||
: profileWorkload?.runtime?.running
|
||
? "success"
|
||
: "";
|
||
const content = (
|
||
{
|
||
changes: renderChanges,
|
||
history: renderHistory,
|
||
deployments: renderRepositoryDeployments,
|
||
gittools: renderGitTools,
|
||
validator: renderGitValidator,
|
||
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) : profile ? "Linked" : "Unknown", profileWorkload ? `${profileWorkload.displayName || profile.containerName || "Container"} · ${profileWorkload.runtime?.running ? "running" : "stopped"} on ${profileWorkload.serverName}` : profile ? (serverState.checkedAt ? `checked ${formatDate(serverState.checkedAt)}` : "profile linked · awaiting live scan") : "No deployment profile", serverTone)}</div>
|
||
${deploymentLinks}
|
||
<nav class="tabs">${[
|
||
["changes", "Changes"],
|
||
["history", "History"],
|
||
["deployments", "Deployments"],
|
||
["gittools", "Git tools"],
|
||
["validator", "Git Validator"],
|
||
["settings", "Project settings"],
|
||
]
|
||
.map(
|
||
([id, label]) =>
|
||
`<button class="tab ${ui.repositoryTab === id ? "active" : ""}" data-action="repo-tab" data-tab="${id}">${label}</button>`,
|
||
)
|
||
.join("")}</nav><div class="repo-content">${content}</div></div>`;
|
||
}
|
||
|
||
function renderActionPanel(repository) {
|
||
const action = repositoryAction(repository);
|
||
const status = repository.localStatus;
|
||
const profile = selectedProfile(repository);
|
||
let body = "";
|
||
if (action.kind === "link") {
|
||
const target = displayCloneTarget(repository);
|
||
body = `<div class="panel-callout"><div class="callout-icon">${icon("link")}</div><h2>${action.title}</h2><p>${action.detail}</p>${target ? `<div class="deploy-proof"><span>Project root</span><strong class="mono">${escapeHtml(defaultWorkspaceRoot())}</strong><span>New folder</span><strong class="mono">${escapeHtml(safeCloneFolderName(repository))}</strong></div>` : '<div class="notice warning">No default project root is configured yet.</div>'}<button class="button primary block" data-action="clone-repo">${icon("cloud")}${escapeHtml(clonePrimaryLabel(repository))}</button><button class="button block" style="margin-top:8px" data-action="link-repo">Link existing folder</button><button class="button ghost block" style="margin-top:8px" data-action="clone-repo-custom">Choose another clone location</button></div>`;
|
||
} else if (action.kind === "commit") {
|
||
const hasStagedSelection = status.counts.staged > 0;
|
||
const commitReady = Boolean(
|
||
(ui.selectedFiles.size || hasStagedSelection) && ui.commitMessage.trim(),
|
||
);
|
||
const commitBlocker =
|
||
!ui.selectedFiles.size && !hasStagedSelection
|
||
? "Select files or stage one or more hunks."
|
||
: !ui.commitMessage.trim()
|
||
? "Enter a commit message to enable commit and push."
|
||
: ui.selectedFiles.size
|
||
? "Ready to commit. ForgeFlow stages the selected files automatically."
|
||
: "Ready to commit only the reviewed staged hunks.";
|
||
body = `<label class="field-label" for="commit-message">Commit message <span class="required-mark">required</span></label><textarea id="commit-message" class="textarea" placeholder="Describe what changed and why…">${escapeHtml(ui.commitMessage)}</textarea><div class="field-hint"><span>${ui.selectedFiles.size ? `${ui.selectedFiles.size} of ${status.counts.changed} files selected` : `${status.counts.staged} staged file(s)`}</span><span>Ctrl+Enter</span></div><div class="commit-readiness ${commitReady ? "ready" : "blocked"}">${icon(commitReady ? "check" : "warning")}<span>${escapeHtml(commitBlocker)}</span></div><button class="button primary block" style="margin-top:10px" data-action="commit-push" ${commitReady ? "" : `disabled title="${attr(commitBlocker)}"`}>${icon("arrowUp")}${ui.selectedFiles.size ? "Commit selected" : "Commit staged hunks"} & push to Gitea</button><button class="button block" style="margin-top:8px" data-action="commit-only" ${commitReady ? "" : `disabled title="${attr(commitBlocker)}"`}>${icon("git")}${ui.selectedFiles.size ? "Commit selected locally" : "Commit staged hunks locally"}</button><div class="stage-note">Partial hunk staging is preserved when no complete files are selected.</div><div class="stack" style="margin-top:8px"><button class="button block" data-action="stage-selected" ${ui.selectedFiles.size ? "" : "disabled"}>Stage selected files</button><button class="button block" data-action="unstage-selected" ${ui.selectedFiles.size ? "" : "disabled"}>Unstage selected files</button><button class="button block" data-action="stash-changes">${icon("archive")}Stash all changes</button></div>`;
|
||
} else if (action.kind === "pull")
|
||
body = `<div class="panel-callout"><div class="callout-icon warning">${icon("arrowDown")}</div><h2>${action.title}</h2><p>${action.detail}</p><button class="button primary block" data-action="pull">Fast-forward from Gitea</button></div>`;
|
||
else if (action.kind === "push")
|
||
body = `<div class="panel-callout"><div class="callout-icon">${icon("arrowUp")}</div><h2>${action.title}</h2><p>${action.detail}</p><button class="button primary block" data-action="push">Push ${status.branch.ahead} commit${status.branch.ahead === 1 ? "" : "s"}</button></div>`;
|
||
else if (
|
||
action.kind === "diverged" ||
|
||
action.kind === "conflict" ||
|
||
action.kind === "error"
|
||
)
|
||
body = `<div class="panel-callout"><div class="callout-icon danger">${icon("error")}</div><h2>${action.title}</h2><p>${action.detail}</p>${action.kind === "diverged" ? `<button class="button primary block" data-action="load-git-tools">${icon("wrench")}Open guided repository repair</button>` : ""}<button class="button block" style="margin-top:8px" data-action="open-path">Open project folder</button><button class="button block" style="margin-top:8px" data-action="refresh">Refresh status</button></div>`;
|
||
else if (action.kind === "configure")
|
||
body = `<div class="panel-callout"><div class="callout-icon">${icon("settings")}</div><h2>${action.title}</h2><p>${action.detail}</p><button class="button primary block" data-action="configure-deployment">Configure first environment</button></div>`;
|
||
else if (action.kind === "branch-profile")
|
||
body = `<div class="panel-callout"><div class="callout-icon">${icon("branch")}</div><h2>${action.title}</h2><p>${action.detail}</p>${repository.deploymentProfiles.length > 1 ? `<label class="field-label">Deployment profile</label><select id="action-profile-select" class="select">${repository.deploymentProfiles.map((item) => `<option value="${attr(item.id)}" ${item.id === profile?.id ? "selected" : ""}>${escapeHtml(item.name)} · ${escapeHtml(item.branch)}</option>`).join("")}</select>` : ""}<button class="button block" style="margin-top:8px" data-action="edit-deployment-profile" data-profile-id="${attr(profile?.id || "")}">Edit profile</button></div>`;
|
||
else if (action.kind === "deploy")
|
||
body = `<div class="panel-callout"><div class="callout-icon success">${icon("rocket")}</div><h2>Release ${escapeHtml(status.shortHead)}</h2><p>${escapeHtml(profile.name)} will deploy the exact commit from ${escapeHtml(profile.branch)} to ${escapeHtml(profile.environment)}.</p>${repository.deploymentProfiles.length > 1 ? `<label class="field-label">Environment</label><select id="action-profile-select" class="select">${repository.deploymentProfiles.map((item) => `<option value="${attr(item.id)}" ${item.id === profile.id ? "selected" : ""}>${escapeHtml(item.name)} · ${escapeHtml(item.environment)}</option>`).join("")}</select>` : ""}<div class="deploy-proof"><span>Local</span><strong>${escapeHtml(status.shortHead)}</strong><span>Gitea</span><strong>${escapeHtml(status.shortHead)}</strong><span>Target</span><strong>${escapeHtml(profile.environment)}</strong></div><button class="button success block" data-action="deploy-profile" data-profile-id="${attr(profile.id)}">${icon("rocket")}Deploy ${escapeHtml(status.shortHead)} → ${escapeHtml(profile.environment)}</button>${profile.state?.previousSha && profile.rollbackWorkflowFile ? `<button class="button danger block" style="margin-top:8px" data-action="rollback-profile" data-profile-id="${attr(profile.id)}">${icon("undo")}Rollback to ${shortSha(profile.state.previousSha)}</button>` : ""}</div>`;
|
||
else
|
||
body = `<div class="panel-callout"><div class="callout-icon success">${icon("check")}</div><h2>${action.title}</h2><p>${action.detail}</p>${profile ? `<button class="button block" data-action="refresh-profile-state" data-profile-id="${attr(profile.id)}">${icon("pulse")}Check ${escapeHtml(profile.environment)}</button>` : ""}</div>`;
|
||
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 renderServerInventory() {
|
||
const servers = ui.serverDiscovery || [];
|
||
const configuredServers = ui.boot?.state?.servers || [];
|
||
const hiddenClassifications = new Set(["backup", "release-folder", "system-container", "manually-excluded"]);
|
||
const visibleForServer = (server) => (server.workloads || []).filter((workload) =>
|
||
workload.reviewDecisionStale || workload.classification?.type === "duplicate" || (!hiddenClassifications.has(workload.classification?.type) && (workload.link || workload.runtime?.running || ["ambiguous", "orphan-container", "stopped-application", "historical-compose", "stale-link", "monitor-only"].includes(workload.classification?.type))),
|
||
);
|
||
const reviewCount = servers.reduce((total, server) => total + visibleForServer(server).filter((workload) => !workload.link || workload.classification?.type === "stale-link" || workload.reviewDecisionStale).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 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 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" : 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
|
||
? `DockerMan ${workload.dockerMan.name || workload.displayName} · ${containers || "template only"}`
|
||
: `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";
|
||
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>`;
|
||
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 || 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>`;
|
||
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>`
|
||
: `<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() {
|
||
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 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;
|
||
const prefs = state.preferences || {};
|
||
const update = ui.updateStatus;
|
||
const servers = state.servers || [];
|
||
return `<div class="settings-layout"><aside class="settings-nav"><button class="nav-button active">${icon("settings")}<span>General</span></button><button class="nav-button" data-action="check-updates">${icon("update")}<span>Updates</span></button><button class="nav-button" data-action="open-add-server">${icon("server")}<span>Servers</span></button><button class="nav-button" data-action="reset-app">${icon("trash")}<span>Reset setup</span></button></aside><div class="settings-content"><div class="page-header"><div><div class="eyebrow">Application</div><h1>Settings</h1><p>Connections, project discovery, secure SSH servers and application updates.</p></div></div>
|
||
<section class="settings-group"><h2>Gitea connection</h2><div class="form-grid"><div class="field full"><label for="settings-gitea-url">Instance URL</label><input id="settings-gitea-url" class="input" value="${attr(state.gitea.baseUrl)}" placeholder="https://gitea.example.com" /></div><div class="field full"><label for="settings-gitea-token">New access token</label><input id="settings-gitea-token" class="input" type="password" placeholder="Leave empty to keep the existing token" /></div></div><div class="connection-card" style="margin-top:10px"><div><strong>${state.gitea.hasToken ? `Connected as ${escapeHtml(state.gitea.user?.login || "user")}` : "Not connected"}</strong><div class="queue-sub">${escapeHtml(state.gitea.baseUrl || "No Gitea instance configured")}</div></div><button class="button primary" data-action="save-gitea-settings">Validate & save</button></div></section>
|
||
<section class="settings-group"><div class="section-heading"><div><h2>ForgeFlow updates</h2><span class="meta">Secure source update from ${escapeHtml(state.updates?.owner || "Jens")}/${escapeHtml(state.updates?.repo || "ForgeFlow")}</span></div><button class="button" data-action="check-updates" ${ui.updateChecking ? "disabled" : ""}>${icon("update")}${ui.updateChecking ? "Checking…" : "Check now"}</button></div><div class="form-grid"><div class="field"><label>Repository owner</label><input id="update-owner" class="input" value="${attr(state.updates?.owner || "Jens")}"/></div><div class="field"><label>Repository name</label><input id="update-repo" class="input" value="${attr(state.updates?.repo || "ForgeFlow")}"/></div><div class="field"><label>Release branch</label><input id="update-branch" class="input" value="${attr(state.updates?.branch || "main")}"/></div><div class="field"><label>Automatic startup check</label><select id="update-auto-check" class="select"><option value="true" ${state.updates?.autoCheck !== false ? "selected" : ""}>Enabled</option><option value="false" ${state.updates?.autoCheck === false ? "selected" : ""}>Disabled</option></select></div></div><div class="update-card ${update?.available ? "available" : ""}"><div>${icon(update?.available ? "download" : "check")}<span><strong>${update ? (update.available ? `ForgeFlow ${escapeHtml(update.remoteVersion)} is available` : `ForgeFlow ${escapeHtml(update.currentVersion)} is up to date`) : `Current version ${escapeHtml(ui.boot.appVersion)}`}</strong><small>${update ? `Branch ${escapeHtml(update.branch)} · commit ${escapeHtml(update.shortSha)} · checked ${formatDate(update.checkedAt)}` : "No update check in this session."}</small></span></div><div class="stack horizontal compact">${update?.available && !update.downloaded ? `<button class="button primary" data-action="download-update">${icon("download")}Download update</button>` : ""}${update?.downloaded ? `<button class="button success" data-action="apply-update">${icon("update")}Apply & restart</button>` : ""}<button class="button" data-action="save-update-settings">Save update settings</button></div></div><div class="notice" style="margin-top:10px">${icon("shield")}The updater downloads an authenticated ZIP for the exact remote commit, verifies its SHA-256 checksum, runs the complete quality gate and restores the previous source version if validation fails.</div></section>
|
||
<section class="settings-group"><div class="section-heading"><div><h2>SSH / Unraid servers</h2><span class="meta">Credentials are entered locally and encrypted with the Windows credential protection used by Electron.</span></div><button class="button primary" data-action="open-add-server">${icon("plus")}Add server</button></div>${servers.length ? `<div class="server-list">${servers.map((server) => `<article class="server-card"><div class="server-card-main">${icon("server")}<div><strong>${escapeHtml(server.name)}</strong><span>${escapeHtml(server.username)}@${escapeHtml(server.host)}:${escapeHtml(server.port)} · ${escapeHtml(server.basePath)}</span><small>${server.hostFingerprint ? `Trusted ${escapeHtml(server.hostFingerprint)}` : "Host identity not trusted yet"}</small></div></div><div class="stack horizontal compact"><button class="button" data-action="test-server" data-server-id="${attr(server.id)}">Test & trust</button><button class="button" data-action="edit-server" data-server-id="${attr(server.id)}">Edit</button><button class="icon-button danger" data-action="delete-server" data-server-id="${attr(server.id)}" title="Delete server">${icon("trash")}</button></div></article>`).join("")}</div>` : '<div class="empty-state compact"><p>No SSH server configured. Add your Unraid server before creating an SSH deployment profile.</p></div>'}</section>
|
||
<section class="settings-group"><div class="section-heading"><div><h2>Git remote maintenance</h2><span class="meta">Standardize linked repositories to the current Gitea SSH URLs.</span></div><button class="button" data-action="normalize-origins">${icon("link")}Normalize all origins</button></div><p>This replaces legacy aliases and renamed owners only after an explicit click. Local commits and files are not changed.</p></section>
|
||
<section class="settings-group"><h2>Project roots</h2><p>The first folder is the default clone destination. ForgeFlow automatically creates one subfolder per repository.</p><div class="stack">${state.workspaceRoots.map((root, index) => `<div class="root-row">${index === 0 ? '<span class="status-pill success">Default</span>' : ""}<input class="input" data-root-index="${index}" value="${attr(root)}" aria-label="Project root ${index + 1}"/><button class="icon-button" data-action="remove-root" data-index="${index}" title="Remove">${icon("trash")}</button></div>`).join("")}<button class="button" data-action="add-root">${icon("plus")}Add project root</button><button class="button primary" data-action="save-roots">Save folders & rescan</button></div></section>
|
||
<section class="settings-group"><h2>Background awareness</h2><div class="form-grid"><div class="field"><label>Automatic repository refresh</label><select id="pref-auto-refresh" class="select"><option value="true" ${prefs.autoRefresh !== false ? "selected" : ""}>Enabled</option><option value="false" ${prefs.autoRefresh === false ? "selected" : ""}>Disabled</option></select></div><div class="field"><label>Local poll interval</label><input id="pref-repo-poll" class="input" type="number" min="2" max="60" value="${attr(prefs.repositoryPollSeconds || 4)}"/></div><div class="field"><label>Actions poll interval</label><input id="pref-operation-poll" class="input" type="number" min="3" max="120" value="${attr(prefs.operationPollSeconds || 5)}"/></div><div class="field"><label>Preferred clone protocol</label><select id="pref-clone-protocol" class="select"><option value="https" ${prefs.preferredCloneProtocol !== "ssh" ? "selected" : ""}>HTTPS</option><option value="ssh" ${prefs.preferredCloneProtocol === "ssh" ? "selected" : ""}>SSH</option></select></div></div><button class="button primary" style="margin-top:12px" data-action="save-preferences">Save awareness settings</button></section>
|
||
<section class="settings-group"><h2>Desktop integration</h2><div class="form-grid"><div class="field"><label>Editor executable</label><input id="pref-editor-executable" class="input" value="${attr(prefs.editor?.executable || "code")}"/></div><div class="field"><label>Editor arguments</label><input id="pref-editor-args" class="input" value="${attr((prefs.editor?.args || ["--reuse-window", "--goto", "{file}:{line}"]).join(" | "))}"/><small>Separate arguments with |. Placeholders: {path}, {file}, {line}</small></div><div class="field"><label>Terminal executable</label><input id="pref-terminal-executable" class="input" value="${attr(prefs.terminal?.executable || "wt.exe")}"/></div><div class="field"><label>Terminal arguments</label><input id="pref-terminal-args" class="input" value="${attr((prefs.terminal?.args || ["-d", "{path}"]).join(" | "))}"/></div><label class="check-field"><input id="pref-notifications" type="checkbox" ${prefs.notificationsEnabled !== false ? "checked" : ""}/><span>Native deployment notifications</span></label><label class="check-field"><input id="pref-tray" type="checkbox" ${prefs.trayEnabled !== false ? "checked" : ""}/><span>Show system tray icon</span></label><label class="check-field"><input id="pref-close-tray" type="checkbox" ${prefs.closeToTray === true ? "checked" : ""}/><span>Hide to tray when closing</span></label><label class="check-field"><input id="pref-login" type="checkbox" ${prefs.startAtLogin === true ? "checked" : ""}/><span>Start ForgeFlow at login</span></label></div><button class="button primary" data-action="save-desktop-preferences">Save desktop integration</button></section>
|
||
<section class="settings-group"><h2>Encrypted configuration backup</h2><p>Repository mappings, servers, deployment profiles and preferences are encrypted. Tokens, passwords, passphrases and operation history are never exported.</p><div class="inline-form"><input id="backup-passphrase" class="input" type="password" minlength="12" placeholder="Passphrase of at least 12 characters"/><button class="button" data-action="export-config-backup">Export</button><button class="button" data-action="import-config-backup">Import</button></div></section>
|
||
<section class="settings-group"><h2>Appearance</h2><div class="field"><label for="appearance-select">Color theme</label><select id="appearance-select" class="select"><option value="dark" ${state.appearance === "dark" ? "selected" : ""}>Dark</option><option value="light" ${state.appearance === "light" ? "selected" : ""}>Light</option><option value="system" ${state.appearance === "system" ? "selected" : ""}>Follow system</option></select></div></section>
|
||
<section class="settings-group danger-zone"><h2>Danger zone</h2><p>Reset removes local ForgeFlow configuration, repository links, profiles and operation history. It does not modify Git repositories or Gitea.</p><button class="button danger" data-action="reset-app">Reset ForgeFlow</button></section>
|
||
</div></div>`;
|
||
}
|
||
|
||
function preflightTone(status) {
|
||
return status === "pass"
|
||
? "success"
|
||
: status === "fail"
|
||
? "danger"
|
||
: status === "warning"
|
||
? "warning"
|
||
: "";
|
||
}
|
||
|
||
function renderPreflightChecks(
|
||
report,
|
||
emptyMessage = "Run the preflight to verify this configuration.",
|
||
) {
|
||
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>` : ""}${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() {
|
||
const prefs = ui.boot.state.preferences || {};
|
||
const status = ui.diagnosticsStatus || ui.boot.diagnostics || {};
|
||
const report = ui.systemPreflight;
|
||
const trouble = ui.troubleshooter;
|
||
const troubleRows =
|
||
trouble?.issues
|
||
?.map(
|
||
(item, index) =>
|
||
`<div class="preflight-row"><span class="preflight-state ${item.severity === "error" ? "danger" : "warning"}">${icon(item.severity === "error" ? "error" : "warning")}</span><div><strong>${escapeHtml(item.title)}</strong><span>${escapeHtml(item.repository || "System")} · ${escapeHtml(item.detail)}</span></div>${item.repairable ? `<button class="button ${item.safe ? "primary" : "danger"}" data-action="troubleshooter-repair" data-issue-index="${index}">${icon("wrench")}${item.safe ? "Repair" : "Review & repair"}</button>` : '<span class="status-pill">Manual review</span>'}</div>`,
|
||
)
|
||
.join("") || "";
|
||
return `<div class="page diagnostics-page"><div class="page-header"><div><div class="eyebrow">Local troubleshooting</div><h1>Diagnostics & support bundle</h1><p>ForgeFlow records structured development diagnostics locally while removing tokens, passwords, authorization headers, private keys and user-home paths.</p></div><button class="button primary" data-action="export-diagnostics">${icon("archive")}Export safe bundle</button></div>
|
||
<div class="notice success">${icon("shield")}Credentials are never added to the diagnostic bundle. Known runtime secrets are redacted again during export. You can inspect the ZIP before sharing it.</div>
|
||
<div class="diagnostic-grid">
|
||
<section class="panel"><div class="panel-header"><h2>Log storage</h2><span class="status-pill ${status.lastWriteError ? "danger" : status.enabled ? "success" : ""}">${status.lastWriteError ? "Write error" : status.enabled ? "Recording" : "Disabled"}</span></div><div class="panel-body"><div class="diagnostic-metrics"><div><span>Files</span><strong>${escapeHtml(status.fileCount ?? "—")}</strong></div><div><span>Total size</span><strong>${escapeHtml(status.totalSize || "—")}</strong></div><div><span>Latest event</span><strong>${status.latestAt ? formatDate(status.latestAt) : "None"}</strong></div><div><span>Retention</span><strong>${escapeHtml(status.retentionDays || prefs.logRetentionDays || 14)} days</strong></div></div><div class="context-summary" style="margin-top:12px"><div class="context-row"><span>Location</span><strong>${escapeHtml(status.directory || "Unavailable")}</strong></div><div class="context-row"><span>Level</span><strong>${escapeHtml(status.level || prefs.diagnosticLevel || "info")}</strong></div>${status.lastWriteError ? `<div class="context-row"><span>Error</span><strong>${escapeHtml(status.lastWriteError)}</strong></div>` : ""}</div><div class="card-actions"><button class="button" data-action="open-diagnostics-folder">${icon("folder")}Open logs</button><button class="button danger" data-action="clear-diagnostics">${icon("trash")}Clear logs</button></div></div></section>
|
||
<section class="panel"><div class="panel-header"><h2>Recording policy</h2></div><div class="panel-body"><div class="form-grid"><div class="field"><label>Diagnostic logging</label><select id="diagnostics-enabled" class="select"><option value="true" ${prefs.diagnosticsEnabled !== false ? "selected" : ""}>Enabled</option><option value="false" ${prefs.diagnosticsEnabled === false ? "selected" : ""}>Disabled</option></select></div><div class="field"><label>Minimum level</label><select id="diagnostic-level" class="select"><option value="debug" ${prefs.diagnosticLevel === "debug" ? "selected" : ""}>Debug</option><option value="info" ${!prefs.diagnosticLevel || prefs.diagnosticLevel === "info" ? "selected" : ""}>Info</option><option value="warning" ${prefs.diagnosticLevel === "warning" ? "selected" : ""}>Warning</option><option value="error" ${prefs.diagnosticLevel === "error" ? "selected" : ""}>Error only</option></select></div><div class="field"><label>Retention days</label><input id="diagnostic-retention" class="input" type="number" min="1" max="90" value="${attr(prefs.logRetentionDays || 14)}"/></div><div class="field"><label>Maximum file size (MB)</label><input id="diagnostic-max-file" class="input" type="number" min="1" max="50" value="${attr(prefs.maxLogFileMb || 8)}"/></div></div><button class="button primary" style="margin-top:12px" data-action="save-diagnostics-preferences">Save diagnostic policy</button></div></section>
|
||
</div>
|
||
<section class="section-block"><div class="section-heading"><div><h2>One-click troubleshooter</h2><span class="meta">Git locks, interrupted operations, branch synchronization and deployment/server inconsistencies</span></div><div class="stack horizontal compact"><button class="button" data-action="run-troubleshooter">${icon("pulse")}Scan everything</button>${trouble?.issues?.some((item) => item.repairable && item.safe) ? `<button class="button primary" data-action="troubleshooter-auto-repair">${icon("wrench")}Repair ${trouble.issues.filter((item) => item.repairable && item.safe).length} safe issue(s)</button>` : ""}</div></div><div class="panel"><div class="preflight-summary">${trouble ? `<span class="status-pill ${trouble.summary.errors ? "danger" : trouble.summary.warnings ? "warning" : "success"}">${trouble.summary.total ? `${trouble.summary.total} issue(s)` : "Healthy"}</span><span>${trouble.summary.errors} errors · ${trouble.summary.warnings} warnings · ${trouble.summary.repairable} repairable</span>` : "<span>Run the troubleshooter to inspect all linked repositories and deployments.</span>"}</div>${troubleRows || '<div class="empty-state compact"><p>No problems detected.</p></div>'}</div></section>
|
||
<section class="section-block"><div class="section-heading"><div><h2>System preflight</h2><span class="meta">Git, writable storage, credential protection, folders and Gitea</span></div><button class="button" data-action="run-system-preflight">${icon("shield")}Run checks</button></div><div class="panel"><div class="preflight-summary">${report ? `<span class="status-pill ${report.summary.ready ? "success" : "danger"}">${report.summary.ready ? "Ready" : `${report.summary.blocking.length} blocking`}</span><span>${report.summary.counts.pass} passed · ${report.summary.counts.warning} warnings · ${report.summary.counts.fail} failed</span>` : "<span>Not run in this session</span>"}</div>${renderPreflightChecks(report)}</div></section>
|
||
<section class="section-block"><div class="section-heading"><div><h2>Export support bundle</h2><span class="meta">Configuration summary, repository states, operations, preflight and redacted JSONL logs</span></div></div><div class="panel panel-body"><div class="form-grid"><div class="field"><label>Privacy mode</label><select id="diagnostic-privacy" class="select"><option value="standard">Standard · preserve repository names</option><option value="strict">Strict · hash repository and user identifiers</option></select></div></div><div class="card-actions"><button class="button primary" data-action="export-diagnostics">${icon("archive")}Create diagnostic ZIP</button></div>${ui.lastDiagnosticBundle ? `<div class="notice success" style="margin-top:12px">${icon("check")}<div><strong>${escapeHtml(ui.lastDiagnosticBundle.size)} bundle created</strong><p class="mono">SHA-256 ${escapeHtml(ui.lastDiagnosticBundle.sha256)}</p><button class="button ghost" data-action="show-diagnostic-bundle">Show file</button></div></div>` : ""}</div></section>
|
||
</div>`;
|
||
}
|
||
|
||
function renderPipelineView() {
|
||
const operation = ui.activeDeployment;
|
||
if (!operation)
|
||
return '<div class="empty-state full"><p>No deployment operation selected.</p></div>';
|
||
const logs = (operation.logs || []).join("\n");
|
||
return `<div class="deployment-view"><div class="page-header"><div><div class="eyebrow">${escapeHtml(operation.action || "deployment")} · ${escapeHtml(operation.environment || "")}</div><h1>${escapeHtml(operation.repository)}</h1><p>Exact commit <span class="mono">${escapeHtml(operation.sha || "")}</span></p></div><div class="stack horizontal"><button class="button" data-action="refresh-current-operation">${icon("refresh")}Refresh</button>${operation.runUrl ? `<button class="button" data-action="open-run-url">${icon("external")}Open in Gitea</button>` : ""}<button class="button" data-action="close-deployment">Close</button></div></div><section class="pipeline-card"><div class="pipeline-head"><div><h2>${escapeHtml(operation.status)}</h2><p>${escapeHtml(operation.profileName || operation.workflowFile || "")} · ${escapeHtml(operation.shortSha || shortSha(operation.sha))}</p></div><span class="status-pill ${toneForStatus(operation.status)}">${escapeHtml(operation.status)}</span></div><div class="pipeline-stages">${(operation.stages || []).map((stage) => `<div class="pipeline-stage ${stage.status}"><span class="stage-icon">${stage.status === "complete" ? icon("check") : stage.status === "failed" ? icon("error") : stage.status === "active" ? icon("pulse") : icon("clock")}</span><span>${escapeHtml(stage.label)}</span></div>`).join("")}</div></section>${operation.jobs?.length ? `<section class="section-block"><div class="section-heading"><h2>Runner jobs</h2></div><div class="panel"><table class="data-table"><thead><tr><th>Job</th><th>Status</th><th>Started</th><th>Completed</th></tr></thead><tbody>${operation.jobs.map((job) => `<tr><td>${escapeHtml(job.name)}</td><td><span class="status-pill ${toneForStatus(job.conclusion || job.status)}">${escapeHtml(job.conclusion || job.status)}</span></td><td>${job.startedAt ? formatDate(job.startedAt) : "—"}</td><td>${job.completedAt ? formatDate(job.completedAt) : "—"}</td></tr>`).join("")}</tbody></table></div></section>` : ""}<div class="log-view"><div class="log-toolbar"><span>Deployment output</span><button class="button ghost" data-action="copy-logs">${icon("copy")}Copy</button></div><pre class="log-lines">${escapeHtml(logs || "Waiting for operation output…")}</pre></div>${operation.failure ? `<div class="notice danger" style="margin-top:14px">${icon("error")}<div><strong>${escapeHtml(operation.failure.stage)}</strong><p>${escapeHtml(operation.failure.message)}</p></div></div>` : ""}</div>`;
|
||
}
|
||
|
||
function renderStatusbar() {
|
||
const state = ui.boot?.state;
|
||
const repository = selectedRepository();
|
||
const active = operations().filter(
|
||
(operation) => !isTerminalOperation(operation.status),
|
||
).length;
|
||
return `<footer class="statusbar"><div class="statusbar-left"><span class="statusbar-item ${ui.boot?.git?.available ? "success" : "danger"}">${icon("git")}${escapeHtml(ui.boot?.git?.version || "Git unavailable")}</span><span class="statusbar-item">${icon("folder")}${state?.workspaceRoots?.length || 0} roots</span>${repository?.localStatus ? `<span class="statusbar-item">${icon("branch")}${escapeHtml(repository.localStatus.branch.head)}</span>` : ""}</div><div class="statusbar-right">${ui.autoRefreshPending ? `<span class="statusbar-item warning">${icon("refresh")}Change detected</span>` : ""}${active ? `<span class="statusbar-item warning">${icon("pulse")}${active} active</span>` : ""}<span class="statusbar-item">ForgeFlow ${escapeHtml(ui.boot?.appVersion || "")}</span></div></footer>`;
|
||
}
|
||
|
||
function renderSetup() {
|
||
const steps = ["Readiness", "Gitea", "Folders", "Discovery", "Ready"];
|
||
let body = "";
|
||
if (ui.setupStep === 0) {
|
||
body = `<div class="setup-body"><h1>Check this computer</h1><p>ForgeFlow verifies Git, writable storage and protected credential support before you enter any connection details.</p><div class="notice" style="margin-top:16px">${icon("shield")}Your Gitea token is entered only inside this local desktop application. It is never included in diagnostic logs or support bundles.</div><div class="setup-preflight">${renderPreflightChecks(ui.systemPreflight, "Run the readiness check to verify this computer.")}</div><div class="setup-support-actions"><button class="button ghost" data-action="export-diagnostics">${icon("archive")}Export setup diagnostics</button><span class="meta">Available even before Gitea is connected.</span></div></div>`;
|
||
} else if (ui.setupStep === 1) {
|
||
body = `<div class="setup-body"><h1>Connect your Gitea instance</h1><p>Enter the URL and a personal access token created on your own Gitea server. ForgeFlow validates it locally and stores it using operating-system encryption when available.</p><div class="form-grid" style="margin-top:24px"><div class="field full"><label>Instance URL</label><input id="setup-url" class="input" value="${attr(ui.setupDraft.baseUrl)}" placeholder="https://gitea.example.com" /></div><div class="field full"><label>Access token</label><input id="setup-token" class="input" type="password" value="${attr(ui.setupDraft.token)}" placeholder="Paste token locally" autocomplete="off" /></div></div>${ui.setupValidation ? `<div class="notice success" style="margin-top:14px">${icon("check")}Connected as ${escapeHtml(ui.setupValidation.user.login)} · ${ui.setupValidation.repositoryCount} repositories · Gitea ${escapeHtml(ui.setupValidation.version || "version unknown")}</div>` : `<div class="notice" style="margin-top:14px">${icon("shield")}Use the narrowest permissions that allow repository reads and Actions workflow dispatch. The setup guide explains this without requiring you to share the token.</div>`}</div>`;
|
||
} else if (ui.setupStep === 2) {
|
||
body = `<div class="setup-body"><h1>Select development folders</h1><p>Choose parent folders. ForgeFlow discovers Git working trees below them and matches their origin to Gitea.</p><div class="stack" style="margin-top:22px">${ui.setupDraft.roots.map((root, index) => `<div class="root-row"><input class="input" value="${attr(root)}" readonly/><button class="icon-button" data-action="setup-remove-root" data-index="${index}">${icon("trash")}</button></div>`).join("")}<button class="button" data-action="setup-add-root">${icon("plus")}Add development folder</button></div></div>`;
|
||
} else if (ui.setupStep === 3) {
|
||
body = `<div class="setup-body"><h1>Discovering repositories</h1><p>Inspecting local Git metadata. Generated folders and nested dependency trees are skipped.</p><div class="discovery-progress"><div class="spinner"></div><strong>Scanning configured folders…</strong></div></div>`;
|
||
} else {
|
||
body = `<div class="setup-body"><h1>ForgeFlow is ready</h1><p>${ui.setupDraft.discovered.length} local repositories were found. You can add deployment environments after opening a repository.</p><div class="setup-summary"><div class="readiness-row"><span class="state-dot success"></span><div><strong>Gitea connected</strong><span>${escapeHtml(ui.setupDraft.baseUrl)} · ${escapeHtml(ui.setupDraft.user?.login || "user")}</span></div></div><div class="readiness-row"><span class="state-dot success"></span><div><strong>Workspace discovery</strong><span>${ui.setupDraft.roots.length} root folder(s), ${ui.setupDraft.discovered.length} repository/repositories</span></div></div><div class="readiness-row"><span class="state-dot success"></span><div><strong>Safe diagnostics</strong><span>Structured local logs with credential redaction are enabled by default.</span></div></div></div><div class="discovery-list">${
|
||
ui.setupDraft.discovered.length
|
||
? ui.setupDraft.discovered
|
||
.slice(0, 8)
|
||
.map(
|
||
(item) =>
|
||
`<div class="discovery-row">${icon(item.error ? "error" : "git")}<div><strong>${escapeHtml(item.localPath.split(/[\\/]/).pop())}</strong><span>${escapeHtml(item.localPath)}</span></div><span class="status-pill ${item.error ? "danger" : "success"}">${item.error ? "Unreadable" : "Ready"}</span></div>`,
|
||
)
|
||
.join("")
|
||
: '<div class="empty-state compact"><p>No repositories found. You can link or clone repositories later.</p></div>'
|
||
}</div></div>`;
|
||
}
|
||
const nextAction =
|
||
ui.setupStep === 0
|
||
? ui.systemPreflight?.summary?.ready
|
||
? '<button class="button primary" data-action="setup-continue">Continue</button>'
|
||
: '<button class="button primary" data-action="setup-run-preflight">Run readiness check</button>'
|
||
: ui.setupStep === 1
|
||
? '<button class="button primary" data-action="setup-validate">Validate & continue</button>'
|
||
: ui.setupStep === 2
|
||
? `<button class="button primary" data-action="setup-next" ${ui.setupDraft.roots.length ? "" : "disabled"}>Scan folders</button>`
|
||
: ui.setupStep === 4
|
||
? '<button class="button primary" data-action="setup-finish">Enter ForgeFlow</button>'
|
||
: "";
|
||
return `<div class="setup-backdrop"><section class="setup-window"><aside class="setup-sidebar"><img class="setup-brand-logo setup-brand-logo-dark" src="./assets/itworx-wordmark-dark.png" alt="ITWorx.tech"/><img class="setup-brand-logo setup-brand-logo-light" src="./assets/itworx-wordmark-light.png" alt="ITWorx.tech"/><h2>Set up ForgeFlow</h2><p>Local code to controlled deployment.</p>${steps.map((step, index) => `<div class="setup-step ${ui.setupStep === index ? "active" : ui.setupStep > index ? "complete" : ""}"><span class="step-number">${ui.setupStep > index ? "✓" : index + 1}</span><span>${step}</span></div>`).join("")}</aside><div class="setup-content">${body}<footer class="setup-actions"><button class="button" data-action="setup-back" ${ui.setupStep === 0 || ui.setupStep === 3 ? "disabled" : ""}>Back</button>${nextAction}</footer></div></section></div>`;
|
||
}
|