676 lines
24 KiB
JavaScript
676 lines
24 KiB
JavaScript
const app = document.querySelector("#app");
|
|
const toastRoot = document.querySelector("#toast-root");
|
|
|
|
const icons = {
|
|
overview:
|
|
'<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>',
|
|
repository:
|
|
'<path d="M6 3v12"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="6" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>',
|
|
deploy: '<path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M5 21h14"/>',
|
|
settings:
|
|
'<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-2.83 2.83-.06-.06a1.7 1.7 0 0 0-1.88-.34 1.7 1.7 0 0 0-1.03 1.56V21h-4v-.09A1.7 1.7 0 0 0 8.95 19.4a1.7 1.7 0 0 0-1.88.34l-.06.06-2.83-2.83.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.56-1.03H3v-4h.09A1.7 1.7 0 0 0 4.6 8.95a1.7 1.7 0 0 0-.34-1.88l-.06-.06 2.83-2.83.06.06A1.7 1.7 0 0 0 8.95 4.6a1.7 1.7 0 0 0 1.03-1.56V3h4v.09A1.7 1.7 0 0 0 15.05 4.6a1.7 1.7 0 0 0 1.88-.34l.06-.06 2.83 2.83-.06.06A1.7 1.7 0 0 0 19.4 8.95a1.7 1.7 0 0 0 1.56 1.03H21v4h-.09A1.7 1.7 0 0 0 19.4 15Z"/>',
|
|
search: '<circle cx="11" cy="11" r="7"/><path d="m20 20-4-4"/>',
|
|
refresh: '<path d="M20 11a8 8 0 1 0-2.34 5.66"/><path d="M20 4v7h-7"/>',
|
|
folder: '<path d="M3 7h6l2 2h10v10H3z"/><path d="M3 7V5h6l2 2"/>',
|
|
git: '<circle cx="6" cy="5" r="2"/><circle cx="18" cy="6" r="2"/><circle cx="6" cy="19" r="2"/><path d="M6 7v10M8 6h8a2 2 0 0 1 2 2v0a5 5 0 0 1-5 5H6"/>',
|
|
branch:
|
|
'<circle cx="6" cy="5" r="2"/><circle cx="18" cy="6" r="2"/><circle cx="6" cy="19" r="2"/><path d="M6 7v10M8 6h8M18 8a7 7 0 0 1-7 7H6"/>',
|
|
file: '<path d="M6 2h8l4 4v16H6z"/><path d="M14 2v5h5"/>',
|
|
check: '<path d="m5 12 4 4L19 6"/>',
|
|
warning: '<path d="M12 3 2 21h20L12 3Z"/><path d="M12 9v5M12 18h.01"/>',
|
|
error: '<circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16h.01"/>',
|
|
arrowRight: '<path d="M5 12h14M14 7l5 5-5 5"/>',
|
|
arrowUp: '<path d="M12 19V5m-5 5 5-5 5 5"/>',
|
|
arrowDown: '<path d="M12 5v14m-5-5 5 5 5-5"/>',
|
|
external:
|
|
'<path d="M14 3h7v7M10 14 21 3"/><path d="M21 14v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h6"/>',
|
|
play: '<path d="m8 5 11 7-11 7V5Z"/>',
|
|
terminal: '<path d="m4 6 5 5-5 5M11 18h9"/>',
|
|
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
|
|
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.42 1.42M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.42-1.42M17.66 6.34l1.41-1.41"/>',
|
|
moon: '<path d="M20 15.2A8 8 0 0 1 8.8 4 8.5 8.5 0 1 0 20 15.2Z"/>',
|
|
plus: '<path d="M12 5v14M5 12h14"/>',
|
|
trash: '<path d="M4 7h16M9 7V4h6v3M7 7l1 14h8l1-14M10 11v6M14 11v6"/>',
|
|
close: '<path d="m6 6 12 12M18 6 6 18"/>',
|
|
copy: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/>',
|
|
chevron: '<path d="m9 18 6-6-6-6"/>',
|
|
link: '<path d="M10 13a5 5 0 0 0 7.07.07l2-2A5 5 0 0 0 12 4l-1.15 1.15"/><path d="M14 11a5 5 0 0 0-7.07-.07l-2 2A5 5 0 0 0 12 20l1.15-1.15"/>',
|
|
cloud:
|
|
'<path d="M17.5 19H6a4 4 0 0 1-.4-7.98A6.5 6.5 0 0 1 18 9.5h.5a4.75 4.75 0 0 1-1 9.5Z"/>',
|
|
pulse: '<path d="M3 12h4l2-6 4 12 2-6h6"/>',
|
|
history:
|
|
'<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5M12 7v5l3 2"/>',
|
|
more: '<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
|
|
star: '<path d="m12 3 2.8 5.7 6.2.9-4.5 4.4 1.1 6.2-5.6-2.9-5.6 2.9 1.1-6.2L3 9.6l6.2-.9L12 3Z"/>',
|
|
archive: '<path d="M4 7h16v13H4zM3 3h18v4H3zM9 11h6"/>',
|
|
shield:
|
|
'<path d="M12 3 20 6v6c0 5-3.4 8-8 9-4.6-1-8-4-8-9V6l8-3Z"/><path d="m9 12 2 2 4-5"/>',
|
|
rocket:
|
|
'<path d="M14 5c2-2 5-2 6-2 0 1 0 4-2 6l-4 4-5-4 5-4Z"/><path d="m9 9-4 1-2 3 6 1M14 14l-1 6-3 2-1-6"/><path d="m5 19 3-3"/>',
|
|
layers:
|
|
'<path d="m12 3 9 5-9 5-9-5 9-5Z"/><path d="m3 12 9 5 9-5M3 16l9 5 9-5"/>',
|
|
undo: '<path d="M9 7 4 12l5 5"/><path d="M4 12h9a7 7 0 0 1 7 7"/>',
|
|
menu: '<path d="M4 6h16M4 12h16M4 18h16"/>',
|
|
download: '<path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M4 21h16"/>',
|
|
server:
|
|
'<rect x="3" y="4" width="18" height="6" rx="2"/><rect x="3" y="14" width="18" height="6" rx="2"/><path d="M7 7h.01M7 17h.01"/>',
|
|
key: '<circle cx="8" cy="15" r="4"/><path d="m11 12 9-9M16 7l2 2M14 9l2 2"/>',
|
|
update:
|
|
'<path d="M21 12a9 9 0 0 1-15.3 6.4L3 16"/><path d="M3 21v-5h5"/><path d="M3 12A9 9 0 0 1 18.3 5.6L21 8"/><path d="M21 3v5h-5"/>',
|
|
wrench:
|
|
'<path d="M14.7 6.3a4 4 0 0 0-5-5l2.1 2.1-2.8 2.8-2.1-2.1a4 4 0 0 0 5 5L20 17.2 17.2 20l-8.1-8.1a4 4 0 0 0-5-5l2.1 2.1-2.8 2.8-2.1-2.1a4 4 0 0 0 5 5"/>',
|
|
};
|
|
|
|
function icon(name, className = "") {
|
|
return `<span class="icon ${className}" aria-hidden="true"><svg viewBox="0 0 24 24">${icons[name] || icons.file}</svg></span>`;
|
|
}
|
|
function escapeHtml(value) {
|
|
return String(value ?? "").replace(
|
|
/[&<>'"]/g,
|
|
(character) =>
|
|
({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ })[
|
|
character
|
|
],
|
|
);
|
|
}
|
|
function attr(value) {
|
|
return escapeHtml(value).replace(/`/g, "`");
|
|
}
|
|
function formatDate(value) {
|
|
if (!value) return "Unknown";
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return String(value);
|
|
const diff = Date.now() - date.getTime();
|
|
if (diff < 60_000) return "just now";
|
|
if (diff < 3_600_000) return `${Math.max(1, Math.floor(diff / 60_000))}m ago`;
|
|
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
|
if (diff < 604_800_000) return `${Math.floor(diff / 86_400_000)}d ago`;
|
|
return date.toLocaleDateString(undefined, {
|
|
day: "2-digit",
|
|
month: "short",
|
|
year:
|
|
date.getFullYear() !== new Date().getFullYear() ? "numeric" : undefined,
|
|
});
|
|
}
|
|
function truncate(value, length = 76) {
|
|
const text = String(value || "");
|
|
return text.length > length ? `${text.slice(0, length - 1)}…` : text;
|
|
}
|
|
function shortSha(value) {
|
|
return String(value || "").slice(0, 7) || "—";
|
|
}
|
|
function defaultWorkspaceRoot() {
|
|
return ui.boot?.state?.workspaceRoots?.[0] || null;
|
|
}
|
|
function safeCloneFolderName(repository) {
|
|
return (
|
|
String(repository?.name || "repository")
|
|
.replace(/\.git$/i, "")
|
|
.replace(/[^a-zA-Z0-9._-]/g, "-") || "repository"
|
|
);
|
|
}
|
|
function displayCloneTarget(repository) {
|
|
const root = defaultWorkspaceRoot();
|
|
if (!root) return null;
|
|
const separator = ui.boot?.platform === "win32" ? "\\" : "/";
|
|
return `${String(root).replace(/[\\/]+$/, "")}${separator}${safeCloneFolderName(repository)}`;
|
|
}
|
|
function clonePrimaryLabel(repository) {
|
|
return defaultWorkspaceRoot()
|
|
? `Clone to ${safeCloneFolderName(repository)}`
|
|
: "Choose project root & clone";
|
|
}
|
|
function isTerminalOperation(status) {
|
|
return ["success", "failed", "cancelled", "rolled-back"].includes(status);
|
|
}
|
|
function toneForStatus(status) {
|
|
if (["success", "healthy", "complete", "rolled-back"].includes(status))
|
|
return "success";
|
|
if (["failed", "failure", "unhealthy", "danger"].includes(status))
|
|
return "danger";
|
|
if (["queued", "running", "requested", "warning", "active"].includes(status))
|
|
return "warning";
|
|
return "";
|
|
}
|
|
|
|
const ui = {
|
|
boot: null,
|
|
repositories: [],
|
|
currentView: "overview",
|
|
selectedRepoId: null,
|
|
repositoryTab: "changes",
|
|
selectedFile: null,
|
|
selectedFiles: new Set(),
|
|
selectedProfileId: null,
|
|
diff: "",
|
|
history: [],
|
|
branches: [],
|
|
stashes: [],
|
|
search: "",
|
|
repoSearch: "",
|
|
commitMessage: "",
|
|
loading: false,
|
|
loadingMessage: "",
|
|
modal: null,
|
|
setupStep: 0,
|
|
systemPreflight: null,
|
|
deploymentPreflight: null,
|
|
serverGitVerifications: {},
|
|
deployKeyLifecycle: null,
|
|
inventoryReviewPlan: null,
|
|
diagnosticsStatus: null,
|
|
troubleshooter: null,
|
|
deploymentDiscovery: null,
|
|
serverDiscovery: [],
|
|
lastDiagnosticBundle: null,
|
|
setupDraft: {
|
|
baseUrl: "https://",
|
|
token: "",
|
|
user: null,
|
|
roots: [],
|
|
discovered: [],
|
|
},
|
|
setupValidation: null,
|
|
activeDeployment: null,
|
|
operationPollTimer: null,
|
|
inputRenderTimer: null,
|
|
isMock: false,
|
|
refreshError: null,
|
|
autoRefreshPending: false,
|
|
paletteQuery: "",
|
|
updateStatus: null,
|
|
updateChecking: false,
|
|
servers: [],
|
|
serverInspection: null,
|
|
gitRecovery: null,
|
|
gitValidation: null,
|
|
diffHunks: null,
|
|
conflictState: null,
|
|
branchProtection: null,
|
|
pullRequests: [],
|
|
auditEvents: [],
|
|
};
|
|
|
|
function scheduleInputRender(delay = 120) {
|
|
if (ui.inputRenderTimer) clearTimeout(ui.inputRenderTimer);
|
|
ui.inputRenderTimer = setTimeout(() => {
|
|
ui.inputRenderTimer = null;
|
|
render();
|
|
}, delay);
|
|
}
|
|
|
|
function selectedRepository() {
|
|
return (
|
|
ui.repositories.find(
|
|
(repository) => String(repository.id) === String(ui.selectedRepoId),
|
|
) || null
|
|
);
|
|
}
|
|
function selectedProfile(repository = selectedRepository()) {
|
|
if (!repository?.deploymentProfiles?.length) return null;
|
|
return (
|
|
repository.deploymentProfiles.find(
|
|
(profile) => profile.id === ui.selectedProfileId,
|
|
) ||
|
|
repository.deploymentProfiles.find(
|
|
(profile) => profile.branch === repository.localStatus?.branch.head,
|
|
) ||
|
|
repository.deploymentProfiles[0]
|
|
);
|
|
}
|
|
|
|
function canDirectPushDeploy(repository, profile = selectedProfile(repository)) {
|
|
const status = repository?.localStatus;
|
|
const mode = deploymentMode(profile);
|
|
return Boolean(
|
|
repository?.localPath
|
|
&& status?.head
|
|
&& !status?.counts?.changed
|
|
&& !status?.counts?.conflicts
|
|
&& profile
|
|
&& mode === "push-bundle"
|
|
&& profile.branch === status.branch?.head,
|
|
);
|
|
}
|
|
|
|
function deploymentMode(profile) {
|
|
if (profile?.provider !== "ssh-unraid") return "gitea-actions";
|
|
return ["push-bundle", "server-git", "monitor-only"].includes(profile.deploymentMode)
|
|
? profile.deploymentMode
|
|
: "push-bundle";
|
|
}
|
|
|
|
function deploymentTargetSha(repository, profile = selectedProfile(repository)) {
|
|
return deploymentMode(profile) === "server-git"
|
|
? profile?.state?.giteaSha || null
|
|
: repository?.localStatus?.head || null;
|
|
}
|
|
|
|
function canServerGitDeploy(repository, profile = selectedProfile(repository)) {
|
|
const target = deploymentTargetSha(repository, profile);
|
|
return Boolean(
|
|
profile
|
|
&& deploymentMode(profile) === "server-git"
|
|
&& target
|
|
&& profile.branch
|
|
&& !(profile.state?.liveSha === target && profile.state?.healthy !== false),
|
|
);
|
|
}
|
|
|
|
function canDeploy(repository, profile = selectedProfile(repository)) {
|
|
const mode = deploymentMode(profile);
|
|
if (mode === "server-git") return canServerGitDeploy(repository, profile);
|
|
if (mode === "push-bundle") return canDirectPushDeploy(repository, profile);
|
|
return profile?.provider === "gitea-actions" && repository?.readyToDeploy;
|
|
}
|
|
function operations() {
|
|
return ui.boot?.state?.operations || [];
|
|
}
|
|
function repositoryOperations(repository) {
|
|
return operations().filter(
|
|
(operation) => operation.repository === repository?.fullName,
|
|
);
|
|
}
|
|
|
|
function applyTheme(appearance) {
|
|
const resolved =
|
|
appearance === "system"
|
|
? matchMedia("(prefers-color-scheme: light)").matches
|
|
? "light"
|
|
: "dark"
|
|
: appearance;
|
|
document.documentElement.dataset.theme = resolved || "dark";
|
|
}
|
|
|
|
function showToast(title, message, type = "info") {
|
|
const toast = document.createElement("div");
|
|
toast.className = `toast ${type}`;
|
|
toast.innerHTML = `${icon(type === "error" ? "error" : type === "success" ? "check" : "warning")}<div><strong>${escapeHtml(title)}</strong><span>${escapeHtml(message)}</span></div>`;
|
|
toastRoot.append(toast);
|
|
setTimeout(() => toast.remove(), 5600);
|
|
}
|
|
|
|
function isSshCredentialError(error) {
|
|
const code = String(error?.code || "");
|
|
const message = String(error?.message || "");
|
|
return ["SSH_PRIVATE_KEY_READ_FAILED", "SSH_PRIVATE_KEY_NOT_FOUND", "SSH_CONNECTION_FAILED"].includes(code)
|
|
|| /private key|publickey|authentication methods failed|permission denied|authentication failed/i.test(message);
|
|
}
|
|
|
|
function setLoading(loading, message = "") {
|
|
ui.loading = loading;
|
|
ui.loadingMessage = message;
|
|
render();
|
|
}
|
|
function updateOperationInState(operation) {
|
|
if (!operation || !ui.boot) return;
|
|
const list = operations();
|
|
ui.boot.state.operations = [
|
|
operation,
|
|
...list.filter((item) => item.id !== operation.id),
|
|
].slice(0, 250);
|
|
if (ui.activeDeployment?.id === operation.id) ui.activeDeployment = operation;
|
|
}
|
|
|
|
function stopOperationPolling() {
|
|
if (ui.operationPollTimer) clearTimeout(ui.operationPollTimer);
|
|
ui.operationPollTimer = null;
|
|
}
|
|
|
|
function startOperationPolling() {
|
|
stopOperationPolling();
|
|
const operationId = ui.activeDeployment?.id;
|
|
if (!operationId || isTerminalOperation(ui.activeDeployment.status)) return;
|
|
const seconds = Math.max(
|
|
2,
|
|
Number(ui.boot?.state?.preferences?.operationPollSeconds) || 3,
|
|
);
|
|
ui.operationPollTimer = setTimeout(async () => {
|
|
try {
|
|
const operation = await window.forgeflow.refreshOperations(operationId);
|
|
if (operation) updateOperationInState(operation);
|
|
render();
|
|
if (operation && !isTerminalOperation(operation.status))
|
|
startOperationPolling();
|
|
else stopOperationPolling();
|
|
} catch (error) {
|
|
showToast("Deployment status refresh failed", error.message, "error");
|
|
stopOperationPolling();
|
|
}
|
|
}, seconds * 1000);
|
|
}
|
|
|
|
async function bootstrap() {
|
|
try {
|
|
ui.boot = await window.forgeflow.bootstrap();
|
|
ui.isMock = String(ui.boot.appVersion).includes("demo");
|
|
ui.diagnosticsStatus = ui.boot.diagnostics || null;
|
|
applyTheme(ui.boot.state.appearance);
|
|
ui.setupDraft.roots = [...(ui.boot.state.workspaceRoots || [])];
|
|
if (ui.boot.state.setupComplete) {
|
|
await refreshRepositories(false);
|
|
const reconciled = await refreshActiveOperations(false);
|
|
if (
|
|
(Array.isArray(reconciled) ? reconciled : []).some((operation) =>
|
|
isTerminalOperation(operation.status),
|
|
)
|
|
)
|
|
await refreshRepositories(false);
|
|
}
|
|
window.forgeflow.onRepositoriesChanged?.(() => scheduleAutoRefresh());
|
|
window.forgeflow.onOperationsChanged?.((payload) => {
|
|
const changed = payload?.operations || [];
|
|
for (const operation of changed) updateOperationInState(operation);
|
|
if (changed.some((operation) => isTerminalOperation(operation.status)))
|
|
scheduleAutoRefresh(250);
|
|
render();
|
|
});
|
|
window.forgeflow.onUpdatesChanged?.((payload) => {
|
|
ui.updateStatus = payload;
|
|
render();
|
|
if (payload?.available)
|
|
showToast(
|
|
"ForgeFlow update available",
|
|
`Version ${payload.remoteVersion} is ready to download.`,
|
|
"success",
|
|
);
|
|
});
|
|
render();
|
|
const updateResult = ui.boot.updateResult;
|
|
if (updateResult?.state === "success") {
|
|
const restartNote = updateResult.restartLaunched
|
|
? ""
|
|
: " Automatic restart was unavailable, but the update itself succeeded.";
|
|
showToast(
|
|
"ForgeFlow updated successfully",
|
|
`Version ${updateResult.installedVersion || updateResult.expectedVersion || ui.boot.appVersion} is installed.${restartNote}`,
|
|
"success",
|
|
);
|
|
} else if (updateResult?.state === "rolled-back") {
|
|
showToast(
|
|
"ForgeFlow update rolled back",
|
|
updateResult.message ||
|
|
"The update failed and the previous version was restored.",
|
|
"error",
|
|
);
|
|
} else if (updateResult?.state === "failed") {
|
|
showToast(
|
|
"ForgeFlow update failed",
|
|
updateResult.message || "See the update log for technical details.",
|
|
"error",
|
|
);
|
|
}
|
|
setTimeout(() => {
|
|
void refreshDeploymentTruth(false);
|
|
}, 500);
|
|
} catch (error) {
|
|
app.innerHTML = `<div class="boot-screen">${icon("error")}<strong>ForgeFlow could not start</strong><span>${escapeHtml(error.message)}</span></div>`;
|
|
}
|
|
}
|
|
|
|
function scheduleAutoRefresh() {
|
|
if (
|
|
ui.loading ||
|
|
ui.autoRefreshPending ||
|
|
!ui.boot?.state?.preferences?.autoRefresh
|
|
)
|
|
return;
|
|
ui.autoRefreshPending = true;
|
|
setTimeout(async () => {
|
|
ui.autoRefreshPending = false;
|
|
await refreshRepositories(false, true);
|
|
}, 450);
|
|
}
|
|
|
|
async function refreshRepositories(withLoader = true, silent = false) {
|
|
if (withLoader) setLoading(true, "Refreshing Local → Gitea → Server state…");
|
|
try {
|
|
const selectedId = ui.selectedRepoId;
|
|
ui.repositories = await window.forgeflow.refreshRepositories();
|
|
ui.refreshError = null;
|
|
if (selectedId && !selectedRepository()) ui.selectedRepoId = null;
|
|
const repository = selectedRepository();
|
|
if (
|
|
repository &&
|
|
!repository.deploymentProfiles.some(
|
|
(profile) => profile.id === ui.selectedProfileId,
|
|
)
|
|
)
|
|
ui.selectedProfileId = selectedProfile(repository)?.id || null;
|
|
if (repository) {
|
|
const availablePaths = new Set(
|
|
(repository.localStatus?.files || []).map((file) => file.path),
|
|
);
|
|
ui.selectedFiles = new Set(
|
|
[...ui.selectedFiles].filter((filePath) =>
|
|
availablePaths.has(filePath),
|
|
),
|
|
);
|
|
if (ui.selectedFile && !availablePaths.has(ui.selectedFile)) {
|
|
ui.selectedFile = repository.localStatus?.files?.[0]?.path || null;
|
|
ui.diff = "";
|
|
}
|
|
}
|
|
if (
|
|
!ui.selectedRepoId &&
|
|
ui.currentView === "repository" &&
|
|
ui.repositories.length
|
|
)
|
|
selectRepository(ui.repositories[0].id, false);
|
|
} catch (error) {
|
|
ui.refreshError = error.message;
|
|
if (!silent) showToast("Refresh failed", error.message, "error");
|
|
} finally {
|
|
if (withLoader) setLoading(false);
|
|
else render();
|
|
}
|
|
}
|
|
|
|
async function refreshActiveOperations(showErrors = true) {
|
|
try {
|
|
const updated = await window.forgeflow.refreshOperations();
|
|
for (const operation of Array.isArray(updated) ? updated : [])
|
|
updateOperationInState(operation);
|
|
return updated;
|
|
} catch (error) {
|
|
if (showErrors)
|
|
showToast("Deployment status unavailable", error.message, "error");
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function refreshDeploymentTruth(showErrors = false) {
|
|
let discovery = [];
|
|
try {
|
|
discovery = (await window.forgeflow.discoverServerDeployments?.()) || [];
|
|
ui.serverDiscovery = discovery;
|
|
const adopted = discovery.reduce(
|
|
(total, server) => total + Number(server.adopted || 0),
|
|
0,
|
|
);
|
|
if (adopted > 0) {
|
|
await refreshRepositories(false, true);
|
|
showToast(
|
|
"Server workloads discovered",
|
|
`${adopted} workload${adopted === 1 ? " was" : "s were"} linked automatically from exact repository provenance.`,
|
|
"success",
|
|
);
|
|
}
|
|
} catch (error) {
|
|
if (showErrors)
|
|
showToast("Server discovery unavailable", error.message, "error");
|
|
}
|
|
const targets = ui.repositories.flatMap((repository) =>
|
|
(repository.deploymentProfiles || []).map((profile) => ({
|
|
repository,
|
|
profile,
|
|
})),
|
|
);
|
|
if (!targets.length) return { checked: 0, failed: 0, discovery };
|
|
|
|
const failures = [];
|
|
const queue = [...targets];
|
|
const workers = Array.from(
|
|
{ length: Math.min(3, queue.length) },
|
|
async () => {
|
|
while (queue.length) {
|
|
const target = queue.shift();
|
|
try {
|
|
target.profile.state = await window.forgeflow.refreshProfileState(
|
|
target.repository.fullName,
|
|
target.profile.id,
|
|
);
|
|
} catch (error) {
|
|
failures.push({
|
|
repository: target.repository.fullName,
|
|
profile: target.profile.name,
|
|
message: error.message,
|
|
});
|
|
}
|
|
}
|
|
},
|
|
);
|
|
await Promise.all(workers);
|
|
await refreshRepositories(false);
|
|
if (showErrors && failures.length) {
|
|
showToast(
|
|
"Some environments could not be checked",
|
|
`${failures.length} profile${failures.length === 1 ? "" : "s"} could not be refreshed. Open Deployments for details.`,
|
|
"error",
|
|
);
|
|
}
|
|
return { checked: targets.length, failed: failures.length, discovery };
|
|
}
|
|
|
|
function selectRepository(id, shouldRender = true) {
|
|
ui.selectedRepoId = id;
|
|
ui.currentView = "repository";
|
|
ui.repositoryTab = "changes";
|
|
ui.commitMessage = "";
|
|
ui.history = [];
|
|
ui.branches = [];
|
|
ui.stashes = [];
|
|
ui.gitRecovery = null;
|
|
ui.gitValidation = null;
|
|
ui.branchProtection = null;
|
|
const repository = selectedRepository();
|
|
ui.selectedProfileId = selectedProfile(repository)?.id || null;
|
|
const files = repository?.localStatus?.files || [];
|
|
ui.selectedFiles = new Set(files.map((file) => file.path));
|
|
ui.selectedFile = files[0]?.path || null;
|
|
ui.diff = "";
|
|
if (ui.selectedFile && repository?.localPath)
|
|
loadDiff(repository, ui.selectedFile);
|
|
if (repository?.owner?.login && repository?.localStatus?.branch?.head) {
|
|
window.forgeflow
|
|
.branchProtection(repository.fullName, repository.localStatus.branch.head)
|
|
.then((protection) => {
|
|
if (String(ui.selectedRepoId) === String(id)) {
|
|
ui.branchProtection = protection;
|
|
render();
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
if (shouldRender) render();
|
|
}
|
|
|
|
async function loadDiff(repository, filePath) {
|
|
ui.diff = "Loading diff…";
|
|
render();
|
|
try {
|
|
const file = repository.localStatus?.files.find(
|
|
(item) => item.path === filePath,
|
|
);
|
|
ui.diff = await window.forgeflow.repositoryDiff(
|
|
repository.localPath,
|
|
filePath,
|
|
Boolean(file?.staged && !file?.unstaged),
|
|
);
|
|
ui.diffHunks = file?.unstaged
|
|
? await window.forgeflow
|
|
.repositoryDiffHunks(repository.localPath, filePath)
|
|
.catch(() => null)
|
|
: null;
|
|
} catch (error) {
|
|
ui.diff = `Unable to load diff: ${error.message}`;
|
|
}
|
|
render();
|
|
}
|
|
|
|
function repositoryAction(repository) {
|
|
if (!repository.localPath)
|
|
return {
|
|
kind: "link",
|
|
title: "Connect this repository",
|
|
detail: "Link an existing local folder or clone it from Gitea.",
|
|
};
|
|
const status = repository.localStatus;
|
|
if (!status)
|
|
return {
|
|
kind: "error",
|
|
title: "Local repository unavailable",
|
|
detail: repository.attentionReason || "The linked folder could not be read.",
|
|
};
|
|
if (status.counts.conflicts)
|
|
return {
|
|
kind: "conflict",
|
|
title: "Resolve merge conflicts",
|
|
detail: `${status.counts.conflicts} conflicted file${status.counts.conflicts === 1 ? "" : "s"} block deployment.`,
|
|
};
|
|
if (status.counts.changed)
|
|
return {
|
|
kind: "commit",
|
|
title: "Commit local changes",
|
|
detail: `${status.counts.changed} changed file${status.counts.changed === 1 ? "" : "s"} detected.`,
|
|
};
|
|
if (!repository.deploymentProfiles?.length)
|
|
return {
|
|
kind: "configure",
|
|
title: "Configure deployment",
|
|
detail: "Connect an Unraid server or a Gitea Actions workflow before deploying.",
|
|
};
|
|
const profile = selectedProfile(repository);
|
|
if (profile?.branch !== status.branch.head)
|
|
return {
|
|
kind: "branch-profile",
|
|
title: "No deployment for this branch",
|
|
detail: `The selected profile accepts ${profile.branch}; you are on ${status.branch.head}.`,
|
|
};
|
|
if (canDirectPushDeploy(repository, profile))
|
|
return {
|
|
kind: "deploy",
|
|
title: "Ready for direct redeploy",
|
|
detail: `ForgeFlow will copy committed HEAD ${status.shortHead} directly to ${profile.environment} over the configured desktop → Unraid connection.`,
|
|
};
|
|
if (status.branch.behind && status.branch.ahead)
|
|
return {
|
|
kind: "diverged",
|
|
title: "Branches have diverged",
|
|
detail: `Local is ${status.branch.ahead} ahead and ${status.branch.behind} behind.`,
|
|
};
|
|
if (status.branch.behind)
|
|
return {
|
|
kind: "pull",
|
|
title: "Synchronize from Gitea",
|
|
detail: `Local ${status.branch.head} is ${status.branch.behind} commit${status.branch.behind === 1 ? "" : "s"} behind.`,
|
|
};
|
|
if (status.branch.ahead)
|
|
return {
|
|
kind: "push",
|
|
title: "Push local commits",
|
|
detail: `${status.branch.ahead} commit${status.branch.ahead === 1 ? "" : "s"} ready to push.`,
|
|
};
|
|
if (repository.readyToDeploy)
|
|
return {
|
|
kind: "deploy",
|
|
title: "Ready for deployment",
|
|
detail: `Commit ${status.shortHead} can be released to ${profile.environment}.`,
|
|
};
|
|
return {
|
|
kind: "clean",
|
|
title: "Repository synchronized",
|
|
detail: "No local or remote action is required.",
|
|
};
|
|
}
|