Add advanced Git and deployment workflows, secure backups and auditing, live Gitea integration, desktop notifications, connection validation, and the premium responsive UX refresh.
1540 lines
50 KiB
JavaScript
1540 lines
50 KiB
JavaScript
(() => {
|
|
if (window.forgeflow) return;
|
|
|
|
const wait = (ms = 180) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
const clone = (value) => JSON.parse(JSON.stringify(value));
|
|
const iso = (offset = 0) => new Date(Date.now() + offset).toISOString();
|
|
const storage = {
|
|
get(key) {
|
|
try {
|
|
return localStorage.getItem(key);
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
set(key, value) {
|
|
try {
|
|
localStorage.setItem(key, value);
|
|
} catch {}
|
|
},
|
|
};
|
|
const repositoryListeners = new Set();
|
|
const operationListeners = new Set();
|
|
const updateListeners = new Set();
|
|
const emitRepositories = () =>
|
|
repositoryListeners.forEach((listener) =>
|
|
listener({ reason: "demo-change" }),
|
|
);
|
|
const emitOperations = (operations) =>
|
|
operationListeners.forEach((listener) =>
|
|
listener({ operations: clone(operations) }),
|
|
);
|
|
const randomSha = () =>
|
|
`${Math.random().toString(16).slice(2)}${Date.now().toString(16)}`
|
|
.padEnd(40, "a")
|
|
.slice(0, 40);
|
|
|
|
const makeStatus = ({
|
|
head,
|
|
branch = "main",
|
|
ahead = 0,
|
|
behind = 0,
|
|
upstream = `origin/${branch}`,
|
|
files = [],
|
|
}) => ({
|
|
branch: { oid: head, head: branch, upstream, ahead, behind },
|
|
files,
|
|
counts: {
|
|
changed: files.length,
|
|
staged: files.filter((item) => item.staged).length,
|
|
unstaged: files.filter((item) => item.unstaged).length,
|
|
conflicts: files.filter((item) => item.conflict).length,
|
|
untracked: files.filter((item) => item.untracked).length,
|
|
},
|
|
clean: files.length === 0,
|
|
root: "",
|
|
remoteUrl: "",
|
|
head,
|
|
shortHead: head.slice(0, 7),
|
|
fingerprint: `${head}:${branch}:${ahead}:${behind}:${files.map((item) => `${item.path}:${item.indexCode}${item.worktreeCode}`).join("|")}`,
|
|
});
|
|
|
|
const makeFile = (path, status = "modified", options = {}) => ({
|
|
path,
|
|
originalPath: options.originalPath || null,
|
|
indexCode: options.staged
|
|
? status === "added"
|
|
? "A"
|
|
: status === "deleted"
|
|
? "D"
|
|
: "M"
|
|
: ".",
|
|
worktreeCode: options.staged
|
|
? "."
|
|
: status === "untracked"
|
|
? "?"
|
|
: status === "deleted"
|
|
? "D"
|
|
: status === "conflict"
|
|
? "U"
|
|
: "M",
|
|
staged: Boolean(options.staged),
|
|
unstaged: !options.staged,
|
|
untracked: status === "untracked",
|
|
conflict: status === "conflict",
|
|
status,
|
|
});
|
|
|
|
const profile = (id, name, environment, options = {}) => ({
|
|
id,
|
|
name,
|
|
environment,
|
|
provider: "gitea-actions",
|
|
branch: options.branch || "main",
|
|
workflowFile: options.workflowFile || "deploy.yml",
|
|
rollbackWorkflowFile: options.rollbackWorkflowFile ?? "rollback.yml",
|
|
healthcheckUrl:
|
|
options.healthcheckUrl || `https://${environment}.internal/health`,
|
|
statusUrl:
|
|
options.statusUrl ||
|
|
`https://${environment}.internal/.well-known/forgeflow`,
|
|
confirmationRequired: options.confirmationRequired !== false,
|
|
inputs: {},
|
|
state: {
|
|
liveSha: options.liveSha || null,
|
|
previousSha: options.previousSha || null,
|
|
healthy: options.healthy ?? null,
|
|
healthConfigured: true,
|
|
statusConfigured: true,
|
|
healthStatus: options.healthy === false ? 503 : 200,
|
|
healthLatencyMs: 42,
|
|
checkedAt: options.checkedAt || iso(-120000),
|
|
},
|
|
});
|
|
|
|
const now = iso();
|
|
const defaultPreferences = {
|
|
autoRefresh: true,
|
|
repositoryPollSeconds: 4,
|
|
operationPollSeconds: 5,
|
|
fetchIntervalMinutes: 10,
|
|
preferredCloneProtocol: "https",
|
|
diagnosticsEnabled: true,
|
|
diagnosticLevel: "info",
|
|
logRetentionDays: 14,
|
|
maxLogFileMb: 8,
|
|
};
|
|
|
|
let state = {
|
|
schemaVersion: 8,
|
|
setupComplete: storage.get("forgeflow-demo-setup") !== "false",
|
|
appearance: storage.get("forgeflow-theme") || "dark",
|
|
gitea: {
|
|
baseUrl: "https://gitea.internal",
|
|
user: { login: "jens", full_name: "Jens" },
|
|
hasToken: true,
|
|
},
|
|
workspaceRoots: ["C:\\Development"],
|
|
repositoryMappings: {},
|
|
deploymentProfiles: {},
|
|
deploymentStates: {},
|
|
favorites: [
|
|
"jens/microsoft-cloud-operations-platform",
|
|
"jens/unraid-appops-gateway",
|
|
],
|
|
updates: {
|
|
owner: "Jens",
|
|
repo: "ForgeFlow",
|
|
branch: "main",
|
|
autoCheck: true,
|
|
lastCheckedAt: null,
|
|
},
|
|
servers: [
|
|
{
|
|
id: "server-unraid",
|
|
name: "Unraid",
|
|
host: "192.168.1.10",
|
|
port: 22,
|
|
username: "root",
|
|
authType: "privateKey",
|
|
basePath: "/mnt/user/appdata",
|
|
privateKeyPath: "C:\\Users\\Jens\\.ssh\\id_ed25519",
|
|
hostFingerprint: "SHA256:demo",
|
|
hasPassword: false,
|
|
hasPassphrase: false,
|
|
},
|
|
],
|
|
preferences: { ...defaultPreferences },
|
|
operations: [
|
|
{
|
|
id: "op-success",
|
|
type: "deployment",
|
|
action: "deploy",
|
|
status: "success",
|
|
repository: "jens/microsoft-cloud-operations-platform",
|
|
profileId: "profile-mcop-prod",
|
|
profileName: "Production",
|
|
environment: "production",
|
|
workflowFile: "deploy.yml",
|
|
branch: "main",
|
|
sha: "b82f91ab0173cd4346ca0f0f7dcc3e8182cc8fd0",
|
|
shortSha: "b82f91a",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
stages: [
|
|
{ id: "requested", label: "Requested", status: "complete" },
|
|
{ id: "verified", label: "Verified", status: "complete" },
|
|
{ id: "queued", label: "Workflow queued", status: "complete" },
|
|
{ id: "runner", label: "Runner execution", status: "complete" },
|
|
{ id: "healthcheck", label: "Healthcheck", status: "complete" },
|
|
{ id: "complete", label: "Complete", status: "complete" },
|
|
],
|
|
logs: [
|
|
"[info] Exact commit verified.",
|
|
"[job] deploy: success",
|
|
"[ok] Server reports b82f91a and healthcheck returned 200.",
|
|
],
|
|
run: {
|
|
id: 48,
|
|
runNumber: 48,
|
|
status: "completed",
|
|
conclusion: "success",
|
|
name: "ForgeFlow deployment",
|
|
},
|
|
runUrl:
|
|
"https://gitea.internal/jens/microsoft-cloud-operations-platform/actions/runs/48",
|
|
},
|
|
{
|
|
id: "op-failed",
|
|
type: "deployment",
|
|
action: "deploy",
|
|
status: "failed",
|
|
repository: "jens/portfolio",
|
|
profileId: "profile-portfolio",
|
|
profileName: "Production",
|
|
environment: "production",
|
|
workflowFile: "deploy.yml",
|
|
branch: "main",
|
|
sha: "a7f2e1c1bb6147fc8b6633d2b08500c93402a719",
|
|
shortSha: "a7f2e1c",
|
|
createdAt: iso(-86400000),
|
|
updatedAt: iso(-86300000),
|
|
failure: { stage: "healthcheck", message: "Healthcheck returned 502." },
|
|
stages: [
|
|
{ id: "requested", label: "Requested", status: "complete" },
|
|
{ id: "verified", label: "Verified", status: "complete" },
|
|
{ id: "queued", label: "Workflow queued", status: "complete" },
|
|
{ id: "runner", label: "Runner execution", status: "complete" },
|
|
{ id: "healthcheck", label: "Healthcheck", status: "failed" },
|
|
{ id: "complete", label: "Complete", status: "failed" },
|
|
],
|
|
logs: ["[job] deploy: success", "[error] Healthcheck returned 502."],
|
|
},
|
|
],
|
|
};
|
|
|
|
let repositories = [
|
|
{
|
|
id: 1,
|
|
name: "microsoft-cloud-operations-platform",
|
|
fullName: "jens/microsoft-cloud-operations-platform",
|
|
owner: { login: "jens" },
|
|
description: "Tenant-aware Microsoft cloud operations console.",
|
|
private: true,
|
|
defaultBranch: "main",
|
|
htmlUrl:
|
|
"https://gitea.internal/jens/microsoft-cloud-operations-platform",
|
|
cloneUrl:
|
|
"https://gitea.internal/jens/microsoft-cloud-operations-platform.git",
|
|
sshUrl: "git@gitea.internal:jens/microsoft-cloud-operations-platform.git",
|
|
updatedAt: now,
|
|
localPath: "C:\\Development\\Microsoft-Cloud-Operations-Platform",
|
|
localStatus: makeStatus({
|
|
head: "b82f91ab0173cd4346ca0f0f7dcc3e8182cc8fd0",
|
|
}),
|
|
linkState: "linked",
|
|
deploymentProfiles: [
|
|
profile("profile-mcop-prod", "Production", "production", {
|
|
liveSha: "72bd10eb0173cd4346ca0f0f7dcc3e8182cc8fd0",
|
|
previousSha: "6ac991ab0173cd4346ca0f0f7dcc3e8182cc8fd0",
|
|
healthy: true,
|
|
}),
|
|
profile("profile-mcop-stage", "Staging", "staging", {
|
|
liveSha: "b82f91ab0173cd4346ca0f0f7dcc3e8182cc8fd0",
|
|
previousSha: "72bd10eb0173cd4346ca0f0f7dcc3e8182cc8fd0",
|
|
healthy: true,
|
|
confirmationRequired: false,
|
|
}),
|
|
],
|
|
},
|
|
{
|
|
id: 2,
|
|
name: "vacancyradar",
|
|
fullName: "jens/vacancyradar",
|
|
owner: { login: "jens" },
|
|
description: "Local-first vacancy intelligence cockpit.",
|
|
private: true,
|
|
defaultBranch: "main",
|
|
htmlUrl: "https://gitea.internal/jens/vacancyradar",
|
|
cloneUrl: "https://gitea.internal/jens/vacancyradar.git",
|
|
sshUrl: "git@gitea.internal:jens/vacancyradar.git",
|
|
updatedAt: now,
|
|
localPath: "C:\\Development\\VacancyRadar",
|
|
localStatus: makeStatus({
|
|
head: "c9182d0d28318c8cf0af109edc054732426aadf1",
|
|
branch: "feature/deployment-api",
|
|
files: [
|
|
makeFile("src/api/deploy.ts", "added", { staged: true }),
|
|
makeFile("src/main.tsx"),
|
|
makeFile("src/components/Sidebar.tsx"),
|
|
],
|
|
}),
|
|
linkState: "linked",
|
|
deploymentProfiles: [
|
|
profile("profile-vr", "Production", "production", {
|
|
liveSha: "c117ab9d28318c8cf0af109edc054732426aadf1",
|
|
previousSha: "b1f57aad28318c8cf0af109edc054732426aadf1",
|
|
healthy: true,
|
|
}),
|
|
],
|
|
},
|
|
{
|
|
id: 3,
|
|
name: "unraid-appops-gateway",
|
|
fullName: "jens/unraid-appops-gateway",
|
|
owner: { login: "jens" },
|
|
description: "Safe operations gateway for Unraid and Portainer.",
|
|
private: true,
|
|
defaultBranch: "main",
|
|
htmlUrl: "https://gitea.internal/jens/unraid-appops-gateway",
|
|
cloneUrl: "https://gitea.internal/jens/unraid-appops-gateway.git",
|
|
sshUrl: "git@gitea.internal:jens/unraid-appops-gateway.git",
|
|
updatedAt: now,
|
|
localPath: "C:\\Development\\Unraid-AppOps-Gateway",
|
|
localStatus: makeStatus({
|
|
head: "f2d1e0a1bb6147fc8b6633d2b08500c93402a719",
|
|
ahead: 2,
|
|
}),
|
|
linkState: "linked",
|
|
deploymentProfiles: [
|
|
profile("profile-appops", "Production", "production", {
|
|
liveSha: "8ac731b1bb6147fc8b6633d2b08500c93402a719",
|
|
previousSha: "7bc198a1bb6147fc8b6633d2b08500c93402a719",
|
|
healthy: true,
|
|
}),
|
|
],
|
|
},
|
|
{
|
|
id: 4,
|
|
name: "support-bundle-collector",
|
|
fullName: "jens/support-bundle-collector",
|
|
owner: { login: "jens" },
|
|
description: "Privacy-aware Windows support bundle collector.",
|
|
private: true,
|
|
defaultBranch: "main",
|
|
htmlUrl: "https://gitea.internal/jens/support-bundle-collector",
|
|
cloneUrl: "https://gitea.internal/jens/support-bundle-collector.git",
|
|
sshUrl: "git@gitea.internal:jens/support-bundle-collector.git",
|
|
updatedAt: now,
|
|
localPath: null,
|
|
localStatus: null,
|
|
linkState: "remote-only",
|
|
deploymentProfiles: [],
|
|
},
|
|
{
|
|
id: 5,
|
|
name: "portfolio",
|
|
fullName: "jens/portfolio",
|
|
owner: { login: "jens" },
|
|
description: "Professional infrastructure and automation portfolio.",
|
|
private: false,
|
|
defaultBranch: "main",
|
|
htmlUrl: "https://gitea.internal/jens/portfolio",
|
|
cloneUrl: "https://gitea.internal/jens/portfolio.git",
|
|
sshUrl: "git@gitea.internal:jens/portfolio.git",
|
|
updatedAt: now,
|
|
localPath: "C:\\Development\\portfolio",
|
|
localStatus: makeStatus({
|
|
head: "a7f2e1c1bb6147fc8b6633d2b08500c93402a719",
|
|
behind: 1,
|
|
}),
|
|
linkState: "linked",
|
|
deploymentProfiles: [
|
|
profile("profile-portfolio", "Production", "production", {
|
|
liveSha: "4c20dd11bb6147fc8b6633d2b08500c93402a719",
|
|
previousSha: "31adfe11bb6147fc8b6633d2b08500c93402a719",
|
|
healthy: false,
|
|
}),
|
|
],
|
|
},
|
|
];
|
|
|
|
const diffs = {
|
|
"src/api/deploy.ts": `diff --git a/src/api/deploy.ts b/src/api/deploy.ts\nnew file mode 100644\n--- /dev/null\n+++ b/src/api/deploy.ts\n@@ -0,0 +1,18 @@\n+export interface DeploymentRequest {\n+ environment: 'staging' | 'production';\n+ commitSha: string;\n+}\n+\n+export async function deploy(request: DeploymentRequest) {\n+ return api.post('/deployments', request);\n+}`,
|
|
"src/main.tsx": `diff --git a/src/main.tsx b/src/main.tsx\nindex 45ad1a2..939fc17 100644\n--- a/src/main.tsx\n+++ b/src/main.tsx\n@@ -24,8 +24,9 @@ import { Router } from './routes';\n-const API_ENDPOINT = 'http://localhost:3000';\n+const API_ENDPOINT = process.env.VITE_API_URL || '/api';\n+const DEPLOY_VERSION = '1.0.4-rc1';`,
|
|
"src/components/Sidebar.tsx": `diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx\nindex a7bbd82..bf21e90 100644\n--- a/src/components/Sidebar.tsx\n+++ b/src/components/Sidebar.tsx\n@@ -31,6 +31,7 @@ export function Sidebar() {\n+ <NavItem to="/deployments">Deployments</NavItem>`,
|
|
};
|
|
|
|
const findRepo = (localPath) =>
|
|
repositories.find((item) => item.localPath === localPath);
|
|
const findProfileRepo = (profileId) =>
|
|
repositories.find((item) =>
|
|
item.deploymentProfiles.some((entry) => entry.id === profileId),
|
|
);
|
|
const syncState = () => {
|
|
state.deploymentProfiles = {};
|
|
state.deploymentStates = {};
|
|
state.repositoryMappings = {};
|
|
for (const repository of repositories) {
|
|
if (repository.localPath)
|
|
state.repositoryMappings[repository.fullName.toLowerCase()] =
|
|
repository.localPath;
|
|
state.deploymentProfiles[repository.fullName.toLowerCase()] =
|
|
repository.deploymentProfiles.map(
|
|
({ state: profileState, ...entry }) => entry,
|
|
);
|
|
for (const entry of repository.deploymentProfiles)
|
|
if (entry.state) state.deploymentStates[entry.id] = clone(entry.state);
|
|
}
|
|
};
|
|
const recompute = (repository) => {
|
|
const status = repository.localStatus;
|
|
if (status) {
|
|
status.counts = {
|
|
changed: status.files.length,
|
|
staged: status.files.filter((item) => item.staged).length,
|
|
unstaged: status.files.filter((item) => item.unstaged).length,
|
|
conflicts: status.files.filter((item) => item.conflict).length,
|
|
untracked: status.files.filter((item) => item.untracked).length,
|
|
};
|
|
status.clean = status.files.length === 0;
|
|
status.shortHead = status.head.slice(0, 7);
|
|
status.branch.oid = status.head;
|
|
}
|
|
repository.favorite = state.favorites.includes(
|
|
repository.fullName.toLowerCase(),
|
|
);
|
|
repository.readyToDeploy = Boolean(
|
|
repository.localPath &&
|
|
status?.clean &&
|
|
status.branch.upstream &&
|
|
status.branch.ahead === 0 &&
|
|
status.branch.behind === 0 &&
|
|
repository.deploymentProfiles.some(
|
|
(entry) => entry.branch === status.branch.head,
|
|
),
|
|
);
|
|
repository.attention =
|
|
!repository.localPath ||
|
|
Boolean(
|
|
status?.counts.conflicts ||
|
|
status?.branch.behind ||
|
|
status?.branch.ahead ||
|
|
status?.counts.changed,
|
|
);
|
|
repository.attentionReason = !repository.localPath
|
|
? "No local folder linked"
|
|
: status?.counts.conflicts
|
|
? `${status.counts.conflicts} conflict(s)`
|
|
: status?.counts.changed
|
|
? `${status.counts.changed} local change(s)`
|
|
: status?.branch.behind
|
|
? `${status.branch.behind} commit(s) behind remote`
|
|
: status?.branch.ahead
|
|
? `${status.branch.ahead} unpushed commit(s)`
|
|
: null;
|
|
repository.preferredCloneUrl =
|
|
state.preferences.preferredCloneProtocol === "ssh"
|
|
? repository.sshUrl
|
|
: repository.cloneUrl;
|
|
};
|
|
const snapshot = () => {
|
|
repositories.forEach(recompute);
|
|
syncState();
|
|
return clone(repositories);
|
|
};
|
|
syncState();
|
|
|
|
const commitHistory = [
|
|
{
|
|
sha: "c9182d0d28318c8cf0af109edc054732426aadf1",
|
|
shortSha: "c9182d0",
|
|
author: "Jens",
|
|
date: now,
|
|
subject: "feat: add deployment provider contract",
|
|
},
|
|
{
|
|
sha: "1fa7399d28318c8cf0af109edc054732426aadf1",
|
|
shortSha: "1fa7399",
|
|
author: "Jens",
|
|
date: iso(-86400000),
|
|
subject: "refactor: consolidate repository state",
|
|
},
|
|
{
|
|
sha: "a251a11d28318c8cf0af109edc054732426aadf1",
|
|
shortSha: "a251a11",
|
|
author: "Jens",
|
|
date: iso(-172800000),
|
|
subject: "docs: define deployment safety gates",
|
|
},
|
|
];
|
|
const branchesByRepo = new Map();
|
|
const stashesByRepo = new Map();
|
|
|
|
function updateOperation(operation) {
|
|
state.operations = [
|
|
clone(operation),
|
|
...state.operations.filter((item) => item.id !== operation.id),
|
|
].slice(0, 250);
|
|
emitOperations([operation]);
|
|
return clone(operation);
|
|
}
|
|
|
|
function advanceOperation(operation) {
|
|
if (
|
|
!operation ||
|
|
["success", "failed", "cancelled", "rolled-back"].includes(
|
|
operation.status,
|
|
)
|
|
)
|
|
return operation;
|
|
operation.demoPolls = (operation.demoPolls || 0) + 1;
|
|
if (operation.demoPolls === 1) {
|
|
operation.status = "running";
|
|
operation.run = {
|
|
id: 81,
|
|
runNumber: 81,
|
|
status: "running",
|
|
conclusion: null,
|
|
name:
|
|
operation.action === "rollback"
|
|
? "ForgeFlow rollback"
|
|
: "ForgeFlow deployment",
|
|
};
|
|
operation.runUrl = `https://gitea.internal/${operation.repository}/actions/runs/81`;
|
|
operation.stages.find((item) => item.id === "queued").status = "complete";
|
|
operation.stages.find((item) => item.id === "runner").status = "active";
|
|
operation.jobs = [
|
|
{
|
|
id: 201,
|
|
name: operation.action === "rollback" ? "rollback" : "deploy",
|
|
status: "running",
|
|
conclusion: null,
|
|
},
|
|
];
|
|
operation.logs.push(`[job] ${operation.jobs[0].name}: running`);
|
|
} else if (operation.demoPolls >= 2) {
|
|
operation.status =
|
|
operation.action === "rollback" ? "rolled-back" : "success";
|
|
operation.stages.forEach((item) => {
|
|
item.status = "complete";
|
|
});
|
|
operation.jobs = [
|
|
{
|
|
id: 201,
|
|
name: operation.action === "rollback" ? "rollback" : "deploy",
|
|
status: "completed",
|
|
conclusion: "success",
|
|
},
|
|
];
|
|
operation.logs.push(
|
|
"[ok] Runner completed successfully.",
|
|
`[ok] Server status endpoint confirms ${operation.shortSha}.`,
|
|
);
|
|
const repository = repositories.find(
|
|
(item) => item.fullName === operation.repository,
|
|
);
|
|
const targetProfile = repository?.deploymentProfiles.find(
|
|
(item) => item.id === operation.profileId,
|
|
);
|
|
if (targetProfile) {
|
|
const oldLive = targetProfile.state.liveSha;
|
|
targetProfile.state.previousSha = oldLive;
|
|
targetProfile.state.liveSha = operation.sha;
|
|
targetProfile.state.healthy = true;
|
|
targetProfile.state.checkedAt = iso();
|
|
}
|
|
}
|
|
operation.updatedAt = iso();
|
|
return operation;
|
|
}
|
|
|
|
window.forgeflow = Object.freeze({
|
|
async bootstrap() {
|
|
await wait(80);
|
|
snapshot();
|
|
return {
|
|
appVersion: "0.8.1-demo",
|
|
platform: "win32",
|
|
state: clone(state),
|
|
git: { available: true, version: "git version 2.47.3" },
|
|
diagnostics: {
|
|
enabled: true,
|
|
level: state.preferences.diagnosticLevel,
|
|
retentionDays: state.preferences.logRetentionDays,
|
|
maxFileMb: state.preferences.maxLogFileMb,
|
|
directory: "<HOME>/AppData/Roaming/ForgeFlow/diagnostics",
|
|
fileCount: 2,
|
|
totalBytes: 18432,
|
|
totalSize: "18.0 KB",
|
|
latestAt: iso(-2000),
|
|
lastWriteError: null,
|
|
},
|
|
};
|
|
},
|
|
async selectDirectory() {
|
|
await wait();
|
|
return "C:\\Development";
|
|
},
|
|
async selectKeyFile() {
|
|
await wait();
|
|
return "C:\\Users\\Jens\\.ssh\\id_ed25519";
|
|
},
|
|
async setupPreflight({ baseUrl, token, roots = [] }) {
|
|
await wait(240);
|
|
const checks = [
|
|
{
|
|
id: "git.available",
|
|
label: "Git command line",
|
|
status: "pass",
|
|
detail: "git version 2.47.3",
|
|
required: true,
|
|
},
|
|
{
|
|
id: "git.identity",
|
|
label: "Git author identity",
|
|
status: "pass",
|
|
detail: "Jens <jens@example.invalid>",
|
|
required: false,
|
|
},
|
|
{
|
|
id: "storage.userdata",
|
|
label: "Application data storage",
|
|
status: "pass",
|
|
detail: "ForgeFlow can write its local configuration.",
|
|
required: true,
|
|
},
|
|
{
|
|
id: "storage.diagnostics",
|
|
label: "Diagnostic log storage",
|
|
status: "pass",
|
|
detail: "The diagnostic directory is writable.",
|
|
required: true,
|
|
},
|
|
{
|
|
id: "storage.credentials",
|
|
label: "Protected credential storage",
|
|
status: "pass",
|
|
detail: "The operating system can encrypt the Gitea token at rest.",
|
|
required: false,
|
|
},
|
|
{
|
|
id: "workspace.roots",
|
|
label: "Development folders",
|
|
status: roots.length ? "pass" : "warning",
|
|
detail: roots.length
|
|
? `${roots.length} folder(s) selected.`
|
|
: "No development folder selected yet.",
|
|
required: false,
|
|
},
|
|
{
|
|
id: "gitea.connection",
|
|
label: "Gitea connection",
|
|
status: baseUrl && token ? "pass" : "warning",
|
|
detail:
|
|
baseUrl && token
|
|
? "Connection parameters are ready for validation."
|
|
: "Enter the Gitea URL and token.",
|
|
required: false,
|
|
},
|
|
];
|
|
return {
|
|
kind: "system",
|
|
startedAt: iso(-100),
|
|
completedAt: iso(),
|
|
checks,
|
|
summary: {
|
|
counts: {
|
|
pass: checks.filter((i) => i.status === "pass").length,
|
|
warning: checks.filter((i) => i.status === "warning").length,
|
|
fail: 0,
|
|
skipped: 0,
|
|
},
|
|
blocking: [],
|
|
ready: true,
|
|
},
|
|
};
|
|
},
|
|
async validateGitea({ baseUrl, token }) {
|
|
await wait(320);
|
|
if (!baseUrl || !token)
|
|
throw new Error("Enter an instance URL and access token.");
|
|
return {
|
|
baseUrl: baseUrl.replace(/\/$/, ""),
|
|
user: { login: "jens", full_name: "Jens" },
|
|
repositoryCount: repositories.length,
|
|
version: "1.26.0",
|
|
};
|
|
},
|
|
async completeSetup(payload) {
|
|
await wait(300);
|
|
state.setupComplete = true;
|
|
state.gitea = {
|
|
baseUrl: payload.baseUrl,
|
|
user: payload.user,
|
|
hasToken: true,
|
|
};
|
|
state.workspaceRoots = payload.workspaceRoots;
|
|
storage.set("forgeflow-demo-setup", "true");
|
|
return { state: clone(state), tokenState: { persistent: true } };
|
|
},
|
|
async updateGitea(payload) {
|
|
const validation = await this.validateGitea({
|
|
...payload,
|
|
token: payload.token || "preserved-demo-token",
|
|
});
|
|
state.gitea = {
|
|
baseUrl: validation.baseUrl,
|
|
user: validation.user,
|
|
hasToken: true,
|
|
};
|
|
return {
|
|
validation,
|
|
tokenState: { persistent: true, preserved: !payload.token },
|
|
state: clone(state),
|
|
};
|
|
},
|
|
async setWorkspaceRoots(roots) {
|
|
state.workspaceRoots = [...new Set(roots)];
|
|
return clone(state);
|
|
},
|
|
async setAppearance(appearance) {
|
|
state.appearance = appearance;
|
|
storage.set("forgeflow-theme", appearance);
|
|
return clone(state);
|
|
},
|
|
async setPreferences(preferences) {
|
|
state.preferences = { ...state.preferences, ...preferences };
|
|
snapshot();
|
|
return clone(state);
|
|
},
|
|
async setUpdatePreferences(updates) {
|
|
state.updates = { ...state.updates, ...updates };
|
|
return clone(state);
|
|
},
|
|
async checkForUpdates() {
|
|
await wait(300);
|
|
return {
|
|
checkedAt: iso(),
|
|
owner: state.updates.owner,
|
|
repo: state.updates.repo,
|
|
branch: state.updates.branch,
|
|
currentVersion: "0.5.4",
|
|
remoteVersion: "0.6.0",
|
|
remoteSha: "a".repeat(40),
|
|
shortSha: "aaaaaaa",
|
|
available: true,
|
|
mode: "source",
|
|
};
|
|
},
|
|
async downloadUpdate() {
|
|
await wait(500);
|
|
return {
|
|
...(await this.checkForUpdates()),
|
|
downloaded: true,
|
|
archivePath: "C:\\Temp\\ForgeFlow-0.4.1.zip",
|
|
sha256: "b".repeat(64),
|
|
};
|
|
},
|
|
async applyUpdate() {
|
|
await wait(200);
|
|
return { launched: true, confirmed: true, version: "0.6.0" };
|
|
},
|
|
async saveServer(server) {
|
|
const saved = {
|
|
...server,
|
|
id: server.id || `server-${Date.now()}`,
|
|
hasPassword: server.authType === "password",
|
|
hasPassphrase: false,
|
|
};
|
|
state.servers = [
|
|
saved,
|
|
...state.servers.filter((item) => item.id !== saved.id),
|
|
];
|
|
return { server: clone(saved), state: clone(state) };
|
|
},
|
|
async deleteServer(serverId) {
|
|
state.servers = state.servers.filter((item) => item.id !== serverId);
|
|
return clone(state);
|
|
},
|
|
async testServer(serverId) {
|
|
const server = state.servers.find((item) => item.id === serverId);
|
|
server.hostFingerprint = server.hostFingerprint || "SHA256:demo";
|
|
return {
|
|
connected: true,
|
|
fingerprint: server.hostFingerprint,
|
|
server: clone(server),
|
|
output: "Linux\n/usr/bin/git\nDocker Compose version v2",
|
|
state: clone(state),
|
|
};
|
|
},
|
|
async inspectServerProject() {
|
|
return {
|
|
exists: true,
|
|
rootGit: true,
|
|
head: "d42d4a7".padEnd(40, "0"),
|
|
branch: "main",
|
|
trackedChanges: [],
|
|
composeFiles: ["docker-compose.yml"],
|
|
nestedGit: ["source"],
|
|
dockerfile: true,
|
|
};
|
|
},
|
|
async refreshRepositories() {
|
|
await wait(260);
|
|
return snapshot();
|
|
},
|
|
async discoverRepositories() {
|
|
await wait(360);
|
|
return snapshot()
|
|
.filter((repo) => repo.localPath)
|
|
.map((repo) => ({
|
|
localPath: repo.localPath,
|
|
remoteUrl: repo.cloneUrl,
|
|
status: repo.localStatus,
|
|
}));
|
|
},
|
|
async favoriteRepository(fullName, favorite) {
|
|
const key = fullName.toLowerCase();
|
|
state.favorites = favorite
|
|
? [...new Set([...state.favorites, key])]
|
|
: state.favorites.filter((item) => item !== key);
|
|
snapshot();
|
|
return clone(state);
|
|
},
|
|
async linkRepository(fullName, localPath) {
|
|
const repo = repositories.find((item) => item.fullName === fullName);
|
|
repo.localPath = localPath;
|
|
repo.linkState = "linked";
|
|
repo.localStatus = makeStatus({ head: randomSha() });
|
|
emitRepositories();
|
|
return snapshot();
|
|
},
|
|
async unlinkRepository(fullName) {
|
|
const repo = repositories.find((item) => item.fullName === fullName);
|
|
repo.localPath = null;
|
|
repo.localStatus = null;
|
|
repo.linkState = "remote-only";
|
|
emitRepositories();
|
|
return snapshot();
|
|
},
|
|
async repositoryStatus(localPath) {
|
|
return clone(findRepo(localPath)?.localStatus);
|
|
},
|
|
async repositoryDiff(localPath, filePath) {
|
|
await wait(80);
|
|
return (
|
|
diffs[filePath] ||
|
|
`diff --git a/${filePath} b/${filePath}\n--- a/${filePath}\n+++ b/${filePath}\n@@ -1 +1 @@\n-old\n+new`
|
|
);
|
|
},
|
|
async repositoryDiffHunks(localPath, filePath) {
|
|
const diff = await this.repositoryDiff(localPath, filePath);
|
|
return {
|
|
filePath,
|
|
partialSupported: true,
|
|
hunks: [
|
|
{
|
|
index: 0,
|
|
heading: "@@ -1 +1 @@",
|
|
additions: 1,
|
|
deletions: 1,
|
|
lines: diff.split("\n").slice(-4),
|
|
},
|
|
],
|
|
};
|
|
},
|
|
async stageHunks(localPath, filePath) {
|
|
return this.stageFiles(localPath, [filePath]);
|
|
},
|
|
async conflictState(localPath) {
|
|
const repo = findRepo(localPath);
|
|
const files = repo.localStatus.files
|
|
.filter((item) => item.conflict)
|
|
.map((item) => item.path);
|
|
return {
|
|
operation: files.length ? "merge" : null,
|
|
files,
|
|
canContinue: false,
|
|
status: clone(repo.localStatus),
|
|
};
|
|
},
|
|
async resolveConflict(localPath, filePath) {
|
|
const repo = findRepo(localPath);
|
|
const file = repo.localStatus.files.find(
|
|
(item) => item.path === filePath,
|
|
);
|
|
if (file) {
|
|
file.conflict = false;
|
|
file.staged = true;
|
|
file.unstaged = false;
|
|
}
|
|
recompute(repo);
|
|
return this.conflictState(localPath);
|
|
},
|
|
async continueGitOperation(localPath) {
|
|
return this.conflictState(localPath);
|
|
},
|
|
async abortGitOperation(localPath) {
|
|
return this.conflictState(localPath);
|
|
},
|
|
async stageFiles(localPath, files) {
|
|
const repo = findRepo(localPath);
|
|
repo.localStatus.files.forEach((item) => {
|
|
if (!files?.length || files.includes(item.path)) {
|
|
item.staged = true;
|
|
item.unstaged = false;
|
|
item.indexCode = item.untracked ? "A" : "M";
|
|
item.worktreeCode = ".";
|
|
}
|
|
});
|
|
recompute(repo);
|
|
emitRepositories();
|
|
return clone(repo.localStatus);
|
|
},
|
|
async unstageFiles(localPath, files) {
|
|
const repo = findRepo(localPath);
|
|
repo.localStatus.files.forEach((item) => {
|
|
if (!files?.length || files.includes(item.path)) {
|
|
item.staged = false;
|
|
item.unstaged = true;
|
|
item.indexCode = ".";
|
|
item.worktreeCode = item.untracked ? "?" : "M";
|
|
}
|
|
});
|
|
recompute(repo);
|
|
emitRepositories();
|
|
return clone(repo.localStatus);
|
|
},
|
|
async commit(localPath, message, files) {
|
|
await wait(520);
|
|
if (!message?.trim()) throw new Error("Enter a commit message.");
|
|
const repo = findRepo(localPath);
|
|
repo.localStatus.files = repo.localStatus.files.filter(
|
|
(item) => !files?.includes(item.path),
|
|
);
|
|
repo.localStatus.head = randomSha();
|
|
repo.localStatus.branch.ahead += 1;
|
|
recompute(repo);
|
|
emitRepositories();
|
|
return {
|
|
commitOutput: `[${repo.localStatus.branch.head} ${repo.localStatus.shortHead}] ${message}`,
|
|
commitSha: repo.localStatus.head,
|
|
status: clone(repo.localStatus),
|
|
};
|
|
},
|
|
async commitAndPush(localPath, message, files) {
|
|
const result = await this.commit(localPath, message, files);
|
|
const repo = findRepo(localPath);
|
|
await wait(240);
|
|
repo.localStatus.branch.ahead = 0;
|
|
recompute(repo);
|
|
emitRepositories();
|
|
return {
|
|
...result,
|
|
pushOutput: "Push completed.",
|
|
status: clone(repo.localStatus),
|
|
};
|
|
},
|
|
async commitStaged(localPath, message) {
|
|
const repo = findRepo(localPath);
|
|
return this.commit(
|
|
localPath,
|
|
message,
|
|
repo.localStatus.files
|
|
.filter((item) => item.staged)
|
|
.map((item) => item.path),
|
|
);
|
|
},
|
|
async commitStagedAndPush(localPath, message) {
|
|
const repo = findRepo(localPath);
|
|
return this.commitAndPush(
|
|
localPath,
|
|
message,
|
|
repo.localStatus.files
|
|
.filter((item) => item.staged)
|
|
.map((item) => item.path),
|
|
);
|
|
},
|
|
async push(localPath) {
|
|
await wait(360);
|
|
const repo = findRepo(localPath);
|
|
repo.localStatus.branch.ahead = 0;
|
|
recompute(repo);
|
|
emitRepositories();
|
|
return { output: "Push completed.", status: clone(repo.localStatus) };
|
|
},
|
|
async fetch() {
|
|
await wait(260);
|
|
return { output: "Fetch completed." };
|
|
},
|
|
async pull(localPath) {
|
|
await wait(380);
|
|
const repo = findRepo(localPath);
|
|
repo.localStatus.branch.behind = 0;
|
|
recompute(repo);
|
|
emitRepositories();
|
|
return { output: "Fast-forwarded.", status: clone(repo.localStatus) };
|
|
},
|
|
async history() {
|
|
await wait(100);
|
|
return clone(commitHistory);
|
|
},
|
|
async branchProtection(fullName, branch) {
|
|
return {
|
|
branch,
|
|
protected: branch === "main",
|
|
requiredApprovals: branch === "main" ? 1 : 0,
|
|
requireSignedCommits: false,
|
|
};
|
|
},
|
|
async pullRequests() {
|
|
return [
|
|
{
|
|
number: 42,
|
|
title: "Harden deployment preflight",
|
|
html_url: "https://gitea.internal/jens/vacancyradar/pulls/42",
|
|
created_at: iso(-7_200_000),
|
|
updated_at: iso(-900_000),
|
|
head: { ref: "feature/deployment-api" },
|
|
base: { ref: "main" },
|
|
},
|
|
];
|
|
},
|
|
async createPullRequest(fullName, title, body, base) {
|
|
return {
|
|
number: 42,
|
|
title,
|
|
body,
|
|
base,
|
|
html_url: `https://gitea.internal/${fullName}/pulls/42`,
|
|
};
|
|
},
|
|
async branches(localPath) {
|
|
const repo = findRepo(localPath);
|
|
if (!branchesByRepo.has(localPath))
|
|
branchesByRepo.set(localPath, [
|
|
{
|
|
name: repo.localStatus.branch.head,
|
|
current: true,
|
|
sha: repo.localStatus.head,
|
|
shortSha: repo.localStatus.shortHead,
|
|
upstream: repo.localStatus.branch.upstream,
|
|
},
|
|
{
|
|
name: "main",
|
|
current: repo.localStatus.branch.head === "main",
|
|
sha: repo.localStatus.head,
|
|
shortSha: repo.localStatus.shortHead,
|
|
upstream: "origin/main",
|
|
},
|
|
]);
|
|
return clone(branchesByRepo.get(localPath));
|
|
},
|
|
async checkoutBranch(localPath, branch) {
|
|
const repo = findRepo(localPath);
|
|
if (!repo.localStatus.clean)
|
|
throw new Error(
|
|
"Commit or stash local changes before switching branches.",
|
|
);
|
|
const list = await this.branches(localPath);
|
|
list.forEach((item) => {
|
|
item.current = item.name === branch;
|
|
});
|
|
branchesByRepo.set(localPath, list);
|
|
repo.localStatus.branch.head = branch;
|
|
repo.localStatus.branch.upstream = `origin/${branch}`;
|
|
recompute(repo);
|
|
emitRepositories();
|
|
return { status: clone(repo.localStatus), branches: clone(list) };
|
|
},
|
|
async createBranch(localPath, branch) {
|
|
const repo = findRepo(localPath);
|
|
const list = await this.branches(localPath);
|
|
list.forEach((item) => {
|
|
item.current = false;
|
|
});
|
|
list.unshift({
|
|
name: branch,
|
|
current: true,
|
|
sha: repo.localStatus.head,
|
|
shortSha: repo.localStatus.shortHead,
|
|
upstream: null,
|
|
});
|
|
branchesByRepo.set(localPath, list);
|
|
repo.localStatus.branch.head = branch;
|
|
repo.localStatus.branch.upstream = null;
|
|
recompute(repo);
|
|
emitRepositories();
|
|
return { status: clone(repo.localStatus), branches: clone(list) };
|
|
},
|
|
async stash(localPath, message) {
|
|
const repo = findRepo(localPath);
|
|
const list = stashesByRepo.get(localPath) || [];
|
|
list.unshift({
|
|
ref: `stash@{${list.length}}`,
|
|
subject: message || "ForgeFlow stash",
|
|
date: iso(),
|
|
});
|
|
stashesByRepo.set(localPath, list);
|
|
repo.localStatus.files = [];
|
|
recompute(repo);
|
|
emitRepositories();
|
|
return {
|
|
output: "Saved working directory and index state.",
|
|
status: clone(repo.localStatus),
|
|
stashes: clone(list),
|
|
};
|
|
},
|
|
async stashList(localPath) {
|
|
return clone(stashesByRepo.get(localPath) || []);
|
|
},
|
|
async popStash(localPath, ref) {
|
|
const repo = findRepo(localPath);
|
|
const list = stashesByRepo.get(localPath) || [];
|
|
const index = list.findIndex((item) => item.ref === ref);
|
|
if (index < 0) throw new Error("Stash not found.");
|
|
list.splice(index, 1);
|
|
stashesByRepo.set(localPath, list);
|
|
repo.localStatus.files = [makeFile("src/restored-from-stash.ts")];
|
|
recompute(repo);
|
|
emitRepositories();
|
|
return {
|
|
output: "Stash applied.",
|
|
status: clone(repo.localStatus),
|
|
stashes: clone(list),
|
|
};
|
|
},
|
|
async indexLockInfo() {
|
|
return { exists: false, ageMs: 0 };
|
|
},
|
|
async repairIndexLock() {
|
|
return { removed: true };
|
|
},
|
|
async setOrigin(localPath, remoteUrl) {
|
|
const repo = findRepo(localPath);
|
|
repo.localStatus.remoteUrl = remoteUrl;
|
|
repo.sshUrl = remoteUrl;
|
|
emitRepositories();
|
|
return clone(repo.localStatus);
|
|
},
|
|
async normalizeOrigins() {
|
|
const changes = [];
|
|
repositories
|
|
.filter((repo) => repo.localPath && repo.sshUrl)
|
|
.forEach((repo) => {
|
|
if (repo.localStatus.remoteUrl !== repo.sshUrl) {
|
|
changes.push({
|
|
fullName: repo.fullName,
|
|
previous: repo.localStatus.remoteUrl,
|
|
next: repo.sshUrl,
|
|
});
|
|
repo.localStatus.remoteUrl = repo.sshUrl;
|
|
}
|
|
});
|
|
emitRepositories();
|
|
return { changes, repositories: snapshot() };
|
|
},
|
|
async cloneRepository(fullName, mode = "default") {
|
|
await wait(620);
|
|
const repository = repositories.find(
|
|
(item) => item.fullName === fullName,
|
|
);
|
|
if (!repository) throw new Error("Repository not found.");
|
|
if (repository.localPath)
|
|
throw new Error("This repository already has a linked local folder.");
|
|
const root =
|
|
mode === "custom" ? "D:\\OtherProjects" : state.workspaceRoots[0];
|
|
if (!root) return { cancelled: true };
|
|
const target = `${root.replace(/[\\/]+$/, "")}\\${repository.name}`;
|
|
const head = randomSha();
|
|
repository.localPath = target;
|
|
repository.localStatus = makeStatus({
|
|
head,
|
|
branch: repository.defaultBranch || "main",
|
|
});
|
|
repository.localStatus.root = target;
|
|
repository.localStatus.remoteUrl =
|
|
repository.preferredCloneUrl || repository.cloneUrl;
|
|
repository.linkState = "linked";
|
|
recompute(repository);
|
|
const current = snapshot();
|
|
emitRepositories();
|
|
return {
|
|
target,
|
|
status: clone(repository.localStatus),
|
|
reused: false,
|
|
repositories: current,
|
|
state: clone(state),
|
|
};
|
|
},
|
|
async openPath() {
|
|
return true;
|
|
},
|
|
async openEditor() {
|
|
return { launched: true, executable: "code" };
|
|
},
|
|
async openTerminal() {
|
|
return { launched: true, executable: "wt.exe" };
|
|
},
|
|
async openExternal() {
|
|
return true;
|
|
},
|
|
async saveDeploymentProfile(fullName, input) {
|
|
const repo = repositories.find((item) => item.fullName === fullName);
|
|
const existing = repo.deploymentProfiles.find(
|
|
(item) => item.id === input.id,
|
|
);
|
|
const saved = {
|
|
...(existing ||
|
|
profile(
|
|
input.id || `profile-${Date.now()}`,
|
|
input.name || input.environment,
|
|
input.environment || "production",
|
|
)),
|
|
...input,
|
|
id: input.id || `profile-${Date.now()}`,
|
|
provider: "gitea-actions",
|
|
inputs: existing?.inputs || {},
|
|
state: existing?.state || {
|
|
liveSha: null,
|
|
previousSha: null,
|
|
healthy: null,
|
|
healthConfigured: Boolean(input.healthcheckUrl),
|
|
statusConfigured: Boolean(input.statusUrl),
|
|
checkedAt: null,
|
|
},
|
|
};
|
|
repo.deploymentProfiles = [
|
|
...repo.deploymentProfiles.filter((item) => item.id !== saved.id),
|
|
saved,
|
|
];
|
|
snapshot();
|
|
return { profile: clone(saved), state: clone(state) };
|
|
},
|
|
async deleteDeploymentProfile(fullName, profileId) {
|
|
const repo = repositories.find((item) => item.fullName === fullName);
|
|
repo.deploymentProfiles = repo.deploymentProfiles.filter(
|
|
(item) => item.id !== profileId,
|
|
);
|
|
snapshot();
|
|
return { profiles: clone(repo.deploymentProfiles), state: clone(state) };
|
|
},
|
|
async deploymentPreflight(repository, profileId) {
|
|
await wait(280);
|
|
const profile = repository.deploymentProfiles.find(
|
|
(item) => item.id === profileId,
|
|
);
|
|
const status = repository.localStatus;
|
|
const checks = [
|
|
{
|
|
id: "repository.linked",
|
|
label: "Local repository link",
|
|
status: repository.localPath ? "pass" : "fail",
|
|
detail: repository.localPath || "No local folder linked.",
|
|
required: true,
|
|
},
|
|
{
|
|
id: "git.branch",
|
|
label: "Allowed branch",
|
|
status: status?.branch.head === profile?.branch ? "pass" : "fail",
|
|
detail: `Current: ${status?.branch.head || "unknown"}; required: ${profile?.branch || "unknown"}.`,
|
|
required: true,
|
|
},
|
|
{
|
|
id: "git.clean",
|
|
label: "Clean working tree",
|
|
status: status?.clean ? "pass" : "fail",
|
|
detail: status?.clean
|
|
? "No uncommitted changes."
|
|
: `${status?.counts.changed || 0} changed file(s).`,
|
|
required: true,
|
|
},
|
|
{
|
|
id: "git.sync",
|
|
label: "Local and Gitea synchronized",
|
|
status:
|
|
!status?.branch.ahead && !status?.branch.behind ? "pass" : "fail",
|
|
detail: `${status?.branch.ahead || 0} ahead, ${status?.branch.behind || 0} behind.`,
|
|
required: true,
|
|
},
|
|
{
|
|
id: "workflow.deploy.remote",
|
|
label: "Deploy workflow on Gitea branch",
|
|
status: "pass",
|
|
detail: `${profile?.workflowFile || "deploy.yml"} exists on ${profile?.branch || "main"}.`,
|
|
required: true,
|
|
},
|
|
{
|
|
id: "gitea.actions",
|
|
label: "Gitea Actions API",
|
|
status: "pass",
|
|
detail: "The Actions runs endpoint is accessible.",
|
|
required: true,
|
|
},
|
|
{
|
|
id: "server.status",
|
|
label: "Server version endpoint",
|
|
status: profile?.statusUrl ? "pass" : "warning",
|
|
detail: profile?.statusUrl
|
|
? `Endpoint reachable; live ${profile.state?.liveSha?.slice(0, 7) || "unknown"}.`
|
|
: "No status URL configured.",
|
|
required: false,
|
|
},
|
|
{
|
|
id: "server.health",
|
|
label: "Application healthcheck",
|
|
status: profile?.healthcheckUrl ? "pass" : "warning",
|
|
detail: profile?.healthcheckUrl
|
|
? "HTTP 200 in 42 ms."
|
|
: "No healthcheck URL configured.",
|
|
required: false,
|
|
},
|
|
];
|
|
const blocking = checks
|
|
.filter((i) => i.required && i.status === "fail")
|
|
.map((i) => i.id);
|
|
return {
|
|
kind: "deployment",
|
|
repository: repository.fullName,
|
|
profileId,
|
|
startedAt: iso(-100),
|
|
completedAt: iso(),
|
|
checks,
|
|
summary: {
|
|
counts: {
|
|
pass: checks.filter((i) => i.status === "pass").length,
|
|
warning: checks.filter((i) => i.status === "warning").length,
|
|
fail: checks.filter((i) => i.status === "fail").length,
|
|
skipped: 0,
|
|
},
|
|
blocking,
|
|
ready: blocking.length === 0,
|
|
},
|
|
head: status?.head || null,
|
|
};
|
|
},
|
|
async deploy(repository, profileId, sha) {
|
|
await wait(320);
|
|
const selected = repository.deploymentProfiles.find(
|
|
(item) => item.id === profileId,
|
|
);
|
|
const operation = {
|
|
id: `deploy-${Date.now()}`,
|
|
type: "deployment",
|
|
action: "deploy",
|
|
status: "queued",
|
|
repository: repository.fullName,
|
|
profileId,
|
|
profileName: selected.name,
|
|
environment: selected.environment,
|
|
workflowFile: selected.workflowFile,
|
|
branch: selected.branch,
|
|
sha,
|
|
shortSha: sha.slice(0, 7),
|
|
dispatchedAt: iso(),
|
|
createdAt: iso(),
|
|
updatedAt: iso(),
|
|
demoPolls: 0,
|
|
stages: [
|
|
{ id: "requested", label: "Requested", status: "complete" },
|
|
{ id: "verified", label: "Verified", status: "complete" },
|
|
{ id: "queued", label: "Workflow queued", status: "active" },
|
|
{ id: "runner", label: "Runner execution", status: "pending" },
|
|
{ id: "healthcheck", label: "Healthcheck", status: "pending" },
|
|
{ id: "complete", label: "Complete", status: "pending" },
|
|
],
|
|
logs: [
|
|
`[info] Verified clean ${selected.branch} at ${sha}`,
|
|
`[ok] Gitea accepted ${selected.workflowFile}.`,
|
|
],
|
|
};
|
|
return updateOperation(operation);
|
|
},
|
|
async rollback(repository, profileId, targetSha) {
|
|
await wait(320);
|
|
const selected = repository.deploymentProfiles.find(
|
|
(item) => item.id === profileId,
|
|
);
|
|
const operation = {
|
|
id: `rollback-${Date.now()}`,
|
|
type: "deployment",
|
|
action: "rollback",
|
|
status: "queued",
|
|
repository: repository.fullName,
|
|
profileId,
|
|
profileName: selected.name,
|
|
environment: selected.environment,
|
|
workflowFile: selected.rollbackWorkflowFile,
|
|
branch: selected.branch,
|
|
sha: targetSha,
|
|
shortSha: targetSha.slice(0, 7),
|
|
dispatchedAt: iso(),
|
|
createdAt: iso(),
|
|
updatedAt: iso(),
|
|
demoPolls: 0,
|
|
stages: [
|
|
{ id: "requested", label: "Requested", status: "complete" },
|
|
{ id: "verified", label: "Verified", status: "complete" },
|
|
{ id: "queued", label: "Workflow queued", status: "active" },
|
|
{ id: "runner", label: "Runner execution", status: "pending" },
|
|
{ id: "healthcheck", label: "Healthcheck", status: "pending" },
|
|
{ id: "complete", label: "Complete", status: "pending" },
|
|
],
|
|
logs: [
|
|
`[warning] Rollback target verified: ${targetSha}`,
|
|
`[ok] Gitea accepted ${selected.rollbackWorkflowFile}.`,
|
|
],
|
|
};
|
|
return updateOperation(operation);
|
|
},
|
|
async healthcheck() {
|
|
await wait(160);
|
|
return { configured: true, healthy: true, status: 200, latencyMs: 42 };
|
|
},
|
|
async refreshProfileState(fullName, profileId) {
|
|
await wait(240);
|
|
const repo =
|
|
repositories.find((item) => item.fullName === fullName) ||
|
|
findProfileRepo(profileId);
|
|
const target = repo?.deploymentProfiles.find(
|
|
(item) => item.id === profileId,
|
|
);
|
|
if (!target) throw new Error("Deployment profile not found.");
|
|
target.state = {
|
|
...target.state,
|
|
checkedAt: iso(),
|
|
healthy: target.state.healthy !== false,
|
|
healthConfigured: Boolean(target.healthcheckUrl),
|
|
statusConfigured: Boolean(target.statusUrl),
|
|
};
|
|
syncState();
|
|
return clone(target.state);
|
|
},
|
|
async refreshOperations(operationId = null) {
|
|
await wait(300);
|
|
if (operationId) {
|
|
const operation = state.operations.find(
|
|
(item) => item.id === operationId,
|
|
);
|
|
if (!operation) throw new Error("Operation not found.");
|
|
return updateOperation(advanceOperation(operation));
|
|
}
|
|
const active = state.operations
|
|
.filter(
|
|
(item) =>
|
|
!["success", "failed", "cancelled", "rolled-back"].includes(
|
|
item.status,
|
|
),
|
|
)
|
|
.map(advanceOperation);
|
|
if (active.length) emitOperations(active);
|
|
state.operations = state.operations.map(
|
|
(item) => active.find((entry) => entry.id === item.id) || item,
|
|
);
|
|
return clone(active);
|
|
},
|
|
async getOperation(operationId) {
|
|
return clone(
|
|
state.operations.find((item) => item.id === operationId) || null,
|
|
);
|
|
},
|
|
async diagnosticsStatus() {
|
|
return {
|
|
enabled: state.preferences.diagnosticsEnabled !== false,
|
|
level: state.preferences.diagnosticLevel,
|
|
retentionDays: state.preferences.logRetentionDays,
|
|
maxFileMb: state.preferences.maxLogFileMb,
|
|
directory: "<HOME>/AppData/Roaming/ForgeFlow/diagnostics",
|
|
fileCount: 2,
|
|
totalBytes: 18432,
|
|
totalSize: "18.0 KB",
|
|
latestAt: iso(-2000),
|
|
lastWriteError: null,
|
|
};
|
|
},
|
|
async exportConfigurationBackup() {
|
|
return {
|
|
filePath: "C:\\Downloads\\ForgeFlow-Configuration-demo.ffbackup",
|
|
};
|
|
},
|
|
async importConfigurationBackup() {
|
|
return { state: clone(state), exportedAt: iso(-86400000) };
|
|
},
|
|
async listAuditEvents() {
|
|
return [
|
|
{
|
|
id: "audit-1",
|
|
timestamp: iso(-60000),
|
|
event: "deployment.completed",
|
|
details: { repository: "Jens/ForgeFlow", result: "success" },
|
|
},
|
|
];
|
|
},
|
|
async exportAuditLog() {
|
|
return { filePath: "C:\\Downloads\\ForgeFlow-Audit-demo.json", count: 1 };
|
|
},
|
|
async clearDiagnostics() {
|
|
return {
|
|
enabled: true,
|
|
level: state.preferences.diagnosticLevel,
|
|
retentionDays: state.preferences.logRetentionDays,
|
|
maxFileMb: state.preferences.maxLogFileMb,
|
|
directory: "<HOME>/AppData/Roaming/ForgeFlow/diagnostics",
|
|
fileCount: 1,
|
|
totalBytes: 256,
|
|
totalSize: "256 B",
|
|
latestAt: iso(),
|
|
lastWriteError: null,
|
|
};
|
|
},
|
|
async openDiagnosticsFolder() {
|
|
return true;
|
|
},
|
|
async exportDiagnostics(privacyMode = "standard") {
|
|
await wait(500);
|
|
return {
|
|
path: `C:\Users\Jens\Downloads\ForgeFlow-Diagnostics-demo.zip`,
|
|
bytes: 38221,
|
|
size: "37.3 KB",
|
|
sha256: "b".repeat(64),
|
|
privacyMode,
|
|
generatedAt: iso(),
|
|
};
|
|
},
|
|
async showDiagnosticBundle() {
|
|
return true;
|
|
},
|
|
async reportRendererEvent() {
|
|
return true;
|
|
},
|
|
onRepositoriesChanged(listener) {
|
|
repositoryListeners.add(listener);
|
|
return () => repositoryListeners.delete(listener);
|
|
},
|
|
onOperationsChanged(listener) {
|
|
operationListeners.add(listener);
|
|
return () => operationListeners.delete(listener);
|
|
},
|
|
onUpdatesChanged(listener) {
|
|
updateListeners.add(listener);
|
|
return () => updateListeners.delete(listener);
|
|
},
|
|
async reset() {
|
|
state.setupComplete = false;
|
|
storage.set("forgeflow-demo-setup", "false");
|
|
return clone(state);
|
|
},
|
|
});
|
|
})();
|