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/
dist/
data/
data/
Cargo.lock

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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"

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>
<Navbar />
<Toaster position="top-right" richColors />
<div class="min-h-screen bg-background text-foreground flex flex-col">
<div class="flex-1">
{@render children()}
<div class="h-screen flex flex-col overflow-hidden bg-background text-foreground">
<Navbar />
<Toaster position="top-right" richColors />
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
<main class="flex-1 flex flex-col min-h-0 overflow-y-auto">
{@render children()}
</main>
<!-- <Footer /> -->
</div>
<Footer />
</div>

View File

@@ -125,7 +125,7 @@
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">
{new Date(activity.CreatedAt).toLocaleString(undefined, {
{new Date(activity.created_at).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",

View File

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

View File

@@ -3,6 +3,7 @@
import {
listProjects,
listDatabases,
getSystemStatus,
type Project,
type Database,
} from "$lib/api";
@@ -33,15 +34,18 @@
let projects = $state<Project[]>([]);
let databases = $state<Database[]>([]);
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;
});
</script>
@@ -118,34 +122,47 @@
</a>
</div>
</TableCell>
<TableCell class="font-mono text-xs"
>localhost</TableCell
>
<TableCell class="font-mono text-xs">
{systemStatus?.local_ip ?? "—"}
</TableCell>
<TableCell class="font-mono text-xs"
>{project.port}</TableCell
>
<TableCell
class="font-mono text-xs text-muted-foreground"
>
http://localhost:{project.port}
</TableCell>
<TableCell
class="font-mono text-xs text-muted-foreground"
>
{systemStatus?.local_ip
? `http://${systemStatus.local_ip}:${project.port}`
: "—"}
</TableCell>
<TableCell>
<span
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>
{#if project.deployments?.[0]?.status === 'live'}
<span
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>
{: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 class="text-right">
<Button
variant="ghost"
size="sm"
href={`http://localhost:${project.port}`}
target="_blank"
>
<ExternalLink class="h-4 w-4" />
</Button>
{#if project.deployments?.[0]?.status === 'live' && systemStatus?.local_ip}
<Button
variant="ghost"
size="sm"
href={`http://${systemStatus.local_ip}:${project.port}`}
target="_blank"
>
<ExternalLink class="h-4 w-4" />
</Button>
{/if}
</TableCell>
</TableRow>
{/each}
@@ -166,17 +183,19 @@
</a>
</div>
</TableCell>
<TableCell class="font-mono text-xs"
>localhost</TableCell
>
<TableCell class="font-mono text-xs">
{systemStatus?.local_ip ?? "—"}
</TableCell>
<TableCell class="font-mono text-xs"
>{db.port}</TableCell
>
<TableCell
class="font-mono text-xs text-muted-foreground"
>
{db.type}://localhost:{db.port}
</TableCell>
<TableCell
class="font-mono text-xs text-muted-foreground"
>
{systemStatus?.local_ip
? `${db.type}://${systemStatus.local_ip}:${db.port}`
: "—"}
</TableCell>
<TableCell>
<span
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,
Activity,
GitCommit,
PanelLeft,
PanelLeftClose
} from "@lucide/svelte";
let { children } = $props();
let projectId = $derived($page.params.id);
let isSidebarOpen = $state(true);
function isActive(path: string) {
return $page.url.pathname.endsWith(path)
? "bg-secondary"
: "hover:bg-secondary/50";
? "bg-secondary text-foreground"
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground";
}
</script>
<div class="container mx-auto py-6 px-4">
<div class="mb-6 flex items-center gap-4">
<Button variant="ghost" size="icon" href="/">
<ArrowLeft class="h-4 w-4" />
</Button>
<h1 class="text-2xl font-bold tracking-tight">Project Dashboard</h1>
</div>
<div class="h-full flex flex-col overflow-hidden">
<header class="border-b bg-background/95 backdrop-blur shrink-0">
<div class="max-w-[1600px] mx-auto w-full px-4 sm:px-6 flex h-14 items-center gap-4">
<Button variant="ghost" size="icon" href="/" class="-ml-2 h-8 w-8 text-muted-foreground hover:text-foreground">
<ArrowLeft class="h-4 w-4" />
</Button>
<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">
<aside class="space-y-2">
<Button
href={`/projects/${projectId}`}
variant="ghost"
class={`w-full justify-start ${$page.url.pathname === `/projects/${projectId}` ? "bg-secondary" : ""}`}
>
<LayoutDashboard class="mr-2 h-4 w-4" /> Overview
</Button>
<Button
href={`/projects/${projectId}/deployments`}
variant="ghost"
class={`w-full justify-start ${isActive("/deployments")}`}
>
<GitCommit class="mr-2 h-4 w-4" /> Deployments
</Button>
<Button
href={`/projects/${projectId}/settings`}
variant="ghost"
class={`w-full justify-start ${isActive("/settings")}`}
>
<Settings class="mr-2 h-4 w-4" /> Settings
</Button>
</aside>
<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">
{#if isSidebarOpen}
<aside class="w-[220px] shrink-0 flex flex-col gap-1 transition-all duration-300 overflow-y-auto">
<div class="text-xs font-semibold text-muted-foreground mb-2 px-3 uppercase tracking-wider">
Menu
</div>
<Button
href={`/projects/${projectId}`}
variant="ghost"
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"}`}
>
<LayoutDashboard class="mr-2 h-4 w-4" /> Overview
</Button>
<Button
href={`/projects/${projectId}/deployments`}
variant="ghost"
class={`w-full justify-start h-9 px-3 ${isActive("/deployments")}`}
>
<GitCommit class="mr-2 h-4 w-4" /> Deployments
</Button>
<Button
href={`/projects/${projectId}/settings`}
variant="ghost"
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]">
{@render children()}
</main>
<main class="flex-1 min-w-0 flex flex-col h-full overflow-y-auto">
{@render children()}
</main>
</div>
</div>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount, tick } from "svelte";
import { onMount, onDestroy } from "svelte";
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 { Card } from "$lib/components/ui/card";
import {
@@ -9,7 +9,7 @@
ExternalLink,
RefreshCw,
Activity,
Terminal,
Terminal as TerminalIcon,
Settings,
Play,
Check,
@@ -17,408 +17,316 @@
GitCommit,
Square,
} 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 loading = $state(true);
let latestDeployment = $derived(project?.deployments?.[0]);
let status = $derived(latestDeployment?.status || "unknown");
let activeDeploymentLogs = $state("");
let activeDeploymentId = $state<string | null>(null);
let ws = $state<WebSocket | null>(null);
let logContentRef = $state<HTMLDivElement | null>(null);
let projectState = new ProjectState($page.params.id as string);
let term: Terminal | null = null;
let fitAddon: FitAddon | null = null;
let terminalContainer = $state<HTMLDivElement | undefined>(undefined);
let copied = $state(false);
let autoScroll = $state(true);
let userScrolled = $state(false);
onMount(async () => {
loadProject();
onMount(() => {
projectState.init();
return () => {
term?.dispose();
projectState.destroy();
};
});
async function loadProject() {
const id = $page.params.id;
if (id) {
const res = await getProject(id);
if (res) {
project = res;
if (
!activeDeploymentId &&
res.deployments &&
res.deployments.length > 0
) {
selectDeployment(res.deployments[0]);
// Initialize terminal when container is available
$effect(() => {
if (terminalContainer && !term) {
term = new Terminal({
theme: {
background: "#0d1117",
foreground: "#c9d1d9",
cursor: "#c9d1d9",
selectionBackground: "#58a6ff33",
},
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
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() {
loading = true;
await loadProject();
loading = false;
}
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);
function handleCopyLogs() {
if (projectState.copyLogs()) {
copied = true;
setTimeout(() => (copied = false), 2000);
}
}
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>
{#if loading}
{#if projectState.loading}
<div class="flex justify-center items-center h-[50vh]">
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
</div>
{:else if project}
<div class="space-y-4 lg:h-[calc(100vh-140px)] flex flex-col lg:overflow-hidden">
<div class="shrink-0 space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<h2 class="text-xl font-bold tracking-tight">{project.name}</h2>
{:else if projectState.project}
<div class="h-full flex flex-col gap-4 overflow-hidden">
<!-- Header Section -->
<div class="flex items-start justify-between border-b pb-4 shrink-0">
<div class="space-y-1">
<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
href={project.repo_url}
href={projectState.project.repo_url}
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" />
{project.repo_url.split("/").pop()?.replace(".git", "")}
<GitCommit class="h-3.5 w-3.5" />
{projectState.project.repo_url.replace("https://", "").replace("http://", "")}
</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 class="flex gap-2">
<Button
variant="ghost"
size="sm"
class="h-8"
onclick={refresh}
title="Refresh Project"
>
<RefreshCw class="h-3.5 w-3.5" />
</Button>
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onclick={() => projectState.refresh()}
disabled={projectState.loading}
>
<RefreshCw class={`h-3.5 w-3.5 mr-2 ${projectState.loading ? 'animate-spin' : ''}`} /> Refresh
</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
variant="outline"
size="sm"
class="h-8"
onclick={handleRedeploy}
disabled={status === "building"}
onclick={() => projectState.handleStop()}
class="text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20"
>
{#if 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}
<Square class="h-3.5 w-3.5 mr-2 fill-current" /> Stop
</Button>
{#if status === "live" || status === "building"}
<Button
variant="outline"
size="sm"
class="h-8"
onclick={handleStop}
>
<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"
{/if}
<Button
href={projectState.latestDeployment?.url}
target="_blank"
disabled={projectState.status !== "live"}
size="sm"
>
<div
class="absolute inset-0 opacity-5 transition-opacity group-hover:opacity-10 {status ===
'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>
<ExternalLink class="mr-2 h-3.5 w-3.5" /> Visit App
</Button>
</div>
</div>
<div class="flex-1 grid grid-cols-1 lg:grid-cols-4 gap-4 min-h-0">
<Card
class="flex flex-col min-h-0 h-[300px] lg:h-auto bg-transparent shadow-none border-0 lg:col-span-1"
>
<div class="flex items-center justify-between px-1 pb-2">
<h3 class="text-sm font-semibold text-muted-foreground">History</h3>
<!-- Stats Grid -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 shrink-0">
<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'}`}>
<Activity class="h-4 w-4" />
</div>
<div class="flex-1 overflow-y-auto pr-1 space-y-1">
{#if project.deployments?.length}
{#each project.deployments as deployment}
<div class="flex items-center gap-2">
<span class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Status:</span>
<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
class="w-full flex items-center justify-between p-2.5 rounded-md border text-left transition-all text-xs
{activeDeploymentId === deployment.id
? 'bg-primary/5 border-primary/20 shadow-sm'
: 'bg-card hover:bg-muted/50 border-input'}"
onclick={() => selectDeployment(deployment)}
class="w-full text-left p-3 rounded-lg border transition-all hover:border-primary/50 hover:bg-muted/50 relative overflow-hidden group
{projectState.activeDeploymentId === deployment.id ? 'bg-primary/5 border-primary shadow-sm ring-1 ring-primary/20' : 'bg-card border-border'}"
onclick={() => projectState.selectDeployment(deployment)}
>
<div class="flex flex-col gap-0.5 min-w-0">
<div class="flex items-center gap-2">
<span class="font-semibold text-xs">#{deployment.id}</span>
<span
class="font-mono text-[10px] text-muted-foreground bg-muted px-1 rounded flex items-center gap-1"
>
<GitCommit class="h-2 w-2" />
{deployment.commit === "HEAD"
? "HEAD"
: deployment.commit === "MANUAL"
? "Manual"
: deployment.commit === "WEBHOOK"
? "Webhook"
: deployment.commit.substring(0, 7)}
<!-- Status Indicator Strip -->
<div class={`absolute left-0 top-0 bottom-0 w-1 ${
deployment.status === 'live' ? 'bg-green-500' :
deployment.status === 'failed' ? 'bg-red-500' :
deployment.status === 'building' ? 'bg-yellow-500' : 'bg-gray-300'
}`}></div>
<div class="pl-2 space-y-1.5">
<div class="flex items-center justify-between">
<span class="font-mono text-xs font-semibold">#{deployment.id.slice(0, 8)}</span>
<span class="text-[10px] text-muted-foreground">
{new Date(deployment.created_at).toLocaleDateString()}
</span>
</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>
</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>
</button>
{/each}
{:else}
<div
class="p-4 text-center text-xs text-muted-foreground border rounded-md border-dashed"
>
No deployments
<div class="h-full flex flex-col items-center justify-center text-muted-foreground">
<RefreshCw class="h-8 w-8 mb-2 opacity-20" />
<p class="text-sm">No deployments found</p>
</div>
{/if}
</div>
</Card>
</div>
<div
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="flex shrink-0 items-center justify-between px-3 py-2 bg-muted/50 border-b border-border"
>
<!-- Logs Terminal -->
<div class="lg:col-span-9 flex flex-col h-full border rounded-lg overflow-hidden shadow-sm bg-[#0d1117]">
<!-- Terminal Header -->
<div class="px-4 py-2 border-b border-white/10 flex items-center justify-between bg-white/5">
<div class="flex items-center gap-2">
<Terminal class="h-3.5 w-3.5 text-muted-foreground" />
<span class="text-xs font-mono text-muted-foreground">
{#if activeDeploymentId}
build-log-{activeDeploymentId}.log
<TerminalIcon class="h-4 w-4 text-muted-foreground" />
<span class="text-xs font-mono text-gray-400">
{#if projectState.activeDeploymentId}
deployments/{projectState.activeDeploymentId}/build.log
{:else}
waiting-for-selection...
waiting_for_selection...
{/if}
</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 class="flex items-center gap-1">
{#if userScrolled}
<Button
variant="ghost"
size="sm"
class="h-5 text-[10px] px-2 text-yellow-500 hover:text-yellow-400 hover:bg-yellow-500/10 mr-2"
onclick={() => {
autoScroll = true;
userScrolled = false;
scrollToBottom(true);
}}
>
Resume Scroll
</Button>
<div class="flex items-center gap-2">
{#if projectState.ws}
<div class="flex items-center gap-1.5 text-xs text-green-400 animate-pulse font-mono mr-2">
<div class="h-1.5 w-1.5 rounded-full bg-green-500"></div>
Live Stream
</div>
{/if}
<Button
variant="ghost"
size="icon"
class="h-6 w-6 text-muted-foreground hover:text-foreground"
onclick={copyLogs}
title="Copy Logs"
class="h-7 w-7 text-gray-400 hover:text-white hover:bg-white/10"
onclick={handleCopyLogs}
title="Copy to Clipboard"
>
{#if copied}
<Check class="h-3 w-3 text-green-500" />
<Check class="h-3.5 w-3.5 text-green-500" />
{:else}
<Copy class="h-3 w-3" />
<Copy class="h-3.5 w-3.5" />
{/if}
</Button>
</div>
</div>
<!-- Terminal Content -->
<div
bind:this={logContentRef}
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"
class="flex-1 overflow-hidden p-1 bg-[#0d1117]"
>
{#if activeDeploymentLogs}
<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 bind:this={terminalContainer} class="h-full w-full"></div>
</div>
</div>
</div>