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>
294 lines
11 KiB
JavaScript
294 lines
11 KiB
JavaScript
"use strict";
|
|
|
|
function createUnraidStateMethods({ path, bash, shellQuote, inventoryRemoteIdentity }) {
|
|
class UnraidStateMethods {
|
|
async refreshProfileState(fullName, profileId, expectedGiteaSha = null) {
|
|
const repository = { fullName, name: fullName.split("/").pop() };
|
|
const { profile, server, remotePath } = this.resolve(repository, profileId);
|
|
const containerName = String(
|
|
profile.containerName || profile.remoteFolder || repository.name,
|
|
);
|
|
const script = `
|
|
root=${shellQuote(remotePath)}
|
|
container=${shellQuote(containerName)}
|
|
template_path=${shellQuote("/boot/config/plugins/dockerMan/templates-user/my-" + containerName + ".xml")}
|
|
live=""; previous=""; running=false; docker_health=""; webui=""; icon=""; shell_label=""; template_exists=false
|
|
[ -f "$template_path" ] && template_exists=true
|
|
[ -f "$root/.forgeflow/current-sha" ] && live=$(cat "$root/.forgeflow/current-sha")
|
|
[ -z "$live" ] && [ -d "$root/.git" ] && live=$(git -C "$root" rev-parse HEAD 2>/dev/null || true)
|
|
[ -f "$root/.forgeflow/previous-sha" ] && previous=$(cat "$root/.forgeflow/previous-sha")
|
|
if docker inspect "$container" >/dev/null 2>&1; then
|
|
running=$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null || echo false)
|
|
docker_health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$container" 2>/dev/null || true)
|
|
webui=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.webui"}}' "$container" 2>/dev/null || true)
|
|
icon=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.icon"}}' "$container" 2>/dev/null || true)
|
|
shell_label=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.shell"}}' "$container" 2>/dev/null || true)
|
|
[ -z "$live" ] && live=$(docker inspect -f '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$container" 2>/dev/null || true)
|
|
[ -z "$live" ] && live=$(docker inspect -f '{{index .Config.Labels "tech.itworx.forgeflow.commit"}}' "$container" 2>/dev/null || true)
|
|
fi
|
|
printf '__FORGEFLOW_KV__\n'
|
|
printf 'liveSha=%s\n' "$live"
|
|
printf 'previousSha=%s\n' "$previous"
|
|
printf 'containerRunning=%s\n' "$running"
|
|
printf 'dockerHealth=%s\n' "$docker_health"
|
|
printf 'webUiLabel=%s\n' "$(printf '%s' "$webui" | base64 | tr -d '\r\n')"
|
|
printf 'iconLabel=%s\n' "$(printf '%s' "$icon" | base64 | tr -d '\r\n')"
|
|
printf 'shellLabel=%s\n' "$(printf '%s' "$shell_label" | base64 | tr -d '\r\n')"
|
|
printf 'templateExists=%s\n' "$template_exists"
|
|
`;
|
|
const result = await this.ssh.exec(server.id, bash(script), {
|
|
timeout: 30_000,
|
|
});
|
|
const marker = result.stdout.lastIndexOf("__FORGEFLOW_KV__");
|
|
if (marker < 0)
|
|
throw new Error(
|
|
"Unraid state inspection did not return a ForgeFlow marker.",
|
|
);
|
|
const fields = {};
|
|
for (const line of result.stdout
|
|
.slice(marker + "__FORGEFLOW_KV__".length)
|
|
.trim()
|
|
.split(/\r?\n/)) {
|
|
const index = line.indexOf("=");
|
|
if (index > 0) fields[line.slice(0, index)] = line.slice(index + 1);
|
|
}
|
|
const decode = (value) => {
|
|
try {
|
|
return value ? Buffer.from(value, "base64").toString("utf8") : "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
};
|
|
const containerRunning = fields.containerRunning === "true";
|
|
const health = containerRunning
|
|
? await this.checkHealth(profile.healthcheckUrl)
|
|
: { configured: false, healthy: false, skipped: "container-stopped" };
|
|
const dockerHealthy = fields.dockerHealth
|
|
? fields.dockerHealth === "healthy"
|
|
: null;
|
|
const effectiveHealthy = !containerRunning ? false : health.configured ? health.healthy : dockerHealthy;
|
|
const runtimeVerification = !containerRunning
|
|
? "stopped"
|
|
: health.configured
|
|
? "desktop-healthcheck"
|
|
: dockerHealthy === true
|
|
? "docker-healthcheck"
|
|
: dockerHealthy === false
|
|
? "docker-unhealthy"
|
|
: containerRunning
|
|
? "running-unverified"
|
|
: "stopped";
|
|
return this.store.saveDeploymentState(profile.id, {
|
|
liveSha: /^[0-9a-f]{40}$/i.test(fields.liveSha || "")
|
|
? fields.liveSha
|
|
: null,
|
|
previousSha: /^[0-9a-f]{40}$/i.test(fields.previousSha || "")
|
|
? fields.previousSha
|
|
: null,
|
|
healthy: effectiveHealthy,
|
|
runtimeVerification,
|
|
healthStatus: health.status,
|
|
healthLatencyMs: health.latencyMs,
|
|
containerName,
|
|
containerRunning,
|
|
dockerHealth: fields.dockerHealth || null,
|
|
dockerMan: {
|
|
webUi: decode(fields.webUiLabel),
|
|
icon: decode(fields.iconLabel),
|
|
shell: decode(fields.shellLabel),
|
|
templateExists: fields.templateExists === "true",
|
|
configured: Boolean(
|
|
decode(fields.webUiLabel) ||
|
|
decode(fields.iconLabel) ||
|
|
fields.templateExists === "true",
|
|
),
|
|
},
|
|
webUiUrl:
|
|
profile.webUiUrl ||
|
|
(profile.hostPort
|
|
? `http://${server.host}:${profile.hostPort}/`
|
|
: null),
|
|
remotePath,
|
|
provider: "ssh-unraid",
|
|
giteaSha: /^[0-9a-f]{40}$/i.test(String(expectedGiteaSha || ""))
|
|
? expectedGiteaSha
|
|
: null,
|
|
matchesGitea:
|
|
/^[0-9a-f]{40}$/i.test(String(expectedGiteaSha || "")) &&
|
|
fields.liveSha === expectedGiteaSha,
|
|
});
|
|
}
|
|
|
|
async applyDockerManMetadata({ repository, profileId }) {
|
|
const { profile, server, remotePath } = this.resolve(repository, profileId);
|
|
if (profile.generatedCompose !== true) {
|
|
// Existing Compose files remain authoritative. Applying a generated
|
|
// labels-only service fragment can create a phantom service when a stale
|
|
// profile hint no longer matches the real Compose service keys.
|
|
return this.refreshProfileState(repository.fullName, profileId);
|
|
}
|
|
const iconReference = await this.prepareIcon(profile, repository, server);
|
|
const metadata = this.metadataCompose(profile, repository, iconReference);
|
|
const compose = this.composeInvocation(profile, repository);
|
|
const flags = this.composeUpFlags(profile);
|
|
const script = `
|
|
root=${shellQuote(remotePath)}
|
|
test -d "$root"
|
|
mkdir -p "$root/.forgeflow"
|
|
cat > "$root/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA'
|
|
${metadata}FORGEFLOW_METADATA
|
|
cd "$root"
|
|
${compose} config >/dev/null
|
|
${compose} up -d --build ${flags}
|
|
${this.containerVerificationScript(profile, repository, compose)}
|
|
${this.dockerManRefreshScript(profile, repository, iconReference)}
|
|
`;
|
|
await this.ssh.exec(server.id, bash(script), {
|
|
timeout: 10 * 60_000,
|
|
maxOutput: 2 * 1024 * 1024,
|
|
});
|
|
return this.refreshProfileState(repository.fullName, profileId);
|
|
}
|
|
|
|
async refreshOperation(
|
|
operationId,
|
|
{ includeTerminal = false, state: suppliedState = null } = {},
|
|
) {
|
|
const operation = this.store.getOperation(operationId);
|
|
if (!operation || operation.provider !== "ssh-unraid") return operation;
|
|
if (
|
|
!includeTerminal &&
|
|
["success", "failed", "cancelled", "rolled-back"].includes(
|
|
operation.status,
|
|
)
|
|
)
|
|
return operation;
|
|
try {
|
|
const state =
|
|
suppliedState ||
|
|
(await this.refreshProfileState(
|
|
operation.repository,
|
|
operation.profileId,
|
|
));
|
|
if (
|
|
state.liveSha === operation.sha &&
|
|
state.containerRunning &&
|
|
state.healthy !== false
|
|
) {
|
|
return this.saveOperation({
|
|
...operation,
|
|
status: operation.action === "rollback" ? "rolled-back" : "success",
|
|
health: { healthy: state.healthy, status: state.healthStatus },
|
|
logs: [
|
|
...(operation.logs || []),
|
|
"Deployment state reconciled from Unraid.",
|
|
],
|
|
});
|
|
}
|
|
if (
|
|
/^[0-9a-f]{40}$/i.test(String(state.liveSha || "")) &&
|
|
state.liveSha !== operation.sha &&
|
|
state.containerRunning &&
|
|
state.healthy !== false
|
|
) {
|
|
return this.saveOperation({
|
|
...operation,
|
|
status: "cancelled",
|
|
error: `Superseded by live commit ${state.liveSha.slice(0, 7)}.`,
|
|
health: { healthy: state.healthy, status: state.healthStatus },
|
|
logs: [
|
|
...(operation.logs || []),
|
|
`Operation superseded by live Unraid commit ${state.liveSha}.`,
|
|
],
|
|
});
|
|
}
|
|
const ageMs =
|
|
Date.now() -
|
|
new Date(operation.updatedAt || operation.createdAt || 0).getTime();
|
|
if (ageMs > 45 * 60_000) {
|
|
return this.saveOperation({
|
|
...operation,
|
|
status: "failed",
|
|
error:
|
|
"Deployment was interrupted or did not reach the requested commit within 45 minutes.",
|
|
logs: [
|
|
...(operation.logs || []),
|
|
"Stale deployment was marked failed during reconciliation.",
|
|
],
|
|
});
|
|
}
|
|
return operation;
|
|
} catch {
|
|
return operation;
|
|
}
|
|
}
|
|
|
|
async reconcileRecordedOperations(profileId, state) {
|
|
const operations = this.store.data.operations
|
|
.filter(
|
|
(item) =>
|
|
item.profileId === profileId && item.provider === "ssh-unraid",
|
|
)
|
|
.sort(
|
|
(left, right) =>
|
|
new Date(right.updatedAt || right.createdAt || 0) -
|
|
new Date(left.updatedAt || left.createdAt || 0),
|
|
);
|
|
const matching = operations.find(
|
|
(item) => item.sha === state.liveSha && item.status === "failed",
|
|
);
|
|
if (matching && state.containerRunning && state.healthy !== false) {
|
|
await this.refreshOperation(matching.id, {
|
|
includeTerminal: true,
|
|
state,
|
|
});
|
|
}
|
|
const latestFailed = operations.find((item) => item.status === "failed");
|
|
if (
|
|
latestFailed &&
|
|
latestFailed.id !== matching?.id &&
|
|
state.matchesGitea &&
|
|
state.containerRunning &&
|
|
state.healthy !== false
|
|
) {
|
|
await this.saveOperation({
|
|
...latestFailed,
|
|
status: "cancelled",
|
|
error: `Superseded by Gitea/live commit ${state.liveSha.slice(0, 7)}.`,
|
|
logs: [
|
|
...(latestFailed.logs || []),
|
|
`Reconciled: Gitea and Unraid now both report ${state.liveSha}.`,
|
|
],
|
|
});
|
|
}
|
|
return this.store.data.operations
|
|
.filter((item) => item.profileId === profileId)
|
|
.slice(0, 10);
|
|
}
|
|
|
|
async refreshActiveOperations() {
|
|
const active = this.store.data.operations.filter(
|
|
(item) =>
|
|
item.provider === "ssh-unraid" &&
|
|
item.type === "deployment" &&
|
|
!["success", "failed", "cancelled", "rolled-back"].includes(
|
|
item.status,
|
|
),
|
|
);
|
|
const queue = [...active];
|
|
const results = [];
|
|
const workers = Array.from({ length: Math.min(4, queue.length) }, async () => {
|
|
while (queue.length) {
|
|
const operation = queue.shift();
|
|
results.push(await this.refreshOperation(operation.id));
|
|
}
|
|
});
|
|
await Promise.all(workers);
|
|
return results;
|
|
}
|
|
}
|
|
return UnraidStateMethods.prototype;
|
|
}
|
|
|
|
module.exports = { createUnraidStateMethods };
|