Project Page Frontend Update
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -32,3 +32,5 @@ bun.lock
|
|||||||
target/
|
target/
|
||||||
dist/
|
dist/
|
||||||
data/
|
data/
|
||||||
|
|
||||||
|
Cargo.lock
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
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>
|
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
||||||
|
|
||||||
|
<div class="h-screen flex flex-col overflow-hidden bg-background text-foreground">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<Toaster position="top-right" richColors />
|
<Toaster position="top-right" richColors />
|
||||||
<div class="min-h-screen bg-background text-foreground flex flex-col">
|
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||||
<div class="flex-1">
|
<main class="flex-1 flex flex-col min-h-0 overflow-y-auto">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
<!-- <Footer /> -->
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -109,10 +109,11 @@
|
|||||||
</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">
|
||||||
|
{#if deploy.status === 'live'}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -122,6 +123,7 @@
|
|||||||
>
|
>
|
||||||
<ExternalLink class="h-4 w-4" />
|
<ExternalLink class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
{/if}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -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
|
||||||
|
? `http://${systemStatus.local_ip}:${project.port}`
|
||||||
|
: "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
{#if project.deployments?.[0]?.status === 'live'}
|
||||||
<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
|
||||||
bg-green-500/15 text-green-500 border-transparent"
|
bg-green-500/15 text-green-500 border-transparent"
|
||||||
>
|
>
|
||||||
Active
|
Active
|
||||||
</span>
|
</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">
|
||||||
|
{#if project.deployments?.[0]?.status === 'live' && systemStatus?.local_ip}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
href={`http://localhost:${project.port}`}
|
href={`http://${systemStatus.local_ip}:${project.port}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<ExternalLink class="h-4 w-4" />
|
<ExternalLink class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
{/if}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -166,16 +183,18 @@
|
|||||||
</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
|
||||||
|
? `${db.type}://${systemStatus.local_ip}:${db.port}`
|
||||||
|
: "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -8,52 +8,79 @@
|
|||||||
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">
|
||||||
|
<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" />
|
<ArrowLeft class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<h1 class="text-2xl font-bold tracking-tight">Project Dashboard</h1>
|
<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>
|
||||||
|
</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}
|
||||||
|
<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
|
<Button
|
||||||
href={`/projects/${projectId}`}
|
href={`/projects/${projectId}`}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class={`w-full justify-start ${$page.url.pathname === `/projects/${projectId}` ? "bg-secondary" : ""}`}
|
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
|
<LayoutDashboard class="mr-2 h-4 w-4" /> Overview
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
href={`/projects/${projectId}/deployments`}
|
href={`/projects/${projectId}/deployments`}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class={`w-full justify-start ${isActive("/deployments")}`}
|
class={`w-full justify-start h-9 px-3 ${isActive("/deployments")}`}
|
||||||
>
|
>
|
||||||
<GitCommit class="mr-2 h-4 w-4" /> Deployments
|
<GitCommit class="mr-2 h-4 w-4" /> Deployments
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
href={`/projects/${projectId}/settings`}
|
href={`/projects/${projectId}/settings`}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class={`w-full justify-start ${isActive("/settings")}`}
|
class={`w-full justify-start h-9 px-3 ${isActive("/settings")}`}
|
||||||
>
|
>
|
||||||
<Settings class="mr-2 h-4 w-4" /> Settings
|
<Settings class="mr-2 h-4 w-4" /> Settings
|
||||||
</Button>
|
</Button>
|
||||||
</aside>
|
</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>
|
||||||
|
|||||||
@@ -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,
|
||||||
loading = false;
|
});
|
||||||
|
|
||||||
|
fitAddon = new FitAddon();
|
||||||
|
term.loadAddon(fitAddon);
|
||||||
|
term.open(terminalContainer);
|
||||||
|
|
||||||
|
// Load initial logs
|
||||||
|
if (projectState.activeDeploymentLogs) {
|
||||||
|
term.write(projectState.activeDeploymentLogs.replace(/\n/g, "\r\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refresh() {
|
// Hook up the state callbacks to xterm
|
||||||
loading = true;
|
projectState.onLogData = (data) => {
|
||||||
await loadProject();
|
term?.write(data);
|
||||||
loading = false;
|
// Heuristic: when build reports success, refresh to pull latest status.
|
||||||
}
|
if (data.includes("Build successful")) {
|
||||||
|
projectState.handleBuildCompleted();
|
||||||
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 = () => {
|
projectState.onLogClear = () => {
|
||||||
console.log("Log stream closed");
|
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();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function scrollToBottom(force = false) {
|
function handleCopyLogs() {
|
||||||
await tick();
|
if (projectState.copyLogs()) {
|
||||||
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;
|
copied = true;
|
||||||
setTimeout(() => (copied = false), 2000);
|
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 class="flex gap-2">
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-8"
|
onclick={() => projectState.refresh()}
|
||||||
onclick={refresh}
|
disabled={projectState.loading}
|
||||||
title="Refresh Project"
|
|
||||||
>
|
>
|
||||||
<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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-8"
|
onclick={() => projectState.handleRedeploy()}
|
||||||
onclick={handleRedeploy}
|
disabled={projectState.status === "building"}
|
||||||
disabled={status === "building"}
|
|
||||||
>
|
>
|
||||||
{#if status === "building"}
|
{#if projectState.status === "building"}
|
||||||
<Loader2 class="h-3.5 w-3.5 mr-2 animate-spin" /> Redeploying
|
<Loader2 class="h-3.5 w-3.5 mr-2 animate-spin" /> Redeploying
|
||||||
{:else}
|
{:else}
|
||||||
<Play class="h-3.5 w-3.5 mr-2" /> Redeploy
|
<Play class="h-3.5 w-3.5 mr-2" /> Redeploy
|
||||||
{/if}
|
{/if}
|
||||||
</Button>
|
</Button>
|
||||||
{#if status === "live" || status === "building"}
|
{#if projectState.status === "live" || projectState.status === "building"}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-8"
|
onclick={() => projectState.handleStop()}
|
||||||
onclick={handleStop}
|
class="text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-950/20"
|
||||||
>
|
>
|
||||||
<Square class="h-3.5 w-3.5 mr-2" /> Stop
|
<Square class="h-3.5 w-3.5 mr-2 fill-current" /> Stop
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
<Button
|
<Button
|
||||||
href={latestDeployment?.url}
|
href={projectState.latestDeployment?.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
disabled={status !== "live"}
|
disabled={projectState.status !== "live"}
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-8"
|
|
||||||
>
|
>
|
||||||
<ExternalLink class="mr-2 h-3.5 w-3.5" /> Visit
|
<ExternalLink class="mr-2 h-3.5 w-3.5" /> Visit App
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<!-- Stats Grid -->
|
||||||
class="grid grid-cols-2 lg:grid-cols-4 gap-px bg-border/40 rounded-lg overflow-hidden border"
|
<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
|
<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'}`}>
|
||||||
class="bg-card p-3 flex flex-col justify-center items-center relative overflow-hidden group"
|
<Activity class="h-4 w-4" />
|
||||||
>
|
|
||||||
<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>
|
||||||
<div
|
<div class="flex items-center gap-2">
|
||||||
class="font-bold flex items-center gap-2 {status === 'live'
|
<span class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Status:</span>
|
||||||
? 'text-green-500'
|
<div class="font-bold text-sm">
|
||||||
: status === 'failed'
|
{#if projectState.status === 'live'}
|
||||||
? 'text-red-500'
|
<span class="text-green-600 dark:text-green-400">Live</span>
|
||||||
: 'text-yellow-500'}"
|
{:else if projectState.status === 'failed'}
|
||||||
>
|
<span class="text-red-600 dark:text-red-400">Failed</span>
|
||||||
<Activity class="h-3.5 w-3.5" />
|
{:else if projectState.status === 'building'}
|
||||||
{status}
|
<span class="text-yellow-600 dark:text-yellow-400">Building</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-muted-foreground">Unknown</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-card p-3 flex flex-col justify-center items-center">
|
</Card>
|
||||||
<div
|
|
||||||
class="text-[10px] uppercase tracking-wider text-muted-foreground font-semibold mb-0.5"
|
<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">
|
||||||
Runtime
|
<TerminalIcon class="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div class="font-bold flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Terminal class="h-3.5 w-3.5" />
|
<span class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Runtime:</span>
|
||||||
{project.runtime || "nodejs"}
|
<div class="font-bold text-sm capitalize">{projectState.project.runtime || "nodejs"}</div>
|
||||||
</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>
|
||||||
<div class="bg-card p-3 flex flex-col justify-center items-center">
|
<div class="flex items-center gap-2">
|
||||||
<div
|
<span class="text-xs font-medium text-muted-foreground uppercase tracking-wider">Deployments:</span>
|
||||||
class="text-[10px] uppercase tracking-wider text-muted-foreground font-semibold mb-0.5"
|
<div class="font-bold text-sm">{projectState.project.deployments?.length || 0}</div>
|
||||||
>
|
|
||||||
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>
|
||||||
|
</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>
|
||||||
|
<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>
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 grid grid-cols-1 lg:grid-cols-4 gap-4 min-h-0">
|
<!-- Main Content Area (History + Logs) -->
|
||||||
<Card
|
<div class="flex-1 grid grid-cols-1 lg:grid-cols-12 gap-6 min-h-0">
|
||||||
class="flex flex-col min-h-0 h-[300px] lg:h-auto bg-transparent shadow-none border-0 lg:col-span-1"
|
<!-- History Sidebar -->
|
||||||
>
|
<div class="lg:col-span-3 flex flex-col h-full bg-card border rounded-lg overflow-hidden shadow-sm">
|
||||||
<div class="flex items-center justify-between px-1 pb-2">
|
<div class="px-4 py-3 border-b flex items-center justify-between bg-muted/30">
|
||||||
<h3 class="text-sm font-semibold text-muted-foreground">History</h3>
|
<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>
|
||||||
<div class="flex-1 overflow-y-auto pr-1 space-y-1">
|
<div class="flex-1 overflow-y-auto p-2 space-y-2">
|
||||||
{#if project.deployments?.length}
|
{#if projectState.project.deployments?.length}
|
||||||
{#each project.deployments as deployment}
|
{#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"
|
|
||||||
: deployment.commit.substring(0, 7)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-[10px] text-muted-foreground truncate">
|
<div class="flex items-center justify-between">
|
||||||
{new Date(deployment.created_at).toLocaleString(undefined, {
|
<div class="flex items-center gap-1.5">
|
||||||
month: "short",
|
<GitCommit class="h-3 w-3 text-muted-foreground" />
|
||||||
day: "numeric",
|
<span class="text-xs text-muted-foreground font-mono">
|
||||||
hour: "2-digit",
|
{deployment.commit === "HEAD" ? "HEAD" :
|
||||||
minute: "2-digit",
|
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>
|
||||||
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user