Project Page Frontend Update
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -31,4 +31,6 @@ bun.lock
|
||||
|
||||
target/
|
||||
dist/
|
||||
data/
|
||||
data/
|
||||
|
||||
Cargo.lock
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
155
frontend/src/lib/project-dashboard.svelte.ts
Normal file
155
frontend/src/lib/project-dashboard.svelte.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user