mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-06-04 04:22:57 +00:00
small fix, don't worry about it
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.*
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
coverage
|
||||
*.log
|
||||
|
||||
@@ -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;"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+8590
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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({}),
|
||||
});
|
||||
}
|
||||
@@ -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] ?? {};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
declare const _default: import("vite").UserConfig;
|
||||
export default _default;
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user