Project Page Frontend Update

This commit is contained in:
2026-02-07 22:28:39 -05:00
parent 1e3489bab2
commit 22fe8b2f54
11 changed files with 590 additions and 431 deletions

4
.gitignore vendored
View File

@@ -31,4 +31,6 @@ bun.lock
target/ target/
dist/ dist/
data/ data/
Cargo.lock

View File

@@ -13,6 +13,10 @@ import (
) )
func main() { func main() {
if err := builder.EnsureNixpacksInstalled(); err != nil {
log.Fatalf("Failed to ensure nixpacks is installed: %v", err)
}
db.Init(".") db.Init(".")
pm := ports.NewManager(2000, 60000) pm := ports.NewManager(2000, 60000)
buildr := builder.NewBuilder() buildr := builder.NewBuilder()

View File

@@ -1,4 +1,5 @@
package builder package builder
import ( import (
"fmt" "fmt"
"io" "io"
@@ -8,7 +9,27 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
) )
type Builder struct{} 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 { func NewBuilder() *Builder {
return &Builder{} return &Builder{}
} }
@@ -65,6 +86,23 @@ func (b *Builder) Build(repoURL, targetCommit, appName, gitToken, buildCmd, star
if runtime == "" { if runtime == "" {
runtime = "nodejs" 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 nixPkgs string
var defaultInstall, defaultBuild, defaultStart string var defaultInstall, defaultBuild, defaultStart string
switch runtime { switch runtime {
@@ -117,7 +155,7 @@ cmd = "%s"
return "", "", fmt.Errorf("failed to write nixpacks.toml: %w", err) 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) fmt.Fprintf(logWriter, "\n>>> Starting Nixpacks build for %s...\n", imageName)
args := []string{"build", ".", "--name", imageName, "--no-cache"} args := []string{"build", ".", "--name", imageName, "--no-cache"}
for k, v := range envVars { for k, v := range envVars {

View File

@@ -32,6 +32,8 @@
"vite": "^7.3.1" "vite": "^7.3.1"
}, },
"dependencies": { "dependencies": {
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"marked": "^17.0.1", "marked": "^17.0.1",
"svelte-sonner": "^1.0.7" "svelte-sonner": "^1.0.7"

View File

@@ -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<Project | null>(null);
loading = $state(true);
activeDeploymentId = $state<string | null>(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;
}
}
}

View File

@@ -10,11 +10,13 @@
<svelte:head><link rel="icon" href={favicon} /></svelte:head> <svelte:head><link rel="icon" href={favicon} /></svelte:head>
<Navbar /> <div class="h-screen flex flex-col overflow-hidden bg-background text-foreground">
<Toaster position="top-right" richColors /> <Navbar />
<div class="min-h-screen bg-background text-foreground flex flex-col"> <Toaster position="top-right" richColors />
<div class="flex-1"> <div class="flex-1 flex flex-col min-h-0 overflow-hidden">
{@render children()} <main class="flex-1 flex flex-col min-h-0 overflow-y-auto">
{@render children()}
</main>
<!-- <Footer /> -->
</div> </div>
<Footer />
</div> </div>

View File

@@ -125,7 +125,7 @@
class="col-span-2 md:col-span-3 flex items-center justify-end gap-3 text-right" class="col-span-2 md:col-span-3 flex items-center justify-end gap-3 text-right"
> >
<span class="hidden md:inline text-xs text-muted-foreground"> <span class="hidden md:inline text-xs text-muted-foreground">
{new Date(activity.CreatedAt).toLocaleString(undefined, { {new Date(activity.created_at).toLocaleString(undefined, {
month: "short", month: "short",
day: "numeric", day: "numeric",
hour: "2-digit", hour: "2-digit",

View File

@@ -109,19 +109,21 @@
</span> </span>
</TableCell> </TableCell>
<TableCell class="hidden md:table-cell text-muted-foreground"> <TableCell class="hidden md:table-cell text-muted-foreground">
{new Date(deploy.CreatedAt).toLocaleDateString()} {new Date(deploy.created_at).toLocaleDateString()}
{new Date(deploy.CreatedAt).toLocaleTimeString()} {new Date(deploy.created_at).toLocaleTimeString()}
</TableCell> </TableCell>
<TableCell class="text-right"> <TableCell class="text-right">
<Button {#if deploy.status === 'live'}
variant="ghost" <Button
size="sm" variant="ghost"
href={deploy.url} size="sm"
target="_blank" href={deploy.url}
disabled={!deploy.url} target="_blank"
> disabled={!deploy.url}
<ExternalLink class="h-4 w-4" /> >
</Button> <ExternalLink class="h-4 w-4" />
</Button>
{/if}
</TableCell> </TableCell>
</TableRow> </TableRow>
{/each} {/each}

View File

@@ -3,6 +3,7 @@
import { import {
listProjects, listProjects,
listDatabases, listDatabases,
getSystemStatus,
type Project, type Project,
type Database, type Database,
} from "$lib/api"; } from "$lib/api";
@@ -33,15 +34,18 @@
let projects = $state<Project[]>([]); let projects = $state<Project[]>([]);
let databases = $state<Database[]>([]); let databases = $state<Database[]>([]);
let systemStatus = $state<{ local_ip: string; public_ip: string } | null>(null);
let loading = $state(true); let loading = $state(true);
onMount(async () => { onMount(async () => {
const [projRes, dbRes] = await Promise.all([ const [projRes, dbRes, sysRes] = await Promise.all([
listProjects(), listProjects(),
listDatabases(), listDatabases(),
getSystemStatus(),
]); ]);
if (projRes) projects = projRes; if (projRes) projects = projRes;
if (dbRes) databases = dbRes; if (dbRes) databases = dbRes;
if (sysRes) systemStatus = sysRes;
loading = false; loading = false;
}); });
</script> </script>
@@ -118,34 +122,47 @@
</a> </a>
</div> </div>
</TableCell> </TableCell>
<TableCell class="font-mono text-xs" <TableCell class="font-mono text-xs">
>localhost</TableCell {systemStatus?.local_ip ?? "—"}
> </TableCell>
<TableCell class="font-mono text-xs" <TableCell class="font-mono text-xs"
>{project.port}</TableCell >{project.port}</TableCell
> >
<TableCell <TableCell
class="font-mono text-xs text-muted-foreground" class="font-mono text-xs text-muted-foreground"
> >
http://localhost:{project.port} {systemStatus?.local_ip
</TableCell> ? `http://${systemStatus.local_ip}:${project.port}`
: "—"}
</TableCell>
<TableCell> <TableCell>
<span {#if project.deployments?.[0]?.status === 'live'}
class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold <span
bg-green-500/15 text-green-500 border-transparent" class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold
> bg-green-500/15 text-green-500 border-transparent"
Active >
</span> Active
</span>
{:else}
<span
class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold
bg-red-500/15 text-red-500 border-transparent"
>
Stopped
</span>
{/if}
</TableCell> </TableCell>
<TableCell class="text-right"> <TableCell class="text-right">
<Button {#if project.deployments?.[0]?.status === 'live' && systemStatus?.local_ip}
variant="ghost" <Button
size="sm" variant="ghost"
href={`http://localhost:${project.port}`} size="sm"
target="_blank" href={`http://${systemStatus.local_ip}:${project.port}`}
> target="_blank"
<ExternalLink class="h-4 w-4" /> >
</Button> <ExternalLink class="h-4 w-4" />
</Button>
{/if}
</TableCell> </TableCell>
</TableRow> </TableRow>
{/each} {/each}
@@ -166,17 +183,19 @@
</a> </a>
</div> </div>
</TableCell> </TableCell>
<TableCell class="font-mono text-xs" <TableCell class="font-mono text-xs">
>localhost</TableCell {systemStatus?.local_ip ?? "—"}
> </TableCell>
<TableCell class="font-mono text-xs" <TableCell class="font-mono text-xs"
>{db.port}</TableCell >{db.port}</TableCell
> >
<TableCell <TableCell
class="font-mono text-xs text-muted-foreground" class="font-mono text-xs text-muted-foreground"
> >
{db.type}://localhost:{db.port} {systemStatus?.local_ip
</TableCell> ? `${db.type}://${systemStatus.local_ip}:${db.port}`
: "—"}
</TableCell>
<TableCell> <TableCell>
<span <span
class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold

View File

@@ -8,53 +8,80 @@
Settings, Settings,
Activity, Activity,
GitCommit, GitCommit,
PanelLeft,
PanelLeftClose
} from "@lucide/svelte"; } from "@lucide/svelte";
let { children } = $props(); let { children } = $props();
let projectId = $derived($page.params.id); let projectId = $derived($page.params.id);
let isSidebarOpen = $state(true);
function isActive(path: string) { function isActive(path: string) {
return $page.url.pathname.endsWith(path) return $page.url.pathname.endsWith(path)
? "bg-secondary" ? "bg-secondary text-foreground"
: "hover:bg-secondary/50"; : "text-muted-foreground hover:bg-secondary/50 hover:text-foreground";
} }
</script> </script>
<div class="container mx-auto py-6 px-4"> <div class="h-full flex flex-col overflow-hidden">
<div class="mb-6 flex items-center gap-4"> <header class="border-b bg-background/95 backdrop-blur shrink-0">
<Button variant="ghost" size="icon" href="/"> <div class="max-w-[1600px] mx-auto w-full px-4 sm:px-6 flex h-14 items-center gap-4">
<ArrowLeft class="h-4 w-4" /> <Button variant="ghost" size="icon" href="/" class="-ml-2 h-8 w-8 text-muted-foreground hover:text-foreground">
</Button> <ArrowLeft class="h-4 w-4" />
<h1 class="text-2xl font-bold tracking-tight">Project Dashboard</h1> </Button>
</div> <div class="h-4 w-px bg-border/60 mx-1 hidden sm:block"></div>
<div class="flex items-center gap-2 text-sm font-medium">
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-muted-foreground hover:text-foreground"
onclick={() => isSidebarOpen = !isSidebarOpen}
title={isSidebarOpen ? "Collapse Sidebar" : "Expand Sidebar"}
>
{#if isSidebarOpen}
<PanelLeftClose class="h-4 w-4" />
{:else}
<PanelLeft class="h-4 w-4" />
{/if}
</Button>
<span class="text-muted-foreground hidden sm:inline-block">/</span>
<span class="hidden sm:inline-block">Project Dashboard</span>
</div>
</div>
</header>
<div class="grid grid-cols-1 md:grid-cols-[250px_1fr] gap-8"> <div class="flex-1 flex max-w-[1600px] mx-auto w-full px-4 sm:px-6 py-6 gap-6 min-h-0 overflow-hidden">
<aside class="space-y-2"> {#if isSidebarOpen}
<Button <aside class="w-[220px] shrink-0 flex flex-col gap-1 transition-all duration-300 overflow-y-auto">
href={`/projects/${projectId}`} <div class="text-xs font-semibold text-muted-foreground mb-2 px-3 uppercase tracking-wider">
variant="ghost" Menu
class={`w-full justify-start ${$page.url.pathname === `/projects/${projectId}` ? "bg-secondary" : ""}`} </div>
> <Button
<LayoutDashboard class="mr-2 h-4 w-4" /> Overview href={`/projects/${projectId}`}
</Button> variant="ghost"
<Button class={`w-full justify-start h-9 px-3 ${$page.url.pathname === `/projects/${projectId}` ? "bg-secondary text-foreground font-medium" : "text-muted-foreground hover:text-foreground"}`}
href={`/projects/${projectId}/deployments`} >
variant="ghost" <LayoutDashboard class="mr-2 h-4 w-4" /> Overview
class={`w-full justify-start ${isActive("/deployments")}`} </Button>
> <Button
<GitCommit class="mr-2 h-4 w-4" /> Deployments href={`/projects/${projectId}/deployments`}
</Button> variant="ghost"
<Button class={`w-full justify-start h-9 px-3 ${isActive("/deployments")}`}
href={`/projects/${projectId}/settings`} >
variant="ghost" <GitCommit class="mr-2 h-4 w-4" /> Deployments
class={`w-full justify-start ${isActive("/settings")}`} </Button>
> <Button
<Settings class="mr-2 h-4 w-4" /> Settings href={`/projects/${projectId}/settings`}
</Button> variant="ghost"
</aside> class={`w-full justify-start h-9 px-3 ${isActive("/settings")}`}
>
<Settings class="mr-2 h-4 w-4" /> Settings
</Button>
</aside>
{/if}
<main class="min-h-[500px]"> <main class="flex-1 min-w-0 flex flex-col h-full overflow-y-auto">
{@render children()} {@render children()}
</main> </main>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount, tick } from "svelte"; import { onMount, onDestroy } from "svelte";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { getProject, type Project, redeployProject, stopProject } from "$lib/api"; import { ProjectState } from "$lib/project-dashboard.svelte";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { Card } from "$lib/components/ui/card"; import { Card } from "$lib/components/ui/card";
import { import {
@@ -9,7 +9,7 @@
ExternalLink, ExternalLink,
RefreshCw, RefreshCw,
Activity, Activity,
Terminal, Terminal as TerminalIcon,
Settings, Settings,
Play, Play,
Check, Check,
@@ -17,408 +17,316 @@
GitCommit, GitCommit,
Square, Square,
} from "@lucide/svelte"; } from "@lucide/svelte";
import { toast } from "svelte-sonner"; import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css";
let project = $state<Project | null>(null); let projectState = new ProjectState($page.params.id as string);
let loading = $state(true); let term: Terminal | null = null;
let latestDeployment = $derived(project?.deployments?.[0]); let fitAddon: FitAddon | null = null;
let status = $derived(latestDeployment?.status || "unknown"); let terminalContainer = $state<HTMLDivElement | undefined>(undefined);
let activeDeploymentLogs = $state("");
let activeDeploymentId = $state<string | null>(null);
let ws = $state<WebSocket | null>(null);
let logContentRef = $state<HTMLDivElement | null>(null);
let copied = $state(false); let copied = $state(false);
let autoScroll = $state(true); onMount(() => {
let userScrolled = $state(false); projectState.init();
return () => {
onMount(async () => { term?.dispose();
loadProject(); projectState.destroy();
};
}); });
async function loadProject() { // Initialize terminal when container is available
const id = $page.params.id; $effect(() => {
if (id) { if (terminalContainer && !term) {
const res = await getProject(id); term = new Terminal({
if (res) { theme: {
project = res; background: "#0d1117",
if ( foreground: "#c9d1d9",
!activeDeploymentId && cursor: "#c9d1d9",
res.deployments && selectionBackground: "#58a6ff33",
res.deployments.length > 0 },
) { fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
selectDeployment(res.deployments[0]); fontSize: 12,
lineHeight: 1.4,
convertEol: true,
disableStdin: true,
});
fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(terminalContainer);
// Load initial logs
if (projectState.activeDeploymentLogs) {
term.write(projectState.activeDeploymentLogs.replace(/\n/g, "\r\n"));
}
// Hook up the state callbacks to xterm
projectState.onLogData = (data) => {
term?.write(data);
// Heuristic: when build reports success, refresh to pull latest status.
if (data.includes("Build successful")) {
projectState.handleBuildCompleted();
} }
} };
projectState.onLogClear = () => {
term?.clear();
term?.reset();
};
// Handle resizing
const resizeObserver = new ResizeObserver(() => {
if (fitAddon && term) {
requestAnimationFrame(() => fitAddon?.fit());
}
});
resizeObserver.observe(terminalContainer);
// Initial fit
requestAnimationFrame(() => fitAddon?.fit());
return () => {
resizeObserver.disconnect();
};
} }
loading = false; });
}
async function refresh() { function handleCopyLogs() {
loading = true; if (projectState.copyLogs()) {
await loadProject(); copied = true;
loading = false; setTimeout(() => (copied = false), 2000);
}
async function handleRedeploy() {
if (!project) return;
toast.info("Starting redeployment...");
const success = await redeployProject(project.id.toString());
if (success) {
toast.success("Redeployment started!");
setTimeout(loadProject, 1000);
} }
} }
async function handleStop() {
if (!project) return;
toast.info("Stopping project...");
const success = await stopProject(project.id.toString());
if (success) {
setTimeout(loadProject, 1000);
}
}
function selectDeployment(deployment: any) {
if (activeDeploymentId === deployment.id) return;
activeDeploymentId = deployment.id;
activeDeploymentLogs = deployment.logs || "";
userScrolled = false;
autoScroll = true;
scrollToBottom(true);
if (deployment.status === "building") {
connectWebSocket(deployment.id);
} else {
if (ws) {
ws.close();
ws = null;
}
}
}
function connectWebSocket(deploymentId: string) {
if (ws) ws.close();
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
ws = new WebSocket(
`${protocol}//${window.location.hostname}:8080/api/deployments/${deploymentId}/logs/stream`,
);
ws.onmessage = (event) => {
activeDeploymentLogs += event.data;
if (autoScroll) {
scrollToBottom();
}
};
ws.onclose = () => {
console.log("Log stream closed");
};
}
async function scrollToBottom(force = false) {
await tick();
if (logContentRef) {
logContentRef.scrollTop = logContentRef.scrollHeight;
}
}
function handleScroll() {
if (!logContentRef) return;
const { scrollTop, scrollHeight, clientHeight } = logContentRef;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
if (!isAtBottom) {
userScrolled = true;
autoScroll = false;
} else {
userScrolled = false;
autoScroll = true;
}
}
function copyLogs() {
navigator.clipboard.writeText(activeDeploymentLogs);
copied = true;
setTimeout(() => (copied = false), 2000);
toast.success("Logs copied to clipboard");
}
</script> </script>
{#if loading} {#if projectState.loading}
<div class="flex justify-center items-center h-[50vh]"> <div class="flex justify-center items-center h-[50vh]">
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" /> <Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
</div> </div>
{:else if project} {:else if projectState.project}
<div class="space-y-4 lg:h-[calc(100vh-140px)] flex flex-col lg:overflow-hidden"> <div class="h-full flex flex-col gap-4 overflow-hidden">
<div class="shrink-0 space-y-4"> <!-- Header Section -->
<div class="flex items-center justify-between"> <div class="flex items-start justify-between border-b pb-4 shrink-0">
<div class="flex items-center gap-3"> <div class="space-y-1">
<h2 class="text-xl font-bold tracking-tight">{project.name}</h2> <h1 class="text-3xl font-bold tracking-tight">{projectState.project.name}</h1>
<div class="flex items-center gap-4 text-sm text-muted-foreground">
<a <a
href={project.repo_url} href={projectState.project.repo_url}
target="_blank" target="_blank"
class="text-muted-foreground hover:text-primary transition-colors text-xs flex items-center gap-1 border px-2 py-0.5 rounded-full" class="hover:text-foreground hover:underline transition-colors flex items-center gap-1"
> >
<GitCommit class="h-3 w-3" /> <GitCommit class="h-3.5 w-3.5" />
{project.repo_url.split("/").pop()?.replace(".git", "")} {projectState.project.repo_url.replace("https://", "").replace("http://", "")}
</a> </a>
<span class="text-muted-foreground/30"></span>
<div class="flex items-center gap-1.5">
<div class={`h-2 w-2 rounded-full ${projectState.status === 'live' ? 'bg-green-500' : projectState.status === 'failed' ? 'bg-red-500' : projectState.status === 'building' ? 'bg-yellow-500' : 'bg-gray-400'}`}></div>
<span class="capitalize">{projectState.status}</span>
</div>
</div> </div>
<div class="flex gap-2"> </div>
<Button <div class="flex items-center gap-2">
variant="ghost" <Button
size="sm" variant="outline"
class="h-8" size="sm"
onclick={refresh} onclick={() => projectState.refresh()}
title="Refresh Project" disabled={projectState.loading}
> >
<RefreshCw class="h-3.5 w-3.5" /> <RefreshCw class={`h-3.5 w-3.5 mr-2 ${projectState.loading ? 'animate-spin' : ''}`} /> Refresh
</Button> </Button>
<Button
variant="outline"
size="sm"
onclick={() => projectState.handleRedeploy()}
disabled={projectState.status === "building"}
>
{#if projectState.status === "building"}
<Loader2 class="h-3.5 w-3.5 mr-2 animate-spin" /> Redeploying
{:else}
<Play class="h-3.5 w-3.5 mr-2" /> Redeploy
{/if}
</Button>
{#if projectState.status === "live" || projectState.status === "building"}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
class="h-8" onclick={() => projectState.handleStop()}
onclick={handleRedeploy} class="text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20"
disabled={status === "building"}
> >
{#if status === "building"} <Square class="h-3.5 w-3.5 mr-2 fill-current" /> Stop
<Loader2 class="h-3.5 w-3.5 mr-2 animate-spin" /> Redeploying
{:else}
<Play class="h-3.5 w-3.5 mr-2" /> Redeploy
{/if}
</Button> </Button>
{#if status === "live" || status === "building"} {/if}
<Button <Button
variant="outline" href={projectState.latestDeployment?.url}
size="sm" target="_blank"
class="h-8" disabled={projectState.status !== "live"}
onclick={handleStop} size="sm"
>
<Square class="h-3.5 w-3.5 mr-2" /> Stop
</Button>
{/if}
<Button
href={latestDeployment?.url}
target="_blank"
disabled={status !== "live"}
size="sm"
class="h-8"
>
<ExternalLink class="mr-2 h-3.5 w-3.5" /> Visit
</Button>
</div>
</div>
<div
class="grid grid-cols-2 lg:grid-cols-4 gap-px bg-border/40 rounded-lg overflow-hidden border"
>
<div
class="bg-card p-3 flex flex-col justify-center items-center relative overflow-hidden group"
> >
<div <ExternalLink class="mr-2 h-3.5 w-3.5" /> Visit App
class="absolute inset-0 opacity-5 transition-opacity group-hover:opacity-10 {status === </Button>
'live'
? 'bg-green-500'
: status === 'failed'
? 'bg-red-500'
: 'bg-yellow-500'}"
></div>
<div
class="text-[10px] uppercase tracking-wider text-muted-foreground font-semibold mb-0.5"
>
Status
</div>
<div
class="font-bold flex items-center gap-2 {status === 'live'
? 'text-green-500'
: status === 'failed'
? 'text-red-500'
: 'text-yellow-500'}"
>
<Activity class="h-3.5 w-3.5" />
{status}
</div>
</div>
<div class="bg-card p-3 flex flex-col justify-center items-center">
<div
class="text-[10px] uppercase tracking-wider text-muted-foreground font-semibold mb-0.5"
>
Runtime
</div>
<div class="font-bold flex items-center gap-2">
<Terminal class="h-3.5 w-3.5" />
{project.runtime || "nodejs"}
</div>
</div>
<div class="bg-card p-3 flex flex-col justify-center items-center">
<div
class="text-[10px] uppercase tracking-wider text-muted-foreground font-semibold mb-0.5"
>
Deployments
</div>
<div class="font-bold flex items-center gap-2">
<RefreshCw class="h-3.5 w-3.5" />
{project.deployments?.length || 0}
</div>
</div>
<div class="bg-card p-3 flex flex-col justify-center items-center">
<div
class="text-[10px] uppercase tracking-wider text-muted-foreground font-semibold mb-0.5"
>
Config
</div>
<div class="font-bold flex items-center gap-2">
<Settings class="h-3.5 w-3.5" />
{project.env_vars?.length || 0} vars
</div>
</div>
</div> </div>
</div> </div>
<div class="flex-1 grid grid-cols-1 lg:grid-cols-4 gap-4 min-h-0"> <!-- Stats Grid -->
<Card <div class="grid grid-cols-2 lg:grid-cols-4 gap-3 shrink-0">
class="flex flex-col min-h-0 h-[300px] lg:h-auto bg-transparent shadow-none border-0 lg:col-span-1" <Card class="p-3 flex items-center gap-3 shadow-sm">
> <div class={`p-2 rounded-full ${projectState.status === 'live' ? 'bg-green-100 dark:bg-green-900/30 text-green-600' : 'bg-muted text-muted-foreground'}`}>
<div class="flex items-center justify-between px-1 pb-2"> <Activity class="h-4 w-4" />
<h3 class="text-sm font-semibold text-muted-foreground">History</h3>
</div> </div>
<div class="flex-1 overflow-y-auto pr-1 space-y-1"> <div class="flex items-center gap-2">
{#if project.deployments?.length} <span class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Status:</span>
{#each project.deployments as deployment} <div class="font-bold text-sm">
{#if projectState.status === 'live'}
<span class="text-green-600 dark:text-green-400">Live</span>
{:else if projectState.status === 'failed'}
<span class="text-red-600 dark:text-red-400">Failed</span>
{:else if projectState.status === 'building'}
<span class="text-yellow-600 dark:text-yellow-400">Building</span>
{:else}
<span class="text-muted-foreground">Unknown</span>
{/if}
</div>
</div>
</Card>
<Card class="p-3 flex items-center gap-3 shadow-sm">
<div class="p-2 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-600">
<TerminalIcon class="h-4 w-4" />
</div>
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Runtime:</span>
<div class="font-bold text-sm capitalize">{projectState.project.runtime || "nodejs"}</div>
</div>
</Card>
<Card class="p-3 flex items-center gap-3 shadow-sm">
<div class="p-2 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-600">
<GitCommit class="h-4 w-4" />
</div>
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Deployments:</span>
<div class="font-bold text-sm">{projectState.project.deployments?.length || 0}</div>
</div>
</Card>
<Card class="p-3 flex items-center gap-3 shadow-sm">
<div class="p-2 rounded-full bg-orange-100 dark:bg-orange-900/30 text-orange-600">
<Settings class="h-4 w-4" />
</div>
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Env Vars:</span>
<div class="font-bold text-sm">{projectState.project.env_vars?.length || 0}</div>
</div>
</Card>
</div>
<!-- Main Content Area (History + Logs) -->
<div class="flex-1 grid grid-cols-1 lg:grid-cols-12 gap-6 min-h-0">
<!-- History Sidebar -->
<div class="lg:col-span-3 flex flex-col h-full bg-card border rounded-lg overflow-hidden shadow-sm">
<div class="px-4 py-3 border-b flex items-center justify-between bg-muted/30">
<h3 class="font-semibold text-sm">Deployments</h3>
<span class="text-xs text-muted-foreground font-mono bg-muted px-2 py-0.5 rounded">
Total: {projectState.project.deployments?.length || 0}
</span>
</div>
<div class="flex-1 overflow-y-auto p-2 space-y-2">
{#if projectState.project.deployments?.length}
{#each projectState.project.deployments as deployment}
<button <button
class="w-full flex items-center justify-between p-2.5 rounded-md border text-left transition-all text-xs class="w-full text-left p-3 rounded-lg border transition-all hover:border-primary/50 hover:bg-muted/50 relative overflow-hidden group
{activeDeploymentId === deployment.id {projectState.activeDeploymentId === deployment.id ? 'bg-primary/5 border-primary shadow-sm ring-1 ring-primary/20' : 'bg-card border-border'}"
? 'bg-primary/5 border-primary/20 shadow-sm' onclick={() => projectState.selectDeployment(deployment)}
: 'bg-card hover:bg-muted/50 border-input'}"
onclick={() => selectDeployment(deployment)}
> >
<div class="flex flex-col gap-0.5 min-w-0"> <!-- Status Indicator Strip -->
<div class="flex items-center gap-2"> <div class={`absolute left-0 top-0 bottom-0 w-1 ${
<span class="font-semibold text-xs">#{deployment.id}</span> deployment.status === 'live' ? 'bg-green-500' :
<span deployment.status === 'failed' ? 'bg-red-500' :
class="font-mono text-[10px] text-muted-foreground bg-muted px-1 rounded flex items-center gap-1" deployment.status === 'building' ? 'bg-yellow-500' : 'bg-gray-300'
> }`}></div>
<GitCommit class="h-2 w-2" />
{deployment.commit === "HEAD" <div class="pl-2 space-y-1.5">
? "HEAD" <div class="flex items-center justify-between">
: deployment.commit === "MANUAL" <span class="font-mono text-xs font-semibold">#{deployment.id.slice(0, 8)}</span>
? "Manual" <span class="text-[10px] text-muted-foreground">
: deployment.commit === "WEBHOOK" {new Date(deployment.created_at).toLocaleDateString()}
? "Webhook" </span>
: deployment.commit.substring(0, 7)} </div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5">
<GitCommit class="h-3 w-3 text-muted-foreground" />
<span class="text-xs text-muted-foreground font-mono">
{deployment.commit === "HEAD" ? "HEAD" :
deployment.commit === "MANUAL" ? "Manual" :
deployment.commit === "WEBHOOK" ? "Webhook" :
deployment.commit.substring(0, 7)}
</span>
</div>
<span class={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
deployment.status === 'live' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' :
deployment.status === 'failed' ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' :
deployment.status === 'building' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' :
'bg-gray-100 text-gray-600'
}`}>
{deployment.status}
</span> </span>
</div> </div>
<span class="text-[10px] text-muted-foreground truncate">
{new Date(deployment.created_at).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
<div class="flex items-center gap-2">
{#if deployment.status === "building"}
<Loader2 class="h-3 w-3 animate-spin text-yellow-500" />
{:else}
<div
class="h-2 w-2 rounded-full {deployment.status === 'live'
? 'bg-green-500'
: deployment.status === 'failed'
? 'bg-red-500'
: 'bg-gray-300'}"
></div>
{/if}
</div> </div>
</button> </button>
{/each} {/each}
{:else} {:else}
<div <div class="h-full flex flex-col items-center justify-center text-muted-foreground">
class="p-4 text-center text-xs text-muted-foreground border rounded-md border-dashed" <RefreshCw class="h-8 w-8 mb-2 opacity-20" />
> <p class="text-sm">No deployments found</p>
No deployments
</div> </div>
{/if} {/if}
</div> </div>
</Card> </div>
<div <!-- Logs Terminal -->
class="lg:col-span-3 flex flex-col min-h-0 h-[500px] lg:h-auto rounded-lg border bg-card shadow-sm overflow-hidden border-border/40" <div class="lg:col-span-9 flex flex-col h-full border rounded-lg overflow-hidden shadow-sm bg-[#0d1117]">
> <!-- Terminal Header -->
<div <div class="px-4 py-2 border-b border-white/10 flex items-center justify-between bg-white/5">
class="flex shrink-0 items-center justify-between px-3 py-2 bg-muted/50 border-b border-border"
>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Terminal class="h-3.5 w-3.5 text-muted-foreground" /> <TerminalIcon class="h-4 w-4 text-muted-foreground" />
<span class="text-xs font-mono text-gray-400">
<span class="text-xs font-mono text-muted-foreground"> {#if projectState.activeDeploymentId}
{#if activeDeploymentId} deployments/{projectState.activeDeploymentId}/build.log
build-log-{activeDeploymentId}.log
{:else} {:else}
waiting-for-selection... waiting_for_selection...
{/if} {/if}
</span> </span>
{#if ws}
<span class="flex h-1.5 w-1.5 relative ml-2">
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
></span>
<span
class="relative inline-flex rounded-full h-1.5 w-1.5 bg-green-500"
></span>
</span>
{/if}
</div> </div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-2">
{#if userScrolled} {#if projectState.ws}
<Button <div class="flex items-center gap-1.5 text-xs text-green-400 animate-pulse font-mono mr-2">
variant="ghost" <div class="h-1.5 w-1.5 rounded-full bg-green-500"></div>
size="sm" Live Stream
class="h-5 text-[10px] px-2 text-yellow-500 hover:text-yellow-400 hover:bg-yellow-500/10 mr-2" </div>
onclick={() => {
autoScroll = true;
userScrolled = false;
scrollToBottom(true);
}}
>
Resume Scroll
</Button>
{/if} {/if}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
class="h-6 w-6 text-muted-foreground hover:text-foreground" class="h-7 w-7 text-gray-400 hover:text-white hover:bg-white/10"
onclick={copyLogs} onclick={handleCopyLogs}
title="Copy Logs" title="Copy to Clipboard"
> >
{#if copied} {#if copied}
<Check class="h-3 w-3 text-green-500" /> <Check class="h-3.5 w-3.5 text-green-500" />
{:else} {:else}
<Copy class="h-3 w-3" /> <Copy class="h-3.5 w-3.5" />
{/if} {/if}
</Button> </Button>
</div> </div>
</div> </div>
<!-- Terminal Content -->
<div <div
bind:this={logContentRef} class="flex-1 overflow-hidden p-1 bg-[#0d1117]"
onscroll={handleScroll}
class="flex-1 overflow-auto p-4 font-mono text-[11px] leading-relaxed text-foreground scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent selection:bg-primary/20 bg-card"
> >
{#if activeDeploymentLogs} <div bind:this={terminalContainer} class="h-full w-full"></div>
<pre
class="whitespace-pre-wrap break-all">{activeDeploymentLogs}</pre>
{:else}
<div
class="flex h-full items-center justify-center text-muted-foreground italic text-xs"
>
<p>Select a deployment to view logs</p>
</div>
{/if}
<div class="h-px w-full"></div>
</div> </div>
</div> </div>
</div> </div>