small fix, don't worry about it

This commit is contained in:
Tomas Dvorak
2026-04-10 12:02:36 +02:00
parent 08bd0c6e5c
commit 08cb5754f3
638 changed files with 57332 additions and 34706 deletions
+12
View File
@@ -0,0 +1,12 @@
node_modules
dist
.git
.gitignore
.env
.env.*
npm-debug.log*
yarn-debug.log*
yarn-error.log*
coverage
*.log
+59
View File
@@ -0,0 +1,59 @@
# Build stage
FROM node:20-alpine AS builder
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build-time API URL injection for Vite
ARG VITE_API_URL=http://localhost:8082
ARG VITE_AUTH_URL=http://localhost:8082/api/auth
ENV VITE_API_URL=${VITE_API_URL}
ENV VITE_AUTH_URL=${VITE_AUTH_URL}
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy custom nginx config
COPY nginx.conf /etc/nginx/nginx.conf
# Copy built assets from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Create non-root user
RUN addgroup -g 1001 -S appgroup && \
adduser -u 1001 -S appuser -G appgroup
# Change ownership of nginx directories
RUN chown -R appuser:appgroup /usr/share/nginx/html && \
chown -R appuser:appgroup /var/cache/nginx && \
chown -R appuser:appgroup /var/log/nginx && \
chown -R appuser:appgroup /etc/nginx/conf.d
# Create nginx PID directory
RUN touch /var/run/nginx.pid && \
chown -R appuser:appgroup /var/run/nginx.pid
# Switch to non-root user
USER appuser
# Expose port
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:80 || exit 1
# Start nginx
CMD ["nginx", "-g", "daemon off;"]
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "src/components",
"utils": "src/lib/utils"
}
}
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>containr</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+65
View File
@@ -0,0 +1,65 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
# Basic settings
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/atom+xml
image/svg+xml;
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
# Handle React Router
location / {
try_files $uri $uri/ /index.html;
}
# Static assets caching
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
}
+8590
View File
File diff suppressed because it is too large Load Diff
+88
View File
@@ -0,0 +1,88 @@
{
"name": "containr",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:check": "tsc -b && vite build",
"generate:api": "openapi-typescript ../../docs/api/openapi.yaml -o src/generated/api-types.ts",
"lint": "eslint .",
"preview": "vite preview",
"remote": "npx vercel deploy --prod",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests"
},
"dependencies": {
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.6",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-navigation-menu": "^1.2.6",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.6",
"@radix-ui/react-scroll-area": "^1.2.6",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.6",
"@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-switch": "^1.1.6",
"@radix-ui/react-tabs": "^1.1.6",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@tabler/icons-react": "^3.37.1",
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.66.0",
"@xyflow/react": "^12.10.0",
"autoprefixer": "^10.4.24",
"axios": "^1.7.9",
"better-auth": "^1.5.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.563.0",
"postcss": "^8.5.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.58.0",
"react-resizable-panels": "^4.6.4",
"react-router-dom": "^7.13.0",
"reactflow": "^11.11.4",
"recharts": "^3.7.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2",
"zustand": "^5.0.11"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.0.18",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"happy-dom": "^20.7.0",
"openapi-typescript": "^7.13.0",
"terser": "^5.46.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1",
"vitest": "^4.0.18"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import App from './App';
function renderApp(initialPath: string) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
refetchOnWindowFocus: false,
},
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[initialPath]}>
<App />
</MemoryRouter>
</QueryClientProvider>,
);
}
describe('App smoke routes', () => {
it('renders projects page in demo mode', async () => {
renderApp('/projects?demo=1');
expect(await screen.findByRole('heading', { name: /^projects$/i })).toBeInTheDocument();
expect(screen.getByText(/demo mode active/i)).toBeInTheDocument();
expect(screen.getByText(/visual topology mapping and real-time observability/i)).toBeInTheDocument();
});
it('renders builds page in demo mode', async () => {
renderApp('/builds?demo=1');
expect(await screen.findByRole('heading', { name: /build pipeline/i })).toBeInTheDocument();
expect(screen.getByText(/offline/i)).toBeInTheDocument();
});
it('renders templates page in demo mode', async () => {
renderApp('/templates?demo=1');
expect(await screen.findByRole('heading', { name: /template catalog/i })).toBeInTheDocument();
expect(screen.getAllByText(/react application/i).length).toBeGreaterThan(0);
});
});
+77
View File
@@ -0,0 +1,77 @@
import { Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom';
import { PlatformShell } from './layout/PlatformShell';
import { ProjectsPage } from '@/features/projects/pages/ProjectsPage';
import { ProjectWorkspacePage } from '@/features/workspace/pages/ProjectWorkspacePage';
import { ServiceDetailPage } from '@/features/service/pages/ServiceDetailPage';
import { ServiceMetricsDashboard } from '@/features/service/pages/ServiceMetricsDashboard';
import { BuildsPage } from '@/features/builds/pages/BuildsPage';
import { TemplatesPage } from '@/features/templates/pages/TemplatesPage';
import {
DocsPage,
PeoplePage,
SettingsPage,
UsagePage,
ComponentShowcase,
} from '@/features/secondary/pages';
import { SignInPage, SignUpPage } from '@/features/auth/pages';
import { useAuthSession } from '@/lib/use-auth-session';
import { ErrorBoundary, LoadingState } from '@/shared/components';
function AuthRequired() {
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
const isDemoMode = searchParams.get('demo') === '1';
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
if (isDemoMode) {
return <Outlet />;
}
if (sessionQuery.isPending) {
return (
<div className="min-h-screen bg-[var(--bg-void)]">
<LoadingState message="Checking session..." className="h-screen" />
</div>
);
}
if (!sessionQuery.data) {
const redirect = `${location.pathname}${location.search}`;
return <Navigate to={`/auth/sign-in?redirect=${encodeURIComponent(redirect)}`} replace />;
}
return <Outlet />;
}
export default function App() {
return (
<ErrorBoundary>
<Routes>
<Route path="/" element={<Navigate to="/projects" replace />} />
<Route path="/auth/sign-in" element={<SignInPage />} />
<Route path="/auth/sign-up" element={<SignUpPage />} />
<Route element={<AuthRequired />}>
<Route element={<PlatformShell />}>
<Route path="/projects" element={<ProjectsPage />} />
<Route path="/projects/:projectId" element={<ProjectWorkspacePage />} />
<Route path="/projects/:projectId/services/:serviceId" element={<ServiceDetailPage />} />
<Route path="/builds" element={<BuildsPage />} />
<Route path="/templates" element={<TemplatesPage />} />
<Route path="/usage" element={<UsagePage />} />
<Route path="/people" element={<PeoplePage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/docs" element={<DocsPage />} />
{/* Enhanced Dashboard & Showcase */}
<Route path="/metrics-demo" element={<ServiceMetricsDashboard />} />
<Route path="/showcase" element={<ComponentShowcase />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/projects" replace />} />
</Routes>
</ErrorBoundary>
);
}
@@ -0,0 +1,279 @@
import { NavLink, Outlet, useLocation } from 'react-router-dom';
import {
FolderKanban,
Hammer,
LayoutTemplate,
ChartBar,
Users,
Settings,
BookOpen,
Container,
Bell,
Search,
Zap,
Activity,
} from 'lucide-react';
const navItems = [
{ label: 'Projects', href: '/projects', icon: FolderKanban },
{ label: 'Builds', href: '/builds', icon: Hammer },
{ label: 'Templates', href: '/templates', icon: LayoutTemplate },
{ label: 'Usage', href: '/usage', icon: ChartBar },
{ label: 'People', href: '/people', icon: Users },
{ label: 'Settings', href: '/settings', icon: Settings },
{ label: 'Docs', href: '/docs', icon: BookOpen },
];
// Developer/Demo pages
const demoNavItems = [
{ label: 'Metrics Demo', href: '/metrics-demo', icon: Activity },
{ label: 'Showcase', href: '/showcase', icon: Zap },
];
export function PlatformShell() {
const location = useLocation();
const isDemoMode = new URLSearchParams(location.search).get('demo') === '1';
const href = (target: string) => (isDemoMode ? `${target}?demo=1` : target);
const isActiveRoute = (itemHref: string) =>
location.pathname === itemHref || location.pathname.startsWith(`${itemHref}/`);
return (
<div className="app-shell min-h-screen">
{/* Ambient glow background */}
<div className="ambient-glow" />
<div className="flex min-h-screen relative">
{/* Desktop Sidebar - self.html exact match: 64px width */}
<aside
className="hidden md:flex shrink-0 flex-col items-center border-r"
style={{
width: '64px',
background: '#111217',
borderRight: '1px solid rgba(255,255,255,0.07)',
padding: '16px 0',
gap: '5px'
}}
>
{/* Logo - exact self.html: 38px circular pink */}
<div
className="rounded-full flex items-center justify-center"
style={{
width: '38px',
height: '38px',
background: '#e8316a',
marginBottom: '14px'
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="white">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z"/>
</svg>
</div>
{/* Nav Items - 40x40 icon-only */}
{navItems.map((item) => {
const isActive = isActiveRoute(item.href);
const Icon = item.icon;
return (
<NavLink
key={item.href}
to={href(item.href)}
className={`nav-item ${isActive ? 'active' : ''}`}
title={item.label}
>
<Icon size={18} />
</NavLink>
);
})}
{/* Separator */}
<div style={{ width: '32px', height: '1px', background: 'rgba(255,255,255,0.07)', margin: '8px 0' }} />
{/* Demo/Developer Items */}
{demoNavItems.map((item) => {
const isActive = isActiveRoute(item.href);
const Icon = item.icon;
return (
<NavLink
key={item.href}
to={href(item.href)}
className={`nav-item ${isActive ? 'active' : ''}`}
title={item.label}
>
<Icon size={18} />
</NavLink>
);
})}
{/* Spacer */}
<div className="flex-1" />
{/* Bottom icons - Settings */}
<NavLink to="/settings" className="nav-item" title="Settings">
<Settings size={18} />
</NavLink>
<NavLink to="/docs" className="nav-item" title="Help">
<BookOpen size={18} />
</NavLink>
{/* User Avatar - exact self.html: 34px */}
<div
className="rounded-full flex items-center justify-center cursor-pointer"
style={{
width: '34px',
height: '34px',
background: '#22233a',
fontSize: '11px',
fontWeight: 700,
color: '#9295a4',
marginTop: '4px',
letterSpacing: '-0.3px'
}}
title="User"
>
w.
</div>
</aside>
{/* Main Content */}
<div className="min-w-0 flex-1 relative z-10 flex flex-col min-h-screen overflow-hidden">
{/* Topbar - self.html exact match: 52px height */}
<header
className="shrink-0 flex items-center"
style={{
height: '52px',
background: '#111217',
borderBottom: '1px solid rgba(255,255,255,0.07)',
padding: '0 22px',
gap: '14px'
}}
>
{/* Search Box */}
<div className="search-box">
<Search size={14} />
<input type="text" placeholder="Search logs..." />
</div>
{/* Right side */}
<div className="ml-auto flex items-center" style={{ gap: '8px' }}>
<button
className="rounded-[9px] border bg-transparent text-[#9295a4] font-medium cursor-pointer"
style={{
height: '32px',
padding: '0 14px',
border: '1px solid rgba(255,255,255,0.1)',
fontSize: '13px',
fontFamily: 'inherit'
}}
>
Support
</button>
<button
className="rounded-[9px] border-none flex items-center text-[#e8e9f0] font-medium cursor-pointer"
style={{
height: '32px',
padding: '0 14px',
background: 'rgba(255,255,255,0.08)',
fontSize: '13px',
fontFamily: 'inherit',
gap: '6px'
}}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
<polyline points="17 11 12 6 7 11"/>
<polyline points="17 18 12 13 7 18"/>
</svg>
Upgrade
</button>
<button
className="rounded-[9px] border flex items-center justify-center cursor-pointer bg-transparent"
style={{
width: '32px',
height: '32px',
border: '1px solid rgba(255,255,255,0.1)'
}}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#9295a4" strokeWidth="2" strokeLinecap="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
</button>
</div>
</header>
{/* Mobile Header */}
<header className="md:hidden sticky top-0 z-50 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/70 backdrop-blur-2xl">
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-3">
<div
className="rounded-xl flex items-center justify-center"
style={{
width: '36px',
height: '36px',
background: '#e8316a'
}}
>
<Container size={16} className="text-white" />
</div>
<span className="font-headline font-semibold text-[var(--text-primary)]">Containr</span>
</div>
<div className="flex items-center gap-2">
<button className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)] transition-all">
<Search size={16} />
</button>
<button className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)] transition-all relative">
<Bell size={16} />
<div className="absolute top-1.5 right-1.5 w-2 h-2 rounded-full bg-[var(--accent-primary)]" />
</button>
</div>
</div>
<nav className="flex gap-1 px-3 pb-3 overflow-x-auto scrollbar-hide">
{navItems.map((item) => {
const isActive = isActiveRoute(item.href);
const Icon = item.icon;
return (
<NavLink
key={item.href}
to={href(item.href)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-all ${
isActive
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]'
}`}
>
<Icon size={14} />
{item.label}
</NavLink>
);
})}
{/* Demo items */}
{demoNavItems.map((item) => {
const isActive = isActiveRoute(item.href);
const Icon = item.icon;
return (
<NavLink
key={item.href}
to={href(item.href)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-all ${
isActive
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]'
}`}
>
<Icon size={14} />
{item.label}
</NavLink>
);
})}
</nav>
</header>
{/* Page Content - scrollable */}
<main className="flex-1 overflow-y-auto">
<Outlet />
</main>
</div>
</div>
</div>
);
}
+416
View File
@@ -0,0 +1,416 @@
import { useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import { Link, Navigate, useNavigate, useSearchParams } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { AlertCircle, ArrowRight, Github, Gitlab, KeyRound, Loader2, Mail, Shield, User2, Workflow } from 'lucide-react';
import {
AuthError,
startBitbucketSignIn,
startGitLabSignIn,
requestMagicLinkInvite,
signInWithEmail,
signUpWithEmail,
startGiteaSignIn,
startGitHubSignIn,
} from '@/lib/auth-client';
import { useAuthSession } from '@/lib/use-auth-session';
function sanitizeRedirect(raw: string | null): string {
if (!raw) {
return '/projects';
}
const value = raw.trim();
if (!value.startsWith('/') || value.startsWith('//')) {
return '/projects';
}
return value;
}
function buildOAuthCallbackURL(path: string): string {
const cleanPath = sanitizeRedirect(path);
return `${window.location.origin}${cleanPath}`;
}
function AuthCanvas() {
const bars = [32, 44, 28, 60, 52, 64, 36, 48, 54, 72, 66, 78];
return (
<div className="relative hidden border-r border-[var(--border-subtle)] bg-[var(--bg-base)]/70 backdrop-blur-2xl xl:flex xl:w-[46%]">
<div className="absolute inset-0 bg-[#e8316a]/10" />
<div className="relative z-10 flex h-full w-full flex-col justify-between p-10">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-[var(--text-muted)]">Containr Access</p>
<h1 className="mt-3 font-headline text-4xl font-semibold leading-tight text-[var(--text-primary)]">
Secure Sessions,
<br />
Better Auth
</h1>
<p className="mt-4 max-w-md text-sm leading-relaxed text-[var(--text-secondary)]">
Email/password, invite magic links, and OAuth providers (GitHub, GitLab, Bitbucket, Gitea) are handled by a dedicated Better Auth service with cookie sessions.
</p>
</div>
<div className="panel p-5">
<div className="mb-4 flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--text-muted)]">Auth Health</p>
<span className="flex items-center gap-2 text-xs text-[var(--success)]">
<span className="live-pulse h-2 w-2 rounded-full bg-[var(--success)]" />
Live
</span>
</div>
<div className="grid grid-cols-12 items-end gap-1.5">
{bars.map((height, index) => (
<div
key={`bar-${index}`}
className="rounded-sm"
style={{ height: `${height}px`, background: '#e8316a' }}
/>
))}
</div>
</div>
</div>
</div>
);
}
function AuthCard({
title,
subtitle,
children,
}: {
title: string;
subtitle: string;
children: React.ReactNode;
}) {
return (
<div className="w-full max-w-[520px] rounded-[var(--radius-xl)] border border-[var(--border-subtle)] bg-[var(--surface-card)]/92 p-7 shadow-2xl shadow-black/35 backdrop-blur-xl md:p-8">
<div className="mb-6">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--text-muted)]">Containr</p>
<h2 className="mt-2 font-headline text-2xl font-semibold text-[var(--text-primary)]">{title}</h2>
<p className="mt-2 text-sm text-[var(--text-secondary)]">{subtitle}</p>
</div>
{children}
</div>
);
}
function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-[var(--bg-void)]">
<div className="ambient-glow" />
<div className="relative flex min-h-screen">
<AuthCanvas />
<div className="flex w-full items-center justify-center px-5 py-8 md:px-8">{children}</div>
</div>
</div>
);
}
function AuthErrorNotice({ message }: { message: string }) {
return (
<div className="mb-4 flex items-start gap-2 rounded-[var(--radius-md)] border border-[var(--error-soft)] bg-[var(--error-soft)] px-3 py-2.5 text-sm text-[var(--error)]">
<AlertCircle size={16} className="mt-0.5 shrink-0" />
<span>{message}</span>
</div>
);
}
function AuthInfoNotice({ message }: { message: string }) {
return (
<div className="mb-4 rounded-[var(--radius-md)] border border-[var(--success-soft)] bg-[var(--success-soft)] px-3 py-2.5 text-sm text-[var(--success)]">
{message}
</div>
);
}
export function SignInPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [searchParams] = useSearchParams();
const redirectPath = useMemo(() => sanitizeRedirect(searchParams.get('redirect')), [searchParams]);
const sessionQuery = useAuthSession();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [magicEmail, setMagicEmail] = useState('');
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isMagicLoading, setIsMagicLoading] = useState(false);
if (sessionQuery.data) {
return <Navigate to={redirectPath} replace />;
}
const submitEmailPassword = async (event: FormEvent) => {
event.preventDefault();
setError(null);
setInfo(null);
setIsSubmitting(true);
try {
await signInWithEmail(email.trim(), password);
await queryClient.invalidateQueries({ queryKey: ['auth-session'] });
navigate(redirectPath, { replace: true });
} catch (exception) {
const message = exception instanceof AuthError ? exception.message : 'Failed to sign in';
setError(message);
} finally {
setIsSubmitting(false);
}
};
const submitMagicLink = async (event: FormEvent) => {
event.preventDefault();
setError(null);
setInfo(null);
setIsMagicLoading(true);
try {
await requestMagicLinkInvite(magicEmail.trim(), buildOAuthCallbackURL(redirectPath));
setInfo('Magic invite link sent. Check your email inbox.');
} catch (exception) {
const message = exception instanceof AuthError ? exception.message : 'Failed to send magic link';
setError(message);
} finally {
setIsMagicLoading(false);
}
};
const signInWithGitHubProvider = async () => {
setError(null);
await startGitHubSignIn(buildOAuthCallbackURL(redirectPath));
};
const signInWithGiteaProvider = async () => {
setError(null);
await startGiteaSignIn(buildOAuthCallbackURL(redirectPath));
};
const signInWithGitLabProvider = async () => {
setError(null);
await startGitLabSignIn(buildOAuthCallbackURL(redirectPath));
};
const signInWithBitbucketProvider = async () => {
setError(null);
await startBitbucketSignIn(buildOAuthCallbackURL(redirectPath));
};
return (
<AuthLayout>
<AuthCard title="Sign In" subtitle="Use your Containr account, invite magic link, or provider OAuth.">
{error ? <AuthErrorNotice message={error} /> : null}
{info ? <AuthInfoNotice message={info} /> : null}
<form className="space-y-3" onSubmit={submitEmailPassword}>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
Email
<input
type="email"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
className="mt-2 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2.5 text-sm text-[var(--text-primary)] outline-none transition-colors focus:border-[var(--accent-primary)]"
placeholder="you@example.com"
/>
</label>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
Password
<input
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
className="mt-2 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2.5 text-sm text-[var(--text-primary)] outline-none transition-colors focus:border-[var(--accent-primary)]"
placeholder="Your password"
/>
</label>
<button
type="submit"
disabled={isSubmitting || sessionQuery.isPending}
className="mt-1 inline-flex h-10 w-full items-center justify-center gap-2 rounded-[var(--radius-md)] text-sm font-semibold text-white shadow-lg transition-all disabled:cursor-not-allowed disabled:opacity-60"
style={{ background: '#e8316a' }}
>
{isSubmitting ? <Loader2 size={15} className="animate-spin" /> : <Mail size={15} />}
Continue
</button>
</form>
<div className="my-5 h-px bg-[var(--border-subtle)]" />
<div className="grid gap-2 sm:grid-cols-2">
<button
type="button"
onClick={() => void signInWithGitHubProvider()}
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
>
<Github size={15} />
GitHub
</button>
<button
type="button"
onClick={() => void signInWithGitLabProvider()}
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
>
<Gitlab size={15} />
GitLab
</button>
<button
type="button"
onClick={() => void signInWithBitbucketProvider()}
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
>
<Workflow size={15} />
Bitbucket
</button>
<button
type="button"
onClick={() => void signInWithGiteaProvider()}
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
>
<Shield size={15} />
Gitea
</button>
</div>
<form className="mt-4 space-y-2" onSubmit={submitMagicLink}>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
Magic Link Invite
<input
type="email"
autoComplete="email"
value={magicEmail}
onChange={(event) => setMagicEmail(event.target.value)}
required
className="mt-2 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2.5 text-sm text-[var(--text-primary)] outline-none transition-colors focus:border-[var(--accent-primary)]"
placeholder="invite@example.com"
/>
</label>
<button
type="submit"
disabled={isMagicLoading}
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)] disabled:opacity-60"
>
{isMagicLoading ? <Loader2 size={15} className="animate-spin" /> : <KeyRound size={15} />}
Send Invite Link
</button>
</form>
<div className="mt-5 flex items-center justify-between text-xs text-[var(--text-secondary)]">
<span>No account yet?</span>
<Link to={`/auth/sign-up?redirect=${encodeURIComponent(redirectPath)}`} className="inline-flex items-center gap-1 text-[var(--accent-primary)] hover:underline">
Create one <ArrowRight size={12} />
</Link>
</div>
</AuthCard>
</AuthLayout>
);
}
export function SignUpPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [searchParams] = useSearchParams();
const redirectPath = useMemo(() => sanitizeRedirect(searchParams.get('redirect')), [searchParams]);
const sessionQuery = useAuthSession();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
if (sessionQuery.data) {
return <Navigate to={redirectPath} replace />;
}
const submitSignUp = async (event: FormEvent) => {
event.preventDefault();
setError(null);
setIsSubmitting(true);
try {
await signUpWithEmail(name.trim(), email.trim(), password);
await queryClient.invalidateQueries({ queryKey: ['auth-session'] });
navigate(redirectPath, { replace: true });
} catch (exception) {
const message = exception instanceof AuthError ? exception.message : 'Failed to create account';
setError(message);
} finally {
setIsSubmitting(false);
}
};
return (
<AuthLayout>
<AuthCard title="Create Account" subtitle="Provision your operator account with email/password auth.">
{error ? <AuthErrorNotice message={error} /> : null}
<form className="space-y-3" onSubmit={submitSignUp}>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
Name
<input
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
required
className="mt-2 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2.5 text-sm text-[var(--text-primary)] outline-none transition-colors focus:border-[var(--accent-primary)]"
placeholder="Operator"
/>
</label>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
Email
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
autoComplete="email"
required
className="mt-2 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2.5 text-sm text-[var(--text-primary)] outline-none transition-colors focus:border-[var(--accent-primary)]"
placeholder="you@example.com"
/>
</label>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
Password
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="new-password"
minLength={8}
required
className="mt-2 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2.5 text-sm text-[var(--text-primary)] outline-none transition-colors focus:border-[var(--accent-primary)]"
placeholder="At least 8 characters"
/>
</label>
<button
type="submit"
disabled={isSubmitting || sessionQuery.isPending}
className="mt-1 inline-flex h-10 w-full items-center justify-center gap-2 rounded-[var(--radius-md)] text-sm font-semibold text-white shadow-lg transition-all disabled:cursor-not-allowed disabled:opacity-60"
style={{ background: '#e8316a' }}
>
{isSubmitting ? <Loader2 size={15} className="animate-spin" /> : <User2 size={15} />}
Create Account
</button>
</form>
<div className="mt-5 flex items-center justify-between text-xs text-[var(--text-secondary)]">
<span>Already signed up?</span>
<Link to={`/auth/sign-in?redirect=${encodeURIComponent(redirectPath)}`} className="inline-flex items-center gap-1 text-[var(--accent-primary)] hover:underline">
Go to sign in <ArrowRight size={12} />
</Link>
</div>
</AuthCard>
</AuthLayout>
);
}
@@ -0,0 +1,475 @@
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
import {
cancelBuild,
getBuildLogs,
listBuilds,
type BuildEntity,
type BuildStatus,
} from '@/lib/api-client';
import { useBuildUpdates } from '@/lib/use-build-updates';
import { formatRelative } from '@/lib/time';
import {
Check,
X,
Loader2,
Clock,
RefreshCw,
FileText,
Box,
Sparkles,
Filter,
Circle,
HardDrive,
} from 'lucide-react';
const statusOptions: Array<{ value: '' | BuildStatus; label: string }> = [
{ value: '', label: 'All statuses' },
{ value: 'pending', label: 'Pending' },
{ value: 'running', label: 'Running' },
{ value: 'success', label: 'Success' },
{ value: 'failed', label: 'Failed' },
{ value: 'cancelled', label: 'Cancelled' },
];
const demoBuilds: BuildEntity[] = [
{
id: 'build-demo-01',
projectId: 'project-demo',
serviceId: 'service-api',
status: 'success',
progress: 100,
startedAt: new Date(Date.now() - 20 * 60_000).toISOString(),
completedAt: new Date(Date.now() - 18 * 60_000).toISOString(),
imageName: 'ghcr.io/containr/api',
imageTag: 'sha-1f2e3d4',
size: 156_000_000,
log: '[demo] Build finished successfully.',
metadata: { branch: 'main' },
},
{
id: 'build-demo-02',
projectId: 'project-demo',
serviceId: 'service-worker',
status: 'running',
progress: 54,
startedAt: new Date(Date.now() - 2 * 60_000).toISOString(),
imageName: 'ghcr.io/containr/worker',
imageTag: 'sha-9a8b7c6',
size: 0,
log: '[demo] Building image layers...',
metadata: { branch: 'feature/queue' },
},
];
function StatusBadge({ status }: { status: BuildStatus }) {
const config = {
success: { color: 'var(--success)', bg: 'var(--success-soft)', Icon: Check, animate: false },
failed: { color: 'var(--error)', bg: 'var(--error-soft)', Icon: X, animate: false },
running: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Loader2, animate: true },
pending: { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Clock, animate: false },
cancelled: { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: X, animate: false },
}[status] || { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Circle, animate: false };
const { Icon } = config;
return (
<div
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium"
style={{ background: config.bg, color: config.color }}
>
<Icon size={12} className={config.animate ? 'animate-spin' : ''} />
{status}
</div>
);
}
function bytesToHumanReadable(bytes: number): string {
if (bytes <= 0) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = bytes;
let index = 0;
while (value >= 1024 && index < units.length - 1) {
value /= 1024;
index += 1;
}
return `${value.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
}
export function BuildsPage() {
const queryClient = useQueryClient();
const [searchParams] = useSearchParams();
const isDemoMode = searchParams.get('demo') === '1';
const [projectFilter, setProjectFilter] = useState('');
const [serviceFilter, setServiceFilter] = useState('');
const [statusFilter, setStatusFilter] = useState<'' | BuildStatus>('');
const [limit, setLimit] = useState(50);
const [selectedBuild, setSelectedBuild] = useState<BuildEntity | null>(null);
const buildsQuery = useQuery({
queryKey: ['builds-page', { projectFilter, serviceFilter, statusFilter, limit }],
enabled: !isDemoMode,
queryFn: () =>
listBuilds({
projectId: projectFilter || undefined,
serviceId: serviceFilter || undefined,
status: statusFilter || undefined,
page: 1,
limit,
}),
});
const builds = useMemo(
() => (isDemoMode ? demoBuilds : buildsQuery.data?.builds ?? []),
[isDemoMode, buildsQuery.data?.builds],
);
const subscribedBuildIds = useMemo(() => builds.map((build) => build.id), [builds]);
const liveConnected = useBuildUpdates(subscribedBuildIds, ({ channel }) => {
queryClient.invalidateQueries({ queryKey: ['builds-page'] });
queryClient.invalidateQueries({ queryKey: ['usage-builds'] });
if (selectedBuild && channel === `build:${selectedBuild.id}`) {
queryClient.invalidateQueries({ queryKey: ['build-logs', selectedBuild.id] });
}
});
const cancelMutation = useMutation({
mutationFn: (buildId: string) => cancelBuild(buildId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['builds-page'] });
queryClient.invalidateQueries({ queryKey: ['usage-builds'] });
},
});
const logsQuery = useQuery({
queryKey: ['build-logs', selectedBuild?.id],
enabled: Boolean(selectedBuild) && !isDemoMode,
queryFn: () => getBuildLogs(selectedBuild!.id),
});
const isCancellingBuild = (buildId: string): boolean =>
cancelMutation.isPending && cancelMutation.variables === buildId;
// Stats
const stats = useMemo(() => {
const running = builds.filter(b => b.status === 'running' || b.status === 'pending').length;
const success = builds.filter(b => b.status === 'success').length;
const failed = builds.filter(b => b.status === 'failed').length;
return { running, success, failed, total: builds.length };
}, [builds]);
return (
<div className="min-h-screen">
{/* Header */}
<div className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/50 backdrop-blur-sm">
<div className="mx-auto w-full max-w-[1400px] px-6 py-4">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-[var(--text-primary)]">Build Pipeline</h1>
<p className="text-sm text-[var(--text-secondary)]">Monitor build progress and manage jobs</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-sm">
<div className={`w-2 h-2 rounded-full ${liveConnected ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
<span className={liveConnected ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}>
{liveConnected ? 'Live' : 'Offline'}
</span>
</div>
<button
onClick={() => queryClient.invalidateQueries({ queryKey: ['builds-page'] })}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
>
<RefreshCw size={14} />
Refresh
</button>
</div>
</div>
</div>
</div>
{/* Demo Mode Banner */}
{isDemoMode && (
<div className="mx-auto w-full max-w-[1400px] px-6 py-4">
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50">
<div className="flex items-center gap-2 text-sm text-[var(--warning)]">
<Sparkles size={16} />
<span>Demo mode active using sample data</span>
</div>
</div>
</div>
)}
{/* Stats Overview */}
<div className="mx-auto w-full max-w-[1400px] px-6 py-6">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="panel p-4">
<div className="flex items-center justify-between mb-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Box size={16} className="text-[var(--accent-primary)]" />
</div>
</div>
<p className="text-2xl font-semibold text-[var(--text-primary)]">{stats.total}</p>
<p className="text-xs text-[var(--text-muted)] mt-1">Total Builds</p>
</div>
<div className="panel p-4">
<div className="flex items-center justify-between mb-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--warning-soft)] flex items-center justify-center">
<Loader2 size={16} className="text-[var(--warning)]" />
</div>
</div>
<p className="text-2xl font-semibold text-[var(--warning)]">{stats.running}</p>
<p className="text-xs text-[var(--text-muted)] mt-1">In Progress</p>
</div>
<div className="panel p-4">
<div className="flex items-center justify-between mb-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--success-soft)] flex items-center justify-center">
<Check size={16} className="text-[var(--success)]" />
</div>
</div>
<p className="text-2xl font-semibold text-[var(--success)]">{stats.success}</p>
<p className="text-xs text-[var(--text-muted)] mt-1">Successful</p>
</div>
<div className="panel p-4">
<div className="flex items-center justify-between mb-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--error-soft)] flex items-center justify-center">
<X size={16} className="text-[var(--error)]" />
</div>
</div>
<p className="text-2xl font-semibold text-[var(--error)]">{stats.failed}</p>
<p className="text-xs text-[var(--text-muted)] mt-1">Failed</p>
</div>
</div>
</div>
{/* Filters */}
<div className="mx-auto w-full max-w-[1400px] px-6">
<div className="panel p-4">
<div className="flex items-center gap-2 mb-4">
<Filter size={16} className="text-[var(--text-tertiary)]" />
<span className="text-sm font-medium text-[var(--text-secondary)]">Filters</span>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Status
</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as '' | BuildStatus)}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
>
{statusOptions.map((option) => (
<option key={option.label} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Project ID
</label>
<input
value={projectFilter}
onChange={(e) => setProjectFilter(e.target.value)}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
placeholder="project-123"
/>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Service ID
</label>
<input
value={serviceFilter}
onChange={(e) => setServiceFilter(e.target.value)}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
placeholder="service-abc"
/>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Page Size
</label>
<select
value={String(limit)}
onChange={(e) => setLimit(Number(e.target.value))}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
>
<option value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</div>
</div>
</div>
{/* Build Table */}
<div className="mx-auto w-full max-w-[1400px] px-6 py-6">
<div className="panel overflow-hidden">
{!isDemoMode && buildsQuery.isLoading ? (
<div className="p-12 text-center">
<Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
<p className="mt-3 text-sm text-[var(--text-muted)]">Loading builds...</p>
</div>
) : null}
{!isDemoMode && buildsQuery.isError ? (
<div className="p-8 text-center">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center">
<X size={24} className="text-[var(--error)]" />
</div>
<p className="text-sm text-[var(--error)]">Failed to load builds</p>
<p className="text-xs text-[var(--text-muted)] mt-1">{buildsQuery.error instanceof Error ? buildsQuery.error.message : 'Unknown error'}</p>
</div>
) : null}
{builds.length === 0 && !buildsQuery.isLoading ? (
<div className="p-12 text-center">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--surface-muted)] flex items-center justify-center">
<Box size={24} className="text-[var(--text-tertiary)]" />
</div>
<p className="text-sm text-[var(--text-muted)]">No builds match current filters</p>
</div>
) : null}
{builds.length > 0 ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 p-4">
{builds.map((build) => (
<div
key={build.id}
className="panel p-4 group hover:border-[var(--accent-primary)]/30 transition-all duration-300 card-lift cursor-pointer"
onClick={() => setSelectedBuild(build)}
>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${
build.status === 'success'
? 'bg-[var(--success-soft)]'
: build.status === 'failed'
? 'bg-[var(--error-soft)]'
: build.status === 'running'
? 'bg-[var(--warning-soft)]'
: 'bg-[var(--surface-muted)]'
}`}>
{build.status === 'success' && <Check size={18} className="text-[var(--success)]" />}
{build.status === 'failed' && <X size={18} className="text-[var(--error)]" />}
{build.status === 'running' && <Loader2 size={18} className="text-[var(--warning)] animate-spin" />}
{build.status === 'pending' && <Clock size={18} className="text-[var(--text-tertiary)]" />}
{build.status === 'cancelled' && <X size={18} className="text-[var(--text-tertiary)]" />}
</div>
<div>
<p className="mono text-sm font-medium text-[var(--text-primary)]">{build.id}</p>
<div className="flex items-center gap-2 mt-1">
<StatusBadge status={build.status} />
<span className="text-xs text-[var(--text-tertiary)]">
{build.startedAt ? formatRelative(build.startedAt) : '—'}
</span>
</div>
</div>
</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<button className="p-2 rounded-lg hover:bg-[var(--surface-muted)] text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-all">
<FileText size={14} />
</button>
</div>
</div>
<div className="space-y-3">
{/* Image info */}
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--text-tertiary)]">Image</span>
<span className="mono text-[var(--text-secondary)]">
{build.imageName || '—'}:{build.imageTag || 'latest'}
</span>
</div>
{/* Progress bar */}
{(build.status === 'running' || build.status === 'pending') && (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--text-tertiary)]">Progress</span>
<span className="text-[var(--text-secondary)] font-medium">{build.progress}%</span>
</div>
<div className="h-2 rounded-full bg-[var(--surface-muted)] overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{ width: `${Math.max(0, Math.min(100, build.progress))}%`, background: '#e8316a' }}
/>
</div>
</div>
)}
{/* Size and service */}
<div className="flex items-center justify-between text-xs pt-2 border-t border-[var(--border-subtle)]">
<div className="flex items-center gap-1.5 text-[var(--text-tertiary)]">
<HardDrive size={10} />
<span>{bytesToHumanReadable(build.size)}</span>
</div>
<span className="mono text-[var(--text-tertiary)]">{build.serviceId || '—'}</span>
</div>
</div>
{/* Error message if present */}
{build.error && (
<div className="mt-3 p-2 rounded-lg bg-[var(--error-soft)]/50 border border-[var(--error)]/20">
<p className="text-xs text-[var(--error)] line-clamp-2">{build.error}</p>
</div>
)}
</div>
))}
</div>
) : null}
</div>
</div>
{/* Error Toast */}
{cancelMutation.isError && (
<div className="fixed bottom-4 right-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] border border-[var(--error)]/20 text-sm text-[var(--error)] shadow-lg">
{cancelMutation.error instanceof Error ? cancelMutation.error.message : 'Failed to cancel build.'}
</div>
)}
{/* Logs Modal */}
{selectedBuild && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setSelectedBuild(null)} />
<div className="relative w-full max-w-3xl panel p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<FileText size={20} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Build Logs</h2>
<p className="mono text-xs text-[var(--text-tertiary)]">{selectedBuild.id}</p>
</div>
</div>
<button
onClick={() => setSelectedBuild(null)}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
>
Close
</button>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] p-4 max-h-[400px] overflow-auto">
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap break-all">
{isDemoMode
? selectedBuild.log || '[demo] No logs available.'
: logsQuery.isLoading
? 'Loading logs...'
: logsQuery.isError
? 'Failed to load logs.'
: logsQuery.data || '(empty logs)'}
</pre>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,408 @@
import { useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { createProject, listProjects, type ProjectEntity, type ProjectStats } from '@/lib/api-client';
import {
Plus,
Search,
ArrowRight,
Layers,
Clock,
Sparkles,
FolderOpen,
X,
Check,
AlertTriangle
} from 'lucide-react';
const demoProjects: ProjectEntity[] = [
{
id: 'project-demo',
name: 'Demo Project',
description: 'Sample project with mock services for UI preview.',
createdAt: new Date(Date.now() - 14 * 86_400_000).toISOString(),
updatedAt: new Date().toISOString(),
stats: { service_count: 3, deployment_count: 12, running_services: 2 },
},
{
id: 'project-staging',
name: 'Staging Environment',
description: 'Pre-production environment for testing new releases.',
createdAt: new Date(Date.now() - 7 * 86_400_000).toISOString(),
updatedAt: new Date(Date.now() - 2 * 86_400_000).toISOString(),
stats: { service_count: 1, deployment_count: 5, running_services: 1 },
},
];
function getHealthStatus(stats: ProjectStats): 'healthy' | 'degraded' | 'critical' {
if (stats.service_count === 0) return 'healthy';
if (stats.running_services === stats.service_count) return 'healthy';
if (stats.running_services >= stats.service_count / 2) return 'degraded';
return 'critical';
}
function healthConfig(health: ReturnType<typeof getHealthStatus>) {
switch (health) {
case 'healthy':
return {
label: 'Operational',
color: 'var(--success)',
bg: 'var(--success-soft)',
Icon: Check
};
case 'degraded':
return {
label: 'Degraded',
color: 'var(--warning)',
bg: 'var(--warning-soft)',
Icon: AlertTriangle
};
case 'critical':
return {
label: 'Critical',
color: 'var(--error)',
bg: 'var(--error-soft)',
Icon: X
};
}
}
function formatRelative(date?: string): string {
if (!date) return '—';
const now = Date.now();
const then = new Date(date).getTime();
const diff = now - then;
if (diff < 60_000) return 'just now';
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
return `${Math.floor(diff / 86_400_000)}d ago`;
}
function ProjectCard({ project, href }: { project: ProjectEntity; href: string }) {
const navigate = useNavigate();
const health = getHealthStatus(project.stats);
const config = healthConfig(health);
const Icon = config.Icon;
const healthPercent = project.stats.service_count === 0
? 100
: Math.round((project.stats.running_services / project.stats.service_count) * 100);
return (
<article
className="group panel p-0 overflow-hidden cursor-pointer transition-all duration-300 hover:shadow-[var(--shadow-lg)] hover:border-[var(--border-default)]"
onClick={() => navigate(href)}
>
{/* Header with solid accent */}
<div className="relative px-5 pt-5 pb-4">
<div className="absolute inset-0 h-28 bg-[#e8316a]/10" />
<div className="relative flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h3 className="font-headline text-lg font-semibold tracking-tight text-[var(--text-primary)] truncate group-hover:text-[var(--accent-primary)] transition-colors">
{project.name}
</h3>
<p className="mt-1.5 text-sm text-[var(--text-secondary)] line-clamp-2 leading-relaxed">
{project.description || 'No description provided'}
</p>
</div>
<div
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-full text-xs font-medium ring-1 ring-inset"
style={{ background: config.bg, color: config.color, borderColor: `${config.color}30` }}
>
<Icon size={12} />
{config.label}
</div>
</div>
</div>
{/* Service Topology Preview */}
<div className="px-5 py-3 border-y border-[var(--border-subtle)] bg-[var(--surface-muted)]/30">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(project.stats.service_count, 5) }).map((_, i) => (
<div
key={i}
className="w-8 h-8 rounded-lg bg-[var(--surface-card)] border border-[var(--border-subtle)] flex items-center justify-center transition-colors group-hover:border-[var(--border-default)]"
>
<Layers size={14} className="text-[var(--text-tertiary)]" />
</div>
))}
{project.stats.service_count > 5 && (
<div className="w-8 h-8 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)] flex items-center justify-center text-xs text-[var(--text-tertiary)] font-medium">
+{project.stats.service_count - 5}
</div>
)}
</div>
<span className="text-xs text-[var(--text-tertiary)] font-medium">
{project.stats.service_count} service{project.stats.service_count !== 1 ? 's' : ''}
</span>
</div>
</div>
{/* Stats Grid */}
<div className="p-5">
<div className="grid grid-cols-3 gap-4">
<div>
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)] font-medium">Services</p>
<p className="mt-1.5 text-xl font-semibold text-[var(--text-primary)]">{project.stats.service_count}</p>
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)] font-medium">Running</p>
<p className="mt-1.5 text-xl font-semibold text-[var(--success)]">{project.stats.running_services}</p>
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)] font-medium">Health</p>
<p className="mt-1.5 text-xl font-semibold text-[var(--text-primary)]">{healthPercent}%</p>
</div>
</div>
{/* Health Progress Bar */}
<div className="mt-4">
<div className="h-1.5 rounded-full bg-[var(--surface-muted)] overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${healthPercent}%`,
background: config.color
}}
/>
</div>
</div>
{/* Footer */}
<div className="mt-4 flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)]">
<Clock size={12} />
<span>Updated {formatRelative(project.updatedAt)}</span>
</div>
<div className="flex items-center gap-1 text-xs font-medium text-[var(--accent-primary)] opacity-0 group-hover:opacity-100 transition-all duration-200 translate-x-2 group-hover:translate-x-0">
<span>Open</span>
<ArrowRight size={12} />
</div>
</div>
</div>
</article>
);
}
export function ProjectsPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const queryClient = useQueryClient();
const isDemoMode = searchParams.get('demo') === '1';
const [search, setSearch] = useState('');
const [isCreateOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ name: '', description: '' });
const projectHref = (projectId: string) =>
isDemoMode ? `/projects/${projectId}?demo=1` : `/projects/${projectId}`;
const projectsQuery = useQuery({
queryKey: ['projects'],
enabled: !isDemoMode,
queryFn: listProjects,
});
const createProjectMutation = useMutation({
mutationFn: () => createProject({ name: form.name.trim(), description: form.description.trim() || undefined }),
onSuccess: (project) => {
setCreateOpen(false);
setForm({ name: '', description: '' });
queryClient.invalidateQueries({ queryKey: ['projects'] });
navigate(projectHref(project.id));
},
});
const filteredProjects = useMemo(() => {
const source = isDemoMode ? demoProjects : projectsQuery.data ?? [];
if (!search.trim()) return source;
const needle = search.toLowerCase();
return source.filter((project) =>
project.name.toLowerCase().includes(needle) ||
project.description?.toLowerCase().includes(needle)
);
}, [isDemoMode, projectsQuery.data, search]);
return (
<div className="min-h-screen relative">
{/* Hero Section - Premium ambient design */}
<div className="relative overflow-hidden border-b border-[var(--border-subtle)]">
{/* Solid ambient background */}
<div className="absolute inset-0 bg-[#e8316a]/5" />
<div className="absolute top-0 left-1/4 w-96 h-96 bg-[var(--accent-primary)]/5 rounded-full blur-3xl" />
<div className="absolute bottom-0 right-1/4 w-80 h-80 bg-[var(--accent-secondary)]/5 rounded-full blur-3xl" />
<div className="relative mx-auto w-full max-w-[1400px] px-6 py-12 md:py-16 lg:py-20">
<div className="max-w-2xl">
<div className="flex items-center gap-3 mb-4">
<div
className="flex items-center justify-center rounded-xl shadow-lg ring-1 ring-white/10"
style={{ width: '44px', height: '44px', background: '#e8316a' }}
>
<FolderOpen size={20} className="text-white" />
</div>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
<div className="w-1.5 h-1.5 rounded-full bg-[var(--success)] live-pulse" />
<span className="text-[10px] font-semibold uppercase tracking-widest text-[var(--text-tertiary)]">
Workspace
</span>
</div>
</div>
<h1 className="font-headline text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight text-[var(--text-primary)]">
Projects
</h1>
<p className="mt-4 text-lg text-[var(--text-secondary)] max-w-xl leading-relaxed">
Deploy, manage, and monitor your containerized services with visual topology mapping and real-time observability.
</p>
</div>
</div>
</div>
{/* Main Content */}
<div className="mx-auto w-full max-w-[1400px] px-6 py-8">
{/* Toolbar */}
<div className="flex flex-col sm:flex-row gap-4 mb-8">
<div className="relative flex-1">
<Search size={16} className="absolute left-4 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)]" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search projects by name or description..."
className="w-full h-11 pl-11 pr-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-card)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
/>
</div>
<button
onClick={() => setCreateOpen(true)}
className="flex items-center justify-center gap-2 h-11 px-5 rounded-[var(--radius-md)] text-white font-medium text-sm shadow-lg hover:shadow-xl transition-all duration-300"
style={{ background: '#e8316a' }}
>
<Plus size={16} />
<span>New Project</span>
</button>
</div>
{/* Demo Mode Banner */}
{isDemoMode && (
<div className="mb-6 px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50">
<div className="flex items-center gap-2 text-sm text-[var(--warning)]">
<Sparkles size={16} />
<span>Demo mode active using sample data for preview</span>
</div>
</div>
)}
{/* Loading State */}
{!isDemoMode && projectsQuery.isLoading && (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="panel h-[320px] animate-pulse bg-[var(--surface-card)]" />
))}
</div>
)}
{/* Error State */}
{!isDemoMode && projectsQuery.isError && (
<div className="panel p-8 text-center">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center">
<X size={24} className="text-[var(--error)]" />
</div>
<p className="text-lg font-medium text-[var(--text-primary)]">Failed to load projects</p>
<p className="mt-2 text-sm text-[var(--text-secondary)]">Check API connectivity and try again</p>
</div>
)}
{/* Empty State */}
{((isDemoMode && filteredProjects.length === 0) || (!isDemoMode && !projectsQuery.isLoading && !projectsQuery.isError && filteredProjects.length === 0)) && (
<div className="panel p-12 text-center">
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-[var(--accent-primary-soft)] flex items-center justify-center">
<FolderOpen size={28} className="text-[var(--accent-primary)]" />
</div>
<p className="text-xl font-semibold text-[var(--text-primary)]">No projects yet</p>
<p className="mt-2 text-[var(--text-secondary)] max-w-md mx-auto">
Create your first project to start deploying services with visual topology management.
</p>
<button
onClick={() => setCreateOpen(true)}
className="mt-6 inline-flex items-center gap-2 px-5 py-2.5 rounded-[var(--radius-md)] border border-[var(--accent-primary)] text-[var(--accent-primary)] font-medium text-sm hover:bg-[var(--accent-primary-soft)] transition-colors"
>
<Plus size={16} />
Create Project
</button>
</div>
)}
{/* Project Grid */}
{((isDemoMode && filteredProjects.length > 0) || (!isDemoMode && !projectsQuery.isLoading && !projectsQuery.isError && filteredProjects.length > 0)) && (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{filteredProjects.map((project) => (
<ProjectCard key={project.id} project={project} href={projectHref(project.id)} />
))}
</div>
)}
</div>
{/* Create Modal */}
{isCreateOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={() => setCreateOpen(false)} />
<div className="relative w-full max-w-lg panel p-6">
<h2 className="text-xl font-semibold text-[var(--text-primary)]">Create new project</h2>
<p className="mt-1 text-sm text-[var(--text-secondary)]">
Projects organize your services and provide a visual canvas for topology management.
</p>
<div className="mt-6 space-y-4">
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Project Name
</label>
<input
value={form.name}
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
placeholder="my-awesome-project"
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
/>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Description <span className="normal-case text-[var(--text-muted)]">(optional)</span>
</label>
<textarea
value={form.description}
onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))}
placeholder="Describe your project..."
rows={3}
className="w-full px-4 py-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all resize-none"
/>
</div>
</div>
{createProjectMutation.isError && (
<div className="mt-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
{(createProjectMutation.error as Error).message}
</div>
)}
<div className="mt-6 flex justify-end gap-3">
<button
onClick={() => setCreateOpen(false)}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-[var(--text-secondary)] text-sm font-medium hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors"
>
Cancel
</button>
<button
onClick={() => createProjectMutation.mutate()}
disabled={!form.name.trim() || createProjectMutation.isPending}
className="px-5 py-2 rounded-[var(--radius-md)] text-white text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all"
style={{ background: '#e8316a' }}
>
{createProjectMutation.isPending ? 'Creating...' : 'Create Project'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,763 @@
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import {
getApiBaseUrl,
getCurrentUserProfile,
listAuditLogs,
listBuilds,
listProjects,
listTemplates,
updateCurrentUserProfile,
} from '@/lib/api-client';
import { getAuthBaseUrl, signOutAuthSession } from '@/lib/auth-client';
import { useBuildUpdates } from '@/lib/use-build-updates';
import {
Clock,
Activity,
Gauge,
Users,
Shield,
FileText,
Settings,
User,
Key,
Database,
RefreshCw,
Trash2,
LogOut,
Check,
AlertCircle,
Loader2,
BookOpen,
Folder,
Box,
Terminal,
Radio,
} from 'lucide-react';
function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
return (
<div className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/50 backdrop-blur-sm">
<div className="mx-auto w-full max-w-[1400px] px-6 py-4">
<h1 className="text-xl font-semibold text-[var(--text-primary)]">{title}</h1>
<p className="text-sm text-[var(--text-secondary)] mt-1">{description}</p>
</div>
</div>
);
}
function StatCard({
title,
value,
description,
icon: Icon,
color = 'default',
}: {
title: string;
value: string;
description: string;
icon: typeof Clock;
color?: 'default' | 'success' | 'warning' | 'error';
}) {
const colorClasses = {
default: 'text-[var(--text-primary)]',
success: 'text-[var(--success)]',
warning: 'text-[var(--warning)]',
error: 'text-[var(--error)]',
};
return (
<div className="panel p-5">
<div className="flex items-start justify-between mb-3">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--surface-muted)] flex items-center justify-center">
<Icon size={18} className="text-[var(--text-tertiary)]" />
</div>
</div>
<p className={`text-2xl font-semibold ${colorClasses[color]}`}>{value}</p>
<p className="text-xs font-medium text-[var(--text-muted)] mt-1">{title}</p>
<p className="text-xs text-[var(--text-tertiary)] mt-2">{description}</p>
</div>
);
}
type LocalStorageSummary = {
canvasKeys: string[];
totalKeys: number;
};
function getLocalStorageSummary(): LocalStorageSummary {
if (typeof window === 'undefined') {
return { canvasKeys: [], totalKeys: 0 };
}
const canvasKeys: string[] = [];
for (let index = 0; index < localStorage.length; index += 1) {
const key = localStorage.key(index);
if (key && key.startsWith('containr.canvas.v1.')) {
canvasKeys.push(key);
}
}
canvasKeys.sort((left, right) => left.localeCompare(right));
return {
canvasKeys,
totalKeys: localStorage.length,
};
}
function endpointStateBadge(isLoading: boolean, isError: boolean): {
label: string;
toneClass: string;
} {
if (isLoading) {
return { label: 'Loading', toneClass: 'text-[var(--warn)]' };
}
if (isError) {
return { label: 'Unavailable', toneClass: 'text-[var(--bad)]' };
}
return { label: 'Available', toneClass: 'text-[var(--ok)]' };
}
export function UsagePage() {
const queryClient = useQueryClient();
const buildsQuery = useQuery({
queryKey: ['usage-builds'],
queryFn: () => listBuilds({ page: 1, limit: 100 }),
});
const builds = buildsQuery.data?.builds ?? [];
const liveConnected = useBuildUpdates(
builds.map((build) => build.id),
() => {
queryClient.invalidateQueries({ queryKey: ['usage-builds'] });
},
);
const successfulBuilds = builds.filter((build) => build.status === 'success').length;
const failedBuilds = builds.filter((build) => build.status === 'failed').length;
const runningBuilds = builds.filter((build) => build.status === 'running').length;
const pendingBuilds = builds.filter((build) => build.status === 'pending').length;
const completedDurationsMs = builds
.filter((build) => Boolean(build.startedAt && build.completedAt))
.map((build) => {
const started = new Date(build.startedAt as string).getTime();
const completed = new Date(build.completedAt as string).getTime();
return Math.max(0, completed - started);
})
.filter((duration) => Number.isFinite(duration) && duration > 0);
const totalBuildDurationMs = completedDurationsMs.reduce((sum, duration) => sum + duration, 0);
const averageBuildDurationMs =
completedDurationsMs.length > 0 ? totalBuildDurationMs / completedDurationsMs.length : 0;
const totalBuildHours = totalBuildDurationMs / 3_600_000;
const avgBuildMinutes = averageBuildDurationMs / 60_000;
const activeBuildPressure = runningBuilds + pendingBuilds;
const uniqueServices = new Set(builds.map((build) => build.serviceId).filter(Boolean)).size;
let buildActivityBody = 'Track deployment frequency and failed rollouts over time.';
let runtimeBody = 'Build duration telemetry will appear after completed builds are available.';
let capacityBody = 'No active build pressure detected.';
if (buildsQuery.isLoading) {
buildActivityBody = 'Loading recent build activity from Containr API.';
runtimeBody = 'Loading build runtime metrics from Containr API.';
capacityBody = 'Loading active queue depth and service fan-out.';
} else if (buildsQuery.isError) {
buildActivityBody = 'Unable to fetch build telemetry right now. Check API connectivity and auth.';
runtimeBody = 'Unable to calculate runtime metrics while build telemetry is unavailable.';
capacityBody = 'Unable to evaluate queue pressure while build telemetry is unavailable.';
} else if (builds.length > 0) {
buildActivityBody = `${builds.length} recent builds, ${successfulBuilds} successful, ${failedBuilds} failed${runningBuilds > 0 ? `, ${runningBuilds} running` : ''}.`;
if (completedDurationsMs.length > 0) {
runtimeBody = `${totalBuildHours.toFixed(1)}h total build runtime across ${completedDurationsMs.length} completed builds (avg ${avgBuildMinutes.toFixed(1)}m).`;
} else {
runtimeBody = 'Builds exist, but completed duration samples are not available yet.';
}
if (activeBuildPressure > 0) {
capacityBody = `${activeBuildPressure} active jobs in queue (${runningBuilds} running, ${pendingBuilds} pending) across ${uniqueServices} service${uniqueServices === 1 ? '' : 's'}.`;
} else {
capacityBody = `No pending or running jobs. Last sampled ${builds.length} builds touched ${uniqueServices} service${uniqueServices === 1 ? '' : 's'}.`;
}
} else {
buildActivityBody = 'No builds recorded yet. Trigger a service deployment to populate this view.';
runtimeBody = 'No completed builds yet, so runtime totals are not available.';
capacityBody = 'Queue depth is empty because no build history is available yet.';
}
return (
<div className="min-h-screen">
<SecondaryPageHeader
title="Usage"
description="Platform usage metrics and operational summaries"
/>
<div className="mx-auto w-full max-w-[1400px] px-6 py-6">
<div className="flex items-center gap-2 mb-6">
<div className={`w-2 h-2 rounded-full ${liveConnected ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
<span className={`text-sm ${liveConnected ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}`}>
{liveConnected ? 'Live sync active' : 'Offline'}
</span>
</div>
{buildsQuery.isLoading ? (
<div className="py-16 text-center">
<Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
<p className="mt-3 text-sm text-[var(--text-muted)]">Loading usage data...</p>
</div>
) : buildsQuery.isError ? (
<div className="panel p-8 text-center">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center">
<AlertCircle size={24} className="text-[var(--error)]" />
</div>
<p className="text-sm text-[var(--error)]">Failed to load usage data</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<StatCard
title="Runtime Hours"
value={totalBuildHours > 0 ? `${totalBuildHours.toFixed(1)}h` : '—'}
description={runtimeBody}
icon={Clock}
color={completedDurationsMs.length > 0 ? 'success' : 'default'}
/>
<StatCard
title="Build Activity"
value={String(builds.length)}
description={buildActivityBody}
icon={Activity}
color={failedBuilds > 0 ? 'warning' : 'success'}
/>
<StatCard
title="Capacity"
value={String(activeBuildPressure)}
description={capacityBody}
icon={Gauge}
color={activeBuildPressure > 0 ? 'warning' : 'default'}
/>
</div>
)}
</div>
</div>
);
}
export function PeoplePage() {
const profileQuery = useQuery({
queryKey: ['user-profile'],
queryFn: getCurrentUserProfile,
});
const projectsQuery = useQuery({
queryKey: ['people-projects'],
queryFn: listProjects,
});
const auditLogsQuery = useQuery({
queryKey: ['people-audit-logs'],
queryFn: () => listAuditLogs({ page: 1, limit: 20 }),
});
let membersBody = 'Owner-only mode currently. Multi-user collaboration is planned.';
if (profileQuery.isLoading) {
membersBody = 'Loading authenticated profile from Containr API.';
} else if (profileQuery.isError) {
membersBody = 'Unable to fetch current user profile. Verify API connectivity and Better Auth session.';
} else if (profileQuery.data) {
membersBody = `${profileQuery.data.name} (${profileQuery.data.email}) is currently authenticated as platform owner.`;
}
let rolesBody = 'Predefined roles will map to service and project permissions.';
if (projectsQuery.isLoading) {
rolesBody = 'Loading project access footprint for current user.';
} else if (projectsQuery.isError) {
rolesBody = 'Project visibility check failed. Role scope cannot be calculated right now.';
} else {
const projectCount = projectsQuery.data?.length ?? 0;
rolesBody = `Current account can access ${projectCount} project${projectCount === 1 ? '' : 's'} in owner mode.`;
}
let auditBody = 'Human-readable action log for project and service changes.';
if (auditLogsQuery.isLoading) {
auditBody = 'Loading recent audit events from Containr API.';
} else if (auditLogsQuery.isError) {
auditBody = 'Unable to load recent audit events. Verify API connectivity and Better Auth session.';
} else {
const auditLogs = auditLogsQuery.data ?? [];
if (auditLogs.length > 0) {
const latest = auditLogs[0];
auditBody = `${auditLogs.length} recent events. Latest action: ${latest.action} on ${latest.resource}${
latest.resourceId ? ` (${latest.resourceId})` : ''
}.`;
} else {
auditBody = 'No audit events recorded yet for this account.';
}
}
return (
<div className="min-h-screen">
<SecondaryPageHeader
title="People"
description="Team management and access control"
/>
<div className="mx-auto w-full max-w-[1400px] px-6 py-6">
{profileQuery.isLoading ? (
<div className="py-16 text-center">
<Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
<p className="mt-3 text-sm text-[var(--text-muted)]">Loading team data...</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<StatCard
title="Members"
value={profileQuery.data ? '1' : '—'}
description={membersBody}
icon={Users}
color={profileQuery.data ? 'success' : 'default'}
/>
<StatCard
title="Roles"
value={projectsQuery.data?.length ? String(projectsQuery.data.length) : '—'}
description={rolesBody}
icon={Shield}
color="default"
/>
<StatCard
title="Audit Trail"
value={auditLogsQuery.data ? String(auditLogsQuery.data.length) : '—'}
description={auditBody}
icon={FileText}
color={auditLogsQuery.data?.length ? 'success' : 'default'}
/>
</div>
)}
</div>
</div>
);
}
export function SettingsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const profileQuery = useQuery({
queryKey: ['settings-profile'],
queryFn: getCurrentUserProfile,
});
const [nameDraft, setNameDraft] = useState<string | null>(null);
const [avatarDraft, setAvatarDraft] = useState<string | null>(null);
const [storageSummary, setStorageSummary] = useState<LocalStorageSummary>(() =>
getLocalStorageSummary(),
);
const resolvedNameDraft = nameDraft ?? profileQuery.data?.name ?? '';
const resolvedAvatarDraft = avatarDraft ?? profileQuery.data?.avatarUrl ?? '';
const hasProfileChanges = useMemo(() => {
if (!profileQuery.data) {
return false;
}
return (
resolvedNameDraft.trim() !== profileQuery.data.name ||
resolvedAvatarDraft.trim() !== (profileQuery.data.avatarUrl ?? '')
);
}, [profileQuery.data, resolvedAvatarDraft, resolvedNameDraft]);
const updateProfileMutation = useMutation({
mutationFn: () =>
updateCurrentUserProfile({
name: resolvedNameDraft.trim(),
avatarUrl: resolvedAvatarDraft.trim() || undefined,
}),
onSuccess: (profile) => {
queryClient.setQueryData(['settings-profile'], profile);
queryClient.setQueryData(['user-profile'], profile);
setNameDraft(null);
setAvatarDraft(null);
},
});
const refreshStorage = () => {
setStorageSummary(getLocalStorageSummary());
};
const clearCanvasCache = () => {
for (const key of storageSummary.canvasKeys) {
localStorage.removeItem(key);
}
refreshStorage();
};
const signOutLocalSession = async () => {
try {
await signOutAuthSession();
} catch {
// Continue with local cleanup even if remote sign-out call fails.
}
queryClient.removeQueries();
await queryClient.invalidateQueries({ queryKey: ['auth-session'] });
navigate('/auth/sign-in', { replace: true });
refreshStorage();
};
return (
<div className="min-h-screen">
<SecondaryPageHeader
title="Settings"
description="Manage account profile and local configuration"
/>
<div className="mx-auto w-full max-w-[1400px] px-6 py-6">
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Profile Section */}
<section className="panel p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<User size={18} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Profile</h2>
<p className="text-xs text-[var(--text-tertiary)]">Backed by GET/PUT /user/profile</p>
</div>
</div>
{profileQuery.isLoading ? (
<div className="py-8 text-center">
<Loader2 size={20} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
</div>
) : profileQuery.isError ? (
<div className="px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
Failed to load profile
</div>
) : (
<div className="space-y-4">
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Name
</label>
<input
value={resolvedNameDraft}
onChange={(e) => setNameDraft(e.target.value)}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
placeholder="Your name"
/>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Avatar URL
</label>
<input
value={resolvedAvatarDraft}
onChange={(e) => setAvatarDraft(e.target.value)}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
placeholder="https://example.com/avatar.png"
/>
</div>
<div className="flex items-center gap-3 pt-2">
<button
onClick={() => updateProfileMutation.mutate()}
disabled={!hasProfileChanges || updateProfileMutation.isPending}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-white text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all"
style={{ background: '#e8316a' }}
>
{updateProfileMutation.isPending ? (
<>
<Loader2 size={14} className="animate-spin" />
Saving...
</>
) : (
<>
<Check size={14} />
Save Profile
</>
)}
</button>
<button
onClick={() => profileQuery.refetch()}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
>
<RefreshCw size={14} />
Refresh
</button>
</div>
{updateProfileMutation.isError && (
<div className="px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
{updateProfileMutation.error instanceof Error ? updateProfileMutation.error.message : 'Failed to update profile'}
</div>
)}
{updateProfileMutation.isSuccess && (
<div className="px-4 py-3 rounded-[var(--radius-md)] bg-[var(--success-soft)] text-sm text-[var(--success)] flex items-center gap-2">
<Check size={16} />
Profile updated successfully
</div>
)}
</div>
)}
</section>
{/* Runtime & Local State Section */}
<section className="panel p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--surface-muted)] flex items-center justify-center">
<Settings size={18} className="text-[var(--text-tertiary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Runtime & Local State</h2>
<p className="text-xs text-[var(--text-tertiary)]">API endpoints, cookie-session mode, and storage diagnostics</p>
</div>
</div>
<div className="space-y-3">
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
<div className="flex items-center gap-2 mb-1">
<Key size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">API Base</span>
</div>
<p className="mono text-xs text-[var(--text-primary)] break-all">{getApiBaseUrl()}</p>
</div>
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
<div className="flex items-center gap-2 mb-1">
<Key size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Auth Base</span>
</div>
<p className="mono text-xs text-[var(--text-primary)] break-all">{getAuthBaseUrl()}</p>
</div>
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
<div className="flex items-center gap-2 mb-1">
<Shield size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Session Mode</span>
</div>
<p className="text-xs text-[var(--text-primary)]">HttpOnly Better Auth cookie (no local auth token)</p>
</div>
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
<div className="flex items-center gap-2 mb-1">
<Database size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Canvas Cache</span>
</div>
<p className="text-xs text-[var(--text-primary)]">
{storageSummary.canvasKeys.length} cached canvas entries ({storageSummary.totalKeys} total keys)
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3 mt-4">
<button
onClick={refreshStorage}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
>
<RefreshCw size={14} />
Refresh
</button>
<button
onClick={clearCanvasCache}
disabled={storageSummary.canvasKeys.length === 0}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] border border-[var(--warning-soft)] text-[var(--warning)] text-sm font-medium hover:bg-[var(--warning-soft)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Trash2 size={14} />
Clear Cache
</button>
<button
onClick={() => {
void signOutLocalSession();
}}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] border border-[var(--error-soft)] text-[var(--error)] text-sm font-medium hover:bg-[var(--error-soft)] transition-colors"
>
<LogOut size={14} />
Sign Out
</button>
</div>
{storageSummary.canvasKeys.length > 0 && (
<div className="mt-4 p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">Cached Keys</p>
<ul className="space-y-1">
{storageSummary.canvasKeys.slice(0, 5).map((key) => (
<li key={key} className="mono text-xs text-[var(--text-tertiary)] truncate">
{key}
</li>
))}
{storageSummary.canvasKeys.length > 5 && (
<li className="text-xs text-[var(--text-muted)]">
+{storageSummary.canvasKeys.length - 5} more...
</li>
)}
</ul>
</div>
)}
</section>
</div>
</div>
</div>
);
}
export function DocsPage() {
const profileQuery = useQuery({
queryKey: ['docs-profile'],
queryFn: getCurrentUserProfile,
});
const projectsQuery = useQuery({
queryKey: ['docs-projects'],
queryFn: listProjects,
});
const templatesQuery = useQuery({
queryKey: ['docs-templates'],
queryFn: () => listTemplates(),
});
const buildsQuery = useQuery({
queryKey: ['docs-builds'],
queryFn: () => listBuilds({ page: 1, limit: 1 }),
});
const profileStatus = endpointStateBadge(profileQuery.isLoading, profileQuery.isError);
const projectStatus = endpointStateBadge(projectsQuery.isLoading, projectsQuery.isError);
const templateStatus = endpointStateBadge(templatesQuery.isLoading, templatesQuery.isError);
const buildStatus = endpointStateBadge(buildsQuery.isLoading, buildsQuery.isError);
return (
<div className="min-h-screen">
<SecondaryPageHeader
title="Docs"
description="Operational references and API documentation"
/>
<div className="mx-auto w-full max-w-[1400px] px-6 py-6">
{/* API Status Grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="panel p-4">
<div className="flex items-center gap-2 mb-2">
<User size={14} className={profileStatus.toneClass} />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Profile</span>
</div>
<p className={`text-sm font-semibold ${profileStatus.toneClass}`}>{profileStatus.label}</p>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
{profileQuery.data ? `${profileQuery.data.name} authenticated` : 'GET /user/profile'}
</p>
</div>
<div className="panel p-4">
<div className="flex items-center gap-2 mb-2">
<Folder size={14} className={projectStatus.toneClass} />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Projects</span>
</div>
<p className={`text-sm font-semibold ${projectStatus.toneClass}`}>{projectStatus.label}</p>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
{projectsQuery.data ? `${projectsQuery.data.length} projects` : 'GET /projects'}
</p>
</div>
<div className="panel p-4">
<div className="flex items-center gap-2 mb-2">
<Box size={14} className={templateStatus.toneClass} />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Templates</span>
</div>
<p className={`text-sm font-semibold ${templateStatus.toneClass}`}>{templateStatus.label}</p>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
{templatesQuery.data ? `${templatesQuery.data.length} templates` : 'GET /templates'}
</p>
</div>
<div className="panel p-4">
<div className="flex items-center gap-2 mb-2">
<Activity size={14} className={buildStatus.toneClass} />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Builds</span>
</div>
<p className={`text-sm font-semibold ${buildStatus.toneClass}`}>{buildStatus.label}</p>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
{buildsQuery.data ? `${buildsQuery.data.total} builds` : 'GET /builds'}
</p>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Repository Paths */}
<section className="panel p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<BookOpen size={18} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Repository Paths</h2>
<p className="text-xs text-[var(--text-tertiary)]">Source files and documentation</p>
</div>
</div>
<div className="space-y-3">
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
<div className="flex items-center gap-2 mb-1">
<FileText size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">API Contract</span>
</div>
<p className="mono text-xs text-[var(--text-primary)]">docs/api/openapi.yaml</p>
</div>
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
<div className="flex items-center gap-2 mb-1">
<FileText size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Generated Types</span>
</div>
<p className="mono text-xs text-[var(--text-primary)]">frontend/src/generated/api-types.ts</p>
</div>
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
<div className="flex items-center gap-2 mb-1">
<BookOpen size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Primary Guides</span>
</div>
<p className="mono text-xs text-[var(--text-primary)]">README.md DOCKER_SETUP.md docs/guides/</p>
</div>
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
<div className="flex items-center gap-2 mb-1">
<FileText size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">UI References</span>
</div>
<p className="mono text-xs text-[var(--text-primary)]">docs/references/self.html</p>
</div>
</div>
</section>
{/* Common Commands */}
<section className="panel p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--surface-muted)] flex items-center justify-center">
<Terminal size={18} className="text-[var(--text-tertiary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Common Commands</h2>
<p className="text-xs text-[var(--text-tertiary)]">Development and build scripts</p>
</div>
</div>
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)]">
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">
{`# Regenerate frontend API types
npm --prefix frontend run generate:api
# Frontend type-check + build
npm --prefix frontend run build:check
# Backend API tests
cd backend && go test ./internal/api/...`}
</pre>
</div>
<div className="mt-4 p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
<div className="flex items-center gap-2 mb-1">
<Radio size={14} className="text-[var(--text-tertiary)]" />
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Active API Base</span>
</div>
<p className="mono text-xs text-[var(--text-primary)] break-all">{getApiBaseUrl()}</p>
</div>
</section>
</div>
</div>
</div>
);
}
export { ComponentShowcase } from './pages/ComponentShowcase';
@@ -0,0 +1,472 @@
import { useState } from 'react';
import {
Cpu,
MemoryStick,
Zap,
Users,
Activity,
TrendingUp,
Database
} from 'lucide-react';
import {
LineChart,
LineAreaChart,
MultiLineChart,
DonutChart,
BarChart,
EnhancedMetricCard,
CacheMetricCard,
PerformanceMetricCard,
useToast
} from '@/shared/components';
// Sample data
const sampleLineData = [12, 19, 15, 25, 22, 30, 28, 35, 32, 38, 35, 42, 38, 45, 42, 48, 45, 52, 48, 55, 52, 58, 55, 60];
const sampleAreaData = [40, 45, 42, 50, 48, 55, 52, 60, 58, 65, 62, 70, 68, 75, 72];
const sampleBarData = [20, 35, 28, 45, 38, 52, 48, 60, 55, 68, 62, 75, 70, 82, 78, 88, 85, 92, 88, 95, 92, 98, 95, 100];
export function ComponentShowcase() {
const { showToast } = useToast();
const [activeTab, setActiveTab] = useState<'charts' | 'cards' | 'ui'>('charts');
const handleCardClick = (name: string) => {
showToast(`${name} clicked!`, 'info');
};
return (
<div className="min-h-screen p-8" style={{ background: '#16171c' }}>
<div className="ambient-glow" />
<div className="max-w-7xl mx-auto relative z-10">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-4">
<div
className="w-12 h-12 rounded-xl flex items-center justify-center"
style={{ background: '#e8316a' }}
>
<Activity size={24} className="text-white" />
</div>
<div>
<h1 className="text-3xl font-bold text-[var(--text-primary)]">
Component Showcase
</h1>
<p className="text-sm text-[var(--text-secondary)]">
Production-grade components matching self.html design
</p>
</div>
</div>
{/* Tabs */}
<div className="flex border-b border-[var(--border-subtle)] gap-1">
<button
onClick={() => setActiveTab('charts')}
className={`tab ${activeTab === 'charts' ? 'active' : ''}`}
>
<Activity size={14} />
Charts
</button>
<button
onClick={() => setActiveTab('cards')}
className={`tab ${activeTab === 'cards' ? 'active' : ''}`}
>
<Cpu size={14} />
Metric Cards
</button>
<button
onClick={() => setActiveTab('ui')}
className={`tab ${activeTab === 'ui' ? 'active' : ''}`}
>
<Zap size={14} />
UI Elements
</button>
</div>
</div>
{/* Charts Tab */}
{activeTab === 'charts' && (
<div className="space-y-8">
<section>
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Line Charts
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="panel p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
Basic Line Chart (CPU Style)
</h3>
<LineChart
data={sampleLineData}
color="#ff7043"
height={120}
showDots={true}
smooth={true}
/>
</div>
<div className="panel p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
Line Chart (No Dots)
</h3>
<LineChart
data={sampleLineData}
color="#6c8ef0"
height={120}
showDots={false}
smooth={true}
/>
</div>
</div>
</section>
<section>
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Area Charts
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="panel p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
Area Chart (Active Users Style)
</h3>
<LineAreaChart
data={sampleAreaData}
color="#e8316a"
fillOpacity={0.15}
height={150}
/>
</div>
<div className="panel p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
Area Chart (Success Color)
</h3>
<LineAreaChart
data={sampleAreaData}
color="#3dd68c"
fillOpacity={0.2}
height={150}
/>
</div>
</div>
</section>
<section>
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Multi-Line & Bar Charts
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="panel p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
Multi-Line Chart (Performance Style)
</h3>
<MultiLineChart
datasets={[
{ data: sampleLineData.map(v => v + 10), color: '#6c8ef0', fillOpacity: 0.15 },
{ data: sampleLineData, color: '#9c7ef0', fillOpacity: 0.15 }
]}
height={120}
/>
</div>
<div className="panel p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
Bar Chart (Timeline Style)
</h3>
<BarChart
data={sampleBarData}
color="#3dd68c"
height={120}
gap={2}
/>
</div>
</div>
</section>
<section>
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Donut Charts
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="panel p-6 flex flex-col items-center">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
RAM Usage (65%)
</h3>
<DonutChart percentage={65} color="#9c7ef0" size={160} />
</div>
<div className="panel p-6 flex flex-col items-center">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
Disk Usage (82%)
</h3>
<DonutChart percentage={82} color="#ff7043" size={160} />
</div>
<div className="panel p-6 flex flex-col items-center">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-4">
Network (45%)
</h3>
<DonutChart percentage={45} color="#6c8ef0" size={160} />
</div>
</div>
</section>
</div>
)}
{/* Metric Cards Tab */}
{activeTab === 'cards' && (
<div className="space-y-8">
<section>
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Standard Metric Cards
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
<EnhancedMetricCard
title="CPU Usage"
value="12%"
subtitle="Daily usage"
status="good"
statusText="Good"
icon={<Cpu size={16} />}
chart={<LineChart data={sampleLineData} color="#ff7043" height={76} />}
onClick={() => handleCardClick('CPU Usage')}
animationDelay={0.04}
/>
<EnhancedMetricCard
title="Memory"
value="65%"
subtitle="Container footprint"
status="average"
statusText="Average"
icon={<MemoryStick size={16} />}
chart={<LineChart data={sampleLineData.map(v => v + 20)} color="#9c7ef0" height={76} />}
onClick={() => handleCardClick('Memory')}
animationDelay={0.09}
/>
<EnhancedMetricCard
title="Requests"
value="2.4K"
subtitle="Last 60 minutes"
icon={<Zap size={16} />}
chart={<BarChart data={sampleBarData} color="#3dd68c" height={76} gap={2} />}
onClick={() => handleCardClick('Requests')}
animationDelay={0.14}
/>
</div>
</section>
<section>
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Horizontal Layout Cards
</h2>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<EnhancedMetricCard
title="Active Users"
value="475 K"
subtitle="Users active right now"
icon={<Users size={16} />}
chart={<LineAreaChart data={sampleAreaData} color="#e8316a" height={130} />}
details={
<div className="flex items-center gap-1.5 flex-wrap">
{['🇨🇳', '🇮🇩', '🇲🇲', '🇲🇾', '🇯🇵', '🇮🇳', '🇰🇷', '🇵🇭'].map((flag, i) => (
<span key={i} className="flag">{flag}</span>
))}
</div>
}
horizontal
onClick={() => handleCardClick('Active Users')}
animationDelay={0.04}
/>
<EnhancedMetricCard
title="Database Queries"
value="12.5K"
subtitle="Queries per minute"
icon={<Database size={16} />}
chart={<LineAreaChart data={sampleAreaData.map(v => v + 10)} color="#6c8ef0" height={130} />}
details={
<div className="flex items-center gap-3 text-xs">
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full bg-[var(--success)]" />
<span className="text-[var(--text-secondary)]">Read: 8.2K</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full bg-[var(--warning)]" />
<span className="text-[var(--text-secondary)]">Write: 4.3K</span>
</div>
</div>
}
horizontal
onClick={() => handleCardClick('Database Queries')}
animationDelay={0.09}
/>
</div>
</section>
<section>
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Specialized Cards
</h2>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<CacheMetricCard
totalMB={352}
cacheMB={212}
nonCacheMB={85}
onClick={() => handleCardClick('Cache')}
animationDelay={0.04}
/>
<PerformanceMetricCard
percentage={89}
upSpeed={10.4}
downSpeed={5.2}
datasets={[
{ data: sampleLineData.map(v => v + 20), color: '#6c8ef0', fillOpacity: 0.15 },
{ data: sampleLineData.map(v => v + 10), color: '#9c7ef0', fillOpacity: 0.15 }
]}
onClick={() => handleCardClick('Performance')}
animationDelay={0.09}
/>
</div>
</section>
</div>
)}
{/* UI Elements Tab */}
{activeTab === 'ui' && (
<div className="space-y-8">
<section>
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Badges & Indicators
</h2>
<div className="panel p-6 space-y-4">
<div className="flex items-center gap-4 flex-wrap">
<span className="badge-active">
<span className="live-dot" />
Active
</span>
<span className="badge-stopped">Stopped</span>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-[var(--success-soft)] text-[var(--success)] text-xs font-medium">
<div className="w-2 h-2 rounded-full bg-[var(--success)]" />
Healthy
</div>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-[var(--warning-soft)] text-[var(--warning)] text-xs font-medium">
<div className="w-2 h-2 rounded-full bg-[var(--warning)]" />
Degraded
</div>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-[var(--error-soft)] text-[var(--error)] text-xs font-medium">
<div className="w-2 h-2 rounded-full bg-[var(--error)]" />
Critical
</div>
</div>
</div>
</section>
<section>
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Buttons
</h2>
<div className="panel p-6 space-y-4">
<div className="flex items-center gap-4 flex-wrap">
<button className="btn-stop">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
<circle cx="12" cy="12" r="10"/>
<circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/>
</svg>
STOP
</button>
<button className="btn-restart">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
<polyline points="1 4 1 10 7 10"/>
<path d="M3.51 15a9 9 0 1 0 .49-4.5"/>
</svg>
RESTART
</button>
<div className="arrow-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</div>
</div>
</div>
</section>
<section>
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Pills & Tabs
</h2>
<div className="panel p-6 space-y-6">
<div>
<p className="text-sm text-[var(--text-secondary)] mb-3">Pill Group</p>
<div className="pill-group">
<div className="pill active">Day</div>
<div className="pill">Month</div>
<div className="pill">Year</div>
</div>
</div>
<div>
<p className="text-sm text-[var(--text-secondary)] mb-3">Tabs</p>
<div className="flex border-b border-[var(--border-subtle)]">
<button className="tab active">
<Activity size={14} />
Metrics
</button>
<button className="tab">
<Zap size={14} />
Requests
</button>
<button className="tab">
<TrendingUp size={14} />
Analytics
</button>
</div>
</div>
</div>
</section>
<section>
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Search & Input
</h2>
<div className="panel p-6 space-y-4">
<div className="search-box">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6b6e7d" strokeWidth="2" strokeLinecap="round">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input type="text" placeholder="Search logs..." />
</div>
</div>
</section>
<section>
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
Toast Notifications
</h2>
<div className="panel p-6">
<div className="flex gap-3 flex-wrap">
<button
onClick={() => showToast('Operation completed successfully!', 'success')}
className="px-4 py-2 rounded-lg bg-[var(--success-soft)] text-[var(--success)] text-sm font-medium hover:bg-[var(--success-soft)]/80 transition-colors"
>
Show Success
</button>
<button
onClick={() => showToast('This is an informational message', 'info')}
className="px-4 py-2 rounded-lg bg-[var(--info-soft)] text-[var(--info)] text-sm font-medium hover:bg-[var(--info-soft)]/80 transition-colors"
>
Show Info
</button>
<button
onClick={() => showToast('Warning: Please check your settings', 'warning')}
className="px-4 py-2 rounded-lg bg-[var(--warning-soft)] text-[var(--warning)] text-sm font-medium hover:bg-[var(--warning-soft)]/80 transition-colors"
>
Show Warning
</button>
<button
onClick={() => showToast('Error: Operation failed', 'error')}
className="px-4 py-2 rounded-lg bg-[var(--error-soft)] text-[var(--error)] text-sm font-medium hover:bg-[var(--error-soft)]/80 transition-colors"
>
Show Error
</button>
</div>
</div>
</section>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,820 @@
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import {
createDeployment,
deleteService,
getProjectById,
getDeploymentLogs,
getServiceById,
listDeployments,
listServiceLogs,
rollbackDeployment,
} from '@/lib/api-client';
import { getDemoProjectById, getDemoServiceById } from '@/lib/demo-data';
import { formatDate, formatRelative, seededMetric } from '@/lib/time';
import {
ArrowLeft,
Activity,
FileText,
Settings,
Sliders,
Play,
RotateCw,
Trash2,
Check,
X,
Loader2,
Clock,
Cpu,
Layers,
MemoryStick,
Zap,
Timer,
Sparkles,
RefreshCw,
Box,
} from 'lucide-react';
type ServiceSection = 'metrics' | 'logs' | 'config' | 'settings';
const sectionItems: Array<{ key: ServiceSection; label: string; icon: typeof Activity }> = [
{ key: 'metrics', label: 'Metrics', icon: Activity },
{ key: 'logs', label: 'Logs', icon: FileText },
{ key: 'config', label: 'Config', icon: Sliders },
{ key: 'settings', label: 'Settings', icon: Settings },
];
function MetricCard({ label, value, hint, icon: Icon, trend }: { label: string; value: string; hint: string; icon: typeof Cpu; trend?: 'up' | 'down' | 'stable' }) {
return (
<div className="panel p-4">
<div className="flex items-center justify-between mb-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Icon size={16} className="text-[var(--accent-primary)]" />
</div>
{trend && (
<div className={`text-xs ${trend === 'up' ? 'text-[var(--success)]' : trend === 'down' ? 'text-[var(--error)]' : 'text-[var(--text-tertiary)]'}`}>
{trend === 'up' ? '↑' : trend === 'down' ? '↓' : '→'}
</div>
)}
</div>
<p className="text-2xl font-semibold text-[var(--text-primary)]">{value}</p>
<p className="text-xs text-[var(--text-muted)] mt-1">{label}</p>
<p className="text-[10px] text-[var(--text-tertiary)] mt-0.5">{hint}</p>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const config = {
running: { color: 'var(--success)', bg: 'var(--success-soft)', Icon: Check, animate: false },
deployed: { color: 'var(--success)', bg: 'var(--success-soft)', Icon: Check, animate: false },
failed: { color: 'var(--error)', bg: 'var(--error-soft)', Icon: X, animate: false },
building: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Loader2, animate: true },
pending: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: Loader2, animate: true },
rolling_back: { color: 'var(--warning)', bg: 'var(--warning-soft)', Icon: RefreshCw, animate: true },
stopped: { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Box, animate: false },
}[status] || { color: 'var(--text-tertiary)', bg: 'var(--surface-muted)', Icon: Box, animate: false };
const { Icon } = config;
return (
<div
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium"
style={{ background: config.bg, color: config.color }}
>
<Icon size={12} className={config.animate ? 'animate-spin' : ''} />
{status.replace('_', ' ')}
</div>
);
}
export function ServiceDetailPage() {
const { projectId = '', serviceId = '' } = useParams<{ projectId: string; serviceId: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const isDemoMode = searchParams.get('demo') === '1';
const [activeSection, setActiveSection] = useState<ServiceSection>('metrics');
const [logTail, setLogTail] = useState('100');
const projectQuery = useQuery({
queryKey: ['project', projectId],
queryFn: () => getProjectById(projectId),
enabled: Boolean(projectId) && !isDemoMode,
});
const serviceQuery = useQuery({
queryKey: ['service', serviceId],
queryFn: () => getServiceById(serviceId),
enabled: Boolean(serviceId) && !isDemoMode,
});
const deploymentsQuery = useQuery({
queryKey: ['service-deployments', serviceId],
queryFn: () => listDeployments(serviceId),
enabled: Boolean(serviceId) && !isDemoMode,
refetchInterval: 4000,
});
const serviceLogsQuery = useQuery({
queryKey: ['service-logs', serviceId, logTail],
queryFn: () => listServiceLogs(serviceId, { tail: logTail }),
enabled: Boolean(serviceId) && !isDemoMode && activeSection === 'logs',
});
const latestDeployment = useMemo(() => {
if (isDemoMode) {
return null;
}
const deployments = deploymentsQuery.data ?? [];
return deployments[0] ?? null;
}, [deploymentsQuery.data, isDemoMode]);
const deploymentLogsQuery = useQuery({
queryKey: ['deployment-logs', latestDeployment?.id],
queryFn: () => getDeploymentLogs(latestDeployment!.id, { type: 'all' }),
enabled:
Boolean(latestDeployment?.id) &&
!isDemoMode &&
activeSection === 'logs',
});
const deployMutation = useMutation({
mutationFn: (trigger: 'manual' | 'restart') => createDeployment(serviceId, { trigger }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['service-deployments', serviceId] });
queryClient.invalidateQueries({ queryKey: ['service', serviceId] });
queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
},
});
const rollbackMutation = useMutation({
mutationFn: (deploymentId: string) => rollbackDeployment(deploymentId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['service-deployments', serviceId] });
queryClient.invalidateQueries({ queryKey: ['service', serviceId] });
queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
},
});
const deleteServiceMutation = useMutation({
mutationFn: () => deleteService(serviceId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
navigate(`/projects/${projectId}`);
},
});
const project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data;
const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data;
const metricSet = useMemo(() => {
if (!service) {
return null;
}
const cpu = seededMetric(`${service.id}:cpu`, 12, 78);
const memory = seededMetric(`${service.id}:mem`, 24, 91);
const req = seededMetric(`${service.id}:req`, 120, 5100);
const latency = seededMetric(`${service.id}:lat`, 17, 210);
return {
cpu,
memory,
req,
latency,
};
}, [service]);
const deploymentSummary = useMemo(() => {
const deployments = isDemoMode ? [] : deploymentsQuery.data ?? [];
const total = deployments.length;
const active = deployments.filter((deployment) =>
['pending', 'building', 'deploying', 'rolling_back'].includes(deployment.status),
).length;
const failed = deployments.filter((deployment) => deployment.status === 'failed').length;
return { total, active, failed };
}, [deploymentsQuery.data, isDemoMode]);
const canRollback = (status: string): boolean => status === 'deployed' || status === 'failed';
if (!isDemoMode && (projectQuery.isLoading || serviceQuery.isLoading)) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="flex items-center gap-3 text-[var(--text-secondary)]">
<Loader2 size={20} className="animate-spin" />
<span>Loading service...</span>
</div>
</div>
);
}
if (!isDemoMode && serviceQuery.isError) {
return (
<div className="min-h-screen flex items-center justify-center p-8">
<div className="panel p-8 text-center max-w-md">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center">
<X size={24} className="text-[var(--error)]" />
</div>
<p className="text-lg font-medium text-[var(--text-primary)]">Failed to load service</p>
<p className="mt-2 text-sm text-[var(--text-secondary)]">{(serviceQuery.error as Error).message}</p>
<button
onClick={() => serviceQuery.refetch()}
className="mt-6 px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
>
Retry
</button>
</div>
</div>
);
}
if (!service || !project) {
return (
<div className="min-h-screen flex items-center justify-center p-8">
<div className="panel p-8 text-center max-w-md">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--surface-muted)] flex items-center justify-center">
<Box size={24} className="text-[var(--text-tertiary)]" />
</div>
<p className="text-lg font-medium text-[var(--text-primary)]">Service not found</p>
<p className="mt-2 text-sm text-[var(--text-secondary)]">This service may have been deleted.</p>
</div>
</div>
);
}
return (
<div className="min-h-screen relative">
{/* Breadcrumb - self.html exact match */}
<div
className="flex items-center"
style={{
gap: '6px',
padding: '14px 24px 10px',
color: '#6b6e7d',
fontSize: '13px'
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6b6e7d" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6"/>
</svg>
<button
onClick={() => navigate(isDemoMode ? `/projects/${project.id}?demo=1` : `/projects/${project.id}`)}
style={{ color: '#6b6e7d', textDecoration: 'none' }}
className="hover:text-[#9295a4] transition-colors"
>
Servers
</button>
<span style={{ opacity: 0.4 }}>/</span>
<span style={{ color: '#9295a4' }}>{service.name}</span>
</div>
{/* Project Header - self.html exact match */}
<div className="flex items-center" style={{ padding: '0 24px 18px' }}>
<div
className="rounded-[13px] flex items-center justify-center flex-shrink-0"
style={{
width: '46px',
height: '46px',
background: '#e8316a',
marginRight: '14px'
}}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="white">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z"/>
</svg>
</div>
<div>
<div className="flex items-center" style={{ gap: '10px' }}>
<span style={{ fontSize: '20px', fontWeight: 800, letterSpacing: '-0.5px', color: '#e8e9f0' }}>{service.name}</span>
<span className={`badge-${service.status === 'running' ? 'active' : 'stopped'}`}>
{service.status === 'running' && <span className="live-dot" />}
{service.status === 'running' ? 'Active' : 'Stopped'}
</span>
</div>
<div className="flex items-center" style={{ gap: '16px', marginTop: '4px' }}>
{service.status === 'running' && (
<a
href={`https://${service.name}.containr.dev`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center hover:text-[#9295a4] transition-colors"
style={{ color: '#6b6e7d', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
>
https://{service.name}.containr.dev
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
</a>
)}
<button
onClick={() => navigate(isDemoMode ? `/projects/${project.id}?demo=1` : `/projects/${project.id}`)}
className="flex items-center hover:text-[#9295a4] transition-colors"
style={{ color: '#6b6e7d', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
>
Project Information
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polyline points="9 18 15 12 9 6"/>
</svg>
</button>
</div>
</div>
<div className="ml-auto flex" style={{ gap: '10px' }}>
{!isDemoMode && (
<>
<button
onClick={() => deployMutation.mutate('restart')}
disabled={deployMutation.isPending || service.status !== 'running'}
className={`btn-stop ${service.status !== 'running' ? 'disabled' : ''}`}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
<circle cx="12" cy="12" r="10"/>
<circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/>
</svg>
STOP
</button>
<button
onClick={() => deployMutation.mutate('manual')}
disabled={deployMutation.isPending}
className={`btn-restart ${deployMutation.isPending ? 'disabled' : ''}`}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
<polyline points="1 4 1 10 7 10"/>
<path d="M3.51 15a9 9 0 1 0 .49-4.5"/>
</svg>
RESTART
</button>
</>
)}
</div>
</div>
{/* Demo Mode Banner */}
{isDemoMode && (
<div className="mx-auto w-full max-w-[1400px] px-6 py-4">
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50">
<div className="flex items-center gap-2 text-sm text-[var(--warning)]">
<Sparkles size={16} />
<span>Demo mode active using sample data for preview</span>
</div>
</div>
</div>
)}
{/* Metrics Overview */}
<div className="mx-auto w-full max-w-[1400px] px-6 py-6">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard
label="CPU"
value={`${metricSet?.cpu ?? 0}%`}
hint="Current utilization"
icon={Cpu}
trend={metricSet && metricSet.cpu > 60 ? 'up' : metricSet && metricSet.cpu < 30 ? 'down' : 'stable'}
/>
<MetricCard
label="Memory"
value={`${metricSet?.memory ?? 0}%`}
hint="Container footprint"
icon={MemoryStick}
trend={metricSet && metricSet.memory > 70 ? 'up' : 'stable'}
/>
<MetricCard
label="Requests"
value={`${metricSet?.req ?? 0}`}
hint="Last 60 minutes"
icon={Zap}
/>
<MetricCard
label="Latency"
value={`${metricSet?.latency ?? 0}ms`}
hint="P95 estimate"
icon={Timer}
trend={metricSet && metricSet.latency > 150 ? 'up' : 'stable'}
/>
</div>
{/* Deployment Summary */}
{!isDemoMode && (
<div className="mt-4 flex items-center gap-6 text-sm">
<div className="flex items-center gap-2 text-[var(--text-secondary)]">
<span className="w-2 h-2 rounded-full bg-[var(--accent-primary)]" />
<span>{deploymentSummary.total} deployments</span>
</div>
{deploymentSummary.active > 0 && (
<div className="flex items-center gap-2 text-[var(--warning)]">
<span className="w-2 h-2 rounded-full bg-[var(--warning)] animate-pulse" />
<span>{deploymentSummary.active} active</span>
</div>
)}
{deploymentSummary.failed > 0 && (
<div className="flex items-center gap-2 text-[var(--error)]">
<span className="w-2 h-2 rounded-full bg-[var(--error)]" />
<span>{deploymentSummary.failed} failed</span>
</div>
)}
{latestDeployment?.createdAt && (
<div className="flex items-center gap-1.5 text-[var(--text-tertiary)]">
<Clock size={12} />
<span>Latest {formatRelative(latestDeployment.createdAt)}</span>
</div>
)}
</div>
)}
</div>
{/* Tabs - self.html exact match */}
<div
className="flex"
style={{
borderBottom: '1px solid rgba(255,255,255,0.07)',
marginBottom: '18px',
padding: '0 24px'
}}
>
{sectionItems.map((item) => {
const active = activeSection === item.key;
const Icon = item.icon;
return (
<button
key={item.key}
onClick={() => setActiveSection(item.key)}
className={`tab ${active ? 'active' : ''}`}
>
<Icon size={14} />
{item.label}
</button>
);
})}
</div>
{/* Content - self.html exact match: padding 0 24px 28px */}
<div style={{ padding: '0 24px 28px' }}>
{activeSection === 'metrics' && (
<div className="space-y-6">
{/* Metrics Grid with enhanced cards */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="panel p-5 group hover:border-[var(--accent-primary)]/30 transition-all duration-300">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="card-icon">
<Cpu size={18} />
</div>
<div>
<p className="text-sm font-medium text-[var(--text-primary)]">CPU Timeline</p>
<p className="text-xs text-[var(--text-tertiary)]">Last 24 intervals</p>
</div>
</div>
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-xs text-[var(--text-tertiary)]">Click to expand</span>
</div>
</div>
<div className="flex h-32 items-end gap-1">
{Array.from({ length: 24 }).map((_, i) => {
const value = seededMetric(`${service.id}:cpu:${i}`, 8, 80);
return (
<div
key={i}
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80 cursor-pointer group/bar"
style={{
height: `${value}%`,
background: '#ff7043',
}}
title={`${value}%`}
/>
);
})}
</div>
</div>
<div className="panel p-5 group hover:border-[var(--success)]/30 transition-all duration-300">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="card-icon">
<Zap size={18} />
</div>
<div>
<p className="text-sm font-medium text-[var(--text-primary)]">Request Timeline</p>
<p className="text-xs text-[var(--text-tertiary)]">Last 24 intervals</p>
</div>
</div>
</div>
<div className="flex h-32 items-end gap-1">
{Array.from({ length: 24 }).map((_, i) => {
const value = seededMetric(`${service.id}:req:${i}`, 18, 96);
return (
<div
key={i}
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80 cursor-pointer"
style={{
height: `${value}%`,
background: '#3dd68c',
}}
title={`${value}%`}
/>
);
})}
</div>
</div>
</div>
{/* Domain & Networking Panel */}
{service.status === 'running' && (
<div className="panel p-5">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Zap size={18} className="text-[var(--accent-primary)]" />
</div>
<div>
<p className="text-sm font-medium text-[var(--text-primary)]">Networking</p>
<p className="text-xs text-[var(--text-tertiary)]">Public endpoints and ports</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-center justify-between p-3 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-[var(--success)]" />
<span className="text-xs text-[var(--text-secondary)]">HTTPS</span>
</div>
<a
href={`https://${service.name}.containr.dev`}
target="_blank"
rel="noopener noreferrer"
className="mono text-xs text-[var(--accent-primary)] hover:underline"
>
{service.name}.containr.dev
</a>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-[var(--accent-secondary)]" />
<span className="text-xs text-[var(--text-secondary)]">Port</span>
</div>
<span className="mono text-xs text-[var(--text-primary)]">8080 443</span>
</div>
</div>
</div>
)}
</div>
)}
{activeSection === 'logs' && (
<div className="panel p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<FileText size={20} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Service Logs</h2>
<p className="text-sm text-[var(--text-secondary)]">Container stdout/stderr output</p>
</div>
</div>
{!isDemoMode && (
<div className="flex items-center gap-3">
<select
value={logTail}
onChange={(e) => setLogTail(e.target.value)}
className="h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm"
>
<option value="50">50 lines</option>
<option value="100">100 lines</option>
<option value="250">250 lines</option>
</select>
<button
onClick={() => {
serviceLogsQuery.refetch();
if (latestDeployment?.id) deploymentLogsQuery.refetch();
}}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
>
<RefreshCw size={14} />
Refresh
</button>
</div>
)}
</div>
<div className="mono rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] p-4 text-xs text-[var(--text-secondary)] max-h-[500px] overflow-auto">
{isDemoMode ? (
<div className="space-y-1">
{Array.from({ length: 15 }).map((_, i) => (
<p key={i}>
<span className="text-[var(--text-muted)]">
[{service.updatedAt ? new Date(new Date(service.updatedAt).getTime() - i * 45000).toLocaleTimeString() : '--:--:--'}]
</span>
{' '}
<span className="text-[var(--accent-primary)]">{service.name}</span>
{' '}
<span className="text-[var(--text-tertiary)]">{i % 3 === 0 ? 'health_check=ok' : 'request=200'}</span>
</p>
))}
</div>
) : serviceLogsQuery.isLoading ? (
<p className="text-[var(--text-muted)]">Loading logs...</p>
) : serviceLogsQuery.isError ? (
<p className="text-[var(--error)]">Failed to load logs.</p>
) : (serviceLogsQuery.data?.length ?? 0) === 0 ? (
<p className="text-[var(--text-muted)]">No logs available.</p>
) : (
<div className="space-y-1">
{serviceLogsQuery.data!.map((entry, i) => (
<p key={`${entry.timestamp}-${i}`}>
<span className="text-[var(--text-muted)]">
[{entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : '--:--:--'}]
</span>
{' '}
<span className="text-[var(--accent-primary)]">{entry.stream}</span>
{' '}
{entry.message}
</p>
))}
</div>
)}
</div>
{!isDemoMode && latestDeployment && (
<div className="mt-6 panel-soft p-4">
<div className="flex items-center justify-between mb-3">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Latest Deployment</p>
<StatusBadge status={latestDeployment.status} />
</div>
<p className="text-xs text-[var(--text-tertiary)] mono">{latestDeployment.id}</p>
<pre className="mono mt-3 max-h-40 overflow-auto whitespace-pre-wrap break-all text-xs text-[var(--text-secondary)]">
{deploymentLogsQuery.isLoading
? 'Loading...'
: deploymentLogsQuery.isError
? 'Failed to load.'
: deploymentLogsQuery.data?.buildLog || deploymentLogsQuery.data?.runtimeLog || '(no output)'}
</pre>
</div>
)}
</div>
)}
{activeSection === 'config' && (
<div className="panel p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Sliders size={20} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Configuration</h2>
<p className="text-sm text-[var(--text-secondary)]">Runtime and deployment settings</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Environment</p>
<p className="mt-2 text-sm text-[var(--text-primary)]">{service.environment ?? 'production'}</p>
</div>
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Image</p>
<p className="mono mt-2 text-sm text-[var(--text-primary)] break-all">{service.image ?? 'not set'}</p>
</div>
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Command</p>
<p className="mono mt-2 text-sm text-[var(--text-primary)] break-all">{service.command ?? 'default'}</p>
</div>
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Git Branch</p>
<p className="mono mt-2 text-sm text-[var(--text-primary)]">{service.gitBranch ?? 'not configured'}</p>
</div>
</div>
</div>
)}
{activeSection === 'settings' && (
<div className="panel p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Settings size={20} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Settings</h2>
<p className="text-sm text-[var(--text-secondary)]">Service metadata and history</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Service ID</p>
<p className="mono mt-2 text-sm text-[var(--text-primary)] break-all">{service.id}</p>
</div>
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Created</p>
<p className="mt-2 text-sm text-[var(--text-primary)]">{formatDate(service.createdAt)}</p>
</div>
<div className="panel-soft p-4 md:col-span-2">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Last Update</p>
<p className="mt-2 text-sm text-[var(--text-primary)]">{formatRelative(service.updatedAt)}</p>
</div>
</div>
{!isDemoMode && (
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-[var(--text-primary)]">Deployment History</h3>
<button
onClick={() => deploymentsQuery.refetch()}
className="flex items-center gap-2 px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] transition-colors"
>
<RefreshCw size={12} />
Refresh
</button>
</div>
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] overflow-hidden">
<table className="min-w-full text-xs">
<thead className="bg-[var(--surface-muted)]">
<tr>
<th className="px-4 py-3 text-left font-medium uppercase tracking-wider text-[var(--text-muted)]">Status</th>
<th className="px-4 py-3 text-left font-medium uppercase tracking-wider text-[var(--text-muted)]">ID</th>
<th className="px-4 py-3 text-left font-medium uppercase tracking-wider text-[var(--text-muted)]">Image</th>
<th className="px-4 py-3 text-left font-medium uppercase tracking-wider text-[var(--text-muted)]">Created</th>
<th className="px-4 py-3 text-left font-medium uppercase tracking-wider text-[var(--text-muted)]">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-subtle)]">
{deploymentsQuery.isLoading ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-[var(--text-muted)]">
Loading...
</td>
</tr>
) : deploymentsQuery.isError ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-[var(--error)]">
Failed to load
</td>
</tr>
) : (deploymentsQuery.data?.length ?? 0) === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-[var(--text-muted)]">
No deployments yet
</td>
</tr>
) : (
deploymentsQuery.data!.map((deployment) => (
<tr key={deployment.id} className="hover:bg-[var(--surface-muted)]/50 transition-colors">
<td className="px-4 py-3">
<StatusBadge status={deployment.status} />
</td>
<td className="px-4 py-3 mono text-[var(--text-secondary)]">{deployment.id}</td>
<td className="px-4 py-3 mono text-[var(--text-secondary)]">
{deployment.imageName || '—'}:{deployment.imageTag || '—'}
</td>
<td className="px-4 py-3 text-[var(--text-tertiary)]">
{deployment.createdAt ? formatRelative(deployment.createdAt) : '—'}
</td>
<td className="px-4 py-3">
<button
onClick={() => rollbackMutation.mutate(deployment.id)}
disabled={!canRollback(deployment.status) || rollbackMutation.isPending}
className="px-3 py-1.5 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] text-xs font-medium hover:border-[var(--border-default)] disabled:opacity-50 transition-colors"
>
{rollbackMutation.isPending && rollbackMutation.variables === deployment.id ? '...' : 'Rollback'}
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)}
</div>
)}
</div>
{/* Error Toasts */}
{!isDemoMode && (deleteServiceMutation.error || deployMutation.error || rollbackMutation.error) && (
<div className="fixed bottom-4 right-4 space-y-2">
{deleteServiceMutation.error && (
<div className="px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] border border-[var(--error)]/20 text-sm text-[var(--error)] shadow-lg">
{(deleteServiceMutation.error as Error).message}
</div>
)}
{deployMutation.error && (
<div className="px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] border border-[var(--error)]/20 text-sm text-[var(--error)] shadow-lg">
{(deployMutation.error as Error).message}
</div>
)}
{rollbackMutation.error && (
<div className="px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] border border-[var(--error)]/20 text-sm text-[var(--error)] shadow-lg">
{(rollbackMutation.error as Error).message}
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,494 @@
import { useMemo, useState, useEffect } from 'react';
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
import {
Cpu,
MemoryStick,
Zap,
Users,
Globe,
ArrowLeft,
Search,
Bell,
ChevronUp,
Activity,
FileText,
Sliders,
Settings as SettingsIcon
} from 'lucide-react';
import {
LineChart,
LineAreaChart,
DonutChart,
MultiLineChart,
EnhancedMetricCard,
CacheMetricCard,
PerformanceMetricCard
} from '@/shared/components';
// Generate seeded random data for demo
function seededRandom(seed: string, min: number, max: number): number {
let hash = 0;
for (let i = 0; i < seed.length; i++) {
hash = ((hash << 5) - hash) + seed.charCodeAt(i);
hash = hash & hash;
}
const normalized = Math.abs(Math.sin(hash));
return Math.floor(normalized * (max - min + 1)) + min;
}
type TabKey = 'metrics' | 'requests' | 'apis' | 'config';
const tabs: Array<{ key: TabKey; label: string; icon: typeof Activity }> = [
{ key: 'metrics', label: 'Metrics', icon: Activity },
{ key: 'requests', label: 'Requests', icon: Zap },
{ key: 'apis', label: 'APIs', icon: Globe },
{ key: 'config', label: 'Config', icon: Sliders },
];
const timePeriods = ['Day', 'Month', 'Year'];
export function ServiceMetricsDashboard() {
const { serviceId = 'demo-service' } = useParams();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<TabKey>('metrics');
const [timePeriod, setTimePeriod] = useState('Day');
const [isSystemRunning, setIsSystemRunning] = useState(true);
// Generate realistic metric data
const metrics = useMemo(() => {
const cpuData = Array.from({ length: 24 }, (_, i) =>
seededRandom(`${serviceId}:cpu:${i}`, 10, 45)
);
const ramPercent = seededRandom(`${serviceId}:ram`, 50, 85);
const userCount = seededRandom(`${serviceId}:users`, 400, 500);
const userData = Array.from({ length: 15 }, (_, i) =>
seededRandom(`${serviceId}:users:${i}`, 40, 110)
);
const perfPercent = seededRandom(`${serviceId}:perf`, 82, 99);
const perfData1 = Array.from({ length: 12 }, (_, i) =>
seededRandom(`${serviceId}:perf1:${i}`, 70, 95)
);
const perfData2 = Array.from({ length: 12 }, (_, i) =>
seededRandom(`${serviceId}:perf2:${i}`, 60, 90)
);
return {
cpu: cpuData[cpuData.length - 1],
cpuData,
ram: ramPercent,
ramUsedGB: ((8 * ramPercent) / 100).toFixed(1),
users: userCount,
userData,
perf: perfPercent,
perfData: [
{ data: perfData1, color: '#6c8ef0', fillOpacity: 0.15 },
{ data: perfData2, color: '#9c7ef0', fillOpacity: 0.15 }
],
upSpeed: (Math.random() * 5 + 8).toFixed(1),
downSpeed: (Math.random() * 3 + 4).toFixed(1),
};
}, [serviceId, timePeriod]);
// Auto-update metrics every 2 seconds
useEffect(() => {
if (!isSystemRunning) return;
const interval = setInterval(() => {
// Trigger re-render by updating time period
setTimePeriod(prev => prev);
}, 2000);
return () => clearInterval(interval);
}, [isSystemRunning]);
const handleStop = () => {
setIsSystemRunning(false);
};
const handleRestart = () => {
setIsSystemRunning(true);
};
return (
<div className="min-h-screen" style={{ background: '#16171c' }}>
{/* Ambient glow */}
<div className="ambient-glow" />
<div className="flex min-h-screen relative">
{/* Sidebar - self.html exact match */}
<aside
className="hidden md:flex shrink-0 flex-col items-center border-r"
style={{
width: '64px',
background: '#111217',
borderRight: '1px solid rgba(255,255,255,0.07)',
padding: '16px 0',
gap: '5px'
}}
>
{/* Logo */}
<div
className="rounded-full flex items-center justify-center"
style={{
width: '38px',
height: '38px',
background: '#e8316a',
marginBottom: '14px'
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="white">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z"/>
</svg>
</div>
{/* Nav Items */}
<div className="nav-item active">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1.5"/>
<rect x="14" y="3" width="7" height="7" rx="1.5"/>
<rect x="3" y="14" width="7" height="7" rx="1.5"/>
<rect x="14" y="14" width="7" height="7" rx="1.5"/>
</svg>
</div>
<div className="nav-item">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2"/>
<path d="M8 21h8M12 17v4"/>
</svg>
</div>
<div className="nav-item">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<ellipse cx="12" cy="5" rx="9" ry="3"/>
<path d="M3 5v6c0 1.66 4.03 3 9 3s9-1.34 9-3V5"/>
<path d="M3 11v6c0 1.66 4.03 3 9 3s9-1.34 9-3v-6"/>
</svg>
</div>
<div className="flex-1" />
{/* User Avatar */}
<div
className="rounded-full flex items-center justify-center cursor-pointer"
style={{
width: '34px',
height: '34px',
background: '#22233a',
fontSize: '11px',
fontWeight: 700,
color: '#9295a4',
marginTop: '4px',
letterSpacing: '-0.3px'
}}
>
w.
</div>
</aside>
{/* Main Content */}
<div className="flex-1 flex flex-col min-h-screen overflow-hidden">
{/* Topbar - self.html exact match */}
<header
className="shrink-0 flex items-center"
style={{
height: '52px',
background: '#111217',
borderBottom: '1px solid rgba(255,255,255,0.07)',
padding: '0 22px',
gap: '14px'
}}
>
<div className="search-box">
<Search size={14} />
<input type="text" placeholder="Search logs..." />
</div>
<div className="ml-auto flex items-center" style={{ gap: '8px' }}>
<button
className="rounded-[9px] border bg-transparent text-[#9295a4] font-medium cursor-pointer"
style={{
height: '32px',
padding: '0 14px',
border: '1px solid rgba(255,255,255,0.1)',
fontSize: '13px'
}}
>
Support
</button>
<button
className="rounded-[9px] border-none flex items-center text-[#e8e9f0] font-medium cursor-pointer"
style={{
height: '32px',
padding: '0 14px',
background: 'rgba(255,255,255,0.08)',
fontSize: '13px',
gap: '6px'
}}
>
<ChevronUp size={13} />
Upgrade
</button>
<button
className="rounded-[9px] border flex items-center justify-center cursor-pointer bg-transparent"
style={{
width: '32px',
height: '32px',
border: '1px solid rgba(255,255,255,0.1)'
}}
>
<Bell size={15} color="#9295a4" />
</button>
</div>
</header>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto" style={{ padding: '0 24px 28px' }}>
{/* Breadcrumb */}
<div
className="flex items-center"
style={{
gap: '6px',
padding: '14px 0 10px',
color: '#6b6e7d',
fontSize: '13px'
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#6b6e7d" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6"/>
</svg>
<button
onClick={() => navigate(-1)}
style={{ color: '#6b6e7d' }}
className="hover:text-[#9295a4] transition-colors"
>
Servers
</button>
<span style={{ opacity: 0.4 }}>/</span>
<span style={{ color: '#9295a4' }}>[NuFest] - App Project</span>
</div>
{/* Project Header */}
<div className="flex items-center" style={{ paddingBottom: '18px' }}>
<div
className="rounded-[13px] flex items-center justify-center flex-shrink-0"
style={{
width: '46px',
height: '46px',
background: '#e8316a',
marginRight: '14px'
}}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="white">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z"/>
</svg>
</div>
<div>
<div className="flex items-center" style={{ gap: '10px' }}>
<span style={{ fontSize: '20px', fontWeight: 800, letterSpacing: '-0.5px', color: '#e8e9f0' }}>
[NuFest] - App Project
</span>
<span className={`badge-${isSystemRunning ? 'active' : 'stopped'}`}>
{isSystemRunning && <span className="live-dot" />}
{isSystemRunning ? 'Active' : 'Stopped'}
</span>
</div>
<div className="flex items-center" style={{ gap: '16px', marginTop: '4px' }}>
<a
href="https://nufest-dth.app"
target="_blank"
rel="noopener noreferrer"
className="flex items-center hover:text-[#9295a4] transition-colors"
style={{ color: '#6b6e7d', fontSize: '12.5px', textDecoration: 'none', gap: '4px' }}
>
https://nufest-dth.app
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
</a>
<button
className="flex items-center hover:text-[#9295a4] transition-colors"
style={{ color: '#6b6e7d', fontSize: '12.5px', gap: '4px' }}
>
Project Information
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polyline points="9 18 15 12 9 6"/>
</svg>
</button>
</div>
</div>
<div className="ml-auto flex" style={{ gap: '10px' }}>
<button
onClick={handleStop}
disabled={!isSystemRunning}
className={`btn-stop ${!isSystemRunning ? 'disabled' : ''}`}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
<circle cx="12" cy="12" r="10"/>
<circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/>
</svg>
STOP
</button>
<button
onClick={handleRestart}
disabled={isSystemRunning}
className={`btn-restart ${isSystemRunning ? 'disabled' : ''}`}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
<polyline points="1 4 1 10 7 10"/>
<path d="M3.51 15a9 9 0 1 0 .49-4.5"/>
</svg>
RESTART
</button>
</div>
</div>
{/* Tabs */}
<div
className="flex"
style={{
borderBottom: '1px solid rgba(255,255,255,0.07)',
marginBottom: '18px'
}}
>
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`tab ${activeTab === tab.key ? 'active' : ''}`}
>
<Icon size={14} />
{tab.label}
</button>
);
})}
</div>
{/* Metrics Header */}
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
<span style={{ fontSize: '15px', fontWeight: 700, color: '#e8e9f0' }}>Metrics</span>
<div className="flex items-center" style={{ gap: '8px' }}>
<button
className="rounded-[9px] border flex items-center font-medium cursor-pointer"
style={{
height: '32px',
padding: '0 12px',
border: '1px solid rgba(255,255,255,0.09)',
background: 'rgba(255,255,255,0.04)',
color: '#9295a4',
fontSize: '12.5px',
gap: '6px'
}}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>
</svg>
Filter
</button>
<div className="pill-group">
{timePeriods.map((period) => (
<div
key={period}
onClick={() => setTimePeriod(period)}
className={`pill ${timePeriod === period ? 'active' : ''}`}
>
{period}
</div>
))}
</div>
</div>
</div>
{/* Row 1: CPU, RAM, Cache */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1.95fr', gap: '13px', marginBottom: '13px' }}>
{/* CPU Card */}
<EnhancedMetricCard
title="CPU Usage"
value={`${metrics.cpu}%`}
subtitle="Daily usage"
status="good"
statusText="Good"
icon={<Cpu size={16} />}
chart={<LineChart data={metrics.cpuData} color="#ff7043" height={76} />}
animationDelay={0.04}
/>
{/* RAM Card */}
<div className="card" style={{ animationDelay: '0.09s' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div className="card-icon">
<MemoryStick size={16} />
</div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>RAM Usage</span>
</div>
<div style={{ fontSize: 38, fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>
{metrics.ram}%
</div>
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>
<span style={{ color: '#f0a040', fontWeight: 700 }}>Average</span> Daily usage
</div>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', margin: '10px 0 4px', position: 'relative' }}>
<DonutChart percentage={metrics.ram} color="#9c7ef0" />
<div style={{ position: 'absolute', bottom: 14, textAlign: 'center' }}>
<div style={{ fontSize: 10.5, color: '#6b6e7d', marginBottom: 1 }}>Used</div>
<div style={{ fontSize: 12.5, fontWeight: 700, color: '#e8e9f0' }}>{metrics.ramUsedGB} GB / 8GB</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: 4 }}>
<span style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span>
<div className="arrow-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</div>
</div>
</div>
{/* Cache Card */}
<CacheMetricCard
totalMB={352}
cacheMB={212}
nonCacheMB={85}
animationDelay={0.14}
/>
</div>
{/* Row 2: Active Users, Performance */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '13px' }}>
{/* Active Users Card */}
<EnhancedMetricCard
title="Active User"
value={`${metrics.users} K`}
subtitle="User active right now"
icon={<Users size={16} />}
chart={<LineAreaChart data={metrics.userData} color="#e8316a" height={130} />}
details={
<div className="flex items-center gap-1.5 flex-wrap">
{['🇨🇳', '🇮🇩', '🇲🇲', '🇲🇾', '🇯🇵', '🇮🇳', '🇰🇷', '🇵🇭'].map((flag, i) => (
<span key={i} className="flag">{flag}</span>
))}
</div>
}
horizontal
animationDelay={0.19}
/>
{/* Performance Card */}
<PerformanceMetricCard
percentage={metrics.perf}
upSpeed={parseFloat(metrics.upSpeed)}
downSpeed={parseFloat(metrics.downSpeed)}
datasets={metrics.perfData}
animationDelay={0.24}
/>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,962 @@
import { useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
deployTemplate,
getTemplateById,
listProjects,
listTemplates,
type TemplateDetailEntity,
type TemplateEntity,
} from '@/lib/api-client';
import {
Search,
Filter,
Check,
Loader2,
Sparkles,
Box,
Code,
Database,
Globe,
Layers,
Terminal,
Play,
ArrowRight,
Star,
} from 'lucide-react';
const demoTemplates: TemplateEntity[] = [
{
id: 'tpl-react',
name: 'React Application',
description: 'Single-page frontend with Vite and static serving runtime.',
category: 'frontend',
logo: 'https://cdn.simpleicons.org/react',
configRaw: '{"runtime":"node"}',
variablesRaw: '[]',
isOfficial: true,
},
{
id: 'tpl-go',
name: 'Go API Service',
description: 'API-ready Go runtime with direct binary startup.',
category: 'web',
logo: 'https://cdn.simpleicons.org/go',
configRaw: '{"runtime":"go"}',
variablesRaw: '[]',
isOfficial: true,
},
{
id: 'tpl-postgres',
name: 'PostgreSQL Database',
description: 'Managed PostgreSQL service with credential setup variables.',
category: 'database',
logo: 'https://cdn.simpleicons.org/postgresql',
configRaw: '{"runtime":"postgres"}',
variablesRaw: '[]',
isOfficial: true,
},
{
id: 'tpl-mysql',
name: 'MySQL Database',
description: 'Managed MySQL service for transactional workloads.',
category: 'database',
logo: 'https://cdn.simpleicons.org/mysql',
configRaw: '{"runtime":"mysql"}',
variablesRaw: '[]',
isOfficial: true,
},
{
id: 'tpl-mariadb',
name: 'MariaDB Database',
description: 'Managed MariaDB service with MySQL compatibility.',
category: 'database',
logo: 'https://cdn.simpleicons.org/mariadb',
configRaw: '{"runtime":"mariadb"}',
variablesRaw: '[]',
isOfficial: true,
},
{
id: 'tpl-clickhouse',
name: 'ClickHouse Database',
description: 'Columnar analytics database template for high-speed queries.',
category: 'database',
logo: 'https://cdn.simpleicons.org/clickhouse',
configRaw: '{"runtime":"clickhouse"}',
variablesRaw: '[]',
isOfficial: true,
},
{
id: 'tpl-dragonfly',
name: 'Dragonfly Database',
description: 'Redis-compatible in-memory store powered by Dragonfly.',
category: 'database',
logo: 'https://cdn.simpleicons.org/redis',
configRaw: '{"runtime":"dragonfly"}',
variablesRaw: '[]',
isOfficial: true,
},
];
const demoTemplateDetails: Record<string, TemplateDetailEntity> = {
'tpl-react': {
template: demoTemplates[0],
config: {
type: 'web',
runtime: 'node',
buildCommand: 'npm install && npm run build',
startCommand: 'npx serve -s dist',
port: 3000,
healthCheck: '/health',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'VITE_API_URL',
label: 'API URL',
defaultValue: 'https://api.example.com',
required: true,
secret: false,
description: 'Public API endpoint for frontend calls',
},
],
},
'tpl-go': {
template: demoTemplates[1],
config: {
type: 'web',
runtime: 'go',
buildCommand: 'go build -o app .',
startCommand: './app',
port: 8080,
healthCheck: '/health',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'GO_ENV',
label: 'Go Environment',
defaultValue: 'production',
required: false,
secret: false,
description: 'Runtime environment value',
},
],
},
'tpl-postgres': {
template: demoTemplates[2],
config: {
type: 'database',
runtime: 'postgres',
buildCommand: '',
startCommand: '',
port: 5432,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'POSTGRES_USER',
label: 'Username',
defaultValue: 'postgres',
required: true,
secret: false,
description: 'Database user',
},
{
key: 'POSTGRES_PASSWORD',
label: 'Password',
defaultValue: '',
required: true,
secret: true,
description: 'Database password',
},
],
},
'tpl-mysql': {
template: demoTemplates[3],
config: {
type: 'database',
runtime: 'mysql',
buildCommand: '',
startCommand: '',
port: 3306,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'MYSQL_DATABASE',
label: 'Database Name',
defaultValue: 'app',
required: true,
secret: false,
description: 'Initial database to create',
},
{
key: 'MYSQL_USER',
label: 'Username',
defaultValue: 'app',
required: true,
secret: false,
description: 'Application DB user',
},
{
key: 'MYSQL_PASSWORD',
label: 'User Password',
defaultValue: '',
required: true,
secret: true,
description: 'Application DB password',
},
{
key: 'MYSQL_ROOT_PASSWORD',
label: 'Root Password',
defaultValue: '',
required: true,
secret: true,
description: 'Root account password',
},
],
},
'tpl-mariadb': {
template: demoTemplates[4],
config: {
type: 'database',
runtime: 'mariadb',
buildCommand: '',
startCommand: '',
port: 3306,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'MARIADB_DATABASE',
label: 'Database Name',
defaultValue: 'app',
required: true,
secret: false,
description: 'Initial database to create',
},
{
key: 'MARIADB_USER',
label: 'Username',
defaultValue: 'app',
required: true,
secret: false,
description: 'Application DB user',
},
{
key: 'MARIADB_PASSWORD',
label: 'User Password',
defaultValue: '',
required: true,
secret: true,
description: 'Application DB password',
},
{
key: 'MARIADB_ROOT_PASSWORD',
label: 'Root Password',
defaultValue: '',
required: true,
secret: true,
description: 'Root account password',
},
],
},
'tpl-clickhouse': {
template: demoTemplates[5],
config: {
type: 'database',
runtime: 'clickhouse',
buildCommand: '',
startCommand: '',
port: 8123,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'CLICKHOUSE_DB',
label: 'Database Name',
defaultValue: 'app',
required: false,
secret: false,
description: 'Default database name',
},
{
key: 'CLICKHOUSE_USER',
label: 'Username',
defaultValue: 'default',
required: false,
secret: false,
description: 'ClickHouse user',
},
{
key: 'CLICKHOUSE_PASSWORD',
label: 'Password',
defaultValue: '',
required: false,
secret: true,
description: 'ClickHouse password',
},
],
},
'tpl-dragonfly': {
template: demoTemplates[6],
config: {
type: 'database',
runtime: 'dragonfly',
buildCommand: '',
startCommand: '',
port: 6379,
healthCheck: '',
environment: {},
nixpacksConfig: {},
},
variables: [
{
key: 'DRAGONFLY_PASSWORD',
label: 'Password',
defaultValue: '',
required: false,
secret: true,
description: 'Optional Redis-compatible password',
},
],
},
};
const demoProjects = [
{ id: 'project-demo', name: 'Demo Project' },
{ id: 'project-internal', name: 'Internal Tooling' },
];
function toServiceName(value: string): string {
const normalized = value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return normalized || 'service';
}
function categoryLabel(category: string): string {
return category ? category[0].toUpperCase() + category.slice(1) : 'Uncategorized';
}
function categoryIcon(category: string): typeof Box {
switch (category) {
case 'frontend':
return Globe;
case 'web':
return Code;
case 'database':
return Database;
case 'backend':
return Terminal;
default:
return Box;
}
}
export function TemplatesPage() {
const queryClient = useQueryClient();
const [searchParams] = useSearchParams();
const isDemoMode = searchParams.get('demo') === '1';
const [categoryFilter, setCategoryFilter] = useState('');
const [searchFilter, setSearchFilter] = useState('');
const [selectedTemplateIdState, setSelectedTemplateId] = useState<string | null>(null);
const [deployProjectIdState, setDeployProjectId] = useState('');
const [deployNameByTemplate, setDeployNameByTemplate] = useState<Record<string, string>>({});
const [variableValuesByTemplate, setVariableValuesByTemplate] = useState<
Record<string, Record<string, string>>
>({});
const [lastDeployment, setLastDeployment] = useState<{
projectId: string;
serviceId: string;
serviceName: string;
} | null>(null);
const templatesQuery = useQuery({
queryKey: ['templates-page', categoryFilter],
enabled: !isDemoMode,
queryFn: () => listTemplates({ category: categoryFilter || undefined }),
});
const projectsQuery = useQuery({
queryKey: ['template-projects'],
enabled: !isDemoMode,
queryFn: listProjects,
});
const templates = useMemo(
() => (isDemoMode ? demoTemplates : templatesQuery.data ?? []),
[isDemoMode, templatesQuery.data],
);
const filteredTemplates = useMemo(() => {
const query = searchFilter.trim().toLowerCase();
if (!query) {
return templates;
}
return templates.filter(
(template) =>
template.name.toLowerCase().includes(query) ||
template.description.toLowerCase().includes(query) ||
template.category.toLowerCase().includes(query),
);
}, [searchFilter, templates]);
const categoryOptions = useMemo(() => {
const values = new Set<string>();
for (const template of templates) {
if (template.category) {
values.add(template.category);
}
}
return Array.from(values).sort((left, right) => left.localeCompare(right));
}, [templates]);
const selectedTemplateId = useMemo(() => {
if (filteredTemplates.length === 0) {
return null;
}
if (selectedTemplateIdState && filteredTemplates.some((template) => template.id === selectedTemplateIdState)) {
return selectedTemplateIdState;
}
return filteredTemplates[0].id;
}, [filteredTemplates, selectedTemplateIdState]);
const templateDetailQuery = useQuery({
queryKey: ['template-detail-page', selectedTemplateId],
enabled: !isDemoMode && Boolean(selectedTemplateId),
queryFn: () => getTemplateById(selectedTemplateId!),
});
const selectedDetail = isDemoMode
? selectedTemplateId
? demoTemplateDetails[selectedTemplateId] ?? null
: null
: templateDetailQuery.data ?? null;
const projectOptions = useMemo(
() =>
isDemoMode
? demoProjects
: (projectsQuery.data ?? []).map((project) => ({
id: project.id,
name: project.name,
})),
[isDemoMode, projectsQuery.data],
);
const deployProjectId = useMemo(() => {
if (projectOptions.length === 0) {
return '';
}
if (projectOptions.some((project) => project.id === deployProjectIdState)) {
return deployProjectIdState;
}
return projectOptions[0].id;
}, [deployProjectIdState, projectOptions]);
const variableDefaults = useMemo(() => {
if (!selectedDetail) {
return {} as Record<string, string>;
}
const defaults: Record<string, string> = {};
for (const variable of selectedDetail.variables) {
defaults[variable.key] = variable.defaultValue;
}
return defaults;
}, [selectedDetail]);
const deployName = useMemo(() => {
if (!selectedTemplateId || !selectedDetail) {
return '';
}
return deployNameByTemplate[selectedTemplateId] ?? toServiceName(selectedDetail.template.name);
}, [deployNameByTemplate, selectedDetail, selectedTemplateId]);
const variableValues = useMemo(() => {
if (!selectedTemplateId) {
return variableDefaults;
}
return {
...variableDefaults,
...(variableValuesByTemplate[selectedTemplateId] ?? {}),
};
}, [selectedTemplateId, variableDefaults, variableValuesByTemplate]);
const missingRequiredVariables = useMemo(() => {
if (!selectedDetail) {
return [];
}
return selectedDetail.variables.filter((variable) => {
if (!variable.required) {
return false;
}
return !(variableValues[variable.key] ?? '').trim();
});
}, [selectedDetail, variableValues]);
const deployMutation = useMutation({
mutationFn: async () => {
if (!selectedTemplateId) {
throw new Error('No template selected');
}
const variables: Record<string, string> = {};
for (const [key, value] of Object.entries(variableValues)) {
if (value.trim()) {
variables[key] = value.trim();
}
}
return deployTemplate(selectedTemplateId, {
projectId: deployProjectId,
name: deployName.trim(),
variables,
});
},
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
queryClient.invalidateQueries({ queryKey: ['project-services'] });
setLastDeployment({
projectId: deployProjectId,
serviceId: result.serviceId,
serviceName: deployName.trim(),
});
},
});
const isDeployDisabled =
!selectedTemplateId ||
!deployProjectId ||
deployName.trim().length === 0 ||
missingRequiredVariables.length > 0 ||
deployMutation.isPending;
return (
<div className="min-h-screen">
{/* Header */}
<div className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/50 backdrop-blur-sm">
<div className="mx-auto w-full max-w-[1400px] px-6 py-4">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold text-[var(--text-primary)]">Template Catalog</h1>
<p className="text-sm text-[var(--text-secondary)]">Deploy services from pre-configured templates</p>
</div>
<div className="flex items-center gap-2 text-sm text-[var(--text-tertiary)]">
<Layers size={16} />
<span>{filteredTemplates.length} templates</span>
</div>
</div>
</div>
</div>
{/* Demo Mode Banner */}
{isDemoMode && (
<div className="mx-auto w-full max-w-[1400px] px-6 py-4">
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50">
<div className="flex items-center gap-2 text-sm text-[var(--warning)]">
<Sparkles size={16} />
<span>Demo mode active using sample data</span>
</div>
</div>
</div>
)}
{/* Main Content */}
<div className="mx-auto w-full max-w-[1400px] px-6 py-6">
<div className="grid grid-cols-1 xl:grid-cols-[380px_1fr] gap-6">
{/* Template List */}
<section className="panel overflow-hidden">
<div className="p-4 border-b border-[var(--border-subtle)]">
<div className="flex items-center gap-2 mb-4">
<Filter size={16} className="text-[var(--text-tertiary)]" />
<span className="text-sm font-medium text-[var(--text-secondary)]">Filters</span>
</div>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Category
</label>
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
>
<option value="">All categories</option>
{categoryOptions.map((option) => (
<option key={option} value={option}>{categoryLabel(option)}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Search
</label>
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)]" />
<input
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
className="w-full h-10 pl-10 pr-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
placeholder="Search templates..."
/>
</div>
</div>
</div>
</div>
{!isDemoMode && templatesQuery.isLoading ? (
<div className="p-12 text-center">
<Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
<p className="mt-3 text-sm text-[var(--text-muted)]">Loading templates...</p>
</div>
) : null}
{!isDemoMode && templatesQuery.isError ? (
<div className="p-8 text-center">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center">
<Box size={24} className="text-[var(--error)]" />
</div>
<p className="text-sm text-[var(--error)]">Failed to load templates</p>
</div>
) : null}
{filteredTemplates.length === 0 && !templatesQuery.isLoading ? (
<div className="p-12 text-center">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--surface-muted)] flex items-center justify-center">
<Search size={24} className="text-[var(--text-tertiary)]" />
</div>
<p className="text-sm text-[var(--text-muted)]">No templates match filters</p>
</div>
) : null}
{filteredTemplates.length > 0 ? (
<div className="max-h-[600px] overflow-auto p-3">
<div className="grid grid-cols-1 gap-2">
{filteredTemplates.map((template) => {
const selected = template.id === selectedTemplateId;
const Icon = categoryIcon(template.category);
const categoryColor = template.category === 'database' ? '#9c7ef0' : template.category === 'frontend' ? '#6c8ef0' : template.category === 'web' ? '#e8316a' : '#9295a4';
return (
<button
key={template.id}
onClick={() => setSelectedTemplateId(template.id)}
className={`w-full p-4 rounded-[var(--radius-lg)] border text-left transition-all duration-300 group ${
selected
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-lg shadow-[var(--accent-primary-glow)]'
: 'border-[var(--border-subtle)] hover:border-[var(--border-default)] hover:bg-[var(--surface-muted)]/50 card-lift'
}`}
>
<div className="flex items-start gap-3">
<div
className={`w-11 h-11 rounded-xl flex items-center justify-center transition-all duration-300 ${
selected ? 'ring-2 ring-white/20' : ''
}`}
style={{
background: selected ? categoryColor : `${categoryColor}20`,
color: selected ? 'white' : categoryColor
}}
>
<Icon size={18} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className={`text-sm font-semibold tracking-tight ${selected ? 'text-[var(--accent-primary)]' : 'text-[var(--text-primary)]'}`}>
{template.name}
</p>
{template.isOfficial && (
<span className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-[var(--warning-soft)] text-[var(--warning)] text-[10px] font-semibold">
<Star size={8} className="fill-[var(--warning)]" />
Official
</span>
)}
</div>
<div className="flex items-center gap-2 mt-1">
<span
className="text-[10px] font-semibold uppercase tracking-wider"
style={{ color: categoryColor }}
>
{categoryLabel(template.category)}
</span>
<span className="text-[10px] text-[var(--text-tertiary)]"></span>
<span className="text-[10px] text-[var(--text-tertiary)]">{template.configRaw ? JSON.parse(template.configRaw).runtime : 'n/a'}</span>
</div>
<p className="text-xs text-[var(--text-tertiary)] mt-2 line-clamp-2 group-hover:text-[var(--text-secondary)] transition-colors">{template.description}</p>
</div>
<ArrowRight
size={14}
className={`text-[var(--text-tertiary)] transition-all duration-300 ${
selected ? 'opacity-100 text-[var(--accent-primary)]' : 'opacity-0 group-hover:opacity-100'
}`}
/>
</div>
</button>
);
})}
</div>
</div>
) : null}
</section>
{/* Template Detail */}
<section className="panel p-6">
{!selectedTemplateId ? (
<div className="py-16 text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-[var(--surface-muted)] flex items-center justify-center">
<Layers size={28} className="text-[var(--text-tertiary)]" />
</div>
<p className="text-sm text-[var(--text-muted)]">Select a template to view details and deploy</p>
</div>
) : null}
{selectedTemplateId && !isDemoMode && templateDetailQuery.isLoading ? (
<div className="py-16 text-center">
<Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
<p className="mt-3 text-sm text-[var(--text-muted)]">Loading template details...</p>
</div>
) : null}
{selectedTemplateId && !isDemoMode && templateDetailQuery.isError ? (
<div className="py-16 text-center">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center">
<Box size={24} className="text-[var(--error)]" />
</div>
<p className="text-sm text-[var(--error)]">Failed to load template details</p>
</div>
) : null}
{selectedDetail ? (
<>
{/* Header */}
<div className="flex items-start justify-between gap-4 mb-6">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
{(() => {
const Icon = categoryIcon(selectedDetail.template.category);
return <Icon size={24} className="text-[var(--accent-primary)]" />;
})()}
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-xl font-semibold text-[var(--text-primary)]">{selectedDetail.template.name}</h2>
{selectedDetail.template.isOfficial && (
<span className="flex items-center gap-1 px-2 py-0.5 rounded-full bg-[var(--warning-soft)] text-[var(--warning)] text-xs font-medium">
<Star size={10} className="fill-[var(--warning)]" />
Official
</span>
)}
</div>
<p className="text-sm text-[var(--text-secondary)] mt-1">{selectedDetail.template.description}</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="px-3 py-1 rounded-full border border-[var(--border-subtle)] text-xs text-[var(--text-tertiary)]">
{categoryLabel(selectedDetail.template.category)}
</span>
<span className="px-3 py-1 rounded-full border border-[var(--border-subtle)] text-xs text-[var(--text-tertiary)]">
{selectedDetail.config.runtime || 'n/a'}
</span>
</div>
</div>
{/* Config Grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="panel-soft p-4">
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Type</p>
<p className="mono text-sm text-[var(--text-primary)] mt-2">{selectedDetail.config.type || '—'}</p>
</div>
<div className="panel-soft p-4">
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Port</p>
<p className="mono text-sm text-[var(--text-primary)] mt-2">{selectedDetail.config.port || '—'}</p>
</div>
<div className="panel-soft p-4">
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Build</p>
<p className="mono text-xs text-[var(--text-primary)] mt-2 truncate">{selectedDetail.config.buildCommand || '—'}</p>
</div>
<div className="panel-soft p-4">
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Start</p>
<p className="mono text-xs text-[var(--text-primary)] mt-2 truncate">{selectedDetail.config.startCommand || '—'}</p>
</div>
</div>
<div className="panel-soft p-4 mb-6">
<p className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">How Templates Work</p>
<p className="text-sm text-[var(--text-secondary)]">
Template defaults are merged with your variable inputs. Creating from template saves a stopped service in the selected project, then you deploy it from the service detail page.
</p>
{selectedDetail.config.type === 'database' && (
<p className="text-sm text-[var(--text-secondary)] mt-2">
Database templates create preconfigured database services with credentials and runtime settings, so you only need to provide required secrets.
</p>
)}
</div>
{/* Deploy Section */}
<div className="border-t border-[var(--border-subtle)] pt-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--success-soft)] flex items-center justify-center">
<Play size={18} className="text-[var(--success)]" />
</div>
<div>
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Deploy from Template</h3>
<p className="text-sm text-[var(--text-secondary)]">Configure and create a new service</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Project
</label>
<select
value={deployProjectId}
onChange={(e) => setDeployProjectId(e.target.value)}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
>
{projectOptions.length === 0 && <option value="">No projects available</option>}
{projectOptions.map((project) => (
<option key={project.id} value={project.id}>{project.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
Service Name
</label>
<input
value={deployName}
onChange={(e) => {
if (!selectedTemplateId) return;
setDeployNameByTemplate((current) => ({
...current,
[selectedTemplateId]: e.target.value,
}));
}}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
placeholder="service-name"
/>
</div>
</div>
{selectedDetail.variables.length > 0 && (
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
{selectedDetail.variables.map((variable) => {
const hasError = variable.required && !(variableValues[variable.key] ?? '').trim();
return (
<div key={variable.key}>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
{variable.label}
{variable.required && <span className="text-[var(--error)] ml-1">*</span>}
</label>
<input
type={variable.secret ? 'password' : 'text'}
value={variableValues[variable.key] ?? ''}
onChange={(e) => {
if (!selectedTemplateId) return;
setVariableValuesByTemplate((current) => ({
...current,
[selectedTemplateId]: {
...(current[selectedTemplateId] ?? {}),
[variable.key]: e.target.value,
},
}));
}}
className={`w-full h-10 px-3 rounded-[var(--radius-md)] border bg-[var(--surface-muted)] text-sm transition-colors ${
hasError ? 'border-[var(--error)]' : 'border-[var(--border-subtle)] focus:border-[var(--accent-primary)]'
}`}
placeholder={variable.defaultValue}
/>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
{variable.description}
</p>
</div>
);
})}
</div>
)}
{missingRequiredVariables.length > 0 && (
<div className="mt-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
Fill required variables: {missingRequiredVariables.map((v) => v.key).join(', ')}
</div>
)}
{!isDemoMode && projectsQuery.isError && (
<div className="mt-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
Failed to load projects
</div>
)}
{deployMutation.isError && (
<div className="mt-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
{deployMutation.error instanceof Error ? deployMutation.error.message : 'Failed to create service'}
</div>
)}
{lastDeployment && (
<div className="mt-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--success-soft)] text-sm text-[var(--success)]">
<div className="flex items-center gap-2">
<Check size={16} />
<span>
Service <span className="mono font-medium">{lastDeployment.serviceName}</span> created
</span>
<Link
to={`/projects/${lastDeployment.projectId}/services/${lastDeployment.serviceId}${isDemoMode ? '?demo=1' : ''}`}
className="flex items-center gap-1 ml-2 underline underline-offset-2 hover:no-underline"
>
View <ArrowRight size={12} />
</Link>
</div>
</div>
)}
<div className="mt-6">
<button
onClick={() => deployMutation.mutate()}
disabled={isDeployDisabled}
className="flex items-center gap-2 h-11 px-6 rounded-[var(--radius-md)] text-white text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all"
style={{ background: '#e8316a' }}
>
{deployMutation.isPending ? (
<>
<Loader2 size={16} className="animate-spin" />
Creating...
</>
) : (
<>
<Play size={16} />
Create Service
</>
)}
</button>
</div>
</div>
</>
) : null}
</section>
</div>
</div>
</div>
);
}
@@ -0,0 +1,202 @@
import type { ServiceEntity } from '@/lib/api-client';
import type { CanvasEdge } from './model';
export type ServiceVariable = {
key: string;
value: string;
isSecret: boolean;
};
type LinkRecord = {
edge: CanvasEdge;
reasons: string[];
};
function normalizeToken(raw: string): string {
return raw.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
}
function slugifyServiceName(name: string): string {
return normalizeToken(name);
}
function tokenizePlaceholders(value: string): string[] {
if (!value || typeof value !== 'string') {
return [];
}
const matches = value.match(/\{\{\s*([^{}]+?)\s*\}\}/g);
if (!matches) {
return [];
}
return matches
.map((match) => match.replace(/\{\{|\}\}/g, '').trim())
.map(normalizeToken)
.filter(Boolean);
}
function candidateTokensForService(service: ServiceEntity): Set<string> {
const slug = slugifyServiceName(service.name);
const tokens = new Set<string>([
slug,
`${slug}_url`,
`${slug}_uri`,
`${slug}_host`,
`${slug}_port`,
`${slug}_database_url`,
]);
if (service.type === 'database') {
tokens.add('database_url');
tokens.add('database_host');
tokens.add('database_port');
if (slug.includes('postgres') || slug.includes('pg')) {
tokens.add('postgres');
tokens.add('postgres_url');
tokens.add('postgres_host');
tokens.add('postgres_port');
tokens.add('db_url');
tokens.add('db_host');
tokens.add('db_port');
}
if (slug.includes('redis')) {
tokens.add('redis');
tokens.add('redis_url');
tokens.add('redis_host');
tokens.add('redis_port');
tokens.add('cache_url');
}
if (slug.includes('mysql')) {
tokens.add('mysql');
tokens.add('mysql_url');
tokens.add('mysql_host');
tokens.add('mysql_port');
}
if (slug.includes('mongo')) {
tokens.add('mongo');
tokens.add('mongodb');
tokens.add('mongodb_url');
tokens.add('mongo_url');
}
}
return tokens;
}
function buildTokenIndex(services: ServiceEntity[]): Map<string, string[]> {
const index = new Map<string, string[]>();
const push = (token: string, serviceId: string) => {
const values = index.get(token);
if (values) {
if (!values.includes(serviceId)) {
values.push(serviceId);
}
return;
}
index.set(token, [serviceId]);
};
const databaseServices = services.filter((service) => service.type === 'database');
const primaryDatabase = databaseServices[0];
for (const service of services) {
for (const token of candidateTokensForService(service)) {
push(token, service.id);
}
}
if (primaryDatabase) {
for (const genericToken of ['db', 'db_url', 'db_host', 'db_port']) {
push(genericToken, primaryDatabase.id);
}
}
return index;
}
function resolveTokenTarget(token: string, sourceService: ServiceEntity, services: ServiceEntity[], index: Map<string, string[]>): string[] {
const exact = index.get(token) ?? [];
const resolvedExact = exact.filter((candidate) => candidate !== sourceService.id);
if (resolvedExact.length > 0) {
return resolvedExact;
}
const potential = services
.filter((service) => service.id !== sourceService.id)
.filter((service) => {
const slug = slugifyServiceName(service.name);
return token.includes(slug) && (token.endsWith('url') || token.endsWith('uri') || token.endsWith('host') || token.endsWith('port'));
})
.map((service) => service.id);
if (potential.length > 0) {
return potential;
}
if (token.startsWith('db_') || token === 'database_url') {
const dbServices = services.filter((service) => service.id !== sourceService.id && service.type === 'database');
if (dbServices.length === 1) {
return [dbServices[0].id];
}
}
return [];
}
export function inferAutoConnections(
services: ServiceEntity[],
variablesByService: Record<string, ServiceVariable[]>,
): LinkRecord[] {
if (services.length === 0) {
return [];
}
const serviceMap = new Map(services.map((service) => [service.id, service]));
const tokenIndex = buildTokenIndex(services);
const linkMap = new Map<string, LinkRecord>();
for (const service of services) {
const variables = variablesByService[service.id] ?? [];
for (const variable of variables) {
const tokens = tokenizePlaceholders(variable.value);
for (const token of tokens) {
const targetIds = resolveTokenTarget(token, service, services, tokenIndex);
for (const targetId of targetIds) {
const targetService = serviceMap.get(targetId);
if (!targetService || targetService.id === service.id) {
continue;
}
const key = `${service.id}->${targetService.id}`;
const existing = linkMap.get(key);
const reason = `${variable.key}:{{${token}}}`;
if (existing) {
if (!existing.reasons.includes(reason)) {
existing.reasons.push(reason);
}
continue;
}
linkMap.set(key, {
edge: {
id: `auto-${service.id}-${targetService.id}`,
sourceServiceId: service.id,
targetServiceId: targetService.id,
},
reasons: [reason],
});
}
}
}
}
return Array.from(linkMap.values());
}
@@ -0,0 +1,467 @@
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from 'react';
import {
Background,
MarkerType,
ReactFlow,
ReactFlowProvider,
useNodesState,
useReactFlow,
type Edge,
type Node,
type NodeTypes,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import type { ServiceEntity } from '@/lib/api-client';
import { inferAutoConnections, type ServiceVariable } from '../auto-connections';
import {
type CanvasGroup,
type CanvasNodeLayout,
type ProjectCanvasMetadata,
} from '../model';
import { loadCanvasMetadata, saveCanvasMetadata } from '../storage';
import { GroupNode, ServiceNode, type GroupNodeData, type ServiceNodeData } from './nodes';
import { Plus, Layers, Maximize2, RotateCcw, Box, Link2 } from 'lucide-react';
type CanvasProps = {
projectId: string;
services: ServiceEntity[];
variablesByService: Record<string, ServiceVariable[]>;
onAddService: () => void;
onOpenService: (serviceId: string) => void;
};
type ServiceCanvasNode = Node<ServiceNodeData, 'serviceNode'>;
type GroupCanvasNode = Node<GroupNodeData, 'groupNode'>;
type CanvasNode = ServiceCanvasNode | GroupCanvasNode;
type CanvasEdge = Edge<{ reasons: string[] }>;
const SERVICE_NODE_WIDTH = 210;
const SERVICE_NODE_HEIGHT = 96;
const GROUP_DEFAULT_WIDTH = 340;
const GROUP_DEFAULT_HEIGHT = 230;
const nodeTypes: NodeTypes = {
serviceNode: ServiceNode,
groupNode: GroupNode,
};
function toFlowNodes(metadata: ProjectCanvasMetadata, services: ServiceEntity[], onOpenService: CanvasProps['onOpenService']): CanvasNode[] {
const groups = metadata.groups.map(
(group): GroupCanvasNode => ({
id: group.id,
type: 'groupNode',
data: { title: group.title },
position: group.position,
draggable: true,
selectable: true,
style: {
width: group.width,
height: group.height,
},
}),
);
const layoutMap = new Map(metadata.nodes.map((layout) => [layout.serviceId, layout]));
const groupIds = new Set(groups.map((group) => group.id));
const serviceNodes = services.map((service): ServiceCanvasNode => {
const layout = layoutMap.get(service.id);
const parentId = layout?.groupId && groupIds.has(layout.groupId) ? layout.groupId : undefined;
return {
id: service.id,
type: 'serviceNode',
position: layout?.position ?? { x: 0, y: 0 },
parentId,
extent: parentId ? 'parent' : undefined,
data: {
service,
selected: false,
onOpen: onOpenService,
},
draggable: true,
selectable: true,
style: {
width: SERVICE_NODE_WIDTH,
},
};
});
return [...groups, ...serviceNodes];
}
function toFlowEdges(links: ReturnType<typeof inferAutoConnections>): CanvasEdge[] {
return links.map((link) => ({
id: link.edge.id,
source: link.edge.sourceServiceId,
target: link.edge.targetServiceId,
animated: false,
data: {
reasons: link.reasons,
},
markerEnd: {
type: MarkerType.ArrowClosed,
color: 'var(--accent-secondary)',
width: 14,
height: 14,
},
style: {
stroke: '#e8316a',
strokeWidth: 2,
},
className: 'edge-premium',
}));
}
function buildMetadataFromFlow(nodes: CanvasNode[], viewport: { x: number; y: number; zoom: number }): ProjectCanvasMetadata {
const groups: CanvasGroup[] = [];
const layouts: CanvasNodeLayout[] = [];
for (const node of nodes) {
if (node.type === 'groupNode') {
const width = typeof node.style?.width === 'number' ? node.style.width : GROUP_DEFAULT_WIDTH;
const height = typeof node.style?.height === 'number' ? node.style.height : GROUP_DEFAULT_HEIGHT;
groups.push({
id: node.id,
title: node.data.title,
position: node.position,
width,
height,
});
continue;
}
if (node.type === 'serviceNode') {
layouts.push({
serviceId: node.id,
position: node.position,
groupId: node.parentId,
});
}
}
return {
groups,
nodes: layouts,
edges: [],
viewport,
};
}
function CanvasInner({ projectId, services, variablesByService, onAddService, onOpenService }: CanvasProps) {
const wrapperRef = useRef<HTMLDivElement | null>(null);
const persistTimeout = useRef<number | null>(null);
const hydratedRef = useRef(false);
const [nodes, setNodes, onNodesChange] = useNodesState<CanvasNode>([]);
const [selectedServiceId, setSelectedServiceId] = useState<string | null>(null);
const [viewportTick, setViewportTick] = useState(0);
const { fitView, getInternalNode, getViewport, screenToFlowPosition, setViewport } = useReactFlow<CanvasNode, CanvasEdge>();
const serviceFingerprint = useMemo(
() => services.map((service) => service.id).sort().join('|'),
[services],
);
const inferredLinks = useMemo(
() => inferAutoConnections(services, variablesByService),
[services, variablesByService],
);
const edges = useMemo(() => toFlowEdges(inferredLinks), [inferredLinks]);
useEffect(() => {
const metadata = loadCanvasMetadata(projectId, services);
const nextNodes = toFlowNodes(metadata, services, onOpenService);
setNodes(nextNodes);
window.requestAnimationFrame(() => {
setViewport(metadata.viewport, { duration: 120 });
});
hydratedRef.current = true;
}, [projectId, serviceFingerprint, onOpenService, setNodes, setViewport, services]);
useEffect(() => {
if (!hydratedRef.current) {
return;
}
if (persistTimeout.current) {
window.clearTimeout(persistTimeout.current);
}
persistTimeout.current = window.setTimeout(() => {
const metadata = buildMetadataFromFlow(nodes, getViewport());
saveCanvasMetadata(projectId, metadata);
}, 150);
return () => {
if (persistTimeout.current) {
window.clearTimeout(persistTimeout.current);
}
};
}, [getViewport, nodes, projectId, viewportTick]);
useEffect(() => {
setNodes((prev) =>
prev.map((node) => {
if (node.type !== 'serviceNode') {
return node;
}
return {
...node,
data: {
...node.data,
selected: node.id === selectedServiceId,
},
};
}),
);
}, [selectedServiceId, setNodes]);
const getGroupUnderPoint = useCallback((x: number, y: number, ignoreGroupId?: string) => {
for (const node of nodes) {
if (node.type !== 'groupNode' || node.id === ignoreGroupId) {
continue;
}
const width = typeof node.style?.width === 'number' ? node.style.width : GROUP_DEFAULT_WIDTH;
const height = typeof node.style?.height === 'number' ? node.style.height : GROUP_DEFAULT_HEIGHT;
const base = getInternalNode(node.id)?.internals.positionAbsolute ?? node.position;
if (x >= base.x && x <= base.x + width && y >= base.y && y <= base.y + height) {
return node;
}
}
return null;
}, [getInternalNode, nodes]);
const onNodeDragStop = useCallback(
(_event: MouseEvent, movedNode: CanvasNode) => {
if (movedNode.type !== 'serviceNode') {
return;
}
const basePosition = getInternalNode(movedNode.id)?.internals.positionAbsolute ?? movedNode.position;
const centerX = basePosition.x + SERVICE_NODE_WIDTH / 2;
const centerY = basePosition.y + SERVICE_NODE_HEIGHT / 2;
const targetGroup = getGroupUnderPoint(centerX, centerY, movedNode.parentId);
if (targetGroup) {
const groupBase = getInternalNode(targetGroup.id)?.internals.positionAbsolute ?? targetGroup.position;
const targetWidth = typeof targetGroup.style?.width === 'number' ? targetGroup.style.width : GROUP_DEFAULT_WIDTH;
const targetHeight = typeof targetGroup.style?.height === 'number' ? targetGroup.style.height : GROUP_DEFAULT_HEIGHT;
const relativeX = Math.max(12, Math.min(targetWidth - SERVICE_NODE_WIDTH - 12, basePosition.x - groupBase.x));
const relativeY = Math.max(30, Math.min(targetHeight - SERVICE_NODE_HEIGHT - 12, basePosition.y - groupBase.y));
setNodes((current) =>
current.map((node) => {
if (node.id !== movedNode.id || node.type !== 'serviceNode') {
return node;
}
return {
...node,
parentId: targetGroup.id,
extent: 'parent',
position: { x: relativeX, y: relativeY },
};
}),
);
return;
}
if (!movedNode.parentId) {
return;
}
setNodes((current) =>
current.map((node) => {
if (node.id !== movedNode.id || node.type !== 'serviceNode') {
return node;
}
return {
...node,
parentId: undefined,
extent: undefined,
position: {
x: basePosition.x,
y: basePosition.y,
},
};
}),
);
},
[getGroupUnderPoint, getInternalNode, setNodes],
);
const addGroup = useCallback(() => {
const bounds = wrapperRef.current?.getBoundingClientRect();
const center = bounds
? screenToFlowPosition({
x: bounds.left + bounds.width / 2,
y: bounds.top + bounds.height / 2,
})
: { x: 180, y: 140 };
const id = `group-${Date.now()}`;
setNodes((current) => [
...current,
{
id,
type: 'groupNode',
position: {
x: center.x - GROUP_DEFAULT_WIDTH / 2,
y: center.y - GROUP_DEFAULT_HEIGHT / 2,
},
data: { title: `Group ${current.filter((node) => node.type === 'groupNode').length + 1}` },
draggable: true,
selectable: true,
style: {
width: GROUP_DEFAULT_WIDTH,
height: GROUP_DEFAULT_HEIGHT,
},
} satisfies GroupCanvasNode,
]);
}, [screenToFlowPosition, setNodes]);
const selectedService = services.find((service) => service.id === selectedServiceId) ?? null;
const selectedServiceLinkCount = selectedService
? inferredLinks.filter(
(link) =>
link.edge.sourceServiceId === selectedService.id || link.edge.targetServiceId === selectedService.id,
).length
: 0;
return (
<div className="panel overflow-hidden">
{/* Toolbar - Railway-inspired premium design */}
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/60 backdrop-blur-xl px-4 py-3">
<button
type="button"
onClick={onAddService}
className="flex items-center gap-2 h-9 px-4 rounded-lg text-white text-sm font-medium shadow-lg hover:shadow-xl transition-all duration-200"
style={{ background: '#e8316a' }}
>
<Plus size={15} />
Add Service
</button>
<div className="w-px h-5 bg-[var(--border-subtle)] mx-1" />
<button
type="button"
onClick={addGroup}
className="flex items-center gap-2 h-9 px-3 rounded-lg bg-[var(--surface-card)] border border-[var(--border-subtle)] text-sm font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-card-hover)] hover:border-[var(--border-default)] transition-all"
>
<Layers size={14} />
Group
</button>
<div className="ml-auto flex items-center gap-2">
<button
type="button"
onClick={() => fitView({ padding: 0.2, duration: 240 })}
className="w-9 h-9 rounded-lg flex items-center justify-center text-[var(--text-tertiary)] hover:text-white hover:bg-[var(--surface-card)] border border-transparent hover:border-[var(--border-subtle)] transition-all"
title="Fit View"
>
<Maximize2 size={16} />
</button>
<button
type="button"
onClick={() => setViewport({ x: 0, y: 0, zoom: 1 }, { duration: 180 })}
className="w-9 h-9 rounded-lg flex items-center justify-center text-[var(--text-tertiary)] hover:text-white hover:bg-[var(--surface-card)] border border-transparent hover:border-[var(--border-subtle)] transition-all"
title="Reset View"
>
<RotateCcw size={16} />
</button>
</div>
<div className="flex items-center gap-4 px-3 py-1.5 rounded-full bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
<div className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)]">
<Box size={12} />
<span className="font-medium">{services.length}</span>
</div>
<div className="w-px h-3 bg-[var(--border-subtle)]" />
<div className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)]">
<Link2 size={12} />
<span className="font-medium">{edges.length}</span>
</div>
</div>
</div>
{/* Canvas */}
<div ref={wrapperRef} className="subtle-grid h-[66vh] min-h-[420px] bg-[var(--bg-void)]">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onPaneClick={() => setSelectedServiceId(null)}
onNodeClick={(_event, node) => {
if (node.type === 'serviceNode') {
setSelectedServiceId(node.id);
}
}}
onNodeDoubleClick={(_event, node) => {
if (node.type === 'serviceNode') {
onOpenService(node.id);
}
}}
onNodeDragStop={onNodeDragStop}
onMoveEnd={() => setViewportTick((value) => value + 1)}
fitView
panOnDrag
zoomOnScroll
minZoom={0.25}
maxZoom={2.3}
deleteKeyCode={null}
proOptions={{ hideAttribution: true }}
>
{/* SVG Definitions for premium edge styling */}
<svg style={{ position: 'absolute', top: 0, left: 0, pointerEvents: 'none' }}>
<defs />
</svg>
<Background color="rgba(255,255,255,0.05)" gap={28} />
</ReactFlow>
</div>
{/* Footer */}
<div className="border-t border-[var(--border-subtle)] bg-[var(--bg-base)]/60 backdrop-blur-xl px-4 py-3">
{selectedService ? (
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-[var(--surface-card)] border border-[var(--border-subtle)] flex items-center justify-center">
<Box size={14} className="text-[var(--accent-primary)]" />
</div>
<div>
<span className="text-sm font-medium text-[var(--text-primary)]">{selectedService.name}</span>
<span className="ml-2 text-xs text-[var(--text-tertiary)]">({selectedService.type})</span>
</div>
</div>
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-[var(--surface-muted)] text-xs text-[var(--text-tertiary)]">
<Link2 size={10} />
<span>{selectedServiceLinkCount} connection{selectedServiceLinkCount !== 1 ? 's' : ''}</span>
</div>
</div>
) : (
<p className="text-xs text-[var(--text-tertiary)] text-center">
Click a service to select Double-click to open Drag to reposition Connections auto-inferred from variables
</p>
)}
</div>
</div>
);
}
export function ProjectCanvas(props: CanvasProps) {
return (
<ReactFlowProvider>
<CanvasInner {...props} />
</ReactFlowProvider>
);
}
@@ -0,0 +1,191 @@
import type { Node, NodeProps } from '@xyflow/react';
import type { ServiceEntity } from '@/lib/api-client';
import { serviceStatusClass } from '@/lib/api-client';
import { Box, ArrowUpRight, Globe, Database, Terminal, MoreHorizontal, ExternalLink } from 'lucide-react';
import { useState } from 'react';
export type ServiceNodeData = {
service: ServiceEntity;
selected: boolean;
onOpen: (serviceId: string) => void;
};
export type GroupNodeData = {
title: string;
};
export type ServiceNodeType = Node<ServiceNodeData, 'serviceNode'>;
export type GroupNodeType = Node<GroupNodeData, 'groupNode'>;
function serviceTypeIcon(type: string): typeof Box {
switch (type) {
case 'web':
return Globe;
case 'database':
return Database;
case 'worker':
return Terminal;
default:
return Box;
}
}
function serviceTypeColor(type: string): string {
switch (type) {
case 'web':
return '#6c8ef0'; // Blue
case 'database':
return '#9c7ef0'; // Purple
case 'worker':
return '#e8316a'; // Pink
default:
return '#9295a4'; // Gray
}
}
export function ServiceNode({ data }: NodeProps<ServiceNodeType>) {
const { service, selected, onOpen } = data;
const iconType = serviceTypeIcon(service.type);
const typeColor = serviceTypeColor(service.type);
const isRunning = service.status === 'running';
// Generate a mock domain for display (in real app, this would come from service data)
const domain = `${service.name}.containr.local`;
return (
<div
className={`rounded-[var(--radius-lg)] border transition-all duration-200 group relative overflow-hidden ${
selected
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-lg shadow-[var(--accent-primary-glow)]'
: 'border-[var(--border-subtle)] bg-[var(--surface-card)] hover:border-[var(--border-default)] hover:shadow-lg'
}`}
style={{ minWidth: 220, maxWidth: 280 }}
>
{/* Ambient overlay */}
<div
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
style={{ background: `${typeColor}10` }}
/>
{/* Status indicator bar at top */}
<div
className={`absolute top-0 left-0 right-0 h-0.5 transition-all duration-300 ${isRunning ? 'opacity-100' : 'opacity-40'}`}
style={{ background: isRunning ? 'var(--success)' : 'var(--text-tertiary)' }}
/>
{/* Action menu on hover */}
<button className="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-all duration-200 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] p-1.5 rounded-lg hover:bg-[var(--surface-muted)]">
<MoreHorizontal size={14} />
</button>
<div className="relative p-4">
{/* Header with icon and name */}
<div className="flex items-start gap-3 mb-3">
<div
className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 transition-all duration-200 ${
selected ? 'ring-2 ring-white/20' : ''
}`}
style={{
background: selected ? typeColor : `${typeColor}20`,
color: selected ? 'white' : typeColor
}}
>
{iconType === Globe && <Globe size={18} />}
{iconType === Database && <Database size={18} />}
{iconType === Terminal && <Terminal size={18} />}
{iconType === Box && <Box size={18} />}
</div>
<div className="min-w-0 flex-1 pt-0.5">
<h4 className="font-semibold text-sm text-[var(--text-primary)] truncate tracking-tight">{service.name}</h4>
<p className="text-[11px] text-[var(--text-tertiary)] truncate mt-0.5">{service.type}</p>
</div>
</div>
{/* Domain display - Railway style */}
<div className="flex items-center gap-2 mb-3 px-2.5 py-1.5 rounded-lg bg-[var(--surface-muted)]/50 border border-[var(--border-subtle)]/50">
<ExternalLink size={10} className="text-[var(--text-tertiary)] flex-shrink-0" />
<span className="text-[10px] text-[var(--text-secondary)] truncate mono">{domain}</span>
</div>
{/* Status and action row */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={`status-dot ${serviceStatusClass(service.status)}`} />
<span
className="text-[11px] font-semibold tracking-wide uppercase"
style={{ color: isRunning ? 'var(--success)' : 'var(--text-tertiary)' }}
>
{isRunning ? 'Online' : service.status}
</span>
</div>
<button
type="button"
onClick={() => onOpen(service.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-semibold transition-all duration-200 ${
selected
? 'bg-[var(--accent-primary)] text-white shadow-lg shadow-[var(--accent-primary-glow)]'
: 'border border-[var(--border-subtle)] text-[var(--text-secondary)] hover:border-[var(--accent-primary)] hover:text-[var(--accent-primary)]'
}`}
>
Open
<ArrowUpRight size={10} />
</button>
</div>
</div>
</div>
);
}
export function GroupNode({ data }: NodeProps<GroupNodeType>) {
const [isHovered, setIsHovered] = useState(false);
return (
<div
className="h-full w-full rounded-[var(--radius-xl)] border-2 border-dashed transition-all duration-200 relative overflow-hidden"
style={{
borderColor: isHovered ? 'var(--accent-primary)' : 'var(--border-default)',
background: isHovered ? 'rgba(232, 49, 106, 0.03)' : 'var(--surface-muted)/30'
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Ambient glow on hover */}
<div
className="absolute inset-0 opacity-0 transition-opacity duration-300 pointer-events-none"
style={{
background: 'rgba(232,49,106,0.03)',
opacity: isHovered ? 1 : 0
}}
/>
{/* Header */}
<div className="relative p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div
className="w-7 h-7 rounded-lg flex items-center justify-center transition-colors duration-200"
style={{
background: isHovered ? 'var(--accent-primary-soft)' : 'var(--surface-card)',
color: isHovered ? 'var(--accent-primary)' : 'var(--text-tertiary)'
}}
>
<Box size={14} />
</div>
<h3 className="text-sm font-semibold text-[var(--text-primary)] tracking-tight">{data.title}</h3>
</div>
{isHovered && (
<button className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors p-1 rounded-lg hover:bg-[var(--surface-muted)]">
<MoreHorizontal size={14} />
</button>
)}
</div>
{/* Helper text */}
<p className="text-[10px] text-[var(--text-tertiary)] mt-2 opacity-0 transition-opacity duration-200" style={{ opacity: isHovered ? 1 : 0 }}>
Drag services here to group them
</p>
</div>
</div>
);
}
@@ -0,0 +1,306 @@
import { useEffect, useState } from 'react';
import {
LineMetricChart,
DualLineChart,
DonutChart,
} from '@/shared/components';
interface MetricData {
cpu: number[];
ram: number;
ramUsed: string;
cache: number;
cacheBreakdown: { cache: number; nonCache: number; total: number };
users: number[];
performance: number[];
performanceAlt: number[];
upSpeed: number;
downSpeed: number;
}
interface MetricsDashboardProps {
projectId?: string;
isRunning?: boolean;
onStop?: () => void;
onRestart?: () => void;
}
function getRandom(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function generateInitialData(): MetricData {
return {
cpu: Array.from({ length: 24 }, () => getRandom(10, 45)),
ram: getRandom(50, 85),
ramUsed: '5.4 GB',
cache: 352,
cacheBreakdown: { cache: 212, nonCache: 85.5, total: 1750 },
users: Array.from({ length: 15 }, () => getRandom(40, 110)),
performance: Array.from({ length: 12 }, () => getRandom(70, 95)),
performanceAlt: Array.from({ length: 12 }, () => getRandom(60, 85)),
upSpeed: 10.4,
downSpeed: 5.2,
};
}
export function MetricsDashboard({
isRunning = true,
}: MetricsDashboardProps) {
const [data, setData] = useState<MetricData>(generateInitialData);
const [timeRange, setTimeRange] = useState<'day' | 'month' | 'year'>('day');
useEffect(() => {
if (!isRunning) return;
const interval = setInterval(() => {
setData((prev) => {
const newCpu = [...prev.cpu.slice(1), getRandom(10, 45)];
const newUsers = [...prev.users.slice(1), getRandom(60, 110)];
const newPerf = [...prev.performance.slice(1), getRandom(82, 99)];
const newPerfAlt = [...prev.performanceAlt.slice(1), getRandom(60, 85)];
return {
...prev,
cpu: newCpu,
ram: getRandom(50, 85),
ramUsed: `${(8 * (prev.ram / 100)).toFixed(1)} GB`,
users: newUsers,
performance: newPerf,
performanceAlt: newPerfAlt,
upSpeed: parseFloat((Math.random() * 5 + 8).toFixed(1)),
downSpeed: parseFloat((Math.random() * 3 + 4).toFixed(1)),
};
});
}, 2000);
return () => clearInterval(interval);
}, [isRunning]);
const currentCpu = data.cpu[data.cpu.length - 1];
const currentUsers = data.users[data.users.length - 1];
const currentPerf = data.performance[data.performance.length - 1];
return (
<div>
{/* Metrics Header - self.html exact match */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
<span style={{ fontSize: '15px', fontWeight: 700, color: '#e8e9f0' }}>Metrics</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button style={{
height: '32px',
padding: '0 12px',
borderRadius: '9px',
border: '1px solid rgba(255,255,255,0.09)',
background: 'rgba(255,255,255,0.04)',
color: '#9295a4',
fontSize: '12.5px',
fontWeight: 500,
fontFamily: 'inherit',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
</svg>
Filter
</button>
<div className="pill-group">
{(['day', 'month', 'year'] as const).map((range) => (
<div
key={range}
className={`pill ${timeRange === range ? 'active' : ''}`}
onClick={() => setTimeRange(range)}
>
{range.charAt(0).toUpperCase() + range.slice(1)}
</div>
))}
</div>
</div>
</div>
{/* Row 1: CPU, RAM, Cache - self.html exact: 13px gap */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1.95fr', gap: '13px', marginBottom: '13px' }}>
{/* CPU Card */}
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
<div className="card-icon"><svg viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/></svg></div>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>CPU Usage</span>
</div>
<div style={{ fontSize: '38px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{currentCpu}%</div>
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>
<span style={{ color: currentCpu < 50 ? '#3dd68c' : currentCpu < 75 ? '#f0a040' : '#ff7043', fontWeight: 700 }}>
{currentCpu < 50 ? 'Good' : currentCpu < 75 ? 'Average' : 'High'}
</span>{' '}
Daily usage
</div>
<div style={{ height: 76, margin: '12px 0 6px' }}>
<LineMetricChart data={data.cpu} color="#ff7043" height={76} />
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '4px' }}>
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span>
<div className="arrow-btn">
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
</div>
{/* RAM Card */}
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
<div className="card-icon"><svg viewBox="0 0 24 24"><rect x="2" y="8" width="20" height="8" rx="2"/><path d="M6 8V6M10 8V6M14 8V6M18 8V6M6 16v2M18 16v2"/></svg></div>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>RAM Usage</span>
</div>
<div style={{ fontSize: '38px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{data.ram}%</div>
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>
<span style={{ color: data.ram < 60 ? '#3dd68c' : '#f0a040', fontWeight: 700 }}>
{data.ram < 60 ? 'Good' : 'Average'}
</span>{' '}
Daily usage
</div>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', margin: '10px 0 4px', position: 'relative' }}>
<DonutChart percent={data.ram} color="#9c7ef0" size={160} thickness={16} />
<div style={{ position: 'absolute', bottom: 14, textAlign: 'center' }}>
<div style={{ fontSize: '10.5px', color: '#6b6e7d', marginBottom: 1 }}>Used</div>
<div style={{ fontSize: '12.5px', fontWeight: 700, color: '#e8e9f0' }}>{data.ramUsed} / 8GB</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '4px' }}>
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span>
<div className="arrow-btn">
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
</div>
{/* Cache Card */}
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
<div className="card-icon"><svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></div>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>Cache</span>
</div>
<div style={{ fontSize: '38px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{data.cache} MB</div>
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>
<span style={{ color: '#f0a040', fontWeight: 700 }}>220MB Average</span>{' '}
cached images and files
</div>
{/* Segmented Bar */}
<div style={{ display: 'flex', alignItems: 'center', gap: '5px', margin: '16px 0 15px', height: '32px' }}>
<div className="cache-seg" style={{ width: '43%', background: '#ff6b5b', borderRadius: '10px 4px 4px 10px' }} />
<div className="cache-seg" style={{ width: '13%', background: '#8c6ef0', borderRadius: '5px' }} />
<div className="cache-seg" style={{ flex: 1, background: 'rgba(255,255,255,0.07)', borderRadius: '4px 10px 10px 4px' }} />
</div>
{/* Stats row */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto 1fr auto 1fr', gap: 0, alignItems: 'stretch' }}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '11px', color: '#6b6e7d', marginBottom: '5px' }}>
<div className="stat-dot" style={{ background: '#ff6b5b' }} />
Cache
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '4px' }}>
<span style={{ fontSize: '15px', fontWeight: 800, color: '#e8e9f0' }}>212 MB</span>
<span style={{ fontSize: '11px', color: '#6b6e7d' }}>12%</span>
</div>
</div>
<div style={{ width: '1px', background: 'rgba(255,255,255,0.08)', margin: '0 16px' }} />
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '11px', color: '#6b6e7d', marginBottom: '5px' }}>
<div className="stat-dot" style={{ background: '#8c6ef0' }} />
Non-Cache
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '4px' }}>
<span style={{ fontSize: '15px', fontWeight: 800, color: '#e8e9f0' }}>85.5 MB</span>
<span style={{ fontSize: '11px', color: '#6b6e7d' }}>4%</span>
</div>
</div>
<div style={{ width: '1px', background: 'rgba(255,255,255,0.08)', margin: '0 16px' }} />
<div>
<div style={{ fontSize: '11px', color: '#6b6e7d', marginBottom: '5px' }}>Total</div>
<div style={{ fontSize: '15px', fontWeight: 800, color: '#e8e9f0' }}>1.75 GB</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '16px' }}>
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span>
<div className="arrow-btn">
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
</div>
</div>
{/* Row 2: Active Users, Performance - self.html exact: 13px gap */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '13px' }}>
{/* Active Users - horizontal layout */}
<div className="card" style={{ flexDirection: 'row', padding: 0, overflow: 'hidden' }}>
<div style={{ flex: 1, padding: '20px 18px 18px 20px', display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
<div className="card-icon"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg></div>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>Active User</span>
</div>
<div style={{ fontSize: '36px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{currentUsers} K</div>
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>User active right now</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '5px', marginTop: '12px', flexWrap: 'wrap' }}>
{['🇨🇳', '🇮🇩', '🇲🇲', '🇲🇾', '🇯🇵', '🇮🇳', '🇰🇷', '🇵🇭'].map((flag, i) => (
<span key={i} className="flag">{flag}</span>
))}
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 'auto', paddingTop: '14px' }}>
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span>
<div className="arrow-btn">
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
</div>
<div style={{ width: '50%', padding: '16px 14px 46px 0', display: 'flex', alignItems: 'flex-end' }}>
<LineMetricChart data={data.users} color="#e8316a" fillOpacity={0.15} showArea height={130} />
</div>
</div>
{/* Performance Card */}
<div className="card">
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
<div className="card-icon"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></div>
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>Performance</span>
</div>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '16px', flex: 1 }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '36px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{currentPerf}%</div>
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>
<span style={{ color: currentPerf > 85 ? '#3dd68c' : '#f0a040', fontWeight: 700 }}>
{currentPerf > 85 ? 'Good' : 'Average'}
</span>{' '}
Last scan
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '8px' }}>
<div style={{ width: 134, height: 58 }}>
<DualLineChart data1={data.performance} data2={data.performanceAlt} height={58} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'flex-end' }}>
<div className="speed-row" style={{ color: '#6c8ef0' }}>
<svg viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 19 19 12"/></svg>
<span>{data.upSpeed}</span> Mbps
</div>
<div className="speed-row" style={{ color: '#e8316a' }}>
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 5 5 12"/></svg>
<span>{data.downSpeed}</span> Mbps
</div>
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '14px' }}>
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Check Speed</span>
<div className="arrow-btn">
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,67 @@
import type { ServiceEntity } from '@/lib/api-client';
export type CanvasPoint = {
x: number;
y: number;
};
export type CanvasGroup = {
id: string;
title: string;
position: CanvasPoint;
width: number;
height: number;
};
export type CanvasNodeLayout = {
serviceId: string;
position: CanvasPoint;
groupId?: string;
};
export type CanvasEdge = {
id: string;
sourceServiceId: string;
targetServiceId: string;
};
export type CanvasViewport = {
x: number;
y: number;
zoom: number;
};
export type ProjectCanvasMetadata = {
groups: CanvasGroup[];
nodes: CanvasNodeLayout[];
edges: CanvasEdge[];
viewport: CanvasViewport;
};
export const DEFAULT_VIEWPORT: CanvasViewport = {
x: 0,
y: 0,
zoom: 1,
};
export function createDefaultCanvasMetadata(services: ServiceEntity[]): ProjectCanvasMetadata {
const nodes = services.map((service, index) => {
const col = index % 3;
const row = Math.floor(index / 3);
return {
serviceId: service.id,
position: {
x: 70 + col * 260,
y: 80 + row * 170,
},
};
});
return {
groups: [],
nodes,
edges: [],
viewport: DEFAULT_VIEWPORT,
};
}
@@ -0,0 +1,667 @@
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import {
createService,
getProjectById,
listServiceLogs,
listServiceVariables,
listServicesByProject,
type CreateServiceInput,
} from '@/lib/api-client';
import { getDemoProjectById, getDemoServicesByProject, getDemoVariablesByProject } from '@/lib/demo-data';
import { formatDate, formatRelative } from '@/lib/time';
import type { ServiceVariable } from '../auto-connections';
import { ProjectCanvas } from '../canvas/ProjectCanvas';
import { canvasStorageKey, clearCanvasMetadata } from '../storage';
import { MetricsDashboard } from '../components/MetricsDashboard';
import { CommandPalette, StatusBadge, LiveIndicator, useToast } from '@/shared/components';
import {
ArrowLeft,
LayoutGrid,
Activity,
FileText,
Settings,
Plus,
X,
Loader2,
Layers,
Clock,
Trash2,
Sparkles,
Box,
Search,
} from 'lucide-react';
type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'settings';
const viewItems: Array<{ key: WorkspaceView; label: string; icon: typeof LayoutGrid }> = [
{ key: 'canvas', label: 'Canvas', icon: LayoutGrid },
{ key: 'observability', label: 'Observability', icon: Activity },
{ key: 'logs', label: 'Logs', icon: FileText },
{ key: 'settings', label: 'Settings', icon: Settings },
];
const serviceTypes: Array<CreateServiceInput['type']> = ['web', 'worker', 'database', 'cron'];
const serviceEnvironments = ['production', 'preview', 'development'] as const;
function ServiceCreateDialog(props: {
open: boolean;
onClose: () => void;
onSubmit: (payload: CreateServiceInput) => void;
loading: boolean;
errorMessage?: string;
}) {
const [form, setForm] = useState<CreateServiceInput>({
name: '',
type: 'web',
environment: 'production',
image: '',
command: '',
});
if (!props.open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm" onClick={props.onClose} />
<div className="relative w-full max-w-lg panel p-6">
<div className="flex items-center gap-3 mb-1">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Plus size={20} className="text-[var(--accent-primary)]" />
</div>
<h3 className="text-xl font-semibold text-[var(--text-primary)]">Add Service</h3>
</div>
<p className="text-sm text-[var(--text-secondary)]">
Deploy a new service to this project. Configure runtime settings after creation.
</p>
<div className="mt-6 space-y-4">
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Service Name
</label>
<input
value={form.name}
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
placeholder="api-gateway"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Type
</label>
<select
value={form.type}
onChange={(e) => setForm((p) => ({ ...p, type: e.target.value as CreateServiceInput['type'] }))}
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
>
{serviceTypes.map((type) => (
<option key={type} value={type}>{type.charAt(0).toUpperCase() + type.slice(1)}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Environment
</label>
<select
value={form.environment}
onChange={(e) => setForm((p) => ({ ...p, environment: e.target.value as typeof serviceEnvironments[number] }))}
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
>
{serviceEnvironments.map((env) => (
<option key={env} value={env}>{env.charAt(0).toUpperCase() + env.slice(1)}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Image <span className="normal-case text-[var(--text-muted)]">(optional)</span>
</label>
<input
value={form.image ?? ''}
onChange={(e) => setForm((p) => ({ ...p, image: e.target.value }))}
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all mono text-sm"
placeholder="ghcr.io/org/app:latest"
/>
</div>
<div>
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
Command <span className="normal-case text-[var(--text-muted)]">(optional)</span>
</label>
<input
value={form.command ?? ''}
onChange={(e) => setForm((p) => ({ ...p, command: e.target.value }))}
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all mono text-sm"
placeholder="npm run start"
/>
</div>
</div>
{props.errorMessage && (
<div className="mt-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
{props.errorMessage}
</div>
)}
<div className="mt-6 flex justify-end gap-3">
<button
onClick={props.onClose}
className="px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-[var(--text-secondary)] text-sm font-medium hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors"
>
Cancel
</button>
<button
disabled={!form.name.trim() || props.loading}
onClick={() => props.onSubmit({ ...form, name: form.name.trim(), image: form.image?.trim(), command: form.command?.trim() })}
className="px-5 py-2 rounded-[var(--radius-md)] text-white text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all"
style={{ background: '#e8316a' }}
>
{props.loading ? 'Creating...' : 'Create Service'}
</button>
</div>
</div>
</div>
);
}
export function ProjectWorkspacePage() {
const { projectId = '' } = useParams<{ projectId: string }>();
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const toast = useToast();
const activeView = (searchParams.get('view') as WorkspaceView | null) ?? 'canvas';
const isDemoMode = searchParams.get('demo') === '1';
const [createOpen, setCreateOpen] = useState(false);
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setCommandPaletteOpen((open) => !open);
}
};
document.addEventListener('keydown', down);
return () => document.removeEventListener('keydown', down);
}, []);
const projectQuery = useQuery({
queryKey: ['project', projectId],
queryFn: () => getProjectById(projectId),
enabled: Boolean(projectId) && !isDemoMode,
});
const servicesQuery = useQuery({
queryKey: ['project-services', projectId],
queryFn: () => listServicesByProject(projectId),
enabled: Boolean(projectId) && !isDemoMode,
});
const createServiceMutation = useMutation({
mutationFn: (payload: CreateServiceInput) => createService(projectId, payload),
onSuccess: () => {
setCreateOpen(false);
queryClient.invalidateQueries({ queryKey: ['project-services', projectId] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
},
});
const project = useMemo(
() => (isDemoMode ? getDemoProjectById(projectId) : projectQuery.data),
[isDemoMode, projectId, projectQuery.data],
);
const services = useMemo(
() => (isDemoMode ? getDemoServicesByProject(projectId) : servicesQuery.data ?? []),
[isDemoMode, projectId, servicesQuery.data],
);
const serviceIdFingerprint = services.map((service) => service.id).sort().join('|');
const variablesQuery = useQuery({
queryKey: ['project-service-vars', projectId, serviceIdFingerprint],
queryFn: async () => {
const entries = await Promise.all(
services.map(async (service) => {
const variables = await listServiceVariables(service.id);
return [service.id, variables.map((variable) => ({
key: variable.key,
value: variable.value,
isSecret: variable.isSecret,
}))] as const;
}),
);
return Object.fromEntries(entries) as Record<string, ServiceVariable[]>;
},
enabled: !isDemoMode && services.length > 0,
});
const variablesByService = isDemoMode ? getDemoVariablesByProject(projectId) : variablesQuery.data ?? {};
const workspaceLogsQuery = useQuery({
queryKey: ['workspace-service-logs', projectId, serviceIdFingerprint],
queryFn: async () => {
const rows = await Promise.all(
services.map(async (service) => {
const logs = await listServiceLogs(service.id, { tail: '20' });
return logs.map((entry) => ({
serviceId: service.id,
serviceName: service.name,
stream: entry.stream,
message: entry.message,
timestamp: entry.timestamp,
}));
}),
);
return rows
.flat()
.sort((left, right) => {
const leftValue = left.timestamp ? new Date(left.timestamp).getTime() : 0;
const rightValue = right.timestamp ? new Date(right.timestamp).getTime() : 0;
return rightValue - leftValue;
})
.slice(0, 200);
},
enabled: !isDemoMode && activeView === 'logs' && services.length > 0,
});
const runningServices = useMemo(() => services.filter((service) => service.status === 'running').length, [services]);
const serviceHref = (serviceId: string) =>
isDemoMode
? `/projects/${projectId}/services/${serviceId}?demo=1`
: `/projects/${projectId}/services/${serviceId}`;
if (!isDemoMode && projectQuery.isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="flex items-center gap-3 text-[var(--text-secondary)]">
<Loader2 size={20} className="animate-spin" />
<span>Loading workspace...</span>
</div>
</div>
);
}
if ((!isDemoMode && projectQuery.isError) || !project) {
return (
<div className="min-h-screen flex items-center justify-center p-8">
<div className="panel p-8 text-center max-w-md">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center">
<X size={24} className="text-[var(--error)]" />
</div>
<p className="text-lg font-medium text-[var(--text-primary)]">Project not found</p>
<p className="mt-2 text-sm text-[var(--text-secondary)]">
This project may have been deleted or you don't have access.
</p>
<button
onClick={() => navigate('/projects')}
className="mt-6 px-4 py-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium hover:border-[var(--border-default)] transition-colors"
>
Back to Projects
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen">
{/* Header */}
<div className="border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/50 backdrop-blur-sm">
<div className="mx-auto w-full max-w-[1400px] px-6 py-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/projects')}
className="flex items-center gap-2 text-sm text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors"
>
<ArrowLeft size={16} />
<span>Projects</span>
</button>
<div className="w-px h-5 bg-[var(--border-subtle)]" />
<div>
<h1 className="text-xl font-semibold text-[var(--text-primary)]">{project.name}</h1>
<p className="text-sm text-[var(--text-secondary)]">{project.description || 'No description'}</p>
</div>
</div>
<div className="flex items-center gap-6">
{/* Stats */}
<div className="hidden md:flex items-center gap-6">
<div className="text-center">
<p className="text-2xl font-semibold text-[var(--text-primary)]">{services.length}</p>
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">Services</p>
</div>
<div className="text-center">
<p className="text-2xl font-semibold text-[var(--success)]">{runningServices}</p>
<p className="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">Running</p>
</div>
<div className="w-px h-8 bg-[var(--border-subtle)]" />
<LiveIndicator isLive={runningServices > 0} />
<div className="w-px h-8 bg-[var(--border-subtle)]" />
<div className="flex items-center gap-1.5 text-xs text-[var(--text-tertiary)]">
<Clock size={12} />
<span>{formatRelative(project.updatedAt)}</span>
</div>
</div>
{/* Search / Command Palette */}
<button
onClick={() => setCommandPaletteOpen(true)}
className="hidden md:flex items-center gap-2 h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-muted)] text-sm hover:border-[var(--border-default)] hover:text-[var(--text-secondary)] transition-colors"
>
<Search size={14} />
<span>Search...</span>
<kbd className="ml-2 px-1.5 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd>
</button>
{/* Add Service Button */}
{!isDemoMode && (
<button
onClick={() => setCreateOpen(true)}
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-white text-sm font-medium shadow-lg hover:shadow-xl transition-all"
style={{ background: '#e8316a' }}
>
<Plus size={16} />
<span className="hidden sm:inline">Add Service</span>
</button>
)}
</div>
</div>
</div>
</div>
{/* Demo Mode Banner */}
{isDemoMode && (
<div className="mx-auto w-full max-w-[1400px] px-6 py-4">
<div className="px-4 py-3 rounded-[var(--radius-md)] border border-[var(--warning-soft)] bg-[var(--warning-soft)]/50">
<div className="flex items-center gap-2 text-sm text-[var(--warning)]">
<Sparkles size={16} />
<span>Demo mode active — using sample data for preview</span>
</div>
</div>
</div>
)}
{/* Main Content */}
<div className="mx-auto w-full max-w-[1400px] px-6 py-6">
<div className="grid grid-cols-1 lg:grid-cols-[200px_1fr] gap-6">
{/* Sidebar Navigation */}
<aside className="lg:sticky lg:top-6 lg:h-fit">
<nav className="flex lg:flex-col gap-1 overflow-x-auto lg:overflow-visible pb-2 lg:pb-0">
{viewItems.map((item) => {
const active = activeView === item.key;
const Icon = item.icon;
return (
<button
key={item.key}
onClick={() => setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('view', item.key);
return next;
})}
className={`flex items-center gap-3 px-3 py-2.5 rounded-[var(--radius-md)] transition-all whitespace-nowrap ${
active
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]'
}`}
>
<Icon size={18} />
<span className="text-sm font-medium">{item.label}</span>
{active && (
<div className="hidden lg:block absolute left-0 w-0.5 h-5 bg-[var(--accent-primary)] rounded-full" />
)}
</button>
);
})}
</nav>
</aside>
{/* Content Area */}
<section className="min-w-0">
{activeView === 'canvas' && (
<ProjectCanvas
projectId={project.id}
services={services}
variablesByService={variablesByService}
onAddService={() => setCreateOpen(true)}
onOpenService={(serviceId) => navigate(serviceHref(serviceId))}
/>
)}
{activeView === 'observability' && (
<div className="space-y-6">
{/* Health Summary */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="panel-soft p-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-[var(--success)] live-pulse" />
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Healthy</p>
</div>
<p className="mt-2 text-3xl font-semibold text-[var(--success)]">{runningServices}</p>
</div>
<div className="panel-soft p-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-[var(--error)]" />
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Failed</p>
</div>
<p className="mt-2 text-3xl font-semibold text-[var(--error)]">
{services.filter((s) => s.status === 'failed').length}
</p>
</div>
<div className="panel-soft p-4">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-[var(--warning)] animate-pulse" />
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Building</p>
</div>
<p className="mt-2 text-3xl font-semibold text-[var(--warning)]">
{services.filter((s) => s.status === 'building').length}
</p>
</div>
</div>
{/* Metrics Dashboard */}
<MetricsDashboard
isRunning={runningServices > 0}
onStop={() => toast.showToast('Services stopped', 'warning')}
onRestart={() => toast.showToast('Services restarted', 'success')}
/>
{/* Service List */}
<div className="panel p-6">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-3">Services</h3>
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] overflow-hidden divide-y divide-[var(--border-subtle)]">
{services.length === 0 ? (
<div className="p-8 text-center">
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-[var(--surface-muted)] flex items-center justify-center">
<Layers size={20} className="text-[var(--text-tertiary)]" />
</div>
<p className="text-sm text-[var(--text-secondary)]">No services deployed yet</p>
<p className="text-xs text-[var(--text-muted)] mt-1">Add services from the Canvas view</p>
</div>
) : (
services.map((service) => (
<button
key={service.id}
onClick={() => navigate(serviceHref(service.id))}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-[var(--surface-muted)]/50 transition-colors text-left"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--surface-card)] border border-[var(--border-subtle)] flex items-center justify-center">
<Box size={14} className="text-[var(--text-tertiary)]" />
</div>
<div>
<p className="text-sm font-medium text-[var(--text-primary)]">{service.name}</p>
<p className="text-xs text-[var(--text-tertiary)]">{service.type}</p>
</div>
</div>
<StatusBadge status={service.status} />
</button>
))
)}
</div>
</div>
</div>
)}
{activeView === 'logs' && (
<div className="panel p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<FileText size={20} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Logs</h2>
<p className="text-sm text-[var(--text-secondary)]">Aggregated service output</p>
</div>
</div>
{!isDemoMode && (
<button
onClick={() => workspaceLogsQuery.refetch()}
className="px-3 py-1.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-xs font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--border-default)] transition-colors"
>
Refresh
</button>
)}
</div>
<div className="mono rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] p-4 text-xs text-[var(--text-secondary)] overflow-x-auto max-h-[600px] overflow-y-auto">
{services.length === 0 ? (
<p className="text-[var(--text-muted)]">No services available.</p>
) : isDemoMode ? (
<div className="space-y-1">
{services.slice(0, 10).map((service, i) => (
<p key={service.id}>
<span className="text-[var(--text-muted)]">
[{service.updatedAt ? new Date(new Date(service.updatedAt).getTime() - i * 60000).toLocaleTimeString() : '--:--:--'}]
</span>
{' '}
<span className="text-[var(--accent-primary)]">{service.name}</span>
{' '}
<span className="text-[var(--text-tertiary)]">status={service.status}</span>
</p>
))}
</div>
) : workspaceLogsQuery.isLoading ? (
<p className="text-[var(--text-muted)]">Loading logs...</p>
) : workspaceLogsQuery.isError ? (
<p className="text-[var(--error)]">Failed to load logs.</p>
) : (workspaceLogsQuery.data?.length ?? 0) === 0 ? (
<p className="text-[var(--text-muted)]">No recent logs.</p>
) : (
<div className="space-y-1">
{workspaceLogsQuery.data!.map((entry, i) => (
<p key={`${entry.serviceId}-${entry.timestamp}-${i}`}>
<span className="text-[var(--text-muted)]">
[{entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : '--:--:--'}]
</span>
{' '}
<span className="text-[var(--accent-primary)]">{entry.serviceName}</span>
{' '}
<span className="text-[var(--text-tertiary)]">{entry.stream}</span>
{' '}
{entry.message}
</p>
))}
</div>
)}
</div>
</div>
)}
{activeView === 'settings' && (
<div className="panel p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Settings size={20} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Settings</h2>
<p className="text-sm text-[var(--text-secondary)]">Project configuration</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Project ID</p>
<p className="mono mt-2 text-sm text-[var(--text-primary)] break-all">{project.id}</p>
</div>
<div className="panel-soft p-4">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Created</p>
<p className="mt-2 text-sm text-[var(--text-primary)]">{formatDate(project.createdAt)}</p>
</div>
<div className="panel-soft p-4 md:col-span-2">
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">Canvas Storage Key</p>
<p className="mono mt-2 text-xs text-[var(--text-secondary)] break-all">{canvasStorageKey(project.id)}</p>
</div>
</div>
<div className="mt-6 pt-6 border-t border-[var(--border-subtle)]">
<h3 className="text-sm font-medium text-[var(--text-secondary)] mb-3">Local Data</h3>
<button
onClick={() => {
clearCanvasMetadata(project.id);
window.location.reload();
}}
className="flex items-center gap-2 px-4 py-2 rounded-[var(--radius-md)] border border-[var(--error-soft)] text-[var(--error)] text-sm font-medium hover:bg-[var(--error-soft)] transition-colors"
>
<Trash2 size={16} />
Reset Canvas Layout
</button>
</div>
</div>
)}
</section>
</div>
</div>
{/* Create Service Dialog */}
{!isDemoMode && (
<ServiceCreateDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
loading={createServiceMutation.isPending}
errorMessage={createServiceMutation.error ? (createServiceMutation.error as Error).message : undefined}
onSubmit={(payload) => createServiceMutation.mutate(payload)}
/>
)}
{/* Command Palette */}
<CommandPalette
open={commandPaletteOpen}
onClose={() => setCommandPaletteOpen(false)}
onAddService={(type) => {
setCreateOpen(true);
toast.showToast(`Creating ${type} service...`, 'info');
}}
onNavigate={(path) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('view', path);
return next;
});
}}
/>
{/* Error Banner */}
{!isDemoMode && (servicesQuery.isError || variablesQuery.isError) && (
<div className="fixed bottom-4 right-4 px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] border border-[var(--error)]/20 text-sm text-[var(--error)] shadow-lg">
Unable to load services for this project.
</div>
)}
</div>
);
}
@@ -0,0 +1,236 @@
import type { ServiceEntity } from '@/lib/api-client';
import {
createDefaultCanvasMetadata,
DEFAULT_VIEWPORT,
type CanvasEdge,
type CanvasGroup,
type CanvasNodeLayout,
type ProjectCanvasMetadata,
} from './model';
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function parsePoint(value: unknown): { x: number; y: number } | null {
if (!value || typeof value !== 'object') {
return null;
}
const candidate = value as Record<string, unknown>;
if (!isFiniteNumber(candidate.x) || !isFiniteNumber(candidate.y)) {
return null;
}
return { x: candidate.x, y: candidate.y };
}
function parseGroups(raw: unknown): CanvasGroup[] {
if (!Array.isArray(raw)) {
return [];
}
return raw
.map((entry) => {
if (!entry || typeof entry !== 'object') {
return null;
}
const group = entry as Record<string, unknown>;
const point = parsePoint(group.position);
if (!point || typeof group.id !== 'string' || typeof group.title !== 'string') {
return null;
}
const width = isFiniteNumber(group.width) ? group.width : 320;
const height = isFiniteNumber(group.height) ? group.height : 220;
return {
id: group.id,
title: group.title,
width,
height,
position: point,
} satisfies CanvasGroup;
})
.filter((entry): entry is CanvasGroup => entry !== null);
}
function parseNodes(raw: unknown): CanvasNodeLayout[] {
if (!Array.isArray(raw)) {
return [];
}
const parsed: CanvasNodeLayout[] = [];
for (const entry of raw) {
if (!entry || typeof entry !== 'object') {
continue;
}
const node = entry as Record<string, unknown>;
const point = parsePoint(node.position);
if (!point || typeof node.serviceId !== 'string') {
continue;
}
const normalized: CanvasNodeLayout = {
serviceId: node.serviceId,
position: point,
};
if (typeof node.groupId === 'string') {
normalized.groupId = node.groupId;
}
parsed.push(normalized);
}
return parsed;
}
function parseEdges(raw: unknown): CanvasEdge[] {
if (!Array.isArray(raw)) {
return [];
}
return raw
.map((entry) => {
if (!entry || typeof entry !== 'object') {
return null;
}
const edge = entry as Record<string, unknown>;
if (
typeof edge.id !== 'string' ||
typeof edge.sourceServiceId !== 'string' ||
typeof edge.targetServiceId !== 'string'
) {
return null;
}
return {
id: edge.id,
sourceServiceId: edge.sourceServiceId,
targetServiceId: edge.targetServiceId,
} satisfies CanvasEdge;
})
.filter((entry): entry is CanvasEdge => entry !== null);
}
function parseViewport(raw: unknown): ProjectCanvasMetadata['viewport'] {
if (!raw || typeof raw !== 'object') {
return DEFAULT_VIEWPORT;
}
const viewport = raw as Record<string, unknown>;
if (!isFiniteNumber(viewport.x) || !isFiniteNumber(viewport.y) || !isFiniteNumber(viewport.zoom)) {
return DEFAULT_VIEWPORT;
}
return {
x: viewport.x,
y: viewport.y,
zoom: viewport.zoom,
};
}
function parseRawCanvasMetadata(raw: unknown): ProjectCanvasMetadata | null {
if (!raw || typeof raw !== 'object') {
return null;
}
const source = raw as Record<string, unknown>;
return {
groups: parseGroups(source.groups),
nodes: parseNodes(source.nodes),
edges: parseEdges(source.edges),
viewport: parseViewport(source.viewport),
};
}
export function canvasStorageKey(projectId: string): string {
return `containr.canvas.v1.${projectId}`;
}
export function clearCanvasMetadata(projectId: string): void {
localStorage.removeItem(canvasStorageKey(projectId));
}
export function saveCanvasMetadata(projectId: string, metadata: ProjectCanvasMetadata): void {
localStorage.setItem(canvasStorageKey(projectId), JSON.stringify(metadata));
}
export function loadCanvasMetadata(projectId: string, services: ServiceEntity[]): ProjectCanvasMetadata {
const key = canvasStorageKey(projectId);
const fallback = createDefaultCanvasMetadata(services);
const serviceIds = new Set(services.map((service) => service.id));
const raw = localStorage.getItem(key);
if (!raw) {
saveCanvasMetadata(projectId, fallback);
return fallback;
}
try {
const parsed = parseRawCanvasMetadata(JSON.parse(raw));
if (!parsed) {
saveCanvasMetadata(projectId, fallback);
return fallback;
}
const validGroups = parsed.groups;
const groupIds = new Set(validGroups.map((group) => group.id));
const nodeMap = new Map(parsed.nodes.map((node) => [node.serviceId, node]));
const normalizedNodes: CanvasNodeLayout[] = [];
for (const [index, service] of services.entries()) {
const existing = nodeMap.get(service.id);
if (existing) {
normalizedNodes.push({
serviceId: existing.serviceId,
position: existing.position,
groupId: existing.groupId && groupIds.has(existing.groupId) ? existing.groupId : undefined,
});
continue;
}
const col = index % 3;
const row = Math.floor(index / 3);
normalizedNodes.push({
serviceId: service.id,
position: {
x: 70 + col * 260,
y: 80 + row * 170,
},
});
}
const seenEdgeIds = new Set<string>();
const normalizedEdges = parsed.edges.filter((edge) => {
if (
!serviceIds.has(edge.sourceServiceId) ||
!serviceIds.has(edge.targetServiceId) ||
edge.sourceServiceId === edge.targetServiceId ||
seenEdgeIds.has(edge.id)
) {
return false;
}
seenEdgeIds.add(edge.id);
return true;
});
const normalized: ProjectCanvasMetadata = {
groups: validGroups,
nodes: normalizedNodes,
edges: normalizedEdges,
viewport: parseViewport(parsed.viewport),
};
saveCanvasMetadata(projectId, normalized);
return normalized;
} catch {
saveCanvasMetadata(projectId, fallback);
return fallback;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+960
View File
@@ -0,0 +1,960 @@
import type { components, paths } from '@/generated/api-types';
export type ServiceStatus = 'running' | 'stopped' | 'building' | 'failed' | 'unknown';
export type ProjectStats = {
service_count: number;
deployment_count: number;
running_services: number;
last_deployment?: string;
};
export type ProjectEntity = {
id: string;
name: string;
description: string;
createdAt?: string;
updatedAt?: string;
stats: ProjectStats;
};
export type UserProfile = {
id: string;
email: string;
name: string;
avatarUrl?: string;
createdAt?: string;
updatedAt?: string;
};
export type ServiceEntity = {
id: string;
projectId: string;
name: string;
type: string;
status: ServiceStatus;
createdAt?: string;
updatedAt?: string;
image?: string;
command?: string;
environment?: string;
gitRepo?: string;
gitBranch?: string;
buildPath?: string;
cpu?: string;
memory?: string;
};
export type ServiceVariable = {
id: string;
serviceId: string;
key: string;
value: string;
isSecret: boolean;
createdAt?: string;
updatedAt?: string;
};
export type BuildStatus = 'pending' | 'running' | 'success' | 'failed' | 'cancelled';
export type BuildEntity = {
id: string;
projectId?: string;
serviceId?: string;
status: BuildStatus;
progress: number;
startedAt?: string;
completedAt?: string;
imageName?: string;
imageTag?: string;
size: number;
error?: string;
log?: string;
metadata: Record<string, string>;
};
export type ListBuildsInput = {
projectId?: string;
serviceId?: string;
status?: BuildStatus;
page?: number;
limit?: number;
};
export type ListBuildsResult = {
builds: BuildEntity[];
total: number;
page: number;
limit: number;
};
export type TemplateEntity = {
id: string;
name: string;
description: string;
category: string;
logo: string;
configRaw: string;
variablesRaw: string;
isOfficial: boolean;
createdAt?: string;
updatedAt?: string;
};
export type TemplateConfigEntity = {
type: string;
runtime: string;
buildCommand: string;
startCommand: string;
port: number;
healthCheck: string;
environment: Record<string, string>;
dockerfile?: string;
nixpacksConfig: Record<string, string>;
};
export type TemplateVariableEntity = {
key: string;
label: string;
defaultValue: string;
required: boolean;
secret: boolean;
description: string;
};
export type TemplateDetailEntity = {
template: TemplateEntity;
config: TemplateConfigEntity;
variables: TemplateVariableEntity[];
};
export type ListTemplatesInput = {
category?: string;
};
export type DeployTemplateInput = {
projectId: string;
name: string;
variables?: Record<string, string>;
};
export type DeployTemplateResult = {
serviceId: string;
message: string;
};
export type UpdateUserProfileInput = {
name?: string;
avatarUrl?: string;
};
export type AuditLogEntity = {
id: string;
userId: string;
userEmail: string;
resource: string;
resourceId: string;
action: string;
details: string;
ipAddress: string;
userAgent: string;
createdAt?: string;
};
export type ListAuditLogsInput = {
resource?: string;
action?: string;
page?: number;
limit?: number;
};
export type DeploymentEntity = {
id: string;
serviceId: string;
commitHash?: string;
status: string;
imageName?: string;
imageTag?: string;
buildLog?: string;
runtimeLog?: string;
error?: string;
startedAt?: string;
completedAt?: string;
createdAt?: string;
updatedAt?: string;
};
export type CreateDeploymentInput = {
commitHash?: string;
branch?: string;
trigger?: string;
envVars?: Record<string, string>;
};
export type RollbackDeploymentResult = {
deployment?: DeploymentEntity;
message: string;
};
export type ServiceLogEntity = {
timestamp?: string;
message: string;
stream: string;
};
export type ListServiceLogsInput = {
tail?: string;
follow?: boolean;
};
export type GetDeploymentLogsInput = {
type?: 'all' | 'build' | 'runtime';
};
type RawProject = components['schemas']['Project'] & {
stats?: Partial<ProjectStats>;
};
type RawService = components['schemas']['Service'] & {
image?: string;
command?: string;
environment?: string;
git_repo?: string;
git_branch?: string;
build_path?: string;
cpu?: string;
memory?: string;
};
type RawUserProfile = components['schemas']['User'] & {
avatar_url?: string;
};
type RawServiceVariable = {
id?: string;
service_id?: string;
key?: string;
value?: string;
is_secret?: boolean;
created_at?: string;
updated_at?: string;
};
type RawBuildStatus = components['schemas']['BuildStatus'];
type RawBuildListResponse = components['schemas']['BuildListResponse'];
type RawServiceTemplate = components['schemas']['ServiceTemplate'];
type RawTemplateConfig = components['schemas']['TemplateConfig'];
type RawTemplateVariable = components['schemas']['TemplateVariable'];
type RawTemplateDetailResponse = components['schemas']['TemplateDetailResponse'];
type RawDeployTemplateResponse = components['schemas']['DeployTemplateResponse'];
type RawAuditLog = components['schemas']['AuditLog'];
type RawAuditLogListResponse = components['schemas']['AuditLogListResponse'];
type RawDeployment = components['schemas']['Deployment'];
type RawDeploymentListResponse = components['schemas']['DeploymentListResponse'];
type RawCreateDeploymentRequest = components['schemas']['CreateDeploymentRequest'];
type RawServiceLog = components['schemas']['ServiceLogEntry'];
type RawServiceLogsResponse = components['schemas']['ServiceLogsResponse'];
type RawDeploymentLogsResponse = components['schemas']['DeploymentLogsResponse'];
type RawRollbackDeploymentResponse = components['schemas']['RollbackDeploymentResponse'];
export type CreateProjectInput = components['schemas']['CreateProjectRequest'];
export type CreateServiceInput = components['schemas']['CreateServiceRequest'] & {
environment?: 'production' | 'preview' | 'development';
project_id?: string;
image?: string;
command?: string;
git_repo?: string;
git_branch?: string;
build_path?: string;
cpu?: string;
memory?: string;
};
export class ApiError extends Error {
readonly status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'ApiError';
this.status = status;
}
}
type ProjectsResponse200 = paths['/projects']['get']['responses'][200]['content']['application/json'];
type JsonLike = Record<string, unknown>;
const rawBase = (import.meta.env.VITE_API_URL as string | undefined) ?? 'http://localhost:8082';
const normalizedBase = rawBase.replace(/\/$/, '');
const API_BASE = /\/api\/v1$/.test(normalizedBase) ? normalizedBase : `${normalizedBase}/api/v1`;
export function getApiBaseUrl(): string {
return API_BASE;
}
function authHeaders(): HeadersInit {
return { 'Content-Type': 'application/json' };
}
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
...init,
credentials: 'include',
headers: {
...authHeaders(),
...(init?.headers ?? {}),
},
});
if (response.status === 204) {
return undefined as T;
}
const payload = (await response.json().catch(() => null)) as JsonLike | null;
if (!response.ok) {
const message =
(payload?.error as string | undefined) ??
(payload?.message as string | undefined) ??
`Request failed with status ${response.status}`;
throw new ApiError(message, response.status);
}
return payload as T;
}
async function requestText(path: string, init?: RequestInit): Promise<string> {
const response = await fetch(`${API_BASE}${path}`, {
...init,
credentials: 'include',
headers: {
...authHeaders(),
...(init?.headers ?? {}),
},
});
const text = await response.text();
if (!response.ok) {
throw new ApiError(text || `Request failed with status ${response.status}`, response.status);
}
return text;
}
function normalizeProject(project: RawProject): ProjectEntity | null {
if (!project.id || !project.name) {
return null;
}
return {
id: project.id,
name: project.name,
description: project.description ?? '',
createdAt: project.created_at,
updatedAt: project.updated_at,
stats: {
service_count: project.stats?.service_count ?? project.services_count ?? 0,
deployment_count: project.stats?.deployment_count ?? 0,
running_services: project.stats?.running_services ?? 0,
last_deployment: project.stats?.last_deployment ?? undefined,
},
};
}
function normalizeUserProfile(profile: RawUserProfile): UserProfile | null {
if (!profile.id || !profile.email || !profile.name) {
return null;
}
return {
id: profile.id,
email: profile.email,
name: profile.name,
avatarUrl: profile.avatar_url ?? undefined,
createdAt: profile.created_at ?? undefined,
updatedAt: profile.updated_at ?? undefined,
};
}
function normalizeService(service: RawService): ServiceEntity | null {
if (!service.id || !service.name || !service.project_id) {
return null;
}
const status = (service.status ?? 'unknown') as ServiceStatus;
return {
id: service.id,
projectId: service.project_id,
name: service.name,
type: service.type ?? 'web',
status,
createdAt: service.created_at,
updatedAt: service.updated_at,
image: service.image,
command: service.command,
environment: service.environment,
gitRepo: service.git_repo,
gitBranch: service.git_branch,
buildPath: service.build_path,
cpu: service.cpu,
memory: service.memory,
};
}
function normalizeProjectArray(raw: unknown): ProjectEntity[] {
if (!Array.isArray(raw)) {
return [];
}
return raw
.map((entry) => normalizeProject(entry as RawProject))
.filter((entry): entry is ProjectEntity => entry !== null);
}
function normalizeServiceArray(raw: unknown): ServiceEntity[] {
if (!Array.isArray(raw)) {
return [];
}
return raw
.map((entry) => normalizeService(entry as RawService))
.filter((entry): entry is ServiceEntity => entry !== null);
}
function normalizeBuild(build: RawBuildStatus): BuildEntity | null {
if (!build.id || !build.status) {
return null;
}
return {
id: build.id,
projectId: build.project_id,
serviceId: build.service_id,
status: build.status as BuildStatus,
progress: build.progress ?? 0,
startedAt: build.started_at,
completedAt: build.completed_at,
imageName: build.image_name,
imageTag: build.image_tag,
size: build.size ?? 0,
error: build.error,
log: build.log,
metadata: build.metadata ?? {},
};
}
function normalizeBuildArray(raw: unknown): BuildEntity[] {
if (!Array.isArray(raw)) {
return [];
}
return raw
.map((entry) => normalizeBuild(entry as RawBuildStatus))
.filter((entry): entry is BuildEntity => entry !== null);
}
function normalizeTemplate(template: RawServiceTemplate): TemplateEntity | null {
if (!template.id || !template.name) {
return null;
}
return {
id: template.id,
name: template.name,
description: template.description ?? '',
category: template.category ?? '',
logo: template.logo ?? '',
configRaw: template.config ?? '',
variablesRaw: template.variables ?? '',
isOfficial: Boolean(template.is_official),
createdAt: template.created_at,
updatedAt: template.updated_at,
};
}
function normalizeTemplateArray(raw: unknown): TemplateEntity[] {
if (!Array.isArray(raw)) {
return [];
}
return raw
.map((entry) => normalizeTemplate(entry as RawServiceTemplate))
.filter((entry): entry is TemplateEntity => entry !== null);
}
function normalizeStringRecord(raw: unknown): Record<string, string> {
if (!raw || typeof raw !== 'object') {
return {};
}
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
if (typeof value === 'string') {
result[key] = value;
}
}
return result;
}
function normalizeTemplateConfig(config?: RawTemplateConfig): TemplateConfigEntity {
return {
type: config?.type ?? '',
runtime: config?.runtime ?? '',
buildCommand: config?.build_command ?? '',
startCommand: config?.start_command ?? '',
port: config?.port ?? 0,
healthCheck: config?.health_check ?? '',
environment: normalizeStringRecord(config?.environment),
dockerfile: config?.dockerfile ?? undefined,
nixpacksConfig: normalizeStringRecord(config?.nixpacks_config),
};
}
function normalizeTemplateVariable(variable: RawTemplateVariable): TemplateVariableEntity | null {
if (!variable.key) {
return null;
}
return {
key: variable.key,
label: variable.label ?? variable.key,
defaultValue: variable.default ?? '',
required: Boolean(variable.required),
secret: Boolean(variable.secret),
description: variable.description ?? '',
};
}
function normalizeTemplateVariableArray(raw: unknown): TemplateVariableEntity[] {
if (!Array.isArray(raw)) {
return [];
}
return raw
.map((entry) => normalizeTemplateVariable(entry as RawTemplateVariable))
.filter((entry): entry is TemplateVariableEntity => entry !== null);
}
function normalizeAuditLog(log: RawAuditLog): AuditLogEntity | null {
if (!log.id || !log.user_id || !log.resource || !log.action) {
return null;
}
return {
id: log.id,
userId: log.user_id,
userEmail: log.user_email ?? '',
resource: log.resource,
resourceId: log.resource_id ?? '',
action: log.action,
details: log.details ?? '',
ipAddress: log.ip_address ?? '',
userAgent: log.user_agent ?? '',
createdAt: log.created_at,
};
}
function normalizeAuditLogArray(raw: unknown): AuditLogEntity[] {
if (!Array.isArray(raw)) {
return [];
}
return raw
.map((entry) => normalizeAuditLog(entry as RawAuditLog))
.filter((entry): entry is AuditLogEntity => entry !== null);
}
function normalizeDeployment(deployment: RawDeployment): DeploymentEntity | null {
if (!deployment.id || !deployment.service_id || !deployment.status) {
return null;
}
return {
id: deployment.id,
serviceId: deployment.service_id,
commitHash: deployment.commit_hash ?? undefined,
status: deployment.status,
imageName: deployment.image_name ?? undefined,
imageTag: deployment.image_tag ?? undefined,
buildLog: deployment.build_log ?? undefined,
runtimeLog: deployment.runtime_log ?? undefined,
error: deployment.error ?? undefined,
startedAt: deployment.started_at ?? undefined,
completedAt: deployment.completed_at ?? undefined,
createdAt: deployment.created_at ?? undefined,
updatedAt: deployment.updated_at ?? undefined,
};
}
function normalizeDeploymentArray(raw: unknown): DeploymentEntity[] {
if (!Array.isArray(raw)) {
return [];
}
return raw
.map((entry) => normalizeDeployment(entry as RawDeployment))
.filter((entry): entry is DeploymentEntity => entry !== null);
}
function normalizeServiceLog(log: RawServiceLog): ServiceLogEntity | null {
if (!log.message || !log.stream) {
return null;
}
return {
timestamp: log.timestamp ?? undefined,
message: log.message,
stream: log.stream,
};
}
function normalizeServiceLogArray(raw: unknown): ServiceLogEntity[] {
if (!Array.isArray(raw)) {
return [];
}
return raw
.map((entry) => normalizeServiceLog(entry as RawServiceLog))
.filter((entry): entry is ServiceLogEntity => entry !== null);
}
export async function listProjects(): Promise<ProjectEntity[]> {
const payload = await requestJson<ProjectsResponse200 | { projects?: RawProject[] }>(`/projects`);
return normalizeProjectArray((payload as { projects?: RawProject[] }).projects);
}
export async function getProjectById(projectId: string): Promise<ProjectEntity> {
const payload = await requestJson<RawProject | { project?: RawProject }>(`/projects/${projectId}`);
const rawProject = (payload as { project?: RawProject }).project ?? (payload as RawProject);
const parsed = normalizeProject(rawProject);
if (!parsed) {
throw new ApiError('Project payload is invalid', 500);
}
return parsed;
}
export async function getCurrentUserProfile(): Promise<UserProfile> {
const payload = await requestJson<RawUserProfile | { user?: RawUserProfile }>(`/user/profile`);
const rawProfile = (payload as { user?: RawUserProfile }).user ?? (payload as RawUserProfile);
const profile = normalizeUserProfile(rawProfile);
if (!profile) {
throw new ApiError('User profile payload is invalid', 500);
}
return profile;
}
export async function updateCurrentUserProfile(input: UpdateUserProfileInput): Promise<UserProfile> {
const payload = await requestJson<RawUserProfile | { user?: RawUserProfile }>(`/user/profile`, {
method: 'PUT',
body: JSON.stringify({
name: input.name,
avatar_url: input.avatarUrl,
}),
});
const rawProfile = (payload as { user?: RawUserProfile }).user ?? (payload as RawUserProfile);
const profile = normalizeUserProfile(rawProfile);
if (!profile) {
throw new ApiError('Updated user profile payload is invalid', 500);
}
return profile;
}
export async function createProject(input: CreateProjectInput): Promise<ProjectEntity> {
const payload = await requestJson<RawProject | { project?: RawProject }>(`/projects`, {
method: 'POST',
body: JSON.stringify(input),
});
const rawProject = (payload as { project?: RawProject }).project ?? (payload as RawProject);
const parsed = normalizeProject(rawProject);
if (!parsed) {
throw new ApiError('Create project response is invalid', 500);
}
return parsed;
}
export async function listServicesByProject(projectId: string): Promise<ServiceEntity[]> {
const payload = await requestJson<RawService[] | { services?: RawService[] }>(`/projects/${projectId}/services`);
const rows = Array.isArray(payload) ? payload : payload.services ?? [];
return normalizeServiceArray(rows);
}
export async function getServiceById(serviceId: string): Promise<ServiceEntity> {
const payload = await requestJson<RawService | { service?: RawService }>(`/services/${serviceId}`);
const row = (payload as { service?: RawService }).service ?? (payload as RawService);
const parsed = normalizeService(row);
if (!parsed) {
throw new ApiError('Service payload is invalid', 500);
}
return parsed;
}
export async function createService(projectId: string, input: CreateServiceInput): Promise<ServiceEntity> {
const payload = await requestJson<RawService | { service?: RawService }>(`/projects/${projectId}/services`, {
method: 'POST',
body: JSON.stringify({ ...input, project_id: projectId }),
});
const row = (payload as { service?: RawService }).service ?? (payload as RawService);
const parsed = normalizeService(row);
if (!parsed) {
throw new ApiError('Create service response is invalid', 500);
}
return parsed;
}
export async function deleteService(serviceId: string): Promise<void> {
await requestJson<{ message?: string }>(`/services/${serviceId}`, {
method: 'DELETE',
});
}
export async function listServiceVariables(serviceId: string): Promise<ServiceVariable[]> {
const payload = await requestJson<{ variables?: RawServiceVariable[] }>(`/services/${serviceId}/variables`);
const rows = payload.variables ?? [];
const result: ServiceVariable[] = [];
for (const row of rows) {
if (!row.id || !row.service_id || !row.key) {
continue;
}
result.push({
id: row.id,
serviceId: row.service_id,
key: row.key,
value: row.value ?? '',
isSecret: Boolean(row.is_secret),
createdAt: row.created_at,
updatedAt: row.updated_at,
});
}
return result;
}
export async function listAuditLogs(input: ListAuditLogsInput = {}): Promise<AuditLogEntity[]> {
const searchParams = new URLSearchParams();
if (input.resource) {
searchParams.set('resource', input.resource);
}
if (input.action) {
searchParams.set('action', input.action);
}
if (input.page && input.page > 0) {
searchParams.set('page', String(input.page));
}
if (input.limit && input.limit > 0) {
searchParams.set('limit', String(input.limit));
}
const query = searchParams.toString();
const payload = await requestJson<RawAuditLogListResponse>(query ? `/audit-logs?${query}` : '/audit-logs');
return normalizeAuditLogArray(payload.audit_logs);
}
export async function listDeployments(serviceId: string): Promise<DeploymentEntity[]> {
const payload = await requestJson<RawDeploymentListResponse>(`/services/${serviceId}/deployments`);
return normalizeDeploymentArray(payload.deployments);
}
export async function createDeployment(
serviceId: string,
input: CreateDeploymentInput = {},
): Promise<DeploymentEntity> {
const requestBody: RawCreateDeploymentRequest = {
commit_hash: input.commitHash,
branch: input.branch,
trigger: input.trigger,
env_vars: input.envVars,
};
const payload = await requestJson<RawDeployment>(`/services/${serviceId}/deployments`, {
method: 'POST',
body: JSON.stringify(requestBody),
});
const deployment = normalizeDeployment(payload);
if (!deployment) {
throw new ApiError('Create deployment response is invalid', 500);
}
return deployment;
}
export async function listServiceLogs(
serviceId: string,
input: ListServiceLogsInput = {},
): Promise<ServiceLogEntity[]> {
const searchParams = new URLSearchParams();
if (input.tail) {
searchParams.set('tail', input.tail);
}
if (typeof input.follow === 'boolean') {
searchParams.set('follow', String(input.follow));
}
const query = searchParams.toString();
const payload = await requestJson<RawServiceLogsResponse>(
query ? `/services/${serviceId}/logs?${query}` : `/services/${serviceId}/logs`,
);
return normalizeServiceLogArray(payload.logs);
}
export async function getDeploymentLogs(
deploymentId: string,
input: GetDeploymentLogsInput = {},
): Promise<{
logs: ServiceLogEntity[];
buildLog: string;
runtimeLog: string;
}> {
const searchParams = new URLSearchParams();
if (input.type) {
searchParams.set('type', input.type);
}
const query = searchParams.toString();
const payload = await requestJson<RawDeploymentLogsResponse>(
query ? `/deployments/${deploymentId}/logs?${query}` : `/deployments/${deploymentId}/logs`,
);
return {
logs: normalizeServiceLogArray(payload.logs),
buildLog: payload.build_log ?? '',
runtimeLog: payload.runtime_log ?? '',
};
}
export async function rollbackDeployment(deploymentId: string): Promise<RollbackDeploymentResult> {
const payload = await requestJson<RawRollbackDeploymentResponse>(`/deployments/${deploymentId}/rollback`, {
method: 'POST',
});
return {
deployment: payload.deployment ? normalizeDeployment(payload.deployment) ?? undefined : undefined,
message: payload.message ?? 'Rollback initiated',
};
}
export async function listBuilds(input: ListBuildsInput = {}): Promise<ListBuildsResult> {
const searchParams = new URLSearchParams();
if (input.projectId) {
searchParams.set('project_id', input.projectId);
}
if (input.serviceId) {
searchParams.set('service_id', input.serviceId);
}
if (input.status) {
searchParams.set('status', input.status);
}
if (input.page && input.page > 0) {
searchParams.set('page', String(input.page));
}
if (input.limit && input.limit > 0) {
searchParams.set('limit', String(input.limit));
}
const query = searchParams.toString();
const payload = await requestJson<RawBuildListResponse>(query ? `/builds?${query}` : '/builds');
return {
builds: normalizeBuildArray(payload.builds),
total: payload.total ?? 0,
page: payload.page ?? input.page ?? 1,
limit: payload.limit ?? input.limit ?? 20,
};
}
export async function cancelBuild(buildId: string): Promise<string> {
const payload = await requestJson<{ message?: string }>(`/builds/${buildId}/cancel`, {
method: 'POST',
});
return payload.message ?? 'Build cancelled';
}
export async function getBuildLogs(buildId: string, follow = false): Promise<string> {
const query = follow ? '?follow=true' : '';
return requestText(`/builds/${buildId}/logs${query}`);
}
export async function listTemplates(input: ListTemplatesInput = {}): Promise<TemplateEntity[]> {
const searchParams = new URLSearchParams();
if (input.category) {
searchParams.set('category', input.category);
}
const query = searchParams.toString();
const payload = await requestJson<{ templates?: RawServiceTemplate[] }>(
query ? `/templates?${query}` : '/templates',
);
return normalizeTemplateArray(payload.templates);
}
export async function getTemplateById(templateId: string): Promise<TemplateDetailEntity> {
const payload = await requestJson<RawTemplateDetailResponse>(`/templates/${templateId}`);
const template = payload.template ? normalizeTemplate(payload.template) : null;
if (!template) {
throw new ApiError('Template payload is invalid', 500);
}
return {
template,
config: normalizeTemplateConfig(payload.config),
variables: normalizeTemplateVariableArray(payload.variables),
};
}
export async function deployTemplate(
templateId: string,
input: DeployTemplateInput,
): Promise<DeployTemplateResult> {
const payload = await requestJson<RawDeployTemplateResponse>(`/templates/${templateId}/deploy`, {
method: 'POST',
body: JSON.stringify({
project_id: input.projectId,
name: input.name,
variables: input.variables ?? {},
}),
});
if (!payload.service_id) {
throw new ApiError('Template deployment response is invalid', 500);
}
return {
serviceId: payload.service_id,
message: payload.message ?? 'Service created from template',
};
}
export function serviceStatusClass(status: ServiceStatus): string {
switch (status) {
case 'running':
return 'status-running';
case 'building':
return 'status-building';
case 'failed':
return 'status-failed';
case 'stopped':
return 'status-stopped';
default:
return 'status-stopped';
}
}
+225
View File
@@ -0,0 +1,225 @@
export type AuthUser = {
id: string;
email: string;
name: string;
image?: string | null;
};
export type AuthSession = {
id: string;
userId: string;
expiresAt: string;
};
export type AuthSessionPayload = {
user: AuthUser;
session: AuthSession;
};
export class AuthError extends Error {
readonly status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'AuthError';
this.status = status;
}
}
const rawAuthBase = (import.meta.env.VITE_AUTH_URL as string | undefined) ?? 'http://localhost:8082/api/auth';
const AUTH_BASE = rawAuthBase.replace(/\/$/, '');
export function getAuthBaseUrl(): string {
return AUTH_BASE;
}
function normalizeSessionPayload(payload: unknown): AuthSessionPayload | null {
if (!payload || typeof payload !== 'object') {
return null;
}
const objectPayload = payload as Record<string, unknown>;
const source = (objectPayload.data as Record<string, unknown> | undefined) ?? objectPayload;
const user = source.user as Record<string, unknown> | undefined;
const session = source.session as Record<string, unknown> | undefined;
if (!user || !session || typeof user.id !== 'string' || typeof user.email !== 'string' || typeof user.name !== 'string' || typeof session.id !== 'string' || typeof session.userId !== 'string' || typeof session.expiresAt !== 'string') {
return null;
}
return {
user: {
id: user.id,
email: user.email,
name: user.name,
image: typeof user.image === 'string' ? user.image : null,
},
session: {
id: session.id,
userId: session.userId,
expiresAt: session.expiresAt,
},
};
}
async function authRequest(path: string, init?: RequestInit): Promise<unknown> {
const response = await fetch(`${AUTH_BASE}${path}`, {
...init,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(init?.headers ?? {}),
},
});
if (response.status === 204) {
return null;
}
const payload = await response.json().catch(() => null);
if (!response.ok) {
const objectPayload = payload as Record<string, unknown> | null;
const message =
(objectPayload?.message as string | undefined) ??
(objectPayload?.error as string | undefined) ??
`Auth request failed with status ${response.status}`;
throw new AuthError(message, response.status);
}
return payload;
}
function parseOAuthRedirectPayload(payload: unknown): { url: string; redirect: boolean } {
if (!payload || typeof payload !== 'object') {
throw new AuthError('Invalid OAuth response payload', 500);
}
const objectPayload = payload as Record<string, unknown>;
const source = (objectPayload.data as Record<string, unknown> | undefined) ?? objectPayload;
const url = source.url;
const redirect = source.redirect;
if (typeof url !== 'string' || !url.trim()) {
throw new AuthError('OAuth redirect URL missing in response', 500);
}
return {
url,
redirect: redirect !== false,
};
}
export async function getAuthSession(): Promise<AuthSessionPayload | null> {
const response = await fetch(`${AUTH_BASE}/get-session`, {
method: 'GET',
credentials: 'include',
});
if (response.status === 401) {
return null;
}
const payload = await response.json().catch(() => null);
if (!response.ok) {
const objectPayload = payload as Record<string, unknown> | null;
const message =
(objectPayload?.message as string | undefined) ??
(objectPayload?.error as string | undefined) ??
`Auth session request failed with status ${response.status}`;
throw new AuthError(message, response.status);
}
return normalizeSessionPayload(payload);
}
export async function signInWithEmail(email: string, password: string): Promise<AuthSessionPayload | null> {
const payload = await authRequest('/sign-in/email', {
method: 'POST',
body: JSON.stringify({
email,
password,
}),
});
return normalizeSessionPayload(payload);
}
export async function signUpWithEmail(name: string, email: string, password: string): Promise<AuthSessionPayload | null> {
const payload = await authRequest('/sign-up/email', {
method: 'POST',
body: JSON.stringify({
name,
email,
password,
}),
});
return normalizeSessionPayload(payload);
}
export async function requestMagicLinkInvite(email: string, callbackURL: string): Promise<void> {
await authRequest('/sign-in/magic-link', {
method: 'POST',
body: JSON.stringify({
email,
callbackURL,
metadata: {
invite: true,
},
}),
});
}
export async function startGitHubSignIn(callbackURL: string): Promise<void> {
const payload = await authRequest('/sign-in/social', {
method: 'POST',
body: JSON.stringify({
provider: 'github',
callbackURL,
disableRedirect: true,
}),
});
const oauth = parseOAuthRedirectPayload(payload);
if (oauth.redirect) {
window.location.assign(oauth.url);
}
}
async function startOAuth2ProviderSignIn(providerId: string, callbackURL: string): Promise<void> {
const payload = await authRequest('/sign-in/oauth2', {
method: 'POST',
body: JSON.stringify({
providerId,
callbackURL,
disableRedirect: true,
}),
});
const oauth = parseOAuthRedirectPayload(payload);
if (oauth.redirect) {
window.location.assign(oauth.url);
}
}
export async function startGitLabSignIn(callbackURL: string): Promise<void> {
await startOAuth2ProviderSignIn('gitlab', callbackURL);
}
export async function startBitbucketSignIn(callbackURL: string): Promise<void> {
await startOAuth2ProviderSignIn('bitbucket', callbackURL);
}
export async function startGiteaSignIn(callbackURL: string): Promise<void> {
await startOAuth2ProviderSignIn('gitea', callbackURL);
}
export async function signOutAuthSession(): Promise<void> {
await authRequest('/sign-out', {
method: 'POST',
body: JSON.stringify({}),
});
}
+233
View File
@@ -0,0 +1,233 @@
import type { ProjectEntity, ServiceEntity } from '@/lib/api-client';
import type { ServiceVariable } from '@/features/workspace/auto-connections';
export const demoProjects: ProjectEntity[] = [
{
id: 'demo-project-core',
name: 'Core Platform',
description: 'Primary production workload with web, API, queue, and data services.',
createdAt: '2026-03-12T09:30:00Z',
updatedAt: '2026-03-31T09:10:00Z',
stats: {
service_count: 4,
deployment_count: 27,
running_services: 3,
last_deployment: '2026-03-31T08:42:00Z',
},
},
{
id: 'demo-project-growth',
name: 'Growth Surface',
description: 'Landing pages and campaign services for growth experiments.',
createdAt: '2026-03-01T14:05:00Z',
updatedAt: '2026-03-30T16:44:00Z',
stats: {
service_count: 3,
deployment_count: 14,
running_services: 3,
last_deployment: '2026-03-30T15:01:00Z',
},
},
{
id: 'demo-project-ml',
name: 'Inference Lab',
description: 'Internal inference jobs and model-serving edge services.',
createdAt: '2026-02-18T07:12:00Z',
updatedAt: '2026-03-29T19:22:00Z',
stats: {
service_count: 2,
deployment_count: 9,
running_services: 1,
last_deployment: '2026-03-29T18:06:00Z',
},
},
];
export const demoServicesByProject: Record<string, ServiceEntity[]> = {
'demo-project-core': [
{
id: 'demo-svc-web',
projectId: 'demo-project-core',
name: 'Web Frontend',
type: 'web',
status: 'running',
environment: 'production',
image: 'ghcr.io/containr/web:2026.03.31',
command: 'npm run serve',
gitBranch: 'main',
createdAt: '2026-03-12T10:00:00Z',
updatedAt: '2026-03-31T08:42:00Z',
},
{
id: 'demo-svc-api',
projectId: 'demo-project-core',
name: 'API Gateway',
type: 'web',
status: 'running',
environment: 'production',
image: 'ghcr.io/containr/api:2026.03.31',
command: './server',
gitBranch: 'main',
createdAt: '2026-03-12T10:02:00Z',
updatedAt: '2026-03-31T08:43:00Z',
},
{
id: 'demo-svc-worker',
projectId: 'demo-project-core',
name: 'Queue Worker',
type: 'worker',
status: 'building',
environment: 'production',
image: 'ghcr.io/containr/worker:2026.03.31',
command: './worker',
gitBranch: 'main',
createdAt: '2026-03-12T10:05:00Z',
updatedAt: '2026-03-31T08:44:00Z',
},
{
id: 'demo-svc-postgres',
projectId: 'demo-project-core',
name: 'Postgres',
type: 'database',
status: 'running',
environment: 'production',
image: 'postgres:15-alpine',
command: 'postgres',
gitBranch: 'main',
createdAt: '2026-03-12T10:08:00Z',
updatedAt: '2026-03-31T08:40:00Z',
},
],
'demo-project-growth': [
{
id: 'demo-growth-site',
projectId: 'demo-project-growth',
name: 'Marketing Site',
type: 'web',
status: 'running',
environment: 'production',
image: 'ghcr.io/containr/marketing:latest',
command: 'npm run start',
gitBranch: 'main',
createdAt: '2026-03-03T09:00:00Z',
updatedAt: '2026-03-30T12:10:00Z',
},
{
id: 'demo-growth-api',
projectId: 'demo-project-growth',
name: 'Campaign API',
type: 'web',
status: 'running',
environment: 'production',
image: 'ghcr.io/containr/campaign-api:latest',
command: './api',
gitBranch: 'main',
createdAt: '2026-03-03T09:04:00Z',
updatedAt: '2026-03-30T12:08:00Z',
},
{
id: 'demo-growth-cache',
projectId: 'demo-project-growth',
name: 'Redis Cache',
type: 'database',
status: 'running',
environment: 'production',
image: 'redis:7-alpine',
command: 'redis-server',
gitBranch: 'main',
createdAt: '2026-03-03T09:07:00Z',
updatedAt: '2026-03-30T12:07:00Z',
},
],
'demo-project-ml': [
{
id: 'demo-ml-api',
projectId: 'demo-project-ml',
name: 'Inference API',
type: 'web',
status: 'running',
environment: 'production',
image: 'ghcr.io/containr/inference-api:latest',
command: './serve',
gitBranch: 'main',
createdAt: '2026-02-18T08:00:00Z',
updatedAt: '2026-03-29T18:00:00Z',
},
{
id: 'demo-ml-worker',
projectId: 'demo-project-ml',
name: 'Batch Evaluator',
type: 'worker',
status: 'failed',
environment: 'production',
image: 'ghcr.io/containr/evaluator:latest',
command: './run-jobs',
gitBranch: 'main',
createdAt: '2026-02-18T08:03:00Z',
updatedAt: '2026-03-29T19:22:00Z',
},
],
};
const allServices = Object.values(demoServicesByProject).flat();
export const demoVariablesByProject: Record<string, Record<string, ServiceVariable[]>> = {
'demo-project-core': {
'demo-svc-web': (
[
{ key: 'API_BASE_URL', value: '{{api_gateway_url}}', isSecret: false },
{ key: 'CACHE_URL', value: '{{redis_url}}', isSecret: false },
] satisfies ServiceVariable[]
),
'demo-svc-api': (
[
{ key: 'DATABASE_URL', value: '{{db_url}}', isSecret: true },
{ key: 'REDIS_URL', value: '{{redis_url}}', isSecret: true },
] satisfies ServiceVariable[]
),
'demo-svc-worker': (
[
{ key: 'DATABASE_URL', value: '{{postgres_url}}', isSecret: true },
{ key: 'QUEUE_BACKEND', value: '{{api_gateway_host}}', isSecret: false },
] satisfies ServiceVariable[]
),
'demo-svc-postgres': [],
},
'demo-project-growth': {
'demo-growth-site': (
[
{ key: 'CAMPAIGN_API_URL', value: '{{campaign_api_url}}', isSecret: false },
] satisfies ServiceVariable[]
),
'demo-growth-api': (
[
{ key: 'REDIS_URL', value: '{{redis_cache_url}}', isSecret: true },
] satisfies ServiceVariable[]
),
'demo-growth-cache': [],
},
'demo-project-ml': {
'demo-ml-api': [],
'demo-ml-worker': (
[
{ key: 'MODEL_API_URL', value: '{{inference_api_url}}', isSecret: false },
] satisfies ServiceVariable[]
),
},
};
export function getDemoProjectById(projectId: string): ProjectEntity | undefined {
return demoProjects.find((project) => project.id === projectId);
}
export function getDemoServicesByProject(projectId: string): ServiceEntity[] {
return demoServicesByProject[projectId] ?? [];
}
export function getDemoServiceById(serviceId: string): ServiceEntity | undefined {
return allServices.find((service) => service.id === serviceId);
}
export function getDemoVariablesByProject(projectId: string): Record<string, ServiceVariable[]> {
return demoVariablesByProject[projectId] ?? {};
}
+47
View File
@@ -0,0 +1,47 @@
export function formatRelative(iso?: string): string {
if (!iso) {
return 'n/a';
}
const timestamp = Date.parse(iso);
if (Number.isNaN(timestamp)) {
return 'n/a';
}
const diffMs = Date.now() - timestamp;
const minute = 60_000;
const hour = 60 * minute;
const day = 24 * hour;
if (diffMs < minute) {
return 'just now';
}
if (diffMs < hour) {
return `${Math.floor(diffMs / minute)}m ago`;
}
if (diffMs < day) {
return `${Math.floor(diffMs / hour)}h ago`;
}
return `${Math.floor(diffMs / day)}d ago`;
}
export function formatDate(iso?: string): string {
if (!iso) {
return 'n/a';
}
const dt = new Date(iso);
if (Number.isNaN(dt.getTime())) {
return 'n/a';
}
return dt.toLocaleString();
}
export function seededMetric(seed: string, min: number, max: number): number {
let hash = 0;
for (let idx = 0; idx < seed.length; idx += 1) {
hash = (hash * 31 + seed.charCodeAt(idx)) & 0xffffffff;
}
const ratio = Math.abs(hash) % 10_000 / 10_000;
return Math.round(min + (max - min) * ratio);
}
+19
View File
@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { getAuthSession } from '@/lib/auth-client';
type UseAuthSessionOptions = {
enabled?: boolean;
};
export function useAuthSession(options: UseAuthSessionOptions = {}) {
const { enabled = true } = options;
return useQuery({
queryKey: ['auth-session'],
queryFn: getAuthSession,
enabled,
staleTime: 30_000,
refetchOnWindowFocus: true,
retry: false,
});
}
+131
View File
@@ -0,0 +1,131 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { getApiBaseUrl } from '@/lib/api-client';
type BuildUpdatePayload = {
channel: string;
data: unknown;
};
type BuildUpdateMessage = {
type?: string;
channel?: string;
data?: unknown;
};
function toWebSocketUrl(apiBase: string): string {
const wsBase = apiBase.startsWith('https://') ? apiBase.replace('https://', 'wss://') : apiBase.replace('http://', 'ws://');
return `${wsBase}/ws`;
}
export function useBuildUpdates(
buildIds: string[],
onBuildUpdate: (payload: BuildUpdatePayload) => void,
): boolean {
const [connected, setConnected] = useState(false);
const callbackRef = useRef(onBuildUpdate);
useEffect(() => {
callbackRef.current = onBuildUpdate;
}, [onBuildUpdate]);
const normalizedBuildIds = useMemo(
() => Array.from(new Set(buildIds.filter(Boolean))).sort(),
[buildIds],
);
const buildIdsKey = normalizedBuildIds.join('|');
const subscriptionIds = useMemo(
() => (buildIdsKey ? buildIdsKey.split('|') : []),
[buildIdsKey],
);
useEffect(() => {
if (subscriptionIds.length === 0) {
return;
}
const wsUrl = toWebSocketUrl(getApiBaseUrl());
let socket: WebSocket | null = null;
let reconnectTimer: number | null = null;
let closedByEffect = false;
const subscribeAll = () => {
if (!socket || socket.readyState !== WebSocket.OPEN) {
return;
}
for (const buildId of subscriptionIds) {
socket.send(
JSON.stringify({
action: 'subscribe',
channel: `build:${buildId}`,
}),
);
}
};
const connect = () => {
socket = new WebSocket(wsUrl);
socket.onopen = () => {
setConnected(true);
subscribeAll();
};
socket.onmessage = (event) => {
const lines = String(event.data ?? '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
for (const line of lines) {
try {
const parsed = JSON.parse(line) as BuildUpdateMessage;
if (parsed.type !== 'build_update' || !parsed.channel) {
continue;
}
callbackRef.current({
channel: parsed.channel,
data: parsed.data,
});
} catch {
// Ignore malformed messages.
}
}
};
socket.onclose = () => {
setConnected(false);
if (closedByEffect) {
return;
}
reconnectTimer = window.setTimeout(connect, 2000);
};
socket.onerror = () => {
setConnected(false);
};
};
connect();
return () => {
closedByEffect = true;
setConnected(false);
if (reconnectTimer !== null) {
window.clearTimeout(reconnectTimer);
}
if (socket && socket.readyState === WebSocket.OPEN) {
for (const buildId of subscriptionIds) {
socket.send(
JSON.stringify({
action: 'unsubscribe',
channel: `build:${buildId}`,
}),
);
}
}
socket?.close();
};
}, [buildIdsKey, subscriptionIds]);
return subscriptionIds.length > 0 && connected;
}
+29
View File
@@ -0,0 +1,29 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import App from './app/App';
import { ToastProvider } from './shared/components';
import './index.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
refetchOnWindowFocus: false,
},
},
});
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<ToastProvider>
<App />
</ToastProvider>
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
);
@@ -0,0 +1,109 @@
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react';
type BadgeVariant = 'default' | 'success' | 'warning' | 'error' | 'info' | 'neutral';
type BadgeSize = 'sm' | 'md' | 'lg';
type BadgeProps = HTMLAttributes<HTMLSpanElement> & {
variant?: BadgeVariant;
size?: BadgeSize;
icon?: ReactNode;
dot?: boolean;
}
const variantStyles: Record<BadgeVariant, { bg: string; color: string; dot: string }> = {
default: {
bg: 'bg-[var(--accent-primary-soft)]',
color: 'text-[var(--accent-primary)]',
dot: 'bg-[var(--accent-primary)]',
},
success: {
bg: 'bg-[var(--success-soft)]',
color: 'text-[var(--success)]',
dot: 'bg-[var(--success)]',
},
warning: {
bg: 'bg-[var(--warning-soft)]',
color: 'text-[var(--warning)]',
dot: 'bg-[var(--warning)]',
},
error: {
bg: 'bg-[var(--error-soft)]',
color: 'text-[var(--error)]',
dot: 'bg-[var(--error)]',
},
info: {
bg: 'bg-[var(--info-soft)]',
color: 'text-[var(--info)]',
dot: 'bg-[var(--info)]',
},
neutral: {
bg: 'bg-[var(--surface-muted)]',
color: 'text-[var(--text-secondary)]',
dot: 'bg-[var(--text-tertiary)]',
},
};
const sizeStyles: Record<BadgeSize, string> = {
sm: 'px-2 py-0.5 text-[10px] gap-1',
md: 'px-2.5 py-1 text-xs gap-1.5',
lg: 'px-3 py-1.5 text-sm gap-2',
};
export const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
(
{
variant = 'default',
size = 'md',
icon,
dot = false,
className = '',
children,
...props
},
ref
) => {
const styles = variantStyles[variant];
return (
<span
ref={ref}
className={`
inline-flex items-center font-medium rounded-full
${styles.bg} ${styles.color} ${sizeStyles[size]}
${className}
`}
{...props}
>
{dot && (
<span className={`w-1.5 h-1.5 rounded-full ${styles.dot}`} />
)}
{icon}
{children}
</span>
);
}
);
Badge.displayName = 'Badge';
type StatusBadgeProps = HTMLAttributes<HTMLSpanElement> & {
status: 'running' | 'success' | 'failed' | 'pending' | 'cancelled';
size?: BadgeSize;
}
const statusConfig: Record<StatusBadgeProps['status'], { variant: BadgeVariant; label: string }> = {
running: { variant: 'warning', label: 'Running' },
success: { variant: 'success', label: 'Success' },
failed: { variant: 'error', label: 'Failed' },
pending: { variant: 'neutral', label: 'Pending' },
cancelled: { variant: 'neutral', label: 'Cancelled' },
};
export function StatusBadge({ status, size = 'md', className = '', ...props }: StatusBadgeProps) {
const config = statusConfig[status];
return (
<Badge variant={config.variant} size={size} dot className={className} {...props}>
{config.label}
</Badge>
);
}
@@ -0,0 +1,76 @@
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
import { Loader2 } from 'lucide-react';
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'success';
type ButtonSize = 'sm' | 'md' | 'lg';
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
icon?: ReactNode;
iconPosition?: 'left' | 'right';
};
const variantStyles: Record<ButtonVariant, string> = {
primary: 'text-white shadow-lg hover:shadow-xl hover:scale-[1.02] active:scale-[0.98]',
secondary: 'border border-[var(--border-subtle)] bg-[var(--surface-card)] text-[var(--text-primary)] hover:border-[var(--border-default)] hover:bg-[var(--surface-card-hover)] active:scale-[0.98]',
ghost: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)] active:scale-[0.98]',
danger: 'border border-[var(--error-soft)] text-[var(--error)] hover:bg-[var(--error-soft)] hover:shadow-lg hover:shadow-[var(--error-glow)] active:scale-[0.98]',
success: 'border border-[var(--success-soft)] text-[var(--success)] hover:bg-[var(--success-soft)] hover:shadow-lg hover:shadow-[var(--success-glow)] active:scale-[0.98]',
};
const sizeStyles: Record<ButtonSize, string> = {
sm: 'h-8 px-3 text-xs gap-1.5 rounded-lg',
md: 'h-10 px-4 text-sm gap-2 rounded-[var(--radius-md)]',
lg: 'h-12 px-6 text-base gap-2.5 rounded-xl',
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
variant = 'primary',
size = 'md',
loading = false,
icon,
iconPosition = 'left',
className = '',
disabled,
children,
...props
},
ref
) => {
const isDisabled = disabled || loading;
return (
<button
ref={ref}
disabled={isDisabled}
className={`
inline-flex items-center justify-center font-semibold
transition-all duration-200 ease-out
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-base)]
${variantStyles[variant]}
${sizeStyles[size]}
${className}
`}
style={variant === 'primary' ? { background: '#e8316a' } : undefined}
{...props}
>
{loading ? (
<Loader2 size={size === 'sm' ? 14 : size === 'md' ? 16 : 18} className="animate-spin" />
) : (
<>
{icon && iconPosition === 'left' && icon}
{children}
{icon && iconPosition === 'right' && icon}
</>
)}
</button>
);
}
);
Button.displayName = 'Button';
@@ -0,0 +1,94 @@
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react';
type CardVariant = 'default' | 'elevated' | 'bordered' | 'ghost' | 'glass';
type CardProps = HTMLAttributes<HTMLDivElement> & {
variant?: CardVariant;
padding?: 'none' | 'sm' | 'md' | 'lg';
header?: ReactNode;
footer?: ReactNode;
hoverable?: boolean;
};
const variantStyles: Record<CardVariant, string> = {
default: 'bg-[var(--surface-card)] border border-[var(--border-subtle)]',
elevated: 'bg-[var(--surface-card)] shadow-lg shadow-black/40',
bordered: 'bg-transparent border border-[var(--border-default)]',
ghost: 'bg-[var(--surface-muted)]/50',
glass: 'bg-[var(--surface-card)]/80 backdrop-blur-xl border border-[var(--border-subtle)]',
};
const paddingStyles: Record<'none' | 'sm' | 'md' | 'lg', string> = {
none: '',
sm: 'p-3',
md: 'p-4 md:p-5',
lg: 'p-6 md:p-8',
};
export const Card = forwardRef<HTMLDivElement, CardProps>(
(
{
variant = 'default',
padding = 'md',
header,
footer,
hoverable = false,
className = '',
children,
...props
},
ref
) => {
return (
<div
ref={ref}
className={`
rounded-[var(--radius-lg)]
${variantStyles[variant]}
${paddingStyles[padding]}
${hoverable ? 'transition-all duration-200 hover:border-[var(--border-default)] hover:shadow-xl hover:shadow-black/30 hover:-translate-y-0.5' : ''}
${className}
`}
{...props}
>
{header && (
<div className="mb-4 pb-4 border-b border-[var(--border-subtle)]">{header}</div>
)}
{children}
{footer && (
<div className="mt-4 pt-4 border-t border-[var(--border-subtle)]">{footer}</div>
)}
</div>
);
}
);
Card.displayName = 'Card';
type CardHeaderProps = HTMLAttributes<HTMLDivElement> & {
title: string;
description?: string;
icon?: ReactNode;
action?: ReactNode;
};
export function CardHeader({ title, description, icon, action, className = '', ...props }: CardHeaderProps) {
return (
<div className={`flex items-start justify-between gap-4 ${className}`} {...props}>
<div className="flex items-start gap-3">
{icon && (
<div className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ring-1 ring-white/5" style={{ background: 'rgba(255,255,255,0.07)' }}>
{icon}
</div>
)}
<div>
<h3 className="text-lg font-semibold text-[var(--text-primary)] tracking-tight">{title}</h3>
{description && (
<p className="text-sm text-[var(--text-secondary)] mt-0.5">{description}</p>
)}
</div>
</div>
{action && <div className="flex-shrink-0">{action}</div>}
</div>
);
}
@@ -0,0 +1,244 @@
import {
Area,
AreaChart,
Line,
LineChart as RechartsLineChart,
ResponsiveContainer,
} from 'recharts';
interface LineChartProps {
data: number[];
color?: string;
fillColor?: string;
height?: number;
showDots?: boolean;
smooth?: boolean;
}
interface LineAreaChartProps {
data: number[];
color?: string;
fillOpacity?: number;
height?: number;
}
interface MultiLineChartDataset {
data: number[];
color: string;
fillOpacity?: number;
}
interface MultiLineChartProps {
datasets: MultiLineChartDataset[];
height?: number;
}
interface LineMetricChartProps {
data: number[];
color?: string;
fillOpacity?: number;
height?: number;
showArea?: boolean;
}
interface DualLineChartProps {
data1: number[];
data2: number[];
height?: number;
color1?: string;
color2?: string;
fillOpacity1?: number;
fillOpacity2?: number;
}
interface BarChartProps {
data: number[];
color?: string;
height?: number;
gap?: number;
}
function toLineData(data: number[]) {
return data.map((value, index) => ({ index, value }));
}
function gradientId(prefix: string, color: string, index = 0) {
return `${prefix}-${color.replace(/[^a-zA-Z0-9]/g, '') || 'chart'}-${index}`;
}
export function LineChart({
data,
color = '#ff7043',
fillColor = 'transparent',
height = 76,
showDots = true,
smooth = true,
}: LineChartProps) {
const chartData = toLineData(data);
return (
<div className="chart-wrap" style={{ height: `${height}px`, width: '100%' }}>
<ResponsiveContainer width="100%" height="100%">
<RechartsLineChart data={chartData}>
<Line
type={smooth ? 'monotone' : 'linear'}
dataKey="value"
stroke={color}
strokeWidth={2}
dot={showDots ? { r: 2, fill: color } : false}
fill={fillColor}
/>
</RechartsLineChart>
</ResponsiveContainer>
</div>
);
}
export function LineAreaChart({
data,
color = '#e8316a',
fillOpacity = 0.15,
height = 130,
}: LineAreaChartProps) {
const chartData = toLineData(data);
const id = gradientId('gradient', color);
return (
<div className="chart-wrap" style={{ height: `${height}px`, width: '100%' }}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={fillOpacity} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={2}
fill={`url(#${id})`}
dot={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}
export function MultiLineChart({
datasets,
height = 58,
}: MultiLineChartProps) {
const maxLength = Math.max(...datasets.map((dataset) => dataset.data.length));
const chartData = Array.from({ length: maxLength }, (_, index) => {
const point: Record<string, number> = { index };
datasets.forEach((dataset, datasetIndex) => {
point[`value${datasetIndex}`] = dataset.data[index] ?? 0;
});
return point;
});
return (
<div className="chart-wrap" style={{ height: `${height}px`, width: '100%' }}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
{datasets.map((dataset, index) => {
const id = gradientId('gradient-multi', dataset.color, index);
return (
<linearGradient key={id} id={id} x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor={dataset.color}
stopOpacity={dataset.fillOpacity ?? 0.15}
/>
<stop offset="100%" stopColor={dataset.color} stopOpacity={0} />
</linearGradient>
);
})}
</defs>
{datasets.map((dataset, index) => {
const id = gradientId('gradient-multi', dataset.color, index);
return (
<Area
key={id}
type="monotone"
dataKey={`value${index}`}
stroke={dataset.color}
strokeWidth={2}
fill={`url(#${id})`}
dot={false}
/>
);
})}
</AreaChart>
</ResponsiveContainer>
</div>
);
}
export function LineMetricChart({
data,
color = '#ff7043',
fillOpacity = 0.15,
height = 76,
showArea = false,
}: LineMetricChartProps) {
if (showArea) {
return (
<LineAreaChart
data={data}
color={color}
fillOpacity={fillOpacity}
height={height}
/>
);
}
return <LineChart data={data} color={color} height={height} showDots={false} />;
}
export function DualLineChart({
data1,
data2,
height = 58,
color1 = '#6c8ef0',
color2 = '#9c7ef0',
fillOpacity1 = 0.15,
fillOpacity2 = 0.15,
}: DualLineChartProps) {
return (
<MultiLineChart
datasets={[
{ data: data1, color: color1, fillOpacity: fillOpacity1 },
{ data: data2, color: color2, fillOpacity: fillOpacity2 },
]}
height={height}
/>
);
}
export function BarChart({
data,
color = '#3dd68c',
height = 128,
gap = 4,
}: BarChartProps) {
return (
<div className="flex h-full items-end" style={{ gap: `${gap}px`, height: `${height}px` }}>
{data.map((value, index) => (
<div
key={index}
className="flex-1 cursor-pointer rounded-[var(--radius-xs)] transition-all hover:opacity-80"
style={{
height: `${value}%`,
background: color,
}}
title={`${value}%`}
/>
))}
</div>
);
}
@@ -0,0 +1,280 @@
import { useEffect, useState } from 'react';
import { Command } from 'cmdk';
import {
Box,
Database,
Github,
Image,
Clock,
Search,
Settings,
FileText,
Activity,
LayoutGrid,
} from 'lucide-react';
interface CommandItem {
id: string;
label: string;
description?: string;
icon: typeof Box;
shortcut?: string;
action: () => void;
category?: string;
}
interface CommandPaletteProps {
open: boolean;
onClose: () => void;
onAddService?: (type: string) => void;
onNavigate?: (path: string) => void;
}
export function CommandPalette({
open,
onClose,
onAddService,
onNavigate,
}: CommandPaletteProps) {
if (!open) return null;
return (
<CommandPaletteContent
onClose={onClose}
onAddService={onAddService}
onNavigate={onNavigate}
/>
);
}
function CommandPaletteContent({
onClose,
onAddService,
onNavigate,
}: Omit<CommandPaletteProps, 'open'>) {
const [search, setSearch] = useState('');
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
onClose();
}
};
document.addEventListener('keydown', down);
return () => document.removeEventListener('keydown', down);
}, [onClose]);
const commands: CommandItem[] = [
{
id: 'add-web',
label: 'Add Web Service',
description: 'Deploy a web application',
icon: Box,
shortcut: 'W',
category: 'Create',
action: () => {
onAddService?.('web');
onClose();
},
},
{
id: 'add-worker',
label: 'Add Worker Service',
description: 'Deploy a background worker',
icon: Clock,
shortcut: 'K',
category: 'Create',
action: () => {
onAddService?.('worker');
onClose();
},
},
{
id: 'add-database',
label: 'Add Database',
description: 'Provision a PostgreSQL database',
icon: Database,
shortcut: 'D',
category: 'Create',
action: () => {
onAddService?.('database');
onClose();
},
},
{
id: 'add-cron',
label: 'Add Cron Job',
description: 'Schedule a recurring task',
icon: Clock,
category: 'Create',
action: () => {
onAddService?.('cron');
onClose();
},
},
{
id: 'connect-github',
label: 'Connect GitHub Repo',
description: 'Link a repository for auto-deploy',
icon: Github,
category: 'Connect',
action: () => {
onClose();
},
},
{
id: 'deploy-image',
label: 'Deploy Docker Image',
description: 'Deploy from a container registry',
icon: Image,
category: 'Connect',
action: () => {
onClose();
},
},
{
id: 'goto-canvas',
label: 'Go to Canvas',
icon: LayoutGrid,
shortcut: 'C',
category: 'Navigate',
action: () => {
onNavigate?.('canvas');
onClose();
},
},
{
id: 'goto-logs',
label: 'Go to Logs',
icon: FileText,
shortcut: 'L',
category: 'Navigate',
action: () => {
onNavigate?.('logs');
onClose();
},
},
{
id: 'goto-metrics',
label: 'Go to Metrics',
icon: Activity,
shortcut: 'M',
category: 'Navigate',
action: () => {
onNavigate?.('observability');
onClose();
},
},
{
id: 'goto-settings',
label: 'Go to Settings',
icon: Settings,
shortcut: 'S',
category: 'Navigate',
action: () => {
onNavigate?.('settings');
onClose();
},
},
];
const filteredCommands = commands.filter(
(cmd) =>
cmd.label.toLowerCase().includes(search.toLowerCase()) ||
cmd.description?.toLowerCase().includes(search.toLowerCase()) ||
cmd.category?.toLowerCase().includes(search.toLowerCase())
);
const groupedCommands = filteredCommands.reduce(
(acc, cmd) => {
const category = cmd.category || 'Other';
if (!acc[category]) acc[category] = [];
acc[category].push(cmd);
return acc;
},
{} as Record<string, CommandItem[]>
);
return (
<div className="fixed inset-0 z-50">
<div
className="absolute inset-0 bg-[var(--bg-void)]/80 backdrop-blur-sm"
onClick={onClose}
/>
<div className="absolute left-1/2 top-[20%] -translate-x-1/2 w-full max-w-xl">
<Command
className="panel overflow-hidden"
loop
>
<div className="flex items-center gap-3 px-4 py-3 border-b border-[var(--border-subtle)]">
<Search size={18} className="text-[var(--text-muted)]" />
<Command.Input
value={search}
onValueChange={setSearch}
placeholder="What would you like to create?"
className="flex-1 bg-transparent text-[var(--text-primary)] placeholder:text-[var(--text-muted)] text-sm outline-none"
/>
<kbd className="px-2 py-0.5 rounded bg-[var(--surface-muted)] text-[10px] font-mono text-[var(--text-muted)]">
ESC
</kbd>
</div>
<Command.List className="max-h-[320px] overflow-y-auto p-2">
<Command.Empty className="py-6 text-center text-sm text-[var(--text-muted)]">
No results found.
</Command.Empty>
{Object.entries(groupedCommands).map(([category, items]) => (
<Command.Group key={category} heading={category} className="mb-2">
<div className="px-2 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-[var(--text-muted)]">
{category}
</div>
{items.map((item) => {
const Icon = item.icon;
return (
<Command.Item
key={item.id}
value={item.label}
onSelect={item.action}
className="flex items-center gap-3 px-3 py-2.5 rounded-[var(--radius-md)] cursor-pointer text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] aria-selected:bg-[var(--accent-primary-soft)] aria-selected:text-[var(--accent-primary)] transition-colors"
>
<Icon size={18} className="text-[var(--text-muted)]" />
<div className="flex-1">
<div className="text-sm font-medium">{item.label}</div>
{item.description && (
<div className="text-xs text-[var(--text-muted)]">{item.description}</div>
)}
</div>
{item.shortcut && (
<kbd className="px-2 py-0.5 rounded bg-[var(--surface-muted)] text-[10px] font-mono text-[var(--text-muted)]">
{item.shortcut}
</kbd>
)}
</Command.Item>
);
})}
</Command.Group>
))}
</Command.List>
<div className="flex items-center gap-4 px-4 py-2 border-t border-[var(--border-subtle)] text-[10px] text-[var(--text-muted)]">
<div className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[9px]"></kbd>
<span>Navigate</span>
</div>
<div className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[9px]"></kbd>
<span>Select</span>
</div>
<div className="flex items-center gap-1">
<kbd className="px-1.5 py-0.5 rounded bg-[var(--surface-muted)] text-[9px]">K</kbd>
<span>Toggle</span>
</div>
</div>
</Command>
</div>
</div>
);
}
@@ -0,0 +1,143 @@
import { useEffect, useRef, useState } from 'react';
interface DonutChartProps {
percent?: number;
percentage?: number;
size?: number;
thickness?: number;
color?: string;
trackColor?: string;
segments?: number;
gap?: number;
animated?: boolean;
}
export function DonutChart({
percent,
percentage,
size = 160,
thickness = 16,
color = '#9c7ef0',
trackColor = 'rgba(255, 255, 255, 0.07)',
segments = 28,
gap = 0.048,
animated = true,
}: DonutChartProps) {
const resolvedPercent = percentage ?? percent ?? 0;
const [isVisible, setIsVisible] = useState(false);
const [animatedPercent, setAnimatedPercent] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const animatedPercentRef = useRef(0);
useEffect(() => {
animatedPercentRef.current = animatedPercent;
}, [animatedPercent]);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) setIsVisible(true);
},
{ threshold: 0.1 }
);
if (containerRef.current) {
observer.observe(containerRef.current);
}
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!animated) {
const frame = requestAnimationFrame(() => setAnimatedPercent(resolvedPercent));
return () => cancelAnimationFrame(frame);
}
if (isVisible && animated) {
const duration = 800;
const startTime = Date.now();
const startPercent = animatedPercentRef.current;
const targetPercent = resolvedPercent;
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
setAnimatedPercent(startPercent + (targetPercent - startPercent) * eased);
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
}
}, [animated, isVisible, resolvedPercent]);
const R = size / 2 - 4;
const r = R - thickness;
const cx = size / 2;
const cy = size - 8;
const filled = Math.round(segments * (animatedPercent / 100));
const segmentPaths = [];
for (let i = 0; i < segments; i++) {
const a0 = Math.PI + (Math.PI / segments) * i + gap / 2;
const a1 = Math.PI + (Math.PI / segments) * (i + 1) - gap / 2;
const isFilled = i < filled;
const x1Start = cx + R * Math.cos(a0);
const y1Start = cy + R * Math.sin(a0);
const x1End = cx + R * Math.cos(a1);
const y1End = cy + R * Math.sin(a1);
const x2Start = cx + r * Math.cos(a1);
const y2Start = cy + r * Math.sin(a1);
const x2End = cx + r * Math.cos(a0);
const y2End = cy + r * Math.sin(a0);
const path = `M ${x1Start} ${y1Start} A ${R} ${R} 0 0 1 ${x1End} ${y1End} L ${x2Start} ${y2Start} A ${r} ${r} 0 0 0 ${x2End} ${y2End} Z`;
segmentPaths.push(
<path
key={i}
d={path}
fill={isFilled ? color : trackColor}
className="transition-colors duration-300"
/>
);
}
return (
<div ref={containerRef} className="relative" style={{ width: size, height: size * 0.6 }}>
<svg width={size} height={size * 0.6} viewBox={`0 0 ${size} ${size * 0.6}`}>
{segmentPaths}
</svg>
</div>
);
}
interface SegmentedBarProps {
segments: Array<{ width: number; color: string }>;
height?: number;
className?: string;
}
export function SegmentedBar({ segments, height = 32, className = '' }: SegmentedBarProps) {
return (
<div className={`flex items-center gap-[5px] h-[${height}px] ${className}`}>
{segments.map((seg, i) => (
<div
key={i}
className="h-full transition-all duration-200 hover:brightness-110 cursor-pointer"
style={{
width: `${seg.width}%`,
background: seg.color,
borderRadius: i === 0 ? '10px 4px 4px 10px' : i === segments.length - 1 ? '4px 10px 10px 4px' : '5px',
}}
/>
))}
</div>
);
}
@@ -0,0 +1,406 @@
import { type ReactNode } from 'react';
interface EnhancedMetricCardProps {
title: string;
value: string | number;
subtitle?: string;
status?: 'good' | 'average' | 'warning';
statusText?: string;
icon: ReactNode;
chart?: ReactNode;
details?: ReactNode;
onClick?: () => void;
className?: string;
animationDelay?: number;
horizontal?: boolean;
}
export function EnhancedMetricCard({
title,
value,
subtitle,
status,
statusText,
icon,
chart,
details,
onClick,
className = '',
animationDelay = 0,
horizontal = false,
}: EnhancedMetricCardProps) {
const statusColors: Record<string, string> = {
good: '#3dd68c',
average: '#f0a040',
warning: '#ff7043',
};
// Horizontal layout for cards like Active User
if (horizontal) {
return (
<div
className={`card ${className}`}
style={{
display: 'flex',
flexDirection: 'row',
padding: 0,
overflow: 'hidden',
animationDelay: `${animationDelay}s`,
}}
>
<div style={{
flex: 1,
padding: '20px 18px 18px 20px',
display: 'flex',
flexDirection: 'column',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div className="card-icon">{icon}</div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>{title}</span>
</div>
<div style={{
fontSize: 36,
fontWeight: 900,
letterSpacing: '-1.5px',
lineHeight: 1,
color: '#e8e9f0',
}}>
{value}
</div>
{subtitle && (
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>{subtitle}</div>
)}
{details && <div style={{ marginTop: 12 }}>{details}</div>}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 'auto',
paddingTop: 14,
}}>
<span
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
onClick={onClick}
>
Details
</span>
<div className="arrow-btn" onClick={onClick}>
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</div>
</div>
</div>
{chart && (
<div style={{
width: '50%',
padding: '16px 14px 46px 0',
display: 'flex',
alignItems: 'flex-end',
}}>
{chart}
</div>
)}
</div>
);
}
// Standard vertical card layout - matching self.html exactly
return (
<div
className={`card ${className}`}
style={{
display: 'flex',
flexDirection: 'column',
padding: 20,
animationDelay: `${animationDelay}s`,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div className="card-icon">{icon}</div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>{title}</span>
</div>
<div style={{
fontSize: 38,
fontWeight: 900,
letterSpacing: '-1.5px',
lineHeight: 1,
color: '#e8e9f0',
}}>
{value}
</div>
{(subtitle || statusText) && (
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>
{statusText && status && (
<span style={{ color: statusColors[status], fontWeight: 700 }}>{statusText}</span>
)}{' '}
{subtitle}
</div>
)}
{chart && <div style={{ marginTop: 12, marginBottom: 6 }}>{chart}</div>}
{details && <div style={{ marginTop: 16 }}>{details}</div>}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingTop: 4,
marginTop: 'auto'
}}>
<span
onClick={onClick}
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
>
Details
</span>
<div className="arrow-btn" onClick={onClick}>
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</div>
</div>
</div>
);
}
interface CacheMetricCardProps {
totalMB: number;
cacheMB: number;
nonCacheMB: number;
onClick?: () => void;
animationDelay?: number;
}
export function CacheMetricCard({
totalMB,
cacheMB,
nonCacheMB,
onClick,
animationDelay = 0,
}: CacheMetricCardProps) {
const cachePercent = Math.round((cacheMB / totalMB) * 100);
const nonCachePercent = Math.round((nonCacheMB / totalMB) * 100);
const freePercent = 100 - cachePercent - nonCachePercent;
return (
<div
className="card"
style={{
display: 'flex',
flexDirection: 'column',
padding: 20,
animationDelay: `${animationDelay}s`,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div className="card-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="#9295a4" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>Cache</span>
</div>
<div style={{
fontSize: 38,
fontWeight: 900,
letterSpacing: '-1.5px',
lineHeight: 1,
color: '#e8e9f0',
}}>
{totalMB} MB
</div>
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>
<span style={{ color: '#f0a040', fontWeight: 700 }}>{Math.round(totalMB * 0.625)}MB Average</span> cached images and files
</div>
{/* Segmented Bar */}
<div style={{ display: 'flex', alignItems: 'center', gap: 5, margin: '16px 0 15px', height: 32 }}>
<div
className="cache-seg"
style={{
width: `${cachePercent}%`,
background: '#ff6b5b',
borderRadius: '10px 4px 4px 10px'
}}
/>
<div
className="cache-seg"
style={{
width: `${nonCachePercent}%`,
background: '#8c6ef0',
borderRadius: '5px'
}}
/>
<div
className="cache-seg"
style={{
flex: 1,
background: 'rgba(255,255,255,0.07)',
borderRadius: '4px 10px 10px 4px'
}}
/>
</div>
{/* Stats Row */}
<div style={{
display: 'grid',
gridTemplateColumns: '1fr auto 1fr auto 1fr',
gap: 0,
alignItems: 'stretch'
}}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 11, color: '#6b6e7d', marginBottom: 5 }}>
<div className="stat-dot" style={{ background: '#ff6b5b' }} /> Cache
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4 }}>
<span style={{ fontSize: 15, fontWeight: 800 }}>{cacheMB} MB</span>
<span style={{ fontSize: 11, color: '#6b6e7d' }}>{cachePercent}%</span>
</div>
</div>
<div style={{ width: 1, background: 'rgba(255,255,255,0.08)', margin: '0 16px' }} />
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 11, color: '#6b6e7d', marginBottom: 5 }}>
<div className="stat-dot" style={{ background: '#8c6ef0' }} /> Non-Cache
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4 }}>
<span style={{ fontSize: 15, fontWeight: 800 }}>{nonCacheMB} MB</span>
<span style={{ fontSize: 11, color: '#6b6e7d' }}>{nonCachePercent}%</span>
</div>
</div>
<div style={{ width: 1, background: 'rgba(255,255,255,0.08)', margin: '0 16px' }} />
<div>
<div style={{ fontSize: 11, color: '#6b6e7d', marginBottom: 5 }}>Total</div>
<div style={{ fontSize: 15, fontWeight: 800 }}>{totalMB * 5} GB</div>
</div>
</div>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingTop: 16
}}>
<span
onClick={onClick}
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
>
Details
</span>
<div className="arrow-btn" onClick={onClick}>
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</div>
</div>
</div>
);
}
interface PerformanceMetricCardProps {
percentage: number;
upSpeed: number;
downSpeed: number;
datasets: Array<{ data: number[]; color: string; fillOpacity?: number }>;
onClick?: () => void;
animationDelay?: number;
}
export function PerformanceMetricCard({
percentage,
upSpeed,
downSpeed,
datasets,
onClick,
animationDelay = 0,
}: PerformanceMetricCardProps) {
const status = percentage >= 85 ? 'Good' : percentage >= 70 ? 'Average' : 'Warning';
const statusColor = percentage >= 85 ? '#3dd68c' : percentage >= 70 ? '#f0a040' : '#ff7043';
return (
<div
className="card"
style={{
display: 'flex',
flexDirection: 'column',
padding: 20,
animationDelay: `${animationDelay}s`,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div className="card-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="#9295a4" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
</div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>Performance</span>
</div>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 16, flex: 1 }}>
<div style={{ flex: 1 }}>
<div style={{
fontSize: 36,
fontWeight: 900,
letterSpacing: '-1.5px',
lineHeight: 1,
color: '#e8e9f0',
}}>
{percentage}%
</div>
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>
<span style={{ color: statusColor, fontWeight: 700 }}>{status}</span> Last scan on {new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 8 }}>
{/* Chart placeholder - will be replaced with actual chart */}
<div style={{ width: 134, height: 58, background: 'rgba(255,255,255,0.03)', borderRadius: 8 }} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-end' }}>
<div className="speed-row" style={{ color: '#6c8ef0' }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
<line x1="12" y1="19" x2="12" y2="5"/>
<polyline points="5 12 12 19 19 12"/>
</svg>
<span>{upSpeed}</span> Mbps
</div>
<div className="speed-row" style={{ color: '#e8316a' }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
<line x1="12" y1="5" x2="12" y2="19"/>
<polyline points="19 12 12 5 5 12"/>
</svg>
<span>{downSpeed}</span> Mbps
</div>
</div>
</div>
</div>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingTop: 14
}}>
<span
onClick={onClick}
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
>
Check Speed
</span>
<div className="arrow-btn" onClick={onClick}>
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</div>
</div>
</div>
);
}
@@ -0,0 +1,102 @@
import { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
};
}
static getDerivedStateFromError(error: Error): State {
return {
hasError: true,
error,
};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
// Call optional error handler
if (this.props.onError) {
this.props.onError(error, errorInfo);
}
// Log to error tracking service (e.g., Sentry)
if (import.meta.env.PROD) {
// TODO: Send to error tracking service
console.error('Production error:', {
error: error.toString(),
componentStack: errorInfo.componentStack,
});
}
}
handleReset = () => {
this.setState({
hasError: false,
error: null,
});
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="min-h-screen bg-[var(--bg-void)] flex items-center justify-center p-4">
<div className="max-w-md w-full bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg p-6 space-y-4">
<div className="space-y-2">
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
Something went wrong
</h2>
<p className="text-sm text-[var(--text-secondary)]">
An unexpected error occurred. Please try refreshing the page.
</p>
</div>
{import.meta.env.DEV && this.state.error && (
<div className="bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded p-3">
<p className="text-xs font-mono text-[var(--text-error)] break-all">
{this.state.error.toString()}
</p>
</div>
)}
<div className="flex gap-2">
<button
onClick={this.handleReset}
className="flex-1 px-4 py-2 bg-[var(--bg-accent)] text-[var(--text-primary)] rounded hover:opacity-90 transition-opacity"
>
Try Again
</button>
<button
onClick={() => window.location.reload()}
className="flex-1 px-4 py-2 bg-[var(--bg-secondary)] text-[var(--text-primary)] border border-[var(--border-primary)] rounded hover:bg-[var(--bg-tertiary)] transition-colors"
>
Reload Page
</button>
</div>
</div>
</div>
);
}
return this.props.children;
}
}
@@ -0,0 +1,170 @@
import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react';
type InputVariant = 'default' | 'error' | 'success';
type InputProps = InputHTMLAttributes<HTMLInputElement> & {
variant?: InputVariant;
icon?: ReactNode;
iconPosition?: 'left' | 'right';
hint?: string;
label?: string;
}
const variantStyles: Record<InputVariant, string> = {
default: 'border-[var(--border-subtle)] focus:border-[var(--accent-primary)]',
error: 'border-[var(--error)] focus:border-[var(--error)]',
success: 'border-[var(--success)] focus:border-[var(--success)]',
};
export const Input = forwardRef<HTMLInputElement, InputProps>(
(
{
variant = 'default',
icon,
iconPosition = 'left',
hint,
label,
className = '',
...props
},
ref
) => {
return (
<div className={className}>
{label && (
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
{label}
</label>
)}
<div className="relative">
{icon && iconPosition === 'left' && (
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)]">
{icon}
</div>
)}
<input
ref={ref}
className={`
w-full h-10 px-3 rounded-[var(--radius-md)]
border bg-[var(--surface-muted)] text-sm
placeholder:text-[var(--text-muted)]
transition-colors duration-200
focus:outline-none
${variantStyles[variant]}
${icon && iconPosition === 'left' ? 'pl-10' : ''}
${icon && iconPosition === 'right' ? 'pr-10' : ''}
`}
{...props}
/>
{icon && iconPosition === 'right' && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)]">
{icon}
</div>
)}
</div>
{hint && (
<p className={`mt-1.5 text-xs ${variant === 'error' ? 'text-[var(--error)]' : 'text-[var(--text-muted)]'}`}>
{hint}
</p>
)}
</div>
);
}
);
Input.displayName = 'Input';
type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement> & {
variant?: InputVariant;
hint?: string;
label?: string;
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
(
{
variant = 'default',
hint,
label,
className = '',
...props
},
ref
) => {
return (
<div className={className}>
{label && (
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
{label}
</label>
)}
<textarea
ref={ref}
className={`
w-full px-3 py-2 rounded-[var(--radius-md)]
border bg-[var(--surface-muted)] text-sm
placeholder:text-[var(--text-muted)]
transition-colors duration-200
focus:outline-none
${variantStyles[variant]}
`}
{...props}
/>
{hint && (
<p className={`mt-1.5 text-xs ${variant === 'error' ? 'text-[var(--error)]' : 'text-[var(--text-muted)]'}`}>
{hint}
</p>
)}
</div>
);
}
);
Textarea.displayName = 'Textarea';
type SelectProps = React.SelectHTMLAttributes<HTMLSelectElement> & {
variant?: 'default' | 'error';
hint?: string;
label?: string;
}
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
(
{
variant = 'default',
hint,
label,
className = '',
...props
},
ref
) => {
return (
<div className={className}>
{label && (
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
{label}
</label>
)}
<select
ref={ref}
className={`
w-full h-10 px-3 rounded-[var(--radius-md)]
border bg-[var(--surface-muted)] text-sm
transition-colors duration-200
focus:outline-none
${variant === 'error' ? 'border-[var(--error)]' : 'border-[var(--border-subtle)] focus:border-[var(--accent-primary)]'}
`}
{...props}
/>
{hint && (
<p className={`mt-1.5 text-xs ${variant === 'error' ? 'text-[var(--error)]' : 'text-[var(--text-muted)]'}`}>
{hint}
</p>
)}
</div>
);
}
);
Select.displayName = 'Select';
@@ -0,0 +1,51 @@
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function LoadingSpinner({ size = 'md', className = '' }: LoadingSpinnerProps) {
const sizeClasses = {
sm: 'w-4 h-4 border-2',
md: 'w-8 h-8 border-2',
lg: 'w-12 h-12 border-3',
};
return (
<div
className={`${sizeClasses[size]} border-[var(--border-primary)] border-t-[var(--text-accent)] rounded-full animate-spin ${className}`}
role="status"
aria-label="Loading"
>
<span className="sr-only">Loading...</span>
</div>
);
}
interface LoadingOverlayProps {
message?: string;
}
export function LoadingOverlay({ message = 'Loading...' }: LoadingOverlayProps) {
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg p-6 flex flex-col items-center gap-4">
<LoadingSpinner size="lg" />
<p className="text-sm text-[var(--text-secondary)]">{message}</p>
</div>
</div>
);
}
interface LoadingStateProps {
message?: string;
className?: string;
}
export function LoadingState({ message = 'Loading...', className = '' }: LoadingStateProps) {
return (
<div className={`flex flex-col items-center justify-center gap-3 py-12 ${className}`}>
<LoadingSpinner size="md" />
<p className="text-sm text-[var(--text-secondary)]">{message}</p>
</div>
);
}
@@ -0,0 +1,188 @@
import { type ReactNode, useEffect, useRef, useState } from 'react';
interface MetricCardProps {
title: string;
value: string | number;
subtitle?: string;
status?: 'good' | 'average' | 'warning';
statusText?: string;
icon: ReactNode;
chart?: ReactNode;
details?: ReactNode;
onClick?: () => void;
className?: string;
animationDelay?: number;
horizontal?: boolean;
}
export function MetricCard({
title,
value,
subtitle,
status,
statusText,
icon,
chart,
details,
onClick,
className = '',
animationDelay = 0,
horizontal = false,
}: MetricCardProps) {
const [isVisible, setIsVisible] = useState(false);
const cardRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setTimeout(() => setIsVisible(true), animationDelay * 1000);
}
},
{ threshold: 0.1 }
);
if (cardRef.current) {
observer.observe(cardRef.current);
}
return () => observer.disconnect();
}, [animationDelay]);
const statusColors: Record<string, string> = {
good: '#3dd68c',
average: '#f0a040',
warning: '#ff7043',
};
// Horizontal layout for cards like Active User
if (horizontal) {
return (
<div
ref={cardRef}
className={`panel ${className}`}
style={{
display: 'flex',
flexDirection: 'row',
padding: 0,
overflow: 'hidden',
opacity: isVisible ? 1 : 0,
transform: isVisible ? 'translateY(0)' : 'translateY(12px)',
transition: 'all 0.5s ease',
}}
>
<div style={{
flex: 1,
padding: '20px 18px 18px 20px',
display: 'flex',
flexDirection: 'column',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div className="card-icon">{icon}</div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>{title}</span>
</div>
<div style={{
fontSize: 36,
fontWeight: 900,
letterSpacing: '-1.5px',
lineHeight: 1,
color: '#e8e9f0',
}}>
{value}
</div>
{subtitle && (
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>{subtitle}</div>
)}
{details && <div style={{ marginTop: 12 }}>{details}</div>}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 'auto',
paddingTop: 14,
}}>
<span
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
onClick={onClick}
>
Details
</span>
<div className="arrow-btn" onClick={onClick}>
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</div>
</div>
</div>
{chart && (
<div style={{
width: '50%',
padding: '16px 14px 46px 0',
display: 'flex',
alignItems: 'flex-end',
}}>
{chart}
</div>
)}
</div>
);
}
// Standard vertical card layout - matching self.html exactly
return (
<div
ref={cardRef}
className={`panel ${className}`}
style={{
display: 'flex',
flexDirection: 'column',
padding: 20,
opacity: isVisible ? 1 : 0,
transform: isVisible ? 'translateY(0)' : 'translateY(12px)',
transition: 'all 0.5s ease',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<div className="card-icon">{icon}</div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#e8e9f0' }}>{title}</span>
</div>
<div style={{
fontSize: 38,
fontWeight: 900,
letterSpacing: '-1.5px',
lineHeight: 1,
color: '#e8e9f0',
}}>
{value}
</div>
{(subtitle || statusText) && (
<div style={{ fontSize: 12, color: '#6b6e7d', marginTop: 4 }}>
{statusText && status && (
<span style={{ color: statusColors[status], fontWeight: 700 }}>{statusText}</span>
)}{' '}
{subtitle}
</div>
)}
{chart && <div style={{ marginTop: 12, marginBottom: 6 }}>{chart}</div>}
{details && <div style={{ marginTop: 16 }}>{details}</div>}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: 4, marginTop: 'auto' }}>
<span
onClick={onClick}
style={{ fontSize: 13, color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}
>
Details
</span>
<div className="arrow-btn" onClick={onClick}>
<svg viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</div>
</div>
</div>
);
}
@@ -0,0 +1,134 @@
import { Check, X, Loader2, Box, HelpCircle, type LucideIcon } from 'lucide-react';
type ServiceStatus = 'running' | 'building' | 'failed' | 'stopped' | 'unknown';
interface StatusBadgeProps {
status: ServiceStatus | string;
size?: 'sm' | 'md' | 'lg';
showLabel?: boolean;
pulse?: boolean;
className?: string;
}
const statusConfig: Record<
ServiceStatus,
{ color: string; bg: string; Icon: LucideIcon; label: string; animate?: boolean; glow?: string }
> = {
running: {
color: 'var(--success)',
bg: 'var(--success-soft)',
Icon: Check,
label: 'Active',
animate: true,
glow: 'rgba(61, 214, 140, 0.4)',
},
building: {
color: 'var(--warning)',
bg: 'var(--warning-soft)',
Icon: Loader2,
label: 'Building',
animate: true,
glow: 'rgba(255, 112, 67, 0.4)',
},
failed: {
color: 'var(--error)',
bg: 'var(--error-soft)',
Icon: X,
label: 'Failed',
glow: 'rgba(255, 107, 91, 0.4)',
},
stopped: {
color: 'var(--text-muted)',
bg: 'var(--surface-muted)',
Icon: Box,
label: 'Stopped',
},
unknown: {
color: 'var(--text-muted)',
bg: 'var(--surface-muted)',
Icon: HelpCircle,
label: 'Unknown',
},
};
export function StatusBadge({
status,
size = 'md',
showLabel = true,
pulse = true,
className = '',
}: StatusBadgeProps) {
const config = statusConfig[status as ServiceStatus] ?? statusConfig.unknown;
const { Icon } = config;
const sizeClasses = {
sm: 'px-2 py-0.5 text-[10px] gap-1.5',
md: 'px-2.5 py-1 text-xs gap-1.5',
lg: 'px-3 py-1.5 text-sm gap-2',
};
const iconSizes = {
sm: 10,
md: 12,
lg: 14,
};
return (
<div
className={`inline-flex items-center rounded-full font-bold uppercase tracking-wider ring-1 ring-inset transition-all duration-200 ${sizeClasses[size]} ${className}`}
style={{
background: config.bg,
color: config.color,
borderColor: `${config.color}20`,
boxShadow: status === 'running' && pulse ? `0 0 12px ${config.glow}` : 'none',
}}
>
{status === 'running' && pulse && (
<span
className="w-1.5 h-1.5 rounded-full live-pulse"
style={{ background: config.color, boxShadow: `0 0 6px ${config.color}` }}
/>
)}
<Icon
size={iconSizes[size]}
className={config.animate && status === 'building' ? 'animate-spin' : ''}
/>
{showLabel && <span>{config.label}</span>}
</div>
);
}
interface LiveIndicatorProps {
isLive: boolean;
label?: string;
size?: 'sm' | 'md' | 'lg';
}
export function LiveIndicator({ isLive, label, size = 'md' }: LiveIndicatorProps) {
const sizeClasses = {
sm: 'px-2 py-0.5 text-[10px] gap-1.5',
md: 'px-2.5 py-1 text-xs gap-2',
lg: 'px-3 py-1.5 text-sm gap-2',
};
return (
<div
className={`inline-flex items-center rounded-full font-bold uppercase tracking-wider ring-1 ring-inset transition-all duration-300 ${sizeClasses[size]}`}
style={{
background: isLive ? 'var(--success-soft)' : 'var(--surface-muted)',
color: isLive ? 'var(--success)' : 'var(--text-muted)',
borderColor: isLive ? 'rgba(61, 214, 140, 0.2)' : 'rgba(255, 255, 255, 0.05)',
boxShadow: isLive ? '0 0 12px rgba(61, 214, 140, 0.3)' : 'none',
}}
>
<span
className={`w-1.5 h-1.5 rounded-full ${isLive ? 'live-pulse' : ''}`}
style={{
background: isLive ? 'var(--success)' : 'var(--text-muted)',
boxShadow: isLive ? '0 0 6px var(--success)' : 'none',
}}
/>
{label || (isLive ? 'Active' : 'Stopped')}
</div>
);
}
@@ -0,0 +1,107 @@
import * as ToastPrimitive from '@radix-ui/react-toast';
import { createContext, useContext, useState, ReactNode } from 'react';
import { IconX, IconCheck, IconAlertCircle, IconInfoCircle } from '@tabler/icons-react';
type ToastType = 'success' | 'error' | 'info' | 'warning';
interface Toast {
id: string;
type: ToastType;
title: string;
description?: string;
}
interface ToastContextValue {
showToast: (type: ToastType, title: string, description?: string) => void;
}
const ToastContext = createContext<ToastContextValue | undefined>(undefined);
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within ToastProvider');
}
return context;
}
interface ToastProviderProps {
children: ReactNode;
}
export function ToastProvider({ children }: ToastProviderProps) {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = (type: ToastType, title: string, description?: string) => {
const id = Math.random().toString(36).substring(7);
setToasts((prev) => [...prev, { id, type, title, description }]);
// Auto-dismiss after 5 seconds
setTimeout(() => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, 5000);
};
const removeToast = (id: string) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
};
const getIcon = (type: ToastType) => {
switch (type) {
case 'success':
return <IconCheck className="w-5 h-5 text-green-500" />;
case 'error':
return <IconAlertCircle className="w-5 h-5 text-red-500" />;
case 'warning':
return <IconAlertCircle className="w-5 h-5 text-yellow-500" />;
case 'info':
return <IconInfoCircle className="w-5 h-5 text-blue-500" />;
}
};
const getBorderColor = (type: ToastType) => {
switch (type) {
case 'success':
return 'border-green-500/50';
case 'error':
return 'border-red-500/50';
case 'warning':
return 'border-yellow-500/50';
case 'info':
return 'border-blue-500/50';
}
};
return (
<ToastContext.Provider value={{ showToast }}>
<ToastPrimitive.Provider swipeDirection="right">
{children}
{toasts.map((toast) => (
<ToastPrimitive.Root
key={toast.id}
className={`bg-[var(--bg-primary)] border ${getBorderColor(toast.type)} rounded-lg shadow-lg p-4 flex items-start gap-3 min-w-[300px] max-w-[420px] data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full`}
onOpenChange={(open) => {
if (!open) removeToast(toast.id);
}}
>
{getIcon(toast.type)}
<div className="flex-1 space-y-1">
<ToastPrimitive.Title className="text-sm font-medium text-[var(--text-primary)]">
{toast.title}
</ToastPrimitive.Title>
{toast.description && (
<ToastPrimitive.Description className="text-xs text-[var(--text-secondary)]">
{toast.description}
</ToastPrimitive.Description>
)}
</div>
<ToastPrimitive.Close className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors">
<IconX className="w-4 h-4" />
</ToastPrimitive.Close>
</ToastPrimitive.Root>
))}
<ToastPrimitive.Viewport className="fixed top-0 right-0 flex flex-col p-6 gap-2 w-full max-w-[420px] m-0 list-none z-[100] outline-none" />
</ToastPrimitive.Provider>
</ToastContext.Provider>
);
}
@@ -0,0 +1,8 @@
export * from './ErrorBoundary';
export * from './LoadingSpinner';
export * from './Toast';
export * from './Charts';
export * from './DonutChart';
export * from './EnhancedMetricCard';
export * from './CommandPalette';
export * from './StatusBadge';
@@ -0,0 +1,17 @@
import { createContext, useContext } from 'react';
export type ToastType = 'success' | 'info' | 'warning' | 'error';
export interface ToastContextValue {
showToast: (message: string, type?: ToastType) => void;
}
export const ToastContext = createContext<ToastContextValue | null>(null);
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
}
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom';
+272
View File
@@ -0,0 +1,272 @@
import type { Config } from 'tailwindcss'
const config: Config = {
darkMode: 'class',
content: [
'./index.html',
'./src/**/*.{js,ts,jsx,tsx}',
],
theme: {
extend: {
colors: {
border: 'rgb(var(--border) / <alpha-value>)',
input: 'rgb(var(--input) / <alpha-value>)',
ring: 'rgb(var(--ring) / <alpha-value>)',
background: 'rgb(var(--background) / <alpha-value>)',
foreground: 'rgb(var(--foreground) / <alpha-value>)',
primary: {
DEFAULT: 'rgb(var(--primary) / <alpha-value>)',
foreground: 'rgb(var(--primary-foreground) / <alpha-value>)',
},
secondary: {
DEFAULT: 'rgb(var(--secondary) / <alpha-value>)',
foreground: 'rgb(var(--secondary-foreground) / <alpha-value>)',
},
destructive: {
DEFAULT: 'rgb(var(--destructive) / <alpha-value>)',
foreground: 'rgb(var(--destructive-foreground) / <alpha-value>)',
},
muted: {
DEFAULT: 'rgb(var(--muted) / <alpha-value>)',
foreground: 'rgb(var(--muted-foreground) / <alpha-value>)',
},
accent: {
DEFAULT: 'rgb(var(--accent) / <alpha-value>)',
foreground: 'rgb(var(--accent-foreground) / <alpha-value>)',
},
popover: {
DEFAULT: 'rgb(var(--popover) / <alpha-value>)',
foreground: 'rgb(var(--popover-foreground) / <alpha-value>)',
},
card: {
DEFAULT: 'rgb(var(--card) / <alpha-value>)',
foreground: 'rgb(var(--card-foreground) / <alpha-value>)',
},
success: {
DEFAULT: 'rgb(var(--success) / <alpha-value>)',
foreground: 'rgb(var(--success-foreground) / <alpha-value>)',
},
warning: {
DEFAULT: 'rgb(var(--warning) / <alpha-value>)',
foreground: 'rgb(var(--warning-foreground) / <alpha-value>)',
},
info: {
DEFAULT: 'rgb(var(--info) / <alpha-value>)',
foreground: 'rgb(var(--info-foreground) / <alpha-value>)',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
'2xl': '1rem',
'3xl': '1.5rem',
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
mono: ['JetBrains Mono', 'Fira Code', 'Consolas', 'monospace'],
geist: ['Geist', 'Inter', 'sans-serif'],
},
fontSize: {
'2xs': ['0.625rem', { lineHeight: '0.875rem' }],
'3xl': ['1.875rem', { lineHeight: '2.25rem', letterSpacing: '-0.025em' }],
'4xl': ['2.25rem', { lineHeight: '2.5rem', letterSpacing: '-0.03em' }],
'5xl': ['3rem', { lineHeight: '1.15', letterSpacing: '-0.035em' }],
},
boxShadow: {
'glow': '0 0 20px rgb(var(--primary) / 0.15)',
'glow-lg': '0 0 40px rgb(var(--primary) / 0.2)',
'glow-xl': '0 0 60px rgb(var(--primary) / 0.25)',
'glow-success': '0 0 20px rgb(var(--success) / 0.15)',
'glow-warning': '0 0 20px rgb(var(--warning) / 0.15)',
'glow-destructive': '0 0 20px rgb(var(--destructive) / 0.15)',
'card': '0 1px 3px 0 rgb(0 0 0 / 0.08), 0 1px 2px -1px rgb(0 0 0 / 0.08)',
'card-hover': '0 10px 40px -10px rgb(0 0 0 / 0.2)',
'card-elevated': '0 4px 20px -2px rgb(0 0 0 / 0.1), 0 2px 8px -2px rgb(0 0 0 / 0.05)',
'sidebar': '0 0 40px rgb(0 0 0 / 0.08)',
'dropdown': '0 4px 24px -4px rgb(0 0 0 / 0.15), 0 2px 8px -2px rgb(0 0 0 / 0.1)',
'modal': '0 8px 40px -8px rgb(0 0 0 / 0.25), 0 4px 16px -4px rgb(0 0 0 / 0.15)',
'inner-glow': 'inset 0 1px 0 0 rgb(255 255 255 / 0.05)',
'elevated': '0 2px 8px -2px rgb(0 0 0 / 0.1), 0 4px 20px -4px rgb(0 0 0 / 0.08)',
},
animation: {
'fade-in': 'fadeIn 0.5s ease-out',
'fade-in-up': 'fadeInUp 0.4s ease-out',
'fade-in-down': 'fadeInDown 0.4s ease-out',
'scale-in': 'scaleIn 0.2s ease-out',
'slide-in-left': 'slideInLeft 0.3s ease-out',
'slide-in-right': 'slideInRight 0.3s ease-out',
'slide-in-up': 'slideInUp 0.3s ease-out',
'slide-in-down': 'slideInDown 0.3s ease-out',
'pulse-glow': 'pulseGlow 2s ease-in-out infinite',
'shimmer': 'shimmer 2s linear infinite',
'spin-slow': 'spin 3s linear infinite',
'bounce-subtle': 'bounceSubtle 2s ease-in-out infinite',
'gradient-x': 'gradientX 3s ease infinite',
'float': 'float 6s ease-in-out infinite',
'ping': 'ping 1s cubic-bezier(0, 0, 0.2, 1) infinite',
'wiggle': 'wiggle 0.5s ease-in-out infinite',
'border-glow': 'borderGlow 2s ease-in-out infinite',
'notification': 'notificationPulse 2s ease-in-out infinite',
'gradient-shift': 'gradientShift 3s ease infinite',
'tooltip-in': 'tooltipFadeIn 0.15s ease-out',
'popover-in': 'popoverFadeIn 0.15s ease-out',
'modal-in': 'modalFadeIn 0.2s ease-out',
'dropdown-in': 'dropdownFadeIn 0.15s ease-out',
'command-in': 'commandPaletteIn 0.15s ease-out',
'bounce-gentle': 'bounceGentle 1s ease-in-out infinite',
'glow-pulse': 'glowPulse 3s ease-in-out infinite',
'rotate-slow': 'rotateSlow 20s linear infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
fadeInUp: {
'0%': { opacity: '0', transform: 'translateY(10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
fadeInDown: {
'0%': { opacity: '0', transform: 'translateY(-10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
scaleIn: {
'0%': { opacity: '0', transform: 'scale(0.95)' },
'100%': { opacity: '1', transform: 'scale(1)' },
},
slideInLeft: {
'0%': { transform: 'translateX(-100%)' },
'100%': { transform: 'translateX(0)' },
},
slideInRight: {
'0%': { transform: 'translateX(100%)' },
'100%': { transform: 'translateX(0)' },
},
slideInUp: {
'0%': { transform: 'translateY(100%)' },
'100%': { transform: 'translateY(0)' },
},
slideInDown: {
'0%': { transform: 'translateY(-100%)' },
'100%': { transform: 'translateY(0)' },
},
pulseGlow: {
'0%, 100%': { boxShadow: '0 0 20px rgb(var(--primary) / 0.3)' },
'50%': { boxShadow: '0 0 40px rgb(var(--primary) / 0.5)' },
},
shimmer: {
'0%': { backgroundPosition: '-200% 0' },
'100%': { backgroundPosition: '200% 0' },
},
bounceSubtle: {
'0%, 100%': { transform: 'translateY(0)' },
'50%': { transform: 'translateY(-5px)' },
},
gradientX: {
'0%, 100%': { backgroundPosition: '0% 50%' },
'50%': { backgroundPosition: '100% 50%' },
},
float: {
'0%, 100%': { transform: 'translateY(0)' },
'50%': { transform: 'translateY(-10px)' },
},
wiggle: {
'0%, 100%': { transform: 'rotate(-3deg)' },
'50%': { transform: 'rotate(3deg)' },
},
borderGlow: {
'0%, 100%': { boxShadow: '0 0 5px rgb(var(--primary) / 0.3)' },
'50%': { boxShadow: '0 0 20px rgb(var(--primary) / 0.5)' },
},
notificationPulse: {
'0%, 100%': { transform: 'scale(1)', boxShadow: '0 0 0 0 rgb(var(--destructive) / 0.4)' },
'50%': { transform: 'scale(1.05)', boxShadow: '0 0 0 8px rgb(var(--destructive) / 0)' },
},
gradientShift: {
'0%, 100%': { backgroundPosition: '0% 50%' },
'50%': { backgroundPosition: '100% 50%' },
},
tooltipFadeIn: {
'0%': { opacity: '0', transform: 'translateY(4px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
popoverFadeIn: {
'0%': { opacity: '0', transform: 'scale(0.95) translateY(-4px)' },
'100%': { opacity: '1', transform: 'scale(1) translateY(0)' },
},
modalFadeIn: {
'0%': { opacity: '0', transform: 'scale(0.95)' },
'100%': { opacity: '1', transform: 'scale(1)' },
},
dropdownFadeIn: {
'0%': { opacity: '0', transform: 'translateY(-8px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
commandPaletteIn: {
'0%': { opacity: '0', transform: 'scale(0.96)' },
'100%': { opacity: '1', transform: 'scale(1)' },
},
bounceGentle: {
'0%, 100%': { transform: 'translateY(0)' },
'50%': { transform: 'translateY(-3px)' },
},
glowPulse: {
'0%, 100%': {
boxShadow: '0 0 20px rgb(var(--primary) / 0.2)',
transform: 'scale(1)'
},
'50%': {
boxShadow: '0 0 40px rgb(var(--primary) / 0.4)',
transform: 'scale(1.02)'
},
},
rotateSlow: {
'0%': { transform: 'rotate(0deg)' },
'100%': { transform: 'rotate(360deg)' },
},
},
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
'shimmer-gradient': 'linear-gradient(90deg, transparent, rgb(var(--muted) / 0.1), transparent)',
'dot-pattern': 'radial-gradient(circle, rgb(var(--border) / 0.5) 1px, transparent 1px)',
'grid-pattern': 'linear-gradient(rgb(var(--border) / 0.3) 1px, transparent 1px), linear-gradient(to right, rgb(var(--border) / 0.3) 1px, transparent 1px)',
'glow-burst': 'radial-gradient(circle at center, rgb(var(--primary) / 0.15), transparent 70%)',
'gradient-primary': 'linear-gradient(135deg, rgb(var(--primary)), rgb(var(--gradient-end)))',
'gradient-card': 'linear-gradient(to bottom right, rgb(var(--card)), rgb(var(--muted) / 0.3))',
'mesh-gradient': 'radial-gradient(at 40% 20%, rgb(var(--primary) / 0.08) 0px, transparent 50%), radial-gradient(at 80% 0%, rgb(var(--gradient-end) / 0.06) 0px, transparent 50%)',
},
backgroundSize: {
'dot': '20px 20px',
'grid': '40px 40px',
},
transitionDuration: {
'400': '400ms',
},
transitionTimingFunction: {
'bounce-in': 'cubic-bezier(0.68, -0.55, 0.265, 1.55)',
'smooth': 'cubic-bezier(0.4, 0, 0.2, 1)',
'snappy': 'cubic-bezier(0.2, 0, 0, 1)',
},
scale: {
'102': '1.02',
'98': '0.98',
},
spacing: {
'18': '4.5rem',
'88': '22rem',
},
zIndex: {
'60': '60',
'70': '70',
'80': '80',
'90': '90',
'100': '100',
},
},
},
plugins: [],
}
export default config
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"composite": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
/* Path mapping */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"composite": true,
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+2
View File
@@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;
+51
View File
@@ -0,0 +1,51 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
// Enable code splitting and optimization
rollupOptions: {
output: {
manualChunks: {
// Split vendor libraries
vendor: ['react', 'react-dom', '@tanstack/react-query'],
ui: ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu', 'lucide-react'],
utils: ['date-fns', 'clsx', 'tailwind-merge']
},
// Optimize chunk naming for better caching
chunkFileNames: 'assets/js/[name]-[hash].js',
entryFileNames: 'assets/js/[name]-[hash].js',
assetFileNames: 'assets/[ext]/[name]-[hash].[ext]'
}
},
// Enable source maps for production debugging
sourcemap: true,
// Optimize bundle size
minify: 'terser',
// Set appropriate chunk size limit for better caching
chunkSizeWarningLimit: 1000
},
// Optimize dependencies during build
optimizeDeps: {
include: ['react', 'react-dom', '@tanstack/react-query', 'date-fns']
},
// Development server optimization
server: {
fs: {
// Allow serving files from project root
allow: ['..']
}
},
// Preview server optimization
preview: {
port: 4173,
strictPort: true
}
});
+64
View File
@@ -0,0 +1,64 @@
/// <reference types="vitest" />
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'happy-dom',
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/lib/**/*.ts'],
},
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
// Enable code splitting and optimization
rollupOptions: {
output: {
manualChunks: {
// Split vendor libraries
vendor: ['react', 'react-dom', '@tanstack/react-query'],
ui: ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu', 'lucide-react'],
utils: ['date-fns', 'clsx', 'tailwind-merge']
},
// Optimize chunk naming for better caching
chunkFileNames: 'assets/js/[name]-[hash].js',
entryFileNames: 'assets/js/[name]-[hash].js',
assetFileNames: 'assets/[ext]/[name]-[hash].[ext]'
}
},
// Enable source maps for production debugging
sourcemap: true,
// Optimize bundle size
minify: 'terser',
// Set appropriate chunk size limit for better caching
chunkSizeWarningLimit: 1000
},
// Optimize dependencies during build
optimizeDeps: {
include: ['react', 'react-dom', '@tanstack/react-query', 'date-fns']
},
// Development server optimization
server: {
fs: {
// Allow serving files from project root
allow: ['..']
}
},
// Preview server optimization
preview: {
port: 4173,
strictPort: true
}
})