eny.space Landingpage
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor(dashboard): split pretty dashboard and dev tools

+484 -187
+2 -84
app/api/pds/service/route.ts
··· 1 1 import { NextResponse } from "next/server"; 2 2 3 3 import { createClient } from "@/lib/supabase/server"; 4 + import { getMockPdsService } from "@/lib/mocks/pds-service"; 4 5 5 6 const PDS_API_BASE_URL = process.env.PDS_API_BASE_URL; 6 7 7 - function getMockService() { 8 - const now = new Date(); 9 - const iso = (d: Date) => d.toISOString(); 10 - 11 - const storageAllocatedBytes = 10 * 1024 ** 3; // 10 GiB 12 - const storageUsedBytes = Math.floor(2.8 * 1024 ** 3); // ~2.8 GiB 13 - const bandwidthLimitBytesPerMonth = 100 * 1024 ** 3; // 100 GiB 14 - const bandwidthUsedBytesThisMonth = Math.floor(14.7 * 1024 ** 3); // ~14.7 GiB 15 - const cpuUsagePercent = 23; 16 - const ramUsagePercent = 41; 17 - const userSlotsUsed = 1; 18 - const userSlotsTotal = 10; 19 - 20 - const storageUsedDailyLast30d = Array.from({ length: 30 }).map((_, i) => { 21 - const d = new Date(now); 22 - d.setUTCDate(d.getUTCDate() - (29 - i)); 23 - const base = Math.floor(1.9 * 1024 ** 3); 24 - const growth = Math.floor(i * (34 * 1024 ** 2)); // ~34 MiB/day 25 - const noise = Math.floor(((i % 5) - 2) * (6 * 1024 ** 2)); 26 - return { date: iso(d).slice(0, 10), usedBytes: base + growth + noise }; 27 - }); 28 - 29 - const requestsPerHourLast24h = Array.from({ length: 24 }).map((_, i) => { 30 - const d = new Date(now); 31 - d.setUTCHours(d.getUTCHours() - (23 - i), 0, 0, 0); 32 - const wave = 260 + Math.floor(180 * Math.sin((i / 24) * Math.PI * 2)); 33 - const jitter = (i % 3) * 17; 34 - return { hour: iso(d), count: Math.max(40, wave + jitter) }; 35 - }); 36 - 37 - const failedRequestsLast24h = 42; 38 - const successfulRequestsLast24h = requestsPerHourLast24h.reduce( 39 - (sum, p) => sum + p.count, 40 - 0 41 - ); 42 - 43 - return { 44 - id: 1, 45 - name: "test1-pds", 46 - service: "bluesky-pds", 47 - namespace: "kd0186-test1-pds", 48 - encrypted_config: { 49 - hostname: "test1.eny.space", 50 - adminPassword: "zoidberg", 51 - emailSmtpUrl: "smtps://max@mustermann.de:s3cr3t@smtp.mustermann.de:465/", 52 - pdsEmailFromAddress: "test1@example.com", 53 - dataStorage: { 54 - size: "10Gi", 55 - }, 56 - }, 57 - install_cmd: 58 - "export KUBECONFIG={kubeconfig}\n" + 59 - "helm repo add nerkho https://charts.nerkho.ch\n" + 60 - "helm repo update\n" + 61 - "helm install bluesky-pds nerkho/bluesky-pds --namespace {namespace} -f {values}\n" + 62 - 'export KUBECONFIG=""', 63 - state: 3, 64 - kubeconfig_id: 1, 65 - created_at: "2026-03-17T15:05:40.000000Z", 66 - updated_at: "2026-03-17T15:05:40.000000Z", 67 - stats: { 68 - cpuUsagePercent, 69 - ramUsagePercent, 70 - storageUsedBytes, 71 - storageAllocatedBytes, 72 - storageObjectsCount: 12345, 73 - storageUsedDailyLast30d, 74 - bandwidthUsedBytesThisMonth, 75 - bandwidthLimitBytesPerMonth, 76 - requestsLast24h: successfulRequestsLast24h, 77 - requestsPerHourLast24h, 78 - activeUsers: 3, 79 - uniqueUsersLast30d: 27, 80 - userSlotsUsed, 81 - userSlotsTotal, 82 - uptimeSeconds: 987654, 83 - lastBackupAt: new Date(now.getTime() - 6 * 60 * 60 * 1000).toISOString(), 84 - failedRequestsLast24h, 85 - successfulRequestsLast24h, 86 - }, 87 - }; 88 - } 89 - 90 8 export async function GET() { 91 9 try { 92 10 const useMock = process.env.PDS_USE_MOCK === "true"; 93 11 if (useMock) { 94 - return NextResponse.json(getMockService()); 12 + return NextResponse.json(getMockPdsService()); 95 13 } 96 14 97 15 if (!PDS_API_BASE_URL) {
+151
app/dashboard/developer/page.tsx
··· 1 + import { redirect } from "next/navigation"; 2 + import { createClient } from "@/lib/supabase/server"; 3 + import { getSubscriptionStatus } from "@/actions/subscription"; 4 + import { 5 + Card, 6 + CardContent, 7 + CardHeader, 8 + } from "@/actions/components/ui/card"; 9 + import { ButtonLink } from "@/components/button-link"; 10 + import { Heading } from "@/components/heading"; 11 + import { Paragraph } from "@/components/paragraph"; 12 + import DashboardClient from "../dashboard-client"; 13 + import { ServiceDetailsClient } from "../service-details-client"; 14 + import { AtprotoTestClient } from "../atproto-test-client"; 15 + import { CollapsibleSection } from "../collapsible-section"; 16 + import { prelaunch } from "@/lib/prelaunch"; 17 + import { getPriceIdForPlan } from "@/lib/stripe-plans"; 18 + import { getPdsServiceForCurrentUser } from "../../api/pds/atproto/helpers"; 19 + import { pdsStateLabel } from "@/lib/pds-state"; 20 + import { PdsHealthClient } from "../pds-health-client"; 21 + 22 + type DashboardPageProps = { 23 + searchParams?: Promise<{ 24 + auto_checkout?: string; 25 + pds_plan?: string; 26 + pds_username?: string; 27 + pds_hostname?: string; 28 + pds_disksize_gb?: string; 29 + }>; 30 + }; 31 + 32 + export default async function DashboardPage({ searchParams }: DashboardPageProps) { 33 + const params = await searchParams; 34 + const supabase = await createClient(); 35 + const { 36 + data: { user }, 37 + } = await supabase.auth.getUser(); 38 + 39 + if (!user) { 40 + redirect("/login"); 41 + } 42 + 43 + const { subscribed, subscription } = await getSubscriptionStatus(); 44 + 45 + if (prelaunch && !subscribed) { 46 + redirect("/welcome"); 47 + } 48 + 49 + let pdsHostname: string | null = null; 50 + let pdsStatus = subscribed ? "active" : "provisioning"; 51 + 52 + try { 53 + const { service } = await getPdsServiceForCurrentUser(); 54 + pdsHostname = service?.hostname || service?.encrypted_config?.hostname || null; 55 + if (service?.state !== undefined && service.state !== null) { 56 + pdsStatus = pdsStateLabel(service.state); 57 + } 58 + } catch { 59 + // Service not provisioned yet or API unavailable — fall back to subscription-derived status 60 + } 61 + 62 + const pdsDashboardUrl = pdsHostname ? `https://pdsls.dev/${pdsHostname}` : null; 63 + 64 + return ( 65 + <main className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-6 sm:px-6"> 66 + <div className="flex items-center gap-3"> 67 + <ButtonLink href="/dashboard" className="text-sm text-white/50 hover:text-white"> 68 + ← Dashboard 69 + </ButtonLink> 70 + <Heading as="h1" className="text-lg font-semibold text-white"> 71 + Developer Settings 72 + </Heading> 73 + </div> 74 + 75 + {/* Overview — always visible */} 76 + <Card> 77 + <CardHeader> 78 + <Heading as="h1" className="text-xl font-semibold text-white"> 79 + My PDS 80 + </Heading> 81 + <Paragraph className="text-sm text-white/80"> 82 + Authenticated as {user.email}. 83 + </Paragraph> 84 + </CardHeader> 85 + <CardContent className="space-y-4"> 86 + <div className="grid gap-4 md:grid-cols-2 text-white"> 87 + <div className="space-y-1"> 88 + <Paragraph className="text-sm font-medium text-white/60">Status</Paragraph> 89 + <Paragraph className="text-base font-semibold capitalize">{pdsStatus}</Paragraph> 90 + </div> 91 + <div className="space-y-1"> 92 + <Paragraph className="text-sm font-medium text-white/60">Hostname</Paragraph> 93 + {pdsDashboardUrl ? ( 94 + <a 95 + href={pdsDashboardUrl} 96 + target="_blank" 97 + rel="noreferrer" 98 + className="text-base font-semibold text-primary underline underline-offset-2" 99 + > 100 + {pdsHostname} 101 + </a> 102 + ) : ( 103 + <Paragraph className="text-base font-semibold text-white/50">Pending</Paragraph> 104 + )} 105 + </div> 106 + </div> 107 + <div className="flex flex-wrap gap-3 pt-1"> 108 + {pdsDashboardUrl && ( 109 + <ButtonLink 110 + href={pdsDashboardUrl} 111 + className="border border-white/80 bg-transparent uppercase tracking-wide text-white hover:bg-white/10 hover:border-white focus-visible:ring-white/50" 112 + > 113 + Open dashboard 114 + </ButtonLink> 115 + )} 116 + </div> 117 + {pdsHostname && ( 118 + <PdsHealthClient pdsHost={`https://${pdsHostname}`} /> 119 + )} 120 + </CardContent> 121 + </Card> 122 + 123 + {/* Usage & Stats — hidden when real API returns no stats */} 124 + <ServiceDetailsClient mode="stats" /> 125 + 126 + {/* Service Details */} 127 + <CollapsibleSection title="Service Details"> 128 + <ServiceDetailsClient mode="details" /> 129 + </CollapsibleSection> 130 + 131 + {/* AT Protocol */} 132 + <CollapsibleSection title="AT Protocol"> 133 + <AtprotoTestClient /> 134 + </CollapsibleSection> 135 + 136 + {/* Billing & Subscription */} 137 + <CollapsibleSection title="Billing & Subscription" defaultOpen> 138 + <DashboardClient 139 + subscribed={subscribed} 140 + subscription={subscription} 141 + priceId={getPriceIdForPlan(params?.pds_plan)} 142 + autoCheckoutFromPlan={params?.auto_checkout === "1"} 143 + pdsPlan={params?.pds_plan} 144 + pdsUsername={params?.pds_username} 145 + pdsHostname={params?.pds_hostname} 146 + pdsDisksizeGb={params?.pds_disksize_gb} 147 + /> 148 + </CollapsibleSection> 149 + </main> 150 + ); 151 + }
+56 -100
app/dashboard/page.tsx
··· 1 1 import { redirect } from "next/navigation"; 2 2 import { createClient } from "@/lib/supabase/server"; 3 3 import { getSubscriptionStatus } from "@/actions/subscription"; 4 - import { 5 - Card, 6 - CardContent, 7 - CardHeader, 8 - } from "@/actions/components/ui/card"; 4 + import { Card, CardContent, CardHeader } from "@/actions/components/ui/card"; 9 5 import { ButtonLink } from "@/components/button-link"; 10 6 import { Heading } from "@/components/heading"; 11 7 import { Paragraph } from "@/components/paragraph"; 12 - import DashboardClient from "./dashboard-client"; 13 - import { ServiceDetailsClient } from "./service-details-client"; 14 - import { AtprotoTestClient } from "./atproto-test-client"; 15 - import { CollapsibleSection } from "./collapsible-section"; 16 8 import { prelaunch } from "@/lib/prelaunch"; 17 - import { getPriceIdForPlan } from "@/lib/stripe-plans"; 18 9 import { getPdsServiceForCurrentUser } from "../api/pds/atproto/helpers"; 19 - import { pdsStateLabel } from "@/lib/pds-state"; 10 + import { isPdsReady, pdsStateLabel } from "@/lib/pds-state"; 20 11 import { PdsHealthClient } from "./pds-health-client"; 12 + import { UserDashboardClient } from "./user-dashboard-client"; 21 13 22 - type DashboardPageProps = { 23 - searchParams?: Promise<{ 24 - auto_checkout?: string; 25 - pds_plan?: string; 26 - pds_username?: string; 27 - pds_hostname?: string; 28 - pds_disksize_gb?: string; 29 - }>; 30 - }; 31 - 32 - export default async function DashboardPage({ searchParams }: DashboardPageProps) { 33 - const params = await searchParams; 14 + export default async function DashboardPage() { 34 15 const supabase = await createClient(); 35 16 const { 36 17 data: { user }, ··· 40 21 redirect("/login"); 41 22 } 42 23 43 - const { subscribed, subscription } = await getSubscriptionStatus(); 24 + const { subscribed } = await getSubscriptionStatus(); 44 25 45 26 if (prelaunch && !subscribed) { 46 27 redirect("/welcome"); 47 28 } 48 29 49 30 let pdsHostname: string | null = null; 50 - let pdsStatus = subscribed ? "active" : "provisioning"; 31 + let pdsState: number | string | null = null; 51 32 52 33 try { 53 34 const { service } = await getPdsServiceForCurrentUser(); 54 35 pdsHostname = service?.hostname || service?.encrypted_config?.hostname || null; 55 - if (service?.state !== undefined && service.state !== null) { 56 - pdsStatus = pdsStateLabel(service.state); 57 - } 36 + pdsState = service?.state ?? null; 58 37 } catch { 59 - // Service not provisioned yet or API unavailable — fall back to subscription-derived status 38 + // Not provisioned yet or API unavailable 60 39 } 61 40 62 - const pdsDashboardUrl = pdsHostname ? `https://pdsls.dev/${pdsHostname}` : null; 41 + const ready = isPdsReady(pdsState); 42 + const statusLabel = pdsState !== null ? pdsStateLabel(pdsState) : subscribed ? "Provisioning" : "No subscription"; 63 43 64 44 return ( 65 - <main className="mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-6 sm:px-6"> 66 - {/* Overview — always visible */} 45 + <main className="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-6 sm:px-6"> 46 + <div className="flex items-center justify-between"> 47 + <Heading as="h1" className="text-xl font-semibold text-white"> 48 + My PDS 49 + </Heading> 50 + <ButtonLink 51 + href="/dashboard/developer" 52 + className="text-sm text-white/40 hover:text-white/80" 53 + > 54 + Developer Settings → 55 + </ButtonLink> 56 + </div> 57 + 58 + {/* Status card */} 67 59 <Card> 68 60 <CardHeader> 69 - <Heading as="h1" className="text-xl font-semibold text-white"> 70 - My PDS 71 - </Heading> 72 - <Paragraph className="text-sm text-white/80"> 73 - Authenticated as {user.email}. 74 - </Paragraph> 75 - </CardHeader> 76 - <CardContent className="space-y-4"> 77 - <div className="grid gap-4 md:grid-cols-2 text-white"> 78 - <div className="space-y-1"> 79 - <Paragraph className="text-sm font-medium text-white/60">Status</Paragraph> 80 - <Paragraph className="text-base font-semibold capitalize">{pdsStatus}</Paragraph> 81 - </div> 82 - <div className="space-y-1"> 83 - <Paragraph className="text-sm font-medium text-white/60">Hostname</Paragraph> 84 - {pdsDashboardUrl ? ( 85 - <a 86 - href={pdsDashboardUrl} 87 - target="_blank" 88 - rel="noreferrer" 89 - className="text-base font-semibold text-primary underline underline-offset-2" 90 - > 91 - {pdsHostname} 92 - </a> 93 - ) : ( 94 - <Paragraph className="text-base font-semibold text-white/50">Pending</Paragraph> 95 - )} 96 - </div> 97 - </div> 98 - <div className="flex flex-wrap gap-3 pt-1"> 99 - {pdsDashboardUrl && ( 100 - <ButtonLink 101 - href={pdsDashboardUrl} 102 - className="border border-white/80 bg-transparent uppercase tracking-wide text-white hover:bg-white/10 hover:border-white focus-visible:ring-white/50" 103 - > 104 - Open dashboard 105 - </ButtonLink> 106 - )} 61 + <div className="flex items-center gap-2"> 62 + <span 63 + className={`inline-block h-2.5 w-2.5 rounded-full ${ 64 + ready ? "bg-emerald-400" : "bg-amber-400 animate-pulse" 65 + }`} 66 + /> 67 + <Heading as="h2" className="text-base font-semibold text-white"> 68 + {statusLabel} 69 + </Heading> 107 70 </div> 108 71 {pdsHostname && ( 72 + <Paragraph className="text-sm text-white/60 font-mono">{pdsHostname}</Paragraph> 73 + )} 74 + </CardHeader> 75 + {pdsHostname && ( 76 + <CardContent> 109 77 <PdsHealthClient pdsHost={`https://${pdsHostname}`} /> 110 - )} 111 - </CardContent> 78 + </CardContent> 79 + )} 112 80 </Card> 113 81 114 - {/* Usage & Stats */} 115 - <CollapsibleSection title="Usage & Stats"> 116 - <ServiceDetailsClient mode="stats" /> 117 - </CollapsibleSection> 118 - 119 - {/* Service Details */} 120 - <CollapsibleSection title="Service Details"> 121 - <ServiceDetailsClient mode="details" /> 122 - </CollapsibleSection> 123 - 124 - {/* AT Protocol */} 125 - <CollapsibleSection title="AT Protocol"> 126 - <AtprotoTestClient /> 127 - </CollapsibleSection> 128 - 129 - {/* Billing & Subscription */} 130 - <CollapsibleSection title="Billing & Subscription" defaultOpen> 131 - <DashboardClient 132 - subscribed={subscribed} 133 - subscription={subscription} 134 - priceId={getPriceIdForPlan(params?.pds_plan)} 135 - autoCheckoutFromPlan={params?.auto_checkout === "1"} 136 - pdsPlan={params?.pds_plan} 137 - pdsUsername={params?.pds_username} 138 - pdsHostname={params?.pds_hostname} 139 - pdsDisksizeGb={params?.pds_disksize_gb} 140 - /> 141 - </CollapsibleSection> 82 + {/* Forms — only shown when PDS is reachable */} 83 + {ready ? ( 84 + <Card> 85 + <CardContent className="pt-6"> 86 + <UserDashboardClient /> 87 + </CardContent> 88 + </Card> 89 + ) : ( 90 + <Card> 91 + <CardContent className="py-8 text-center"> 92 + <Paragraph className="text-sm text-white/50"> 93 + The PDS is not ready yet. Forms will appear once it is running. 94 + </Paragraph> 95 + </CardContent> 96 + </Card> 97 + )} 142 98 </main> 143 99 ); 144 100 }
+16 -1
app/dashboard/service-details-client.tsx
··· 4 4 import { Heading } from "@/components/heading"; 5 5 import { Paragraph } from "@/components/paragraph"; 6 6 import { pdsStateLabel } from "@/lib/pds-state"; 7 + import { CollapsibleSection } from "./collapsible-section"; 7 8 8 9 type ServiceStats = { 9 10 cpuUsagePercent?: number; ··· 125 126 126 127 const cfg = service.encrypted_config || {}; 127 128 const stats = service.stats; 129 + 130 + if (mode === "stats" && !stats) { 131 + return null; 132 + } 128 133 const maskedAdminPassword = 129 134 cfg.adminPassword && cfg.adminPassword.length > 0 ? "••••••••" : undefined; 130 135 ··· 162 167 const showDetails = mode === "all" || mode === "details"; 163 168 const showStats = mode === "all" || mode === "stats"; 164 169 165 - return ( 170 + const content = ( 166 171 <section 167 172 className={ 168 173 mode === "stats" ··· 491 496 )} 492 497 </section> 493 498 ); 499 + 500 + if (mode === "stats") { 501 + return ( 502 + <CollapsibleSection title="Usage & Stats"> 503 + {content} 504 + </CollapsibleSection> 505 + ); 506 + } 507 + 508 + return content; 494 509 }
+175
app/dashboard/user-dashboard-client.tsx
··· 1 + "use client"; 2 + 3 + import { useEffect, useMemo, useState } from "react"; 4 + import { Paragraph } from "@/components/paragraph"; 5 + import { Button } from "@/actions/components/ui/button"; 6 + import { Input } from "@/actions/components/ui/input"; 7 + import { Label } from "@/actions/components/ui/label"; 8 + 9 + type ServiceResponse = { 10 + hostname?: string; 11 + encrypted_config?: { hostname?: string }; 12 + state?: number | string; 13 + }; 14 + 15 + function stripScheme(hostname?: string) { 16 + if (!hostname) return ""; 17 + return hostname.replace(/^https?:\/\//i, "").replace(/\/.*$/, ""); 18 + } 19 + 20 + export function UserDashboardClient() { 21 + const [pdsHost, setPdsHost] = useState<string>(""); 22 + const [inviteCode, setInviteCode] = useState<string>(""); 23 + const [handle, setHandle] = useState<string>(""); 24 + const [password, setPassword] = useState<string>(""); 25 + const [loading, setLoading] = useState(false); 26 + const [fetchError, setFetchError] = useState<string | null>(null); 27 + const [actionError, setActionError] = useState<string | null>(null); 28 + const [actionSuccess, setActionSuccess] = useState<string | null>(null); 29 + 30 + const pdsBareHost = useMemo(() => stripScheme(pdsHost), [pdsHost]); 31 + const fullHandle = useMemo( 32 + () => (handle && pdsBareHost ? `${handle}.${pdsBareHost}` : ""), 33 + [handle, pdsBareHost], 34 + ); 35 + 36 + useEffect(() => { 37 + fetch("/api/pds/service") 38 + .then((r) => r.json()) 39 + .then((data: ServiceResponse) => { 40 + const host = data?.hostname || data?.encrypted_config?.hostname || ""; 41 + setPdsHost(host ? `https://${stripScheme(host)}` : ""); 42 + }) 43 + .catch((e) => setFetchError(e instanceof Error ? e.message : "Failed to load PDS info")); 44 + }, []); 45 + 46 + const call = async (path: string, body: unknown) => { 47 + setLoading(true); 48 + setActionError(null); 49 + setActionSuccess(null); 50 + try { 51 + const res = await fetch(path, { 52 + method: "POST", 53 + headers: { "Content-Type": "application/json" }, 54 + body: JSON.stringify(body), 55 + }); 56 + const payload = await res.json().catch(() => ({})); 57 + if (!res.ok) { 58 + throw new Error(payload?.message || payload?.payload?.message || "Request failed"); 59 + } 60 + return payload; 61 + } finally { 62 + setLoading(false); 63 + } 64 + }; 65 + 66 + const createInvite = async () => { 67 + try { 68 + const payload = await call("/api/pds/atproto/invite", { useCount: 1 }); 69 + const code = payload?.code || payload?.inviteCode || ""; 70 + setInviteCode(code); 71 + } catch (e) { 72 + setActionError(e instanceof Error ? e.message : String(e)); 73 + } 74 + }; 75 + 76 + const createAccount = async () => { 77 + if (!handle || !password || !inviteCode) return; 78 + try { 79 + await call("/api/pds/atproto/create-account", { 80 + handle: fullHandle, 81 + password, 82 + inviteCode, 83 + }); 84 + setActionSuccess(`Account created: ${fullHandle}`); 85 + setHandle(""); 86 + setPassword(""); 87 + setInviteCode(""); 88 + } catch (e) { 89 + setActionError(e instanceof Error ? e.message : String(e)); 90 + } 91 + }; 92 + 93 + if (fetchError) { 94 + return ( 95 + <Paragraph className="text-sm text-rose-300">{fetchError}</Paragraph> 96 + ); 97 + } 98 + 99 + return ( 100 + <div className="space-y-8"> 101 + {/* Invite code */} 102 + <section className="space-y-3"> 103 + <Paragraph className="text-sm font-semibold text-white/80 uppercase tracking-wide"> 104 + Invitation code 105 + </Paragraph> 106 + <div className="flex flex-wrap items-center gap-3"> 107 + <Button onClick={createInvite} disabled={loading || !pdsHost} className="rounded-full"> 108 + {loading ? "Working…" : "Generate invite"} 109 + </Button> 110 + {inviteCode && ( 111 + <Paragraph className="font-mono text-sm text-white break-all">{inviteCode}</Paragraph> 112 + )} 113 + </div> 114 + </section> 115 + 116 + {/* Create user */} 117 + <section className="space-y-4"> 118 + <Paragraph className="text-sm font-semibold text-white/80 uppercase tracking-wide"> 119 + Create user 120 + </Paragraph> 121 + <div className="grid gap-3 sm:grid-cols-2"> 122 + <div className="space-y-1"> 123 + <Label htmlFor="handle">Handle</Label> 124 + <div className="flex items-center gap-1"> 125 + <Input 126 + id="handle" 127 + value={handle} 128 + onChange={(e) => setHandle(e.target.value)} 129 + placeholder="username" 130 + className="flex-1" 131 + /> 132 + {pdsBareHost && ( 133 + <Paragraph className="text-xs text-white/40 whitespace-nowrap">.{pdsBareHost}</Paragraph> 134 + )} 135 + </div> 136 + </div> 137 + <div className="space-y-1"> 138 + <Label htmlFor="password">Password</Label> 139 + <Input 140 + id="password" 141 + type="password" 142 + value={password} 143 + onChange={(e) => setPassword(e.target.value)} 144 + placeholder="Password" 145 + /> 146 + </div> 147 + <div className="space-y-1 sm:col-span-2"> 148 + <Label htmlFor="invite">Invite code</Label> 149 + <Input 150 + id="invite" 151 + value={inviteCode} 152 + onChange={(e) => setInviteCode(e.target.value)} 153 + placeholder="Paste invite code or generate one above" 154 + className="font-mono" 155 + /> 156 + </div> 157 + </div> 158 + <Button 159 + onClick={createAccount} 160 + disabled={loading || !handle || !password || !inviteCode || !pdsHost} 161 + className="rounded-full" 162 + > 163 + {loading ? "Working…" : "Create account"} 164 + </Button> 165 + </section> 166 + 167 + {actionSuccess && ( 168 + <Paragraph className="text-sm text-emerald-300">{actionSuccess}</Paragraph> 169 + )} 170 + {actionError && ( 171 + <Paragraph className="text-sm text-rose-300 break-all">{actionError}</Paragraph> 172 + )} 173 + </div> 174 + ); 175 + }
+83
lib/mocks/pds-service.ts
··· 1 + export function getMockPdsService() { 2 + const now = new Date(); 3 + const iso = (d: Date) => d.toISOString(); 4 + 5 + const storageAllocatedBytes = 10 * 1024 ** 3; // 10 GiB 6 + const storageUsedBytes = Math.floor(2.8 * 1024 ** 3); // ~2.8 GiB 7 + const bandwidthLimitBytesPerMonth = 100 * 1024 ** 3; // 100 GiB 8 + const bandwidthUsedBytesThisMonth = Math.floor(14.7 * 1024 ** 3); // ~14.7 GiB 9 + const cpuUsagePercent = 23; 10 + const ramUsagePercent = 41; 11 + const userSlotsUsed = 1; 12 + const userSlotsTotal = 10; 13 + 14 + const storageUsedDailyLast30d = Array.from({ length: 30 }).map((_, i) => { 15 + const d = new Date(now); 16 + d.setUTCDate(d.getUTCDate() - (29 - i)); 17 + const base = Math.floor(1.9 * 1024 ** 3); 18 + const growth = Math.floor(i * (34 * 1024 ** 2)); // ~34 MiB/day 19 + const noise = Math.floor(((i % 5) - 2) * (6 * 1024 ** 2)); 20 + return { date: iso(d).slice(0, 10), usedBytes: base + growth + noise }; 21 + }); 22 + 23 + const requestsPerHourLast24h = Array.from({ length: 24 }).map((_, i) => { 24 + const d = new Date(now); 25 + d.setUTCHours(d.getUTCHours() - (23 - i), 0, 0, 0); 26 + const wave = 260 + Math.floor(180 * Math.sin((i / 24) * Math.PI * 2)); 27 + const jitter = (i % 3) * 17; 28 + return { hour: iso(d), count: Math.max(40, wave + jitter) }; 29 + }); 30 + 31 + const failedRequestsLast24h = 42; 32 + const successfulRequestsLast24h = requestsPerHourLast24h.reduce( 33 + (sum, p) => sum + p.count, 34 + 0, 35 + ); 36 + 37 + return { 38 + id: 1, 39 + name: "test1-pds", 40 + service: "bluesky-pds", 41 + namespace: "kd0186-test1-pds", 42 + hostname: "test1.eny.space", 43 + encrypted_config: { 44 + hostname: "test1.eny.space", 45 + adminPassword: "zoidberg", 46 + emailSmtpUrl: "smtps://max@mustermann.de:s3cr3t@smtp.mustermann.de:465/", 47 + pdsEmailFromAddress: "test1@example.com", 48 + dataStorage: { 49 + size: "10Gi", 50 + }, 51 + }, 52 + install_cmd: 53 + "export KUBECONFIG={kubeconfig}\n" + 54 + "helm repo add nerkho https://charts.nerkho.ch\n" + 55 + "helm repo update\n" + 56 + "helm install bluesky-pds nerkho/bluesky-pds --namespace {namespace} -f {values}\n" + 57 + 'export KUBECONFIG=""', 58 + state: 3, 59 + kubeconfig_id: 1, 60 + created_at: "2026-03-17T15:05:40.000000Z", 61 + updated_at: "2026-03-17T15:05:40.000000Z", 62 + stats: { 63 + cpuUsagePercent, 64 + ramUsagePercent, 65 + storageUsedBytes, 66 + storageAllocatedBytes, 67 + storageObjectsCount: 12345, 68 + storageUsedDailyLast30d, 69 + bandwidthUsedBytesThisMonth, 70 + bandwidthLimitBytesPerMonth, 71 + requestsLast24h: successfulRequestsLast24h, 72 + requestsPerHourLast24h, 73 + activeUsers: 3, 74 + uniqueUsersLast30d: 27, 75 + userSlotsUsed, 76 + userSlotsTotal, 77 + uptimeSeconds: 987654, 78 + lastBackupAt: new Date(now.getTime() - 6 * 60 * 60 * 1000).toISOString(), 79 + failedRequestsLast24h, 80 + successfulRequestsLast24h, 81 + }, 82 + }; 83 + }
+1 -2
lib/pds-state.ts
··· 2 2 0: "Pending", 3 3 1: "Deploying", 4 4 2: "Creating user", 5 - 3: "Running", 6 - 4: "Error", 5 + 3: "Done", 7 6 }; 8 7 9 8 export function pdsStateLabel(state: number | string | null | undefined): string {