fix: repair broken IPC wiring and cut the cost of repository polling

Three handlers referenced a dependency they were never given, which made them
throw a ReferenceError as soon as they ran:

- deployment:preflight for Gitea Actions profiles (`preflight` was passed to
  registerOperationsIpc but not to registerDeploymentIpc)
- Unraid write-access repair (`safeRelativeRemoteFile` was missing from
  createUnraidAccessMethods)
- a dead reference of the same name in unraid-state-methods

no-undef and no-unused-vars were disabled for every file, which is why none of
these were caught. Both are now enabled for src/main and src/shared, where the
dependency graph is explicit. The renderer keeps them off because its functions
are deliberately cross-script globals.

Performance:

- git.status() spawned three processes (rev-parse, status, remote get-url) per
  call. A directory holding its own .git is by definition the work tree root, so
  rev-parse is unnecessary, and the remote URL is cached against the mtime of
  .git/config, including the failure for a repository without that remote.
- git status runs with --no-optional-locks so a read no longer rewrites the
  index. That stops it fighting a concurrent Git command for the index lock, and
  is what makes filesystem watching viable at all.
- One commit issued four `git status` reads; callers that already hold the
  status now pass it on, leaving two.
- The repository monitor is event driven. A watched repository is read on
  filesystem activity, with a 30s safety net for watchers that stop delivering
  and a 1s floor so a busy tree cannot drive a read per event. Repositories that
  cannot be watched keep using the interval. Idle cost for one repository over
  35s: 24 git processes before, 3 after.
- Resolving one repository by name no longer refreshes the whole workspace.
- Concurrent configuration saves share a single write of the latest state.
- Repository discovery follows directory junctions again. The filter that
  skipped them made the realpath cycle guard dead code, and hid any project
  folder reached through a junction.

Renderer:

- render() replaced the whole shell on every poll, discarding focus, caret and
  scroll position while the user was typing. Those are preserved now, and an
  unchanged render leaves the DOM alone entirely.
- The four sections that enhanceRenderedUi() injected after render moved into
  the views, so the rendered markup is the single source of truth.
- The monitor no longer keeps a repository paused forever when it is unlinked
  mid-mutation, scheduleAutoRefresh honours its delay argument, the demo bridges
  no longer block startup, and #app is no longer an aria-live region announcing
  the entire UI on every render.

IPC channel plumbing moved to src/main/ipc/channel.cjs, replacing a module-level
mutable diagnostics singleton with an argument.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
NuklearRabbit
2026-08-23 14:31:58 +02:00
co-authored by Claude Opus 5
parent cf1da8a2fa
commit 9260d35957
21 changed files with 858 additions and 147 deletions
+28
View File
@@ -29,6 +29,34 @@ export default [
eqeqeq: ["error", "always", { null: "ignore" }], eqeqeq: ["error", "always", { null: "ignore" }],
}, },
}, },
{
// The main process and shared modules are plain CommonJS with an explicit
// dependency graph, so undefined identifiers there are always real bugs
// (missing require, missing injected dependency) rather than a global that
// another script tag happens to define.
files: ["src/main/**/*.cjs", "src/shared/**/*.cjs", "main.cjs", "preload.cjs"],
languageOptions: {
sourceType: "commonjs",
globals: {
require: "readonly", module: "writable", exports: "writable",
__dirname: "readonly", __filename: "readonly",
Buffer: "readonly", process: "readonly", console: "readonly",
setTimeout: "readonly", clearTimeout: "readonly",
setInterval: "readonly", clearInterval: "readonly", setImmediate: "readonly",
queueMicrotask: "readonly", structuredClone: "readonly", globalThis: "readonly",
URL: "readonly", URLSearchParams: "readonly", fetch: "readonly",
FormData: "readonly", Blob: "readonly",
AbortController: "readonly", AbortSignal: "readonly",
TextEncoder: "readonly", TextDecoder: "readonly",
},
},
rules: {
"no-undef": "error",
// Also catches code that a refactor left behind, such as a value computed
// from a dependency that is no longer injected.
"no-unused-vars": ["error", { args: "none", caughtErrors: "none", ignoreRestSiblings: true }],
},
},
{ {
files: ["tests/**/*.mjs"], files: ["tests/**/*.mjs"],
rules: { rules: {
+16 -3
View File
@@ -56,6 +56,8 @@ class ConfigStore {
this.sessionToken = null; this.sessionToken = null;
this.data = structuredClone(DEFAULT_CONFIG); this.data = structuredClone(DEFAULT_CONFIG);
this.saveQueue = Promise.resolve(); this.saveQueue = Promise.resolve();
this.pendingSave = null;
this.lastWrittenSnapshot = null;
} }
migrate(parsed) { migrate(parsed) {
@@ -138,16 +140,27 @@ class ConfigStore {
} }
async save() { async save() {
const snapshot = JSON.stringify(this.data, null, 2); // Several callers persist in quick succession (a server scan writes deployment
// state per workload). Serializing the configuration once per call is the
// expensive part, so saves that are still queued share a single write of the
// latest data. That is equivalent because every caller asks for "persist the
// current configuration", not "persist the snapshot I saw".
if (this.pendingSave) return this.pendingSave;
const operation = async () => { const operation = async () => {
this.pendingSave = null;
const snapshot = JSON.stringify(this.data, null, 2);
if (snapshot === this.lastWrittenSnapshot
&& await fs.access(this.filePath).then(() => true).catch(() => false)) return;
await fs.mkdir(path.dirname(this.filePath), { recursive: true }); await fs.mkdir(path.dirname(this.filePath), { recursive: true });
const temporary = `${this.filePath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`; const temporary = `${this.filePath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`;
await fs.writeFile(temporary, snapshot, { mode: 0o600 }); await fs.writeFile(temporary, snapshot, { mode: 0o600 });
await fs.rename(temporary, this.filePath); await fs.rename(temporary, this.filePath);
try { await fs.chmod(this.filePath, 0o600); } catch {} try { await fs.chmod(this.filePath, 0o600); } catch {}
this.lastWrittenSnapshot = snapshot;
}; };
this.saveQueue = this.saveQueue.then(operation, operation); this.pendingSave = this.saveQueue.then(operation, operation);
return this.saveQueue; this.saveQueue = this.pendingSave.catch(() => {});
return this.pendingSave;
} }
getGitValidatorState(fullName) { getGitValidatorState(fullName) {
-4
View File
@@ -36,10 +36,6 @@ function isRunningStatus(value) {
return ['running', 'in_progress', 'processing'].includes(String(value || '').toLowerCase()); return ['running', 'in_progress', 'processing'].includes(String(value || '').toLowerCase());
} }
function isQueuedStatus(value) {
return ['pending', 'queued', 'waiting', 'blocked', 'requested'].includes(String(value || '').toLowerCase());
}
class DeploymentService { class DeploymentService {
constructor(store, giteaService, gitService, diagnostics = null) { constructor(store, giteaService, gitService, diagnostics = null) {
this.store = store; this.store = store;
+66 -11
View File
@@ -29,6 +29,13 @@ function parseUnifiedDiff(diffText) {
} }
class GitService { class GitService {
constructor() {
// `git remote get-url` is only re-run when the repository configuration file
// itself changed. Status polling asks for the remote URL of every repository
// every few seconds, and on Windows the child process dominates that cost.
this.remoteUrlCache = new Map();
}
async isAvailable() { async isAvailable() {
try { try {
const result = await run('git', ['--version'], { timeout: 10_000 }); const result = await run('git', ['--version'], { timeout: 10_000 });
@@ -42,13 +49,24 @@ class GitService {
const resolved = assertSafeRepositoryPath(repoPath); const resolved = assertSafeRepositoryPath(repoPath);
const stat = await fs.stat(resolved).catch(() => null); const stat = await fs.stat(resolved).catch(() => null);
if (!stat?.isDirectory()) throw new Error('The linked local folder no longer exists.'); if (!stat?.isDirectory()) throw new Error('The linked local folder no longer exists.');
// A directory that carries its own `.git` entry is by definition the top level
// of that working tree, for plain repositories as well as for submodules and
// linked worktrees where `.git` is a file. Spawning `git rev-parse` to learn
// that again is pure overhead, and every status poll passes an already
// resolved repository root back in.
const marker = await fs.stat(path.join(resolved, '.git')).catch(() => null);
if (marker) return resolved;
const result = await run('git', ['rev-parse', '--show-toplevel'], { cwd: resolved, timeout: 15_000 }); const result = await run('git', ['rev-parse', '--show-toplevel'], { cwd: resolved, timeout: 15_000 });
return path.resolve(result.stdout.trim()); return path.resolve(result.stdout.trim());
} }
async status(repoPath) { async status(repoPath) {
const root = await this.ensureRepository(repoPath); const root = await this.ensureRepository(repoPath);
const result = await run('git', ['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all'], { // `--no-optional-locks` keeps a status read from refreshing and rewriting the
// index. Without it every read writes inside .git, which both fights a
// concurrent Git command for the index lock and retriggers the filesystem
// watcher that asked for this read in the first place.
const result = await run('git', ['--no-optional-locks', 'status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all'], {
cwd: root, cwd: root,
timeout: 30_000 timeout: 30_000
}); });
@@ -66,9 +84,34 @@ class GitService {
}); });
} }
remoteUrlCacheKey(repoPath, remote) {
return JSON.stringify([path.resolve(repoPath), remote]);
}
async getRemoteUrl(repoPath, remote = 'origin') { async getRemoteUrl(repoPath, remote = 'origin') {
const result = await run('git', ['remote', 'get-url', remote], { cwd: repoPath, timeout: 15_000 }); const cacheKey = this.remoteUrlCacheKey(repoPath, remote);
return result.stdout.trim(); const config = await fs.stat(path.join(repoPath, '.git', 'config')).catch(() => null);
const cached = this.remoteUrlCache.get(cacheKey);
if (config && cached && cached.mtimeMs === config.mtimeMs && cached.size === config.size) {
if (cached.error) throw cached.error;
return cached.url;
}
const remember = (entry) => {
if (config) this.remoteUrlCache.set(cacheKey, { ...entry, mtimeMs: config.mtimeMs, size: config.size });
else this.remoteUrlCache.delete(cacheKey);
};
try {
const result = await run('git', ['remote', 'get-url', remote], { cwd: repoPath, timeout: 15_000 });
const url = result.stdout.trim();
remember({ url, error: null });
return url;
} catch (error) {
// A repository that has no such remote keeps failing until its configuration
// changes, so the failure is remembered too. Without this, every status poll
// of an unmatched local repository spawns a child process that cannot succeed.
remember({ url: '', error });
throw error;
}
} }
@@ -279,6 +322,7 @@ class GitService {
const name = String(remote || 'origin').trim(); const name = String(remote || 'origin').trim();
if (!/^[A-Za-z0-9._-]+$/.test(name)) throw new Error('Invalid Git remote name.'); if (!/^[A-Za-z0-9._-]+$/.test(name)) throw new Error('Invalid Git remote name.');
await run('git', ['remote', 'set-url', name, safeRemote], { cwd: root, timeout: 30_000 }); await run('git', ['remote', 'set-url', name, safeRemote], { cwd: root, timeout: 30_000 });
this.remoteUrlCache.delete(this.remoteUrlCacheKey(root, name));
return this.status(root); return this.status(root);
} }
@@ -354,8 +398,7 @@ class GitService {
return { selected, matches }; return { selected, matches };
} }
async expandSelectedPaths(root, files, { unstagedOnly = false } = {}) { expandStatusPaths(status, files, { unstagedOnly = false } = {}) {
const status = await this.status(root);
const { selected, matches } = this.selectedStatusFiles(status, files); const { selected, matches } = this.selectedStatusFiles(status, files);
if (!selected.length) return []; if (!selected.length) return [];
const expanded = new Set(); const expanded = new Set();
@@ -367,12 +410,17 @@ class GitService {
return [...expanded]; return [...expanded];
} }
async stage(repoPath, files) { async expandSelectedPaths(root, files, options = {}) {
const root = await this.ensureRepository(repoPath); return this.expandStatusPaths(await this.status(root), files, options);
}
// Callers that already read the status pass it in. Reading it again costs a
// child process, and a commit used to pay for four of them.
async applyStage(root, files, knownStatus = null) {
const requested = assertRepositoryRelativePaths(files); const requested = assertRepositoryRelativePaths(files);
if (!requested.length) { if (!requested.length) {
await run('git', ['add', '--all'], { cwd: root, timeout: 60_000 }); await run('git', ['add', '--all'], { cwd: root, timeout: 60_000 });
return this.status(root); return;
} }
// Only stage records that still have a worktree-side change. Re-running // Only stage records that still have a worktree-side change. Re-running
@@ -380,10 +428,16 @@ class GitService {
// Git fail with "pathspec did not match any files" because the file no // Git fail with "pathspec did not match any files" because the file no
// longer exists in either the worktree or HEAD. Staged-only deletions and // longer exists in either the worktree or HEAD. Staged-only deletions and
// renames are already ready for commit and must therefore be left alone. // renames are already ready for commit and must therefore be left alone.
const selected = await this.expandSelectedPaths(root, requested, { unstagedOnly: true }); const status = knownStatus || await this.status(root);
const selected = this.expandStatusPaths(status, requested, { unstagedOnly: true });
if (selected.length) { if (selected.length) {
await this.runWithPathspec(root, ['add', '-A'], selected, { timeout: 120_000 }); await this.runWithPathspec(root, ['add', '-A'], selected, { timeout: 120_000 });
} }
}
async stage(repoPath, files) {
const root = await this.ensureRepository(repoPath);
await this.applyStage(root, files);
return this.status(root); return this.status(root);
} }
@@ -403,8 +457,9 @@ class GitService {
async prepareSelectedStage(root, files) { async prepareSelectedStage(root, files) {
const selected = assertRepositoryRelativePaths(files); const selected = assertRepositoryRelativePaths(files);
let current = null;
if (selected.length) { if (selected.length) {
const current = await this.status(root); current = await this.status(root);
const excludedStaged = current.files const excludedStaged = current.files
.filter((file) => file.staged) .filter((file) => file.staged)
.filter((file) => !selected.includes(file.path) && !(file.originalPath && selected.includes(file.originalPath))) .filter((file) => !selected.includes(file.path) && !(file.originalPath && selected.includes(file.originalPath)))
@@ -413,7 +468,7 @@ class GitService {
throw new Error(`Some staged files are not selected (${excludedStaged.slice(0, 3).join(', ')}${excludedStaged.length > 3 ? ', …' : ''}). Select them or unstage them first.`); throw new Error(`Some staged files are not selected (${excludedStaged.slice(0, 3).join(', ')}${excludedStaged.length > 3 ? ', …' : ''}). Select them or unstage them first.`);
} }
} }
await this.stage(root, selected); await this.applyStage(root, selected, current);
const stagedCheck = await run('git', ['diff', '--cached', '--quiet'], { cwd: root, allowExitCodes: [1] }); const stagedCheck = await run('git', ['diff', '--cached', '--quiet'], { cwd: root, allowExitCodes: [1] });
if (stagedCheck.exitCode === 0) throw new Error('There are no staged changes to commit.'); if (stagedCheck.exitCode === 0) throw new Error('There are no staged changes to commit.');
return selected; return selected;
+14 -68
View File
@@ -1,13 +1,17 @@
"use strict"; "use strict";
const path = require("node:path"); const path = require("node:path");
const fs = require("node:fs/promises"); const fs = require("node:fs/promises");
const { fileURLToPath } = require("node:url"); const { dialog, shell, app } = require("electron");
const { ipcMain, dialog, shell, app } = require("electron");
const { matchRemoteToRepository } = require("../shared/repository-match.cjs"); const { matchRemoteToRepository } = require("../shared/repository-match.cjs");
const { const {
cloneDirectoryName, cloneDirectoryName,
resolveCloneTarget, resolveCloneTarget,
} = require("../shared/clone-target.cjs"); } = require("../shared/clone-target.cjs");
const {
createChannelRegistrar,
assertTrustedSender,
toErrorPayload,
} = require("./ipc/channel.cjs");
const { registerRepositoryIpc } = require("./ipc/repository-handlers.cjs"); const { registerRepositoryIpc } = require("./ipc/repository-handlers.cjs");
const { registerDeploymentIpc } = require("./ipc/deployment-handlers.cjs"); const { registerDeploymentIpc } = require("./ipc/deployment-handlers.cjs");
const { registerOperationsIpc } = require("./ipc/operations-handlers.cjs"); const { registerOperationsIpc } = require("./ipc/operations-handlers.cjs");
@@ -16,66 +20,6 @@ const {
readEncryptedBackup, readEncryptedBackup,
} = require("./configuration-backup.cjs"); } = require("./configuration-backup.cjs");
const { evaluateDeploymentPolicy } = require("../shared/deployment-policy.cjs"); const { evaluateDeploymentPolicy } = require("../shared/deployment-policy.cjs");
let diagnosticsService = null;
const TRUSTED_RENDERER_PATH = path.resolve(
__dirname,
"..",
"renderer",
"index.html",
);
function toErrorPayload(error) {
return {
message: error?.message || "Unknown error",
code: error?.code || null,
status: error?.status || null,
recoverable: Boolean(error?.recoverable),
commitSha: error?.commitSha || null,
};
}
function assertTrustedSender(event) {
const url = event?.senderFrame?.url || event?.sender?.getURL?.() || "";
try {
const parsed = new URL(url);
if (parsed.protocol !== "file:") throw new Error("not a file URL");
const senderPath = path.resolve(fileURLToPath(parsed));
const normalize = (value) =>
process.platform === "win32" ? value.toLowerCase() : value;
if (normalize(senderPath) !== normalize(TRUSTED_RENDERER_PATH))
throw new Error("unexpected renderer file");
} catch {
throw new Error("Rejected IPC request from an untrusted renderer origin.");
}
}
function register(channel, handler) {
ipcMain.handle(channel, async (event, payload) => {
const started = Date.now();
try {
assertTrustedSender(event);
const data = await handler(payload || {}, event);
await diagnosticsService?.debug("ipc.completed", {
channel,
durationMs: Date.now() - started,
});
return { ok: true, data };
} catch (error) {
await diagnosticsService?.error("ipc.failed", {
channel,
durationMs: Date.now() - started,
error: {
name: error?.name,
message: error?.message,
code: error?.code,
status: error?.status,
stack: error?.stack,
},
});
return { ok: false, error: toErrorPayload(error) };
}
});
}
function registerIpc({ function registerIpc({
store, store,
git, git,
@@ -95,7 +39,7 @@ function registerIpc({
monitor, monitor,
onPreferencesChanged, onPreferencesChanged,
}) { }) {
diagnosticsService = diagnostics; const register = createChannelRegistrar(diagnostics);
const repositoryMutations = new Map(); const repositoryMutations = new Map();
const withRepositoryPause = async (localPath, action) => { const withRepositoryPause = async (localPath, action) => {
monitor?.pause(localPath); monitor?.pause(localPath);
@@ -161,8 +105,11 @@ function registerIpc({
await repositories.refresh(); await repositories.refresh();
knownPaths = repositories.getWatchPaths(); knownPaths = repositories.getWatchPaths();
} }
const canonicalKnown = await Promise.all(knownPaths.map(canonicalPath)); // Watch paths are already canonical, so re-resolving all of them on every
if (!canonicalKnown.some((known) => known === candidate)) // guarded call is only needed when the cheap comparison finds no match.
const matched = knownPaths.some((known) => path.resolve(known) === candidate)
|| (await Promise.all(knownPaths.map(canonicalPath))).some((known) => known === candidate);
if (!matched)
throw new Error( throw new Error(
"The requested local repository is not linked or discovered by ForgeFlow.", "The requested local repository is not linked or discovered by ForgeFlow.",
); );
@@ -172,9 +119,7 @@ function registerIpc({
const resolveRepository = async (repositoryPayload) => { const resolveRepository = async (repositoryPayload) => {
const fullName = String(repositoryPayload?.fullName || "").trim(); const fullName = String(repositoryPayload?.fullName || "").trim();
if (!fullName) throw new Error("Repository identity is required."); if (!fullName) throw new Error("Repository identity is required.");
const current = (await repositories.refresh()).find( const current = await repositories.resolveByFullName(fullName);
(item) => item.fullName === fullName,
);
if (!current) if (!current)
throw new Error( throw new Error(
"The repository is no longer available through the configured Gitea account.", "The repository is no longer available through the configured Gitea account.",
@@ -732,6 +677,7 @@ function registerIpc({
registerDeploymentIpc({ registerDeploymentIpc({
register, store, resolveRepository, unraid, deployments, evaluateDeploymentPolicy, register, store, resolveRepository, unraid, deployments, evaluateDeploymentPolicy,
audit, deployKeys, repositories, inventoryReviews, diagnostics, git, gitea, ssh, audit, deployKeys, repositories, inventoryReviews, diagnostics, git, gitea, ssh,
preflight,
}); });
registerOperationsIpc({ registerOperationsIpc({
register, store, unraid, deployments, diagnostics, shell, dialog, path, app, register, store, unraid, deployments, diagnostics, shell, dialog, path, app,
+77
View File
@@ -0,0 +1,77 @@
"use strict";
const path = require("node:path");
const { fileURLToPath } = require("node:url");
const { ipcMain } = require("electron");
const TRUSTED_RENDERER_PATH = path.resolve(
__dirname,
"..",
"..",
"renderer",
"index.html",
);
function toErrorPayload(error) {
return {
message: error?.message || "Unknown error",
code: error?.code || null,
status: error?.status || null,
recoverable: Boolean(error?.recoverable),
commitSha: error?.commitSha || null,
};
}
function assertTrustedSender(event) {
const url = event?.senderFrame?.url || event?.sender?.getURL?.() || "";
try {
const parsed = new URL(url);
if (parsed.protocol !== "file:") throw new Error("not a file URL");
const senderPath = path.resolve(fileURLToPath(parsed));
const normalize = (value) =>
process.platform === "win32" ? value.toLowerCase() : value;
if (normalize(senderPath) !== normalize(TRUSTED_RENDERER_PATH))
throw new Error("unexpected renderer file");
} catch {
throw new Error("Rejected IPC request from an untrusted renderer origin.");
}
}
// Built per registerIpc() call so the diagnostics sink is an argument instead of
// module-level mutable state that every handler silently depends on.
function createChannelRegistrar(diagnostics) {
return function register(channel, handler) {
ipcMain.handle(channel, async (event, payload) => {
const started = Date.now();
try {
assertTrustedSender(event);
const data = await handler(payload || {}, event);
await diagnostics?.debug("ipc.completed", {
channel,
durationMs: Date.now() - started,
});
return { ok: true, data };
} catch (error) {
await diagnostics?.error("ipc.failed", {
channel,
durationMs: Date.now() - started,
error: {
name: error?.name,
message: error?.message,
code: error?.code,
status: error?.status,
stack: error?.stack,
},
});
return { ok: false, error: toErrorPayload(error) };
}
});
};
}
module.exports = {
createChannelRegistrar,
assertTrustedSender,
toErrorPayload,
TRUSTED_RENDERER_PATH,
};
+1
View File
@@ -3,6 +3,7 @@
function registerDeploymentIpc({ function registerDeploymentIpc({
register, store, resolveRepository, unraid, deployments, evaluateDeploymentPolicy, register, store, resolveRepository, unraid, deployments, evaluateDeploymentPolicy,
audit, deployKeys, repositories, inventoryReviews, diagnostics, git, gitea, ssh, audit, deployKeys, repositories, inventoryReviews, diagnostics, git, gitea, ssh,
preflight,
}) { }) {
register("deployment:save-profile", async ({ fullName, profile }) => { register("deployment:save-profile", async ({ fullName, profile }) => {
const saved = await store.saveDeploymentProfile(fullName, profile); const saved = await store.saveDeploymentProfile(fullName, profile);
+100 -4
View File
@@ -1,5 +1,16 @@
'use strict'; 'use strict';
const fs = require('node:fs');
// A watched repository is only re-read when the filesystem reports activity. The
// interval below stays as a safety net for watchers that silently stop
// delivering, which happens on network shares and removed folders.
const SAFETY_CHECK_INTERVAL_MS = 30_000;
const WATCH_DEBOUNCE_MS = 250;
// Busy trees (a build, an install, a fetch) produce a continuous event stream.
// This bounds how often that can turn into a Git read.
const MIN_WATCH_CHECK_INTERVAL_MS = 1_000;
class RepositoryMonitor { class RepositoryMonitor {
constructor({ store, git, onChange, diagnostics = null }) { constructor({ store, git, onChange, diagnostics = null }) {
this.store = store; this.store = store;
@@ -11,13 +22,87 @@ class RepositoryMonitor {
this.timer = null; this.timer = null;
this.running = false; this.running = false;
this.paused = new Set(); this.paused = new Set();
this.active = false;
this.watchers = new Map();
this.changed = new Set();
this.lastCheckedAt = new Map();
this.watchTimer = null;
} }
setPaths(paths) { setPaths(paths) {
this.paths = [...new Set((paths || []).filter(Boolean))]; this.paths = [...new Set((paths || []).filter(Boolean))];
const watched = new Set(this.paths);
for (const existing of [...this.fingerprints.keys()]) { for (const existing of [...this.fingerprints.keys()]) {
if (!this.paths.includes(existing)) this.fingerprints.delete(existing); if (!watched.has(existing)) this.fingerprints.delete(existing);
} }
// A repository that is unlinked while a mutation holds it paused would keep
// that pause forever, silently freezing its status once it is watched again.
for (const existing of [...this.paused]) {
if (!watched.has(existing)) this.paused.delete(existing);
}
for (const existing of [...this.changed]) {
if (!watched.has(existing)) this.changed.delete(existing);
}
for (const existing of [...this.lastCheckedAt.keys()]) {
if (!watched.has(existing)) this.lastCheckedAt.delete(existing);
}
this.syncWatchers();
}
syncWatchers() {
for (const [localPath, watcher] of [...this.watchers]) {
if (this.active && this.paths.includes(localPath)) continue;
this.closeWatcher(localPath, watcher);
}
if (!this.active) return;
for (const localPath of this.paths) {
if (this.watchers.has(localPath)) continue;
try {
const watcher = fs.watch(
localPath,
{ recursive: true, persistent: false },
() => this.noteFilesystemChange(localPath)
);
watcher.on('error', () => this.dropWatcher(localPath));
this.watchers.set(localPath, watcher);
} catch {
// Watching is unavailable for this folder. Leaving it unwatched makes
// shouldCheck() fall back to the interval for that repository only.
}
}
}
closeWatcher(localPath, watcher = this.watchers.get(localPath)) {
if (!watcher) return;
try { watcher.close(); } catch { /* already closed */ }
this.watchers.delete(localPath);
}
dropWatcher(localPath) {
this.closeWatcher(localPath);
this.changed.add(localPath);
}
noteFilesystemChange(localPath) {
this.changed.add(localPath);
this.scheduleWatchTick();
}
scheduleWatchTick() {
if (this.watchTimer) return;
this.watchTimer = setTimeout(() => {
this.watchTimer = null;
this.tick().catch((error) => this.diagnostics?.warning('repository-monitor.tick.failed', error));
}, WATCH_DEBOUNCE_MS);
this.watchTimer.unref?.();
}
shouldCheck(localPath, now) {
if (this.paused.has(localPath)) return false;
if (!this.watchers.has(localPath)) return true;
const sinceLastCheck = now - (this.lastCheckedAt.get(localPath) || 0);
if (this.changed.has(localPath)) return sinceLastCheck >= MIN_WATCH_CHECK_INTERVAL_MS;
return sinceLastCheck >= SAFETY_CHECK_INTERVAL_MS;
} }
pause(localPath) { if (localPath) this.paused.add(localPath); } pause(localPath) { if (localPath) this.paused.add(localPath); }
@@ -26,6 +111,8 @@ class RepositoryMonitor {
restart() { restart() {
this.stop(); this.stop();
if (!this.store.data.preferences.autoRefresh) return; if (!this.store.data.preferences.autoRefresh) return;
this.active = true;
this.syncWatchers();
const seconds = Math.min(Math.max(Number(this.store.data.preferences.repositoryPollSeconds) || 4, 2), 60); const seconds = Math.min(Math.max(Number(this.store.data.preferences.repositoryPollSeconds) || 4, 2), 60);
this.timer = setInterval(() => this.tick().catch((error) => this.diagnostics?.warning('repository-monitor.tick.failed', error)), seconds * 1000); this.timer = setInterval(() => this.tick().catch((error) => this.diagnostics?.warning('repository-monitor.tick.failed', error)), seconds * 1000);
this.timer.unref?.(); this.timer.unref?.();
@@ -34,17 +121,23 @@ class RepositoryMonitor {
stop() { stop() {
if (this.timer) clearInterval(this.timer); if (this.timer) clearInterval(this.timer);
this.timer = null; this.timer = null;
if (this.watchTimer) clearTimeout(this.watchTimer);
this.watchTimer = null;
this.active = false;
this.syncWatchers();
} }
async tick() { async tick() {
if (this.running || !this.paths.length) return; if (this.running || !this.paths.length) return;
this.running = true; this.running = true;
try { try {
const queue = [...this.paths]; const now = Date.now();
const queue = this.paths.filter((localPath) => this.shouldCheck(localPath, now));
const workers = Array.from({ length: Math.min(4, queue.length) }, async () => { const workers = Array.from({ length: Math.min(4, queue.length) }, async () => {
while (queue.length) { while (queue.length) {
const localPath = queue.shift(); const localPath = queue.shift();
if (this.paused.has(localPath)) continue; this.changed.delete(localPath);
this.lastCheckedAt.set(localPath, Date.now());
try { try {
const status = await this.git.status(localPath); const status = await this.git.status(localPath);
const next = this.git.statusFingerprint(status); const next = this.git.statusFingerprint(status);
@@ -68,8 +161,11 @@ class RepositoryMonitor {
await Promise.all(workers); await Promise.all(workers);
} finally { } finally {
this.running = false; this.running = false;
// Activity that arrived while the check was running keeps its flag set, so
// it must not wait for the safety interval.
if (this.active && this.changed.size) this.scheduleWatchTick();
} }
} }
} }
module.exports = { RepositoryMonitor }; module.exports = { RepositoryMonitor, SAFETY_CHECK_INTERVAL_MS, WATCH_DEBOUNCE_MS, MIN_WATCH_CHECK_INTERVAL_MS };
+40 -1
View File
@@ -35,6 +35,7 @@ class RepositoryService {
this.lastDiscoveredPaths = []; this.lastDiscoveredPaths = [];
this.lastDiscoveryAtMs = 0; this.lastDiscoveryAtMs = 0;
this.refreshPromise = null; this.refreshPromise = null;
this.lastResult = null;
} }
async discoverInRoot(root, maxDepth = 4) { async discoverInRoot(root, maxDepth = 4) {
@@ -57,8 +58,13 @@ class RepositoryService {
let entries; let entries;
try { entries = await fs.readdir(real, { withFileTypes: true }); } catch { return; } try { entries = await fs.readdir(real, { withFileTypes: true }); } catch { return; }
// Directory entries report as a symbolic link instead of a directory, which
// is how Windows junctions surface. Skipping those made a project folder
// that is mapped through a junction invisible; visit() resolves each entry
// and the `seen` set above keeps links that point back into the tree from
// being scanned twice.
await mapLimit(entries await mapLimit(entries
.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink() && !SKIP_DIRECTORIES.has(entry.name)), 12, .filter((entry) => (entry.isDirectory() || entry.isSymbolicLink()) && !SKIP_DIRECTORIES.has(entry.name)), 12,
(entry) => visit(path.join(real, entry.name), depth + 1)); (entry) => visit(path.join(real, entry.name), depth + 1));
}; };
@@ -133,6 +139,38 @@ class RepositoryService {
return paths; return paths;
} }
// Resolving a single repository used to go through a full refresh, which runs
// `git status` for every discovered repository. Handlers that act on one
// repository only need that one, so its local state is read directly. Anything
// this cannot answer confidently still falls back to the full scan.
async resolveByFullName(fullName) {
const name = String(fullName || '').trim();
if (!name) return null;
const fromFullRefresh = async () => (await this.refresh()).find((item) => item.fullName === name) || null;
const remoteResult = await this.getRemoteRepositories({});
const remote = remoteResult.repositories.find((item) => item.full_name === name);
if (!remote) return fromFullRefresh();
const explicitPath = this.store.data.repositoryMappings[repositoryKey(remote)];
const knownPath = explicitPath || (this.lastResult || []).find((item) => item.fullName === name)?.localPath || null;
// Without a known path the link can still exist through remote-URL matching,
// which only the discovery pass can establish.
if (!knownPath && !this.lastResult) return fromFullRefresh();
const local = knownPath ? (await this.getLocalDescriptors([knownPath]))[0] : null;
const profiles = this.store.getDeploymentProfiles(remote.full_name).map((profile) => ({
...profile,
state: this.store.getDeploymentState(profile.id)
}));
return {
...this.decorate(remote, local, profiles),
remoteStale: remoteResult.stale,
remoteRefreshError: remoteResult.error,
remoteLastRefreshedAt: this.lastSuccessfulRemoteRefreshAt
};
}
async refresh(options = {}) { async refresh(options = {}) {
if (this.refreshPromise) return this.refreshPromise; if (this.refreshPromise) return this.refreshPromise;
this.refreshPromise = this.performRefresh(options).finally(() => { this.refreshPromise = null; }); this.refreshPromise = this.performRefresh(options).finally(() => { this.refreshPromise = null; });
@@ -215,6 +253,7 @@ class RepositoryService {
attentionCount: sorted.filter((item) => item.attention).length, attentionCount: sorted.filter((item) => item.attention).length,
readyToDeployCount: sorted.filter((item) => item.readyToDeploy).length readyToDeployCount: sorted.filter((item) => item.readyToDeploy).length
}); });
this.lastResult = sorted;
return sorted; return sorted;
} }
-1
View File
@@ -1,6 +1,5 @@
'use strict'; 'use strict';
const fs = require('node:fs');
const fsp = require('node:fs/promises'); const fsp = require('node:fs/promises');
const crypto = require('node:crypto'); const crypto = require('node:crypto');
const path = require('node:path').posix; const path = require('node:path').posix;
+1 -1
View File
@@ -1,6 +1,6 @@
"use strict"; "use strict";
function createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIdentity, checksSummary, crypto, parsePermissionInspection }) { function createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIdentity, checksSummary, crypto, parsePermissionInspection, safeRelativeRemoteFile }) {
class UnraidAccessMethods { class UnraidAccessMethods {
serverGitRemote(repository, profile) { serverGitRemote(repository, profile) {
const candidates = [ const candidates = [
+1 -1
View File
@@ -490,7 +490,7 @@ for (const name of Object.getOwnPropertyNames(preflightMethods)) {
if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(preflightMethods, name)); if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(preflightMethods, name));
} }
const accessMethods = createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIdentity, checksSummary, crypto, parsePermissionInspection }); const accessMethods = createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIdentity, checksSummary, crypto, parsePermissionInspection, safeRelativeRemoteFile });
for (const name of Object.getOwnPropertyNames(accessMethods)) { for (const name of Object.getOwnPropertyNames(accessMethods)) {
if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(accessMethods, name)); if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(accessMethods, name));
} }
-3
View File
@@ -127,9 +127,6 @@ function createUnraidStateMethods({ path, bash, shellQuote, inventoryRemoteIdent
// profile hint no longer matches the real Compose service keys. // profile hint no longer matches the real Compose service keys.
return this.refreshProfileState(repository.fullName, profileId); return this.refreshProfileState(repository.fullName, profileId);
} }
const composeFile = profile.generatedCompose
? ".forgeflow/compose.forgeflow.yml"
: safeRelativeRemoteFile(profile.composeFile || "docker-compose.yml");
const iconReference = await this.prepareIcon(profile, repository, server); const iconReference = await this.prepareIcon(profile, repository, server);
const metadata = this.metadataCompose(profile, repository, iconReference); const metadata = this.metadataCompose(profile, repository, iconReference);
const compose = this.composeInvocation(profile, repository); const compose = this.composeInvocation(profile, repository);
+2 -2
View File
@@ -413,7 +413,7 @@ async function bootstrap() {
} }
} }
function scheduleAutoRefresh() { function scheduleAutoRefresh(delay = 450) {
if ( if (
ui.loading || ui.loading ||
ui.autoRefreshPending || ui.autoRefreshPending ||
@@ -424,7 +424,7 @@ function scheduleAutoRefresh() {
setTimeout(async () => { setTimeout(async () => {
ui.autoRefreshPending = false; ui.autoRefreshPending = false;
await refreshRepositories(false, true); await refreshRepositories(false, true);
}, 450); }, delay);
} }
async function refreshRepositories(withLoader = true, silent = false) { async function refreshRepositories(withLoader = true, silent = false) {
+139 -44
View File
@@ -1,3 +1,25 @@
// These three sections used to be pushed into the DOM after render() had already
// written the shell. Keeping them in the markup makes the rendered output the
// single source of truth, so an unchanged render can be skipped safely.
function renderDeploymentPolicyFields(policy) {
const windows = (policy.maintenanceWindows || [])
.map((window) => `${window.days.join(",")}:${window.start}-${window.end}`)
.join(" | ");
return `<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(windows)}" placeholder="1,2,3,4,5:09:00-17:00"/><small>Day 0 is Sunday. Separate windows with |.</small></div>`;
}
function renderReleaseNoteFields(profile) {
return `<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>`;
}
function renderWorkloadClassificationFields(workload) {
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("");
return `<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>`;
}
function renderModal() { function renderModal() {
if (!ui.modal) return ""; if (!ui.modal) return "";
const repository = const repository =
@@ -59,7 +81,7 @@ function renderModal() {
.map((container) => container.name) .map((container) => container.name)
.filter(Boolean) .filter(Boolean)
.join(", "); .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>`; 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>${renderWorkloadClassificationFields(workload)}</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") { if (ui.modal.type === "deployment-config") {
const storedProfile = const storedProfile =
@@ -106,7 +128,7 @@ function renderModal() {
<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>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>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>` <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>`; }<label class="check-field full"><input id="profile-confirmation" type="checkbox" ${existing.confirmationRequired !== false ? "checked" : ""}/><span>Require an explicit confirmation before deployment</span></label>${renderDeploymentPolicyFields(storedProfile.deploymentPolicy || {})}</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") { if (ui.modal.type === "inventory-review-plan") {
const plan = ui.inventoryReviewPlan; const plan = ui.inventoryReviewPlan;
@@ -133,7 +155,7 @@ function renderModal() {
(item) => item.id === ui.modal.profileId, (item) => item.id === ui.modal.profileId,
) || selectedProfile(repository); ) || selectedProfile(repository);
const targetSha = deploymentTargetSha(repository, profile); 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>`; 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>` : ""}${renderReleaseNoteFields(profile)}</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") { if (ui.modal.type === "rollback-confirm") {
const profile = repository?.deploymentProfiles?.find( const profile = repository?.deploymentProfiles?.find(
@@ -239,46 +261,6 @@ function renderCommandPalette() {
} }
function enhanceRenderedUi() { 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) => { document.querySelectorAll("button.icon-button:not([aria-label])").forEach((button) => {
const action = String(button.title || button.dataset.action || "Action").replaceAll("-", " "); const action = String(button.title || button.dataset.action || "Action").replaceAll("-", " ");
button.setAttribute("aria-label", action.charAt(0).toUpperCase() + action.slice(1)); button.setAttribute("aria-label", action.charAt(0).toUpperCase() + action.slice(1));
@@ -296,6 +278,110 @@ function enhanceRenderedUi() {
}); });
} }
// A render replaces the complete application shell. Without this, a background
// repository poll or deployment poll destroys the element the user is typing in,
// discarding the caret position and every scroll offset on screen.
function elementRenderPath(element) {
const parts = [];
let node = element;
while (node && node !== app) {
const parent = node.parentElement;
if (!parent) return null;
parts.push(`${node.tagName}.${Array.prototype.indexOf.call(parent.children, node)}`);
node = parent;
}
return node === app ? parts.reverse().join(">") : null;
}
function elementAtRenderPath(renderPath) {
let node = app;
for (const part of renderPath.split(">")) {
const separator = part.lastIndexOf(".");
node = node?.children?.[Number(part.slice(separator + 1))];
// The shell can be structurally different after a view change, in which case
// the old offset belongs to an unrelated element and must be dropped.
if (!node || node.tagName !== part.slice(0, separator)) return null;
}
return node;
}
// enhanceRenderedUi() re-injects these controls empty on every render, so a
// background refresh would otherwise discard a release note or review reason
// while the user is still writing it.
const INJECTED_FIELD_IDS = [
"deployment-note",
"deployment-override",
"deployment-override-reason",
"inventory-review-action",
"inventory-review-reason",
"profile-policy-frozen",
"profile-policy-note",
"profile-policy-freeze-reason",
"profile-policy-windows",
];
function captureInjectedFieldValues() {
const values = [];
for (const id of INJECTED_FIELD_IDS) {
const element = document.getElementById(id);
if (!element) continue;
if (element.type === "checkbox") values.push({ id, checked: element.checked });
else if (element.value) values.push({ id, value: element.value });
}
return values;
}
function restoreInjectedFieldValues(values) {
for (const entry of values) {
const element = document.getElementById(entry.id);
if (!element) continue;
// Never overwrite a value the freshly rendered control already carries; only
// fill back in what the injection left empty.
if ("checked" in entry) {
if (!element.checked) element.checked = entry.checked;
} else if (!element.value) element.value = entry.value;
}
}
function captureInteractionState() {
const scroll = [];
for (const element of app.querySelectorAll("*")) {
if (!element.scrollTop && !element.scrollLeft) continue;
const renderPath = elementRenderPath(element);
if (renderPath) scroll.push({ renderPath, top: element.scrollTop, left: element.scrollLeft });
}
const injectedFields = captureInjectedFieldValues();
const active = document.activeElement;
if (!active?.id || !app.contains(active)) return { scroll, injectedFields, focus: null };
const focus = { id: active.id, start: null, end: null, direction: "none" };
try {
focus.start = active.selectionStart;
focus.end = active.selectionEnd;
focus.direction = active.selectionDirection || "none";
} catch {}
return { scroll, injectedFields, focus };
}
function restoreInteractionState(state) {
restoreInjectedFieldValues(state.injectedFields);
for (const entry of state.scroll) {
const element = elementAtRenderPath(entry.renderPath);
if (!element) continue;
element.scrollTop = entry.top;
element.scrollLeft = entry.left;
}
if (!state.focus) return;
const element = document.getElementById(state.focus.id);
if (!element || !app.contains(element)) return;
element.focus({ preventScroll: true });
if (state.focus.start === null) return;
try {
element.setSelectionRange(state.focus.start, state.focus.end, state.focus.direction);
} catch {}
}
let lastRenderedMarkup = null;
function render() { function render() {
if (!ui.boot) return; if (!ui.boot) return;
const repository = selectedRepository(); const repository = selectedRepository();
@@ -314,8 +400,17 @@ function render() {
? renderRepositoryWorkspace(repository) ? renderRepositoryWorkspace(repository)
: renderOverview(); : renderOverview();
const withPanel = ui.currentView === "repository" && repository; 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()}`; const markup = `<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()}`;
// Most renders are triggered by a poll that found nothing new. Rebuilding an
// identical shell would only cost layout work and interrupt the user. The
// markup is the complete rendered state, so comparing it is sufficient:
// enhanceRenderedUi() only derives labels and ids from what is already there.
if (markup === lastRenderedMarkup) return;
const interaction = captureInteractionState();
app.innerHTML = markup;
enhanceRenderedUi(); enhanceRenderedUi();
restoreInteractionState(interaction);
lastRenderedMarkup = markup;
if (ui.modal?.type === "command-palette") if (ui.modal?.type === "command-palette")
requestAnimationFrame(() => requestAnimationFrame(() =>
document.querySelector("#palette-input")?.focus(), document.querySelector("#palette-input")?.focus(),
+4 -4
View File
@@ -9,7 +9,7 @@
<link rel="stylesheet" href="styles.css" /> <link rel="stylesheet" href="styles.css" />
</head> </head>
<body> <body>
<div id="app" aria-live="polite"> <div id="app">
<div class="boot-screen"> <div class="boot-screen">
<img class="boot-brand-logo" src="./assets/itworx-mark.png" alt="ITWorx.tech"/> <img class="boot-brand-logo" src="./assets/itworx-mark.png" alt="ITWorx.tech"/>
<strong>Starting ForgeFlow</strong> <strong>Starting ForgeFlow</strong>
@@ -17,9 +17,9 @@
</div> </div>
</div> </div>
<div id="toast-root" class="toast-root" aria-live="assertive"></div> <div id="toast-root" class="toast-root" aria-live="assertive"></div>
<script src="mock-repository-bridge.js"></script> <script defer src="mock-repository-bridge.js"></script>
<script src="mock-deployment-bridge.js"></script> <script defer src="mock-deployment-bridge.js"></script>
<script src="mock-bridge.js"></script> <script defer src="mock-bridge.js"></script>
<script defer src="app.js"></script> <script defer src="app.js"></script>
<script defer src="views.js"></script> <script defer src="views.js"></script>
<script defer src="dialogs.js"></script> <script defer src="dialogs.js"></script>
+1
View File
@@ -687,6 +687,7 @@ function renderDiagnostics() {
<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>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>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> <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>
<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>
</div>`; </div>`;
} }
+79
View File
@@ -215,3 +215,82 @@ test("inventory, deployment safety and failure evidence dialogs are reviewable",
} }
await assertSurface(page); await assertSurface(page);
}); });
// A repository or deployment poll renders the whole shell again. Changing an
// unrelated part of the state is what a poll effectively does, and it must not
// take the caret or the scroll position away from the user.
async function forceUnrelatedRerender(page) {
await page.evaluate(() => {
ui.diagnosticsStatus = { ...(ui.diagnosticsStatus || {}), enabled: !(ui.diagnosticsStatus?.enabled === false) };
render();
});
}
test("a background refresh keeps typing and caret position intact", async ({ page }) => {
const search = page.locator("#global-search");
await search.click();
await search.fill("Forge");
// Typing schedules a debounced render. Wait for it, otherwise the caret below
// can land on the element that render is about to replace.
await expect.poll(() => page.evaluate(() => ui.inputRenderTimer === null)).toBe(true);
await search.evaluate((element) => element.setSelectionRange(1, 3));
await forceUnrelatedRerender(page);
await expect(search).toBeFocused();
expect(await search.inputValue()).toBe("Forge");
expect(await search.evaluate((element) => [element.selectionStart, element.selectionEnd])).toEqual([1, 3]);
});
test("a background refresh keeps scroll offsets intact", async ({ page }) => {
await page.locator('.nav-button[data-action="navigate"][data-view="settings"]').click();
const canvas = page.locator(".main-canvas");
const scrolled = await canvas.evaluate((element) => {
element.scrollTop = Math.min(120, Math.max(0, element.scrollHeight - element.clientHeight));
return element.scrollTop;
});
expect(scrolled).toBeGreaterThan(0);
await forceUnrelatedRerender(page);
expect(await canvas.evaluate((element) => element.scrollTop)).toBe(scrolled);
});
test("sections that used to be injected after render are part of the rendered markup", async ({ page }) => {
await page.locator('.nav-button[data-action="navigate"][data-view="diagnostics"]').click();
const auditPanel = page.locator(".diagnostics-page .section-block", { hasText: "Operational audit log" });
await expect(auditPanel).toBeVisible();
await expect(auditPanel).toContainText("Load the operational audit log");
// The audit rows are state the shell renders itself now, so a plain render has
// to pick them up without any post-render injection step.
await page.evaluate(() => {
ui.auditEvents = [{ timestamp: new Date().toISOString(), event: "deployment.requested", details: { repository: "Jens/Probe", result: "queued" } }];
render();
});
await expect(auditPanel.locator("table.data-table")).toContainText("Jens/Probe");
await expect(auditPanel.locator("table.data-table")).toContainText("deployment.requested");
});
test("an unchanged render leaves the existing DOM in place", async ({ page }) => {
await page.locator('[data-action="select-repo"]').first().click();
const marked = await page.evaluate(() => {
// Relative timestamps ("just now" turning into "1m ago") and pending async
// state legitimately change the markup between two renders that are seconds
// apart. Rendering twice inside one synchronous block removes that window,
// so the second render can only be skipped because nothing changed.
render();
document.querySelector(".repo-list").dataset.renderProbe = "kept";
render();
return document.querySelector(".repo-list")?.dataset.renderProbe || null;
});
expect(marked).toBe("kept");
const replaced = await page.evaluate(() => {
document.querySelector(".repo-list").dataset.renderProbe = "kept";
ui.repoSearch = `probe-${Date.now()}`;
render();
return document.querySelector(".repo-list")?.dataset.renderProbe || null;
});
expect(replaced).toBe(null);
});
+165
View File
@@ -0,0 +1,165 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { registerDeploymentIpc } = require("../src/main/ipc/deployment-handlers.cjs");
const { UnraidDeploymentService } = require("../src/main/unraid-deployment-service.cjs");
const REPOSITORY = { fullName: "Jens/ForgeFlow", owner: { login: "Jens" }, localPath: "C:/Projects/ForgeFlow" };
const WORKLOAD = { workloadId: "workload-1", classification: { type: "ambiguous" } };
// Every collaborator answers, so a channel can only fail on a dependency the
// module references but never receives.
function harness(overrides = {}) {
const calls = [];
const record = (name, result) => async (...args) => { calls.push({ name, args }); return typeof result === "function" ? result(...args) : result; };
const handlers = new Map();
const profile = overrides.profile || { id: "profile-1", provider: "ssh-unraid", branch: "main", name: "Production" };
const dependencies = {
register: (channel, handler) => handlers.set(channel, handler),
store: {
data: { servers: [{ id: "server-1", name: "Unraid" }], operations: [] },
getDeploymentProfile: () => profile,
getPublicState: () => ({ ok: true }),
saveDeploymentProfile: record("store.saveDeploymentProfile", profile),
deleteDeploymentProfile: record("store.deleteDeploymentProfile", []),
addOperation: record("store.addOperation", null),
},
resolveRepository: record("resolveRepository", REPOSITORY),
unraid: {
preflight: record("unraid.preflight", { ok: true }),
repairWriteAccess: record("unraid.repairWriteAccess", { changed: true, after: {}, before: {} }),
deploy: record("unraid.deploy", { id: "operation-1" }),
rollback: record("unraid.rollback", { id: "operation-2" }),
linkServerWorkload: record("unraid.linkServerWorkload", { linked: true }),
configureServerGitAccess: record("unraid.configureServerGitAccess", { keyFingerprint: "a", hostFingerprint: "b" }),
verifyServerGitProfile: record("unraid.verifyServerGitProfile", { readiness: "ready", ready: true, checkedAt: "now" }),
discoverServerWorkloads: record("unraid.discoverServerWorkloads", { serverId: "server-1", workloads: [] }),
planServerInventoryReconciliation: record("unraid.planServerInventoryReconciliation", { plan: { id: "plan-1", summary: {} } }),
reconcileServerInventory: record("unraid.reconcileServerInventory", { adopted: 0, refreshed: 0, retired: 0 }),
scanServerInventory: record("unraid.scanServerInventory", { workloads: [WORKLOAD] }),
refreshProfileState: record("unraid.refreshProfileState", { liveSha: null }),
applyDockerManMetadata: record("unraid.applyDockerManMetadata", { applied: true }),
refreshOperation: record("unraid.refreshOperation", null),
reconcileRecordedOperations: record("unraid.reconcileRecordedOperations", []),
},
deployments: {
deploy: record("deployments.deploy", { id: "operation-3" }),
rollback: record("deployments.rollback", { id: "operation-4" }),
checkHealth: record("deployments.checkHealth", { healthy: true }),
refreshProfileState: record("deployments.refreshProfileState", { liveSha: null }),
},
evaluateDeploymentPolicy: () => ({ note: "", overridden: false, reason: "", violations: [] }),
audit: { append: record("audit.append", null) },
deployKeys: {
inventory: record("deployKeys.inventory", { keys: [] }),
planRotation: record("deployKeys.planRotation", { id: "rotation-1" }),
rotate: record("deployKeys.rotate", { rotated: true }),
planRevocation: record("deployKeys.planRevocation", { id: "revocation-1" }),
revoke: record("deployKeys.revoke", { revoked: true }),
restore: record("deployKeys.restore", { restored: true }),
},
repositories: { refresh: record("repositories.refresh", [REPOSITORY]) },
inventoryReviews: {
preview: (...args) => { calls.push({ name: "inventoryReviews.preview", args }); return { id: "review-1" }; },
apply: record("inventoryReviews.apply", { applied: true }),
},
diagnostics: { info: record("diagnostics.info"), warning: record("diagnostics.warning"), error: record("diagnostics.error"), debug: record("diagnostics.debug") },
git: {},
gitea: { getBranch: record("gitea.getBranch", { commit: { id: "c".repeat(40) } }) },
ssh: {},
preflight: { runDeployment: record("preflight.runDeployment", { ok: "actions" }) },
...overrides.dependencies,
};
registerDeploymentIpc(dependencies);
return { handlers, calls, names: () => calls.map((item) => item.name) };
}
const PAYLOAD = {
repository: REPOSITORY,
fullName: REPOSITORY.fullName,
profileId: "profile-1",
sha: "a".repeat(40),
serverId: "server-1",
workloadId: WORKLOAD.workloadId,
planId: "plan-1",
action: "manual-link",
url: "https://app.example/health",
profile: { name: "Production" },
targetSha: "b".repeat(40),
};
// Both provider paths have to run: a dependency that only the Gitea Actions
// branch reads stays invisible while every channel is exercised as SSH/Unraid.
for (const provider of ["ssh-unraid", "gitea-actions"]) {
test(`every deployment IPC channel runs with the dependencies it is given (${provider})`, async () => {
const { handlers } = harness({ profile: { id: "profile-1", provider, branch: "main", name: "Production" } });
assert.ok(handlers.size >= 20, "expected the complete deployment channel surface");
const failures = [];
for (const [channel, handler] of handlers) {
try {
await handler({ ...PAYLOAD });
} catch (error) {
// A refusal is a decision the handler made; a missing dependency is not.
if (error instanceof ReferenceError || error instanceof TypeError) {
failures.push(`${channel}: ${error.name}: ${error.message}`);
}
}
}
assert.deepEqual(failures, []);
});
}
test("deployment preflight routes by provider", async () => {
const actions = harness({ profile: { id: "profile-1", provider: "gitea-actions" } });
assert.deepEqual(await actions.handlers.get("deployment:preflight")({ ...PAYLOAD }), { ok: "actions" });
assert.ok(actions.names().includes("preflight.runDeployment"));
const unraid = harness();
assert.deepEqual(await unraid.handlers.get("deployment:preflight")({ ...PAYLOAD }), { ok: true });
assert.ok(unraid.names().includes("unraid.preflight"));
assert.ok(!unraid.names().includes("preflight.runDeployment"));
});
test("write-access repair is refused for anything but an SSH/Unraid profile", async () => {
const actions = harness({ profile: { id: "profile-1", provider: "gitea-actions" } });
await assert.rejects(
() => actions.handlers.get("deployment:repair-write-access")({ ...PAYLOAD }),
/available only for SSH \/ Unraid/,
);
});
test("a stale workload blocks an inventory review instead of guessing", async () => {
const { handlers } = harness({
dependencies: { unraid: { scanServerInventory: async () => ({ workloads: [] }) } },
});
for (const channel of ["deployment:plan-inventory-review", "deployment:apply-inventory-review"]) {
await assert.rejects(() => handlers.get(channel)({ ...PAYLOAD }), (error) => {
assert.equal(error.code, "INVENTORY_REVIEW_WORKLOAD_STALE");
return true;
});
}
});
test("write-access repair builds a repair script that preserves runtime paths", () => {
const service = new UnraidDeploymentService({});
const profile = {
id: "profile-3",
provider: "ssh-unraid",
remoteFolder: "portfolio",
composeFiles: ["docker-compose.yml"],
preservePaths: ["data/uploads"],
};
const server = { id: "server-1", basePath: "/mnt/user/appdata" };
const script = service.permissionRepairScript(profile, server, "/mnt/user/appdata/portfolio");
assert.equal(typeof script, "string");
assert.match(script, /data\/uploads/);
assert.match(script, /node_modules/);
assert.match(script, /ForgeFlow repaired project write access/);
});
+53
View File
@@ -49,3 +49,56 @@ test('repository monitor checks multiple repositories concurrently with a bounde
assert.equal(active, 0); assert.equal(active, 0);
assert.equal(monitor.fingerprints.size, 10); assert.equal(monitor.fingerprints.size, 10);
}); });
test('a watched repository is read on filesystem activity instead of on every interval', async (context) => {
const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises');
const os = await import('node:os');
const path = await import('node:path');
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-watch-'));
context.after(() => rm(root, { recursive: true, force: true }));
await mkdir(path.join(root, '.git'), { recursive: true });
let revision = 1;
const reads = [];
const changes = [];
const git = {
status: async (localPath) => { reads.push(localPath); return { localPath, revision }; },
statusFingerprint: (status) => String(status.revision)
};
const store = { data: { preferences: { autoRefresh: true, repositoryPollSeconds: 2 } } };
const monitor = new RepositoryMonitor({ store, git, onChange: (change) => changes.push(change) });
context.after(() => monitor.stop());
monitor.restart();
monitor.setPaths([root]);
if (!monitor.watchers.has(root)) {
context.skip('this platform does not support recursive directory watching');
return;
}
await monitor.tick();
assert.equal(reads.length, 1, 'the baseline is established once');
// Without filesystem activity the interval must not spawn another read.
await monitor.tick();
assert.equal(reads.length, 1);
revision = 2;
await writeFile(path.join(root, 'feature.txt'), 'changed\n');
// The watcher debounce and the per-repository cooldown both apply here.
const deadline = Date.now() + 5_000;
while (changes.length === 0 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
assert.ok(reads.length > 1, 'filesystem activity triggers a read');
assert.equal(changes.length, 1);
assert.equal(changes[0].reason, 'working-tree-changed');
const readsAfterChange = reads.length;
await new Promise((resolve) => setTimeout(resolve, 800));
assert.equal(reads.length, readsAfterChange, 'a quiet repository is not read again');
monitor.stop();
assert.equal(monitor.watchers.size, 0, 'stopping releases every watcher');
});
+71
View File
@@ -80,6 +80,24 @@ test('repository discovery is bounded, skips generated trees and ignores inacces
assert.ok(all.includes(found[0])); assert.ok(all.includes(found[0]));
}); });
test('a repository reached through a directory junction is discovered once', async (context) => {
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-junction-'));
context.after(() => import('node:fs/promises').then(({ rm }) => rm(root, { recursive: true, force: true })));
const elsewhere = path.join(root, 'elsewhere', 'service');
await mkdir(path.join(elsewhere, '.git'), { recursive: true });
await mkdir(path.join(root, 'workspace'), { recursive: true });
try {
await symlink(elsewhere, path.join(root, 'workspace', 'linked-service'), 'junction');
} catch {
context.skip('this platform does not allow creating directory links');
return;
}
const { realpath } = await import('node:fs/promises');
const found = await service().discoverInRoot(path.join(root, 'workspace'), 3);
assert.deepEqual(found, [await realpath(elsewhere)]);
});
test('local descriptors preserve Git failures and watch paths are defensive copies', async () => { test('local descriptors preserve Git failures and watch paths are defensive copies', async () => {
const instance = new RepositoryService({ data: {} }, { const instance = new RepositoryService({ data: {} }, {
status: async (localPath) => { status: async (localPath) => {
@@ -133,6 +151,59 @@ test('refresh links explicit and remote-matched repositories and retains unmatch
assert.equal(diagnostics[0][0], 'repositories.refresh.completed'); assert.equal(diagnostics[0][0], 'repositories.refresh.completed');
}); });
test('resolving one repository reads only that repository, not the whole workspace', async () => {
const scanned = [];
const store = {
data: {
gitea: { baseUrl: 'https://gitea.example' },
workspaceRoots: ['root'],
repositoryMappings: { 'jens/portfolio': 'C:/explicit' },
preferences: { preferredCloneProtocol: 'https' },
favorites: []
},
getToken: () => 'token',
getDeploymentProfiles: () => [{ id: 'prod', branch: 'main' }],
getDeploymentState: () => ({ liveSha: null, healthy: null })
};
const instance = new RepositoryService(store, {
status: async (localPath) => {
scanned.push(localPath);
return { ...status(), root: localPath, remoteUrl: remote.clone_url };
}
}, { listRepositories: async () => [remote, { ...remote, id: 2, full_name: 'Jens/Other', name: 'Other' }] });
instance.discoverAll = async () => ['C:/explicit', 'C:/other', 'C:/third'];
await instance.refresh();
const duringRefresh = scanned.length;
assert.equal(duringRefresh, 3);
scanned.length = 0;
const resolved = await instance.resolveByFullName(remote.full_name);
assert.equal(resolved.fullName, remote.full_name);
assert.equal(resolved.localPath, 'C:/explicit');
assert.equal(resolved.deploymentProfiles[0].id, 'prod');
assert.deepEqual(scanned, ['C:/explicit']);
assert.equal(await instance.resolveByFullName(''), null);
});
test('resolving an unknown repository still falls back to a full refresh', async () => {
const store = {
data: { gitea: { baseUrl: 'https://gitea.example' }, workspaceRoots: [], repositoryMappings: {}, preferences: { preferredCloneProtocol: 'https' }, favorites: [] },
getToken: () => 'token',
getDeploymentProfiles: () => [],
getDeploymentState: () => null
};
const instance = new RepositoryService(store, {
status: async (localPath) => ({ ...status(), root: localPath, remoteUrl: '' })
}, { listRepositories: async () => [remote] });
instance.discoverAll = async () => ['C:/loose-checkout'];
const local = await instance.resolveByFullName('loose-checkout');
assert.equal(local.linkState, 'unmatched-local');
assert.equal(await instance.resolveByFullName('Jens/Missing'), null);
});
test('refresh remains local-only without configured Gitea credentials', async () => { test('refresh remains local-only without configured Gitea credentials', async () => {
const store = { const store = {
data: { gitea: { baseUrl: '' }, workspaceRoots: [], repositoryMappings: {}, preferences: { preferredCloneProtocol: 'ssh' }, favorites: [] }, data: { gitea: { baseUrl: '' }, workspaceRoots: [], repositoryMappings: {}, preferences: { preferredCloneProtocol: 'ssh' }, favorites: [] },