Release ForgeFlow 0.6.0
This commit is contained in:
+224
-27
@@ -44,7 +44,8 @@ const icons = {
|
||||
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"/>'
|
||||
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 = '') {
|
||||
@@ -116,6 +117,7 @@ const ui = {
|
||||
setupDraft: { baseUrl: 'https://', token: '', user: null, roots: [], discovered: [] },
|
||||
setupValidation: null,
|
||||
activeDeployment: null,
|
||||
operationPollTimer: null,
|
||||
isMock: false,
|
||||
refreshError: null,
|
||||
autoRefreshPending: false,
|
||||
@@ -123,7 +125,8 @@ const ui = {
|
||||
updateStatus: null,
|
||||
updateChecking: false,
|
||||
servers: [],
|
||||
serverInspection: null
|
||||
serverInspection: null,
|
||||
gitRecovery: null
|
||||
};
|
||||
|
||||
function selectedRepository() { return ui.repositories.find((repository) => String(repository.id) === String(ui.selectedRepoId)) || null; }
|
||||
@@ -157,6 +160,30 @@ function updateOperationInState(operation) {
|
||||
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();
|
||||
@@ -166,11 +193,14 @@ async function bootstrap() {
|
||||
ui.setupDraft.roots = [...(ui.boot.state.workspaceRoots || [])];
|
||||
if (ui.boot.state.setupComplete) {
|
||||
await refreshRepositories(false);
|
||||
await refreshActiveOperations(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) => {
|
||||
for (const operation of payload?.operations || []) updateOperationInState(operation);
|
||||
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) => {
|
||||
@@ -179,6 +209,16 @@ async function bootstrap() {
|
||||
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>`;
|
||||
}
|
||||
@@ -225,11 +265,37 @@ async function refreshActiveOperations(showErrors = true) {
|
||||
for (const operation of Array.isArray(updated) ? updated : []) updateOperationInState(operation);
|
||||
return updated;
|
||||
} catch (error) {
|
||||
if (showErrors) showToast('Actions status unavailable', error.message, 'error');
|
||||
if (showErrors) showToast('Deployment status unavailable', error.message, 'error');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDeploymentTruth(showErrors = false) {
|
||||
const targets = ui.repositories.flatMap((repository) =>
|
||||
(repository.deploymentProfiles || []).map((profile) => ({ repository, profile }))
|
||||
);
|
||||
if (!targets.length) return { checked: 0, failed: 0 };
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
function selectRepository(id, shouldRender = true) {
|
||||
ui.selectedRepoId = id;
|
||||
ui.currentView = 'repository';
|
||||
@@ -238,6 +304,7 @@ function selectRepository(id, shouldRender = true) {
|
||||
ui.history = [];
|
||||
ui.branches = [];
|
||||
ui.stashes = [];
|
||||
ui.gitRecovery = null;
|
||||
const repository = selectedRepository();
|
||||
ui.selectedProfileId = selectedProfile(repository)?.id || null;
|
||||
const files = repository?.localStatus?.files || [];
|
||||
@@ -264,7 +331,7 @@ function repositoryAction(repository) {
|
||||
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 synchronization.` };
|
||||
if (status.counts.changed) return { kind: 'commit', title: 'Commit local changes', detail: `${status.counts.changed} changed file${status.counts.changed === 1 ? '' : 's'} detected.` };
|
||||
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. Resolve this in your Git tooling.` };
|
||||
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. ForgeFlow can create a safety branch and repair this from Git tools.` };
|
||||
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.deploymentProfiles?.length) return { kind: 'configure', title: 'Configure deployment', detail: 'Connect a predefined Gitea Actions workflow before deploying.' };
|
||||
@@ -428,6 +495,23 @@ function environmentState(profile) {
|
||||
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 renderProfileCard(repository, profile, compact = false) {
|
||||
const state = profile.state || {};
|
||||
const health = environmentState(profile);
|
||||
@@ -437,7 +521,11 @@ function renderProfileCard(repository, profile, compact = false) {
|
||||
? `SSH / Unraid · ${profile.remoteFolder || repository.name} · ${profile.branch}`
|
||||
: `${profile.workflowFile} · ${profile.branch}`;
|
||||
const rollbackConfigured = isSsh || Boolean(profile.rollbackWorkflowFile);
|
||||
return `<article class="deploy-card ${compact ? 'compact-card' : ''}"><div class="deploy-card-header"><div><div class="eyebrow">${escapeHtml(profile.environment)}</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>Provider</span><strong>${isSsh ? 'SSH / Unraid' : 'Gitea Actions'}</strong><span>Live version</span><strong>${state.liveSha ? shortSha(state.liveSha) : 'Unknown'}</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><span>Rollback</span><strong>${rollbackConfigured ? 'Available after first deploy' : 'Not configured'}</strong></div><div class="card-actions"><button class="button" data-action="run-deployment-preflight" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon('shield')}Preflight</button><button class="button" data-action="refresh-profile-state" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon('pulse')}Check state</button>${ready ? `<button class="button primary" data-action="deploy-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon('rocket')}Deploy ${escapeHtml(repository.localStatus.shortHead)}</button>` : ''}<button class="button ghost" data-action="edit-deployment-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">Edit</button>${state.previousSha && rollbackConfigured ? `<button class="button danger" data-action="rollback-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon('undo')}Rollback</button>` : ''}</div></div></article>`;
|
||||
const dockerMan = dockerManIntegration(profile);
|
||||
const { templateReady, webUiReady, iconReady } = dockerMan;
|
||||
const dockerManReady = dockerMan.ready;
|
||||
const webUi = profile.webUiUrl || state.webUiUrl || state.dockerMan?.webUi || '';
|
||||
return `<article class="deploy-card ${compact ? 'compact-card' : ''}"><div class="deploy-card-header"><div><div class="eyebrow">${escapeHtml(profile.environment)}</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>Provider</span><strong>${isSsh ? 'SSH / Unraid' : 'Gitea Actions'}</strong><span>Live version</span><strong>${state.liveSha ? shortSha(state.liveSha) : 'Unknown'}</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>Container</span><strong>${escapeHtml(state.containerName || profile.containerName || profile.remoteFolder || repository.name)}${state.containerRunning === false ? ' · stopped' : state.containerRunning ? ' · running' : ''}</strong><span>DockerMan</span><strong class="${dockerManReady ? 'text-success' : 'text-warning'}">${dockerManReady ? (templateReady ? 'Labels/template active' : 'WebUI/icon labels active') : `WebUI ${webUiReady ? 'ready' : 'missing'} · icon ${iconReady ? 'ready' : 'missing'}`}</strong>` : ''}<span>Rollback</span><strong>${rollbackConfigured ? 'Available after first deploy' : 'Not configured'}</strong></div><div class="card-actions"><button class="button" data-action="run-deployment-preflight" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon('shield')}Preflight</button><button class="button" data-action="reconcile-deployment" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon('refresh')}Reconcile</button>${webUi ? `<button class="button" data-action="open-profile-webui" data-url="${attr(webUi)}">${icon('external')}Open Web UI</button>` : ''}${isSsh ? `<button class="button ${dockerManReady ? 'ghost' : ''}" data-action="apply-dockerman-metadata" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon('wrench')}${dockerManReady ? 'Reapply DockerMan metadata' : 'Repair DockerMan integration'}</button>` : ''}${ready ? `<button class="button primary" data-action="deploy-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon('rocket')}Deploy ${escapeHtml(repository.localStatus.shortHead)}</button>` : ''}<button class="button ghost" data-action="edit-deployment-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">Edit</button>${state.previousSha && rollbackConfigured ? `<button class="button danger" data-action="rollback-profile" data-repository-id="${attr(repository.id)}" data-profile-id="${attr(profile.id)}">${icon('undo')}Rollback</button>` : ''}</div></div></article>`;
|
||||
}
|
||||
|
||||
function renderRepositoryDeployments(repository) {
|
||||
@@ -448,7 +536,11 @@ function renderRepositoryDeployments(repository) {
|
||||
|
||||
function renderGitTools(repository) {
|
||||
if (!repository.localPath) return '<div class="empty-state full"><p>Link a local repository to manage branches and stashes.</p></div>';
|
||||
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></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) {
|
||||
@@ -456,7 +548,7 @@ function renderRepositorySettings(repository) {
|
||||
const currentOrigin = repository.localStatus?.remoteUrl || 'Unavailable';
|
||||
const desiredOrigin = repository.sshUrl || repository.preferredCloneUrl || '';
|
||||
const originNeedsRepair = Boolean(repository.localPath && desiredOrigin && currentOrigin !== desiredOrigin);
|
||||
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="repair-index-lock">${icon('key')}Repair stale Git lock</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"><h2>Repository behavior</h2><div class="notice">${icon('shield')}Origin repair changes only the Git remote URL. Lock repair refuses recent locks and never changes files or commits.</div></section></div>`;
|
||||
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"><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 renderRepositoryWorkspace(repository) {
|
||||
@@ -488,7 +580,7 @@ function renderActionPanel(repository) {
|
||||
}
|
||||
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><button class="button block" data-action="open-path">Open project folder</button><button class="button block" data-action="refresh">Refresh status</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>`;
|
||||
@@ -499,7 +591,8 @@ function renderActionPanel(repository) {
|
||||
function renderDeployments() {
|
||||
const cards = ui.repositories.flatMap((repository) => (repository.deploymentProfiles || []).map((profile) => ({ repository, profile })));
|
||||
const active = operations().filter((operation) => !isTerminalOperation(operation.status));
|
||||
return `<div class="page"><div class="page-header"><div><div class="eyebrow">Server releases</div><h1>Deployments</h1><p>Exact commits, predefined workflows, authoritative Actions status and server-side version checks.</p></div><button class="button" data-action="refresh-operations">${icon('refresh')}Refresh runs</button></div>${active.length ? `<div class="notice warning">${icon('pulse')} ${active.length} deployment operation${active.length === 1 ? ' is' : 's are'} still active.</div>` : ''}<div class="deploy-card-grid">${cards.length ? cards.map(({ repository, profile }) => renderProfileCard(repository, profile, true)).join('') : '<div class="empty-state panel"><h3>No deployment environments configured</h3><p>Open a repository and add an environment.</p></div>'}</div><section class="section-block"><div class="section-heading"><h2>All operations</h2><span class="meta">Newest first</span></div><div class="panel">${operations().length ? `<table class="data-table"><thead><tr><th>Repository</th><th>Action</th><th>Environment</th><th>Commit</th><th>Status</th><th>Updated</th><th></th></tr></thead><tbody>${operations().map((operation) => `<tr><td>${escapeHtml(operation.repository)}</td><td>${escapeHtml(operation.action || 'deploy')}</td><td>${escapeHtml(operation.environment || '—')}</td><td class="mono">${escapeHtml(operation.shortSha || shortSha(operation.sha))}</td><td><span class="status-pill ${toneForStatus(operation.status)}">${escapeHtml(operation.status)}</span></td><td>${formatDate(operation.updatedAt || operation.createdAt)}</td><td><button class="button ghost" data-action="open-operation" data-operation-id="${attr(operation.id)}">Open</button></td></tr>`).join('')}</tbody></table>` : '<div class="empty-state compact"><p>No operations recorded.</p></div>'}</div></section></div>`;
|
||||
const missingDockerMan = cards.filter(({ profile }) => profile.provider === 'ssh-unraid' && profile.state?.containerRunning && !dockerManIntegration(profile).ready);
|
||||
return `<div class="page"><div class="page-header"><div><div class="eyebrow">Server releases</div><h1>Deployments</h1><p>Exact commits, live container truth, DockerMan integration and controlled release recovery.</p></div><div class="stack horizontal compact"><button class="button" data-action="refresh-operations">${icon('refresh')}Refresh runs & servers</button>${missingDockerMan.length ? `<button class="button primary" data-action="repair-missing-dockerman">${icon('wrench')}Repair ${missingDockerMan.length} missing integration${missingDockerMan.length === 1 ? '' : 's'}</button>` : ''}</div></div>${active.length ? `<div class="notice warning">${icon('pulse')} ${active.length} deployment operation${active.length === 1 ? ' is' : 's are'} still active. ForgeFlow reconciles these against the live server automatically.</div>` : ''}<div class="deploy-card-grid">${cards.length ? cards.map(({ repository, profile }) => renderProfileCard(repository, profile, true)).join('') : '<div class="empty-state panel"><h3>No deployment environments configured</h3><p>Open a repository and add an environment.</p></div>'}</div><section class="section-block"><div class="section-heading"><h2>All operations</h2><span class="meta">Newest first</span></div><div class="panel">${operations().length ? `<table class="data-table"><thead><tr><th>Repository</th><th>Action</th><th>Environment</th><th>Commit</th><th>Status</th><th>Updated</th><th></th></tr></thead><tbody>${operations().map((operation) => `<tr><td>${escapeHtml(operation.repository)}</td><td>${escapeHtml(operation.action || 'deploy')}</td><td>${escapeHtml(operation.environment || '—')}</td><td class="mono">${escapeHtml(operation.shortSha || shortSha(operation.sha))}</td><td><span class="status-pill ${toneForStatus(operation.status)}">${escapeHtml(operation.status)}</span></td><td>${formatDate(operation.updatedAt || operation.createdAt)}</td><td><button class="button ghost" data-action="open-operation" data-operation-id="${attr(operation.id)}">Open</button></td></tr>`).join('')}</tbody></table>` : '<div class="empty-state compact"><p>No operations recorded.</p></div>'}</div></section></div>`;
|
||||
}
|
||||
|
||||
function renderSettings() {
|
||||
@@ -576,7 +669,7 @@ function renderSetup() {
|
||||
: 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" src="./assets/itworx-wordmark.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>`;
|
||||
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>`;
|
||||
}
|
||||
|
||||
function renderModal() {
|
||||
@@ -595,11 +688,12 @@ function renderModal() {
|
||||
<label class="check-field"><input id="profile-align-remote" type="checkbox" ${existing.alignRemote === true ? 'checked' : ''}/><span>Align an existing server origin to this URL</span></label>
|
||||
<div class="field"><label>Compose mode</label><select id="profile-generated-compose" class="select"><option value="false" ${existing.generatedCompose !== true ? 'selected' : ''}>Use Compose file from repository/server</option><option value="true" ${existing.generatedCompose === true ? 'selected' : ''}>Generate a basic ForgeFlow Compose file</option></select></div>
|
||||
<div class="field"><label>Compose file</label><input id="profile-compose-file" class="input" value="${attr(existing.composeFile || 'docker-compose.yml')}"/></div>
|
||||
<div class="field"><label>Compose service / container name</label><input id="profile-compose-service" class="input" value="${attr(existing.composeService || safeCloneFolderName(repository).toLowerCase())}"/></div>
|
||||
<div class="field"><label>Compose service (internal)</label><input id="profile-compose-service" class="input" value="${attr(existing.composeService || safeCloneFolderName(repository).toLowerCase())}"/><small>Must match the Compose service key and remain lowercase.</small></div><div class="field"><label>Visible container name</label><input id="profile-container-name" class="input" value="${attr(existing.containerName || remoteFolder)}"/><small>May remain Portfolio while internal image/service names are lowercase.</small></div>
|
||||
<div class="field"><label>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 full"><label>Unraid icon URL (optional)</label><input id="profile-icon-url" class="input" value="${attr(existing.iconUrl || '')}" placeholder="https://…/icon.png"/></div>
|
||||
<div class="field"><label>DockerMan icon source</label><select id="profile-icon-mode" class="select"><option value="builtin" ${(existing.iconMode || (!existing.iconUrl && !existing.iconFilePath ? 'builtin' : existing.iconFilePath ? 'upload' : 'url')) === 'builtin' ? 'selected' : ''}>Built-in high-contrast ITWorx mark</option><option value="upload" ${existing.iconMode === 'upload' || (!existing.iconMode && existing.iconFilePath) ? 'selected' : ''}>Upload local PNG</option><option value="url" ${existing.iconMode === 'url' || (!existing.iconMode && existing.iconUrl) ? 'selected' : ''}>Use icon URL</option><option value="none" ${existing.iconMode === 'none' ? 'selected' : ''}>No custom icon</option></select></div><div class="field"><label>Container shell</label><select id="profile-docker-shell" class="select"><option value="/bin/sh" ${(existing.dockerShell || '/bin/sh') === '/bin/sh' ? 'selected' : ''}>/bin/sh</option><option value="/bin/bash" ${existing.dockerShell === '/bin/bash' ? 'selected' : ''}>/bin/bash</option></select></div>
|
||||
<div class="field full"><label>DockerMan icon URL</label><input id="profile-icon-url" class="input" value="${attr(existing.iconUrl || '')}" placeholder="https://…/icon.png"/></div><div class="field full"><label>Local PNG</label><div class="inline-form"><input id="profile-icon-file" class="input mono" value="${attr(existing.iconFilePath || '')}" placeholder="Select a local transparent PNG" readonly/><button class="button" data-action="select-profile-icon">${icon('folder')}Browse</button><button class="button ghost" data-action="clear-profile-icon">Clear</button></div><small>Built-in or uploaded PNGs are copied to DockerMan's persistent image folder and referenced through a file:/// URL. ForgeFlow also refreshes the relevant Unraid icon cache after recreating the container.</small></div>
|
||||
<div class="field full"><label>Healthcheck URL from this desktop (optional)</label><input id="profile-healthcheck" class="input" value="${attr(existing.healthcheckUrl || '')}" placeholder="http://unraid:1223/health"/></div>
|
||||
<div class="field full"><label>Preserve server-only paths</label><input id="profile-preserve-paths" class="input" value="${attr((existing.preservePaths || ['.env','appdata','data','logs','config','compose.override.yml']).join(', '))}"/><small>These untracked runtime paths remain untouched by Git deployments.</small></div>
|
||||
` : `
|
||||
@@ -693,7 +787,8 @@ async function executeDeployment(profileId) {
|
||||
ui.activeDeployment = await window.forgeflow.deploy(repository, profile.id, repository.localStatus.head);
|
||||
updateOperationInState(ui.activeDeployment);
|
||||
ui.currentView = 'deployment-run';
|
||||
showToast('Deployment requested', `${repository.name} ${repository.localStatus.shortHead} → ${profile.environment}`, 'success');
|
||||
showToast('Deployment started', `${repository.name} ${repository.localStatus.shortHead} → ${profile.environment}`, 'success');
|
||||
startOperationPolling();
|
||||
} catch (error) { showToast('Deployment failed to start', error.message, 'error'); }
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -718,7 +813,11 @@ async function loadGitTools(repository) {
|
||||
if (!repository?.localPath) return;
|
||||
setLoading(true, 'Loading branches and stashes…');
|
||||
try {
|
||||
[ui.branches, ui.stashes] = await Promise.all([window.forgeflow.branches(repository.localPath), window.forgeflow.stashList(repository.localPath)]);
|
||||
[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);
|
||||
@@ -780,8 +879,17 @@ app.addEventListener('click', async (event) => {
|
||||
|
||||
if (action === 'navigate') { ui.currentView = target.dataset.view; ui.modal = null; render(); }
|
||||
else if (action === 'select-repo') selectRepository(target.dataset.id);
|
||||
else if (action === 'refresh') await refreshRepositories(true);
|
||||
else if (action === 'refresh-operations') { setLoading(true, 'Refreshing Gitea Actions runs…'); await refreshActiveOperations(); setLoading(false); }
|
||||
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') {
|
||||
@@ -832,6 +940,11 @@ app.addEventListener('click', async (event) => {
|
||||
else if (action === 'configure-deployment') { ui.modal = { type: 'deployment-config', profileId: null, provider: (ui.boot.state.servers || []).length ? 'ssh-unraid' : 'gitea-actions' }; render(); }
|
||||
else if (action === 'edit-deployment-profile') { if (!repository) repository = profileRepository(target.dataset.profileId); 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 === 'save-deployment-profile') {
|
||||
const provider = document.querySelector('#profile-provider').value;
|
||||
const profile = {
|
||||
@@ -850,10 +963,14 @@ app.addEventListener('click', async (event) => {
|
||||
generatedCompose: document.querySelector('#profile-generated-compose').value === 'true',
|
||||
composeFile: document.querySelector('#profile-compose-file').value.trim(),
|
||||
composeService: document.querySelector('#profile-compose-service').value.trim(),
|
||||
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)
|
||||
} : {
|
||||
workflowFile: document.querySelector('#profile-workflow').value.trim(),
|
||||
@@ -894,10 +1011,52 @@ app.addEventListener('click', async (event) => {
|
||||
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 === 'open-operation') { const operation = await window.forgeflow.getOperation(target.dataset.operationId); if (operation) { ui.activeDeployment = operation; ui.currentView = 'deployment-run'; render(); } }
|
||||
else if (action === 'refresh-current-operation') { setLoading(true, 'Refreshing Actions run…'); try { const operation = await window.forgeflow.refreshOperations(ui.activeDeployment.id); updateOperationInState(operation); } catch (error) { showToast('Status refresh 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') { ui.activeDeployment = null; ui.currentView = selectedRepository() ? 'repository' : 'deployments'; render(); }
|
||||
else if (action === 'close-deployment') { stopOperationPolling(); ui.activeDeployment = null; ui.currentView = selectedRepository() ? 'repository' : 'deployments'; render(); }
|
||||
else 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); }
|
||||
@@ -997,12 +1156,50 @@ app.addEventListener('click', async (event) => {
|
||||
} catch (error) { showToast('Could not normalize origins', error.message, 'error'); }
|
||||
setLoading(false); render();
|
||||
}
|
||||
else if (action === 'repair-index-lock') {
|
||||
if (!repository?.localPath || !confirm('Remove the stale Git index.lock for this repository? Only continue after other Git tools have stopped.')) return;
|
||||
setLoading(true, 'Repairing stale Git lock…');
|
||||
try { await window.forgeflow.repairIndexLock(repository.localPath); await refreshRepositories(false); showToast('Git lock removed', 'The repository can accept Git changes again.', 'success'); }
|
||||
catch (error) { showToast('Could not remove Git lock', error.message, 'error'); }
|
||||
setLoading(false);
|
||||
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-system-preflight') await runSystemPreflight();
|
||||
else if (action === 'save-diagnostics-preferences') {
|
||||
|
||||
Reference in New Issue
Block a user