Admin Dashboard, Webdocs, LICENSE, webhook, and ID's update.

This commit is contained in:
2026-02-04 03:05:12 +00:00
parent 890e52af8c
commit 1d0ccca7d1
51 changed files with 1290 additions and 229 deletions

View File

@@ -22,12 +22,23 @@ export interface EnvVar {
value: string;
}
export interface Deployment {
id: string;
project_id: string;
status: string;
commit: string;
logs: string;
url: string;
created_at: string;
updated_at: string;
}
export interface Project {
ID: number;
id: string;
name: string;
repo_url: string;
port: number;
deployments: any[];
deployments: Deployment[];
env_vars: EnvVar[];
webhook_secret: string;
git_token?: string;
@@ -228,3 +239,33 @@ export async function createDatabase(name: string, type: string = "sqlite") {
return null;
}
}
export async function getAdminUsers() {
try {
return await fetchWithAuth("/api/admin/users");
} catch (e: any) {
toast.error(e.message);
return [];
}
}
export async function deleteAdminUser(id: string) {
try {
await fetchWithAuth(`/api/admin/users/${id}`, {
method: "DELETE",
});
return true;
} catch (e: any) {
toast.error(e.message);
return false;
}
}
export async function getAdminStats() {
try {
return await fetchWithAuth("/api/admin/stats");
} catch (e: any) {
console.error(e);
return null;
}
}

View File

@@ -2,10 +2,11 @@ import { writable } from 'svelte/store';
import { browser } from '$app/environment';
export interface User {
ID: number;
id: string;
email: string;
name: string;
avatar: string;
is_admin: boolean;
}
const storedUser = browser ? localStorage.getItem('user') : null;

View File

@@ -42,15 +42,13 @@
class="flex flex-wrap justify-center gap-4 text-sm text-muted-foreground"
>
<a href="/" class="hover:text-foreground transition-colors">Home</a>
<a href="/" class="hover:text-foreground transition-colors">Docs</a>
<a href="/docs" class="hover:text-foreground transition-colors">Docs</a>
<a
href="https://github.com/SirBlobby/Clickploy"
target="_blank"
rel="noreferrer"
class="hover:text-foreground transition-colors">GitHub</a
>
<a href="/" class="hover:text-foreground transition-colors">Support</a>
<a href="/" class="hover:text-foreground transition-colors">Legal</a>
</nav>
</div>
@@ -63,6 +61,11 @@
<span class={isNormal ? "text-blue-500" : "text-red-500"}>
{isNormal ? "All systems normal." : status}
</span>
{#if version}
<span class="text-muted-foreground ml-2">
{version}
</span>
{/if}
</div>
</div>
</footer>

View File

@@ -12,6 +12,8 @@
Rocket,
Network,
Database,
Shield,
Book,
} from "@lucide/svelte";
import { page } from "$app/stores";
import * as Sheet from "$lib/components/ui/sheet";
@@ -35,48 +37,83 @@
{#if $user}
<nav class="hidden md:flex items-center gap-2">
<Button variant="ghost" size="sm" href="/" class={isActive("/")}>
<LayoutDashboard class="mr-2 h-4 w-4" /> Overview
<Button
variant="ghost"
size="sm"
href="/"
class={`group ${isActive("/")}`}
>
<LayoutDashboard class="h-4 w-4" />
<span
class={`max-w-0 overflow-hidden group-hover:max-w-xs transition-all duration-300 ease-in-out whitespace-nowrap opacity-0 group-hover:opacity-100 group-hover:ml-2 ${$page.url.pathname === "/" ? "max-w-xs opacity-100 ml-2" : ""}`}
>
Overview
</span>
</Button>
<Button
variant="ghost"
size="sm"
href="/deployments"
class={isActive("/deployments")}
class={`group ${isActive("/deployments")}`}
>
<Rocket class="mr-2 h-4 w-4" /> Deployments
<Rocket class="h-4 w-4" />
<span
class={`max-w-0 overflow-hidden group-hover:max-w-xs transition-all duration-300 ease-in-out whitespace-nowrap opacity-0 group-hover:opacity-100 group-hover:ml-2 ${$page.url.pathname === "/deployments" ? "max-w-xs opacity-100 ml-2" : ""}`}
>
Deployments
</span>
</Button>
<Button
variant="ghost"
size="sm"
href="/network"
class={isActive("/network")}
class={`group ${isActive("/network")}`}
>
<Network class="mr-2 h-4 w-4" /> Network
<Network class="h-4 w-4" />
<span
class={`max-w-0 overflow-hidden group-hover:max-w-xs transition-all duration-300 ease-in-out whitespace-nowrap opacity-0 group-hover:opacity-100 group-hover:ml-2 ${$page.url.pathname === "/network" ? "max-w-xs opacity-100 ml-2" : ""}`}
>
Network
</span>
</Button>
<Button
variant="ghost"
size="sm"
href="/activity"
class={isActive("/activity")}
class={`group ${isActive("/activity")}`}
>
<Activity class="mr-2 h-4 w-4" /> Activity
<Activity class="h-4 w-4" />
<span
class={`max-w-0 overflow-hidden group-hover:max-w-xs transition-all duration-300 ease-in-out whitespace-nowrap opacity-0 group-hover:opacity-100 group-hover:ml-2 ${$page.url.pathname === "/activity" ? "max-w-xs opacity-100 ml-2" : ""}`}
>
Activity
</span>
</Button>
<Button
variant="ghost"
size="sm"
href="/storage"
class={isActive("/storage")}
class={`group ${isActive("/storage")}`}
>
<Database class="mr-2 h-4 w-4" /> Storage
<Database class="h-4 w-4" />
<span
class={`max-w-0 overflow-hidden group-hover:max-w-xs transition-all duration-300 ease-in-out whitespace-nowrap opacity-0 group-hover:opacity-100 group-hover:ml-2 ${$page.url.pathname === "/storage" ? "max-w-xs opacity-100 ml-2" : ""}`}
>
Storage
</span>
</Button>
<Button
variant="ghost"
size="sm"
href="/settings"
class={isActive("/settings")}
href="/docs"
class={`group ${isActive("/docs")}`}
>
<Settings class="mr-2 h-4 w-4" /> Settings
<Book class="h-4 w-4" />
<span
class={`max-w-0 overflow-hidden group-hover:max-w-xs transition-all duration-300 ease-in-out whitespace-nowrap opacity-0 group-hover:opacity-100 group-hover:ml-2 ${$page.url.pathname.startsWith("/docs") ? "max-w-xs opacity-100 ml-2" : ""}`}
>
Docs
</span>
</Button>
</nav>
{/if}
@@ -84,6 +121,26 @@
{#if $user}
<div class="flex items-center gap-4">
<nav class="hidden md:flex items-center gap-2 mr-2">
<Button
variant="ghost"
size="sm"
href="/settings"
class={isActive("/settings")}
>
<Settings class="h-4 w-4" />
</Button>
{#if $user.is_admin}
<Button
variant="ghost"
size="sm"
href="/admin"
class={isActive("/admin")}
>
<Shield class="h-4 w-4" />
</Button>
{/if}
</nav>
<div class="hidden md:flex items-center gap-2">
<div
class="h-8 w-8 rounded-full bg-linear-to-tr from-primary to-purple-500"
@@ -147,6 +204,13 @@
>
<Database class="h-5 w-5" /> Storage
</a>
<a
href="/docs"
class="flex items-center gap-2 py-2 text-lg font-medium"
onclick={() => (mobileOpen = false)}
>
<Book class="h-5 w-5" /> Docs
</a>
<a
href="/settings"
class="flex items-center gap-2 py-2 text-lg font-medium"
@@ -154,6 +218,15 @@
>
<Settings class="h-5 w-5" /> Settings
</a>
{#if $user.is_admin}
<a
href="/admin"
class="flex items-center gap-2 py-2 text-lg font-medium"
onclick={() => (mobileOpen = false)}
>
<Shield class="h-5 w-5" /> Admin
</a>
{/if}
<div class="border-t my-2"></div>
<div class="flex items-center gap-2 py-2">
<div
@@ -175,6 +248,7 @@
</div>
{:else}
<div class="flex gap-2">
<Button variant="ghost" size="sm" href="/docs">Docs</Button>
<Button variant="ghost" size="sm" href="/login">Login</Button>
<Button size="sm" href="/register">Get Started</Button>
</div>

View File

@@ -7,7 +7,6 @@ export {
Root,
Description,
Title,
//
Root as Alert,
Description as AlertDescription,
Title as AlertTitle,

View File

@@ -8,7 +8,6 @@ import Root, {
export {
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,

View File

@@ -14,7 +14,6 @@ export {
Header,
Title,
Action,
//
Root as Card,
Content as CardContent,
Description as CardDescription,

View File

@@ -6,7 +6,6 @@ export {
Root,
Content,
Trigger,
//
Root as Collapsible,
Content as CollapsibleContent,
Trigger as CollapsibleTrigger,

View File

@@ -20,7 +20,6 @@ export {
Content,
Description,
Close,
//
Root as Dialog,
Title as DialogTitle,
Portal as DialogPortal,

View File

@@ -2,6 +2,5 @@ import Root from "./input.svelte";
export {
Root,
//
Root as Input,
};

View File

@@ -2,6 +2,5 @@ import Root from "./label.svelte";
export {
Root,
//
Root as Label,
};

View File

@@ -2,6 +2,5 @@ import Root from "./progress.svelte";
export {
Root,
//
Root as Progress,
};

View File

@@ -22,7 +22,6 @@ export {
ScrollUpButton,
GroupHeading,
Portal,
//
Root as Select,
Group as SelectGroup,
Label as SelectLabel,

View File

@@ -2,6 +2,5 @@ import Root from "./separator.svelte";
export {
Root,
//
Root as Separator,
};

View File

@@ -20,7 +20,6 @@ export {
Footer,
Title,
Description,
//
Root as Sheet,
Close as SheetClose,
Trigger as SheetTrigger,

View File

@@ -16,7 +16,6 @@ export {
Head,
Header,
Row,
//
Root as Table,
Body as TableBody,
Caption as TableCaption,

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import { page } from '$app/stores';
import { Button } from "$lib/components/ui/button";
import { AlertTriangle, Home, ArrowLeft } from "@lucide/svelte";
</script>
<div class="flex flex-col items-center justify-center min-h-[70vh] text-center px-4 animate-in fade-in zoom-in duration-300">
<div class="bg-destructive/10 p-6 rounded-full mb-6">
<AlertTriangle class="h-12 w-12 text-destructive" />
</div>
<h1 class="text-4xl font-bold tracking-tight mb-2">
{$page.status}
</h1>
<p class="text-xl text-muted-foreground mb-8 max-w-md text-balance">
{$page.error?.message || "Something went wrong."}
</p>
<div class="flex gap-4">
<Button variant="outline" onclick={() => history.back()}>
<ArrowLeft class="mr-2 h-4 w-4" />
Go Back
</Button>
<Button href="/">
<Home class="mr-2 h-4 w-4" />
Home
</Button>
</div>
</div>

View File

@@ -0,0 +1 @@
export const ssr = false;

View File

@@ -22,6 +22,7 @@
Settings,
ChevronsUpDown,
ExternalLink,
Upload,
} from "@lucide/svelte";
import * as Collapsible from "$lib/components/ui/collapsible";
import * as Select from "$lib/components/ui/select";
@@ -59,6 +60,48 @@
envVars = envVars.filter((_, i) => i !== index);
}
async function handleEnvUpload(e: Event) {
const input = e.target as HTMLInputElement;
if (!input.files || input.files.length === 0) return;
const file = input.files[0];
const text = await file.text();
const newVars: { key: string; value: string }[] = [];
const lines = text.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIdx = trimmed.indexOf('=');
if (eqIdx === -1) continue;
const key = trimmed.slice(0, eqIdx).trim();
let value = trimmed.slice(eqIdx + 1).trim();
// Remove surrounding quotes if present
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
if (key) {
newVars.push({ key, value });
}
}
if (newVars.length > 0) {
// If the first item is empty, remove it
if (envVars.length === 1 && !envVars[0].key && !envVars[0].value) {
envVars = newVars;
} else {
envVars = [...envVars, ...newVars];
}
}
input.value = ''; // Reset input
}
async function handleDeploy() {
if (!repo || !name) return;
deploying = true;
@@ -108,21 +151,99 @@
<div class="container mx-auto py-10 px-4">
{#if !$user}
<div
class="flex flex-col items-center justify-center space-y-10 py-20 text-center"
class="flex flex-col items-center justify-center min-h-[60vh] text-center space-y-8 animate-in fade-in zoom-in duration-500"
>
Deploy with <span
class="bg-linear-to-r from-blue-400 to-purple-600 bg-clip-text text-transparent"
>One Click</span
>.
<p class="max-w-[600px] text-muted-foreground text-xl">
The simplified PaaS for your personal projects. Push, build, and scale
without the complexity.
</p>
<div class="flex gap-4">
<Button href="/login" size="lg">Get Started</Button>
<Button href="https://github.com/clickploy" variant="outline" size="lg">
<Github class="mr-2 h-4 w-4" /> GitHub
<div
class="bg-primary/10 p-4 rounded-full mb-4 ring-1 ring-primary/20 shadow-[0_0_30px_-10px_rgba(255,255,255,0.3)]"
>
<Terminal class="w-12 h-12 text-primary" />
</div>
<div class="space-y-4 max-w-2xl">
<h1 class="text-4xl md:text-6xl font-extrabold tracking-tight">
Deploy with Clickploy
</h1>
<p class="text-xl text-muted-foreground leading-relaxed">
Self-hosted PaaS made simple. Push your code, we handle the rest. No
complex configs, just pure deployment power.
</p>
</div>
<div class="flex flex-col sm:flex-row gap-4 w-full sm:w-auto pt-4">
<Button
href="/login"
size="lg"
class="min-w-[160px] text-lg h-12 shadow-lg hover:shadow-primary/25 transition-all"
>
Get Started
</Button>
<Button
href="https://github.com/SirBlobby/Clickploy"
variant="outline"
size="lg"
class="min-w-[160px] text-lg h-12"
>
<Github class="mr-2 h-5 w-5" /> GitHub
</Button>
</div>
<div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 pt-12 w-full max-w-5xl text-left"
>
<Card class="bg-card/50 border-muted">
<CardHeader class="pb-2">
<Activity class="w-8 h-8 text-primary mb-2" />
<CardTitle class="text-lg">Zero Config</CardTitle>
</CardHeader>
<CardContent class="text-sm text-muted-foreground">
Deploy from any Git repo without writing Dockerfiles or YAML.
</CardContent>
</Card>
<Card class="bg-card/50 border-muted">
<CardHeader class="pb-2">
<Terminal class="w-8 h-8 text-primary mb-2" />
<CardTitle class="text-lg">Docker Native</CardTitle>
</CardHeader>
<CardContent class="text-sm text-muted-foreground">
Every app runs in its own isolated container for security.
</CardContent>
</Card>
<Card class="bg-card/50 border-muted">
<CardHeader class="pb-2">
<ExternalLink class="w-8 h-8 text-primary mb-2" />
<CardTitle class="text-lg">Auto Ports</CardTitle>
</CardHeader>
<CardContent class="text-sm text-muted-foreground">
We automatically assign and manage ports for your services.
</CardContent>
</Card>
<Card class="bg-card/50 border-muted">
<CardHeader class="pb-2">
<Settings class="w-8 h-8 text-primary mb-2" />
<CardTitle class="text-lg">Full Control</CardTitle>
</CardHeader>
<CardContent class="text-sm text-muted-foreground">
Self-hosted means you own your data, logs, and infrastructure.
</CardContent>
</Card>
<Card class="bg-card/50 border-muted">
<CardHeader class="pb-2">
<Terminal class="w-8 h-8 text-primary mb-2" />
<CardTitle class="text-lg">CLI Integration</CardTitle>
</CardHeader>
<CardContent class="text-sm text-muted-foreground">
Manage your deployments from the terminal. (Coming Soon)
</CardContent>
</Card>
<Card class="bg-card/50 border-muted">
<CardHeader class="pb-2">
<Github class="w-8 h-8 text-primary mb-2" />
<CardTitle class="text-lg">Git Webhooks</CardTitle>
</CardHeader>
<CardContent class="text-sm text-muted-foreground">
Automatically deploy when you push changes to your repository.
</CardContent>
</Card>
</div>
</div>
{:else}
@@ -165,14 +286,14 @@
<div class="flex items-center gap-2">
<Input
readonly
value={`http://localhost:8080/webhooks/trigger?project_id=${createdProject.ID}`}
value={`http://localhost:8080/webhooks/trigger?project_id=${createdProject.id}`}
/>
<Button
variant="outline"
size="icon"
onclick={() =>
navigator.clipboard.writeText(
`http://localhost:8080/webhooks/trigger?project_id=${createdProject.ID}`,
`http://localhost:8080/webhooks/trigger?project_id=${createdProject.id}`,
)}
>
Copy
@@ -341,6 +462,24 @@
>
<Plus class="mr-2 h-4 w-4" /> Add Variable
</Button>
<div class="relative">
<input
type="file"
accept=".env,text/plain"
class="hidden"
id="env-upload"
onchange={handleEnvUpload}
/>
<Button
variant="secondary"
size="sm"
class="w-full"
onclick={() => document.getElementById('env-upload')?.click()}
>
<Upload class="mr-2 h-4 w-4" /> Upload .env File
</Button>
</div>
</Collapsible.Content>
</Collapsible.Root>
</div>
@@ -391,16 +530,16 @@
<Card
class="group hover:shadow-lg transition-all duration-300 border-muted/60 hover:border-primary/50 cursor-pointer overflow-hidden relative"
>
<a href={`/projects/${project.ID}`} class="block h-full">
<a href={`/projects/${project.id}`} class="block h-full">
<CardHeader class="pb-3">
<div class="flex items-start justify-between">
<div class="space-y-1">
<CardTitle class="text-xl flex items-center gap-2">
{project.name}
</CardTitle>
<CardDescription class="flex items-center gap-1">
<CardDescription class="flex items-center gap-1" >
<Github class="h-3 w-3" />
{new URL(project.repo_url).pathname.slice(1)}
<a href={project.repo_url} target="_blank">{new URL(project.repo_url).pathname.slice(1)}</a>
</CardDescription>
</div>
<span
@@ -464,7 +603,7 @@
<Button
variant="secondary"
class="w-full group-hover:bg-primary group-hover:text-primary-foreground transition-colors"
href={`/projects/${project.ID}`}
href={`/projects/${project.id}`}
>
Manage
</Button>

View File

@@ -60,10 +60,10 @@
<div
class="grid grid-cols-12 gap-4 px-4 py-3 border-b text-xs font-medium text-muted-foreground bg-muted/40 uppercase tracking-wider"
>
<div class="col-span-3">Project</div>
<div class="col-span-4">Commit</div>
<div class="col-span-2">Status</div>
<div class="col-span-3 text-right">Time</div>
<div class="col-span-6 md:col-span-3">Project</div>
<div class="hidden md:block col-span-4">Commit</div>
<div class="col-span-4 md:col-span-2">Status</div>
<div class="col-span-2 md:col-span-3 text-right">Time</div>
</div>
<div class="divide-y divide-border/40">
@@ -71,7 +71,7 @@
<div
class="grid grid-cols-12 gap-4 px-4 py-3 items-center hover:bg-muted/30 transition-colors text-sm group"
>
<div class="col-span-3 flex items-center gap-2 overflow-hidden">
<div class="col-span-6 md:col-span-3 flex items-center gap-2 overflow-hidden">
<div
class="h-8 w-8 rounded bg-primary/10 flex items-center justify-center shrink-0 text-primary uppercase font-bold text-xs ring-1 ring-inset ring-primary/20"
>
@@ -86,7 +86,7 @@
</div>
<div
class="col-span-4 flex items-center gap-2 font-mono text-xs text-muted-foreground"
class="hidden md:flex col-span-4 items-center gap-2 font-mono text-xs text-muted-foreground"
>
<GitCommit class="h-3.5 w-3.5 shrink-0" />
<span
@@ -103,7 +103,7 @@
</span>
</div>
<div class="col-span-2">
<div class="col-span-4 md:col-span-2">
<span
class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium border
{activity.status === 'live'
@@ -122,9 +122,9 @@
</div>
<div
class="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="text-xs text-muted-foreground">
<span class="hidden md:inline text-xs text-muted-foreground">
{new Date(activity.CreatedAt).toLocaleString(undefined, {
month: "short",
day: "numeric",

View File

@@ -0,0 +1,158 @@
<script lang="ts">
import { getAdminUsers, deleteAdminUser, getAdminStats } from "$lib/api";
import { user, type User } from "$lib/auth";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "$lib/components/ui/card";
import { Button } from "$lib/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "$lib/components/ui/table";
import { Loader2, Trash2, Shield, Users, Box, Layers } from "@lucide/svelte";
import { toast } from "svelte-sonner";
let loading = true;
let users: any[] = [];
let stats: any = null;
onMount(async () => {
if (!$user || !$user.is_admin) {
toast.error("Unauthorized");
goto("/");
return;
}
await loadData();
});
async function loadData() {
loading = true;
const [u, s] = await Promise.all([getAdminUsers(), getAdminStats()]);
users = u || [];
stats = s;
loading = false;
}
async function handleDeleteUser(id: string) {
if (!confirm("Are you sure you want to delete this user?")) return;
const success = await deleteAdminUser(id);
if (success) {
toast.success("User deleted");
users = users.filter((u) => u.id !== id);
}
}
</script>
<div class="container mx-auto py-10 px-4">
<div class="flex items-center justify-between mb-8">
<div>
<h2 class="text-3xl font-bold tracking-tight">Admin Dashboard</h2>
<p class="text-muted-foreground">System overview and user management.</p>
</div>
</div>
{#if loading}
<div class="flex justify-center p-20">
<Loader2 class="h-8 w-8 animate-spin" />
</div>
{:else}
<!-- Stats Cards -->
{#if stats}
<div class="grid gap-4 md:grid-cols-3 mb-8">
<Card>
<CardHeader class="flex flex-row items-center justify-between pb-2">
<CardTitle class="text-sm font-medium">Total Users</CardTitle>
<Users class="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{stats.users}</div>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between pb-2">
<CardTitle class="text-sm font-medium">Total Projects</CardTitle>
<Box class="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{stats.projects}</div>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between pb-2">
<CardTitle class="text-sm font-medium">Total Deployments</CardTitle>
<Layers class="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div class="text-2xl font-bold">{stats.deployments}</div>
</CardContent>
</Card>
</div>
{/if}
<!-- Users Table -->
<Card>
<CardHeader>
<CardTitle>Users</CardTitle>
<CardDescription>Manage registered users.</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Projects</TableHead>
<TableHead>Role</TableHead>
<TableHead class="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each users as u}
<TableRow>
<TableCell>{u.id}</TableCell>
<TableCell class="font-medium">{u.name}</TableCell>
<TableCell>{u.email}</TableCell>
<TableCell>{u.projects?.length || 0}</TableCell>
<TableCell>
{#if u.is_admin}
<span
class="inline-flex items-center rounded-full border border-transparent bg-primary/10 px-2.5 py-0.5 text-xs font-semibold text-primary"
>
<Shield class="mr-1 h-3 w-3" /> Admin
</span>
{:else}
<span class="text-muted-foreground">User</span>
{/if}
</TableCell>
<TableCell class="text-right">
{#if !u.is_admin}
<Button
variant="ghost"
size="icon"
class="text-destructive hover:text-destructive/90"
onclick={() => handleDeleteUser(u.id)}
>
<Trash2 class="h-4 w-4" />
</Button>
{/if}
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</CardContent>
</Card>
{/if}
</div>

View File

@@ -67,9 +67,9 @@
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Commit</TableHead>
<TableHead class="hidden md:table-cell">Commit</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead class="hidden md:table-cell">Created</TableHead>
<TableHead class="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
@@ -79,7 +79,7 @@
<TableCell class="font-medium">
{#if deploy.project}
<a
href={`/projects/${deploy.project.ID}`}
href={`/projects/${deploy.project.id}`}
class="hover:underline flex items-center gap-2"
>
{deploy.project.name}
@@ -88,7 +88,7 @@
<span class="text-muted-foreground">Deleted Project</span>
{/if}
</TableCell>
<TableCell>
<TableCell class="hidden md:table-cell">
<div class="flex items-center gap-2">
<GitCommit class="h-4 w-4 text-muted-foreground" />
<span class="font-mono text-sm"
@@ -108,7 +108,7 @@
{deploy.status}
</span>
</TableCell>
<TableCell class="text-muted-foreground">
<TableCell class="hidden md:table-cell text-muted-foreground">
{new Date(deploy.CreatedAt).toLocaleDateString()}
{new Date(deploy.CreatedAt).toLocaleTimeString()}
</TableCell>

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import { Book } from "@lucide/svelte";
import { page } from "$app/stores";
let { children } = $props();
function isActive(path: string) {
return $page.url.pathname === path ? "bg-secondary" : "hover:bg-accent/50";
}
const navItems = [
{ href: "/docs", label: "Introduction" },
{ href: "/docs/features", label: "Features" },
{ href: "/docs/architecture", label: "Architecture" },
{ href: "/docs/api", label: "API Reference" },
];
</script>
<div class="container mx-auto py-10 px-4">
<div class="flex flex-col md:flex-row gap-10">
<!-- Sidebar Navigation -->
<aside class="hidden md:block w-64 shrink-0 space-y-8">
<div class="sticky top-20">
<div class="flex items-center gap-2 mb-6">
<Book class="h-6 w-6 text-primary" />
<h2 class="text-xl font-bold tracking-tight">Documentation</h2>
</div>
<nav class="space-y-1">
{#each navItems as item}
<Button
variant="ghost"
class="w-full justify-start {isActive(item.href)}"
href={item.href}
>
{item.label}
</Button>
{/each}
</nav>
</div>
</aside>
<!-- Main Content -->
<main class="flex-1 space-y-12 max-w-4xl min-h-[50vh]">
{@render children()}
</main>
</div>
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { Separator } from "$lib/components/ui/separator";
</script>
<div class="space-y-6">
<div class="space-y-2">
<h1 class="text-4xl font-extrabold tracking-tight lg:text-5xl">
Introduction
</h1>
<p class="text-xl text-muted-foreground">
A minimal, self-hosted Platform as a Service (PaaS) for building and
deploying applications quickly.
</p>
</div>
<Separator />
<div class="prose prose-zinc dark:prose-invert max-w-none">
<p>
Clickploy is designed to be a simple, powerful alternative to complex
container orchestration platforms. It leverages Docker and Nixpacks to turn
your Git repositories into running applications with zero configuration.
</p>
</div>
</div>

View File

@@ -0,0 +1,132 @@
<script lang="ts">
import { Separator } from "$lib/components/ui/separator";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "$lib/components/ui/card";
import { Badge } from "$lib/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "$lib/components/ui/table";
</script>
<div class="space-y-6">
<div class="space-y-2">
<h1 class="text-4xl font-extrabold tracking-tight lg:text-5xl">
API Reference
</h1>
<p class="text-xl text-muted-foreground">
The backend exposes a RESTful API for automation and integration. All
authenticated endpoints require a <code>Bearer</code> token.
</p>
</div>
<Separator />
<div class="space-y-8">
<!-- Project Endpoints -->
<div class="space-y-4">
<h3 class="text-xl font-semibold">Projects</h3>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-[100px]">Method</TableHead>
<TableHead>Endpoint</TableHead>
<TableHead>Description</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell><Badge>GET</Badge></TableCell>
<TableCell class="font-mono">/api/projects</TableCell>
<TableCell>List all projects for the current user.</TableCell>
</TableRow>
<TableRow>
<TableCell><Badge>POST</Badge></TableCell>
<TableCell class="font-mono">/api/projects</TableCell>
<TableCell>Create and deploy a new project.</TableCell>
</TableRow>
<TableRow>
<TableCell><Badge variant="secondary">GET</Badge></TableCell>
<TableCell class="font-mono">/api/projects/:id</TableCell>
<TableCell>Get details of a specific project.</TableCell>
</TableRow>
<TableRow>
<TableCell><Badge>POST</Badge></TableCell>
<TableCell class="font-mono">/api/projects/:id/redeploy</TableCell>
<TableCell>Trigger a manual redeployment.</TableCell>
</TableRow>
<TableRow>
<TableCell><Badge variant="outline">PUT</Badge></TableCell>
<TableCell class="font-mono">/api/projects/:id/env</TableCell>
<TableCell>Update environment variables.</TableCell>
</TableRow>
</TableBody>
</Table>
</Card>
</div>
<!-- Activity & Stats -->
<div class="space-y-4">
<h3 class="text-xl font-semibold">Activity & Data</h3>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-[100px]">Method</TableHead>
<TableHead>Endpoint</TableHead>
<TableHead>Description</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell><Badge variant="secondary">GET</Badge></TableCell>
<TableCell class="font-mono">/api/activity</TableCell>
<TableCell>Get recent deployment activity.</TableCell>
</TableRow>
</TableBody>
</Table>
</Card>
</div>
<!-- System Endpoints -->
<div class="space-y-4">
<h3 class="text-xl font-semibold">System & Admin</h3>
<Card>
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-[100px]">Method</TableHead>
<TableHead>Endpoint</TableHead>
<TableHead>Description</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell><Badge variant="secondary">GET</Badge></TableCell>
<TableCell class="font-mono">/api/system/status</TableCell>
<TableCell>Check backend health and version.</TableCell>
</TableRow>
<TableRow>
<TableCell><Badge variant="secondary">GET</Badge></TableCell>
<TableCell class="font-mono">/api/admin/stats</TableCell>
<TableCell>Get global system statistics (Admin only).</TableCell>
</TableRow>
<TableRow>
<TableCell><Badge variant="secondary">GET</Badge></TableCell>
<TableCell class="font-mono">/api/admin/users</TableCell>
<TableCell>List all users (Admin only).</TableCell>
</TableRow>
</TableBody>
</Table>
</Card>
</div>
</div>
</div>

View File

@@ -0,0 +1,44 @@
<script lang="ts">
import { Separator } from "$lib/components/ui/separator";
import { Card, CardContent } from "$lib/components/ui/card";
</script>
<div class="space-y-6">
<div class="space-y-2">
<h1 class="text-4xl font-extrabold tracking-tight lg:text-5xl">
Architecture
</h1>
<p class="text-xl text-muted-foreground">
How Clickploy components interact.
</p>
</div>
<Separator />
<Card>
<CardContent class="pt-6 space-y-4">
<p>
Clickploy operates as a control plane for your deployments. Here's how the
components interact:
</p>
<ul class="list-disc pl-6 space-y-2 text-muted-foreground">
<li>
<strong class="text-foreground">Backend (Go):</strong> Handles API
requests, manages the SQLite database, and orchestrates Docker
containers.
</li>
<li>
<strong class="text-foreground">Frontend (SvelteKit):</strong> Provides a
reactive web interface for managing projects and viewing logs.
</li>
<li>
<strong class="text-foreground">Builder (Nixpacks):</strong> Analyzes
source code and generates OCI-compliant images.
</li>
<li>
<strong class="text-foreground">Reverse Proxy:</strong> (Optional)
Typically runs behind Nginx or Caddy for SSL termination.
</li>
</ul>
</CardContent>
</Card>
</div>

View File

@@ -0,0 +1,158 @@
<script lang="ts">
import { Separator } from "$lib/components/ui/separator";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "$lib/components/ui/card";
import { Badge } from "$lib/components/ui/badge";
import {
Globe,
Server,
Terminal,
Database,
Settings,
Shield,
HardDrive,
} from "@lucide/svelte";
</script>
<div class="space-y-8">
<div class="space-y-2">
<h1 class="text-4xl font-extrabold tracking-tight lg:text-5xl">
Features
</h1>
<p class="text-xl text-muted-foreground">
Explore the core capabilities of Clickploy and learn how to use them.
</p>
</div>
<Separator />
<!-- Deployment & Build -->
<section class="space-y-4">
<h2 class="text-2xl font-bold tracking-tight">Deployment & Build</h2>
<div class="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Globe class="h-5 w-5 text-primary" />
Auto-Build System
</CardTitle>
<CardDescription>Zero-config builds using Nixpacks</CardDescription>
</CardHeader>
<CardContent class="space-y-2">
<p>
Clickploy automatically detects your application's language and framework (Node.js, Python, Go, Rust, etc.) and builds a container image without needing a Dockerfile.
</p>
<div class="bg-muted p-3 rounded-md text-sm">
<strong>How to use:</strong> Simply paste your Git repository URL when creating a new project. Clickploy handles the rest.
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Server class="h-5 w-5 text-primary" />
Isolated Containers
</CardTitle>
<CardDescription>Docker-based runtime</CardDescription>
</CardHeader>
<CardContent class="space-y-2">
<p>
Every application runs in its own isolated Docker container. This ensures consistent performance, security, and prevents dependency conflicts between projects.
</p>
<div class="bg-muted p-3 rounded-md text-sm">
<strong>How it works:</strong> The system assigns a unique port and manages the container lifecycle (start, stop, restart) automatically.
</div>
</CardContent>
</Card>
</div>
</section>
<!-- Management & Ops -->
<section class="space-y-4">
<h2 class="text-2xl font-bold tracking-tight">Management & Operations</h2>
<div class="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Terminal class="h-5 w-5 text-primary" />
Real-time Observability
</CardTitle>
<CardDescription>Live logs and deployment history</CardDescription>
</CardHeader>
<CardContent class="space-y-2">
<p>
Watch your build process and application logs stream in real-time via WebSockets. Access historical logs for any past deployment to debug issues.
</p>
<div class="bg-muted p-3 rounded-md text-sm">
<strong>How to use:</strong> Open a project dashboard. The terminal window shows live logs. Click "History" to view past deployments.
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Settings class="h-5 w-5 text-primary" />
Environment Configuration
</CardTitle>
<CardDescription>Secrets and Variables</CardDescription>
</CardHeader>
<CardContent class="space-y-2">
<p>
Manage environment variables (API keys, database URLs) securely. Variables are injected into the container at runtime.
</p>
<div class="bg-muted p-3 rounded-md text-sm">
<strong>How to use:</strong> Go to the <strong>Settings</strong> tab in your project dashboard to add or update variables.
</div>
</CardContent>
</Card>
</div>
</section>
<!-- Storage & Admin -->
<section class="space-y-4">
<h2 class="text-2xl font-bold tracking-tight">Storage & Administration</h2>
<div class="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<HardDrive class="h-5 w-5 text-primary" />
Managed Storage
</CardTitle>
<CardDescription>Integrated Databases</CardDescription>
</CardHeader>
<CardContent class="space-y-2">
<p>
Provision lightweight SQLite databases instantly for your applications. Perfect for prototypes and small-to-medium apps.
</p>
<div class="bg-muted p-3 rounded-md text-sm">
<strong>How to use:</strong> Navigate to the <strong>Storage</strong> page to create and manage databases.
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Shield class="h-5 w-5 text-primary" />
Admin Control
</CardTitle>
<CardDescription>User Management & Stats</CardDescription>
</CardHeader>
<CardContent class="space-y-2">
<p>
Administrators have full visibility into system performance, user registration, and global deployment statistics.
</p>
<div class="bg-muted p-3 rounded-md text-sm">
<strong>How to use:</strong> Admin users can access the <strong>Admin</strong> dashboard from the sidebar to manage users and view system health.
</div>
</CardContent>
</Card>
</div>
</section>
</div>

View File

@@ -88,7 +88,7 @@
<div class="flex items-center gap-2">
<Server class="h-4 w-4 text-muted-foreground" />
<a
href={`/projects/${project.ID}`}
href={`/projects/${project.id}`}
class="hover:underline"
>
{project.name}

View File

@@ -24,7 +24,7 @@
let status = $derived(latestDeployment?.status || "unknown");
let activeDeploymentLogs = $state("");
let activeDeploymentId = $state<number | null>(null);
let activeDeploymentId = $state<string | null>(null);
let ws = $state<WebSocket | null>(null);
let logContentRef = $state<HTMLDivElement | null>(null);
let copied = $state(false);
@@ -63,7 +63,7 @@
async function handleRedeploy() {
if (!project) return;
toast.info("Starting redeployment...");
const success = await redeployProject(project.ID.toString());
const success = await redeployProject(project.id.toString());
if (success) {
toast.success("Redeployment started!");
setTimeout(loadProject, 1000);
@@ -71,16 +71,16 @@
}
function selectDeployment(deployment: any) {
if (activeDeploymentId === deployment.ID) return;
if (activeDeploymentId === deployment.id) return;
activeDeploymentId = deployment.ID;
activeDeploymentId = deployment.id;
activeDeploymentLogs = deployment.logs || "";
userScrolled = false;
autoScroll = true;
scrollToBottom(true);
if (deployment.status === "building") {
connectWebSocket(deployment.ID);
connectWebSocket(deployment.id);
} else {
if (ws) {
ws.close();
@@ -89,7 +89,7 @@
}
}
function connectWebSocket(deploymentId: number) {
function connectWebSocket(deploymentId: string) {
if (ws) ws.close();
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
ws = new WebSocket(
@@ -143,7 +143,7 @@
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
</div>
{:else if project}
<div class="space-y-4 h-[calc(100vh-140px)] flex flex-col overflow-hidden">
<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">
@@ -193,7 +193,7 @@
</div>
<div
class="grid grid-cols-4 gap-px bg-border/40 rounded-lg overflow-hidden border"
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"
@@ -260,7 +260,7 @@
<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 bg-transparent shadow-none border-0 lg:col-span-1"
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>
@@ -270,25 +270,29 @@
{#each 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
{activeDeploymentId === deployment.id
? 'bg-primary/5 border-primary/20 shadow-sm'
: 'bg-card hover:bg-muted/50 border-input'}"
onclick={() => selectDeployment(deployment)}
>
<div class="flex flex-col gap-0.5 min-w-0">
<div class="flex items-center gap-2">
<span class="font-semibold">#{deployment.ID}</span>
<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
? deployment.commit.substring(0, 7)
: "HEAD"}
{deployment.commit === "HEAD"
? "HEAD"
: deployment.commit === "MANUAL"
? "Manual"
: deployment.commit === "WEBHOOK"
? "Webhook"
: deployment.commit.substring(0, 7)}
</span>
</div>
<span class="text-[10px] text-muted-foreground truncate">
{new Date(deployment.CreatedAt).toLocaleString(undefined, {
{new Date(deployment.created_at).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
@@ -322,14 +326,15 @@
</Card>
<div
class="lg:col-span-3 flex flex-col min-h-0 rounded-lg border bg-zinc-950 shadow-sm overflow-hidden border-border/40"
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-zinc-900/50 border-b border-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">
<Terminal class="h-3.5 w-3.5 text-zinc-400" />
<span class="text-xs font-mono text-zinc-300">
<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
{:else}
@@ -365,7 +370,7 @@
<Button
variant="ghost"
size="icon"
class="h-6 w-6 text-zinc-400 hover:text-white"
class="h-6 w-6 text-muted-foreground hover:text-foreground"
onclick={copyLogs}
title="Copy Logs"
>
@@ -381,14 +386,14 @@
<div
bind:this={logContentRef}
onscroll={handleScroll}
class="flex-1 overflow-auto p-3 font-mono text-[11px] leading-relaxed text-gray-200 scrollbar-thin scrollbar-thumb-zinc-800 scrollbar-track-transparent selection:bg-white/20"
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}
<pre
class="whitespace-pre-wrap break-all">{activeDeploymentLogs}</pre>
{:else}
<div
class="flex h-full items-center justify-center text-zinc-600 italic text-xs"
class="flex h-full items-center justify-center text-muted-foreground italic text-xs"
>
<p>Select a deployment to view logs</p>
</div>

View File

@@ -40,7 +40,7 @@
(d) =>
d.commit.toLowerCase().includes(searchTerm.toLowerCase()) ||
d.status.toLowerCase().includes(searchTerm.toLowerCase()) ||
d.ID.toString().includes(searchTerm),
d.id.toString().includes(searchTerm),
) || [],
);
@@ -90,26 +90,26 @@
<CardContent class="p-0">
{#if project.deployments?.length}
<div
class="grid grid-cols-12 gap-4 px-4 py-3 border-b text-xs font-medium text-muted-foreground bg-muted/40 uppercase tracking-wider"
class="grid grid-cols-12 gap-4 px-4 py-3 border-b text-xs font-medium text-muted-foreground bg-muted/40 uppercase tracking-wider text-center"
>
<div class="col-span-1">ID</div>
<div class="col-span-2">Status</div>
<div class="col-span-5">Commit</div>
<div class="col-span-3">Date</div>
<div class="col-span-1 text-right">Actions</div>
<div class="col-span-4 md:col-span-1">ID</div>
<div class="col-span-4 md:col-span-2">Status</div>
<div class="hidden md:block col-span-5">Commit</div>
<div class="hidden md:block col-span-3">Date</div>
<div class="col-span-4 md:col-span-1">Actions</div>
</div>
<div class="divide-y divide-border/40">
{#each filteredDeployments as deployment}
{@const StatusIcon = getStatusIcon(deployment.status)}
<div
class="grid grid-cols-12 gap-4 px-4 py-2.5 items-center hover:bg-muted/30 transition-colors text-sm group"
class="grid grid-cols-12 gap-4 px-4 py-2.5 items-center hover:bg-muted/30 transition-colors text-sm group text-center"
>
<div class="col-span-1 font-mono text-xs text-muted-foreground">
#{deployment.ID}
<div class="col-span-4 md:col-span-1 font-mono text-xs text-muted-foreground">
#{deployment.id}
</div>
<div class="col-span-2 flex items-center gap-2">
<div class="col-span-4 md:col-span-2 flex items-center justify-center gap-2">
<StatusIcon
class="h-4 w-4 {getStatusColor(
deployment.status,
@@ -125,15 +125,19 @@
</div>
<div
class="col-span-5 flex items-center gap-2 font-mono text-xs"
class="hidden md:flex col-span-5 items-center justify-center gap-2 font-mono text-xs"
>
<GitCommit class="h-3.5 w-3.5 text-muted-foreground" />
<span
class="bg-muted px-1.5 py-0.5 rounded border border-border/50 text-foreground/80"
class="bg-muted px-2 py-0.5 rounded-md border border-border/50 text-foreground/80 font-mono"
>
{deployment.commit
? deployment.commit.substring(0, 7)
: "HEAD"}
{deployment.commit === "HEAD"
? "HEAD"
: deployment.commit === "MANUAL"
? "MANUAL"
: deployment.commit === "WEBHOOK"
? "WEBHOOK"
: deployment.commit.substring(0, 7)}
</span>
<span
class="text-muted-foreground truncate hidden md:inline-block max-w-[200px]"
@@ -142,16 +146,16 @@
? "Manual Redeploy"
: deployment.commit === "WEBHOOK"
? "Webhook Trigger"
: "Git Push"}
: "Git Push"}
</span>
</div>
<div class="col-span-3 text-xs text-muted-foreground">
{new Date(deployment.CreatedAt).toLocaleString()}
<div class="hidden md:block col-span-3 text-xs text-muted-foreground">
{new Date(deployment.created_at).toLocaleString()}
</div>
<div
class="col-span-1 flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
class="col-span-4 md:col-span-1 flex items-center justify-center gap-1 opacity-100 md:opacity-0 group-hover:opacity-100 transition-opacity"
>
{#if deployment.status === "live"}
<Button
@@ -169,7 +173,7 @@
variant="ghost"
size="icon"
class="h-7 w-7"
href={`/projects/${project.ID}?deployment=${deployment.ID}`}
href={`/projects/${project.id}?deployment=${deployment.id}`}
title="View Logs"
>
<Terminal class="h-3.5 w-3.5" />

View File

@@ -21,6 +21,7 @@
EyeOff,
Plus,
Copy,
Upload,
} from "@lucide/svelte";
import { toast } from "svelte-sonner";
@@ -90,12 +91,12 @@
}
loading = true;
const success = await updateProjectEnv(project.ID.toString(), envMap);
const success = await updateProjectEnv(project.id.toString(), envMap);
loading = false;
if (success) {
toast.success("Environment variables updated successfully");
const res = await getProject(project.ID.toString());
const res = await getProject(project.id.toString());
if (res) {
project = res;
initEnvVars();
@@ -107,10 +108,46 @@
function copyWebhook() {
if (!project) return;
const displayUrl = `http://localhost:8080/webhooks/trigger?project_id=${project.ID}`;
const displayUrl = `http://localhost:8080/projects/${project.id}/webhook/${project.webhook_secret}`;
navigator.clipboard.writeText(displayUrl);
toast.success("Webhook URL copied");
}
async function handleFileUpload(e: Event) {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
const text = await file.text();
const lines = text.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const [key, ...parts] = trimmed.split("=");
if (key) {
const value = parts.join("=");
const existingIndex = tempEnvVars.findIndex(
(e) => e.key === key.trim(),
);
if (existingIndex >= 0) {
tempEnvVars[existingIndex].value = value.replace(
/^["'](.*)["']$/,
"$1",
); // remove quotes
} else {
tempEnvVars = [
...tempEnvVars,
{ key: key.trim(), value: value.replace(/^["'](.*)["']$/, "$1") },
];
}
}
}
isDirty = true;
target.value = "";
toast.success("Parsed .env file successfully");
}
</script>
{#if loading && !project}
@@ -166,14 +203,32 @@
</div>
{/each}
<Button
variant="outline"
class="w-full h-11 border-dashed border-border/60 hover:bg-muted/50"
onclick={addEnvVar}
>
<Plus class="h-4 w-4 mr-2" />
Add Variable
</Button>
<div class="flex gap-2">
<Button
variant="outline"
class="flex-1 h-11 border-dashed border-border/60 hover:bg-muted/50"
onclick={addEnvVar}
>
<Plus class="h-4 w-4 mr-2" />
Add Variable
</Button>
<div class="relative">
<input
type="file"
accept=".env"
class="hidden"
id="env-upload"
onchange={handleFileUpload}
/>
<Label
for="env-upload"
class="flex items-center justify-center px-4 h-11 rounded-md border border-dashed border-border/60 hover:bg-muted/50 cursor-pointer bg-background"
>
<Upload class="h-4 w-4 mr-2" />
Upload .env
</Label>
</div>
</div>
</CardContent>
<CardFooter class="border-t px-4 flex justify-end">
<Button onclick={saveEnvVars} disabled={!isDirty || loading} size="sm">
@@ -199,7 +254,7 @@
<div class="flex items-center gap-2">
<Input
readonly
value={`http://localhost:8080/webhooks/trigger?project_id=${project.ID}`}
value={`http://localhost:8080/projects/${project.id}/webhook/${project.webhook_secret}`}
class="bg-muted font-mono text-xs"
/>
<Button variant="outline" size="icon" onclick={copyWebhook}>
@@ -207,15 +262,6 @@
</Button>
</div>
</div>
<div class="space-y-2">
<Label class="text-sm">Webhook Secret</Label>
<Input
type="password"
readonly
value={project.webhook_secret}
class="bg-muted font-mono text-xs"
/>
</div>
</CardContent>
</Card>

View File

@@ -88,8 +88,11 @@
</div>
<div class="space-y-2">
<Label>New Password</Label>
type="password" bind:value={newPassword}
required minlength={6}
<Input
type="password"
bind:value={newPassword}
required
minlength={6}
/>
</div>
<Button type="submit" variant="secondary" disabled={loading}