From 22fe8b2f549e85d70c1e34899f4e34bd39d42e84 Mon Sep 17 00:00:00 2001 From: SirBlobby Date: Sat, 7 Feb 2026 22:28:39 -0500 Subject: [PATCH] Project Page Frontend Update --- .gitignore | 4 +- backend/cmd/server/main.go | 4 + backend/internal/builder/builder.go | 40 +- frontend/package.json | 2 + frontend/src/lib/project-dashboard.svelte.ts | 155 +++++ frontend/src/routes/+layout.svelte | 14 +- frontend/src/routes/activity/+page.svelte | 2 +- frontend/src/routes/deployments/+page.svelte | 24 +- frontend/src/routes/network/+page.svelte | 81 ++- .../src/routes/projects/[id]/+layout.svelte | 99 +-- .../src/routes/projects/[id]/+page.svelte | 596 ++++++++---------- 11 files changed, 590 insertions(+), 431 deletions(-) create mode 100644 frontend/src/lib/project-dashboard.svelte.ts diff --git a/.gitignore b/.gitignore index d4f9549..84f1a95 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,6 @@ bun.lock target/ dist/ -data/ \ No newline at end of file +data/ + +Cargo.lock \ No newline at end of file diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 57d9b6e..11c320c 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -13,6 +13,10 @@ import ( ) func main() { + if err := builder.EnsureNixpacksInstalled(); err != nil { + log.Fatalf("Failed to ensure nixpacks is installed: %v", err) + } + db.Init(".") pm := ports.NewManager(2000, 60000) buildr := builder.NewBuilder() diff --git a/backend/internal/builder/builder.go b/backend/internal/builder/builder.go index 88b8cc8..ae401ee 100644 --- a/backend/internal/builder/builder.go +++ b/backend/internal/builder/builder.go @@ -1,4 +1,5 @@ package builder + import ( "fmt" "io" @@ -8,7 +9,27 @@ import ( "path/filepath" "strings" ) + type Builder struct{} + +func EnsureNixpacksInstalled() error { + cmd := exec.Command("nixpacks", "--version") + if err := cmd.Run(); err == nil { + return nil // Nixpacks is already installed + } + + fmt.Println("Nixpacks not found. Installing...") + installCmd := exec.Command("bash", "-c", "curl -sSL https://nixpacks.com/install.sh | bash") + installCmd.Stdout = os.Stdout + installCmd.Stderr = os.Stderr + if err := installCmd.Run(); err != nil { + return fmt.Errorf("failed to install nixpacks: %w", err) + } + + fmt.Println("Nixpacks installed successfully.") + return nil +} + func NewBuilder() *Builder { return &Builder{} } @@ -65,6 +86,23 @@ func (b *Builder) Build(repoURL, targetCommit, appName, gitToken, buildCmd, star if runtime == "" { runtime = "nodejs" } + + imageName := strings.ToLower(appName) + + // Dockerfile runtime: bypass nixpacks entirely and build via docker + if runtime == "dockerfile" { + fmt.Fprintf(logWriter, "\n>>> Detected Dockerfile runtime. Building Docker image %s...\n", imageName) + dockerCmd := exec.Command("docker", "build", "-t", imageName, ".") + dockerCmd.Dir = workDir + dockerCmd.Stdout = logWriter + dockerCmd.Stderr = logWriter + if err := dockerCmd.Run(); err != nil { + return "", "", fmt.Errorf("docker build failed: %w", err) + } + fmt.Fprintf(logWriter, "\n>>> Build successful!\n") + return imageName, commitHash, nil + } + var nixPkgs string var defaultInstall, defaultBuild, defaultStart string switch runtime { @@ -117,7 +155,7 @@ cmd = "%s" return "", "", fmt.Errorf("failed to write nixpacks.toml: %w", err) } } - imageName := strings.ToLower(appName) + fmt.Fprintf(logWriter, "\n>>> Starting Nixpacks build for %s...\n", imageName) args := []string{"build", ".", "--name", imageName, "--no-cache"} for k, v := range envVars { diff --git a/frontend/package.json b/frontend/package.json index 2933ece..00cdedb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,6 +32,8 @@ "vite": "^7.3.1" }, "dependencies": { + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "gray-matter": "^4.0.3", "marked": "^17.0.1", "svelte-sonner": "^1.0.7" diff --git a/frontend/src/lib/project-dashboard.svelte.ts b/frontend/src/lib/project-dashboard.svelte.ts new file mode 100644 index 0000000..312c776 --- /dev/null +++ b/frontend/src/lib/project-dashboard.svelte.ts @@ -0,0 +1,155 @@ +import { getProject, redeployProject, stopProject, type Project, type Deployment } from "$lib/api"; +import { toast } from "svelte-sonner"; + +export class ProjectState { + project = $state(null); + loading = $state(true); + activeDeploymentId = $state(null); + + // We keep a string copy of logs for clipboard and initial loading, + // though xterm manages its own buffer. + activeDeploymentLogs = $state(""); + + ws: WebSocket | null = null; + onLogData: ((data: string) => void) | null = null; + onLogClear: (() => void) | null = null; + + latestDeployment = $derived(this.project?.deployments?.[0]); + status = $derived(this.latestDeployment?.status || "unknown"); + + constructor(public projectId: string) {} + + async init() { + await this.loadProject(); + this.startStatusPoll(); + } + + private pollInterval: number | null = null; + + startStatusPoll() { + if (typeof window === "undefined") return; + if (this.pollInterval) window.clearInterval(this.pollInterval); + this.pollInterval = window.setInterval(() => { + if (this.status === "building") { + this.loadProject(); + } + }, 2000); + } + + async loadProject() { + if (!this.projectId) return; + const res = await getProject(this.projectId); + if (res) { + this.project = res; + const active = res.deployments?.find((d) => d.id === this.activeDeploymentId) ?? res.deployments?.[0]; + if (active) { + this.selectDeployment(active, true); + } + } + this.loading = false; + } + + async refresh() { + this.loading = true; + await this.loadProject(); + this.loading = false; + } + + async handleRedeploy() { + if (!this.project) return; + toast.info("Starting redeployment..."); + const success = await redeployProject(this.project.id); + if (success) { + toast.success("Redeployment started!"); + setTimeout(() => this.loadProject(), 1000); + } + } + + async handleStop() { + if (!this.project) return; + toast.info("Stopping project..."); + const success = await stopProject(this.project.id); + if (success) { + setTimeout(() => this.loadProject(), 1000); + } + } + + selectDeployment(deployment: Deployment, force = false) { + if (this.activeDeploymentId === deployment.id && !force) return; + + this.activeDeploymentId = deployment.id; + this.activeDeploymentLogs = deployment.logs || ""; + + // Reset xterm + if (this.onLogClear) this.onLogClear(); + + // Write existing logs + // We format CR LF for xterm + const formattedLogs = (deployment.logs || "").replace(/\n/g, "\r\n"); + if (this.onLogData) this.onLogData(formattedLogs); + + if (deployment.status === "building") { + this.connectWebSocket(deployment.id); + } else { + this.closeWebSocket(); + } + } + + handleBuildCompleted() { + // When a build finishes, refresh project data to update status/UI. + this.loadProject(); + } + + connectWebSocket(deploymentId: string) { + this.closeWebSocket(); + // Ensure we are in browser + if (typeof window === "undefined") return; + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + this.ws = new WebSocket( + `${protocol}//${window.location.hostname}:8080/api/deployments/${deploymentId}/logs/stream`, + ); + + this.ws.onmessage = (event) => { + this.activeDeploymentLogs += event.data; + // Pass raw data to xterm, it handles ANSI codes. + // Ensure newlines are treated as CRLF for terminal + const chunk = event.data.replace(/\n/g, "\r\n"); + if (this.onLogData) { + this.onLogData(chunk); + } + }; + + this.ws.onclose = () => { + console.log("Log stream closed"); + // If we were building, pull latest status once stream ends. + if (this.status === "building") { + this.loadProject(); + } + }; + } + + closeWebSocket() { + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } + + copyLogs() { + if (typeof navigator !== "undefined") { + navigator.clipboard.writeText(this.activeDeploymentLogs); + toast.success("Logs copied to clipboard"); + return true; + } + return false; + } + + destroy() { + this.closeWebSocket(); + if (this.pollInterval) { + window.clearInterval(this.pollInterval); + this.pollInterval = null; + } + } +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 1307576..af1187e 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -10,11 +10,13 @@ - - -
-
- {@render children()} +
+ + +
+
+ {@render children()} +
+
-
diff --git a/frontend/src/routes/activity/+page.svelte b/frontend/src/routes/activity/+page.svelte index 5e2e7f8..4d48d67 100644 --- a/frontend/src/routes/activity/+page.svelte +++ b/frontend/src/routes/activity/+page.svelte @@ -125,7 +125,7 @@ class="col-span-2 md:col-span-3 flex items-center justify-end gap-3 text-right" > - + {#if deploy.status === 'live'} + + {/if} {/each} diff --git a/frontend/src/routes/network/+page.svelte b/frontend/src/routes/network/+page.svelte index 6faf48f..de392ab 100644 --- a/frontend/src/routes/network/+page.svelte +++ b/frontend/src/routes/network/+page.svelte @@ -3,6 +3,7 @@ import { listProjects, listDatabases, + getSystemStatus, type Project, type Database, } from "$lib/api"; @@ -33,15 +34,18 @@ let projects = $state([]); let databases = $state([]); + let systemStatus = $state<{ local_ip: string; public_ip: string } | null>(null); let loading = $state(true); onMount(async () => { - const [projRes, dbRes] = await Promise.all([ + const [projRes, dbRes, sysRes] = await Promise.all([ listProjects(), listDatabases(), + getSystemStatus(), ]); if (projRes) projects = projRes; if (dbRes) databases = dbRes; + if (sysRes) systemStatus = sysRes; loading = false; }); @@ -118,34 +122,47 @@
- localhost + + {systemStatus?.local_ip ?? "—"} + {project.port} - - http://localhost:{project.port} - + + {systemStatus?.local_ip + ? `http://${systemStatus.local_ip}:${project.port}` + : "—"} + - - Active - + {#if project.deployments?.[0]?.status === 'live'} + + Active + + {:else} + + Stopped + + {/if} - + {#if project.deployments?.[0]?.status === 'live' && systemStatus?.local_ip} + + {/if} {/each} @@ -166,17 +183,19 @@
- localhost + + {systemStatus?.local_ip ?? "—"} + {db.port} - - {db.type}://localhost:{db.port} - + + {systemStatus?.local_ip + ? `${db.type}://${systemStatus.local_ip}:${db.port}` + : "—"} + -
-
- -

Project Dashboard

-
+
+
+
+ + +
+ + + +
+
+
-
- +
+ {#if isSidebarOpen} + + {/if} -
- {@render children()} -
+
+ {@render children()} +
diff --git a/frontend/src/routes/projects/[id]/+page.svelte b/frontend/src/routes/projects/[id]/+page.svelte index bf53b14..d76c93f 100644 --- a/frontend/src/routes/projects/[id]/+page.svelte +++ b/frontend/src/routes/projects/[id]/+page.svelte @@ -1,7 +1,7 @@ -{#if loading} +{#if projectState.loading}
-{:else if project} -
-
-
-
-

{project.name}

+{:else if projectState.project} +
+ +
+
+

{projectState.project.name}

+ -
- +
+
+ + + {#if projectState.status === "live" || projectState.status === "building"} - {#if status === "live" || status === "building"} - - {/if} - -
-
- -
-
-
-
- Status -
-
- - {status} -
-
-
-
- Runtime -
-
- - {project.runtime || "nodejs"} -
-
-
-
- Deployments -
-
- - {project.deployments?.length || 0} -
-
-
-
- Config -
-
- - {project.env_vars?.length || 0} vars -
-
+ Visit App +
-
- -
-

History

+ +
+ +
+
-
- {#if project.deployments?.length} - {#each project.deployments as deployment} +
+ Status: +
+ {#if projectState.status === 'live'} + Live + {:else if projectState.status === 'failed'} + Failed + {:else if projectState.status === 'building'} + Building + {:else} + Unknown + {/if} +
+
+ + + +
+ +
+
+ Runtime: +
{projectState.project.runtime || "nodejs"}
+
+
+ + +
+ +
+
+ Deployments: +
{projectState.project.deployments?.length || 0}
+
+
+ + +
+ +
+
+ Env Vars: +
{projectState.project.env_vars?.length || 0}
+
+
+
+ + +
+ +
+
+

Deployments

+ + Total: {projectState.project.deployments?.length || 0} + +
+
+ {#if projectState.project.deployments?.length} + {#each projectState.project.deployments as deployment} {/each} {:else} -
- No deployments +
+ +

No deployments found

{/if}
- +
-
-
+ +
+ +
- - - - {#if activeDeploymentId} - build-log-{activeDeploymentId}.log + + + {#if projectState.activeDeploymentId} + deployments/{projectState.activeDeploymentId}/build.log {:else} - waiting-for-selection... + waiting_for_selection... {/if} - {#if ws} - - - - - {/if}
-
- {#if userScrolled} - +
+ {#if projectState.ws} +
+
+ Live Stream +
{/if} +
+
- {#if activeDeploymentLogs} -
{activeDeploymentLogs}
- {:else} -
-

Select a deployment to view logs

-
- {/if} -
+