refactor: split renderer ipc and unraid domains

This commit is contained in:
NuklearRabbit
2026-07-29 18:11:07 +02:00
parent 5d3731a853
commit 7b05c953b6
36 changed files with 9316 additions and 8330 deletions
+22
View File
@@ -0,0 +1,22 @@
async function handleCommandActions(event, target, action, repository) {
if (action === "run-command") {
const command = target.dataset.command;
ui.modal = null;
if (command === "overview") ui.currentView = "overview";
else if (command === "deployments") ui.currentView = "deployments";
else if (command === "diagnostics") ui.currentView = "diagnostics";
else if (command === "settings") ui.currentView = "settings";
else if (command === "refresh") await refreshRepositories(true);
else if (command === "open-folder" && repository?.localPath)
await window.forgeflow.openPath(repository.localPath);
else if (command === "git-tools" && repository)
await loadGitTools(repository);
else if (command === "deploy-selected" && canDeploy(repository)) {
const profile = selectedProfile(repository);
if (profile) await runDeploymentPreflight(repository, profile.id);
}
render();
}
else return false;
return true;
}
@@ -0,0 +1,183 @@
async function handleDeploymentOperationActions(event, target, action, repository) {
if (action === "deploy-profile") {
if (repository && String(repository.id) !== String(ui.selectedRepoId))
selectRepository(repository.id, false);
const profile =
repository?.deploymentProfiles?.find(
(item) => item.id === target.dataset.profileId,
) || selectedProfile(repository);
ui.selectedProfileId = profile?.id || null;
if (!profile) return;
const report = await runDeploymentPreflight(repository, profile.id, {
showModal: true,
});
if (!report) return;
} else if (action === "continue-after-preflight") {
const profile = selectedRepository()?.deploymentProfiles?.find(
(item) => item.id === target.dataset.profileId,
);
if (!profile || !ui.deploymentPreflight?.summary?.ready) return;
if (profile.confirmationRequired !== false) {
ui.modal = { type: "deploy-confirm", profileId: profile.id };
render();
} else await executeDeployment(profile.id);
} else if (action === "confirm-deploy")
await executeDeployment(target.dataset.profileId);
else if (action === "rollback-profile") {
if (!repository) repository = profileRepository(target.dataset.profileId);
if (repository && String(repository.id) !== String(ui.selectedRepoId))
selectRepository(repository.id, false);
ui.modal = {
type: "rollback-confirm",
profileId: target.dataset.profileId,
};
render();
} else if (action === "confirm-rollback")
await executeRollback(target.dataset.profileId);
else if (action === "refresh-profile-state") {
if (!repository) repository = profileRepository(target.dataset.profileId);
const profile = repository?.deploymentProfiles?.find(
(item) => item.id === target.dataset.profileId,
);
setLoading(true, `Checking ${profile?.environment || "environment"}`);
try {
const state = await window.forgeflow.refreshProfileState(
repository.fullName,
target.dataset.profileId,
);
profile.state = state;
showToast(
"Environment checked",
state.healthy === false
? "Healthcheck reports an unhealthy state."
: state.liveSha
? `Server reports ${shortSha(state.liveSha)}.`
: "Connection checked; no live SHA reported.",
state.healthy === false ? "error" : "success",
);
} catch (error) {
showToast("Status check failed", error.message, "error");
}
setLoading(false);
} else if (action === "repair-missing-dockerman") {
const targets = ui.repositories.flatMap((candidate) =>
(candidate.deploymentProfiles || [])
.filter(
(profile) =>
profile.provider === "ssh-unraid" &&
profile.state?.containerRunning &&
!dockerManIntegration(profile).ready,
)
.map((profile) => ({ repository: candidate, profile })),
);
if (!targets.length) return;
if (
!confirm(
`Recreate ${targets.length} running container${targets.length === 1 ? "" : "s"} with the missing DockerMan WebUI, icon and template metadata?`,
)
)
return;
setLoading(true, "Repairing missing DockerMan integrations…");
let repaired = 0;
const failures = [];
for (const item of targets) {
try {
await window.forgeflow.applyDockerManMetadata(
item.repository,
item.profile.id,
);
repaired += 1;
} catch (error) {
failures.push(`${item.repository.name}: ${error.message}`);
}
}
await refreshDeploymentTruth(false);
showToast(
failures.length
? "DockerMan repair partially completed"
: "DockerMan integrations repaired",
failures.length
? `${repaired} repaired, ${failures.length} failed.`
: `${repaired} running container${repaired === 1 ? "" : "s"} updated.`,
failures.length ? "error" : "success",
);
setLoading(false);
} else if (action === "apply-dockerman-metadata") {
if (!repository) repository = profileRepository(target.dataset.profileId);
setLoading(
true,
"Applying DockerMan labels, template, icon and WebUI metadata…",
);
try {
await window.forgeflow.applyDockerManMetadata(
repository,
target.dataset.profileId,
);
await refreshRepositories(false);
showToast(
"DockerMan integration repaired",
"The container was recreated with labels, a persistent template, WebUI and icon metadata.",
"success",
);
} catch (error) {
showToast(
"Could not repair DockerMan integration",
error.message,
"error",
);
}
setLoading(false);
} else if (action === "reconcile-deployment") {
if (!repository) repository = profileRepository(target.dataset.profileId);
setLoading(true, "Reconciling ForgeFlow with the live Unraid container…");
try {
await window.forgeflow.reconcileDeployment(
repository.fullName,
target.dataset.profileId,
);
await refreshActiveOperations(false);
await refreshRepositories(false);
showToast(
"Deployment reconciled",
"Live SHA, container health and operation status were refreshed.",
"success",
);
} catch (error) {
showToast("Could not reconcile deployment", error.message, "error");
}
setLoading(false);
} else if (action === "open-profile-webui")
await window.forgeflow.openExternal(target.dataset.url);
else if (action === "open-operation") {
const operation = await window.forgeflow.getOperation(
target.dataset.operationId,
);
if (operation) {
ui.activeDeployment = operation;
ui.currentView = "deployment-run";
render();
startOperationPolling();
}
} else if (action === "refresh-current-operation") {
setLoading(true, "Refreshing deployment status…");
try {
const operation = await window.forgeflow.refreshOperations(
ui.activeDeployment.id,
);
updateOperationInState(operation);
if (!isTerminalOperation(operation.status)) startOperationPolling();
} catch (error) {
showToast("Status refresh failed", error.message, "error");
}
setLoading(false);
} else if (action === "open-run-url")
await window.forgeflow.openExternal(ui.activeDeployment.runUrl);
else if (action === "close-deployment") {
stopOperationPolling();
ui.activeDeployment = null;
ui.currentView = selectedRepository() ? "repository" : "deployments";
render();
}
else return false;
return true;
}
+401
View File
@@ -0,0 +1,401 @@
async function handleDeploymentProfileActions(event, target, action, repository) {
if (action === "configure-deployment") {
ui.deploymentDiscovery = null;
ui.modal = {
type: "deployment-config",
profileId: null,
provider: (ui.boot.state.servers || []).length
? "ssh-unraid"
: "gitea-actions",
};
render();
} else if (action === "edit-deployment-profile") {
ui.deploymentDiscovery = null;
repository = profileRepository(target.dataset.profileId) || repository;
if (repository && String(repository.id) !== String(ui.selectedRepoId))
selectRepository(repository.id, false);
ui.modal = {
type: "deployment-config",
profileId: target.dataset.profileId || null,
provider: repository?.deploymentProfiles?.find(
(item) => item.id === target.dataset.profileId,
)?.provider,
};
render();
} else if (action === "close-modal") {
ui.modal = null;
render();
} else if (action === "select-profile-icon") {
const iconPath = await window.forgeflow.selectImageFile({
title: "Select DockerMan PNG icon",
defaultPath:
document.querySelector("#profile-icon-file")?.value || undefined,
});
if (iconPath) {
document.querySelector("#profile-icon-file").value = iconPath;
const mode = document.querySelector("#profile-icon-mode");
if (mode) mode.value = "upload";
}
} else if (action === "clear-profile-icon") {
const input = document.querySelector("#profile-icon-file");
if (input) input.value = "";
const mode = document.querySelector("#profile-icon-mode");
if (mode) mode.value = "builtin";
} else if (action === "discover-existing-deployment") {
const serverId = document.querySelector("#profile-server")?.value;
const remoteFolder =
document.querySelector("#profile-remote-folder")?.value.trim() ||
safeCloneFolderName(repository);
if (!serverId) {
showToast(
"Select an Unraid server",
"Configure and select the server before importing an existing deployment.",
"error",
);
return;
}
setLoading(
true,
"Reading Git, Compose, Docker and DockerMan from the server…",
);
try {
const result = await window.forgeflow.discoverExistingDeployment(
repository,
serverId,
remoteFolder,
);
ui.deploymentDiscovery = { ...result, repository: repository.fullName };
showToast(
"Existing deployment imported",
`${result.runtime.containers} container(s), ${result.runtime.services} service(s) and ${result.runtime.ports.length} port mapping(s) detected.`,
"success",
);
render();
} catch (error) {
showToast("Could not import deployment", error.message, "error");
}
setLoading(false);
} else if (action === "save-deployment-profile") {
const previousProfile =
repository?.deploymentProfiles?.find(
(item) => item.id === target.dataset.profileId,
) || {};
const provider = document.querySelector("#profile-provider").value;
let maintenanceWindows = [];
try {
maintenanceWindows = (
document.querySelector("#profile-policy-windows")?.value || ""
)
.split("|")
.map((item) => item.trim())
.filter(Boolean)
.map((item) => {
const match = item.match(
/^([0-6](?:,[0-6])*)\s*:\s*((?:[01]\d|2[0-3]):[0-5]\d)-((?:[01]\d|2[0-3]):[0-5]\d)$/,
);
if (!match) throw new Error(`Invalid maintenance window: ${item}`);
return {
days: match[1].split(",").map(Number),
start: match[2],
end: match[3],
};
});
} catch (error) {
showToast("Could not save profile", error.message, "error");
return;
}
const composeFiles =
provider === "ssh-unraid"
? (document.querySelector("#profile-compose-files")?.value || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean)
: [];
const composeServices =
provider === "ssh-unraid"
? (document.querySelector("#profile-compose-services")?.value || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean)
: [];
const profile = {
id: target.dataset.profileId || undefined,
provider,
name: document.querySelector("#profile-name").value.trim(),
environment: document.querySelector("#profile-environment").value.trim(),
branch: document.querySelector("#profile-branch").value.trim(),
healthcheckUrl:
document.querySelector("#profile-healthcheck")?.value.trim() || "",
confirmationRequired: document.querySelector("#profile-confirmation")
.checked,
deploymentPolicy: {
frozen:
document.querySelector("#profile-policy-frozen")?.checked === true,
freezeReason:
document
.querySelector("#profile-policy-freeze-reason")
?.value.trim() || "",
requireNote:
document.querySelector("#profile-policy-note")?.checked === true,
maintenanceWindows,
},
...(provider === "ssh-unraid"
? {
serverId: document.querySelector("#profile-server").value,
remoteFolder: document
.querySelector("#profile-remote-folder")
.value.trim(),
deploymentMode: ["server-git", "push-bundle", "monitor-only"].includes(
document.querySelector("#profile-deployment-mode")?.value,
) ? document.querySelector("#profile-deployment-mode").value : "server-git",
cloneUrl: previousProfile.cloneUrl || "",
alignRemote: false,
generatedCompose:
document.querySelector("#profile-generated-compose").value ===
"true",
composeProject:
document.querySelector("#profile-compose-project")?.value.trim() ||
previousProfile.composeProject ||
"",
composeWorkingDir: previousProfile.composeWorkingDir || "",
composeFiles: composeFiles.length ? composeFiles : ["docker-compose.yml"],
composeFile: composeFiles[0] || "docker-compose.yml",
composeServices: composeServices.length
? composeServices
: [safeCloneFolderName(repository).toLowerCase()],
composeService:
composeServices[0] || safeCloneFolderName(repository).toLowerCase(),
containerName: document
.querySelector("#profile-container-name")
.value.trim(),
hostPort:
Number(document.querySelector("#profile-host-port").value) ||
null,
containerPort:
Number(document.querySelector("#profile-container-port").value) ||
null,
webUiUrl: document.querySelector("#profile-web-ui").value.trim(),
iconMode: document.querySelector("#profile-icon-mode").value,
iconUrl: document.querySelector("#profile-icon-url").value.trim(),
iconFilePath: document
.querySelector("#profile-icon-file")
.value.trim(),
dockerShell: document.querySelector("#profile-docker-shell").value,
preservePaths: document
.querySelector("#profile-preserve-paths")
.value.split(",")
.map((item) => item.trim())
.filter(Boolean),
manageDockerMan:
document.querySelector("#profile-manage-dockerman")?.checked ===
true,
forceRecreate: false,
removeOrphans: false,
adoptedFromServer: Boolean(
ui.deploymentDiscovery || previousProfile.adoptedFromServer,
),
serverSourceOfTruth: Boolean(
ui.deploymentDiscovery || previousProfile.serverSourceOfTruth,
),
workloadIdentity:
ui.deploymentDiscovery?.profile?.workloadIdentity ||
previousProfile.workloadIdentity ||
null,
detectedAt:
ui.deploymentDiscovery?.profile?.detectedAt ||
previousProfile.detectedAt ||
null,
provenance:
ui.deploymentDiscovery?.provenance ||
previousProfile.provenance ||
{},
detectedMetadata:
ui.deploymentDiscovery?.profile?.detectedMetadata ||
previousProfile.detectedMetadata ||
{},
}
: {
workflowFile: document
.querySelector("#profile-workflow")
.value.trim(),
rollbackWorkflowFile: document
.querySelector("#profile-rollback-workflow")
.value.trim(),
statusUrl: document
.querySelector("#profile-status-url")
.value.trim(),
}),
};
setLoading(true, "Saving deployment environment…");
try {
const result = await window.forgeflow.saveDeploymentProfile(
repository.fullName,
profile,
);
ui.boot.state = result.state;
ui.modal = null;
ui.deploymentDiscovery = null;
await refreshRepositories(false);
ui.selectedProfileId = result.profile.id;
showToast(
"Deployment configured",
`${profile.name} targets ${profile.environment}.`,
"success",
);
} catch (error) {
showToast("Could not save profile", error.message, "error");
}
setLoading(false);
} else if (action === "delete-deployment-profile") {
if (
!confirm("Delete this deployment profile? Operation history is retained.")
)
return;
setLoading(true, "Deleting deployment profile…");
try {
const result = await window.forgeflow.deleteDeploymentProfile(
repository.fullName,
target.dataset.profileId,
);
ui.boot.state = result.state;
ui.modal = null;
await refreshRepositories(false);
showToast(
"Profile deleted",
"Deployment environment removed.",
"success",
);
} catch (error) {
showToast("Could not delete profile", error.message, "error");
}
setLoading(false);
} else if (action === "run-deployment-preflight") {
if (!repository) repository = profileRepository(target.dataset.profileId);
await runDeploymentPreflight(repository, target.dataset.profileId);
} else if (action === "manage-deploy-key") {
const profileId = target.dataset.profileId || ui.selectedProfileId;
if (!repository) repository = profileRepository(profileId);
if (!repository || !profileId) return;
setLoading(true, "Inspecting deploy-key lifecycle without changing access…");
try {
const [inventory, rotation, revocation] = await Promise.all([
window.forgeflow.deployKeyInventory(repository, profileId),
window.forgeflow.planDeployKeyRotation(repository, profileId),
window.forgeflow.planDeployKeyRevocation(repository, profileId),
]);
ui.deployKeyLifecycle = { repositoryId: repository.id, profileId, inventory, rotation, revocation };
ui.modal = { type: "deploy-key-lifecycle" };
render();
} catch (error) {
showToast("Could not inspect deploy key", error.message, "error");
} finally { setLoading(false); }
} else if (action === "confirm-rotate-deploy-key") {
const lifecycle = ui.deployKeyLifecycle;
const targetRepository = repositories().find((item) => item.id === lifecycle?.repositoryId);
if (!targetRepository || !lifecycle?.rotation?.id) return;
setLoading(true, "Rotating and verifying the repository deploy key…");
try {
const result = await window.forgeflow.applyDeployKeyRotation(targetRepository, lifecycle.profileId, lifecycle.rotation.id);
if (result.state) ui.boot.state = result.state;
ui.modal = null; ui.deployKeyLifecycle = null;
await refreshRepositories(false, true);
showToast("Deploy key rotated", `New fingerprint ${result.profile?.serverGitAccess?.keyFingerprint || "verified"}.`, "success");
} catch (error) { showToast("Deploy-key rotation failed safely", error.message, "error"); }
finally { setLoading(false); }
} else if (action === "confirm-revoke-deploy-key") {
const lifecycle = ui.deployKeyLifecycle;
const targetRepository = repositories().find((item) => item.id === lifecycle?.repositoryId);
if (!targetRepository || !lifecycle?.revocation?.id) return;
setLoading(true, "Revoking repository access while preserving recovery…");
try {
const result = await window.forgeflow.applyDeployKeyRevocation(targetRepository, lifecycle.profileId, lifecycle.revocation.id);
if (result.state) ui.boot.state = result.state;
ui.modal = null; ui.deployKeyLifecycle = null;
await refreshRepositories(false, true);
showToast("Deploy key revoked", "Server pull is disabled; containers were not changed and recovery is available.", "success");
} catch (error) { showToast("Deploy-key revocation failed", error.message, "error"); }
finally { setLoading(false); }
} else if (action === "restore-deploy-key") {
const lifecycle = ui.deployKeyLifecycle;
const targetRepository = repositories().find((item) => item.id === lifecycle?.repositoryId);
if (!targetRepository || !lifecycle?.profileId) return;
setLoading(true, "Restoring and verifying repository access…");
try {
const result = await window.forgeflow.restoreDeployKey(targetRepository, lifecycle.profileId);
if (result.state) ui.boot.state = result.state;
ui.modal = null; ui.deployKeyLifecycle = null;
await refreshRepositories(false, true);
showToast("Deploy key restored", "Read-only server pull access is verified again.", "success");
} catch (error) { showToast("Deploy-key recovery failed", error.message, "error"); }
finally { setLoading(false); }
} else if (action === "verify-server-git-access") {
const profileId = target.dataset.profileId || ui.selectedProfileId;
if (!repository) repository = profileRepository(profileId);
if (!repository || !profileId) return;
setLoading(true, "Verifying Gitea, deploy key, server commit and runtime…");
try {
const result = await window.forgeflow.verifyServerGitProfile(repository, profileId);
ui.serverGitVerifications[profileId] = result;
render();
const failures = result.checks.filter((check) => check.status === "fail");
showToast(result.readiness, failures[0]?.detail || `Verified ${result.checks.length} server-pull checks without changing the server.`, result.ready ? "success" : "warning");
} catch (error) {
showToast("Server-pull verification failed", error.message, "error");
} finally {
setLoading(false);
}
} else if (action === "configure-server-git-access") {
const profileId = target.dataset.profileId || ui.selectedProfileId;
if (!repository) repository = profileRepository(profileId);
if (!repository || !profileId) return;
const approved = confirm(
`Configure read-only Gitea access for ${repository.fullName}?\n\nForgeFlow creates a dedicated SSH deploy key on the selected server, adds only its public key to this Gitea repository and pins the observed Gitea SSH host key. The private key never leaves the server.`,
);
if (!approved) return;
setLoading(true, "Configuring repository-scoped Gitea access…");
try {
const result = await window.forgeflow.configureServerGitAccess(repository, profileId);
if (result.state) ui.boot.state = result.state;
await refreshRepositories(false, true);
await refreshDeploymentTruth(false);
showToast(
"Server pull ready",
`Read-only Gitea access verified at ${shortSha(result.remoteSha)}.`,
"success",
);
await runDeploymentPreflight(repository, profileId, { showModal: true });
} catch (error) {
showToast("Could not configure Gitea access", error.message, "error");
} finally {
setLoading(false);
}
} else if (action === "repair-deployment-write-access") {
const profileId = target.dataset.profileId || ui.selectedProfileId;
if (!repository) repository = profileRepository(profileId);
if (!repository || !profileId) return;
const profile = repository.deploymentProfiles?.find((item) => item.id === profileId);
const approved = confirm(
`Repair write access for ${repository.fullName} on ${profile?.name || profile?.environment || "the linked Unraid deployment"}?\n\nForgeFlow will only adjust the linked project source tree and its .forgeflow state folders. Preserved runtime paths such as appdata, data, config and logs are excluded. No container will be stopped, removed or recreated.`,
);
if (!approved) return;
setLoading(true, "Repairing scoped Unraid write access…");
try {
const result = await window.forgeflow.repairDeploymentWriteAccess(
repository,
profileId,
);
showToast(
"Write access normalized",
"Project source and ForgeFlow upload folders now use safe shared write permissions. Preserved runtime data was not changed.",
"success",
);
await runDeploymentPreflight(repository, profileId, { showModal: true });
} catch (error) {
showToast("Write-access repair failed", error.message, "error");
} finally {
setLoading(false);
}
}
else return false;
return true;
}
+185
View File
@@ -0,0 +1,185 @@
async function handleInventoryActions(event, target, action, repository) {
if (action === "scan-server-inventory") {
setLoading(true, "Scanning Docker, Compose and DockerMan workloads…");
try {
await refreshDeploymentTruth(true);
const detected = (ui.serverDiscovery || []).reduce(
(total, item) => total + Number(item.detected || 0),
0,
);
const review = (ui.serverDiscovery || []).reduce(
(total, item) => total + Number(item.needsReview || 0),
0,
);
const failures = (ui.serverDiscovery || []).filter((item) => item.error);
if (failures.length) {
showToast(
"Server scan failed",
failures.map((item) => `${item.serverName || item.serverId}: ${item.error}`).join(" · "),
"error",
);
} else {
showToast(
"Server inventory updated",
`${detected} workload${detected === 1 ? "" : "s"} detected; ${review} require manual review.`,
review ? "info" : "success",
);
}
} catch (error) {
showToast("Server scan failed", error.message, "error");
}
setLoading(false);
} else if (action === "plan-server-reconciliation") {
setLoading(true, "Building a read-only reconciliation preview…");
try {
const result = await window.forgeflow.planServerReconciliation(target.dataset.serverId);
ui.modal = { type: "server-reconciliation-plan", result };
render();
} catch (error) {
showToast("Could not build reconciliation plan", error.message, "error");
}
setLoading(false);
} else if (action === "apply-server-reconciliation") {
setLoading(true, "Applying the reviewed configuration plan…");
try {
const result = await window.forgeflow.applyServerReconciliation(target.dataset.serverId, target.dataset.planId);
if (result.state) ui.boot.state = result.state;
ui.modal = null;
await refreshRepositories(false, true);
await refreshDeploymentTruth(false);
showToast("Reconciliation applied", `${result.adopted || 0} link(s) added and ${result.refreshed || 0} profile(s) refreshed. No containers were changed.`, "success");
} catch (error) {
showToast("Reconciliation was not applied", error.message, "error");
}
setLoading(false);
} else if (action === "quick-link-server-workload") {
const serverResult = (ui.serverDiscovery || []).find(
(item) => item.serverId === target.dataset.serverId,
);
const workload = serverResult?.workloads?.find(
(item) => item.workloadId === target.dataset.workloadId,
);
const linkedRepository = ui.repositories.find(
(item) => item.fullName === target.dataset.repository,
);
if (!workload || !linkedRepository || !workload.remoteFolderCandidate) {
showToast("Automatic link unavailable", "Scan the server again and use Review & link.", "error");
return;
}
setLoading(true, `Linking ${workload.displayName} to ${linkedRepository.fullName}`);
try {
const result = await window.forgeflow.linkServerWorkload(
linkedRepository,
target.dataset.serverId,
target.dataset.workloadId,
"server-git",
workload.remoteFolderCandidate,
);
if (result.state) ui.boot.state = result.state;
ui.selectedProfileId = result.profile?.id || null;
await refreshRepositories(false, true);
await refreshDeploymentTruth(false);
showToast(
"Deployment linked",
`${linkedRepository.fullName} is linked to ${workload.compose?.workingDir || workload.remoteFolderCandidate}. Compose values were read from the server.`,
"success",
);
} catch (error) {
showToast("Could not link deployment", error.message, "error");
}
setLoading(false);
} else if (action === "preview-inventory-review") {
const reviewAction = document.querySelector("#inventory-review-action")?.value || "ignore";
const reason = document.querySelector("#inventory-review-reason")?.value.trim() || "";
const serverId = target.dataset.serverId;
const workloadId = target.dataset.workloadId;
try {
ui.inventoryReviewPlan = await window.forgeflow.planInventoryReview(serverId, workloadId, reviewAction, reason, document.querySelector("#workload-repository")?.value || null);
ui.modal = { type: "inventory-review-plan" };
render();
} catch (error) { showToast("Review preview unavailable", error.message, "error"); }
} else if (action === "apply-inventory-review") {
const plan = ui.inventoryReviewPlan;
if (!plan?.id) return;
setLoading(true, "Saving the evidence-bound inventory decision…");
try {
const result = await window.forgeflow.applyInventoryReview(plan.serverId, plan.workloadId, plan.action, plan.reason, plan.repositoryFullName, plan.id);
if (result.state) ui.boot.state = result.state;
ui.serverDiscovery = (ui.serverDiscovery || []).map((item) => item.serverId === plan.serverId ? result.inventory : item);
ui.inventoryReviewPlan = null; ui.modal = null; render();
showToast("Inventory decision saved", "Containers and Compose runtime were not changed.", "success");
} catch (error) { showToast("Inventory review failed safely", error.message, "error"); }
finally { setLoading(false); }
} else if (action === "link-server-workload") {
const serverResult = (ui.serverDiscovery || []).find(
(item) => item.serverId === target.dataset.serverId,
);
const workload = serverResult?.workloads?.find(
(item) => item.workloadId === target.dataset.workloadId,
);
if (!workload) {
showToast(
"Workload unavailable",
"Scan the server inventory again before linking this workload.",
"error",
);
return;
}
ui.modal = {
type: "workload-link",
serverId: target.dataset.serverId,
workloadId: target.dataset.workloadId,
repositoryFullName:
workload.candidates?.[0]?.repositoryFullName ||
repository?.fullName ||
ui.repositories[0]?.fullName ||
"",
remoteFolder: workload.remoteFolderCandidate || "",
};
render();
} else if (action === "confirm-link-server-workload") {
const repositoryFullName = document
.querySelector("#workload-repository")
?.value.trim();
const deploymentMode = document.querySelector("#workload-deployment-mode")?.value || "server-git";
const remoteFolder = document
.querySelector("#workload-remote-folder")
?.value.trim();
const linkedRepository = ui.repositories.find(
(item) => item.fullName === repositoryFullName,
);
if (!linkedRepository) {
showToast(
"Choose a repository",
"The workload must be linked to a ForgeFlow project.",
"error",
);
return;
}
setLoading(true, "Saving the permanent server workload link…");
try {
const result = await window.forgeflow.linkServerWorkload(
linkedRepository,
target.dataset.serverId,
target.dataset.workloadId,
deploymentMode,
remoteFolder,
);
if (result.state) ui.boot.state = result.state;
ui.modal = null;
ui.selectedProfileId = result.profile?.id || null;
await refreshRepositories(false, true);
await refreshDeploymentTruth(false);
showToast(
"Workload linked",
`${linkedRepository.fullName} now uses direct desktop-to-Unraid copy and the Compose configuration detected on the server.`,
"success",
);
} catch (error) {
showToast("Could not link workload", error.message, "error");
}
setLoading(false);
}
else return false;
return true;
}
+355
View File
@@ -0,0 +1,355 @@
async function handleRecoveryActions(event, target, action, repository) {
if (action === "repair-origin") {
if (!repository?.localPath || !repository.sshUrl) return;
if (
!confirm(
`Replace origin with ${repository.sshUrl}? Local files and commits are not changed.`,
)
)
return;
setLoading(true, "Updating Git origin…");
try {
await window.forgeflow.setOrigin(repository.localPath, repository.sshUrl);
await refreshRepositories(false);
showToast("Git origin updated", repository.sshUrl, "success");
} catch (error) {
showToast("Could not update origin", error.message, "error");
}
setLoading(false);
} else if (action === "normalize-origins") {
if (
!confirm(
"Replace legacy origin URLs for every linked repository with the current Gitea SSH URL? Local files and commits are not changed.",
)
)
return;
setLoading(true, "Normalizing linked Git origins…");
try {
const result = await window.forgeflow.normalizeOrigins();
ui.repositories = result.repositories;
showToast(
"Git origins normalized",
`${result.changes.length} repository origin${result.changes.length === 1 ? "" : "s"} updated.`,
"success",
);
} catch (error) {
showToast("Could not normalize origins", error.message, "error");
}
setLoading(false);
render();
} else if (action === "scan-git-recovery") {
if (!repository?.localPath) return;
setLoading(true, "Scanning Git directory and active processes…");
try {
ui.gitRecovery = await window.forgeflow.gitRecoveryStatus(
repository.localPath,
);
ui.repositoryTab = "gittools";
showToast(
"Git health scan complete",
`${ui.gitRecovery.lockReport.locks.length} lock file(s) found.`,
ui.gitRecovery.lockReport.locks.length ? "info" : "success",
);
} catch (error) {
showToast("Git health scan failed", error.message, "error");
}
setLoading(false);
render();
} else if (action === "repair-git-locks" || action === "repair-index-lock") {
if (
!repository?.localPath ||
!confirm(
"Repair stale Git lock files for this repository? ForgeFlow refuses while a matching Git process is active.",
)
)
return;
setLoading(true, "Safely repairing stale Git locks…");
try {
const result = await window.forgeflow.repairGitLocks(
repository.localPath,
false,
);
ui.gitRecovery = await window.forgeflow.gitRecoveryStatus(
repository.localPath,
);
await refreshRepositories(false);
showToast(
"Git locks repaired",
`${result.removed.length} stale lock file(s) removed.`,
"success",
);
} catch (error) {
if (
error.code === "GIT_PROCESS_PROBE_UNAVAILABLE" &&
confirm(`${error.message}
Force repair after you have closed all Git tools for this repository?`)
) {
try {
const result = await window.forgeflow.repairGitLocks(
repository.localPath,
true,
);
showToast(
"Git locks force-repaired",
`${result.removed.length} lock file(s) removed.`,
"success",
);
await refreshRepositories(false);
} catch (forceError) {
showToast("Could not repair Git locks", forceError.message, "error");
}
} else showToast("Could not repair Git locks", error.message, "error");
}
setLoading(false);
render();
} else if (action === "reconcile-repository") {
if (!repository?.localPath) return;
setLoading(true, "Refreshing repository truth from Git…");
try {
ui.gitRecovery = await window.forgeflow.reconcileRepository(
repository.localPath,
);
await refreshRepositories(false);
showToast(
"Repository reconciled",
"Branch, upstream, lock and working-tree state were refreshed.",
"success",
);
} catch (error) {
showToast("Could not reconcile repository", error.message, "error");
}
setLoading(false);
render();
} else if (action === "repair-repository-sync") {
if (!repository?.localPath) return;
const strategy = target.dataset.strategy;
const destructive = strategy === "backup-reset";
const message = destructive
? "Create a safety branch from the current HEAD and reset this branch to its upstream? Uncommitted changes are never discarded."
: `Run the repository-specific ${strategy} repair now?`;
if (!confirm(message)) return;
setLoading(
true,
destructive
? "Creating safety branch and repairing divergence…"
: "Repairing repository synchronization…",
);
try {
const result = await window.forgeflow.repairRepositorySync(
repository.localPath,
strategy,
);
ui.gitRecovery = await window.forgeflow.gitRecoveryStatus(
repository.localPath,
);
await refreshRepositories(false);
showToast(
"Repository synchronization repaired",
result.backupBranch
? `Safety branch created: ${result.backupBranch}`
: `Completed ${strategy}.`,
"success",
);
} catch (error) {
showToast("Synchronization repair failed", error.message, "error");
}
setLoading(false);
render();
} else if (action === "run-troubleshooter") {
setLoading(
true,
"Scanning repositories, Git operations and deployment servers…",
);
try {
ui.troubleshooter = await window.forgeflow.troubleshooterScan();
showToast(
"Troubleshooter completed",
ui.troubleshooter.summary.total
? `${ui.troubleshooter.summary.total} issue(s) found; ${ui.troubleshooter.summary.repairable} repairable.`
: "No problems were detected.",
ui.troubleshooter.summary.errors ? "error" : "success",
);
} catch (error) {
showToast("Troubleshooter failed", error.message, "error");
}
setLoading(false);
render();
} else if (action === "troubleshooter-auto-repair") {
const safeIssues = (ui.troubleshooter?.issues || []).filter(
(item) => item.repairable && item.safe,
);
if (
!safeIssues.length ||
!confirm(
`Repair ${safeIssues.length} safe issue(s) now? ForgeFlow will not run destructive reset actions automatically.`,
)
)
return;
setLoading(true, "Applying safe one-click repairs…");
try {
const results =
await window.forgeflow.troubleshooterAutoRepair(safeIssues);
ui.troubleshooter = await window.forgeflow.troubleshooterScan();
await refreshRepositories(false);
const failed = results.filter((item) => !item.ok);
showToast(
failed.length
? "Repairs partially completed"
: "Safe repairs completed",
`${results.length - failed.length} repaired, ${failed.length} failed.`,
failed.length ? "error" : "success",
);
} catch (error) {
showToast("Automatic repair failed", error.message, "error");
}
setLoading(false);
render();
} else if (action === "troubleshooter-repair") {
const issue =
ui.troubleshooter?.issues?.[Number(target.dataset.issueIndex)];
if (!issue) return;
const warning = issue.safe
? `Repair “${issue.title}” now?`
: `${issue.title}” requires a safety branch or another potentially destructive change. Continue?`;
if (!confirm(warning)) return;
setLoading(true, `Repairing ${issue.title}`);
try {
const result = await window.forgeflow.troubleshooterRepair(issue);
ui.troubleshooter = await window.forgeflow.troubleshooterScan();
await refreshRepositories(false);
showToast(
"Problem repaired",
result?.backupBranch
? `Safety branch created: ${result.backupBranch}`
: issue.title,
"success",
);
} catch (error) {
showToast("Repair failed", error.message, "error");
}
setLoading(false);
render();
} else if (action === "run-system-preflight") await runSystemPreflight();
else if (action === "load-audit-log") {
try {
ui.auditEvents = await window.forgeflow.listAuditEvents(500);
render();
} catch (error) {
showToast("Could not load audit log", error.message, "error");
}
} else if (action === "export-audit-json" || action === "export-audit-csv") {
try {
const result = await window.forgeflow.exportAuditLog(
action.endsWith("csv") ? "csv" : "json",
);
if (result)
showToast(
"Audit log exported",
`${result.count} records exported.`,
"success",
);
} catch (error) {
showToast("Could not export audit log", error.message, "error");
}
} else if (action === "save-diagnostics-preferences") {
const preferences = {
diagnosticsEnabled:
document.querySelector("#diagnostics-enabled").value === "true",
diagnosticLevel: document.querySelector("#diagnostic-level").value,
logRetentionDays: Number(
document.querySelector("#diagnostic-retention").value,
),
maxLogFileMb: Number(
document.querySelector("#diagnostic-max-file").value,
),
};
setLoading(true, "Saving diagnostic policy…");
try {
ui.boot.state = await window.forgeflow.setPreferences(preferences);
ui.diagnosticsStatus = await window.forgeflow.diagnosticsStatus();
showToast(
"Diagnostic policy saved",
"New events now use the updated retention and logging level.",
"success",
);
} catch (error) {
showToast("Could not save diagnostics", error.message, "error");
}
setLoading(false);
} else if (action === "export-diagnostics") {
const privacyMode =
document.querySelector("#diagnostic-privacy")?.value || "standard";
setLoading(true, "Creating redacted diagnostic bundle…");
try {
const bundle = await window.forgeflow.exportDiagnostics(privacyMode);
if (bundle) {
ui.lastDiagnosticBundle = bundle;
ui.diagnosticsStatus = await window.forgeflow.diagnosticsStatus();
showToast(
"Diagnostic bundle created",
`${bundle.size} · SHA-256 ${shortSha(bundle.sha256)}`,
"success",
);
}
} catch (error) {
showToast("Could not export diagnostics", error.message, "error");
}
setLoading(false);
} else if (action === "show-diagnostic-bundle") {
if (!ui.lastDiagnosticBundle?.path) return;
await window.forgeflow
.showDiagnosticBundle(ui.lastDiagnosticBundle.path)
.catch((error) =>
showToast("Could not show bundle", error.message, "error"),
);
} else if (action === "open-diagnostics-folder")
await window.forgeflow
.openDiagnosticsFolder()
.catch((error) =>
showToast("Could not open diagnostic folder", error.message, "error"),
);
else if (action === "clear-diagnostics") {
if (
!confirm(
"Clear local ForgeFlow diagnostic logs? This does not affect repositories or configuration.",
)
)
return;
try {
ui.diagnosticsStatus = await window.forgeflow.clearDiagnostics();
showToast(
"Diagnostic logs cleared",
"A new session marker was created.",
"success",
);
render();
} catch (error) {
showToast("Could not clear logs", error.message, "error");
}
} else if (action === "reset-app") {
if (
!confirm(
"Reset ForgeFlow configuration? Your Git repositories and Gitea data are not modified.",
)
)
return;
ui.boot.state = await window.forgeflow.reset();
ui.repositories = [];
ui.setupStep = 0;
ui.setupValidation = null;
ui.systemPreflight = null;
ui.deploymentPreflight = null;
ui.lastDiagnosticBundle = null;
ui.setupDraft = {
baseUrl: "https://",
token: "",
user: null,
roots: [],
discovered: [],
};
render();
}
else return false;
return true;
}
+426
View File
@@ -0,0 +1,426 @@
async function handleSetupAndSettingsActions(event, target, action, repository) {
if (action === "setup-run-preflight")
await runSystemPreflight({ setup: true });
else if (action === "setup-continue") {
if (ui.systemPreflight?.summary?.ready) {
ui.setupStep = 1;
render();
}
} else if (action === "setup-validate") {
setLoading(true, "Validating Gitea connection…");
try {
ui.setupValidation = await window.forgeflow.validateGitea(ui.setupDraft);
ui.setupDraft.baseUrl = ui.setupValidation.baseUrl;
ui.setupDraft.user = ui.setupValidation.user;
ui.setupStep = 2;
} catch (error) {
showToast("Connection failed", error.message, "error");
}
setLoading(false);
} else if (action === "setup-add-root") {
const root = await window.forgeflow.selectDirectory({
title: "Select a development folder",
});
if (root && !ui.setupDraft.roots.includes(root))
ui.setupDraft.roots.push(root);
render();
} else if (action === "setup-remove-root") {
ui.setupDraft.roots.splice(Number(target.dataset.index), 1);
render();
} else if (action === "setup-next") {
if (ui.setupStep === 2) {
ui.setupStep = 3;
ui.setupDraft.discovered = [];
render();
try {
ui.setupDraft.discovered = await window.forgeflow.discoverRepositories(
ui.setupDraft.roots,
);
} catch (error) {
showToast("Discovery failed", error.message, "error");
}
ui.setupStep = 4;
render();
}
} else if (action === "setup-back") {
ui.setupStep = Math.max(0, ui.setupStep - 1);
render();
} else if (action === "setup-finish") {
setLoading(true, "Saving configuration…");
try {
const result = await window.forgeflow.completeSetup({
baseUrl: ui.setupDraft.baseUrl,
token: ui.setupDraft.token,
user: ui.setupDraft.user,
workspaceRoots: ui.setupDraft.roots,
});
ui.boot.state = result.state;
await refreshRepositories(false);
showToast(
"Setup complete",
result.tokenState.persistent
? "Your token is stored securely."
: "Your token is available for this session only.",
"success",
);
} catch (error) {
showToast("Could not complete setup", error.message, "error");
}
setLoading(false);
} else if (action === "check-updates") {
ui.updateChecking = true;
render();
try {
ui.updateStatus = await window.forgeflow.checkForUpdates();
showToast(
ui.updateStatus.available ? "Update available" : "ForgeFlow is current",
ui.updateStatus.available
? `Version ${ui.updateStatus.remoteVersion} can be downloaded.`
: `Version ${ui.updateStatus.currentVersion} is the newest release.`,
ui.updateStatus.available ? "success" : "info",
);
} catch (error) {
showToast("Update check failed", error.message, "error");
}
ui.updateChecking = false;
render();
} else if (action === "save-update-settings") {
const updates = {
owner: document.querySelector("#update-owner").value.trim(),
repo: document.querySelector("#update-repo").value.trim(),
branch: document.querySelector("#update-branch").value.trim(),
autoCheck: document.querySelector("#update-auto-check").value === "true",
};
try {
ui.boot.state = await window.forgeflow.setUpdatePreferences(updates);
ui.updateStatus = null;
showToast(
"Update settings saved",
"The next check will use this repository and branch.",
"success",
);
} catch (error) {
showToast("Could not save update settings", error.message, "error");
}
render();
} else if (action === "download-update") {
setLoading(true, "Downloading and verifying the exact ForgeFlow update…");
try {
ui.updateStatus = await window.forgeflow.downloadUpdate();
showToast(
"Update downloaded",
`Version ${ui.updateStatus.remoteVersion} passed the integrity check.`,
"success",
);
} catch (error) {
showToast("Update download failed", error.message, "error");
}
setLoading(false);
} else if (action === "apply-update") {
if (
!confirm(
`Apply ForgeFlow ${ui.updateStatus?.remoteVersion || "update"} now? ForgeFlow closes, validates the update and restarts automatically.`,
)
)
return;
setLoading(true, "Launching safe updater…");
try {
await window.forgeflow.applyUpdate();
showToast(
"Update launched",
"ForgeFlow will close and restart after validation.",
"success",
);
} catch (error) {
showToast("Could not launch update", error.message, "error");
setLoading(false);
}
} else if (action === "use-server-password") {
ui.modal = {
type: "server-password",
serverId: target.dataset.serverId,
retry: { type: target.dataset.retry || "scan" },
};
render();
} else if (action === "confirm-server-password") {
const server = (ui.boot?.state?.servers || []).find((item) => item.id === target.dataset.serverId);
const password = document.querySelector("#quick-server-password")?.value || "";
if (!server || !password) {
showToast("Password required", "Enter the Unraid SSH password.", "error");
return;
}
const retry = ui.modal?.retry || { type: "scan" };
setLoading(true, "Switching the server connection to password authentication…");
try {
const saved = await window.forgeflow.saveServer(
{ ...server, authType: "password", privateKeyPath: "" },
password,
"",
);
ui.boot.state = saved.state;
const tested = await window.forgeflow.testServer(server.id);
ui.boot.state = tested.state;
ui.modal = null;
showToast("Server password saved", "ForgeFlow will no longer use an SSH key for this server.", "success");
if (retry.type === "deploy") {
const retryRepository = ui.repositories.find((item) => item.fullName === retry.repositoryFullName);
if (retryRepository) ui.selectedRepoId = retryRepository.id;
await executeDeployment(retry.profileId);
} else {
await refreshDeploymentTruth(true);
}
} catch (error) {
showToast("Server authentication failed", error.message, "error");
}
setLoading(false);
} else if (action === "open-add-server") {
ui.modal = {
type: "server-config",
serverId: null,
authType: "password",
};
render();
} else if (action === "edit-server") {
const server = (ui.boot.state.servers || []).find(
(item) => item.id === target.dataset.serverId,
);
ui.modal = {
type: "server-config",
serverId: target.dataset.serverId,
authType: server?.authType || "password",
};
render();
} else if (action === "select-private-key") {
const keyPath = await window.forgeflow.selectKeyFile({
title: "Select SSH private key",
defaultPath:
document.querySelector("#server-private-key")?.value || undefined,
});
if (keyPath) document.querySelector("#server-private-key").value = keyPath;
} else if (action === "save-server") {
const authType = document.querySelector("#server-auth-type").value;
const server = {
id: target.dataset.serverId || undefined,
name: document.querySelector("#server-name").value.trim(),
host: document.querySelector("#server-host").value.trim(),
port: Number(document.querySelector("#server-port").value),
username: document.querySelector("#server-username").value.trim(),
authType,
basePath: document.querySelector("#server-base-path").value.trim(),
scanRoots: document.querySelector("#server-scan-roots").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean),
scanExcludes: document.querySelector("#server-scan-excludes").value.split(",").map((value) => value.trim()).filter(Boolean),
privateKeyPath:
document.querySelector("#server-private-key")?.value.trim() || "",
hostFingerprint: document
.querySelector("#server-fingerprint")
.value.trim(),
};
const password = document.querySelector("#server-password")?.value || "";
const passphrase =
document.querySelector("#server-passphrase")?.value || "";
setLoading(true, "Saving encrypted SSH configuration…");
try {
const result = await window.forgeflow.saveServer(
server,
password,
passphrase,
);
ui.boot.state = result.state;
ui.modal = null;
showToast(
"Server saved",
"Run Test & trust before creating a deployment.",
"success",
);
} catch (error) {
showToast("Could not save server", error.message, "error");
}
setLoading(false);
} else if (action === "test-server") {
setLoading(
true,
"Checking SSH identity, Docker, Compose and optional Git capabilities…",
);
try {
const result = await window.forgeflow.testServer(target.dataset.serverId);
ui.boot.state = result.state;
const capabilities = result.capabilities || {};
const deploymentReady =
capabilities.docker && capabilities.dockerReady && capabilities.compose;
showToast(
deploymentReady ? "SSH server ready" : "SSH connected with missing tools",
`${result.server.name} presented ${result.fingerprint}. Docker ${capabilities.dockerReady ? "ready" : "unavailable"}; Compose ${capabilities.compose ? "ready" : "missing"}.`,
deploymentReady ? "success" : "info",
);
} catch (error) {
showToast("SSH test failed", error.message, "error");
}
setLoading(false);
} else if (action === "delete-server") {
if (
!confirm("Delete this server and all deployment profiles linked to it?")
)
return;
try {
ui.boot.state = await window.forgeflow.deleteServer(
target.dataset.serverId,
);
ui.modal = null;
await refreshRepositories(false);
showToast(
"Server deleted",
"Linked SSH deployment profiles were removed.",
"success",
);
} catch (error) {
showToast("Could not delete server", error.message, "error");
}
} else if (action === "add-root") {
const root = await window.forgeflow.selectDirectory({
title: "Add development folder",
});
if (root && !ui.boot.state.workspaceRoots.includes(root))
ui.boot.state.workspaceRoots.push(root);
render();
} else if (action === "remove-root") {
ui.boot.state.workspaceRoots.splice(Number(target.dataset.index), 1);
render();
} else if (action === "save-roots") {
const roots = [...document.querySelectorAll("[data-root-index]")]
.map((input) => input.value.trim())
.filter(Boolean);
setLoading(true, "Saving workspace folders…");
try {
ui.boot.state = await window.forgeflow.setWorkspaceRoots(roots);
await refreshRepositories(false);
showToast(
"Folders saved",
"Repository discovery has been refreshed.",
"success",
);
} catch (error) {
showToast("Could not save folders", error.message, "error");
}
setLoading(false);
} else if (action === "save-gitea-settings") {
const baseUrl = document.querySelector("#settings-gitea-url").value.trim();
const token = document.querySelector("#settings-gitea-token").value.trim();
setLoading(true, "Validating Gitea…");
try {
const result = await window.forgeflow.updateGitea({ baseUrl, token });
ui.boot.state = result.state;
await refreshRepositories(false);
showToast(
"Gitea connected",
`Signed in as ${result.validation.user.login}.`,
"success",
);
} catch (error) {
showToast("Connection failed", error.message, "error");
}
setLoading(false);
} else if (action === "save-preferences") {
const preferences = {
autoRefresh:
document.querySelector("#pref-auto-refresh").value === "true",
repositoryPollSeconds: Number(
document.querySelector("#pref-repo-poll").value,
),
operationPollSeconds: Number(
document.querySelector("#pref-operation-poll").value,
),
preferredCloneProtocol: document.querySelector("#pref-clone-protocol")
.value,
};
setLoading(true, "Saving background settings…");
try {
ui.boot.state = await window.forgeflow.setPreferences(preferences);
await refreshRepositories(false);
showToast(
"Settings saved",
"Background awareness has been updated.",
"success",
);
} catch (error) {
showToast("Could not save settings", error.message, "error");
}
setLoading(false);
} else if (action === "save-desktop-preferences") {
const splitArgs = (selector) =>
document
.querySelector(selector)
.value.split("|")
.map((item) => item.trim())
.filter(Boolean);
const preferences = {
editor: {
executable: document
.querySelector("#pref-editor-executable")
.value.trim(),
args: splitArgs("#pref-editor-args"),
},
terminal: {
executable: document
.querySelector("#pref-terminal-executable")
.value.trim(),
args: splitArgs("#pref-terminal-args"),
},
notificationsEnabled: document.querySelector("#pref-notifications")
.checked,
trayEnabled: document.querySelector("#pref-tray").checked,
closeToTray: document.querySelector("#pref-close-tray").checked,
startAtLogin: document.querySelector("#pref-login").checked,
};
try {
ui.boot.state = await window.forgeflow.setPreferences(preferences);
showToast(
"Desktop integration saved",
"Editor, terminal, tray and notification settings are active.",
"success",
);
} catch (error) {
showToast("Could not save desktop integration", error.message, "error");
}
render();
} else if (
action === "export-config-backup" ||
action === "import-config-backup"
) {
const passphrase = document.querySelector("#backup-passphrase").value;
if (passphrase.length < 12) {
showToast("Passphrase too short", "Use at least 12 characters.", "error");
return;
}
setLoading(
true,
action === "export-config-backup"
? "Encrypting configuration backup…"
: "Decrypting and validating configuration…",
);
try {
const result =
action === "export-config-backup"
? await window.forgeflow.exportConfigurationBackup(passphrase)
: await window.forgeflow.importConfigurationBackup(passphrase);
if (result?.state) {
ui.boot.state = result.state;
await refreshRepositories(false);
}
if (result)
showToast(
action === "export-config-backup"
? "Encrypted backup created"
: "Configuration restored",
action === "export-config-backup"
? result.filePath
: `Backup from ${result.exportedAt} imported; credentials were preserved only where already present.`,
"success",
);
} catch (error) {
showToast("Configuration backup failed", error.message, "error");
}
setLoading(false);
}
else return false;
return true;
}
+480
View File
@@ -0,0 +1,480 @@
async function handleShellActions(event, target, action, repository) {
if (action === "navigate") {
ui.currentView = target.dataset.view;
ui.modal = null;
render();
if (ui.currentView === "deployments" && (ui.boot?.state?.servers || []).length && !(ui.serverDiscovery || []).length) {
setLoading(true, "Reading Docker, Compose and DockerMan inventory from Unraid…");
await refreshDeploymentTruth(true);
setLoading(false);
}
} else if (action === "select-repo") selectRepository(target.dataset.id);
else if (action === "refresh") {
await refreshRepositories(true);
await refreshActiveOperations(false);
await refreshDeploymentTruth(false);
} else if (action === "refresh-operations") {
setLoading(true, "Refreshing deployment operations and live server state…");
await refreshActiveOperations();
await refreshDeploymentTruth(true);
setLoading(false);
} else if (action === "toggle-theme") {
const appearance =
document.documentElement.dataset.theme === "dark" ? "light" : "dark";
applyTheme(appearance);
ui.boot.state = await window.forgeflow.setAppearance(appearance);
render();
} else if (action === "open-palette") {
ui.paletteQuery = "";
ui.modal = { type: "command-palette" };
render();
} else if (action === "repo-tab") {
ui.repositoryTab = target.dataset.tab;
if (ui.repositoryTab === "gittools" && !ui.branches.length)
await loadGitTools(repository);
else if (ui.repositoryTab === "validator" && !ui.gitValidation) {
setLoading(true, "Validating Git and Gitea best practices…");
try {
ui.gitValidation = await window.forgeflow.gitValidatorScan(
repository.fullName,
);
} catch (error) {
showToast("Git Validator failed", error.message, "error");
} finally {
setLoading(false);
}
render();
} else if (ui.repositoryTab === "settings") {
try {
ui.pullRequests = await window.forgeflow.pullRequests(
repository.fullName,
"open",
);
} catch (error) {
ui.pullRequests = [];
showToast("Could not load pull requests", error.message, "error");
}
render();
} else render();
} else if (action === "git-validator-scan") {
setLoading(true, "Validating Git and Gitea best practices…");
try {
ui.gitValidation = await window.forgeflow.gitValidatorScan(
repository.fullName,
);
showToast(
"Git validation complete",
`${ui.gitValidation.score}/100 · ${ui.gitValidation.grade}`,
ui.gitValidation.summary.errors ? "error" : "success",
);
} catch (error) {
showToast("Git Validator failed", error.message, "error");
} finally {
setLoading(false);
}
} else if (action === "git-validator-repair") {
const check = ui.gitValidation?.checks?.[Number(target.dataset.checkIndex)];
if (!check?.fixAction) return;
if (!check.safe && !confirm(check.confirmation || `Apply ${check.title}?`))
return;
setLoading(true, `Repairing ${check.title}`);
try {
await window.forgeflow.gitValidatorRepair(repository.fullName, check);
await refreshRepositories(false, true);
ui.gitValidation = await window.forgeflow.gitValidatorScan(
repository.fullName,
);
showToast("Git best practice repaired", check.title, "success");
} catch (error) {
showToast("Repair failed", error.message, "error");
} finally {
setLoading(false);
}
} else if (action === "git-validator-repair-safe") {
const checks = (ui.gitValidation?.checks || []).filter(
(check) => check.fixAction && check.safe,
);
setLoading(true, `Applying ${checks.length} safe Git fixes…`);
let repaired = 0;
try {
for (const check of checks) {
await window.forgeflow.gitValidatorRepair(repository.fullName, check);
repaired += 1;
}
await refreshRepositories(false, true);
ui.gitValidation = await window.forgeflow.gitValidatorScan(
repository.fullName,
);
showToast("Safe Git fixes applied", `${repaired} repaired.`, "success");
} catch (error) {
showToast("Safe repair stopped", error.message, "error");
} finally {
setLoading(false);
}
} else if (action === "toggle-favorite") {
ui.boot.state = await window.forgeflow.favoriteRepository(
repository.fullName,
!repository.favorite,
);
repository.favorite = !repository.favorite;
render();
} else if (action === "select-file") {
if (event.target.matches("input[type=checkbox]")) return;
ui.selectedFile = target.dataset.path;
await loadDiff(repository, ui.selectedFile);
} else if (action === "toggle-all-files") {
const files = repository.localStatus?.files || [];
ui.selectedFiles =
ui.selectedFiles.size === files.length
? new Set()
: new Set(files.map((file) => file.path));
render();
} else if (action === "copy-diff") {
await navigator.clipboard.writeText(ui.diff || "");
showToast("Copied", "Diff copied to clipboard.", "success");
} else if (action === "open-hunk-staging") {
if (ui.diffHunks?.partialSupported) {
ui.modal = { type: "hunk-staging" };
render();
}
} else if (action === "stage-chosen-hunks") {
const indexes = [
...document.querySelectorAll("[data-hunk-index]:checked"),
].map((input) => Number(input.dataset.hunkIndex));
if (!indexes.length) return;
const result = await runOperation(
"Staging selected hunks…",
() =>
window.forgeflow.stageHunks(
repository.localPath,
ui.selectedFile,
indexes,
),
"Selected hunks staged.",
);
if (result) {
ui.modal = null;
await loadDiff(selectedRepository(), ui.selectedFile);
}
} else if (action === "open-file-editor") {
await window.forgeflow
.openEditor(repository.localPath, ui.selectedFile)
.catch((error) =>
showToast("Could not open editor", error.message, "error"),
);
} else if (action === "open-editor") {
await window.forgeflow
.openEditor(repository.localPath)
.catch((error) =>
showToast("Could not open editor", error.message, "error"),
);
} else if (action === "open-terminal") {
await window.forgeflow
.openTerminal(repository.localPath)
.catch((error) =>
showToast("Could not open terminal", error.message, "error"),
);
} else if (action === "load-conflicts") {
ui.conflictState = await window.forgeflow.conflictState(
repository.localPath,
);
ui.modal = { type: "conflict-guide" };
render();
} else if (action === "resolve-conflict") {
if (
!ui.selectedFile ||
!confirm(`Apply “${target.dataset.resolution}” to ${ui.selectedFile}?`)
)
return;
ui.conflictState = await window.forgeflow.resolveConflict(
repository.localPath,
ui.selectedFile,
target.dataset.resolution,
);
await refreshRepositories(false);
ui.modal = { type: "conflict-guide" };
render();
} else if (action === "open-conflict-file") {
await window.forgeflow.openEditor(
repository.localPath,
target.dataset.path,
);
} else if (action === "continue-git-operation") {
ui.conflictState = await window.forgeflow.continueGitOperation(
repository.localPath,
);
ui.modal = null;
await refreshRepositories(false);
showToast(
"Git operation continued",
"The repository operation completed.",
"success",
);
} else if (action === "abort-git-operation") {
if (
!confirm(
"Abort the active Git operation? Conflict-resolution work may be discarded.",
)
)
return;
await window.forgeflow.abortGitOperation(repository.localPath);
ui.modal = null;
await refreshRepositories(false);
} else if (action === "check-branch-protection") {
ui.branchProtection = await window.forgeflow.branchProtection(
repository.fullName,
repository.localStatus.branch.head,
);
showToast(
ui.branchProtection.protected
? "Protected branch"
: "Branch is not protected",
ui.branchProtection.protected
? `${ui.branchProtection.requiredApprovals} approval(s) required.`
: "Direct pushes are permitted by the reported branch rule.",
ui.branchProtection.protected ? "info" : "success",
);
render();
} else if (action === "open-pull-request") {
const subject =
ui.history[0]?.subject || repository.localStatus.branch.head;
ui.modal = {
type: "pull-request",
title: subject,
body: `## Summary\n\nChanges from ${repository.localStatus.branch.head}.`,
};
render();
} else if (action === "load-pull-requests") {
try {
ui.pullRequests = await window.forgeflow.pullRequests(
repository.fullName,
"open",
);
render();
} catch (error) {
showToast("Could not load pull requests", error.message, "error");
}
} else if (action === "open-pull-request-url") {
if (target.dataset.url)
await window.forgeflow.openExternal(target.dataset.url);
} else if (action === "create-pull-request") {
setLoading(true, "Creating pull request…");
try {
const pull = await window.forgeflow.createPullRequest(
repository.fullName,
document.querySelector("#pr-title").value,
document.querySelector("#pr-body").value,
document.querySelector("#pr-base").value,
);
ui.modal = null;
ui.pullRequests = await window.forgeflow
.pullRequests(repository.fullName, "open")
.catch(() => ui.pullRequests);
showToast("Pull request created", `#${pull.number}`, "success");
if (pull.html_url) await window.forgeflow.openExternal(pull.html_url);
} catch (error) {
showToast("Could not create pull request", error.message, "error");
}
setLoading(false);
} else if (action === "copy-logs") {
const text = (ui.activeDeployment?.logs || []).join("\n");
await navigator.clipboard.writeText(text);
showToast(
"Copied",
"Safe operation output copied. Open Gitea for raw runner logs.",
"success",
);
} else if (action === "stage-selected") {
if (!repository?.localPath || !ui.selectedFiles.size) return;
await runOperation(
"Staging selected files…",
() =>
window.forgeflow.stageFiles(repository.localPath, [
...ui.selectedFiles,
]),
"Files staged.",
);
} else if (action === "unstage-selected") {
if (!repository?.localPath || !ui.selectedFiles.size) return;
await runOperation(
"Unstaging selected files…",
() =>
window.forgeflow.unstageFiles(repository.localPath, [
...ui.selectedFiles,
]),
"Files unstaged.",
);
} else if (action === "commit-push" || action === "commit-only") {
if (
!repository?.localPath ||
!ui.commitMessage.trim() ||
(!ui.selectedFiles.size && !repository.localStatus?.counts?.staged)
)
return;
const selected = [...ui.selectedFiles];
const result = await runOperation(
action === "commit-push"
? "Committing and pushing…"
: "Creating local commit…",
() =>
action === "commit-push"
? selected.length
? window.forgeflow.commitAndPush(
repository.localPath,
ui.commitMessage,
selected,
)
: window.forgeflow.commitStagedAndPush(
repository.localPath,
ui.commitMessage,
)
: selected.length
? window.forgeflow.commit(
repository.localPath,
ui.commitMessage,
selected,
)
: window.forgeflow.commitStaged(
repository.localPath,
ui.commitMessage,
),
action === "commit-push"
? "Changes committed and pushed to Gitea."
: "Local commit created.",
);
if (result) {
ui.commitMessage = "";
ui.selectedFiles.clear();
ui.selectedFile = null;
ui.diff = "";
}
} else if (action === "push")
await runOperation(
"Pushing local commits…",
() => window.forgeflow.push(repository.localPath),
"Push completed.",
);
else if (action === "fetch")
await runOperation(
"Fetching from Gitea…",
() => window.forgeflow.fetch(repository.localPath),
"Remote state refreshed.",
);
else if (action === "pull")
await runOperation(
"Synchronizing from Gitea…",
() => window.forgeflow.pull(repository.localPath),
"Local branch fast-forwarded.",
);
else if (action === "load-history") {
setLoading(true, "Loading commit history…");
try {
ui.history = await window.forgeflow.history(repository.localPath, 50);
} catch (error) {
showToast("History unavailable", error.message, "error");
}
setLoading(false);
} else if (action === "load-git-tools") await loadGitTools(repository);
else if (action === "create-branch") {
const branch = document.querySelector("#new-branch-name")?.value.trim();
if (branch)
await runOperation(
`Creating ${branch}`,
() => window.forgeflow.createBranch(repository.localPath, branch),
`Switched to ${branch}.`,
);
await loadGitTools(selectedRepository());
} else if (action === "checkout-branch") {
await runOperation(
`Switching to ${target.dataset.branch}`,
() =>
window.forgeflow.checkoutBranch(
repository.localPath,
target.dataset.branch,
),
`Switched to ${target.dataset.branch}.`,
);
await loadGitTools(selectedRepository());
} else if (action === "stash-changes") {
const result = await runOperation(
"Stashing local changes…",
() =>
window.forgeflow.stash(
repository.localPath,
`ForgeFlow ${new Date().toLocaleString()}`,
),
"Local changes stashed.",
);
if (result) ui.stashes = result.stashes;
} else if (action === "pop-stash") {
const result = await runOperation(
`Applying ${target.dataset.stashRef}`,
() =>
window.forgeflow.popStash(
repository.localPath,
target.dataset.stashRef,
),
"Stash applied.",
);
if (result) ui.stashes = result.stashes;
} else if (action === "open-path")
await window.forgeflow
.openPath(repository.localPath)
.catch((error) =>
showToast("Could not open folder", error.message, "error"),
);
else if (action === "open-gitea")
await window.forgeflow
.openExternal(repository.htmlUrl)
.catch((error) =>
showToast("Could not open Gitea", error.message, "error"),
);
else if (action === "link-repo") {
const localPath = await window.forgeflow.selectDirectory({
title: `Link local folder for ${repository.name}`,
});
if (localPath) {
ui.repositories =
(await runOperation(
"Linking local repository…",
() => window.forgeflow.linkRepository(repository.fullName, localPath),
"Local folder linked.",
{ refresh: false },
)) || ui.repositories;
selectRepository(repository.id);
}
} else if (action === "unlink-repo") {
ui.repositories =
(await runOperation(
"Removing local link…",
() => window.forgeflow.unlinkRepository(repository.fullName),
"Repository link removed.",
{ refresh: false },
)) || ui.repositories;
selectRepository(repository.id);
} else if (action === "clone-repo" || action === "clone-repo-custom") {
const mode = action === "clone-repo-custom" ? "custom" : "default";
const clone = await runOperation(
mode === "custom"
? "Choosing location and cloning repository…"
: `Cloning ${repository.name} into the default project root…`,
() => window.forgeflow.cloneRepository(repository.fullName, mode),
null,
{ refresh: false },
);
if (clone?.cancelled) return;
if (clone?.target) {
if (clone.state) ui.boot.state = clone.state;
ui.repositories =
clone.repositories || (await window.forgeflow.refreshRepositories());
selectRepository(repository.id);
showToast(
clone.reused ? "Existing repository linked" : "Repository cloned",
clone.target,
"success",
);
}
}
else return false;
return true;
}
-3412
View File
File diff suppressed because it is too large Load Diff
+319
View File
@@ -0,0 +1,319 @@
function renderModal() {
if (!ui.modal) return "";
const repository =
selectedRepository() ||
ui.repositories.find(
(repo) => repo.fullName === ui.modal.repositoryFullName,
);
if (ui.modal.type === "server-password") {
const server = (ui.boot?.state?.servers || []).find((item) => item.id === ui.modal.serverId);
if (!server) return `<div class="modal-backdrop"><section class="modal"><header class="modal-header"><h2>Server password</h2></header><div class="modal-body"><div class="notice danger">${icon("error")}The selected server no longer exists.</div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Close</button></footer></section></div>`;
const retryText = ui.modal.retry?.type === "deploy" ? "Save password & redeploy" : "Save password & rescan";
return `<div class="modal-backdrop" role="presentation"><section class="modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Use password for ${escapeHtml(server.name)}</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="notice success">${icon("shield")}ForgeFlow stores the server password with Windows protected storage and uses it only for the desktop → Unraid connection.</div><div class="field" style="margin-top:14px"><label>SSH password for ${escapeHtml(server.username)}@${escapeHtml(server.host)}</label><input id="quick-server-password" class="input" type="password" autocomplete="current-password" autofocus placeholder="Server password"/></div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="confirm-server-password" data-server-id="${attr(server.id)}">${escapeHtml(retryText)}</button></footer></section></div>`;
}
if (ui.modal.type === "server-reconciliation-plan") {
const plan = ui.modal.result?.plan || {};
const summary = plan.summary || {};
const rows = [
...(plan.additions || []).map((item) => ({ tone: "success", title: `Link ${item.repositoryFullName}`, detail: `${item.evidence} · ${item.impact}` })),
...(plan.updates || []).map((item) => ({ tone: "", title: `Refresh ${item.repositoryFullName}`, detail: item.impact })),
...(plan.stale || []).map((item) => ({ tone: "warning", title: `Review stale link ${item.repositoryFullName}`, detail: `${item.reason} · no automatic removal` })),
...(plan.conflicts || []).map((item) => ({ tone: "danger", title: `Manual review: ${item.displayName}`, detail: `${item.status} · ${(item.candidates || []).map((candidate) => candidate.repositoryFullName).join(", ") || "no unique repository"}` })),
];
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true" aria-labelledby="reconciliation-title"><header class="modal-header"><h2 id="reconciliation-title">Review server reconciliation</h2><button class="icon-button" data-action="close-modal" aria-label="Close reconciliation preview">${icon("close")}</button></header><div class="modal-body"><div class="notice success">${icon("shield")}This reviewed plan may update ForgeFlow configuration only. It never starts, stops or recreates containers, and stale profiles are never removed automatically.</div><div class="summary-grid" style="margin-top:12px"><div class="summary-card"><span>New links</span><strong>${Number(summary.additions || 0)}</strong></div><div class="summary-card"><span>Refreshes</span><strong>${Number(summary.updates || 0)}</strong></div><div class="summary-card"><span>Stale reviews</span><strong>${Number(summary.stale || 0)}</strong></div><div class="summary-card"><span>Conflicts</span><strong>${Number(summary.conflicts || 0)}</strong></div></div><div class="tool-list" style="margin-top:14px">${rows.length ? rows.map((item) => `<div class="tool-row"><div><strong>${escapeHtml(item.title)}</strong><span>${escapeHtml(item.detail)}</span></div>${item.tone ? `<span class="status-pill ${item.tone}">${escapeHtml(item.tone === "success" ? "Planned" : item.tone === "warning" ? "Review" : "Blocked")}</span>` : ""}</div>`).join("") : '<div class="empty-state compact"><p>No configuration changes are proposed.</p></div>'}</div><div class="notice" style="margin-top:12px">${icon("archive")}A private recovery snapshot is written before the plan is applied. Plan ID: <span class="mono">${escapeHtml(String(plan.id || "").slice(0, 12))}</span></div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="apply-server-reconciliation" data-server-id="${attr(plan.serverId || "")}" data-plan-id="${attr(plan.id || "")}" ${summary.conflicts ? "disabled title=\"Resolve ambiguous workloads manually before applying reconciliation\"" : ""}>Apply reviewed plan</button></footer></section></div>`;
}
if (ui.modal.type === "workload-link") {
const serverResult = (ui.serverDiscovery || []).find(
(item) => item.serverId === ui.modal.serverId,
);
const workload = serverResult?.workloads?.find(
(item) => item.workloadId === ui.modal.workloadId,
);
if (!workload) {
return `<div class="modal-backdrop" role="presentation"><section class="modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Link server workload</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="notice danger">${icon("error")}This workload is no longer present in the latest server inventory. Scan the servers again.</div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Close</button></footer></section></div>`;
}
const availableRepositories = ui.repositories.filter((item) => item.fullName);
const suggestedRepository =
ui.modal.repositoryFullName ||
workload.candidates?.[0]?.repositoryFullName ||
selectedRepository()?.fullName ||
availableRepositories[0]?.fullName ||
"";
const selectedLinkRepository = availableRepositories.find(
(item) => item.fullName === suggestedRepository,
);
const remoteFolder =
ui.modal.remoteFolder ||
workload.remoteFolderCandidate ||
safeCloneFolderName(selectedLinkRepository);
const candidateSummary = workload.candidates?.length
? workload.candidates
.slice(0, 4)
.map(
(candidate) =>
`<div class="context-row"><span>${escapeHtml(candidate.repositoryFullName)}</span><strong>${escapeHtml(candidate.exact ? "Exact provenance" : `${candidate.score} confidence`)} · ${escapeHtml((candidate.reasons || []).join(", ") || "name similarity")}</strong></div>`,
)
.join("")
: '<div class="context-row"><span>Repository candidates</span><strong>No confident match; choose manually.</strong></div>';
const containerNames = (workload.containers || [])
.map((container) => container.name)
.filter(Boolean)
.join(", ");
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Link existing server workload</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero">${icon("link")}<div><strong>${escapeHtml(workload.displayName)}</strong><span>${escapeHtml(serverResult?.serverName || serverResult?.server?.name || ui.modal.serverId)} · ${workload.runtime?.running ? "running" : "stopped"}</span></div></div><div class="context-summary"><div class="context-row"><span>Containers</span><strong>${escapeHtml(containerNames || "Unknown")}</strong></div><div class="context-row"><span>Compose identity</span><strong>${escapeHtml(workload.compose?.project || "DockerMan / standalone container")} ${workload.compose?.services?.length ? `· ${escapeHtml(workload.compose.services.join(", "))}` : ""}</strong></div><div class="context-row"><span>Detected folder</span><strong class="mono">${escapeHtml(workload.compose?.workingDir || workload.dockerMan?.templatePath || "No Git checkout required")}</strong></div>${candidateSummary}</div><div class="form-grid" style="margin-top:14px"><div class="field full"><label>Repository to link</label><select id="workload-repository" class="select">${availableRepositories.map((item) => `<option value="${attr(item.fullName)}" ${item.fullName === suggestedRepository ? "selected" : ""}>${escapeHtml(item.fullName)}</option>`).join("") || '<option value="">No repositories available</option>'}</select></div><div class="field"><label>Deployment source</label><select id="workload-deployment-mode" class="select"><option value="server-git" selected>Server pull from Gitea</option><option value="push-bundle">Direct copy fallback</option><option value="monitor-only">Monitor only</option></select></div><div class="field"><label>Detected deployment folder</label><input id="workload-remote-folder" class="input" value="${attr(remoteFolder)}" readonly/></div></div><div class="notice success" style="margin-top:12px">${icon("shield")}ForgeFlow preserves the detected Compose project, services and container identity. Server pull provisions a repository-scoped read-only key and activates only the selected Gitea commit.</div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="confirm-link-server-workload" data-server-id="${attr(ui.modal.serverId)}" data-workload-id="${attr(ui.modal.workloadId)}" ${availableRepositories.length ? "" : "disabled"}>Link workload</button></footer></section></div>`;
}
if (ui.modal.type === "deployment-config") {
const storedProfile =
repository?.deploymentProfiles?.find(
(profile) => profile.id === ui.modal.profileId,
) || {};
const discovery =
ui.deploymentDiscovery?.repository === repository?.fullName
? ui.deploymentDiscovery
: null;
const existing = { ...storedProfile, ...(discovery?.profile || {}) };
if (discovery?.provenance) existing.provenance = discovery.provenance;
const servers = ui.boot.state.servers || [];
const provider =
ui.modal.provider ||
existing.provider ||
(servers.length ? "ssh-unraid" : "gitea-actions");
const ssh = provider === "ssh-unraid";
const remoteFolder =
existing.remoteFolder || safeCloneFolderName(repository);
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>${existing.id ? "Edit" : "Add"} deployment environment</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="form-grid"><div class="field full"><label>Deployment provider</label><select id="profile-provider" class="select"><option value="ssh-unraid" ${ssh ? "selected" : ""}>SSH / Unraid · direct controlled deployment</option><option value="gitea-actions" ${!ssh ? "selected" : ""}>Gitea Actions · runner workflow</option></select></div>${ssh ? `<div class="field full"><div class="notice ${discovery ? "success" : ""}">${icon(discovery ? "check" : "server")}<div><strong>${discovery ? "Existing deployment imported from server" : "Import by folder or use Server inventory"}</strong><p>${discovery ? `${escapeHtml(discovery.runtime?.containers || 0)} container(s), ${escapeHtml(discovery.runtime?.services || 0)} service(s) and ${escapeHtml(discovery.runtime?.ports?.length || 0)} port mapping(s) detected. Every imported value remains editable as an explicit override.` : "For a known folder, ForgeFlow can read Docker, Compose and DockerMan metadata. For uncertain matches, use Server inventory and select the actual running workload."}</p><button type="button" class="button ${discovery ? "" : "primary"}" data-action="discover-existing-deployment">${icon("refresh")}${discovery ? "Rescan folder" : "Import known folder"}</button></div></div></div>` : ""}<div class="field"><label>Profile name</label><input id="profile-name" class="input" value="${attr(existing.name || "Production")}" /></div><div class="field"><label>Environment</label><input id="profile-environment" class="input" value="${attr(existing.environment || "production")}" /></div><div class="field"><label>Allowed branch</label><input id="profile-branch" class="input" value="${attr(existing.branch || repository?.defaultBranch || "main")}" /></div>${
ssh
? `
<div class="field"><label>Unraid server</label><select id="profile-server" class="select">${servers.length ? servers.map((server) => `<option value="${attr(server.id)}" ${server.id === existing.serverId ? "selected" : ""}>${escapeHtml(server.name)} · ${escapeHtml(server.host)}</option>`).join("") : '<option value="">Configure a server first</option>'}</select></div>
<div class="field"><label>Server folder name</label><input id="profile-remote-folder" class="input" value="${attr(remoteFolder)}"/></div>
<div class="field"><label>Deployment mode</label><select id="profile-deployment-mode" class="select"><option value="server-git" ${(existing.deploymentMode || "server-git") === "server-git" ? "selected" : ""}>Server pull from Gitea</option><option value="push-bundle" ${existing.deploymentMode === "push-bundle" ? "selected" : ""}>Direct copy & deploy</option><option value="monitor-only" ${existing.deploymentMode === "monitor-only" ? "selected" : ""}>Monitor only</option></select><small>Server pull uses an automatically managed repository-scoped read-only deploy key. Direct copy remains available as a fallback and never requires Gitea credentials on Unraid.</small></div>
<div class="field"><label>Compose mode</label><select id="profile-generated-compose" class="select"><option value="false" ${existing.generatedCompose !== true ? "selected" : ""}>Use existing Compose definition</option><option value="true" ${existing.generatedCompose === true ? "selected" : ""}>Generate a basic ForgeFlow Compose file</option></select></div>
<div class="field"><label>Compose project identity</label><input id="profile-compose-project" class="input" value="${attr(existing.composeProject || "")}" placeholder="Existing docker compose project name"/><small>Kept stable to update the existing containers instead of creating duplicates.</small></div>
<div class="field full"><label>Compose files</label><input id="profile-compose-files" class="input" value="${attr((existing.composeFiles?.length ? existing.composeFiles : [existing.composeFile || "docker-compose.yml"]).join(", "))}"/><small>Comma-separated, in the same order used by the existing deployment. These real server Compose files remain authoritative; ForgeFlow does not inject a synthetic service overlay.</small></div>
<div class="field full"><label>Compose services to verify</label><input id="profile-compose-services" class="input" value="${attr((existing.composeServices?.length ? existing.composeServices : [existing.composeService || safeCloneFolderName(repository).toLowerCase()]).join(", "))}"/><small>Discovery hints only. At deployment time ForgeFlow reads the actual service keys from docker compose config and verifies every active service.</small></div><div class="field"><label>Visible container name</label><input id="profile-container-name" class="input" value="${attr(existing.containerName || remoteFolder)}"/><small>Used as an inventory hint; adopted Compose identity remains authoritative.</small></div>
<div class="field"><label>Host port</label><input id="profile-host-port" class="input" type="number" min="1" max="65535" value="${attr(existing.hostPort || "")}" placeholder="1223"/></div>
<div class="field"><label>Container port</label><input id="profile-container-port" class="input" type="number" min="1" max="65535" value="${attr(existing.containerPort || "")}" placeholder="8080"/></div>
<div class="field full"><label>Unraid Web UI URL (optional)</label><input id="profile-web-ui" class="input" value="${attr(existing.webUiUrl || "")}" placeholder="http://[IP]:[PORT:1223]/"/></div>
<div class="field"><label>DockerMan icon source</label><select id="profile-icon-mode" class="select"><option value="builtin" ${(existing.iconMode || (!existing.iconUrl && !existing.iconFilePath ? "builtin" : existing.iconFilePath ? "upload" : "url")) === "builtin" ? "selected" : ""}>Built-in high-contrast ITWorx mark</option><option value="upload" ${existing.iconMode === "upload" || (!existing.iconMode && existing.iconFilePath) ? "selected" : ""}>Upload local PNG</option><option value="url" ${existing.iconMode === "url" || (!existing.iconMode && existing.iconUrl) ? "selected" : ""}>Use icon URL</option><option value="none" ${existing.iconMode === "none" ? "selected" : ""}>No custom icon</option></select></div><div class="field"><label>Container shell</label><select id="profile-docker-shell" class="select"><option value="/bin/sh" ${(existing.dockerShell || "/bin/sh") === "/bin/sh" ? "selected" : ""}>/bin/sh</option><option value="/bin/bash" ${existing.dockerShell === "/bin/bash" ? "selected" : ""}>/bin/bash</option></select></div>
<div class="field full"><label>DockerMan icon URL</label><input id="profile-icon-url" class="input" value="${attr(existing.iconUrl || "")}" placeholder="https://…/icon.png"/></div><div class="field full"><label>Local PNG</label><div class="inline-form"><input id="profile-icon-file" class="input mono" value="${attr(existing.iconFilePath || "")}" placeholder="Select a local transparent PNG" readonly/><button class="button" data-action="select-profile-icon">${icon("folder")}Browse</button><button class="button ghost" data-action="clear-profile-icon">Clear</button></div><small>Built-in or uploaded PNGs are copied to DockerMan's persistent image folder and referenced through a file:/// URL. ForgeFlow also refreshes the relevant Unraid icon cache after recreating the container.</small></div>
<div class="field full"><label>Healthcheck URL from this desktop (optional)</label><input id="profile-healthcheck" class="input" value="${attr(existing.healthcheckUrl || "")}" placeholder="http://unraid:1223/health"/></div>
<div class="field full"><label>Preserve server-only paths</label><input id="profile-preserve-paths" class="input" value="${attr((existing.preservePaths || [".env", "appdata", "data", "logs", "config", "compose.override.yml"]).join(", "))}"/><small>Push bundle never replaces these paths and only removes files previously managed by ForgeFlow.</small></div>
<label class="check-field"><input id="profile-manage-dockerman" type="checkbox" ${existing.manageDockerMan === true ? "checked" : ""}/><span>Manage a generated DockerMan template</span><small>Existing/imported DockerMan templates are always preserved. This applies only to ForgeFlow-generated Compose deployments.</small></label>
<label class="check-field"><input id="profile-force-recreate" type="checkbox" disabled/><span>Destructive force-recreate disabled</span><small>ForgeFlow builds first and lets Compose replace only services whose image or configuration actually changed.</small></label>
<label class="check-field"><input id="profile-remove-orphans" type="checkbox" disabled/><span>Orphan removal disabled</span><small>ForgeFlow never removes unrelated or orphaned containers during a deployment.</small></label>
`
: `
<div class="field"><label>Deploy workflow file</label><input id="profile-workflow" class="input" value="${attr(existing.workflowFile || "deploy.yml")}" /></div>
<div class="field full"><label>Rollback workflow file (optional)</label><input id="profile-rollback-workflow" class="input" value="${attr(existing.rollbackWorkflowFile || "")}" placeholder="rollback.yml" /></div>
<div class="field full"><label>Application status URL</label><input id="profile-status-url" class="input" value="${attr(existing.statusUrl || "")}" required placeholder="https://app.example.com/.well-known/forgeflow" /></div>
<div class="field full"><label>Healthcheck URL (optional)</label><input id="profile-healthcheck" class="input" value="${attr(existing.healthcheckUrl || "")}" placeholder="https://app.example.com/health" /></div>`
}<label class="check-field full"><input id="profile-confirmation" type="checkbox" ${existing.confirmationRequired !== false ? "checked" : ""}/><span>Require an explicit confirmation before deployment</span></label></div><div class="notice" style="margin-top:13px">${icon("shield")}${ssh ? "Server pull fetches the exact selected Gitea commit with a repository-scoped read-only key, validates Compose and services, then promotes atomically with rollback protection." : "ForgeFlow sends only controlled workflow inputs: environment, exact SHA and a unique request ID."}</div></div><footer class="modal-footer">${existing.id ? `<button class="button danger" data-action="delete-deployment-profile" data-profile-id="${attr(existing.id)}">Delete</button>` : ""}<span class="modal-spacer"></span><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="save-deployment-profile" data-profile-id="${attr(existing.id || "")}" ${ssh && !servers.length ? "disabled" : ""}>Save environment</button></footer></section></div>`;
}
if (ui.modal.type === "inventory-review-plan") {
const plan = ui.inventoryReviewPlan;
return `<div class="modal-backdrop" role="presentation"><section class="modal" role="dialog" aria-modal="true" aria-labelledby="inventory-review-title"><header class="modal-header"><h2 id="inventory-review-title">Review inventory decision</h2><button class="icon-button" data-action="close-modal" aria-label="Close inventory review">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero">${icon("shield")}<div><strong>${escapeHtml(plan?.action || "Review")}</strong><span>${escapeHtml(plan?.workloadId || "")} · ${escapeHtml(plan?.classification || "unclassified")}</span></div></div><div class="confirm-grid"><span>Evidence hash</span><strong class="mono">${escapeHtml(plan?.evidenceHash || "")}</strong><span>Configuration change</span><strong>${escapeHtml(plan?.configurationChanges?.join("; ") || "None")}</strong><span>Containers</span><strong>${plan?.containersUnaffected ? "Unaffected" : "Review required"}</strong><span>Recovery</span><strong>${escapeHtml(plan?.recovery || "")}</strong><span>Reason</span><strong>${escapeHtml(plan?.reason || "Not supplied")}</strong></div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="apply-inventory-review">Apply reviewed decision</button></footer></section></div>`;
}
if (ui.modal.type === "deploy-key-lifecycle") {
const lifecycle = ui.deployKeyLifecycle;
const inventory = lifecycle?.inventory;
const rotation = lifecycle?.rotation;
const revocation = lifecycle?.revocation;
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true" aria-labelledby="deploy-key-title"><header class="modal-header"><h2 id="deploy-key-title">Deploy key lifecycle</h2><button class="icon-button" data-action="close-modal" aria-label="Close deploy key lifecycle">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero ${inventory?.ready ? "" : "danger"}">${icon(inventory?.ready ? "shield" : "warning")}<div><strong>${inventory?.ready ? "Repository access is verified" : "Deploy key requires review"}</strong><span>${escapeHtml(inventory?.repository || "")} · ${escapeHtml(inventory?.server?.name || "server")}</span></div></div><div class="confirm-grid"><span>Key ID</span><strong>${escapeHtml(inventory?.configuredKey?.id || "Missing")}</strong><span>Fingerprint</span><strong class="mono">${escapeHtml(inventory?.serverKey?.fingerprint || "Unavailable")}</strong><span>Rights</span><strong>${inventory?.configuredKey?.readOnly ? "Repository-scoped · read-only" : "Unverified or writable"}</strong><span>Stale</span><strong>${inventory?.stale ? "Yes · blocked" : "No"}</strong><span>Shared references</span><strong>${inventory?.shared?.length || 0}</strong><span>Orphaned Gitea keys</span><strong>${inventory?.orphaned?.length || 0}</strong></div><section class="settings-group" style="margin-top:16px"><h3>Rotation impact</h3><ul>${(rotation?.impact || []).map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul><p>${escapeHtml(rotation?.recovery || "")}</p><small class="mono">Plan ${escapeHtml(rotation?.id || "unavailable")}</small></section><section class="settings-group"><h3>Revocation impact</h3><ul>${(revocation?.impact || []).map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul><p>Containers remain untouched. Server pull changes to monitoring-only until restored.</p><small class="mono">Plan ${escapeHtml(revocation?.id || "unavailable")}</small></section></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button" data-action="restore-deploy-key" data-profile-id="${attr(lifecycle?.profileId || "")}">Restore</button><button class="button danger" data-action="confirm-revoke-deploy-key" data-profile-id="${attr(lifecycle?.profileId || "")}" ${revocation?.id ? "" : "disabled"}>Revoke key</button><button class="button primary" data-action="confirm-rotate-deploy-key" data-profile-id="${attr(lifecycle?.profileId || "")}" ${rotation?.id ? "" : "disabled"}>Rotate safely</button></footer></section></div>`;
}
if (ui.modal.type === "deployment-preflight") {
const profile =
repository?.deploymentProfiles?.find(
(item) => item.id === ui.modal.profileId,
) || selectedProfile(repository);
const report = ui.deploymentPreflight;
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Deployment preflight</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero ${report?.summary?.ready ? "" : "danger"}">${icon(report?.summary?.ready ? "shield" : "error")}<div><strong>${report?.summary?.ready ? "Environment is ready to test" : "Deployment is blocked"}</strong><span>${escapeHtml(repository?.fullName || "")}${escapeHtml(profile?.environment || "")}</span></div></div><div class="preflight-summary"><span class="status-pill ${report?.summary?.ready ? "success" : "danger"}">${report?.summary?.ready ? "Ready" : `${report?.summary?.blocking?.length || 0} blocking`}</span><span>${report?.summary?.counts?.pass || 0} passed · ${report?.summary?.counts?.warning || 0} warnings · ${report?.summary?.counts?.fail || 0} failed</span></div>${renderPreflightChecks(report)}</div><footer class="modal-footer"><button class="button" data-action="close-modal">Close</button>${report?.summary?.ready ? `<button class="button success" data-action="continue-after-preflight" data-profile-id="${attr(profile?.id || "")}">${icon("rocket")}Continue</button>` : `<button class="button" data-action="edit-deployment-profile" data-profile-id="${attr(profile?.id || "")}">Edit environment</button>`}</footer></section></div>`;
}
if (ui.modal.type === "deploy-confirm") {
const profile =
repository?.deploymentProfiles?.find(
(item) => item.id === ui.modal.profileId,
) || selectedProfile(repository);
const targetSha = deploymentTargetSha(repository, profile);
return `<div class="modal-backdrop" role="presentation"><section class="modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Confirm production action</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero">${icon("rocket")}<div><strong>Deploy ${escapeHtml(shortSha(targetSha))}${escapeHtml(profile.environment)}</strong><span>${escapeHtml(repository.fullName)}</span></div></div><div class="confirm-grid"><span>Exact commit</span><strong class="mono">${escapeHtml(targetSha || "Unavailable")}</strong><span>Branch</span><strong>${escapeHtml(profile.branch)}</strong><span>Provider</span><strong>${profile.provider === "ssh-unraid" ? `${deploymentMode(profile) === "server-git" ? "Gitea → Unraid" : "Desktop → Unraid"} · ${escapeHtml(profile.remoteFolder)}` : escapeHtml(profile.workflowFile)}</strong><span>Healthcheck</span><strong>${escapeHtml(profile.healthcheckUrl || "Not configured")}</strong></div>${ui.deploymentPreflight ? `<div class="notice success" style="margin-top:12px">${icon("shield")}Preflight passed with ${ui.deploymentPreflight.summary.counts.warning} warning(s). Backend safety checks run again at dispatch time.</div>` : ""}</div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button success" data-action="confirm-deploy" data-profile-id="${attr(profile.id)}" ${targetSha ? "" : "disabled"}>Deploy exact commit</button></footer></section></div>`;
}
if (ui.modal.type === "rollback-confirm") {
const profile = repository?.deploymentProfiles?.find(
(item) => item.id === ui.modal.profileId,
);
const target = profile?.state?.previousSha;
return `<div class="modal-backdrop" role="presentation"><section class="modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>Confirm rollback</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="confirm-hero danger">${icon("undo")}<div><strong>Rollback ${escapeHtml(profile?.environment || "")} to ${shortSha(target)}</strong><span>The target must still exist on origin/${escapeHtml(profile?.branch || "")}.</span></div></div><div class="confirm-grid"><span>Target commit</span><strong class="mono">${escapeHtml(target || "Unavailable")}</strong><span>Provider</span><strong>${profile?.provider === "ssh-unraid" ? "SSH exact-SHA reset" : escapeHtml(profile?.rollbackWorkflowFile || "Not configured")}</strong><span>Current live</span><strong class="mono">${escapeHtml(profile?.state?.liveSha || "Unknown")}</strong></div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button danger" data-action="confirm-rollback" data-profile-id="${attr(profile?.id || "")}" ${target ? "" : "disabled"}>Rollback exact commit</button></footer></section></div>`;
}
if (ui.modal.type === "server-config") {
const server =
(ui.boot.state.servers || []).find(
(item) => item.id === ui.modal.serverId,
) || {};
const authType = ui.modal.authType || server.authType || "password";
return `<div class="modal-backdrop" role="presentation"><section class="modal wide-modal" role="dialog" aria-modal="true"><header class="modal-header"><h2>${server.id ? "Edit" : "Add"} SSH / Unraid server</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="form-grid"><div class="field"><label>Name</label><input id="server-name" class="input" value="${attr(server.name || "Unraid")}"/></div><div class="field"><label>Host or IP</label><input id="server-host" class="input" value="${attr(server.host || "")}" placeholder="192.168.1.10"/></div><div class="field"><label>SSH port</label><input id="server-port" class="input" type="number" min="1" max="65535" value="${attr(server.port || 22)}"/></div><div class="field"><label>Username</label><input id="server-username" class="input" value="${attr(server.username || "root")}"/></div><div class="field"><label>Authentication</label><select id="server-auth-type" class="select"><option value="privateKey" ${authType === "privateKey" ? "selected" : ""}>Private key · optional</option><option value="password" ${authType === "password" ? "selected" : ""}>Password · no key</option></select></div><div class="field"><label>Appdata base path</label><input id="server-base-path" class="input" value="${attr(server.basePath || "/mnt/user/appdata")}"/></div><div class="field full"><label>Inventory scan roots</label><textarea id="server-scan-roots" class="input" rows="3" placeholder="One absolute server path per line">${escapeHtml((server.scanRoots || [server.basePath || "/mnt/user/appdata"]).join("\n"))}</textarea><small>ForgeFlow scans only these roots and never changes containers during discovery.</small></div><div class="field full"><label>Excluded folder names</label><input id="server-scan-excludes" class="input" value="${attr((server.scanExcludes || ["backups", "archives", "releases", "staging", "testdata"]).join(", "))}"/><small>Comma-separated directory names or safe wildcard patterns.</small></div>${authType === "privateKey" ? `<div class="field full"><label>Private key file</label><div class="input-action"><input id="server-private-key" class="input" value="${attr(server.privateKeyPath || "")}" placeholder="C:\\Users\\Jens\\.ssh\\id_ed25519"/><button class="button" data-action="select-private-key">Browse</button></div></div><div class="field full"><label>Private key passphrase</label><input id="server-passphrase" class="input" type="password" placeholder="${server.hasPassphrase ? "Leave empty to keep stored passphrase" : "Only when the key is encrypted"}"/></div>` : `<div class="field full"><label>SSH password</label><input id="server-password" class="input" type="password" placeholder="${server.hasPassword ? "Leave empty to keep stored password" : "Password"}"/></div>`}<div class="field full"><label>Trusted host fingerprint</label><input id="server-fingerprint" class="input mono" value="${attr(server.hostFingerprint || "")}" readonly placeholder="Filled automatically after Test & trust"/></div></div><div class="notice warning" style="margin-top:12px">${icon("key")}This login secures the desktop → Unraid connection. Server pull separately creates one read-only deploy key per repository and pins the Gitea SSH host key. No reusable Gitea token is stored on Unraid.</div></div><footer class="modal-footer">${server.id ? `<button class="button danger" data-action="delete-server" data-server-id="${attr(server.id)}">Delete</button>` : ""}<span class="modal-spacer"></span><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="save-server" data-server-id="${attr(server.id || "")}">Save server</button></footer></section></div>`;
}
if (ui.modal.type === "hunk-staging") {
const hunks = ui.diffHunks?.hunks || [];
return `<div class="modal-backdrop"><section class="modal wide-modal"><header class="modal-header"><h2>Stage selected hunks</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><p>${escapeHtml(ui.selectedFile)}</p><div class="stack">${hunks.map((hunk) => `<label class="check-field"><input type="checkbox" data-hunk-index="${hunk.index}" checked/><span><strong>${escapeHtml(hunk.heading)}</strong><small>${hunk.additions} additions · ${hunk.deletions} deletions</small></span></label><pre class="log-lines">${escapeHtml(hunk.lines.join("\n"))}</pre>`).join("")}</div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="stage-chosen-hunks">Stage selected hunks</button></footer></section></div>`;
}
if (ui.modal.type === "pull-request") {
return `<div class="modal-backdrop"><section class="modal"><header class="modal-header"><h2>Create Gitea pull request</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body"><div class="form-grid"><div class="field"><label>Source branch</label><input class="input" value="${attr(repository?.localStatus?.branch?.head || "")}" readonly/></div><div class="field"><label>Target branch</label><input id="pr-base" class="input" value="${attr(repository?.defaultBranch || "main")}"/></div><div class="field full"><label>Title</label><input id="pr-title" class="input" value="${attr(ui.modal.title || "")}"/></div><div class="field full"><label>Description</label><textarea id="pr-body" class="textarea">${escapeHtml(ui.modal.body || "")}</textarea></div></div></div><footer class="modal-footer"><button class="button" data-action="close-modal">Cancel</button><button class="button primary" data-action="create-pull-request">Create pull request</button></footer></section></div>`;
}
if (ui.modal.type === "conflict-guide") {
const state = ui.conflictState || {};
return `<div class="modal-backdrop"><section class="modal"><header class="modal-header"><h2>Conflict guide · ${escapeHtml(state.operation || "Git")}</h2><button class="icon-button" data-action="close-modal">${icon("close")}</button></header><div class="modal-body">${state.files?.length ? `<p>Resolve these files, then continue:</p><div class="tool-list">${state.files.map((file) => `<div class="tool-row"><strong>${escapeHtml(file)}</strong><button class="button" data-action="open-conflict-file" data-path="${attr(file)}">Open in editor</button></div>`).join("")}</div>` : '<div class="notice success">All conflicts are marked resolved. You can continue the Git operation.</div>'}</div><footer class="modal-footer"><button class="button danger" data-action="abort-git-operation">Abort operation</button><span class="modal-spacer"></span><button class="button" data-action="close-modal">Close</button><button class="button primary" data-action="continue-git-operation" ${state.canContinue ? "" : "disabled"}>Continue</button></footer></section></div>`;
}
if (ui.modal.type === "command-palette") return renderCommandPalette();
return "";
}
function paletteCommands() {
const repository = selectedRepository();
return [
{
id: "overview",
label: "Go to release overview",
detail: "Workspace",
icon: "overview",
enabled: true,
},
{
id: "refresh",
label: "Refresh all repositories",
detail: "Local and Gitea",
icon: "refresh",
enabled: true,
},
{
id: "deployments",
label: "Open deployments",
detail: "Release history",
icon: "deploy",
enabled: true,
},
{
id: "diagnostics",
label: "Open diagnostics",
detail: "Logs, preflight and support bundle",
icon: "shield",
enabled: true,
},
{
id: "settings",
label: "Open settings",
detail: "Connections and awareness",
icon: "settings",
enabled: true,
},
{
id: "open-folder",
label: "Open selected project folder",
detail: repository?.name || "No repository selected",
icon: "folder",
enabled: Boolean(repository?.localPath),
},
{
id: "git-tools",
label: "Open branch and stash tools",
detail: repository?.name || "No repository selected",
icon: "branch",
enabled: Boolean(repository?.localPath),
},
{
id: "deploy-selected",
label: "Deploy selected repository",
detail: canDeploy(repository)
? `${repository.name} ${shortSha(deploymentTargetSha(repository))}`
: "Not ready",
icon: "rocket",
enabled: canDeploy(repository),
},
];
}
function renderCommandPalette() {
const query = ui.paletteQuery.toLowerCase();
const commands = paletteCommands().filter(
(command) =>
!query ||
`${command.label} ${command.detail}`.toLowerCase().includes(query),
);
return `<div class="modal-backdrop palette-backdrop"><section class="command-palette"><div class="palette-search">${icon("search")}<input id="palette-input" value="${attr(ui.paletteQuery)}" placeholder="Type a command…" autofocus/></div><div class="palette-list">${commands.map((command) => `<button class="palette-row" data-action="run-command" data-command="${command.id}" ${command.enabled ? "" : "disabled"}>${icon(command.icon)}<span><strong>${escapeHtml(command.label)}</strong><small>${escapeHtml(command.detail)}</small></span><kbd>↵</kbd></button>`).join("")}</div><div class="palette-footer">Esc to close · Ctrl K anywhere</div></section></div>`;
}
function enhanceRenderedUi() {
const repository = selectedRepository();
if (ui.modal?.type === "deployment-config") {
const profile =
repository?.deploymentProfiles?.find(
(item) => item.id === ui.modal.profileId,
) || {};
const policy = profile.deploymentPolicy || {};
document
.querySelector(".modal-body .form-grid")
?.insertAdjacentHTML(
"beforeend",
`<div class="field full"><h3>Deployment policy</h3></div><label class="check-field"><input id="profile-policy-frozen" type="checkbox" ${policy.frozen ? "checked" : ""}/><span>Freeze deployments</span></label><label class="check-field"><input id="profile-policy-note" type="checkbox" ${policy.requireNote ? "checked" : ""}/><span>Require release note</span></label><div class="field full"><label>Freeze reason</label><input id="profile-policy-freeze-reason" class="input" value="${attr(policy.freezeReason || "")}"/></div><div class="field full"><label>Maintenance windows</label><input id="profile-policy-windows" class="input" value="${attr((policy.maintenanceWindows || []).map((window) => `${window.days.join(",")}:${window.start}-${window.end}`).join(" | "))}" placeholder="1,2,3,4,5:09:00-17:00"/><small>Day 0 is Sunday. Separate windows with |.</small></div>`,
);
}
if (ui.modal?.type === "workload-link") {
const workload = (ui.serverDiscovery || []).find((server) => server.serverId === ui.modal.serverId)?.workloads?.find((item) => item.workloadId === ui.modal.workloadId);
const type = workload?.classification?.type || "ambiguous";
const recommended = type === "duplicate" ? "select-authoritative" : type === "stale-link" ? "archive-link" : type === "historical-compose" ? "mark-historical" : type === "orphan-container" ? "monitor-only" : "manual-link";
const actions = [["manual-link", "Confirm selected repository match"], ["select-authoritative", "Select as authoritative instance"], ["mark-historical", "Mark historical definition"], ["archive-link", "Archive stale link"], ["monitor-only", "Keep for monitoring only"], ["manual-exclude", "Exclude this workload"], ["ignore", "Ignore with reason"]];
const options = actions.map(([value, label]) => `<option value="${value}" ${value === recommended ? "selected" : ""}>${escapeHtml(label)}${value === recommended ? " · recommended" : ""}</option>`).join("");
document.querySelector(".modal-body")?.insertAdjacentHTML("beforeend", `<section class="settings-group" style="margin-top:14px"><h3>Classify without touching containers</h3><div class="notice" style="margin-bottom:10px">${icon("info")}<div><strong>${escapeHtml(type)}</strong><p>${escapeHtml(workload?.classification?.reason || "ForgeFlow needs an explicit decision for this workload.")}</p></div></div><div class="form-grid"><div class="field"><label for="inventory-review-action">Review decision</label><select id="inventory-review-action" class="select">${options}</select></div><div class="field"><label for="inventory-review-reason">Reason</label><input id="inventory-review-reason" class="input" placeholder="Why is this the correct classification?"/></div></div><button class="button" style="margin-top:10px" data-action="preview-inventory-review" data-server-id="${attr(ui.modal.serverId)}" data-workload-id="${attr(ui.modal.workloadId)}">${icon("shield")}Preview classification impact</button><p class="meta">The decision is tied to current evidence and becomes stale automatically when server truth changes.</p></section>`);
}
if (ui.modal?.type === "deploy-confirm") {
const profile = repository?.deploymentProfiles?.find(
(item) => item.id === ui.modal.profileId,
);
document
.querySelector(".modal-body")
?.insertAdjacentHTML(
"beforeend",
`<div class="form-grid" style="margin-top:14px"><div class="field full"><label>Release note ${profile?.deploymentPolicy?.requireNote ? "(required)" : "(optional)"}</label><textarea id="deployment-note" class="textarea" placeholder="What is being released and why?"></textarea></div><label class="check-field"><input id="deployment-override" type="checkbox"/><span>Emergency policy override</span></label><div class="field"><label>Override reason</label><input id="deployment-override-reason" class="input" placeholder="Required when overriding"/></div></div>`,
);
}
if (ui.currentView === "diagnostics") {
const container = document.querySelector(".diagnostics-page");
container?.insertAdjacentHTML(
"beforeend",
`<section class="section-block"><div class="section-heading"><div><h2>Operational audit log</h2><span class="meta">Append-only release, pull-request and recovery events</span></div><div class="stack horizontal compact"><button class="button" data-action="load-audit-log">Refresh</button><button class="button" data-action="export-audit-json">Export JSON</button><button class="button" data-action="export-audit-csv">Export CSV</button></div></div><div class="panel">${ui.auditEvents.length ? `<table class="data-table"><thead><tr><th>Time</th><th>Event</th><th>Repository</th><th>Result</th></tr></thead><tbody>${ui.auditEvents.map((item) => `<tr><td>${formatDate(item.timestamp)}</td><td>${escapeHtml(item.event)}</td><td>${escapeHtml(item.details?.repository || "—")}</td><td>${escapeHtml(item.details?.result || item.details?.note || "—")}</td></tr>`).join("")}</tbody></table>` : '<div class="empty-state compact"><p>Load the operational audit log.</p></div>'}</div></section>`,
);
}
document.querySelectorAll("button.icon-button:not([aria-label])").forEach((button) => {
const action = String(button.title || button.dataset.action || "Action").replaceAll("-", " ");
button.setAttribute("aria-label", action.charAt(0).toUpperCase() + action.slice(1));
});
document.querySelectorAll(".field > label:not([for])").forEach((label, index) => {
const control = label.parentElement?.querySelector("input, select, textarea");
if (!control) return;
if (!control.id) control.id = `forgeflow-field-${index}`;
label.htmlFor = control.id;
});
}
function render() {
if (!ui.boot) return;
const repository = selectedRepository();
const main =
ui.currentView === "overview"
? renderOverview()
: ui.currentView === "deployments"
? renderDeployments()
: ui.currentView === "settings"
? renderSettings()
: ui.currentView === "diagnostics"
? renderDiagnostics()
: ui.currentView === "deployment-run"
? renderPipelineView()
: repository
? renderRepositoryWorkspace(repository)
: renderOverview();
const withPanel = ui.currentView === "repository" && repository;
app.innerHTML = `<div class="app-shell">${renderTitlebar()}<div class="app-body">${renderSidebar()}<main class="workspace ${withPanel ? "with-panel" : ""}"><section class="main-canvas ${withPanel ? "repository-canvas" : ""}">${main}</section>${withPanel ? renderActionPanel(repository) : ""}${ui.loading ? `<div class="loading-overlay"><div class="boot-screen"><div class="spinner"></div><strong>${escapeHtml(ui.loadingMessage || "Working…")}</strong></div></div>` : ""}</main></div>${renderStatusbar()}</div>${ui.boot.state.setupComplete ? "" : renderSetup()}${renderModal()}`;
enhanceRenderedUi();
if (ui.modal?.type === "command-palette")
requestAnimationFrame(() =>
document.querySelector("#palette-input")?.focus(),
);
}
+169
View File
@@ -0,0 +1,169 @@
app.addEventListener("click", async (event) => {
const target = event.target.closest("[data-action]");
if (!target) return;
const action = target.dataset.action;
let repository = selectedRepository();
if (target.dataset.repositoryId) {
const actionRepository = ui.repositories.find(
(item) => String(item.id) === String(target.dataset.repositoryId),
);
if (actionRepository) repository = actionRepository;
}
const handlers = [handleShellActions, handleInventoryActions, handleDeploymentProfileActions, handleDeploymentOperationActions, handleSetupAndSettingsActions, handleRecoveryActions, handleCommandActions];
for (const handler of handlers) if (await handler(event, target, action, repository)) return;
});
app.addEventListener("input", (event) => {
if (event.target.id === "global-search") {
ui.search = event.target.value;
render();
document.querySelector("#global-search")?.focus();
} else if (event.target.id === "repo-filter") {
ui.repoSearch = event.target.value;
render();
document.querySelector("#repo-filter")?.focus();
} else if (event.target.id === "commit-message") {
ui.commitMessage = event.target.value;
const position = event.target.selectionStart;
render();
const next = document.querySelector("#commit-message");
if (next) {
next.focus();
next.setSelectionRange(position, position);
}
} else if (event.target.id === "setup-url")
ui.setupDraft.baseUrl = event.target.value;
else if (event.target.id === "setup-token")
ui.setupDraft.token = event.target.value;
else if (event.target.id === "palette-input") {
ui.paletteQuery = event.target.value;
render();
}
});
app.addEventListener("change", async (event) => {
if (event.target.matches("[data-file-select]")) {
const filePath = event.target.dataset.fileSelect;
if (event.target.checked) ui.selectedFiles.add(filePath);
else ui.selectedFiles.delete(filePath);
render();
} else if (event.target.id === "appearance-select") {
ui.boot.state = await window.forgeflow.setAppearance(event.target.value);
applyTheme(event.target.value);
render();
} else if (event.target.id === "action-profile-select") {
ui.selectedProfileId = event.target.value;
render();
} else if (event.target.id === "profile-provider") {
ui.modal.provider = event.target.value;
render();
} else if (event.target.id === "server-auth-type") {
ui.modal.authType = event.target.value;
render();
}
});
document.addEventListener("keydown", (event) => {
if (
(event.key === "Enter" || event.key === " ") &&
event.target.matches('.file-row[data-action="select-file"]')
) {
event.preventDefault();
event.target.click();
return;
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
ui.paletteQuery = "";
ui.modal = { type: "command-palette" };
render();
return;
}
if (
(event.ctrlKey || event.metaKey) &&
event.key === "Enter" &&
ui.currentView === "repository"
) {
const button = document.querySelector(
'[data-action="commit-push"]:not(:disabled)',
);
if (button) button.click();
}
if (event.key === "F5") {
event.preventDefault();
refreshRepositories(true);
}
if (event.key === "Escape" && ui.modal) {
ui.modal = null;
render();
}
});
document.addEventListener("pointermove", (event) => {
const illustration = event.target.closest?.("[data-project-illustration]");
if (illustration) {
const bounds = illustration.getBoundingClientRect();
illustration.style.setProperty(
"--tilt-x",
`${((event.clientY - bounds.top) / bounds.height - 0.5) * -7}deg`,
);
illustration.style.setProperty(
"--tilt-y",
`${((event.clientX - bounds.left) / bounds.width - 0.5) * 9}deg`,
);
}
const diffPanel = event.target.closest?.(".diff-panel");
const atmosphere = diffPanel?.querySelector("[data-diff-atmosphere]");
if (atmosphere) {
const bounds = diffPanel.getBoundingClientRect();
atmosphere.style.setProperty(
"--diff-tilt-x",
`${((event.clientY - bounds.top) / bounds.height - 0.5) * -3}deg`,
);
atmosphere.style.setProperty(
"--diff-tilt-y",
`${((event.clientX - bounds.left) / bounds.width - 0.5) * 4}deg`,
);
}
});
document.addEventListener("pointerout", (event) => {
const illustration = event.target.closest?.("[data-project-illustration]");
if (illustration && !illustration.contains(event.relatedTarget)) {
illustration.style.removeProperty("--tilt-x");
illustration.style.removeProperty("--tilt-y");
}
const diffPanel = event.target.closest?.(".diff-panel");
if (diffPanel && !diffPanel.contains(event.relatedTarget)) {
const atmosphere = diffPanel.querySelector("[data-diff-atmosphere]");
atmosphere?.style.removeProperty("--diff-tilt-x");
atmosphere?.style.removeProperty("--diff-tilt-y");
}
});
window.addEventListener("error", (event) => {
window.forgeflow
.reportRendererEvent?.("error", "uncaught-error", {
message: event.message,
filename: event.filename,
line: event.lineno,
column: event.colno,
stack: event.error?.stack,
})
.catch(() => {});
});
window.addEventListener("unhandledrejection", (event) => {
const reason = event.reason;
window.forgeflow
.reportRendererEvent?.("error", "unhandled-rejection", {
message: reason?.message || String(reason || "Unknown rejection"),
stack: reason?.stack,
})
.catch(() => {});
});
bootstrap();
+13
View File
@@ -17,7 +17,20 @@
</div>
</div>
<div id="toast-root" class="toast-root" aria-live="assertive"></div>
<script src="mock-repository-bridge.js"></script>
<script src="mock-deployment-bridge.js"></script>
<script src="mock-bridge.js"></script>
<script defer src="app.js"></script>
<script defer src="views.js"></script>
<script defer src="dialogs.js"></script>
<script defer src="operations.js"></script>
<script defer src="actions/shell.js"></script>
<script defer src="actions/inventory.js"></script>
<script defer src="actions/deployment-profile.js"></script>
<script defer src="actions/deployment-operation.js"></script>
<script defer src="actions/setup-and-settings.js"></script>
<script defer src="actions/recovery.js"></script>
<script defer src="actions/command.js"></script>
<script defer src="events.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+660
View File
@@ -0,0 +1,660 @@
function createMockDeploymentBridge(context) {
const { wait, clone, iso, storage, repositoryListeners, operationListeners, updateListeners, emitRepositories, emitOperations, randomSha, now, profile, state, repositories, recompute, snapshot, commitHistory, advanceOperation, syncState, diffs, findRepo, findProfileRepo, branchesByRepo, stashesByRepo, updateOperation } = context;
return {
async saveDeploymentProfile(fullName, input) {
const repo = repositories.find((item) => item.fullName === fullName);
const existing = repo.deploymentProfiles.find(
(item) => item.id === input.id,
);
const saved = {
...(existing ||
profile(
input.id || `profile-${Date.now()}`,
input.name || input.environment,
input.environment || "production",
)),
...input,
id: input.id || `profile-${Date.now()}`,
provider: input.provider || existing?.provider || "gitea-actions",
inputs: existing?.inputs || {},
state: existing?.state || {
liveSha: null,
previousSha: null,
healthy: null,
healthConfigured: Boolean(input.healthcheckUrl),
statusConfigured: Boolean(input.statusUrl),
checkedAt: null,
},
};
repo.deploymentProfiles = [
...repo.deploymentProfiles.filter((item) => item.id !== saved.id),
saved,
];
snapshot();
return { profile: clone(saved), state: clone(state) };
},
async deleteDeploymentProfile(fullName, profileId) {
const repo = repositories.find((item) => item.fullName === fullName);
repo.deploymentProfiles = repo.deploymentProfiles.filter(
(item) => item.id !== profileId,
);
snapshot();
return { profiles: clone(repo.deploymentProfiles), state: clone(state) };
},
async deploymentPreflight(repository, profileId) {
await wait(280);
const profile = repository.deploymentProfiles.find(
(item) => item.id === profileId,
);
const status = repository.localStatus;
const checks = [
{
id: "repository.linked",
label: "Local repository link",
status: repository.localPath ? "pass" : "fail",
detail: repository.localPath || "No local folder linked.",
required: true,
},
{
id: "git.branch",
label: "Allowed branch",
status: status?.branch.head === profile?.branch ? "pass" : "fail",
detail: `Current: ${status?.branch.head || "unknown"}; required: ${profile?.branch || "unknown"}.`,
required: true,
},
{
id: "git.clean",
label: "Clean working tree",
status: status?.clean ? "pass" : "fail",
detail: status?.clean
? "No uncommitted changes."
: `${status?.counts.changed || 0} changed file(s).`,
required: true,
},
{
id: "git.sync",
label: "Local and Gitea synchronized",
status:
!status?.branch.ahead && !status?.branch.behind ? "pass" : "fail",
detail: `${status?.branch.ahead || 0} ahead, ${status?.branch.behind || 0} behind.`,
required: true,
},
{
id: "workflow.deploy.remote",
label: "Deploy workflow on Gitea branch",
status: "pass",
detail: `${profile?.workflowFile || "deploy.yml"} exists on ${profile?.branch || "main"}.`,
required: true,
},
{
id: "gitea.actions",
label: "Gitea Actions API",
status: "pass",
detail: "The Actions runs endpoint is accessible.",
required: true,
},
{
id: "server.status",
label: "Server version endpoint",
status: profile?.statusUrl ? "pass" : "warning",
detail: profile?.statusUrl
? `Endpoint reachable; live ${profile.state?.liveSha?.slice(0, 7) || "unknown"}.`
: "No status URL configured.",
required: false,
},
{
id: "server.health",
label: "Application healthcheck",
status: profile?.healthcheckUrl ? "pass" : "warning",
detail: profile?.healthcheckUrl
? "HTTP 200 in 42 ms."
: "No healthcheck URL configured.",
required: false,
},
];
const blocking = checks
.filter((i) => i.required && i.status === "fail")
.map((i) => i.id);
return {
kind: "deployment",
repository: repository.fullName,
profileId,
startedAt: iso(-100),
completedAt: iso(),
checks,
summary: {
counts: {
pass: checks.filter((i) => i.status === "pass").length,
warning: checks.filter((i) => i.status === "warning").length,
fail: checks.filter((i) => i.status === "fail").length,
skipped: 0,
},
blocking,
ready: blocking.length === 0,
},
head: status?.head || null,
};
},
async deploy(repository, profileId, sha) {
await wait(320);
const selected = repository.deploymentProfiles.find(
(item) => item.id === profileId,
);
const operation = {
id: `deploy-${Date.now()}`,
type: "deployment",
action: "deploy",
status: "queued",
repository: repository.fullName,
profileId,
profileName: selected.name,
environment: selected.environment,
workflowFile: selected.workflowFile,
branch: selected.branch,
sha,
shortSha: sha.slice(0, 7),
dispatchedAt: iso(),
createdAt: iso(),
updatedAt: iso(),
demoPolls: 0,
stages: [
{ id: "requested", label: "Requested", status: "complete" },
{ id: "verified", label: "Verified", status: "complete" },
{ id: "queued", label: "Workflow queued", status: "active" },
{ id: "runner", label: "Runner execution", status: "pending" },
{ id: "healthcheck", label: "Healthcheck", status: "pending" },
{ id: "complete", label: "Complete", status: "pending" },
],
logs: [
`[info] Verified clean ${selected.branch} at ${sha}`,
`[ok] Gitea accepted ${selected.workflowFile}.`,
],
};
return updateOperation(operation);
},
async rollback(repository, profileId, targetSha) {
await wait(320);
const selected = repository.deploymentProfiles.find(
(item) => item.id === profileId,
);
const operation = {
id: `rollback-${Date.now()}`,
type: "deployment",
action: "rollback",
status: "queued",
repository: repository.fullName,
profileId,
profileName: selected.name,
environment: selected.environment,
workflowFile: selected.rollbackWorkflowFile,
branch: selected.branch,
sha: targetSha,
shortSha: targetSha.slice(0, 7),
dispatchedAt: iso(),
createdAt: iso(),
updatedAt: iso(),
demoPolls: 0,
stages: [
{ id: "requested", label: "Requested", status: "complete" },
{ id: "verified", label: "Verified", status: "complete" },
{ id: "queued", label: "Workflow queued", status: "active" },
{ id: "runner", label: "Runner execution", status: "pending" },
{ id: "healthcheck", label: "Healthcheck", status: "pending" },
{ id: "complete", label: "Complete", status: "pending" },
],
logs: [
`[warning] Rollback target verified: ${targetSha}`,
`[ok] Gitea accepted ${selected.rollbackWorkflowFile}.`,
],
};
return updateOperation(operation);
},
async healthcheck() {
await wait(160);
return { configured: true, healthy: true, status: 200, latencyMs: 42 };
},
async refreshProfileState(fullName, profileId) {
await wait(240);
const repo =
repositories.find((item) => item.fullName === fullName) ||
findProfileRepo(profileId);
const target = repo?.deploymentProfiles.find(
(item) => item.id === profileId,
);
if (!target) throw new Error("Deployment profile not found.");
target.state = {
...target.state,
checkedAt: iso(),
healthy: target.state.healthy !== false,
healthConfigured: Boolean(target.healthcheckUrl),
statusConfigured: Boolean(target.statusUrl),
};
syncState();
return clone(target.state);
},
async discoverServerDeployments() {
await wait(80);
return [
{
serverId: "server-unraid",
serverName: "Unraid",
detected: 2,
adopted: 0,
verified: 1,
linked: 1,
unmatched: 0,
needsReview: 1,
running: 2,
stopped: 0,
capabilities: {
docker: true,
dockerReady: true,
compose: true,
git: false,
tar: true,
checksum: true,
},
warnings: [],
workloads: [
{
workloadId: "workload-demo-linked",
displayName: "Portfolio",
status: "linked",
runtime: { running: true, health: "healthy" },
compose: {
project: "portfolio",
workingDir: "/mnt/user/appdata/portfolio",
configFiles: ["/mnt/user/appdata/portfolio/docker-compose.yml"],
services: ["web"],
},
containers: [{ name: "Portfolio", running: true }],
candidates: [],
link: {
profileId: "profile-portfolio",
repositoryFullName: "jens/portfolio",
source: "manual",
},
},
{
workloadId: "workload-demo-review",
displayName: "OmniRoute",
status: "suggested",
runtime: { running: true, health: "unverified" },
compose: {
project: "omniroute",
workingDir: "/mnt/user/appdata/OmniRoute",
configFiles: ["/mnt/user/appdata/OmniRoute/docker-compose.yml"],
services: ["omniroute"],
},
containers: [{ name: "omniroute", running: true }],
remoteFolderCandidate: "OmniRoute",
candidates: repositories.slice(0, 1).map((repository) => ({
repositoryFullName: repository.fullName,
repositoryName: repository.name,
score: 55,
exact: false,
reasons: ["container and repository names are similar"],
})),
},
],
},
];
},
async planServerReconciliation(serverId) {
await wait(90);
const id = "a".repeat(64);
return {
inventory: (await this.discoverServerDeployments()).find((item) => item.serverId === serverId),
plan: {
id,
serverId,
summary: { additions: 0, updates: 1, stale: 0, conflicts: 1 },
additions: [],
updates: [{ workloadId: "workload-demo-linked", profileId: "profile-portfolio", repositoryFullName: "jens/portfolio", impact: "Refresh detected Compose identity and observed deployment state" }],
stale: [],
conflicts: [{ workloadId: "workload-demo-review", displayName: "OmniRoute", status: "suggested", candidates: [{ repositoryFullName: repositories[0].fullName, score: 55, exact: false }] }],
},
};
},
async applyServerReconciliation(serverId, planId) {
await wait(120);
if (serverId !== "server-unraid" || planId !== "a".repeat(64)) throw new Error("The reconciliation plan is stale.");
return { adopted: 0, refreshed: 1, retired: 0, state: clone(state) };
},
async planInventoryReview(serverId, workloadId, action, reason = "", repositoryFullName = null) {
if (["ignore", "manual-exclude", "exclude-scan-root"].includes(action) && reason.length < 5) throw new Error("A meaningful review reason is required.");
return { id: "b".repeat(64), serverId, workloadId, action, reason, repositoryFullName, evidenceHash: "c".repeat(64), classification: "ambiguous", containersUnaffected: true, configurationChanges: [`Persist review decision ${action}`], recovery: "Remove the decision or rescan after evidence changes." };
},
async applyInventoryReview(serverId, workloadId, action, reason, repositoryFullName, planId) {
if (planId !== "b".repeat(64)) throw new Error("The inventory review plan is stale.");
const inventory = (await this.discoverServerDeployments()).find((item) => item.serverId === serverId);
const workload = inventory.workloads.find((item) => item.workloadId === workloadId);
if (workload) workload.reviewDecision = { action, reason, repositoryFullName, evidenceHash: "c".repeat(64) };
return { decision: workload?.reviewDecision, inventory, state: clone(state) };
},
async linkServerWorkload(repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "") {
await wait(120);
const repo = repositories.find((item) => item.fullName === repository.fullName);
if (!repo) throw new Error("Repository not found.");
const id = `profile-${workloadId}`;
const saved = {
id,
name: `Unraid · ${remoteFolder || repo.name}`,
environment: "production",
provider: "ssh-unraid",
branch: repo.defaultBranch || "main",
serverId,
remoteFolder: remoteFolder || repo.name,
deploymentMode,
composeFile: "docker-compose.yml",
composeFiles: ["docker-compose.yml"],
composeProject: String(remoteFolder || repo.name).toLowerCase(),
composeService: String(remoteFolder || repo.name).toLowerCase(),
composeServices: [String(remoteFolder || repo.name).toLowerCase()],
containerName: remoteFolder || repo.name,
preservePaths: [".env", "appdata", "data", "logs", "config"],
generatedCompose: false,
adoptedFromServer: true,
serverSourceOfTruth: true,
manageDockerMan: false,
forceRecreate: false,
removeOrphans: false,
workloadIdentity: { workloadId, linkSource: "manual", linkedAt: iso() },
confirmationRequired: true,
state: {
liveSha: null,
healthy: null,
containerRunning: true,
runtimeVerification: "running-unverified",
checkedAt: iso(),
},
};
repo.deploymentProfiles = [
...repo.deploymentProfiles.filter((item) => item.id !== id),
saved,
];
syncState();
return { profile: clone(saved), state: clone(state) };
},
async configureServerGitAccess(repository, profileId) {
const repo = repositories.find((item) => item.fullName === repository.fullName);
const target = repo?.deploymentProfiles.find((item) => item.id === profileId);
if (!target) throw new Error("Deployment profile not found.");
target.deploymentMode = "server-git";
target.serverGitAccess = { configured: true, keyFingerprint: "SHA256:demo", hostFingerprint: "SHA256:gitea", configuredAt: iso() };
syncState();
return { profile: clone(target), created: true, remoteSha: target.state?.giteaSha || repo.localStatus?.head };
},
async verifyServerGitProfile(repository, profileId) {
const repo = repositories.find((item) => item.fullName === repository.fullName);
const target = repo?.deploymentProfiles.find((item) => item.id === profileId);
if (!target) throw new Error("Deployment profile not found.");
const branchSha = target.state?.giteaSha || repo.localStatus?.head || null;
const liveSha = target.state?.liveSha || null;
return {
readiness: branchSha && liveSha === branchSha ? "Ready" : "Commit mismatch",
ready: true,
checkedAt: iso(),
repository: repo.fullName,
profileId,
branchSha,
liveSha,
checks: [
{ id: "remote-branch", label: "Gitea branch", status: "pass", detail: "Exact branch resolved." },
{ id: "deploy-key-scope", label: "Repository deploy key", status: "pass", detail: "Repository-scoped and read-only." },
{ id: "server-git-access", label: "Unraid to Gitea", status: "pass", detail: "Pinned SSH access verified." },
],
};
},
async deployKeyInventory(repository, profileId) {
const repo = repositories.find((item) => item.fullName === repository.fullName);
const profile = repo?.deploymentProfiles.find((item) => item.id === profileId);
return { repository: repo.fullName, profileId, server: { id: profile.serverId, name: "Unraid" }, configuredKey: { id: profile.serverGitAccess?.deployKeyId || 17, readOnly: true }, serverKey: { privateKeyPresent: true, fingerprint: profile.serverGitAccess?.keyFingerprint || "SHA256:demo" }, stale: false, orphaned: [], shared: [], conflicts: [], ready: true, checkedAt: iso() };
},
async planDeployKeyRotation(repository, profileId) {
const evidence = await this.deployKeyInventory(repository, profileId);
return { id: `rotation-${profileId}`, operation: "rotate-deploy-key", impact: ["Generate a new server-side key", "Verify read-only access", "Switch atomically", "Revoke the previous key"], recovery: "Previous access remains recoverable until verification succeeds.", evidence };
},
async applyDeployKeyRotation(repository, profileId) {
const repo = repositories.find((item) => item.fullName === repository.fullName); const profile = repo.deploymentProfiles.find((item) => item.id === profileId);
profile.serverGitAccess = { ...profile.serverGitAccess, configured: true, deployKeyId: 18, keyFingerprint: "SHA256:rotated", rotatedAt: iso() }; syncState(); return { profile: clone(profile), state: clone(state) };
},
async planDeployKeyRevocation(repository, profileId) {
const evidence = await this.deployKeyInventory(repository, profileId);
return { id: `revocation-${profileId}`, operation: "revoke-deploy-key", impact: ["Remove the repository key", "Disable server pull", "Preserve recovery material"], containersUnaffected: true, evidence };
},
async applyDeployKeyRevocation(repository, profileId) {
const repo = repositories.find((item) => item.fullName === repository.fullName); const profile = repo.deploymentProfiles.find((item) => item.id === profileId);
profile.deploymentMode = "monitor-only"; profile.serverGitAccess = { ...profile.serverGitAccess, configured: false, revokedAt: iso(), recoveryAvailable: true }; syncState(); return { profile: clone(profile), state: clone(state) };
},
async restoreDeployKey(repository, profileId) {
const repo = repositories.find((item) => item.fullName === repository.fullName); const profile = repo.deploymentProfiles.find((item) => item.id === profileId);
profile.deploymentMode = "server-git"; profile.serverGitAccess = { ...profile.serverGitAccess, configured: true, deployKeyId: 19, keyFingerprint: "SHA256:restored", restoredAt: iso() }; syncState(); return { profile: clone(profile), state: clone(state), proof: { ready: true } };
},
async refreshOperations(operationId = null) {
await wait(300);
if (operationId) {
const operation = state.operations.find(
(item) => item.id === operationId,
);
if (!operation) throw new Error("Operation not found.");
return updateOperation(advanceOperation(operation));
}
const active = state.operations
.filter(
(item) =>
!["success", "failed", "cancelled", "rolled-back"].includes(
item.status,
),
)
.map(advanceOperation);
if (active.length) emitOperations(active);
state.operations = state.operations.map(
(item) => active.find((entry) => entry.id === item.id) || item,
);
return clone(active);
},
async getOperation(operationId) {
return clone(
state.operations.find((item) => item.id === operationId) || null,
);
},
async gitValidatorScan(fullName) {
await wait(260);
return {
repository: fullName,
checkedAt: iso(),
score: 78,
grade: "Good",
summary: { passed: 7, warnings: 3, errors: 0, repairable: 2 },
checks: [
{
id: "origin",
category: "Repository identity",
title: "Origin matches Gitea",
status: "pass",
detail: "The local origin resolves to this Gitea repository.",
weight: 15,
},
{
id: "default-branch-protection",
category: "Gitea governance",
title: "Default branch protection",
status: "warning",
detail: "main accepts unprotected direct changes.",
weight: 18,
fixAction: "protect-default-branch",
safe: false,
confirmation:
"Protect main on Gitea and block direct and force pushes?",
},
{
id: "force-push",
category: "Gitea governance",
title: "Force-push protection",
status: "pass",
detail: "Force pushes are blocked.",
weight: 8,
},
{
id: "upstream",
category: "Branch hygiene",
title: "Current branch has an upstream",
status: "pass",
detail: "main tracks origin/main.",
weight: 8,
},
{
id: "working-tree",
category: "Branch hygiene",
title: "Working tree is intentional",
status: "warning",
detail: "3 changed files require review, commit or stash.",
weight: 5,
},
{
id: "identity",
category: "Commit integrity",
title: "Repository author identity",
status: "pass",
detail: "Jens <jens@example.test>",
weight: 7,
},
{
id: "local-safety",
category: "Local configuration",
title: "Safe synchronization defaults",
status: "warning",
detail: "Recommended repository-local safeguards are incomplete.",
weight: 10,
fixAction: "configure-local-safety",
safe: true,
},
{
id: "readme",
category: "Repository documentation",
title: "README is versioned",
status: "pass",
detail: "Repository documentation is tracked.",
weight: 7,
},
{
id: "gitignore",
category: "Repository hygiene",
title: ".gitignore is versioned",
status: "pass",
detail: "Generated files are excluded centrally.",
weight: 8,
},
{
id: "tracked-secrets",
category: "Security",
title: "No secret-shaped files are tracked",
status: "pass",
detail:
"No tracked environment, key or credential filenames detected.",
weight: 22,
},
{
id: "large-files",
category: "Repository performance",
title: "No oversized tracked files",
status: "pass",
detail: "No tracked files above 10 MB were found.",
weight: 7,
},
],
};
},
async gitValidatorRepair() {
await wait(180);
return { repaired: true };
},
async diagnosticsStatus() {
return {
enabled: state.preferences.diagnosticsEnabled !== false,
level: state.preferences.diagnosticLevel,
retentionDays: state.preferences.logRetentionDays,
maxFileMb: state.preferences.maxLogFileMb,
directory: "<HOME>/AppData/Roaming/ForgeFlow/diagnostics",
fileCount: 2,
totalBytes: 18432,
totalSize: "18.0 KB",
latestAt: iso(-2000),
lastWriteError: null,
};
},
async exportConfigurationBackup() {
return {
filePath: "C:\\Downloads\\ForgeFlow-Configuration-demo.ffbackup",
};
},
async importConfigurationBackup() {
return { state: clone(state), exportedAt: iso(-86400000) };
},
async listAuditEvents() {
return [
{
id: "audit-1",
timestamp: iso(-60000),
event: "deployment.completed",
details: { repository: "Jens/ForgeFlow", result: "success" },
},
];
},
async exportAuditLog() {
return { filePath: "C:\\Downloads\\ForgeFlow-Audit-demo.json", count: 1 };
},
async clearDiagnostics() {
return {
enabled: true,
level: state.preferences.diagnosticLevel,
retentionDays: state.preferences.logRetentionDays,
maxFileMb: state.preferences.maxLogFileMb,
directory: "<HOME>/AppData/Roaming/ForgeFlow/diagnostics",
fileCount: 1,
totalBytes: 256,
totalSize: "256 B",
latestAt: iso(),
lastWriteError: null,
};
},
async openDiagnosticsFolder() {
return true;
},
async exportDiagnostics(privacyMode = "standard") {
await wait(500);
return {
path: `C:\Users\Jens\Downloads\ForgeFlow-Diagnostics-demo.zip`,
bytes: 38221,
size: "37.3 KB",
sha256: "b".repeat(64),
privacyMode,
generatedAt: iso(),
};
},
async showDiagnosticBundle() {
return true;
},
async reportRendererEvent() {
return true;
},
onRepositoriesChanged(listener) {
repositoryListeners.add(listener);
return () => repositoryListeners.delete(listener);
},
onOperationsChanged(listener) {
operationListeners.add(listener);
return () => operationListeners.delete(listener);
},
onUpdatesChanged(listener) {
updateListeners.add(listener);
return () => updateListeners.delete(listener);
},
async reset() {
state.setupComplete = false;
storage.set("forgeflow-demo-setup", "false");
return clone(state);
},
};
}
+635
View File
@@ -0,0 +1,635 @@
function createMockRepositoryBridge(context) {
const { wait, clone, iso, storage, repositoryListeners, operationListeners, updateListeners, emitRepositories, emitOperations, randomSha, now, profile, state, repositories, recompute, snapshot, commitHistory, advanceOperation, syncState, diffs, findRepo, findProfileRepo, branchesByRepo, stashesByRepo, updateOperation } = context;
return {
async bootstrap() {
await wait(80);
snapshot();
return {
appVersion: "0.10.0-demo",
platform: "win32",
state: clone(state),
git: { available: true, version: "git version 2.47.3" },
diagnostics: {
enabled: true,
level: state.preferences.diagnosticLevel,
retentionDays: state.preferences.logRetentionDays,
maxFileMb: state.preferences.maxLogFileMb,
directory: "<HOME>/AppData/Roaming/ForgeFlow/diagnostics",
fileCount: 2,
totalBytes: 18432,
totalSize: "18.0 KB",
latestAt: iso(-2000),
lastWriteError: null,
},
};
},
async selectDirectory() {
await wait();
return "C:\\Development";
},
async selectKeyFile() {
await wait();
return "C:\\Users\\Jens\\.ssh\\id_ed25519";
},
async setupPreflight({ baseUrl, token, roots = [] }) {
await wait(240);
const checks = [
{
id: "git.available",
label: "Git command line",
status: "pass",
detail: "git version 2.47.3",
required: true,
},
{
id: "git.identity",
label: "Git author identity",
status: "pass",
detail: "Jens <jens@example.invalid>",
required: false,
},
{
id: "storage.userdata",
label: "Application data storage",
status: "pass",
detail: "ForgeFlow can write its local configuration.",
required: true,
},
{
id: "storage.diagnostics",
label: "Diagnostic log storage",
status: "pass",
detail: "The diagnostic directory is writable.",
required: true,
},
{
id: "storage.credentials",
label: "Protected credential storage",
status: "pass",
detail: "The operating system can encrypt the Gitea token at rest.",
required: false,
},
{
id: "workspace.roots",
label: "Development folders",
status: roots.length ? "pass" : "warning",
detail: roots.length
? `${roots.length} folder(s) selected.`
: "No development folder selected yet.",
required: false,
},
{
id: "gitea.connection",
label: "Gitea connection",
status: baseUrl && token ? "pass" : "warning",
detail:
baseUrl && token
? "Connection parameters are ready for validation."
: "Enter the Gitea URL and token.",
required: false,
},
];
return {
kind: "system",
startedAt: iso(-100),
completedAt: iso(),
checks,
summary: {
counts: {
pass: checks.filter((i) => i.status === "pass").length,
warning: checks.filter((i) => i.status === "warning").length,
fail: 0,
skipped: 0,
},
blocking: [],
ready: true,
},
};
},
async validateGitea({ baseUrl, token }) {
await wait(320);
if (!baseUrl || !token)
throw new Error("Enter an instance URL and access token.");
return {
baseUrl: baseUrl.replace(/\/$/, ""),
user: { login: "jens", full_name: "Jens" },
repositoryCount: repositories.length,
version: "1.26.0",
};
},
async completeSetup(payload) {
await wait(300);
state.setupComplete = true;
state.gitea = {
baseUrl: payload.baseUrl,
user: payload.user,
hasToken: true,
};
state.workspaceRoots = payload.workspaceRoots;
storage.set("forgeflow-demo-setup", "true");
return { state: clone(state), tokenState: { persistent: true } };
},
async updateGitea(payload) {
const validation = await this.validateGitea({
...payload,
token: payload.token || "preserved-demo-token",
});
state.gitea = {
baseUrl: validation.baseUrl,
user: validation.user,
hasToken: true,
};
return {
validation,
tokenState: { persistent: true, preserved: !payload.token },
state: clone(state),
};
},
async setWorkspaceRoots(roots) {
state.workspaceRoots = [...new Set(roots)];
return clone(state);
},
async setAppearance(appearance) {
state.appearance = appearance;
storage.set("forgeflow-theme", appearance);
return clone(state);
},
async setPreferences(preferences) {
state.preferences = { ...state.preferences, ...preferences };
snapshot();
return clone(state);
},
async setUpdatePreferences(updates) {
state.updates = { ...state.updates, ...updates };
return clone(state);
},
async checkForUpdates() {
await wait(300);
return {
checkedAt: iso(),
owner: state.updates.owner,
repo: state.updates.repo,
branch: state.updates.branch,
currentVersion: "0.5.4",
remoteVersion: "0.6.0",
remoteSha: "a".repeat(40),
shortSha: "aaaaaaa",
available: true,
mode: "source",
};
},
async downloadUpdate() {
await wait(500);
return {
...(await this.checkForUpdates()),
downloaded: true,
archivePath: "C:\\Temp\\ForgeFlow-0.4.1.zip",
sha256: "b".repeat(64),
};
},
async applyUpdate() {
await wait(200);
return { launched: true, confirmed: true, version: "0.6.0" };
},
async saveServer(server) {
const saved = {
...server,
id: server.id || `server-${Date.now()}`,
hasPassword: server.authType === "password",
hasPassphrase: false,
};
state.servers = [
saved,
...state.servers.filter((item) => item.id !== saved.id),
];
return { server: clone(saved), state: clone(state) };
},
async deleteServer(serverId) {
state.servers = state.servers.filter((item) => item.id !== serverId);
return clone(state);
},
async testServer(serverId) {
const server = state.servers.find((item) => item.id === serverId);
server.hostFingerprint = server.hostFingerprint || "SHA256:demo";
return {
connected: true,
fingerprint: server.hostFingerprint,
server: clone(server),
output: "Linux\n/usr/bin/git\nDocker Compose version v2",
state: clone(state),
};
},
async inspectServerProject() {
return {
exists: true,
rootGit: true,
head: "d42d4a7".padEnd(40, "0"),
branch: "main",
trackedChanges: [],
composeFiles: ["docker-compose.yml"],
nestedGit: ["source"],
dockerfile: true,
};
},
async refreshRepositories() {
await wait(260);
return snapshot();
},
async discoverRepositories() {
await wait(360);
return snapshot()
.filter((repo) => repo.localPath)
.map((repo) => ({
localPath: repo.localPath,
remoteUrl: repo.cloneUrl,
status: repo.localStatus,
}));
},
async favoriteRepository(fullName, favorite) {
const key = fullName.toLowerCase();
state.favorites = favorite
? [...new Set([...state.favorites, key])]
: state.favorites.filter((item) => item !== key);
snapshot();
return clone(state);
},
async linkRepository(fullName, localPath) {
const repo = repositories.find((item) => item.fullName === fullName);
repo.localPath = localPath;
repo.linkState = "linked";
repo.localStatus = makeStatus({ head: randomSha() });
emitRepositories();
return snapshot();
},
async unlinkRepository(fullName) {
const repo = repositories.find((item) => item.fullName === fullName);
repo.localPath = null;
repo.localStatus = null;
repo.linkState = "remote-only";
emitRepositories();
return snapshot();
},
async repositoryStatus(localPath) {
return clone(findRepo(localPath)?.localStatus);
},
async repositoryDiff(localPath, filePath) {
await wait(80);
return (
diffs[filePath] ||
`diff --git a/${filePath} b/${filePath}\n--- a/${filePath}\n+++ b/${filePath}\n@@ -1 +1 @@\n-old\n+new`
);
},
async repositoryDiffHunks(localPath, filePath) {
const diff = await this.repositoryDiff(localPath, filePath);
return {
filePath,
partialSupported: true,
hunks: [
{
index: 0,
heading: "@@ -1 +1 @@",
additions: 1,
deletions: 1,
lines: diff.split("\n").slice(-4),
},
],
};
},
async stageHunks(localPath, filePath) {
return this.stageFiles(localPath, [filePath]);
},
async conflictState(localPath) {
const repo = findRepo(localPath);
const files = repo.localStatus.files
.filter((item) => item.conflict)
.map((item) => item.path);
return {
operation: files.length ? "merge" : null,
files,
canContinue: false,
status: clone(repo.localStatus),
};
},
async resolveConflict(localPath, filePath) {
const repo = findRepo(localPath);
const file = repo.localStatus.files.find(
(item) => item.path === filePath,
);
if (file) {
file.conflict = false;
file.staged = true;
file.unstaged = false;
}
recompute(repo);
return this.conflictState(localPath);
},
async continueGitOperation(localPath) {
return this.conflictState(localPath);
},
async abortGitOperation(localPath) {
return this.conflictState(localPath);
},
async stageFiles(localPath, files) {
const repo = findRepo(localPath);
repo.localStatus.files.forEach((item) => {
if (!files?.length || files.includes(item.path)) {
item.staged = true;
item.unstaged = false;
item.indexCode = item.untracked ? "A" : "M";
item.worktreeCode = ".";
}
});
recompute(repo);
emitRepositories();
return clone(repo.localStatus);
},
async unstageFiles(localPath, files) {
const repo = findRepo(localPath);
repo.localStatus.files.forEach((item) => {
if (!files?.length || files.includes(item.path)) {
item.staged = false;
item.unstaged = true;
item.indexCode = ".";
item.worktreeCode = item.untracked ? "?" : "M";
}
});
recompute(repo);
emitRepositories();
return clone(repo.localStatus);
},
async commit(localPath, message, files) {
await wait(520);
if (!message?.trim()) throw new Error("Enter a commit message.");
const repo = findRepo(localPath);
repo.localStatus.files = repo.localStatus.files.filter(
(item) => !files?.includes(item.path),
);
repo.localStatus.head = randomSha();
repo.localStatus.branch.ahead += 1;
recompute(repo);
emitRepositories();
return {
commitOutput: `[${repo.localStatus.branch.head} ${repo.localStatus.shortHead}] ${message}`,
commitSha: repo.localStatus.head,
status: clone(repo.localStatus),
};
},
async commitAndPush(localPath, message, files) {
const result = await this.commit(localPath, message, files);
const repo = findRepo(localPath);
await wait(240);
repo.localStatus.branch.ahead = 0;
recompute(repo);
emitRepositories();
return {
...result,
pushOutput: "Push completed.",
status: clone(repo.localStatus),
};
},
async commitStaged(localPath, message) {
const repo = findRepo(localPath);
return this.commit(
localPath,
message,
repo.localStatus.files
.filter((item) => item.staged)
.map((item) => item.path),
);
},
async commitStagedAndPush(localPath, message) {
const repo = findRepo(localPath);
return this.commitAndPush(
localPath,
message,
repo.localStatus.files
.filter((item) => item.staged)
.map((item) => item.path),
);
},
async push(localPath) {
await wait(360);
const repo = findRepo(localPath);
repo.localStatus.branch.ahead = 0;
recompute(repo);
emitRepositories();
return { output: "Push completed.", status: clone(repo.localStatus) };
},
async fetch() {
await wait(260);
return { output: "Fetch completed." };
},
async pull(localPath) {
await wait(380);
const repo = findRepo(localPath);
repo.localStatus.branch.behind = 0;
recompute(repo);
emitRepositories();
return { output: "Fast-forwarded.", status: clone(repo.localStatus) };
},
async history() {
await wait(100);
return clone(commitHistory);
},
async branchProtection(fullName, branch) {
return {
branch,
protected: branch === "main",
requiredApprovals: branch === "main" ? 1 : 0,
requireSignedCommits: false,
};
},
async pullRequests() {
return [
{
number: 42,
title: "Harden deployment preflight",
html_url: "https://gitea.internal/jens/vacancyradar/pulls/42",
created_at: iso(-7_200_000),
updated_at: iso(-900_000),
head: { ref: "feature/deployment-api" },
base: { ref: "main" },
},
];
},
async createPullRequest(fullName, title, body, base) {
return {
number: 42,
title,
body,
base,
html_url: `https://gitea.internal/${fullName}/pulls/42`,
};
},
async branches(localPath) {
const repo = findRepo(localPath);
if (!branchesByRepo.has(localPath))
branchesByRepo.set(localPath, [
{
name: repo.localStatus.branch.head,
current: true,
sha: repo.localStatus.head,
shortSha: repo.localStatus.shortHead,
upstream: repo.localStatus.branch.upstream,
},
{
name: "main",
current: repo.localStatus.branch.head === "main",
sha: repo.localStatus.head,
shortSha: repo.localStatus.shortHead,
upstream: "origin/main",
},
]);
return clone(branchesByRepo.get(localPath));
},
async checkoutBranch(localPath, branch) {
const repo = findRepo(localPath);
if (!repo.localStatus.clean)
throw new Error(
"Commit or stash local changes before switching branches.",
);
const list = await this.branches(localPath);
list.forEach((item) => {
item.current = item.name === branch;
});
branchesByRepo.set(localPath, list);
repo.localStatus.branch.head = branch;
repo.localStatus.branch.upstream = `origin/${branch}`;
recompute(repo);
emitRepositories();
return { status: clone(repo.localStatus), branches: clone(list) };
},
async createBranch(localPath, branch) {
const repo = findRepo(localPath);
const list = await this.branches(localPath);
list.forEach((item) => {
item.current = false;
});
list.unshift({
name: branch,
current: true,
sha: repo.localStatus.head,
shortSha: repo.localStatus.shortHead,
upstream: null,
});
branchesByRepo.set(localPath, list);
repo.localStatus.branch.head = branch;
repo.localStatus.branch.upstream = null;
recompute(repo);
emitRepositories();
return { status: clone(repo.localStatus), branches: clone(list) };
},
async stash(localPath, message) {
const repo = findRepo(localPath);
const list = stashesByRepo.get(localPath) || [];
list.unshift({
ref: `stash@{${list.length}}`,
subject: message || "ForgeFlow stash",
date: iso(),
});
stashesByRepo.set(localPath, list);
repo.localStatus.files = [];
recompute(repo);
emitRepositories();
return {
output: "Saved working directory and index state.",
status: clone(repo.localStatus),
stashes: clone(list),
};
},
async stashList(localPath) {
return clone(stashesByRepo.get(localPath) || []);
},
async popStash(localPath, ref) {
const repo = findRepo(localPath);
const list = stashesByRepo.get(localPath) || [];
const index = list.findIndex((item) => item.ref === ref);
if (index < 0) throw new Error("Stash not found.");
list.splice(index, 1);
stashesByRepo.set(localPath, list);
repo.localStatus.files = [makeFile("src/restored-from-stash.ts")];
recompute(repo);
emitRepositories();
return {
output: "Stash applied.",
status: clone(repo.localStatus),
stashes: clone(list),
};
},
async indexLockInfo() {
return { exists: false, ageMs: 0 };
},
async repairIndexLock() {
return { removed: true };
},
async setOrigin(localPath, remoteUrl) {
const repo = findRepo(localPath);
repo.localStatus.remoteUrl = remoteUrl;
repo.sshUrl = remoteUrl;
emitRepositories();
return clone(repo.localStatus);
},
async normalizeOrigins() {
const changes = [];
repositories
.filter((repo) => repo.localPath && repo.sshUrl)
.forEach((repo) => {
if (repo.localStatus.remoteUrl !== repo.sshUrl) {
changes.push({
fullName: repo.fullName,
previous: repo.localStatus.remoteUrl,
next: repo.sshUrl,
});
repo.localStatus.remoteUrl = repo.sshUrl;
}
});
emitRepositories();
return { changes, repositories: snapshot() };
},
async cloneRepository(fullName, mode = "default") {
await wait(620);
const repository = repositories.find(
(item) => item.fullName === fullName,
);
if (!repository) throw new Error("Repository not found.");
if (repository.localPath)
throw new Error("This repository already has a linked local folder.");
const root =
mode === "custom" ? "D:\\OtherProjects" : state.workspaceRoots[0];
if (!root) return { cancelled: true };
const target = `${root.replace(/[\\/]+$/, "")}\\${repository.name}`;
const head = randomSha();
repository.localPath = target;
repository.localStatus = makeStatus({
head,
branch: repository.defaultBranch || "main",
});
repository.localStatus.root = target;
repository.localStatus.remoteUrl =
repository.preferredCloneUrl || repository.cloneUrl;
repository.linkState = "linked";
recompute(repository);
const current = snapshot();
emitRepositories();
return {
target,
status: clone(repository.localStatus),
reused: false,
repositories: current,
state: clone(state),
};
},
async openPath() {
return true;
},
async openEditor() {
return { launched: true, executable: "code" };
},
async openTerminal() {
return { launched: true, executable: "wt.exe" };
},
async openExternal() {
return true;
},
};
}
+211
View File
@@ -0,0 +1,211 @@
async function runOperation(
message,
operation,
successMessage,
{ refresh = true } = {},
) {
setLoading(true, message);
try {
const result = await operation();
if (successMessage) showToast("Done", successMessage, "success");
if (refresh) await refreshRepositories(false);
return result;
} catch (error) {
const pushAfterCommit = error.code === "PUSH_AFTER_COMMIT_FAILED";
showToast(
pushAfterCommit
? "Commit saved locally; push failed"
: "Operation failed",
error.message,
"error",
);
// Always reload the real Git state. A failed stage must keep changes visible, while a
// failed push after a successful commit must immediately surface as an ahead branch.
await refreshRepositories(false, true);
if (pushAfterCommit) {
ui.selectedFiles.clear();
ui.selectedFile = null;
ui.diff = "";
ui.commitMessage = "";
render();
}
return null;
} finally {
setLoading(false);
}
}
async function executeDeployment(profileId) {
const repository = selectedRepository();
const profile =
repository?.deploymentProfiles?.find((item) => item.id === profileId) ||
selectedProfile(repository);
if (!repository || !profile) return;
const targetSha = deploymentTargetSha(repository, profile);
if (!targetSha) {
showToast("Refresh required", "Refresh Gitea and server truth before deploying this environment.", "error");
return;
}
const deploymentOptions = {
note: document.querySelector("#deployment-note")?.value.trim() || "",
override: document.querySelector("#deployment-override")?.checked === true,
overrideReason:
document.querySelector("#deployment-override-reason")?.value.trim() || "",
};
ui.modal = null;
setLoading(
true,
profile.provider === "ssh-unraid"
? `Deploying ${repository.name} to ${profile.remoteFolder} over SSH…`
: `Dispatching ${profile.name} workflow…`,
);
try {
ui.activeDeployment = await window.forgeflow.deploy(
repository,
profile.id,
targetSha,
deploymentOptions,
);
updateOperationInState(ui.activeDeployment);
ui.currentView = "deployment-run";
showToast(
"Deployment started",
`${repository.name} ${shortSha(targetSha)}${profile.environment}`,
"success",
);
startOperationPolling();
} catch (error) {
if (profile.provider === "ssh-unraid" && isSshCredentialError(error)) {
ui.modal = {
type: "server-password",
serverId: profile.serverId,
retry: {
type: "deploy",
repositoryFullName: repository.fullName,
profileId: profile.id,
},
};
showToast("SSH key rejected", "Enter the Unraid server password once; ForgeFlow will retry the direct desktop → Unraid connection.", "error");
render();
} else {
showToast("Deployment failed to start", error.message, "error");
}
}
setLoading(false);
}
async function executeRollback(profileId) {
const repository = selectedRepository();
const profile = repository?.deploymentProfiles?.find(
(item) => item.id === profileId,
);
const target = profile?.state?.previousSha;
if (!repository || !profile || !target) return;
ui.modal = null;
setLoading(
true,
profile?.provider === "ssh-unraid"
? `Rolling back ${profile.remoteFolder} over SSH…`
: `Dispatching rollback to ${shortSha(target)}`,
);
try {
ui.activeDeployment = await window.forgeflow.rollback(
repository,
profile.id,
target,
);
updateOperationInState(ui.activeDeployment);
ui.currentView = "deployment-run";
showToast(
"Rollback requested",
`${profile.environment}${shortSha(target)}`,
"success",
);
} catch (error) {
showToast("Rollback failed to start", error.message, "error");
}
setLoading(false);
}
async function loadGitTools(repository) {
if (!repository?.localPath) return;
setLoading(true, "Loading branches and stashes…");
try {
[ui.branches, ui.stashes, ui.gitRecovery] = await Promise.all([
window.forgeflow.branches(repository.localPath),
window.forgeflow.stashList(repository.localPath),
window.forgeflow.gitRecoveryStatus(repository.localPath),
]);
ui.repositoryTab = "gittools";
} catch (error) {
showToast("Git tools unavailable", error.message, "error");
}
setLoading(false);
}
function profileRepository(profileId) {
return ui.repositories.find((repository) =>
repository.deploymentProfiles?.some((profile) => profile.id === profileId),
);
}
async function runSystemPreflight({ setup = false } = {}) {
setLoading(true, "Checking local readiness…");
try {
ui.systemPreflight = await window.forgeflow.setupPreflight({
baseUrl: ui.setupDraft.baseUrl,
token: ui.setupDraft.token,
roots: setup ? ui.setupDraft.roots : ui.boot.state.workspaceRoots,
});
if (!setup)
ui.diagnosticsStatus = await window.forgeflow.diagnosticsStatus();
showToast(
ui.systemPreflight.summary.ready
? "Readiness checks passed"
: "Readiness needs attention",
ui.systemPreflight.summary.ready
? `${ui.systemPreflight.summary.counts.pass} checks passed.`
: `${ui.systemPreflight.summary.blocking.length} blocking check(s) must be resolved.`,
ui.systemPreflight.summary.ready ? "success" : "error",
);
return ui.systemPreflight;
} catch (error) {
showToast("Readiness check failed", error.message, "error");
return null;
} finally {
setLoading(false);
}
}
async function runDeploymentPreflight(
repository,
profileId,
{ showModal = true } = {},
) {
if (!repository || !profileId) return null;
if (String(repository.id) !== String(ui.selectedRepoId))
selectRepository(repository.id, false);
ui.selectedProfileId = profileId;
ui.deploymentPreflight = null;
setLoading(true, "Verifying repository, workflow and server…");
try {
const report = await window.forgeflow.deploymentPreflight(
repository,
profileId,
);
ui.deploymentPreflight = report;
if (showModal)
ui.modal = {
type: "deployment-preflight",
profileId,
repositoryFullName: repository.fullName,
};
return report;
} catch (error) {
showToast("Deployment preflight failed", error.message, "error");
return null;
} finally {
setLoading(false);
}
}
+694
View File
@@ -0,0 +1,694 @@
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 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 (!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)}">
<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 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 repoOps = repositoryOperations(repository).slice(0, 10);
return `<div class="tab-page"><div class="section-heading"><div><h2>Deployment environments</h2><span class="meta">Exact-commit Gitea Actions or pinned SSH / Unraid deployments</span></div><button class="button primary" data-action="configure-deployment">${icon("plus")}Add environment</button></div>${profiles.length ? `<div class="deploy-card-grid">${profiles.map((profile) => renderProfileCard(repository, profile)).join("")}</div>` : '<div class="empty-state panel"><div class="empty-icon">↗</div><h3>No deployment profile</h3><p>Connect a Gitea Actions workflow or a trusted SSH / Unraid server.</p><button class="button primary" data-action="configure-deployment">Configure deployment</button></div>'}<section class="section-block"><div class="section-heading"><h2>Release history</h2></div><div class="panel">${repoOps.length ? `<table class="data-table"><thead><tr><th>Action</th><th>Environment</th><th>Commit</th><th>Status</th><th>Updated</th><th></th></tr></thead><tbody>${repoOps.map((operation) => `<tr><td>${escapeHtml(operation.action || "deploy")}</td><td>${escapeHtml(operation.environment)}</td><td class="mono">${escapeHtml(operation.shortSha || shortSha(operation.sha))}</td><td><span class="status-pill ${toneForStatus(operation.status)}">${escapeHtml(operation.status)}</span></td><td>${formatDate(operation.updatedAt || operation.createdAt)}</td><td><button class="button ghost" data-action="open-operation" data-operation-id="${attr(operation.id)}">Open</button></td></tr>`).join("")}</tbody></table>` : '<div class="empty-state compact"><p>No releases for this repository yet.</p></div>'}</div></section></div>`;
}
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 safeFixes = report.checks.filter(
(check) => check.fixAction && check.safe,
);
const groups = report.checks.reduce((grouped, check) => {
(grouped[check.category] ||= []).push(check);
return grouped;
}, {});
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">Git assurance score</div><h2>${escapeHtml(report.grade)}</h2><p>${report.summary.passed} passed · ${report.summary.warnings} recommendations · ${report.summary.errors} critical</p></div>${projectIllustration("diagnostics")}<div class="validator-actions"><button class="button" data-action="git-validator-scan">${icon("refresh")}Scan again</button>${safeFixes.length ? `<button class="button primary" data-action="git-validator-repair-safe">${icon("wrench")}Apply ${safeFixes.length} safe fix${safeFixes.length === 1 ? "" : "es"}</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></div>${check.fixAction ? `<button class="button ${check.safe ? "" : "primary"}" data-action="git-validator-repair" data-check-index="${checkIndex}">${icon("wrench")}${check.safe ? "Fix safely" : "Review & fix"}</button>` : `<span class="status-pill ${check.status === "pass" ? "success" : check.status === "error" ? "danger" : "warning"}">${check.status === "pass" ? "Best practice" : "Review"}</span>`}</article>`;
})
.join("")}</div></section>`,
)
.join("")}</div></div>`;
}
function renderRepositoryWorkspace(repository) {
const status = repository.localStatus;
const profile = selectedProfile(repository);
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"
: "";
const content = (
{
changes: renderChanges,
history: renderHistory,
deployments: renderRepositoryDeployments,
gittools: renderGitTools,
validator: renderGitValidator,
settings: renderRepositorySettings,
}[ui.repositoryTab] || renderChanges
)(repository);
return `<div class="repo-workspace"><header class="repo-header illustrated-repo-header"><div class="repo-heading"><h1><button class="favorite-button ${repository.favorite ? "active" : ""}" data-action="toggle-favorite" title="Toggle favorite">${icon("star")}</button>${escapeHtml(repository.fullName)}</h1><p>${escapeHtml(repository.localPath || "No local working tree linked")}</p></div>${projectIllustration("repo")}<div class="repo-header-actions"><button class="button" data-action="fetch" ${!repository.localPath ? "disabled" : ""}>${icon("refresh")}Fetch</button><button class="button" data-action="open-path" ${!repository.localPath ? "disabled" : ""}>${icon("folder")}Folder</button><button class="button" data-action="open-gitea" ${!repository.htmlUrl ? "disabled" : ""}>${icon("external")}Gitea</button></div></header>
${repository.localPath ? `<div class="repo-quick-actions"><button class="button" data-action="open-editor">${icon("external")}Open in editor</button><button class="button" data-action="open-terminal">${icon("terminal")}Open terminal</button><button class="button" data-action="check-branch-protection">${icon("shield")}Check branch protection</button><button class="button primary" data-action="open-pull-request">${icon("git")}Create pull request</button>${ui.branchProtection ? `<span class="status-pill ${ui.branchProtection.protected ? "warning" : "success"}">${ui.branchProtection.protected ? `Protected · ${ui.branchProtection.requiredApprovals || 0} approval(s)` : "Direct pushes allowed"}</span>` : ""}</div>` : ""}
<div class="release-rail">${releaseNode("Local", status?.shortHead || "Not linked", status ? `${status.counts.changed} changes · ${status.branch.head}` : "No working tree", localTone)}${releaseNode("Gitea", status?.shortHead || "Unknown", status?.branch.upstream ? `${status.branch.ahead} ahead · ${status.branch.behind} behind` : "Branch not published", remoteTone)}${releaseNode(`Server${profile ? ` · ${profile.environment}` : ""}`, serverState.liveSha ? shortSha(serverState.liveSha) : "Unknown", profile ? (serverState.checkedAt ? `checked ${formatDate(serverState.checkedAt)}` : "not checked") : "No deployment profile", serverTone)}</div>
<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 linked = (workload.status === "linked" || Boolean(workload.link)) && workload.classification?.type !== "stale-link";
const classification = workload.classification?.type || workload.status || "review";
const statusTone = linked && !workload.reviewDecisionStale ? "success" : ["ambiguous", "duplicate", "orphan-container"].includes(classification) || workload.reviewDecisionStale ? "danger" : "warning";
const detail = workload.compose?.project
? `Compose ${workload.compose.project} · ${(workload.compose.services || []).join(", ") || "services unknown"}`
: workload.dockerMan?.templatePath
? `DockerMan ${workload.dockerMan.name || workload.displayName} · ${containers || "template only"}`
: `Container installation · ${containers || "unnamed"}`;
const candidate = linked
? `Linked to ${workload.link?.repositoryFullName || "repository"}`
: topCandidate
? `${topCandidate.repositoryFullName} suggested · ${topCandidate.confidence || 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 ? "text-warning" : "meta"}">${escapeHtml(evidenceNote)}</span>${workload.metadata?.composeDefinitionError ? `<span class="text-warning">Compose file found; validation warning: ${escapeHtml(workload.metadata.composeDefinitionError)}</span>` : ""}</div><div class="stack horizontal compact"><span class="status-pill ${statusTone}">${escapeHtml(workload.reviewDecisionStale ? "Decision stale" : linked ? "Linked" : classification)}</span>${linked ? `<button class="button ghost" data-action="edit-deployment-profile" data-profile-id="${attr(workload.link?.profileId || "")}">Open link</button>` : linkButton}</div></div>`;
}).join("")
: `<div class="empty-state compact"><p>${server.error ? "No inventory could be read until the SSH connection works." : "Docker returned no containers, Compose projects or DockerMan templates."}</p></div>`;
return `<section class="panel server-inventory-panel"><div class="panel-header"><div><h3>${escapeHtml(server.serverName || server.server?.name || server.serverId)}</h3><span class="meta">${server.running || 0} running · ${server.linked || 0} repository links · ${visibleWorkloads.filter((workload) => !workload.link).length} to review${hiddenCount ? ` · ${hiddenCount} unrelated/system workloads hidden` : ""}</span></div><div class="stack horizontal compact"><span class="status-pill ${server.error ? "danger" : capabilities.docker && capabilities.compose ? "success" : "warning"}">${server.error ? "Scan failed" : escapeHtml(capabilityText)}</span>${server.error ? "" : `<button class="button" data-action="plan-server-reconciliation" data-server-id="${attr(server.serverId)}">${icon("shield")}Review reconciliation</button>`}</div></div><div class="panel-body">${errorBlock}${warnings}<div class="tool-list">${workloads}</div></div></section>`;
}).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>`;
}