Experimental gui.

This commit is contained in:
2025-06-26 08:28:52 -07:00
parent 55b052256a
commit c855786e61
99 changed files with 11664 additions and 0 deletions

24
experimental/app/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,8 @@
{
"hash": "0e2daab1",
"configHash": "9fbff803",
"lockfileHash": "e3b0c442",
"browserHash": "04872398",
"optimized": {},
"chunks": {}
}

View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

View File

@@ -0,0 +1,54 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config({
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config({
plugins: {
// Add the react-x and react-dom plugins
'react-x': reactX,
'react-dom': reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules,
...reactDom.configs.recommended.rules,
},
})
```

View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -0,0 +1,28 @@
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'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Wild Cloud Central Management"
/>
<link rel="apple-touch-icon" href="/logo192.png" />
<link rel="manifest" href="/manifest.json" />
<title>Wild Cloud Central</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,53 @@
{
"name": "wild-cloud-central",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"type-check": "tsc --noEmit",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"build:css": "tailwindcss -i src/index.css -o ./output.css --config ./tailwind.config.js"
},
"dependencies": {
"@hookform/resolvers": "^5.1.1",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.10",
"@tanstack/react-query": "^5.62.10",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.516.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.58.1",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.10",
"zod": "^3.25.67"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@types/node": "^24.0.3",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"eslint": "^9.25.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"tw-animate-css": "^1.3.4",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5"
},
"packageManager": "pnpm@10.9.0+sha512.0486e394640d3c1fb3c9d43d49cf92879ff74f8516959c235308f5a8f62e2e19528a65cdc2a3058f587cde71eba3d5b56327c8c33a97e4c4051ca48a10ca2d5f"
}

3284
experimental/app/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@@ -0,0 +1,140 @@
import { useEffect, useState } from 'react';
import { useConfig } from './hooks';
import {
Advanced,
ErrorBoundary
} from './components';
import { CloudComponent } from './components/CloudComponent';
import { CentralComponent } from './components/CentralComponent';
import { DnsComponent } from './components/DnsComponent';
import { DhcpComponent } from './components/DhcpComponent';
import { PxeComponent } from './components/PxeComponent';
import { ClusterNodesComponent } from './components/ClusterNodesComponent';
import { ClusterServicesComponent } from './components/ClusterServicesComponent';
import { AppsComponent } from './components/AppsComponent';
import { AppSidebar } from './components/AppSidebar';
import { SidebarProvider, SidebarInset, SidebarTrigger } from './components/ui/sidebar';
import type { Phase, Tab } from './components/AppSidebar';
function App() {
const [currentTab, setCurrentTab] = useState<Tab>('cloud');
const [completedPhases, setCompletedPhases] = useState<Phase[]>([]);
const { config } = useConfig();
// Update phase state from config when it changes
useEffect(() => {
console.log('Config changed:', config);
console.log('config?.wildcloud:', config?.wildcloud);
if (config?.wildcloud?.currentPhase) {
console.log('Setting currentTab to:', config.wildcloud.currentPhase);
setCurrentTab(config.wildcloud.currentPhase as Phase);
}
if (config?.wildcloud?.completedPhases) {
console.log('Setting completedPhases to:', config.wildcloud.completedPhases);
setCompletedPhases(config.wildcloud.completedPhases as Phase[]);
}
}, [config]);
const handlePhaseComplete = (phase: Phase) => {
if (!completedPhases.includes(phase)) {
setCompletedPhases(prev => [...prev, phase]);
}
// Auto-advance to next phase (excluding advanced)
const phases: Phase[] = ['setup', 'infrastructure', 'cluster', 'apps'];
const currentIndex = phases.indexOf(phase);
if (currentIndex < phases.length - 1) {
setCurrentTab(phases[currentIndex + 1]);
}
};
const renderCurrentTab = () => {
switch (currentTab) {
case 'cloud':
return (
<ErrorBoundary>
<CloudComponent />
</ErrorBoundary>
);
case 'central':
return (
<ErrorBoundary>
<CentralComponent />
</ErrorBoundary>
);
case 'dns':
return (
<ErrorBoundary>
<DnsComponent />
</ErrorBoundary>
);
case 'dhcp':
return (
<ErrorBoundary>
<DhcpComponent />
</ErrorBoundary>
);
case 'pxe':
return (
<ErrorBoundary>
<PxeComponent />
</ErrorBoundary>
);
case 'setup':
case 'infrastructure':
return (
<ErrorBoundary>
<ClusterNodesComponent onComplete={() => handlePhaseComplete('infrastructure')} />
</ErrorBoundary>
);
case 'cluster':
return (
<ErrorBoundary>
<ClusterServicesComponent onComplete={() => handlePhaseComplete('cluster')} />
</ErrorBoundary>
);
case 'apps':
return (
<ErrorBoundary>
<AppsComponent onComplete={() => handlePhaseComplete('apps')} />
</ErrorBoundary>
);
case 'advanced':
return (
<ErrorBoundary>
<Advanced />
</ErrorBoundary>
);
default:
return (
<ErrorBoundary>
<CloudComponent />
</ErrorBoundary>
);
}
};
return (
<SidebarProvider>
<AppSidebar
currentTab={currentTab}
onTabChange={setCurrentTab}
completedPhases={completedPhases}
/>
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold">Dashboard</h1>
</div>
</header>
<div className="flex flex-1 flex-col gap-4 p-4">
{renderCurrentTab()}
</div>
</SidebarInset>
</SidebarProvider>
);
}
export default App;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,114 @@
import { useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "./ui/card";
import { ConfigEditor } from "./ConfigEditor";
import { Button, Input, Label } from "./ui";
import { Check, Edit2, HelpCircle, X } from "lucide-react";
export function Advanced() {
const [upstreamValue, setUpstreamValue] = useState("https://mywildcloud.org");
const [editingUpstream, setEditingUpstream] = useState(false);
const [tempUpstream, setTempUpstream] = useState(upstreamValue);
const handleUpstreamEdit = () => {
setTempUpstream(upstreamValue);
setEditingUpstream(true);
};
const handleUpstreamSave = () => {
setUpstreamValue(tempUpstream);
setEditingUpstream(false);
};
const handleUpstreamCancel = () => {
setTempUpstream(upstreamValue);
setEditingUpstream(false);
};
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Advanced Configuration</CardTitle>
<CardDescription>
Advanced settings and system configuration options
</CardDescription>
</CardHeader>
<CardContent>
<div>
<h3 className="text-sm font-medium mb-2">
Configuration Management
</h3>
<p className="text-sm text-muted-foreground mb-4">
Edit the raw YAML configuration file directly. This provides full
access to all configuration options.
</p>
<ConfigEditor />
</div>
</CardContent>
</Card>
{/* Upstream Section */}
<Card className="p-4 border-l-4 border-l-blue-500">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="font-medium">Upstream Configuration</h3>
<p className="text-sm text-muted-foreground">
External service endpoint
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm">
<HelpCircle className="h-4 w-4" />
</Button>
{!editingUpstream && (
<Button variant="outline" size="sm" onClick={handleUpstreamEdit}>
<Edit2 className="h-4 w-4 mr-1" />
Edit
</Button>
)}
</div>
</div>
{editingUpstream ? (
<div className="space-y-3">
<div>
<Label htmlFor="upstream-edit">Upstream URL</Label>
<Input
id="upstream-edit"
value={tempUpstream}
onChange={(e) => setTempUpstream(e.target.value)}
placeholder="https://example.com"
className="mt-1"
/>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={handleUpstreamSave}>
<Check className="h-4 w-4 mr-1" />
Save
</Button>
<Button
variant="outline"
size="sm"
onClick={handleUpstreamCancel}
>
<X className="h-4 w-4 mr-1" />
Cancel
</Button>
</div>
</div>
) : (
<div>
<Label>Upstream URL</Label>
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
{upstreamValue}
</div>
</div>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,416 @@
import { CheckCircle, Lock, Server, Play, Container, AppWindow, Settings, CloudLightning, Sun, Moon, Monitor, ChevronDown, Globe, Wifi, HardDrive } from 'lucide-react';
import { cn } from '../lib/utils';
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarRail,
} from './ui/sidebar';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
import { useTheme } from '../contexts/ThemeContext';
export type Phase = 'setup' | 'infrastructure' | 'cluster' | 'apps';
export type Tab = Phase | 'advanced' | 'cloud' | 'central' | 'dns' | 'dhcp' | 'pxe';
interface AppSidebarProps {
currentTab: Tab;
onTabChange: (tab: Tab) => void;
completedPhases: Phase[];
}
export function AppSidebar({ currentTab, onTabChange, completedPhases }: AppSidebarProps) {
const { theme, setTheme } = useTheme();
const cycleTheme = () => {
if (theme === 'light') {
setTheme('dark');
} else if (theme === 'dark') {
setTheme('system');
} else {
setTheme('light');
}
};
const getThemeIcon = () => {
switch (theme) {
case 'light':
return <Sun className="h-4 w-4" />;
case 'dark':
return <Moon className="h-4 w-4" />;
default:
return <Monitor className="h-4 w-4" />;
}
};
const getThemeLabel = () => {
switch (theme) {
case 'light':
return 'Light mode';
case 'dark':
return 'Dark mode';
default:
return 'System theme';
}
};
const getTabStatus = (tab: Tab) => {
// Non-phase tabs (like advanced and cloud) are always available
if (tab === 'advanced' || tab === 'cloud') {
return 'available';
}
// Central sub-tabs are available if setup phase is available or completed
if (tab === 'central' || tab === 'dns' || tab === 'dhcp' || tab === 'pxe') {
if (completedPhases.includes('setup')) {
return 'completed';
}
return 'available';
}
// For phase tabs, check completion status
if (completedPhases.includes(tab as Phase)) {
return 'completed';
}
// Allow access to the first phase always
if (tab === 'setup') {
return 'available';
}
// Allow access to the next phase if the previous phase is completed
if (tab === 'infrastructure' && completedPhases.includes('setup')) {
return 'available';
}
if (tab === 'cluster' && completedPhases.includes('infrastructure')) {
return 'available';
}
if (tab === 'apps' && completedPhases.includes('cluster')) {
return 'available';
}
return 'locked';
};
return (
<Sidebar variant="sidebar" collapsible="icon">
<SidebarHeader>
<div className="flex items-center gap-2 px-2">
<div className="p-1 bg-primary/10 rounded-lg">
<CloudLightning className="h-6 w-6 text-primary" />
</div>
<div className="group-data-[collapsible=icon]:hidden">
<h2 className="text-lg font-bold text-foreground">Wild Cloud</h2>
<p className="text-sm text-muted-foreground">Central</p>
</div>
</div>
</SidebarHeader>
<SidebarContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
isActive={currentTab === 'cloud'}
onClick={() => {
const status = getTabStatus('cloud');
if (status !== 'locked') onTabChange('cloud');
}}
disabled={getTabStatus('cloud') === 'locked'}
tooltip="Configure cloud settings and domains"
className={cn(
"transition-colors",
getTabStatus('cloud') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'cloud' && "bg-primary/10",
getTabStatus('cloud') === 'locked' && "bg-muted"
)}>
<CloudLightning className={cn(
"h-4 w-4",
currentTab === 'cloud' && "text-primary",
currentTab !== 'cloud' && "text-muted-foreground"
)} />
</div>
<span className="truncate">Cloud</span>
</SidebarMenuButton>
</SidebarMenuItem>
<Collapsible defaultOpen className="group/collapsible">
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton>
<Server className="h-4 w-4" />
Central
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
<SidebarMenuSubItem>
<SidebarMenuSubButton
isActive={currentTab === 'central'}
onClick={() => {
const status = getTabStatus('central');
if (status !== 'locked') onTabChange('central');
}}
className={cn(
"transition-colors",
getTabStatus('central') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'central' && "bg-primary/10",
getTabStatus('central') === 'locked' && "bg-muted"
)}>
<Server className={cn(
"h-4 w-4",
currentTab === 'central' && "text-primary",
currentTab !== 'central' && "text-muted-foreground"
)} />
</div>
<span className="truncate">Central</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton
isActive={currentTab === 'dns'}
onClick={() => {
const status = getTabStatus('dns');
if (status !== 'locked') onTabChange('dns');
}}
className={cn(
"transition-colors",
getTabStatus('dns') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'dns' && "bg-primary/10",
getTabStatus('dns') === 'locked' && "bg-muted"
)}>
<Globe className={cn(
"h-4 w-4",
currentTab === 'dns' && "text-primary",
currentTab !== 'dns' && "text-muted-foreground"
)} />
</div>
<span className="truncate">DNS</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton
isActive={currentTab === 'dhcp'}
onClick={() => {
const status = getTabStatus('dhcp');
if (status !== 'locked') onTabChange('dhcp');
}}
className={cn(
"transition-colors",
getTabStatus('dhcp') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'dhcp' && "bg-primary/10",
getTabStatus('dhcp') === 'locked' && "bg-muted"
)}>
<Wifi className={cn(
"h-4 w-4",
currentTab === 'dhcp' && "text-primary",
currentTab !== 'dhcp' && "text-muted-foreground"
)} />
</div>
<span className="truncate">DHCP</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton
isActive={currentTab === 'pxe'}
onClick={() => {
const status = getTabStatus('pxe');
if (status !== 'locked') onTabChange('pxe');
}}
className={cn(
"transition-colors",
getTabStatus('pxe') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'pxe' && "bg-primary/10",
getTabStatus('pxe') === 'locked' && "bg-muted"
)}>
<HardDrive className={cn(
"h-4 w-4",
currentTab === 'pxe' && "text-primary",
currentTab !== 'pxe' && "text-muted-foreground"
)} />
</div>
<span className="truncate">PXE</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
<Collapsible defaultOpen className="group/collapsible">
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton>
<Container className="h-4 w-4" />
Cluster
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
<SidebarMenuSubItem>
<SidebarMenuSubButton
isActive={currentTab === 'infrastructure'}
onClick={() => {
const status = getTabStatus('infrastructure');
if (status !== 'locked') onTabChange('infrastructure');
}}
className={cn(
"transition-colors",
getTabStatus('infrastructure') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'infrastructure' && "bg-primary/10",
getTabStatus('infrastructure') === 'locked' && "bg-muted"
)}>
<Play className={cn(
"h-4 w-4",
currentTab === 'infrastructure' && "text-primary",
currentTab !== 'infrastructure' && "text-muted-foreground"
)} />
</div>
<span className="truncate">Cluster Nodes</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton
isActive={currentTab === 'cluster'}
onClick={() => {
const status = getTabStatus('cluster');
if (status !== 'locked') onTabChange('cluster');
}}
className={cn(
"transition-colors",
getTabStatus('cluster') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'cluster' && "bg-primary/10",
getTabStatus('cluster') === 'locked' && "bg-muted"
)}>
<Container className={cn(
"h-4 w-4",
currentTab === 'cluster' && "text-primary",
currentTab !== 'cluster' && "text-muted-foreground"
)} />
</div>
<span className="truncate">Cluster Services</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
<SidebarMenuItem>
<SidebarMenuButton
isActive={currentTab === 'apps'}
onClick={() => {
const status = getTabStatus('apps');
if (status !== 'locked') onTabChange('apps');
}}
disabled={getTabStatus('apps') === 'locked'}
tooltip="Install and manage applications"
className={cn(
"transition-colors",
getTabStatus('apps') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'apps' && "bg-primary/10",
getTabStatus('apps') === 'locked' && "bg-muted"
)}>
<AppWindow className={cn(
"h-4 w-4",
currentTab === 'apps' && "text-primary",
currentTab !== 'apps' && "text-muted-foreground"
)} />
</div>
<span className="truncate">Apps</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={currentTab === 'advanced'}
onClick={() => {
const status = getTabStatus('advanced');
if (status !== 'locked') onTabChange('advanced');
}}
disabled={getTabStatus('advanced') === 'locked'}
tooltip="Advanced settings and system configuration"
className={cn(
"transition-colors",
getTabStatus('advanced') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'advanced' && "bg-primary/10",
getTabStatus('advanced') === 'locked' && "bg-muted"
)}>
<Settings className={cn(
"h-4 w-4",
currentTab === 'advanced' && "text-primary",
currentTab !== 'advanced' && "text-muted-foreground"
)} />
</div>
<span className="truncate">Advanced</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
onClick={cycleTheme}
tooltip={`Current: ${getThemeLabel()}. Click to cycle themes.`}
>
{getThemeIcon()}
<span>{getThemeLabel()}</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
<SidebarRail/>
</Sidebar>
);
}

View File

@@ -0,0 +1,394 @@
import { useState } from 'react';
import { Card } from './ui/card';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import {
AppWindow,
Database,
Globe,
Shield,
BarChart3,
MessageSquare,
Plus,
Search,
Settings,
ExternalLink,
CheckCircle,
AlertCircle,
Clock,
Download,
Trash2,
BookOpen
} from 'lucide-react';
interface AppsComponentProps {
onComplete?: () => void;
}
interface Application {
id: string;
name: string;
description: string;
category: 'database' | 'web' | 'security' | 'monitoring' | 'communication' | 'storage';
status: 'available' | 'installing' | 'running' | 'error' | 'stopped';
version?: string;
namespace?: string;
replicas?: number;
resources?: {
cpu: string;
memory: string;
};
urls?: string[];
}
export function AppsComponent({ onComplete }: AppsComponentProps) {
const [applications, setApplications] = useState<Application[]>([
{
id: 'postgres',
name: 'PostgreSQL',
description: 'Reliable, high-performance SQL database',
category: 'database',
status: 'running',
version: 'v15.4',
namespace: 'default',
replicas: 1,
resources: { cpu: '500m', memory: '1Gi' },
urls: ['postgres://postgres.wildcloud.local:5432'],
},
{
id: 'redis',
name: 'Redis',
description: 'In-memory data structure store',
category: 'database',
status: 'running',
version: 'v7.2',
namespace: 'default',
replicas: 1,
resources: { cpu: '250m', memory: '512Mi' },
},
{
id: 'traefik-dashboard',
name: 'Traefik Dashboard',
description: 'Load balancer and reverse proxy dashboard',
category: 'web',
status: 'running',
version: 'v3.0',
namespace: 'kube-system',
urls: ['https://traefik.wildcloud.local'],
},
{
id: 'grafana',
name: 'Grafana',
description: 'Monitoring and observability dashboards',
category: 'monitoring',
status: 'installing',
version: 'v10.2',
namespace: 'monitoring',
},
{
id: 'prometheus',
name: 'Prometheus',
description: 'Time-series monitoring and alerting',
category: 'monitoring',
status: 'running',
version: 'v2.45',
namespace: 'monitoring',
replicas: 1,
resources: { cpu: '1000m', memory: '2Gi' },
},
{
id: 'vault',
name: 'HashiCorp Vault',
description: 'Secrets management and encryption',
category: 'security',
status: 'available',
version: 'v1.15',
},
{
id: 'minio',
name: 'MinIO',
description: 'High-performance object storage',
category: 'storage',
status: 'available',
version: 'RELEASE.2023-12-07',
},
]);
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const getStatusIcon = (status: Application['status']) => {
switch (status) {
case 'running':
return <CheckCircle className="h-5 w-5 text-green-500" />;
case 'error':
return <AlertCircle className="h-5 w-5 text-red-500" />;
case 'installing':
return <Clock className="h-5 w-5 text-blue-500 animate-spin" />;
case 'stopped':
return <AlertCircle className="h-5 w-5 text-yellow-500" />;
default:
return <Download className="h-5 w-5 text-muted-foreground" />;
}
};
const getStatusBadge = (status: Application['status']) => {
const variants = {
available: 'secondary',
installing: 'default',
running: 'success',
error: 'destructive',
stopped: 'warning',
} as const;
const labels = {
available: 'Available',
installing: 'Installing',
running: 'Running',
error: 'Error',
stopped: 'Stopped',
};
return (
<Badge variant={variants[status] as any}>
{labels[status]}
</Badge>
);
};
const getCategoryIcon = (category: Application['category']) => {
switch (category) {
case 'database':
return <Database className="h-4 w-4" />;
case 'web':
return <Globe className="h-4 w-4" />;
case 'security':
return <Shield className="h-4 w-4" />;
case 'monitoring':
return <BarChart3 className="h-4 w-4" />;
case 'communication':
return <MessageSquare className="h-4 w-4" />;
case 'storage':
return <Database className="h-4 w-4" />;
default:
return <AppWindow className="h-4 w-4" />;
}
};
const handleAppAction = (appId: string, action: 'install' | 'start' | 'stop' | 'delete' | 'configure') => {
console.log(`${action} app: ${appId}`);
};
const categories = ['all', 'database', 'web', 'security', 'monitoring', 'communication', 'storage'];
const filteredApps = applications.filter(app => {
const matchesSearch = app.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
app.description.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory = selectedCategory === 'all' || app.category === selectedCategory;
return matchesSearch && matchesCategory;
});
const runningApps = applications.filter(app => app.status === 'running').length;
return (
<div className="space-y-6">
{/* Educational Intro Section */}
<Card className="p-6 bg-gradient-to-r from-pink-50 to-rose-50 dark:from-pink-950/20 dark:to-rose-950/20 border-pink-200 dark:border-pink-800">
<div className="flex items-start gap-4">
<div className="p-3 bg-pink-100 dark:bg-pink-900/30 rounded-lg">
<BookOpen className="h-6 w-6 text-pink-600 dark:text-pink-400" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-pink-900 dark:text-pink-100 mb-2">
What are Apps in your Personal Cloud?
</h3>
<p className="text-pink-800 dark:text-pink-200 mb-3 leading-relaxed">
Apps are the useful programs that make your personal cloud valuable - like having a personal Netflix
(media server), Google Drive (file storage), or Gmail (email server) running on your own hardware.
Instead of relying on big tech companies, you control your data and services.
</p>
<p className="text-pink-700 dark:text-pink-300 mb-4 text-sm">
Your cluster can run databases, web servers, photo galleries, password managers, backup services, and much more.
Each app runs in its own secure container, so they don't interfere with each other and can be easily managed.
</p>
<Button variant="outline" size="sm" className="text-pink-700 border-pink-300 hover:bg-pink-100 dark:text-pink-300 dark:border-pink-700 dark:hover:bg-pink-900/20">
<ExternalLink className="h-4 w-4 mr-2" />
Learn more about self-hosted applications
</Button>
</div>
</div>
</Card>
<Card className="p-6">
<div className="flex items-center gap-4 mb-6">
<div className="p-2 bg-primary/10 rounded-lg">
<AppWindow className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">App Management</h2>
<p className="text-muted-foreground">
Install and manage applications on your Kubernetes cluster
</p>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search applications..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border rounded-lg bg-background"
/>
</div>
<div className="flex gap-2">
{categories.map(category => (
<Button
key={category}
variant={selectedCategory === category ? 'default' : 'outline'}
size="sm"
onClick={() => setSelectedCategory(category)}
className="capitalize"
>
{category}
</Button>
))}
</div>
</div>
<div className="flex items-center justify-between mb-4">
<div className="text-sm text-muted-foreground">
{runningApps} applications running • {applications.length} total available
</div>
<Button size="sm">
<Plus className="h-4 w-4 mr-2" />
Add App
</Button>
</div>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{filteredApps.map((app) => (
<Card key={app.id} className="p-4">
<div className="flex items-start gap-3">
<div className="p-2 bg-muted rounded-lg">
{getCategoryIcon(app.category)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium truncate">{app.name}</h3>
{app.version && (
<Badge variant="outline" className="text-xs">
{app.version}
</Badge>
)}
{getStatusIcon(app.status)}
</div>
<p className="text-sm text-muted-foreground mb-2">{app.description}</p>
{app.status === 'running' && (
<div className="space-y-1 text-xs text-muted-foreground">
{app.namespace && (
<div>Namespace: {app.namespace}</div>
)}
{app.replicas && (
<div>Replicas: {app.replicas}</div>
)}
{app.resources && (
<div>Resources: {app.resources.cpu} CPU, {app.resources.memory} RAM</div>
)}
{app.urls && app.urls.length > 0 && (
<div className="flex items-center gap-1">
<span>URLs:</span>
{app.urls.map((url, index) => (
<Button
key={index}
variant="link"
size="sm"
className="h-auto p-0 text-xs"
onClick={() => window.open(url, '_blank')}
>
<ExternalLink className="h-3 w-3 mr-1" />
Access
</Button>
))}
</div>
)}
</div>
)}
</div>
<div className="flex flex-col gap-2">
{getStatusBadge(app.status)}
<div className="flex gap-1">
{app.status === 'available' && (
<Button
size="sm"
onClick={() => handleAppAction(app.id, 'install')}
>
Install
</Button>
)}
{app.status === 'running' && (
<>
<Button
size="sm"
variant="outline"
onClick={() => handleAppAction(app.id, 'configure')}
>
<Settings className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleAppAction(app.id, 'stop')}
>
Stop
</Button>
</>
)}
{app.status === 'stopped' && (
<Button
size="sm"
onClick={() => handleAppAction(app.id, 'start')}
>
Start
</Button>
)}
{(app.status === 'running' || app.status === 'stopped') && (
<Button
size="sm"
variant="destructive"
onClick={() => handleAppAction(app.id, 'delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
</div>
</Card>
))}
</div>
{filteredApps.length === 0 && (
<Card className="p-8 text-center">
<AppWindow className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">No applications found</h3>
<p className="text-muted-foreground mb-4">
{searchTerm || selectedCategory !== 'all'
? 'Try adjusting your search or category filter'
: 'Install your first application to get started'
}
</p>
<Button>
<Plus className="h-4 w-4 mr-2" />
Browse App Catalog
</Button>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,114 @@
import { Card } from './ui/card';
import { Button } from './ui/button';
import { Server, Network, Settings, Clock, HelpCircle, CheckCircle, BookOpen, ExternalLink } from 'lucide-react';
import { Input, Label } from './ui';
export function CentralComponent() {
return (
<div className="space-y-6">
{/* Educational Intro Section */}
<Card className="p-6 bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-950/20 dark:to-indigo-950/20 border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-4">
<div className="p-3 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<BookOpen className="h-6 w-6 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-blue-900 dark:text-blue-100 mb-2">
What is the Central Service?
</h3>
<p className="text-blue-800 dark:text-blue-200 mb-3 leading-relaxed">
The Central Service is the "brain" of your personal cloud. It acts as the main coordinator that manages
all the different services running on your network. Think of it like the control tower at an airport -
it keeps track of what's happening, routes traffic between services, and ensures everything works together smoothly.
</p>
<p className="text-blue-700 dark:text-blue-300 mb-4 text-sm">
This service handles configuration management, service discovery, and provides the web interface you're using right now.
</p>
<Button variant="outline" size="sm" className="text-blue-700 border-blue-300 hover:bg-blue-100 dark:text-blue-300 dark:border-blue-700 dark:hover:bg-blue-900/20">
<ExternalLink className="h-4 w-4 mr-2" />
Learn more about service orchestration
</Button>
</div>
</div>
</Card>
<Card className="p-6">
<div className="flex items-center gap-4 mb-6">
<div className="p-2 bg-primary/10 rounded-lg">
<Server className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">Central Service</h2>
<p className="text-muted-foreground">
Monitor and manage the central server service
</p>
</div>
</div>
<div>
<h3 className="text-lg font-medium mb-4">Service Status</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
<div className="flex items-center gap-2">
<Server className="h-5 w-5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">IP Address: 192.168.8.50</span>
</div>
<div className="flex items-center gap-2">
<Network className="h-5 w-5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Network: 192.168.8.0/24</span>
</div>
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Version: 1.0.0 (update available)</span>
</div>
<div className="flex items-center gap-2">
<Clock className="h-5 w-5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Age: 12s</span>
</div>
<div className="flex items-center gap-2">
<HelpCircle className="h-5 w-5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Platform: ARM</span>
</div>
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-500" />
<span className="text-sm text-green-500">File permissions: Good</span>
</div>
</div>
<div className="space-y-4">
<div>
<Label htmlFor="ip">IP</Label>
<div className="flex w-full items-center mt-1">
<Input id="ip" value="192.168.5.80"/>
<Button variant="ghost">
<HelpCircle/>
</Button>
</div>
</div>
<div>
<Label htmlFor="interface">Interface</Label>
<div className="flex w-full items-center mt-1">
<Input id="interface" value="eth0"/>
<Button variant="ghost">
<HelpCircle/>
</Button>
</div>
</div>
</div>
<div className="flex gap-2 justify-end mt-4">
<Button onClick={() => console.log('Update service')}>
Update
</Button>
<Button onClick={() => console.log('Restart service')}>
Restart
</Button>
<Button onClick={() => console.log('View log')}>
View log
</Button>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,137 @@
import { useState } from "react";
import { Card } from "./ui/card";
import { Button } from "./ui/button";
import { Cloud, HelpCircle, Edit2, Check, X } from "lucide-react";
import { Input, Label } from "./ui";
export function CloudComponent() {
const [domainValue, setDomainValue] = useState("cloud.payne.io");
const [internalDomainValue, setInternalDomainValue] = useState(
"internal.cloud.payne.io"
);
const [editingDomains, setEditingDomains] = useState(false);
const [tempDomain, setTempDomain] = useState(domainValue);
const [tempInternalDomain, setTempInternalDomain] =
useState(internalDomainValue);
const handleDomainsEdit = () => {
setTempDomain(domainValue);
setTempInternalDomain(internalDomainValue);
setEditingDomains(true);
};
const handleDomainsSave = () => {
setDomainValue(tempDomain);
setInternalDomainValue(tempInternalDomain);
setEditingDomains(false);
};
const handleDomainsCancel = () => {
setTempDomain(domainValue);
setTempInternalDomain(internalDomainValue);
setEditingDomains(false);
};
return (
<div className="space-y-6">
<Card className="p-6">
<div className="flex items-center gap-4 mb-6">
<div className="p-2 bg-primary/10 rounded-lg">
<Cloud className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">Cloud Configuration</h2>
<p className="text-muted-foreground">
Configure top-level cloud settings and domains
</p>
</div>
</div>
<div className="space-y-6">
{/* Domains Section */}
<Card className="p-4 border-l-4 border-l-green-500">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="font-medium">Domain Configuration</h3>
<p className="text-sm text-muted-foreground">
Public and internal domain settings
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm">
<HelpCircle className="h-4 w-4" />
</Button>
{!editingDomains && (
<Button
variant="outline"
size="sm"
onClick={handleDomainsEdit}
>
<Edit2 className="h-4 w-4 mr-1" />
Edit
</Button>
)}
</div>
</div>
{editingDomains ? (
<div className="space-y-3">
<div>
<Label htmlFor="domain-edit">Public Domain</Label>
<Input
id="domain-edit"
value={tempDomain}
onChange={(e) => setTempDomain(e.target.value)}
placeholder="example.com"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="internal-domain-edit">Internal Domain</Label>
<Input
id="internal-domain-edit"
value={tempInternalDomain}
onChange={(e) => setTempInternalDomain(e.target.value)}
placeholder="internal.example.com"
className="mt-1"
/>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={handleDomainsSave}>
<Check className="h-4 w-4 mr-1" />
Save
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDomainsCancel}
>
<X className="h-4 w-4 mr-1" />
Cancel
</Button>
</div>
</div>
) : (
<div className="space-y-3">
<div>
<Label>Public Domain</Label>
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
{domainValue}
</div>
</div>
<div>
<Label>Internal Domain</Label>
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
{internalDomainValue}
</div>
</div>
</div>
)}
</Card>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,378 @@
import { useState } from 'react';
import { Card } from './ui/card';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import { Cpu, HardDrive, Network, Monitor, Plus, CheckCircle, AlertCircle, Clock, BookOpen, ExternalLink } from 'lucide-react';
interface ClusterNodesComponentProps {
onComplete?: () => void;
}
interface Node {
id: string;
name: string;
type: 'controller' | 'worker' | 'unassigned';
status: 'pending' | 'connecting' | 'connected' | 'healthy' | 'error';
ipAddress?: string;
macAddress: string;
osVersion?: string;
specs: {
cpu: string;
memory: string;
storage: string;
};
}
export function ClusterNodesComponent({ onComplete }: ClusterNodesComponentProps) {
const [currentOsVersion, setCurrentOsVersion] = useState('v13.0.5');
const [nodes, setNodes] = useState<Node[]>([
{
id: 'controller-1',
name: 'Controller Node 1',
type: 'controller',
status: 'healthy',
macAddress: '00:1A:2B:3C:4D:5E',
osVersion: 'v13.0.4',
specs: {
cpu: '4 cores',
memory: '8GB RAM',
storage: '120GB SSD',
},
},
{
id: 'worker-1',
name: 'Worker Node 1',
type: 'worker',
status: 'healthy',
macAddress: '00:1A:2B:3C:4D:5F',
osVersion: 'v13.0.5',
specs: {
cpu: '8 cores',
memory: '16GB RAM',
storage: '500GB SSD',
},
},
{
id: 'worker-2',
name: 'Worker Node 2',
type: 'worker',
status: 'healthy',
macAddress: '00:1A:2B:3C:4D:60',
osVersion: 'v13.0.4',
specs: {
cpu: '8 cores',
memory: '16GB RAM',
storage: '500GB SSD',
},
},
{
id: 'node-1',
name: 'Node 1',
type: 'unassigned',
status: 'pending',
macAddress: '00:1A:2B:3C:4D:5E',
osVersion: 'v13.0.5',
specs: {
cpu: '4 cores',
memory: '8GB RAM',
storage: '120GB SSD',
},
},
{
id: 'node-2',
name: 'Node 2',
type: 'unassigned',
status: 'pending',
macAddress: '00:1A:2B:3C:4D:5F',
osVersion: 'v13.0.5',
specs: {
cpu: '8 cores',
memory: '16GB RAM',
storage: '500GB SSD',
},
},
]);
const getStatusIcon = (status: Node['status']) => {
switch (status) {
case 'connected':
return <CheckCircle className="h-5 w-5 text-green-500" />;
case 'error':
return <AlertCircle className="h-5 w-5 text-red-500" />;
case 'connecting':
return <Clock className="h-5 w-5 text-blue-500 animate-spin" />;
default:
return <Monitor className="h-5 w-5 text-muted-foreground" />;
}
};
const getStatusBadge = (status: Node['status']) => {
const variants = {
pending: 'secondary',
connecting: 'default',
connected: 'success',
healthy: 'success',
error: 'destructive',
} as const;
const labels = {
pending: 'Pending',
connecting: 'Connecting',
connected: 'Connected',
healthy: 'Healthy',
error: 'Error',
};
return (
<Badge variant={variants[status] as any}>
{labels[status]}
</Badge>
);
};
const getTypeIcon = (type: Node['type']) => {
return type === 'controller' ? (
<Cpu className="h-4 w-4" />
) : (
<HardDrive className="h-4 w-4" />
);
};
const handleNodeAction = (nodeId: string, action: 'connect' | 'retry' | 'upgrade_node') => {
console.log(`${action} node: ${nodeId}`);
};
const connectedNodes = nodes.filter(node => node.status === 'connected').length;
const assignedNodes = nodes.filter(node => node.type !== 'unassigned');
const unassignedNodes = nodes.filter(node => node.type === 'unassigned');
const totalNodes = nodes.length;
const isComplete = connectedNodes === totalNodes;
return (
<div className="space-y-6">
{/* Educational Intro Section */}
<Card className="p-6 bg-gradient-to-r from-cyan-50 to-blue-50 dark:from-cyan-950/20 dark:to-blue-950/20 border-cyan-200 dark:border-cyan-800">
<div className="flex items-start gap-4">
<div className="p-3 bg-cyan-100 dark:bg-cyan-900/30 rounded-lg">
<BookOpen className="h-6 w-6 text-cyan-600 dark:text-cyan-400" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-cyan-900 dark:text-cyan-100 mb-2">
What are Cluster Nodes?
</h3>
<p className="text-cyan-800 dark:text-cyan-200 mb-3 leading-relaxed">
Think of cluster nodes as the "workers" in your personal cloud factory. Each node is a separate computer
that contributes its processing power, memory, and storage to the overall cluster. Some nodes are "controllers"
(like managers) that coordinate the work, while others are "workers" that do the heavy lifting.
</p>
<p className="text-cyan-700 dark:text-cyan-300 mb-4 text-sm">
By connecting multiple computers together as nodes, you create a powerful, resilient system where if one
computer fails, the others can pick up the work. This is how you scale your personal cloud from one machine to many.
</p>
<Button variant="outline" size="sm" className="text-cyan-700 border-cyan-300 hover:bg-cyan-100 dark:text-cyan-300 dark:border-cyan-700 dark:hover:bg-cyan-900/20">
<ExternalLink className="h-4 w-4 mr-2" />
Learn more about distributed computing
</Button>
</div>
</div>
</Card>
<Card className="p-6">
<div className="flex items-center gap-4 mb-6">
<div className="p-2 bg-primary/10 rounded-lg">
<Network className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">Cluster Nodes</h2>
<p className="text-muted-foreground">
Connect machines to your wild-cloud
</p>
</div>
</div>
<div className="space-y-4">
<h2 className="text-lg font-medium mb-4">Assigned Nodes ({assignedNodes.length}/{totalNodes})</h2>
{assignedNodes.map((node) => (
<Card key={node.id} className="p-4">
<div className="flex items-center gap-4">
<div className="p-2 bg-muted rounded-lg">
{getTypeIcon(node.type)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium">{node.name}</h4>
<Badge variant="outline" className="text-xs">
{node.type}
</Badge>
{getStatusIcon(node.status)}
</div>
<div className="text-sm text-muted-foreground mb-2">
MAC: {node.macAddress}
{node.ipAddress && ` • IP: ${node.ipAddress}`}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Cpu className="h-3 w-3" />
{node.specs.cpu}
</span>
<span className="flex items-center gap-1">
<Monitor className="h-3 w-3" />
{node.specs.memory}
</span>
<span className="flex items-center gap-1">
<HardDrive className="h-3 w-3" />
{node.specs.storage}
</span>
{node.osVersion && (
<span className="flex items-center gap-1">
<Badge variant="outline" className="text-xs">
OS: {node.osVersion}
</Badge>
</span>
)}
</div>
</div>
<div className="flex items-center gap-3">
{getStatusBadge(node.status)}
{node.osVersion !== currentOsVersion && (
<Button
size="sm"
onClick={() => handleNodeAction(node.id, 'upgrade_node')}
>
Upgrade OS
</Button>
)}
{node.status === 'error' && (
<Button
size="sm"
variant="outline"
onClick={() => handleNodeAction(node.id, 'retry')}
>
Retry
</Button>
)}
</div>
</div>
</Card>
))}
</div>
<h2 className="text-lg font-medium mb-4 mt-6">Unassigned Nodes ({unassignedNodes.length}/{totalNodes})</h2>
<div className="space-y-4">
{unassignedNodes.map((node) => (
<Card key={node.id} className="p-4">
<div className="flex items-center gap-4">
<div className="p-2 bg-muted rounded-lg">
{getTypeIcon(node.type)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium">{node.name}</h4>
<Badge variant="outline" className="text-xs">
{node.type}
</Badge>
{getStatusIcon(node.status)}
</div>
<div className="text-sm text-muted-foreground mb-2">
MAC: {node.macAddress}
{node.ipAddress && ` • IP: ${node.ipAddress}`}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Cpu className="h-3 w-3" />
{node.specs.cpu}
</span>
<span className="flex items-center gap-1">
<Monitor className="h-3 w-3" />
{node.specs.memory}
</span>
<span className="flex items-center gap-1">
<HardDrive className="h-3 w-3" />
{node.specs.storage}
</span>
</div>
</div>
<div className="flex items-center gap-3">
{getStatusBadge(node.status)}
{node.status === 'pending' && (
<Button
size="sm"
onClick={() => handleNodeAction(node.id, 'connect')}
>
Assign
</Button>
)}
{node.status === 'error' && (
<Button
size="sm"
variant="outline"
onClick={() => handleNodeAction(node.id, 'retry')}
>
Retry
</Button>
)}
</div>
</div>
</Card>
))}
</div>
{isComplete && (
<div className="mt-6 p-4 bg-green-50 dark:bg-green-950 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="h-5 w-5 text-green-600" />
<h3 className="font-medium text-green-800 dark:text-green-200">
Infrastructure Ready!
</h3>
</div>
<p className="text-sm text-green-700 dark:text-green-300 mb-3">
All nodes are connected and ready for Kubernetes installation.
</p>
<Button onClick={onComplete} className="bg-green-600 hover:bg-green-700">
Continue to Kubernetes Installation
</Button>
</div>
)}
</Card>
<Card className="p-6">
<h3 className="text-lg font-medium mb-4">PXE Boot Instructions</h3>
<div className="space-y-3 text-sm">
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium">
1
</div>
<div>
<p className="font-medium">Power on your nodes</p>
<p className="text-muted-foreground">
Ensure network boot (PXE) is enabled in BIOS/UEFI settings
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium">
2
</div>
<div>
<p className="font-medium">Connect to the wild-cloud network</p>
<p className="text-muted-foreground">
Nodes will automatically receive IP addresses via DHCP
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium">
3
</div>
<div>
<p className="font-medium">Boot Talos Linux</p>
<p className="text-muted-foreground">
Nodes will automatically download and boot Talos Linux via PXE
</p>
</div>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,299 @@
import { useState } from 'react';
import { Card } from './ui/card';
import { Button } from './ui/button';
import { Badge } from './ui/badge';
import { Container, Shield, Network, Database, CheckCircle, AlertCircle, Clock, Terminal, FileText, BookOpen, ExternalLink } from 'lucide-react';
interface ClusterServicesComponentProps {
onComplete?: () => void;
}
interface ClusterComponent {
id: string;
name: string;
description: string;
status: 'pending' | 'installing' | 'ready' | 'error';
version?: string;
logs?: string[];
}
export function ClusterServicesComponent({ onComplete }: ClusterServicesComponentProps) {
const [components, setComponents] = useState<ClusterComponent[]>([
{
id: 'talos-config',
name: 'Talos Configuration',
description: 'Generate and apply Talos cluster configuration',
status: 'pending',
},
{
id: 'kubernetes-bootstrap',
name: 'Kubernetes Bootstrap',
description: 'Initialize Kubernetes control plane',
status: 'pending',
version: 'v1.29.0',
},
{
id: 'cni-plugin',
name: 'Container Network Interface',
description: 'Install and configure Cilium CNI',
status: 'pending',
version: 'v1.14.5',
},
{
id: 'storage-class',
name: 'Storage Classes',
description: 'Configure persistent volume storage',
status: 'pending',
},
{
id: 'ingress-controller',
name: 'Ingress Controller',
description: 'Install Traefik ingress controller',
status: 'pending',
version: 'v3.0.0',
},
{
id: 'monitoring',
name: 'Cluster Monitoring',
description: 'Deploy Prometheus and Grafana stack',
status: 'pending',
},
]);
const [showLogs, setShowLogs] = useState<string | null>(null);
const getStatusIcon = (status: ClusterComponent['status']) => {
switch (status) {
case 'ready':
return <CheckCircle className="h-5 w-5 text-green-500" />;
case 'error':
return <AlertCircle className="h-5 w-5 text-red-500" />;
case 'installing':
return <Clock className="h-5 w-5 text-blue-500 animate-spin" />;
default:
return null;
}
};
const getStatusBadge = (status: ClusterComponent['status']) => {
const variants = {
pending: 'secondary',
installing: 'default',
ready: 'success',
error: 'destructive',
} as const;
const labels = {
pending: 'Pending',
installing: 'Installing',
ready: 'Ready',
error: 'Error',
};
return (
<Badge variant={variants[status] as any}>
{labels[status]}
</Badge>
);
};
const getComponentIcon = (id: string) => {
switch (id) {
case 'talos-config':
return <FileText className="h-5 w-5" />;
case 'kubernetes-bootstrap':
return <Container className="h-5 w-5" />;
case 'cni-plugin':
return <Network className="h-5 w-5" />;
case 'storage-class':
return <Database className="h-5 w-5" />;
case 'ingress-controller':
return <Shield className="h-5 w-5" />;
case 'monitoring':
return <Terminal className="h-5 w-5" />;
default:
return <Container className="h-5 w-5" />;
}
};
const handleComponentAction = (componentId: string, action: 'install' | 'retry') => {
console.log(`${action} component: ${componentId}`);
};
const readyComponents = components.filter(component => component.status === 'ready').length;
const totalComponents = components.length;
const isComplete = readyComponents === totalComponents;
return (
<div className="space-y-6">
{/* Educational Intro Section */}
<Card className="p-6 bg-gradient-to-r from-indigo-50 to-purple-50 dark:from-indigo-950/20 dark:to-purple-950/20 border-indigo-200 dark:border-indigo-800">
<div className="flex items-start gap-4">
<div className="p-3 bg-indigo-100 dark:bg-indigo-900/30 rounded-lg">
<BookOpen className="h-6 w-6 text-indigo-600 dark:text-indigo-400" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-indigo-900 dark:text-indigo-100 mb-2">
What are Cluster Services?
</h3>
<p className="text-indigo-800 dark:text-indigo-200 mb-3 leading-relaxed">
Cluster services are like the "essential utilities" that make your personal cloud actually work. Just like a city
needs electricity, water, and roads, your cluster needs networking, storage, monitoring, and security services.
These services run automatically in the background to keep everything functioning smoothly.
</p>
<p className="text-indigo-700 dark:text-indigo-300 mb-4 text-sm">
Services like Kubernetes orchestration, container networking, ingress routing, and monitoring work together to
create a robust platform where you can easily deploy and manage your applications.
</p>
<Button variant="outline" size="sm" className="text-indigo-700 border-indigo-300 hover:bg-indigo-100 dark:text-indigo-300 dark:border-indigo-700 dark:hover:bg-indigo-900/20">
<ExternalLink className="h-4 w-4 mr-2" />
Learn more about Kubernetes services
</Button>
</div>
</div>
</Card>
<Card className="p-6">
<div className="flex items-center gap-4 mb-6">
<div className="p-2 bg-primary/10 rounded-lg">
<Container className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">Cluster Services</h2>
<p className="text-muted-foreground">
Install and configure essential cluster services
</p>
</div>
</div>
<div className="flex items-center justify-between mb-4">
<pre className="text-xs text-muted-foreground bg-muted p-2 rounded-lg">
endpoint: civil<br/>
endpointIp: 192.168.8.240<br/>
kubernetes:<br/>
config: /home/payne/.kube/config<br/>
context: default<br/>
loadBalancerRange: 192.168.8.240-192.168.8.250<br/>
dashboard:<br/>
adminUsername: admin<br/>
certManager:<br/>
namespace: cert-manager<br/>
cloudflare:<br/>
domain: payne.io<br/>
ownerId: cloud-payne-io-cluster<br/>
</pre>
</div>
<div className="space-y-4">
{components.map((component) => (
<div key={component.id}>
<div className="flex items-center gap-4 p-4 rounded-lg border bg-card">
<div className="p-2 bg-muted rounded-lg">
{getComponentIcon(component.id)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium">{component.name}</h3>
{component.version && (
<Badge variant="outline" className="text-xs">
{component.version}
</Badge>
)}
{getStatusIcon(component.status)}
</div>
<p className="text-sm text-muted-foreground">{component.description}</p>
</div>
<div className="flex items-center gap-3">
{getStatusBadge(component.status)}
{(component.status === 'installing' || component.status === 'error') && (
<Button
size="sm"
variant="outline"
onClick={() => setShowLogs(showLogs === component.id ? null : component.id)}
>
<Terminal className="h-4 w-4 mr-1" />
Logs
</Button>
)}
{component.status === 'pending' && (
<Button
size="sm"
onClick={() => handleComponentAction(component.id, 'install')}
>
Install
</Button>
)}
{component.status === 'error' && (
<Button
size="sm"
variant="outline"
onClick={() => handleComponentAction(component.id, 'retry')}
>
Retry
</Button>
)}
</div>
</div>
{showLogs === component.id && (
<Card className="mt-2 p-4 bg-black text-green-400 font-mono text-sm">
<div className="max-h-40 overflow-y-auto">
<div>Installing {component.name}...</div>
<div> Checking prerequisites</div>
<div> Downloading manifests</div>
{component.status === 'installing' && (
<div className="animate-pulse"> Applying configuration...</div>
)}
{component.status === 'error' && (
<div className="text-red-400"> Installation failed: timeout waiting for pods</div>
)}
</div>
</Card>
)}
</div>
))}
</div>
{isComplete && (
<div className="mt-6 p-4 bg-green-50 dark:bg-green-950 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="h-5 w-5 text-green-600" />
<h3 className="font-medium text-green-800 dark:text-green-200">
Kubernetes Cluster Ready!
</h3>
</div>
<p className="text-sm text-green-700 dark:text-green-300 mb-3">
Your Kubernetes cluster is fully configured and ready for application deployment.
</p>
<Button onClick={onComplete} className="bg-green-600 hover:bg-green-700">
Continue to App Management
</Button>
</div>
)}
</Card>
<Card className="p-6">
<h3 className="text-lg font-medium mb-4">Cluster Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<div className="font-medium mb-2">Control Plane</div>
<div className="space-y-1 text-muted-foreground">
<div> API Server: https://cluster.wildcloud.local:6443</div>
<div> Nodes: 1 controller, 2 workers</div>
<div> Version: Kubernetes v1.29.0</div>
</div>
</div>
<div>
<div className="font-medium mb-2">Network Configuration</div>
<div className="space-y-1 text-muted-foreground">
<div> Pod CIDR: 10.244.0.0/16</div>
<div> Service CIDR: 10.96.0.0/12</div>
<div> CNI: Cilium v1.14.5</div>
</div>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,125 @@
import { useState, useEffect } from 'react';
import { Settings, Save, X } from 'lucide-react';
import { useConfigYaml } from '../hooks';
import { Button, Textarea } from './ui';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger} from '@/components/ui/dialog';
export function ConfigEditor() {
const { yamlContent, isLoading, error, isEndpointMissing, updateYaml, refetch } = useConfigYaml();
const [editedContent, setEditedContent] = useState('');
const [hasChanges, setHasChanges] = useState(false);
// Update edited content when YAML content changes
useEffect(() => {
if (yamlContent) {
setEditedContent(yamlContent);
setHasChanges(false);
}
}, [yamlContent]);
// Track changes
useEffect(() => {
setHasChanges(editedContent !== yamlContent);
}, [editedContent, yamlContent]);
const handleSave = () => {
if (!hasChanges) return;
updateYaml(editedContent, {
onSuccess: () => {
setHasChanges(false);
},
onError: (err) => {
console.error('Failed to update config:', err);
}
});
};
const handleOpenChange = (open: boolean) => {
if (!open && hasChanges) {
if (!window.confirm('You have unsaved changes. Close anyway?')) {
return;
}
}
if (open) {
refetch();
}
};
return (
<Dialog onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button>
<Settings className="h-4 w-4" />
Config
</Button>
</DialogTrigger>
<DialogContent className="max-w-6xl w-full max-h-[80vh] h-full flex flex-col">
<DialogHeader>
<DialogTitle>
Configuration Editor
</DialogTitle>
<DialogDescription>
Edit the raw YAML configuration file. This provides direct access to all configuration options.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col flex-1">
{error && error instanceof Error && error.message && (
<div className="p-3 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-md">
<p className="text-sm text-red-800 dark:text-red-200">
Error: {error.message}
</p>
</div>
)}
{isEndpointMissing && (
<div className="p-3 bg-orange-50 dark:bg-orange-950 border border-orange-200 dark:border-orange-800 rounded-md">
<p className="text-sm text-orange-800 dark:text-orange-200">
Backend endpoints missing. Raw YAML editing not available.
</p>
</div>
)}
<Textarea
value={editedContent}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setEditedContent(e.target.value)}
placeholder={isLoading ? "Loading YAML configuration..." : "No configuration found"}
disabled={isLoading || !!isEndpointMissing}
className="font-mono text-sm w-full flex-1 min-h-0 resize-none"
/>
{hasChanges && (
<div className="text-sm text-orange-600 dark:text-orange-400">
You have unsaved changes
</div>
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">
Cancel
</Button>
</DialogClose>
<Button
onClick={handleSave}
disabled={!hasChanges || isLoading || !!isEndpointMissing}
>
Update Config
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,297 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { FileText, Check, AlertCircle, Loader2 } from 'lucide-react';
import { useConfig, useMessages } from '../hooks';
import { configFormSchema, defaultConfigValues, type ConfigFormData } from '../schemas/config';
import {
Card,
CardHeader,
CardTitle,
CardContent,
Button,
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
Input,
} from './ui';
export const ConfigurationForm = () => {
const {
config,
isConfigured,
showConfigSetup,
isLoading,
isCreating,
error,
createConfig,
refetch
} = useConfig();
const form = useForm<ConfigFormData>({
resolver: zodResolver(configFormSchema),
defaultValues: defaultConfigValues,
});
const onSubmit = (data: ConfigFormData) => {
createConfig(data);
};
return (
<Card>
<CardHeader>
<CardTitle>Configuration (With Form Validation)</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<Button onClick={() => refetch()} disabled={isLoading} variant="outline">
<FileText className="mr-2 h-4 w-4" />
{isLoading ? 'Loading...' : 'Reload Configuration'}
</Button>
{error && (
<div className="p-3 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-md flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-red-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-red-800 dark:text-red-200">Configuration Error</p>
<p className="text-sm text-red-700 dark:text-red-300">{error.message}</p>
</div>
</div>
)}
{showConfigSetup && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium">Initial Configuration Setup</h3>
<p className="text-sm text-muted-foreground">Configure your wild-cloud central server settings with real-time validation.</p>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Server Configuration */}
<div className="space-y-4">
<h4 className="text-md font-medium text-foreground">Server Configuration</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="server.host"
render={({ field }) => (
<FormItem>
<FormLabel>Server Host</FormLabel>
<FormControl>
<Input placeholder="0.0.0.0" {...field} />
</FormControl>
<FormDescription>
The host address the server will bind to
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="server.port"
render={({ field }) => (
<FormItem>
<FormLabel>Server Port</FormLabel>
<FormControl>
<Input
type="number"
placeholder="5055"
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
/>
</FormControl>
<FormDescription>
The port the server will listen on
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
{/* Cloud Configuration */}
<div className="space-y-4">
<h4 className="text-md font-medium text-foreground">Cloud Configuration</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="cloud.domain"
render={({ field }) => (
<FormItem>
<FormLabel>Domain</FormLabel>
<FormControl>
<Input placeholder="wildcloud.local" {...field} />
</FormControl>
<FormDescription>
The main domain for your wild-cloud
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cloud.internalDomain"
render={({ field }) => (
<FormItem>
<FormLabel>Internal Domain</FormLabel>
<FormControl>
<Input placeholder="cluster.local" {...field} />
</FormControl>
<FormDescription>
The internal cluster domain
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cloud.dns.ip"
render={({ field }) => (
<FormItem>
<FormLabel>DNS Server IP</FormLabel>
<FormControl>
<Input placeholder="192.168.8.50" {...field} />
</FormControl>
<FormDescription>
The IP address of the DNS server
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cloud.router.ip"
render={({ field }) => (
<FormItem>
<FormLabel>Router IP</FormLabel>
<FormControl>
<Input placeholder="192.168.8.1" {...field} />
</FormControl>
<FormDescription>
The IP address of the network router
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cloud.dhcpRange"
render={({ field }) => (
<FormItem>
<FormLabel>DHCP Range</FormLabel>
<FormControl>
<Input placeholder="192.168.8.100,192.168.8.200" {...field} />
</FormControl>
<FormDescription>
DHCP IP range in format: start_ip,end_ip
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cloud.dnsmasq.interface"
render={({ field }) => (
<FormItem>
<FormLabel>Network Interface</FormLabel>
<FormControl>
<Input placeholder="eth0" {...field} />
</FormControl>
<FormDescription>
The network interface for dnsmasq to use
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
{/* Cluster Configuration */}
<div className="space-y-4">
<h4 className="text-md font-medium text-foreground">Cluster Configuration</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="cluster.endpointIp"
render={({ field }) => (
<FormItem>
<FormLabel>Cluster Endpoint IP</FormLabel>
<FormControl>
<Input placeholder="192.168.8.60" {...field} />
</FormControl>
<FormDescription>
The IP address of the cluster endpoint
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cluster.nodes.talos.version"
render={({ field }) => (
<FormItem>
<FormLabel>Talos Version</FormLabel>
<FormControl>
<Input placeholder="v1.8.0" {...field} />
</FormControl>
<FormDescription>
The version of Talos Linux to use
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<Button type="submit" disabled={isCreating} className="w-full">
{isCreating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating Configuration...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Create Configuration
</>
)}
</Button>
</form>
</Form>
</div>
)}
{config && isConfigured && (
<div className="space-y-2">
<div className="p-3 bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-md">
<p className="text-sm text-green-800 dark:text-green-200">
Configuration loaded successfully
</p>
</div>
<pre className="p-4 bg-muted rounded-md text-sm overflow-auto max-h-96">
{JSON.stringify(config, null, 2)}
</pre>
</div>
)}
<div className="text-xs text-muted-foreground">
Form Validation Status: {form.formState.isValid ? '✓ Valid' : '⚠ Has Errors'} |
Errors: {Object.keys(form.formState.errors).length}
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,156 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { FileText, Check, AlertCircle, Loader2 } from 'lucide-react';
import { useConfig, useMessages } from '../hooks';
import { configFormSchema, defaultConfigValues, type ConfigFormData } from '../schemas/config';
import { Message } from './Message';
import { Card, CardHeader, CardTitle, CardContent, Button, Form, FormField, FormItem, FormLabel, FormControl, FormMessage, Input } from './ui';
export const ConfigurationSection = () => {
const {
config,
isConfigured,
showConfigSetup,
isLoading,
isCreating,
error,
createConfig,
refetch
} = useConfig();
const { messages } = useMessages();
const form = useForm<ConfigFormData>({
resolver: zodResolver(configFormSchema),
defaultValues: defaultConfigValues,
});
const onSubmit = (data: ConfigFormData) => {
createConfig(data);
};
return (
<Card>
<CardHeader>
<CardTitle>Configuration</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Button onClick={() => refetch()} disabled={isLoading} variant="outline">
<FileText className="mr-2 h-4 w-4" />
{isLoading ? 'Loading...' : 'Reload Configuration'}
</Button>
{error && (
<div className="p-3 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-md flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-red-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-red-800 dark:text-red-200">Configuration Error</p>
<p className="text-sm text-red-700 dark:text-red-300">{error.message}</p>
</div>
</div>
)}
<Message message={messages.config} />
{showConfigSetup && (
<div className="space-y-4">
<div>
<h3 className="text-lg font-medium">Initial Configuration Setup</h3>
<p className="text-sm text-muted-foreground">Configure key settings for your wild-cloud central server:</p>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="cloud.domain"
render={({ field }) => (
<FormItem>
<FormLabel>Cloud Domain</FormLabel>
<FormControl>
<Input placeholder="wildcloud.local" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cloud.dns.ip"
render={({ field }) => (
<FormItem>
<FormLabel>DNS Server IP</FormLabel>
<FormControl>
<Input placeholder="192.168.8.50" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cloud.router.ip"
render={({ field }) => (
<FormItem>
<FormLabel>Router IP</FormLabel>
<FormControl>
<Input placeholder="192.168.8.1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cloud.dnsmasq.interface"
render={({ field }) => (
<FormItem>
<FormLabel>Network Interface</FormLabel>
<FormControl>
<Input placeholder="eth0" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button type="submit" disabled={isCreating}>
{isCreating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Create Configuration
</>
)}
</Button>
</form>
</Form>
</div>
)}
{config && isConfigured && (
<div className="space-y-2">
<div className="p-3 bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-md">
<p className="text-sm text-green-800 dark:text-green-200">
Configuration loaded successfully
</p>
</div>
<pre className="p-4 bg-muted rounded-md text-sm overflow-auto max-h-96">
{JSON.stringify(config, null, 2)}
</pre>
</div>
)}
{/* Debug info */}
<div className="text-xs text-muted-foreground">
React Query Status: isLoading={isLoading.toString()}, isConfigured={isConfigured.toString()}, showSetup={showConfigSetup.toString()}
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,77 @@
import { Card } from './ui/card';
import { Button } from './ui/button';
import { Wifi, HelpCircle, BookOpen, ExternalLink } from 'lucide-react';
import { Input, Label } from './ui';
export function DhcpComponent() {
return (
<div className="space-y-6">
{/* Educational Intro Section */}
<Card className="p-6 bg-gradient-to-r from-purple-50 to-violet-50 dark:from-purple-950/20 dark:to-violet-950/20 border-purple-200 dark:border-purple-800">
<div className="flex items-start gap-4">
<div className="p-3 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<BookOpen className="h-6 w-6 text-purple-600 dark:text-purple-400" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-purple-900 dark:text-purple-100 mb-2">
What is DHCP?
</h3>
<p className="text-purple-800 dark:text-purple-200 mb-3 leading-relaxed">
DHCP (Dynamic Host Configuration Protocol) is like an automatic "address assignment system" for your network.
When a device joins your network, DHCP automatically gives it an IP address, tells it how to connect to the internet,
and provides other network settings - no manual configuration needed!
</p>
<p className="text-purple-700 dark:text-purple-300 mb-4 text-sm">
Without DHCP, you'd need to manually assign IP addresses to every device. DHCP makes it so you can just connect
a phone, laptop, or smart device and it automatically gets everything it needs to work on your network.
</p>
<Button variant="outline" size="sm" className="text-purple-700 border-purple-300 hover:bg-purple-100 dark:text-purple-300 dark:border-purple-700 dark:hover:bg-purple-900/20">
<ExternalLink className="h-4 w-4 mr-2" />
Learn more about DHCP
</Button>
</div>
</div>
</Card>
<Card className="p-6">
<div className="flex items-center gap-4 mb-6">
<div className="p-2 bg-primary/10 rounded-lg">
<Wifi className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">DHCP Configuration</h2>
<p className="text-muted-foreground">
Manage DHCP settings and IP address allocation
</p>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2 mb-4">
<span className="text-sm font-medium">Status:</span>
<span className="text-sm text-green-600">Active</span>
</div>
<div>
<Label htmlFor="dhcpRange">IP Range</Label>
<div className="flex w-full items-center mt-1">
<Input id="dhcpRange" value="192.168.8.100,192.168.8.239"/>
<Button variant="ghost">
<HelpCircle/>
</Button>
</div>
</div>
<div className="flex gap-2 justify-end mt-4">
<Button variant="outline" onClick={() => console.log('View DHCP clients')}>
View Clients
</Button>
<Button onClick={() => console.log('Configure DHCP')}>
Configure
</Button>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import { Card } from './ui/card';
import { Button } from './ui/button';
import { Globe, CheckCircle, BookOpen, ExternalLink } from 'lucide-react';
export function DnsComponent() {
return (
<div className="space-y-6">
{/* Educational Intro Section */}
<Card className="p-6 bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-950/20 dark:to-emerald-950/20 border-green-200 dark:border-green-800">
<div className="flex items-start gap-4">
<div className="p-3 bg-green-100 dark:bg-green-900/30 rounded-lg">
<BookOpen className="h-6 w-6 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-green-900 dark:text-green-100 mb-2">
What is DNS?
</h3>
<p className="text-green-800 dark:text-green-200 mb-3 leading-relaxed">
DNS (Domain Name System) is like the "phone book" of the internet. Instead of remembering complex IP addresses
like "192.168.1.100", you can use friendly names like "my-server.local". When you type a name, DNS translates
it to the correct IP address so your devices can find each other.
</p>
<p className="text-green-700 dark:text-green-300 mb-4 text-sm">
Your personal cloud runs its own DNS service so devices can easily find services like "photos.home" or "media.local"
without needing to remember numbers.
</p>
<Button variant="outline" size="sm" className="text-green-700 border-green-300 hover:bg-green-100 dark:text-green-300 dark:border-green-700 dark:hover:bg-green-900/20">
<ExternalLink className="h-4 w-4 mr-2" />
Learn more about DNS
</Button>
</div>
</div>
</Card>
<Card className="p-6">
<div className="flex items-center gap-4 mb-6">
<div className="p-2 bg-primary/10 rounded-lg">
<Globe className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">DNS Configuration</h2>
<p className="text-muted-foreground">
Manage DNS settings and domain resolution
</p>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-500" />
<span className="text-sm">Local resolution: Active</span>
</div>
<div className="mt-4">
<h4 className="font-medium mb-2">DNS Status</h4>
<p className="text-sm text-muted-foreground">
DNS service is running and resolving domains correctly.
</p>
</div>
<div className="flex gap-2 justify-end mt-4">
<Button variant="outline" onClick={() => console.log('Test DNS')}>
Test DNS
</Button>
<Button onClick={() => console.log('Configure DNS')}>
Configure
</Button>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,86 @@
import { Settings, RotateCcw, AlertCircle } from 'lucide-react';
import { useDnsmasq, useMessages } from '../hooks';
import { Message } from './Message';
import { Card, CardHeader, CardTitle, CardContent, Button } from './ui';
export const DnsmasqSection = () => {
const {
dnsmasqConfig,
generateConfig,
isGenerating,
generateError,
restart,
isRestarting,
restartError,
restartData
} = useDnsmasq();
const { messages, setMessage } = useMessages();
// Handle success/error messaging
if (generateError) {
setMessage('dnsmasq', `Failed to generate dnsmasq config: ${generateError.message}`, 'error');
} else if (dnsmasqConfig) {
setMessage('dnsmasq', 'Dnsmasq config generated successfully', 'success');
}
if (restartError) {
setMessage('dnsmasq', `Failed to restart dnsmasq: ${restartError.message}`, 'error');
} else if (restartData) {
setMessage('dnsmasq', `Dnsmasq restart: ${restartData.status}`, 'success');
}
return (
<Card>
<CardHeader>
<CardTitle>DNS/DHCP Management</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Button onClick={() => generateConfig()} disabled={isGenerating} variant="outline">
<Settings className="mr-2 h-4 w-4" />
{isGenerating ? 'Generating...' : 'Generate Dnsmasq Config'}
</Button>
<Button onClick={() => restart()} disabled={isRestarting} variant="outline">
<RotateCcw className={`mr-2 h-4 w-4 ${isRestarting ? 'animate-spin' : ''}`} />
{isRestarting ? 'Restarting...' : 'Restart Dnsmasq'}
</Button>
</div>
{generateError && (
<div className="p-3 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-md flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-red-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-red-800 dark:text-red-200">Generation Error</p>
<p className="text-sm text-red-700 dark:text-red-300">{generateError.message}</p>
</div>
</div>
)}
{restartError && (
<div className="p-3 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-md flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-red-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-red-800 dark:text-red-200">Restart Error</p>
<p className="text-sm text-red-700 dark:text-red-300">{restartError.message}</p>
</div>
</div>
)}
{restartData && (
<div className="p-3 bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-md">
<p className="text-sm text-green-800 dark:text-green-200">
Dnsmasq restart: {restartData.status}
</p>
</div>
)}
<Message message={messages.dnsmasq} />
{dnsmasqConfig && (
<pre className="p-4 bg-muted rounded-md text-sm overflow-auto max-h-96">
{dnsmasqConfig}
</pre>
)}
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,166 @@
import React, { Component as ReactComponent, ErrorInfo, ReactNode } from 'react';
import { AlertTriangle, RefreshCw, Home } from 'lucide-react';
import { Button } from './ui/button';
import { Card, CardHeader, CardTitle, CardContent } from './ui/card';
interface Props {
children?: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
}
interface State {
hasError: boolean;
error?: Error;
errorInfo?: ErrorInfo;
}
export class ErrorBoundary extends ReactComponent<Props, State> {
public state: State = {
hasError: false,
};
public static getDerivedStateFromError(error: Error): State {
// Update state so the next render will show the fallback UI
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
this.setState({
error,
errorInfo,
});
// Call optional error handler
if (this.props.onError) {
this.props.onError(error, errorInfo);
}
}
private handleReset = () => {
this.setState({ hasError: false, error: undefined, errorInfo: undefined });
};
private handleReload = () => {
window.location.reload();
};
public render() {
if (this.state.hasError) {
// If a custom fallback is provided, use it
if (this.props.fallback) {
return this.props.fallback;
}
// Default error UI
return <ErrorFallback
error={this.state.error}
errorInfo={this.state.errorInfo}
onReset={this.handleReset}
onReload={this.handleReload}
/>;
}
return this.props.children;
}
}
interface ErrorFallbackProps {
error?: Error;
errorInfo?: ErrorInfo;
onReset: () => void;
onReload: () => void;
}
export const ErrorFallback: React.FC<ErrorFallbackProps> = ({
error,
errorInfo,
onReset,
onReload
}) => {
const isDev = process.env.NODE_ENV === 'development';
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<Card className="w-full max-w-2xl">
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg">
<AlertTriangle className="h-6 w-6 text-red-600 dark:text-red-400" />
</div>
<div>
<CardTitle className="text-red-800 dark:text-red-200">
Something went wrong
</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
The application encountered an unexpected error
</p>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
Don't worry, your data is safe. You can try the following options:
</p>
<div className="flex gap-2">
<Button onClick={onReset} variant="outline" size="sm">
<RefreshCw className="h-4 w-4 mr-2" />
Try Again
</Button>
<Button onClick={onReload} variant="outline" size="sm">
<Home className="h-4 w-4 mr-2" />
Reload Page
</Button>
</div>
</div>
{isDev && error && (
<div className="space-y-3">
<h4 className="text-sm font-medium text-red-800 dark:text-red-200">
Error Details (Development Mode)
</h4>
<div className="space-y-2">
<div>
<p className="text-xs font-medium text-muted-foreground">Error Message:</p>
<p className="text-xs bg-red-50 p-2 rounded border font-mono">
{error.message}
</p>
</div>
{error.stack && (
<div>
<p className="text-xs font-medium text-muted-foreground">Stack Trace:</p>
<pre className="text-xs p-2 rounded border overflow-auto max-h-40 font-mono">
{error.stack}
</pre>
</div>
)}
{errorInfo?.componentStack && (
<div>
<p className="text-xs font-medium text-muted-foreground">Component Stack:</p>
<pre className="text-xs p-2 rounded border overflow-auto max-h-40 font-mono">
{errorInfo.componentStack}
</pre>
</div>
)}
</div>
</div>
)}
{!isDev && (
<div className="p-3 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-md">
<p className="text-sm text-blue-800 dark:text-blue-200">
If this problem persists, please contact support with details about what you were doing when the error occurred.
</p>
</div>
)}
</CardContent>
</Card>
</div>
);
};

View File

@@ -0,0 +1,47 @@
import { useState } from 'react';
import { Button } from './ui/button';
import { Card, CardHeader, CardTitle, CardContent } from './ui/card';
import { AlertTriangle } from 'lucide-react';
// Component that can trigger errors for testing
export const ErrorTester = () => {
const [shouldThrow, setShouldThrow] = useState(false);
if (shouldThrow) {
throw new Error('Test error: This is a simulated component crash for testing the error boundary.');
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-yellow-600" />
Error Boundary Tester
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
This component can be used to test the error boundary functionality in development.
</p>
<div className="p-3 bg-yellow-50 dark:bg-yellow-950 border border-yellow-200 dark:border-yellow-800 rounded-md">
<p className="text-sm text-yellow-800 dark:text-yellow-200">
Warning: Clicking the button below will intentionally crash this component to test error handling.
</p>
</div>
<Button
onClick={() => setShouldThrow(true)}
variant="destructive"
size="sm"
>
Trigger Error
</Button>
<div className="text-xs text-muted-foreground">
Development tool - remove from production builds
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,43 @@
import { AlertCircle, CheckCircle, Info } from 'lucide-react';
import type { Message as MessageType } from '../types';
import { cn } from '@/lib/utils';
interface MessageProps {
message?: MessageType;
}
export const Message = ({ message }: MessageProps) => {
if (!message) return null;
const getIcon = () => {
switch (message.type) {
case 'error':
return <AlertCircle className="h-4 w-4" />;
case 'success':
return <CheckCircle className="h-4 w-4" />;
default:
return <Info className="h-4 w-4" />;
}
};
const getVariantStyles = () => {
switch (message.type) {
case 'error':
return 'border-destructive/50 text-destructive bg-destructive/10';
case 'success':
return 'border-green-500/50 text-green-700 bg-green-50 dark:bg-green-950 dark:text-green-400';
default:
return 'border-blue-500/50 text-blue-700 bg-blue-50 dark:bg-blue-950 dark:text-blue-400';
}
};
return (
<div className={cn(
'flex items-center gap-2 p-3 rounded-md border text-sm',
getVariantStyles()
)}>
{getIcon()}
<span>{message.message}</span>
</div>
);
};

View File

@@ -0,0 +1,49 @@
import { Download, AlertCircle } from 'lucide-react';
import { useAssets, useMessages } from '../hooks';
import { Message } from './Message';
import { Card, CardHeader, CardTitle, CardContent, Button } from './ui';
export const PxeAssetsSection = () => {
const { downloadAssets, isDownloading, error, data } = useAssets();
const { messages, setMessage } = useMessages();
// Handle success/error messaging
if (error) {
setMessage('assets', `Failed to download assets: ${error.message}`, 'error');
} else if (data) {
setMessage('assets', `PXE Assets: ${data.status}`, 'success');
}
return (
<Card>
<CardHeader>
<CardTitle>PXE Boot Assets</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Button onClick={() => downloadAssets()} disabled={isDownloading} variant="outline">
<Download className="mr-2 h-4 w-4" />
{isDownloading ? 'Downloading...' : 'Download/Update PXE Assets'}
</Button>
{error && (
<div className="p-3 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-md flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-red-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-red-800 dark:text-red-200">Download Error</p>
<p className="text-sm text-red-700 dark:text-red-300">{error.message}</p>
</div>
</div>
)}
{data && (
<div className="p-3 bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-md">
<p className="text-sm text-green-800 dark:text-green-200">
PXE Assets: {data.status}
</p>
</div>
)}
<Message message={messages.assets} />
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,73 @@
import { Card } from './ui/card';
import { Button } from './ui/button';
import { HardDrive, BookOpen, ExternalLink } from 'lucide-react';
export function PxeComponent() {
return (
<div className="space-y-6">
{/* Educational Intro Section */}
<Card className="p-6 bg-gradient-to-r from-orange-50 to-amber-50 dark:from-orange-950/20 dark:to-amber-950/20 border-orange-200 dark:border-orange-800">
<div className="flex items-start gap-4">
<div className="p-3 bg-orange-100 dark:bg-orange-900/30 rounded-lg">
<BookOpen className="h-6 w-6 text-orange-600 dark:text-orange-400" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-orange-900 dark:text-orange-100 mb-2">
What is PXE Boot?
</h3>
<p className="text-orange-800 dark:text-orange-200 mb-3 leading-relaxed">
PXE (Preboot Execution Environment) is like having a "network installer" that can set up computers without
needing USB drives or DVDs. When you turn on a computer, instead of booting from its hard drive, it can boot
from the network and automatically install an operating system or run diagnostics.
</p>
<p className="text-orange-700 dark:text-orange-300 mb-4 text-sm">
This is especially useful for setting up multiple computers in your cloud infrastructure. PXE can automatically
install and configure the same operating system on many machines, making it easy to expand your personal cloud.
</p>
<Button variant="outline" size="sm" className="text-orange-700 border-orange-300 hover:bg-orange-100 dark:text-orange-300 dark:border-orange-700 dark:hover:bg-orange-900/20">
<ExternalLink className="h-4 w-4 mr-2" />
Learn more about network booting
</Button>
</div>
</div>
</Card>
<Card className="p-6">
<div className="flex items-center gap-4 mb-6">
<div className="p-2 bg-primary/10 rounded-lg">
<HardDrive className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">PXE Configuration</h2>
<p className="text-muted-foreground">
Manage PXE boot assets and network boot configuration
</p>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2 mb-4">
<span className="text-sm font-medium">Status:</span>
<span className="text-sm text-green-600">Active</span>
</div>
<div>
<h4 className="font-medium mb-2">Boot Assets</h4>
<p className="text-sm text-muted-foreground mb-4">
Manage Talos Linux boot images and iPXE configurations for network booting.
</p>
</div>
<div className="flex gap-2 justify-end mt-4">
<Button variant="outline" onClick={() => console.log('View assets')}>
View Assets
</Button>
<Button onClick={() => console.log('Download PXE assets')}>
Download Assets
</Button>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,72 @@
import { Server, RefreshCw } from 'lucide-react';
import { useStatus } from '../hooks';
import { Card, CardHeader, CardTitle, CardContent, Button } from './ui';
export const StatusSection = () => {
const { data: status, isLoading, error, refetch } = useStatus();
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="h-5 w-5" />
Server Status
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<span>Current Status</span>
<Button
onClick={() => refetch()}
disabled={isLoading}
variant="outline"
size="sm"
>
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
{isLoading ? 'Refreshing...' : 'Refresh'}
</Button>
</div>
{error && (
<div className="p-3 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-md">
<p className="text-sm text-red-800 dark:text-red-200">
Failed to fetch status: {error.message}
</p>
</div>
)}
{status && (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-4">
<div>
<span className="text-sm font-medium">Status</span>
<p className="text-muted-foreground">{status.status}</p>
</div>
<div>
<span className="text-sm font-medium">Version</span>
<p className="text-muted-foreground">{status.version}</p>
</div>
</div>
{status.uptime && (
<div>
<span className="text-sm font-medium">Uptime</span>
<p className="text-muted-foreground">{status.uptime}</p>
</div>
)}
<pre className="p-4 bg-muted rounded-md text-sm overflow-auto max-h-48">
{JSON.stringify(status, null, 2)}
</pre>
</div>
)}
{isLoading && !status && (
<div className="flex items-center justify-center p-8">
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,94 @@
import { RefreshCw, Activity, AlertCircle } from 'lucide-react';
import { useStatus, useHealth, useMessages } from '../hooks';
import { formatTimestamp } from '../utils/formatters';
import { Message } from './Message';
import { Card, CardHeader, CardTitle, CardContent, Button, Badge } from './ui';
export const SystemStatus = () => {
const { data: status, isLoading: statusLoading, error: statusError, refetch } = useStatus();
const { mutate: checkHealth, isPending: healthLoading, error: healthError, data: healthData } = useHealth();
const { messages, setMessage } = useMessages();
// Handle health check messaging
if (healthError) {
setMessage('health', `Health check failed: ${healthError.message}`, 'error');
} else if (healthData) {
setMessage('health', `Service: ${healthData.service} - Status: ${healthData.status}`, 'success');
}
return (
<Card>
<CardHeader>
<CardTitle>System Status</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Button onClick={() => refetch()} disabled={statusLoading} variant="outline">
<RefreshCw className={`mr-2 h-4 w-4 ${statusLoading ? 'animate-spin' : ''}`} />
{statusLoading ? 'Checking...' : 'Refresh Status'}
</Button>
<Button onClick={() => checkHealth()} disabled={healthLoading} variant="outline">
<Activity className="mr-2 h-4 w-4" />
{healthLoading ? 'Checking...' : 'Check Health'}
</Button>
</div>
{statusError && (
<div className="p-3 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-md flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-red-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-red-800 dark:text-red-200">Status Error</p>
<p className="text-sm text-red-700 dark:text-red-300">{statusError.message}</p>
</div>
</div>
)}
{healthError && (
<div className="p-3 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-md flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-red-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-red-800 dark:text-red-200">Health Check Error</p>
<p className="text-sm text-red-700 dark:text-red-300">{healthError.message}</p>
</div>
</div>
)}
{healthData && (
<div className="p-3 bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-md">
<p className="text-sm text-green-800 dark:text-green-200">
Service: {healthData.service} - Status: {healthData.status}
</p>
</div>
)}
<Message message={messages.health} />
{status && (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Status</p>
<Badge
variant={status.status === 'running' ? 'default' : 'destructive'}
className={`text-xs font-medium ${status.status === 'running' ? 'bg-emerald-600 hover:bg-emerald-700' : ''}`}
>
<div className={`w-2 h-2 rounded-full mr-2 ${status.status === 'running' ? 'bg-emerald-200' : 'bg-red-200'}`} />
{status.status}
</Badge>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Version</p>
<p className="text-sm">{status.version}</p>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Uptime</p>
<p className="text-sm">{status.uptime}</p>
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Last Updated</p>
<p className="text-sm">{formatTimestamp(status.timestamp)}</p>
</div>
</div>
)}
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,52 @@
import { Moon, Sun, Monitor } from 'lucide-react';
import { Button } from './ui/button';
import { useTheme } from '../contexts/ThemeContext';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const cycleTheme = () => {
if (theme === 'light') {
setTheme('dark');
} else if (theme === 'dark') {
setTheme('system');
} else {
setTheme('light');
}
};
const getIcon = () => {
switch (theme) {
case 'light':
return <Sun className="h-4 w-4" />;
case 'dark':
return <Moon className="h-4 w-4" />;
default:
return <Monitor className="h-4 w-4" />;
}
};
const getLabel = () => {
switch (theme) {
case 'light':
return 'Light mode';
case 'dark':
return 'Dark mode';
default:
return 'System theme';
}
};
return (
<Button
variant="ghost"
size="sm"
onClick={cycleTheme}
title={`Current: ${getLabel()}. Click to cycle themes.`}
className="gap-2"
>
{getIcon()}
<span className="text-xs font-medium">{getLabel()}</span>
</Button>
);
}

View File

@@ -0,0 +1,19 @@
export { Message } from './Message';
export { SystemStatus } from './SystemStatus';
export { ConfigurationSection } from './ConfigurationSection';
export { ConfigurationForm } from './ConfigurationForm';
export { StatusSection } from './StatusSection';
export { DnsmasqSection } from './DnsmasqSection';
export { PxeAssetsSection } from './PxeAssetsSection';
export { AppSidebar } from './AppSidebar';
export { Advanced } from './Advanced';
export { ConfigEditor } from './ConfigEditor';
export { ErrorBoundary, ErrorFallback } from './ErrorBoundary';
export { CloudComponent } from './CloudComponent';
export { CentralComponent } from './CentralComponent';
export { DnsComponent } from './DnsComponent';
export { DhcpComponent } from './DhcpComponent';
export { PxeComponent } from './PxeComponent';
export { ClusterNodesComponent } from './ClusterNodesComponent';
export { ClusterServicesComponent } from './ClusterServicesComponent';
export { AppsComponent } from './AppsComponent';

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,31 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,141 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,167 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -0,0 +1,27 @@
export { Button, buttonVariants } from './button';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from './card';
export { Badge, badgeVariants } from './badge';
export { Input } from './input';
export { Label } from './label';
export { Textarea } from './textarea';
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from './dialog';
export {
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
} from './form';

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,137 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,727 @@
"use client"
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva } from "class-variance-authority"
import type { VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,73 @@
import { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'dark' | 'light' | 'system';
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
};
type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
};
const initialState: ThemeProviderState = {
theme: 'system',
setTheme: () => null,
};
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({
children,
defaultTheme = 'system',
storageKey = 'wild-central-theme',
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
);
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
.matches
? 'dark'
: 'light';
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}, [theme]);
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
},
};
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined)
throw new Error('useTheme must be used within a ThemeProvider');
return context;
};

View File

@@ -0,0 +1,165 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { useConfig } from '../useConfig';
import { apiService } from '../../services/api';
// Mock the API service
vi.mock('../../services/api', () => ({
apiService: {
getConfig: vi.fn(),
createConfig: vi.fn(),
},
}));
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
mutations: {
retry: false,
},
},
});
return ({ children }: { children: React.ReactNode }) => (
React.createElement(QueryClientProvider, { client: queryClient }, children)
);
};
describe('useConfig', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should fetch config successfully when configured', async () => {
const mockConfigResponse = {
configured: true,
config: {
server: { host: '0.0.0.0', port: 5055 },
cloud: {
domain: 'wildcloud.local',
internalDomain: 'cluster.local',
dhcpRange: '192.168.8.100,192.168.8.200',
dns: { ip: '192.168.8.50' },
router: { ip: '192.168.8.1' },
dnsmasq: { interface: 'eth0' },
},
cluster: {
endpointIp: '192.168.8.60',
nodes: { talos: { version: 'v1.8.0' } },
},
},
};
vi.mocked(apiService.getConfig).mockResolvedValue(mockConfigResponse);
const { result } = renderHook(() => useConfig(), {
wrapper: createWrapper(),
});
expect(result.current.isLoading).toBe(true);
expect(result.current.showConfigSetup).toBe(false);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.config).toEqual(mockConfigResponse.config);
expect(result.current.isConfigured).toBe(true);
expect(result.current.showConfigSetup).toBe(false);
expect(result.current.error).toBeNull();
});
it('should show config setup when not configured', async () => {
const mockConfigResponse = {
configured: false,
message: 'No configuration found',
};
vi.mocked(apiService.getConfig).mockResolvedValue(mockConfigResponse);
const { result } = renderHook(() => useConfig(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.config).toBeNull();
expect(result.current.isConfigured).toBe(false);
expect(result.current.showConfigSetup).toBe(true);
});
it('should create config successfully', async () => {
const mockConfigResponse = {
configured: false,
message: 'No configuration found',
};
const mockCreateResponse = {
status: 'Configuration created successfully',
};
const newConfig = {
server: { host: '0.0.0.0', port: 5055 },
cloud: {
domain: 'wildcloud.local',
internalDomain: 'cluster.local',
dhcpRange: '192.168.8.100,192.168.8.200',
dns: { ip: '192.168.8.50' },
router: { ip: '192.168.8.1' },
dnsmasq: { interface: 'eth0' },
},
cluster: {
endpointIp: '192.168.8.60',
nodes: { talos: { version: 'v1.8.0' } },
},
};
vi.mocked(apiService.getConfig).mockResolvedValue(mockConfigResponse);
vi.mocked(apiService.createConfig).mockResolvedValue(mockCreateResponse);
const { result } = renderHook(() => useConfig(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.showConfigSetup).toBe(true);
// Create config
await act(async () => {
result.current.createConfig(newConfig);
});
await waitFor(() => {
expect(result.current.isCreating).toBe(false);
});
expect(apiService.createConfig).toHaveBeenCalledWith(newConfig);
});
it('should handle error when fetching config fails', async () => {
const mockError = new Error('Network error');
vi.mocked(apiService.getConfig).mockRejectedValue(mockError);
const { result } = renderHook(() => useConfig(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.error).toEqual(mockError);
expect(result.current.config).toBeNull();
});
});

View File

@@ -0,0 +1,127 @@
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useMessages } from '../useMessages';
describe('useMessages', () => {
it('should initialize with empty messages', () => {
const { result } = renderHook(() => useMessages());
expect(result.current.messages).toEqual({});
});
it('should set a message', () => {
const { result } = renderHook(() => useMessages());
act(() => {
result.current.setMessage('test', 'Test message', 'success');
});
expect(result.current.messages).toEqual({
test: { message: 'Test message', type: 'success' }
});
});
it('should set multiple messages', () => {
const { result } = renderHook(() => useMessages());
act(() => {
result.current.setMessage('success', 'Success message', 'success');
result.current.setMessage('error', 'Error message', 'error');
result.current.setMessage('info', 'Info message', 'info');
});
expect(result.current.messages).toEqual({
success: { message: 'Success message', type: 'success' },
error: { message: 'Error message', type: 'error' },
info: { message: 'Info message', type: 'info' },
});
});
it('should update existing message', () => {
const { result } = renderHook(() => useMessages());
act(() => {
result.current.setMessage('test', 'First message', 'info');
});
expect(result.current.messages.test).toEqual({
message: 'First message',
type: 'info'
});
act(() => {
result.current.setMessage('test', 'Updated message', 'error');
});
expect(result.current.messages.test).toEqual({
message: 'Updated message',
type: 'error'
});
});
it('should clear a specific message', () => {
const { result } = renderHook(() => useMessages());
act(() => {
result.current.setMessage('test1', 'Message 1', 'info');
result.current.setMessage('test2', 'Message 2', 'success');
});
expect(Object.keys(result.current.messages)).toHaveLength(2);
act(() => {
result.current.clearMessage('test1');
});
expect(result.current.messages).toEqual({
test2: { message: 'Message 2', type: 'success' }
});
});
it('should clear message by setting to null', () => {
const { result } = renderHook(() => useMessages());
act(() => {
result.current.setMessage('test', 'Test message', 'info');
});
expect(result.current.messages.test).toBeDefined();
act(() => {
result.current.setMessage('test', null);
});
expect(result.current.messages.test).toBeUndefined();
});
it('should clear all messages', () => {
const { result } = renderHook(() => useMessages());
act(() => {
result.current.setMessage('test1', 'Message 1', 'info');
result.current.setMessage('test2', 'Message 2', 'success');
result.current.setMessage('test3', 'Message 3', 'error');
});
expect(Object.keys(result.current.messages)).toHaveLength(3);
act(() => {
result.current.clearAllMessages();
});
expect(result.current.messages).toEqual({});
});
it('should default to info type when type not specified', () => {
const { result } = renderHook(() => useMessages());
act(() => {
result.current.setMessage('test', 'Test message');
});
expect(result.current.messages.test).toEqual({
message: 'Test message',
type: 'info'
});
});
});

View File

@@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { useStatus } from '../useStatus';
import { apiService } from '../../services/api';
// Mock the API service
vi.mock('../../services/api', () => ({
apiService: {
getStatus: vi.fn(),
},
}));
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
},
});
return ({ children }: { children: React.ReactNode }) => (
React.createElement(QueryClientProvider, { client: queryClient }, children)
);
};
describe('useStatus', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should fetch status successfully', async () => {
const mockStatus = {
status: 'running',
version: '1.0.0',
uptime: '2 hours',
timestamp: '2024-01-01T00:00:00Z',
};
vi.mocked(apiService.getStatus).mockResolvedValue(mockStatus);
const { result } = renderHook(() => useStatus(), {
wrapper: createWrapper(),
});
expect(result.current.isLoading).toBe(true);
expect(result.current.data).toBeUndefined();
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toEqual(mockStatus);
expect(result.current.error).toBeNull();
expect(apiService.getStatus).toHaveBeenCalledTimes(1);
});
it('should handle error when fetching status fails', async () => {
const mockError = new Error('Network error');
vi.mocked(apiService.getStatus).mockRejectedValue(mockError);
const { result } = renderHook(() => useStatus(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toBeUndefined();
expect(result.current.error).toEqual(mockError);
});
it('should refetch data when refetch is called', async () => {
const mockStatus = {
status: 'running',
version: '1.0.0',
uptime: '2 hours',
timestamp: '2024-01-01T00:00:00Z',
};
vi.mocked(apiService.getStatus).mockResolvedValue(mockStatus);
const { result } = renderHook(() => useStatus(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(apiService.getStatus).toHaveBeenCalledTimes(1);
// Trigger refetch
result.current.refetch();
await waitFor(() => {
expect(apiService.getStatus).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -0,0 +1,7 @@
export { useMessages } from './useMessages';
export { useStatus } from './useStatus';
export { useHealth } from './useHealth';
export { useConfig } from './useConfig';
export { useConfigYaml } from './useConfigYaml';
export { useDnsmasq } from './useDnsmasq';
export { useAssets } from './useAssets';

View File

@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

View File

@@ -0,0 +1,19 @@
import { useMutation } from '@tanstack/react-query';
import { apiService } from '../services/api';
interface AssetsResponse {
status: string;
}
export const useAssets = () => {
const downloadMutation = useMutation<AssetsResponse>({
mutationFn: apiService.downloadPXEAssets,
});
return {
downloadAssets: downloadMutation.mutate,
isDownloading: downloadMutation.isPending,
error: downloadMutation.error,
data: downloadMutation.data,
};
};

View File

@@ -0,0 +1,52 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiService } from '../services/api';
import type { Config } from '../types';
interface ConfigResponse {
configured: boolean;
config?: Config;
message?: string;
}
interface CreateConfigResponse {
status: string;
}
export const useConfig = () => {
const queryClient = useQueryClient();
const [showConfigSetup, setShowConfigSetup] = useState(false);
const configQuery = useQuery<ConfigResponse>({
queryKey: ['config'],
queryFn: () => apiService.getConfig(),
});
// Update showConfigSetup based on query data
useEffect(() => {
if (configQuery.data) {
setShowConfigSetup(configQuery.data.configured === false);
}
}, [configQuery.data]);
const createConfigMutation = useMutation<CreateConfigResponse, Error, Config>({
mutationFn: apiService.createConfig,
onSuccess: () => {
// Invalidate and refetch config after successful creation
queryClient.invalidateQueries({ queryKey: ['config'] });
setShowConfigSetup(false);
},
});
return {
config: configQuery.data?.config || null,
isConfigured: configQuery.data?.configured || false,
showConfigSetup,
setShowConfigSetup,
isLoading: configQuery.isLoading,
isCreating: createConfigMutation.isPending,
error: configQuery.error || createConfigMutation.error,
createConfig: createConfigMutation.mutate,
refetch: configQuery.refetch,
};
};

View File

@@ -0,0 +1,40 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiService } from '../services/api';
export const useConfigYaml = () => {
const queryClient = useQueryClient();
const configYamlQuery = useQuery({
queryKey: ['config', 'yaml'],
queryFn: () => apiService.getConfigYaml(),
staleTime: 30000, // Consider data fresh for 30 seconds
retry: true,
});
const updateConfigYamlMutation = useMutation({
mutationFn: (data: string) => apiService.updateConfigYaml(data),
onSuccess: () => {
// Invalidate both YAML and JSON config queries
queryClient.invalidateQueries({ queryKey: ['config'] });
},
});
// Check if error is 404 (endpoint doesn't exist)
const isEndpointMissing = configYamlQuery.error &&
configYamlQuery.error instanceof Error &&
configYamlQuery.error.message.includes('404');
// Only pass through real errors
const actualError = (configYamlQuery.error instanceof Error ? configYamlQuery.error : null) ||
(updateConfigYamlMutation.error instanceof Error ? updateConfigYamlMutation.error : null);
return {
yamlContent: configYamlQuery.data || '',
isLoading: configYamlQuery.isLoading,
error: actualError,
isEndpointMissing,
isUpdating: updateConfigYamlMutation.isPending,
updateYaml: updateConfigYamlMutation.mutate,
refetch: configYamlQuery.refetch,
};
};

View File

@@ -0,0 +1,33 @@
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { apiService } from '../services/api';
interface DnsmasqResponse {
status: string;
}
export const useDnsmasq = () => {
const [dnsmasqConfig, setDnsmasqConfig] = useState<string>('');
const generateConfigMutation = useMutation<string>({
mutationFn: apiService.getDnsmasqConfig,
onSuccess: (data) => {
setDnsmasqConfig(data);
},
});
const restartMutation = useMutation<DnsmasqResponse>({
mutationFn: apiService.restartDnsmasq,
});
return {
dnsmasqConfig,
generateConfig: generateConfigMutation.mutate,
isGenerating: generateConfigMutation.isPending,
generateError: generateConfigMutation.error,
restart: restartMutation.mutate,
isRestarting: restartMutation.isPending,
restartError: restartMutation.error,
restartData: restartMutation.data,
};
};

View File

@@ -0,0 +1,13 @@
import { useMutation } from '@tanstack/react-query';
import { apiService } from '../services/api';
interface HealthResponse {
service: string;
status: string;
}
export const useHealth = () => {
return useMutation<HealthResponse>({
mutationFn: apiService.getHealth,
});
};

View File

@@ -0,0 +1,33 @@
import { useState } from 'react';
import type { Messages } from '../types';
export const useMessages = () => {
const [messages, setMessages] = useState<Messages>({});
const setMessage = (key: string, message: string | null, type: 'info' | 'success' | 'error' = 'info') => {
if (message === null) {
setMessages(prev => {
const newMessages = { ...prev };
delete newMessages[key];
return newMessages;
});
} else {
setMessages(prev => ({ ...prev, [key]: { message, type } }));
}
};
const clearMessage = (key: string) => {
setMessage(key, null);
};
const clearAllMessages = () => {
setMessages({});
};
return {
messages,
setMessage,
clearMessage,
clearAllMessages,
};
};

View File

@@ -0,0 +1,11 @@
import { useQuery } from '@tanstack/react-query';
import { apiService } from '../services/api';
import type { Status } from '../types';
export const useStatus = () => {
return useQuery<Status>({
queryKey: ['status'],
queryFn: apiService.getStatus,
refetchInterval: 30000, // Refetch every 30 seconds
});
};

View File

@@ -0,0 +1,120 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(50.59% 0.12582 244.557);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,15 @@
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
refetchOnWindowFocus: false,
},
mutations: {
retry: 1,
},
},
});

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,24 @@
import { StrictMode } from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClientProvider } from '@tanstack/react-query';
import './index.css';
import App from './App';
import { ThemeProvider } from './contexts/ThemeContext';
import { queryClient } from './lib/queryClient';
import { ErrorBoundary } from './components/ErrorBoundary';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<StrictMode>
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<ThemeProvider defaultTheme="light" storageKey="wild-central-theme">
<App />
</ThemeProvider>
</QueryClientProvider>
</ErrorBoundary>
</StrictMode>
);

View File

@@ -0,0 +1,330 @@
import { describe, it, expect } from 'vitest';
import { configFormSchema, defaultConfigValues } from '../config';
describe('config schema validation', () => {
describe('valid configurations', () => {
it('should validate default configuration', () => {
const result = configFormSchema.safeParse(defaultConfigValues);
expect(result.success).toBe(true);
});
it('should validate complete configuration', () => {
const validConfig = {
server: {
host: '0.0.0.0',
port: 5055,
},
cloud: {
domain: 'wildcloud.local',
internalDomain: 'cluster.local',
dhcpRange: '192.168.8.100,192.168.8.200',
dns: { ip: '192.168.8.50' },
router: { ip: '192.168.8.1' },
dnsmasq: { interface: 'eth0' },
},
cluster: {
endpointIp: '192.168.8.60',
nodes: { talos: { version: 'v1.8.0' } },
},
};
const result = configFormSchema.safeParse(validConfig);
expect(result.success).toBe(true);
});
});
describe('server validation', () => {
it('should reject empty host', () => {
const config = {
...defaultConfigValues,
server: { ...defaultConfigValues.server, host: '' },
};
const result = configFormSchema.safeParse(config);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.errors[0].path).toEqual(['server', 'host']);
expect(result.error.errors[0].message).toBe('Host is required');
}
});
it('should reject invalid port ranges', () => {
const invalidPorts = [0, -1, 65536, 99999];
invalidPorts.forEach(port => {
const config = {
...defaultConfigValues,
server: { ...defaultConfigValues.server, port },
};
const result = configFormSchema.safeParse(config);
expect(result.success).toBe(false);
});
});
it('should accept valid port ranges', () => {
const validPorts = [1, 80, 443, 5055, 65535];
validPorts.forEach(port => {
const config = {
...defaultConfigValues,
server: { ...defaultConfigValues.server, port },
};
const result = configFormSchema.safeParse(config);
expect(result.success).toBe(true);
});
});
});
describe('IP address validation', () => {
it('should reject invalid IP addresses', () => {
const invalidIPs = [
'256.1.1.1',
'192.168.1',
'192.168.1.256',
'not-an-ip',
'192.168.1.1.1',
'',
];
invalidIPs.forEach(ip => {
const config = {
...defaultConfigValues,
cloud: {
...defaultConfigValues.cloud,
dns: { ip },
},
};
const result = configFormSchema.safeParse(config);
expect(result.success).toBe(false);
});
});
it('should accept valid IP addresses', () => {
const validIPs = [
'192.168.1.1',
'10.0.0.1',
'172.16.0.1',
'127.0.0.1',
'0.0.0.0',
'255.255.255.255',
];
validIPs.forEach(ip => {
const config = {
...defaultConfigValues,
cloud: {
...defaultConfigValues.cloud,
dns: { ip },
},
};
const result = configFormSchema.safeParse(config);
expect(result.success).toBe(true);
});
});
});
describe('domain validation', () => {
it('should reject invalid domains', () => {
const invalidDomains = [
'',
'.com',
'domain.',
'domain..com',
'domain-.com',
'-domain.com',
'domain.c',
'very-long-domain-name-that-exceeds-the-maximum-allowed-length-for-a-domain-label.com',
];
invalidDomains.forEach(domain => {
const config = {
...defaultConfigValues,
cloud: {
...defaultConfigValues.cloud,
domain,
},
};
const result = configFormSchema.safeParse(config);
expect(result.success, `Domain "${domain}" should be invalid but passed validation`).toBe(false);
});
});
it('should accept valid domains', () => {
const validDomains = [
'wildcloud.local',
'example.com',
'sub.domain.com',
'localhost',
'test123.example.org',
'my-domain.net',
];
validDomains.forEach(domain => {
const config = {
...defaultConfigValues,
cloud: {
...defaultConfigValues.cloud,
domain,
},
};
const result = configFormSchema.safeParse(config);
expect(result.success).toBe(true);
});
});
});
describe('DHCP range validation', () => {
it('should reject invalid DHCP ranges', () => {
const invalidRanges = [
'',
'192.168.1.1',
'192.168.1.1,',
',192.168.1.200',
'192.168.1.1-192.168.1.200',
'192.168.1.1,192.168.1.256',
'start,end',
];
invalidRanges.forEach(dhcpRange => {
const config = {
...defaultConfigValues,
cloud: {
...defaultConfigValues.cloud,
dhcpRange,
},
};
const result = configFormSchema.safeParse(config);
expect(result.success).toBe(false);
});
});
it('should accept valid DHCP ranges', () => {
const validRanges = [
'192.168.1.100,192.168.1.200',
'10.0.0.10,10.0.0.100',
'172.16.1.1,172.16.1.254',
];
validRanges.forEach(dhcpRange => {
const config = {
...defaultConfigValues,
cloud: {
...defaultConfigValues.cloud,
dhcpRange,
},
};
const result = configFormSchema.safeParse(config);
expect(result.success).toBe(true);
});
});
});
describe('version validation', () => {
it('should reject invalid versions', () => {
const invalidVersions = [
'',
'1.8.0',
'v1.8',
'v1.8.0.1',
'version1.8.0',
'v1.8.0-beta',
];
invalidVersions.forEach(version => {
const config = {
...defaultConfigValues,
cluster: {
...defaultConfigValues.cluster,
nodes: {
talos: { version },
},
},
};
const result = configFormSchema.safeParse(config);
expect(result.success).toBe(false);
});
});
it('should accept valid versions', () => {
const validVersions = [
'v1.8.0',
'v1.0.0',
'v10.20.30',
'v0.0.1',
];
validVersions.forEach(version => {
const config = {
...defaultConfigValues,
cluster: {
...defaultConfigValues.cluster,
nodes: {
talos: { version },
},
},
};
const result = configFormSchema.safeParse(config);
expect(result.success).toBe(true);
});
});
});
describe('network interface validation', () => {
it('should reject invalid interfaces', () => {
const invalidInterfaces = [
'',
'eth-0',
'eth.0',
'eth 0',
'eth/0',
];
invalidInterfaces.forEach(interfaceName => {
const config = {
...defaultConfigValues,
cloud: {
...defaultConfigValues.cloud,
dnsmasq: { interface: interfaceName },
},
};
const result = configFormSchema.safeParse(config);
expect(result.success).toBe(false);
});
});
it('should accept valid interfaces', () => {
const validInterfaces = [
'eth0',
'eth1',
'enp0s3',
'wlan0',
'lo',
'br0',
];
validInterfaces.forEach(interfaceName => {
const config = {
...defaultConfigValues,
cloud: {
...defaultConfigValues.cloud,
dnsmasq: { interface: interfaceName },
},
};
const result = configFormSchema.safeParse(config);
expect(result.success).toBe(true);
});
});
});
});

View File

@@ -0,0 +1,184 @@
import { z } from 'zod';
// Network validation helpers
const ipAddressSchema = z.string().regex(
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/,
'Must be a valid IP address'
);
const domainSchema = z.string().regex(
/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9]))*$/,
'Must be a valid domain name'
);
const dhcpRangeSchema = z.string().regex(
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?),(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/,
'Must be in format: start_ip,end_ip (e.g., 192.168.1.100,192.168.1.200)'
);
const interfaceSchema = z.string().regex(
/^[a-zA-Z0-9]+$/,
'Must be a valid network interface name (e.g., eth0, enp0s3)'
);
const versionSchema = z.string().regex(
/^v\d+\.\d+\.\d+$/,
'Must be a valid version format (e.g., v1.8.0)'
);
// Server configuration schema
const serverConfigSchema = z.object({
host: z.string().min(1, 'Host is required').default('0.0.0.0'),
port: z.number()
.int('Port must be an integer')
.min(1, 'Port must be at least 1')
.max(65535, 'Port must be at most 65535')
.default(5055),
});
// Cloud DNS configuration schema
const cloudDnsSchema = z.object({
ip: ipAddressSchema,
});
// Cloud router configuration schema
const cloudRouterSchema = z.object({
ip: ipAddressSchema,
});
// Cloud dnsmasq configuration schema
const cloudDnsmasqSchema = z.object({
interface: interfaceSchema,
});
// Cloud configuration schema
const cloudConfigSchema = z.object({
domain: domainSchema,
internalDomain: domainSchema,
dhcpRange: dhcpRangeSchema,
dns: cloudDnsSchema,
router: cloudRouterSchema,
dnsmasq: cloudDnsmasqSchema,
});
// Talos configuration schema
const talosConfigSchema = z.object({
version: versionSchema,
});
// Nodes configuration schema
const nodesConfigSchema = z.object({
talos: talosConfigSchema,
});
// Cluster configuration schema
const clusterConfigSchema = z.object({
endpointIp: ipAddressSchema,
nodes: nodesConfigSchema,
});
// Wildcloud configuration schema (optional)
const wildcloudConfigSchema = z.object({
repository: z.string().min(1, 'Repository is required'),
currentPhase: z.enum(['setup', 'infrastructure', 'cluster', 'apps']).optional(),
completedPhases: z.array(z.enum(['setup', 'infrastructure', 'cluster', 'apps'])).optional(),
}).optional();
// Main configuration schema
export const configSchema = z.object({
server: serverConfigSchema,
cloud: cloudConfigSchema,
cluster: clusterConfigSchema,
wildcloud: wildcloudConfigSchema,
});
// Form schema for creating new configurations (some fields can be optional for partial updates)
export const configFormSchema = z.object({
server: z.object({
host: z.string().min(1, 'Host is required'),
port: z.coerce.number()
.int('Port must be an integer')
.min(1, 'Port must be at least 1')
.max(65535, 'Port must be at most 65535'),
}),
cloud: z.object({
domain: z.string().min(1, 'Domain is required').refine(
(val) => domainSchema.safeParse(val).success,
'Must be a valid domain name'
),
internalDomain: z.string().min(1, 'Internal domain is required').refine(
(val) => domainSchema.safeParse(val).success,
'Must be a valid domain name'
),
dhcpRange: z.string().min(1, 'DHCP range is required').refine(
(val) => dhcpRangeSchema.safeParse(val).success,
'Must be in format: start_ip,end_ip'
),
dns: z.object({
ip: z.string().min(1, 'DNS IP is required').refine(
(val) => ipAddressSchema.safeParse(val).success,
'Must be a valid IP address'
),
}),
router: z.object({
ip: z.string().min(1, 'Router IP is required').refine(
(val) => ipAddressSchema.safeParse(val).success,
'Must be a valid IP address'
),
}),
dnsmasq: z.object({
interface: z.string().min(1, 'Interface is required').refine(
(val) => interfaceSchema.safeParse(val).success,
'Must be a valid network interface name'
),
}),
}),
cluster: z.object({
endpointIp: z.string().min(1, 'Endpoint IP is required').refine(
(val) => ipAddressSchema.safeParse(val).success,
'Must be a valid IP address'
),
nodes: z.object({
talos: z.object({
version: z.string().min(1, 'Talos version is required').refine(
(val) => versionSchema.safeParse(val).success,
'Must be a valid version format (e.g., v1.8.0)'
),
}),
}),
}),
});
// Type exports
export type Config = z.infer<typeof configSchema>;
export type ConfigFormData = z.infer<typeof configFormSchema>;
// Default values for the form
export const defaultConfigValues: ConfigFormData = {
server: {
host: '0.0.0.0',
port: 5055,
},
cloud: {
domain: 'wildcloud.local',
internalDomain: 'cluster.local',
dhcpRange: '192.168.8.100,192.168.8.200',
dns: {
ip: '192.168.8.50',
},
router: {
ip: '192.168.8.1',
},
dnsmasq: {
interface: 'eth0',
},
},
cluster: {
endpointIp: '192.168.8.60',
nodes: {
talos: {
version: 'v1.8.0',
},
},
},
};

View File

@@ -0,0 +1,92 @@
import type { Status, ConfigResponse, Config, HealthResponse, StatusResponse } from '../types';
const API_BASE = 'http://localhost:5055';
class ApiService {
private baseUrl: string;
constructor(baseUrl: string = API_BASE) {
this.baseUrl = baseUrl;
}
private async request<T>(endpoint: string, options?: RequestInit): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
private async requestText(endpoint: string, options?: RequestInit): Promise<string> {
const url = `${this.baseUrl}${endpoint}`;
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.text();
}
async getStatus(): Promise<Status> {
return this.request<Status>('/api/status');
}
async getHealth(): Promise<HealthResponse> {
return this.request<HealthResponse>('/api/v1/health');
}
async getConfig(): Promise<ConfigResponse> {
return this.request<ConfigResponse>('/api/v1/config');
}
async getConfigYaml(): Promise<string> {
return this.requestText('/api/v1/config/yaml');
}
async updateConfigYaml(yamlContent: string): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/config/yaml', {
method: 'PUT',
headers: { 'Content-Type': 'text/plain' },
body: yamlContent
});
}
async createConfig(config: Config): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
}
async updateConfig(config: Config): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
}
async getDnsmasqConfig(): Promise<string> {
return this.requestText('/api/v1/dnsmasq/config');
}
async restartDnsmasq(): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/dnsmasq/restart', {
method: 'POST'
});
}
async downloadPXEAssets(): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/pxe/assets', {
method: 'POST'
});
}
}
export const apiService = new ApiService();
export default ApiService;

View File

@@ -0,0 +1,23 @@
import '@testing-library/jest-dom/vitest';
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // deprecated
removeListener: vi.fn(), // deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock ResizeObserver
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}));

View File

@@ -0,0 +1,35 @@
import React, { ReactElement } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '../contexts/ThemeContext';
// Custom render function that includes providers
const AllTheProviders = ({ children }: { children: React.ReactNode }) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
mutations: {
retry: false,
},
},
});
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider defaultTheme="light" storageKey="test-theme">
{children}
</ThemeProvider>
</QueryClientProvider>
);
};
const customRender = (
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'>,
) => render(ui, { wrapper: AllTheProviders, ...options });
export * from '@testing-library/react';
export { customRender as render };

View File

@@ -0,0 +1,86 @@
export interface Status {
status: string;
version: string;
uptime: string;
timestamp: string;
}
export interface ServerConfig {
host: string;
port: number;
}
export interface CloudDns {
ip: string;
}
export interface CloudRouter {
ip: string;
}
export interface CloudDnsmasq {
interface: string;
}
export interface CloudConfig {
domain: string;
internalDomain: string;
dhcpRange: string;
dns: CloudDns;
router: CloudRouter;
dnsmasq: CloudDnsmasq;
}
export interface TalosConfig {
version: string;
}
export interface NodesConfig {
talos: TalosConfig;
}
export interface ClusterConfig {
endpointIp: string;
nodes: NodesConfig;
}
export interface WildcloudConfig {
repository: string;
currentPhase?: 'setup' | 'infrastructure' | 'cluster' | 'apps';
completedPhases?: ('setup' | 'infrastructure' | 'cluster' | 'apps')[];
}
export interface Config {
server: ServerConfig;
cloud: CloudConfig;
cluster: ClusterConfig;
wildcloud?: WildcloudConfig;
}
export interface ConfigResponse {
configured: boolean;
config?: Config;
message?: string;
}
export interface Message {
message: string;
type: 'info' | 'success' | 'error';
}
export interface LoadingState {
[key: string]: boolean;
}
export interface Messages {
[key: string]: Message;
}
export interface HealthResponse {
service: string;
status: string;
}
export interface StatusResponse {
status: string;
}

View File

@@ -0,0 +1,3 @@
export const formatTimestamp = (timestamp: string): string => {
return new Date(timestamp).toLocaleString();
};

View File

@@ -0,0 +1,61 @@
import { Config } from '../types';
// Simple YAML to JSON parser for basic configuration
export const parseSimpleYaml = (yamlText: string): Config => {
const config: Config = {
cloud: {
domain: '',
internalDomain: '',
dhcpRange: '',
dns: { ip: '' },
router: { ip: '' },
dnsmasq: { interface: '' }
},
cluster: {
endpointIp: '',
nodes: { talos: { version: '' } }
},
server: { host: '', port: 0 }
};
const lines = yamlText.split('\n');
let currentSection: 'cloud' | 'cluster' | 'server' | null = null;
let currentSubsection: string | null = null;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
if (trimmed.startsWith('cloud:')) currentSection = 'cloud';
else if (trimmed.startsWith('cluster:')) currentSection = 'cluster';
else if (trimmed.startsWith('server:')) currentSection = 'server';
else if (trimmed.startsWith('dns:')) currentSubsection = 'dns';
else if (trimmed.startsWith('router:')) currentSubsection = 'router';
else if (trimmed.startsWith('dnsmasq:')) currentSubsection = 'dnsmasq';
else if (trimmed.startsWith('nodes:')) currentSubsection = 'nodes';
else if (trimmed.startsWith('talos:')) currentSubsection = 'talos';
else if (trimmed.includes(':')) {
const [key, value] = trimmed.split(':').map(s => s.trim());
const cleanValue = value.replace(/"/g, '');
if (currentSection === 'cloud') {
if (currentSubsection === 'dns') (config.cloud.dns as any)[key] = cleanValue;
else if (currentSubsection === 'router') (config.cloud.router as any)[key] = cleanValue;
else if (currentSubsection === 'dnsmasq') (config.cloud.dnsmasq as any)[key] = cleanValue;
else (config.cloud as any)[key] = cleanValue;
} else if (currentSection === 'cluster') {
if (currentSubsection === 'nodes') {
// Skip nodes level
} else if (currentSubsection === 'talos') {
(config.cluster.nodes.talos as any)[key] = cleanValue;
} else {
(config.cluster as any)[key] = cleanValue;
}
} else if (currentSection === 'server') {
(config.server as any)[key] = key === 'port' ? parseInt(cleanValue) : cleanValue;
}
}
}
return config;
};

1
experimental/app/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,37 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"src"
]
}

View File

@@ -0,0 +1,19 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
}
}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,14 @@
import path from "path"
import tailwindcss from "@tailwindcss/vite"
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
})

View File

@@ -0,0 +1,21 @@
/// <reference types="vitest" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
import { fileURLToPath, URL } from 'node:url';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": fileURLToPath(new URL('./src', import.meta.url)),
},
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
css: true,
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
},
});

View File

@@ -0,0 +1,90 @@
# Default target
.DEFAULT_GOAL := help
# Build configuration
BINARY_NAME := wild-api
VERSION ?= 0.1.1
BUILD_DIR := build
# Go build configuration
GO_VERSION := $(shell go version | cut -d' ' -f3)
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
BUILD_TIME := $(shell date -u '+%Y-%m-%d_%H:%M:%S')
LDFLAGS := -X main.Version=$(VERSION) -X main.GitCommit=$(GIT_COMMIT) -X main.BuildTime=$(BUILD_TIME)
.PHONY: help build clean test run install check fmt vet lint deps-check version
# Usage: $(call package_deb,architecture,binary_name)
help:
@echo "🏗️ Wild Cloud API Build System"
@echo ""
@echo "📦 Build targets (compile binaries):"
@echo " build - Build for current architecture"
@echo ""
@echo "🔍 Quality assurance:"
@echo " check - Run all checks (fmt + vet + test)"
@echo " fmt - Format Go code"
@echo " vet - Run go vet"
@echo " test - Run tests"
@echo ""
@echo "🛠️ Development:"
@echo " run - Run application locally"
@echo " clean - Remove all build artifacts"
@echo " deps-check - Verify and tidy dependencies"
@echo " version - Show build information"
@echo " install - Install to system"
@echo ""
@echo "📁 Directory structure:"
@echo " build/ - Intermediate build artifacts"
build:
@echo "Building $(BINARY_NAME) for current architecture..."
@mkdir -p $(BUILD_DIR)
go build -ldflags="$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME) .
@echo "✅ Build complete: $(BUILD_DIR)/$(BINARY_NAME)"
clean:
@echo "🧹 Cleaning build artifacts..."
@rm -rf $(BUILD_DIR) $(DIST_DIR) $(DEB_DIR)-* $(DEB_DIR)
@go clean
@echo "✅ Clean complete"
test:
@echo "🧪 Running tests..."
@go test -v ./...
run:
@echo "🚀 Running $(BINARY_NAME)..."
@go run -ldflags="$(LDFLAGS)" .
# Code quality targets
fmt:
@echo "🎨 Formatting code..."
@go fmt ./...
@echo "✅ Format complete"
vet:
@echo "🔍 Running go vet..."
@go vet ./...
@echo "✅ Vet complete"
check: fmt vet test
@echo "✅ All checks passed"
# Dependency management
deps-check:
@echo "📦 Checking dependencies..."
@go mod verify
@go mod tidy
@echo "✅ Dependencies verified"
# Version information
version:
@echo "Version: $(VERSION)"
@echo "Git Commit: $(GIT_COMMIT)"
@echo "Build Time: $(BUILD_TIME)"
@echo "Go Version: $(GO_VERSION)"
dev:
go run . &
echo "Server started on http://localhost:5055"

View File

@@ -0,0 +1 @@
# Wild-cloud API Backend Service

Binary file not shown.

View File

@@ -0,0 +1,8 @@
module wild-cloud-central
go 1.21
require (
github.com/gorilla/mux v1.8.1
gopkg.in/yaml.v3 v3.0.1
)

View File

@@ -0,0 +1,6 @@
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,94 @@
package config
import (
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
// Config represents the main configuration structure
type Config struct {
Wildcloud struct {
Repository string `yaml:"repository" json:"repository"`
CurrentPhase string `yaml:"currentPhase" json:"currentPhase"`
CompletedPhases []string `yaml:"completedPhases" json:"completedPhases"`
} `yaml:"wildcloud" json:"wildcloud"`
Server struct {
Port int `yaml:"port" json:"port"`
Host string `yaml:"host" json:"host"`
} `yaml:"server" json:"server"`
Cloud struct {
Domain string `yaml:"domain" json:"domain"`
InternalDomain string `yaml:"internalDomain" json:"internalDomain"`
DNS struct {
IP string `yaml:"ip" json:"ip"`
} `yaml:"dns" json:"dns"`
Router struct {
IP string `yaml:"ip" json:"ip"`
} `yaml:"router" json:"router"`
DHCPRange string `yaml:"dhcpRange" json:"dhcpRange"`
Dnsmasq struct {
Interface string `yaml:"interface" json:"interface"`
} `yaml:"dnsmasq" json:"dnsmasq"`
} `yaml:"cloud" json:"cloud"`
Cluster struct {
EndpointIP string `yaml:"endpointIp" json:"endpointIp"`
Nodes struct {
Talos struct {
Version string `yaml:"version" json:"version"`
} `yaml:"talos" json:"talos"`
} `yaml:"nodes" json:"nodes"`
} `yaml:"cluster" json:"cluster"`
}
// Load loads configuration from the specified path
func Load(configPath string) (*Config, error) {
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("reading config file %s: %w", configPath, err)
}
config := &Config{}
if err := yaml.Unmarshal(data, config); err != nil {
return nil, fmt.Errorf("parsing config file: %w", err)
}
// Set defaults
if config.Server.Port == 0 {
config.Server.Port = 5055
}
if config.Server.Host == "" {
config.Server.Host = "0.0.0.0"
}
return config, nil
}
// Save saves the configuration to the specified path
func Save(config *Config, configPath string) error {
// Ensure the directory exists
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
return fmt.Errorf("creating config directory: %w", err)
}
data, err := yaml.Marshal(config)
if err != nil {
return fmt.Errorf("marshaling config: %w", err)
}
return os.WriteFile(configPath, data, 0644)
}
// IsEmpty checks if the configuration is empty or uninitialized
func (c *Config) IsEmpty() bool {
if c == nil {
return true
}
// Check if any essential fields are empty
return c.Cloud.Domain == "" ||
c.Cloud.DNS.IP == "" ||
c.Cluster.Nodes.Talos.Version == ""
}

View File

@@ -0,0 +1,130 @@
package data
import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
)
// Paths represents the data directory paths configuration
type Paths struct {
ConfigFile string
DataDir string
LogsDir string
AssetsDir string
DnsmasqConf string
}
// Manager handles data directory management
type Manager struct {
dataDir string
isDev bool
}
// NewManager creates a new data manager
func NewManager() *Manager {
return &Manager{}
}
// Initialize sets up the data directory structure
func (m *Manager) Initialize() error {
// Detect environment: development vs production
m.isDev = m.isDevelopmentMode()
var dataDir string
if m.isDev {
// Development mode: use .wildcloud in current directory
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("failed to get current directory: %w", err)
}
dataDir = filepath.Join(cwd, ".wildcloud")
log.Printf("Running in development mode, using data directory: %s", dataDir)
} else {
// Production mode: use standard Linux directories
dataDir = "/var/lib/wild-cloud-central"
log.Printf("Running in production mode, using data directory: %s", dataDir)
}
m.dataDir = dataDir
// Create directory structure
paths := m.GetPaths()
// Create all necessary directories
for _, dir := range []string{paths.DataDir, paths.LogsDir, paths.AssetsDir} {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", dir, err)
}
}
log.Printf("Data directory structure initialized at: %s", dataDir)
return nil
}
// isDevelopmentMode detects if we're running in development mode
func (m *Manager) isDevelopmentMode() bool {
// Check multiple indicators for development mode
// 1. Check if GO_ENV is set to development
if env := os.Getenv("GO_ENV"); env == "development" {
return true
}
// 2. Check if running as systemd service (has INVOCATION_ID)
if os.Getenv("INVOCATION_ID") != "" {
return false // Running under systemd
}
// 3. Check if running from a typical development location
if exe, err := os.Executable(); err == nil {
// If executable is in current directory or contains "wild-central" without being in /usr/bin
if strings.Contains(exe, "/usr/bin") || strings.Contains(exe, "/usr/local/bin") {
return false
}
if filepath.Base(exe) == "wild-central" && !strings.HasPrefix(exe, "/") {
return true
}
}
// 4. Check if we can write to /var/lib (if not, probably development)
if _, err := os.Stat("/var/lib"); err != nil {
return true
}
// 5. Default to development if uncertain
return true
}
// GetPaths returns the appropriate paths for the current environment
func (m *Manager) GetPaths() Paths {
if m.isDev {
return Paths{
ConfigFile: filepath.Join(m.dataDir, "config.yaml"),
DataDir: m.dataDir,
LogsDir: filepath.Join(m.dataDir, "logs"),
AssetsDir: filepath.Join(m.dataDir, "assets"),
DnsmasqConf: filepath.Join(m.dataDir, "dnsmasq.conf"),
}
} else {
return Paths{
ConfigFile: "/etc/wild-cloud-central/config.yaml",
DataDir: m.dataDir,
LogsDir: "/var/log/wild-cloud-central",
AssetsDir: "/var/www/html/wild-central",
DnsmasqConf: "/etc/dnsmasq.conf",
}
}
}
// GetDataDir returns the current data directory
func (m *Manager) GetDataDir() string {
return m.dataDir
}
// IsDevelopment returns true if running in development mode
func (m *Manager) IsDevelopment() bool {
return m.isDev
}

View File

@@ -0,0 +1,97 @@
package dnsmasq
import (
"fmt"
"log"
"os"
"os/exec"
"wild-cloud-central/internal/config"
)
// ConfigGenerator handles dnsmasq configuration generation
type ConfigGenerator struct{}
// NewConfigGenerator creates a new dnsmasq config generator
func NewConfigGenerator() *ConfigGenerator {
return &ConfigGenerator{}
}
// Generate creates a dnsmasq configuration from the app config
func (g *ConfigGenerator) Generate(cfg *config.Config) string {
template := `# Configuration file for dnsmasq.
# Basic Settings
interface=%s
listen-address=%s
domain-needed
bogus-priv
no-resolv
# DNS Local Resolution - Central server handles these domains authoritatively
local=/%s/
address=/%s/%s
local=/%s/
address=/%s/%s
server=1.1.1.1
server=8.8.8.8
# --- DHCP Settings ---
dhcp-range=%s,12h
dhcp-option=3,%s
dhcp-option=6,%s
# --- PXE Booting ---
enable-tftp
tftp-root=/var/ftpd
dhcp-match=set:efi-x86_64,option:client-arch,7
dhcp-boot=tag:efi-x86_64,ipxe.efi
dhcp-boot=tag:!efi-x86_64,undionly.kpxe
dhcp-match=set:efi-arm64,option:client-arch,11
dhcp-boot=tag:efi-arm64,ipxe-arm64.efi
dhcp-userclass=set:ipxe,iPXE
dhcp-boot=tag:ipxe,http://%s/boot.ipxe
log-queries
log-dhcp
`
return fmt.Sprintf(template,
cfg.Cloud.Dnsmasq.Interface,
cfg.Cloud.DNS.IP,
cfg.Cloud.Domain,
cfg.Cloud.Domain,
cfg.Cluster.EndpointIP,
cfg.Cloud.InternalDomain,
cfg.Cloud.InternalDomain,
cfg.Cluster.EndpointIP,
cfg.Cloud.DHCPRange,
cfg.Cloud.Router.IP,
cfg.Cloud.DNS.IP,
cfg.Cloud.DNS.IP,
)
}
// WriteConfig writes the dnsmasq configuration to the specified path
func (g *ConfigGenerator) WriteConfig(cfg *config.Config, configPath string) error {
configContent := g.Generate(cfg)
log.Printf("Writing dnsmasq config to: %s", configPath)
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
return fmt.Errorf("writing dnsmasq config: %w", err)
}
return nil
}
// RestartService restarts the dnsmasq service
func (g *ConfigGenerator) RestartService() error {
cmd := exec.Command("sudo", "/usr/bin/systemctl", "restart", "dnsmasq.service")
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to restart dnsmasq: %w", err)
}
return nil
}

View File

@@ -0,0 +1,45 @@
package handlers
import (
"encoding/json"
"log"
"net/http"
)
// GetDnsmasqConfigHandler handles requests to view the dnsmasq configuration
func (app *App) GetDnsmasqConfigHandler(w http.ResponseWriter, r *http.Request) {
if app.Config == nil || app.Config.IsEmpty() {
http.Error(w, "No configuration available. Please configure the system first.", http.StatusPreconditionFailed)
return
}
config := app.DnsmasqManager.Generate(app.Config)
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte(config))
}
// RestartDnsmasqHandler handles requests to restart the dnsmasq service
func (app *App) RestartDnsmasqHandler(w http.ResponseWriter, r *http.Request) {
if app.Config == nil || app.Config.IsEmpty() {
http.Error(w, "No configuration available. Please configure the system first.", http.StatusPreconditionFailed)
return
}
// Update dnsmasq config first
paths := app.DataManager.GetPaths()
if err := app.DnsmasqManager.WriteConfig(app.Config, paths.DnsmasqConf); err != nil {
log.Printf("Failed to update dnsmasq config: %v", err)
http.Error(w, "Failed to update dnsmasq config", http.StatusInternalServerError)
return
}
// Restart dnsmasq service
if err := app.DnsmasqManager.RestartService(); err != nil {
log.Printf("Failed to restart dnsmasq: %v", err)
http.Error(w, "Failed to restart dnsmasq service", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "restarted"})
}

View File

@@ -0,0 +1,263 @@
package handlers
import (
"encoding/json"
"io"
"log"
"net/http"
"os"
"time"
"wild-cloud-central/internal/config"
"wild-cloud-central/internal/data"
"wild-cloud-central/internal/dnsmasq"
)
// App represents the application with its dependencies
type App struct {
Config *config.Config
StartTime time.Time
DataManager *data.Manager
DnsmasqManager *dnsmasq.ConfigGenerator
}
// NewApp creates a new application instance
func NewApp() *App {
return &App{
StartTime: time.Now(),
DataManager: data.NewManager(),
DnsmasqManager: dnsmasq.NewConfigGenerator(),
}
}
// HealthHandler handles health check requests
func (app *App) HealthHandler(w http.ResponseWriter, r *http.Request) {
response := map[string]string{
"status": "healthy",
"service": "wild-cloud-central",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// StatusHandler handles status requests for the UI
func (app *App) StatusHandler(w http.ResponseWriter, r *http.Request) {
uptime := time.Since(app.StartTime)
response := map[string]interface{}{
"status": "running",
"version": "1.0.0",
"uptime": uptime.String(),
"timestamp": time.Now().UnixMilli(),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
// GetConfigHandler handles configuration retrieval requests
func (app *App) GetConfigHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
// Always reload config from file on each request
paths := app.DataManager.GetPaths()
cfg, err := config.Load(paths.ConfigFile)
if err != nil {
log.Printf("Failed to load config from file: %v", err)
response := map[string]interface{}{
"configured": false,
"message": "No configuration found. Please POST a configuration to /api/v1/config to get started.",
}
json.NewEncoder(w).Encode(response)
return
}
// Update the cached config with fresh data
app.Config = cfg
// Check if config is empty/uninitialized
if cfg.IsEmpty() {
response := map[string]interface{}{
"configured": false,
"message": "Configuration is incomplete. Please complete the setup.",
}
json.NewEncoder(w).Encode(response)
return
}
response := map[string]interface{}{
"configured": true,
"config": cfg,
}
json.NewEncoder(w).Encode(response)
}
// CreateConfigHandler handles configuration creation requests
func (app *App) CreateConfigHandler(w http.ResponseWriter, r *http.Request) {
// Only allow config creation if no config exists
if app.Config != nil && !app.Config.IsEmpty() {
http.Error(w, "Configuration already exists. Use PUT to update.", http.StatusConflict)
return
}
var newConfig config.Config
if err := json.NewDecoder(r.Body).Decode(&newConfig); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Set defaults
if newConfig.Server.Port == 0 {
newConfig.Server.Port = 5055
}
if newConfig.Server.Host == "" {
newConfig.Server.Host = "0.0.0.0"
}
app.Config = &newConfig
// Persist config to file
paths := app.DataManager.GetPaths()
if err := config.Save(app.Config, paths.ConfigFile); err != nil {
log.Printf("Failed to save config: %v", err)
http.Error(w, "Failed to save config", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "created"})
}
// UpdateConfigHandler handles configuration update requests
func (app *App) UpdateConfigHandler(w http.ResponseWriter, r *http.Request) {
// Check if config exists
if app.Config == nil || app.Config.IsEmpty() {
http.Error(w, "No configuration exists. Use POST to create initial configuration.", http.StatusNotFound)
return
}
var newConfig config.Config
if err := json.NewDecoder(r.Body).Decode(&newConfig); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
app.Config = &newConfig
// Persist config to file
paths := app.DataManager.GetPaths()
if err := config.Save(app.Config, paths.ConfigFile); err != nil {
log.Printf("Failed to save config: %v", err)
http.Error(w, "Failed to save config", http.StatusInternalServerError)
return
}
// Regenerate and apply dnsmasq config
if err := app.DnsmasqManager.WriteConfig(app.Config, paths.DnsmasqConf); err != nil {
log.Printf("Failed to update dnsmasq config: %v", err)
http.Error(w, "Failed to update dnsmasq config", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "updated"})
}
// GetConfigYamlHandler handles raw YAML config file retrieval
func (app *App) GetConfigYamlHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
paths := app.DataManager.GetPaths()
// Read the raw config file
yamlContent, err := os.ReadFile(paths.ConfigFile)
if err != nil {
if os.IsNotExist(err) {
http.Error(w, "Configuration file not found", http.StatusNotFound)
return
}
log.Printf("Failed to read config file: %v", err)
http.Error(w, "Failed to read configuration file", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write(yamlContent)
}
// UpdateConfigYamlHandler handles raw YAML config file updates
func (app *App) UpdateConfigYamlHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Read the raw YAML content from request body
yamlContent, err := io.ReadAll(r.Body)
if err != nil {
log.Printf("Failed to read request body: %v", err)
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
paths := app.DataManager.GetPaths()
// Write the raw YAML content to file
if err := os.WriteFile(paths.ConfigFile, yamlContent, 0644); err != nil {
log.Printf("Failed to write config file: %v", err)
http.Error(w, "Failed to write configuration file", http.StatusInternalServerError)
return
}
// Try to reload the config to validate it and update the in-memory config
newConfig, err := config.Load(paths.ConfigFile)
if err != nil {
log.Printf("Warning: Saved YAML config but failed to parse it: %v", err)
// File was written but parsing failed - this is a validation warning
w.Header().Set("Content-Type", "application/json")
response := map[string]interface{}{
"status": "saved_with_warnings",
"warning": "Configuration saved but contains validation errors: " + err.Error(),
}
json.NewEncoder(w).Encode(response)
return
}
// Update in-memory config if parsing succeeded
app.Config = newConfig
// Try to regenerate dnsmasq config if the new config is valid
if err := app.DnsmasqManager.WriteConfig(app.Config, paths.DnsmasqConf); err != nil {
log.Printf("Warning: Failed to update dnsmasq config: %v", err)
// Config was saved but dnsmasq update failed
w.Header().Set("Content-Type", "application/json")
response := map[string]interface{}{
"status": "saved_with_warnings",
"warning": "Configuration saved but failed to update dnsmasq config: " + err.Error(),
}
json.NewEncoder(w).Encode(response)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "updated"})
}
// CORSMiddleware adds CORS headers to responses
func (app *App) CORSMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,138 @@
package handlers
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
)
// DownloadPXEAssetsHandler handles requests to download PXE boot assets
func (app *App) DownloadPXEAssetsHandler(w http.ResponseWriter, r *http.Request) {
if app.Config == nil || app.Config.IsEmpty() {
http.Error(w, "No configuration available. Please configure the system first.", http.StatusPreconditionFailed)
return
}
if err := app.downloadTalosAssets(); err != nil {
log.Printf("Failed to download PXE assets: %v", err)
http.Error(w, "Failed to download PXE assets", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "downloaded"})
}
// downloadTalosAssets downloads Talos Linux PXE assets
func (app *App) downloadTalosAssets() error {
// Get assets directory from data paths
paths := app.DataManager.GetPaths()
assetsDir := filepath.Join(paths.AssetsDir, "talos")
log.Printf("Downloading Talos assets to: %s", assetsDir)
if err := os.MkdirAll(filepath.Join(assetsDir, "amd64"), 0755); err != nil {
return fmt.Errorf("creating assets directory: %w", err)
}
// Create Talos bare metal configuration (schematic format)
bareMetalConfig := `customization:
extraKernelArgs:
- net.ifnames=0
systemExtensions:
officialExtensions:
- siderolabs/gvisor
- siderolabs/intel-ucode`
// Create Talos schematic
var buf bytes.Buffer
buf.WriteString(bareMetalConfig)
resp, err := http.Post("https://factory.talos.dev/schematics", "text/yaml", &buf)
if err != nil {
return fmt.Errorf("creating Talos schematic: %w", err)
}
defer resp.Body.Close()
var schematic struct {
ID string `json:"id"`
}
if err := json.NewDecoder(resp.Body).Decode(&schematic); err != nil {
return fmt.Errorf("decoding schematic response: %w", err)
}
log.Printf("Created Talos schematic with ID: %s", schematic.ID)
// Download kernel
kernelURL := fmt.Sprintf("https://pxe.factory.talos.dev/image/%s/%s/kernel-amd64",
schematic.ID, app.Config.Cluster.Nodes.Talos.Version)
if err := downloadFile(kernelURL, filepath.Join(assetsDir, "amd64", "vmlinuz")); err != nil {
return fmt.Errorf("downloading kernel: %w", err)
}
// Download initramfs
initramfsURL := fmt.Sprintf("https://pxe.factory.talos.dev/image/%s/%s/initramfs-amd64.xz",
schematic.ID, app.Config.Cluster.Nodes.Talos.Version)
if err := downloadFile(initramfsURL, filepath.Join(assetsDir, "amd64", "initramfs.xz")); err != nil {
return fmt.Errorf("downloading initramfs: %w", err)
}
// Create boot.ipxe file
bootScript := fmt.Sprintf(`#!ipxe
imgfree
kernel http://%s/amd64/vmlinuz talos.platform=metal console=tty0 init_on_alloc=1 slab_nomerge pti=on consoleblank=0 nvme_core.io_timeout=4294967295 printk.devkmsg=on ima_template=ima-ng ima_appraise=fix ima_hash=sha512 selinux=1 net.ifnames=0
initrd http://%s/amd64/initramfs.xz
boot
`, app.Config.Cloud.DNS.IP, app.Config.Cloud.DNS.IP)
if err := os.WriteFile(filepath.Join(assetsDir, "boot.ipxe"), []byte(bootScript), 0644); err != nil {
return fmt.Errorf("writing boot script: %w", err)
}
// Download iPXE bootloaders
tftpDir := filepath.Join(paths.AssetsDir, "tftp")
if err := os.MkdirAll(tftpDir, 0755); err != nil {
return fmt.Errorf("creating tftp directory: %w", err)
}
bootloaders := map[string]string{
"http://boot.ipxe.org/ipxe.efi": filepath.Join(tftpDir, "ipxe.efi"),
"http://boot.ipxe.org/undionly.kpxe": filepath.Join(tftpDir, "undionly.kpxe"),
"http://boot.ipxe.org/arm64-efi/ipxe.efi": filepath.Join(tftpDir, "ipxe-arm64.efi"),
}
for url, path := range bootloaders {
if err := downloadFile(url, path); err != nil {
return fmt.Errorf("downloading %s: %w", url, err)
}
}
log.Printf("Successfully downloaded PXE assets")
return nil
}
// downloadFile downloads a file from a URL to a local path
func downloadFile(url, filepath string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("bad status: %s", resp.Status)
}
out, err := os.Create(filepath)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}

View File

@@ -0,0 +1,74 @@
package main
import (
"fmt"
"log"
"net/http"
"github.com/gorilla/mux"
"wild-cloud-central/internal/config"
"wild-cloud-central/internal/handlers"
)
func main() {
// Create application instance
app := handlers.NewApp()
// Initialize data directory
if err := app.DataManager.Initialize(); err != nil {
log.Fatalf("Failed to initialize data directory: %v", err)
}
// Load configuration if it exists
paths := app.DataManager.GetPaths()
if cfg, err := config.Load(paths.ConfigFile); err != nil {
log.Printf("No configuration found, starting with empty config: %v", err)
} else {
app.Config = cfg
log.Printf("Configuration loaded successfully")
}
// Set up HTTP router
router := mux.NewRouter()
setupRoutes(app, router)
// Use default server settings if config is empty
host := "0.0.0.0"
port := 5055
if app.Config != nil && app.Config.Server.Host != "" {
host = app.Config.Server.Host
}
if app.Config != nil && app.Config.Server.Port != 0 {
port = app.Config.Server.Port
}
addr := fmt.Sprintf("%s:%d", host, port)
log.Printf("Starting wild-cloud-central server on %s", addr)
if err := http.ListenAndServe(addr, router); err != nil {
log.Fatal("Server failed to start:", err)
}
}
func setupRoutes(app *handlers.App, router *mux.Router) {
// Add CORS middleware
router.Use(app.CORSMiddleware)
// API v1 routes
router.HandleFunc("/api/v1/health", app.HealthHandler).Methods("GET")
router.HandleFunc("/api/v1/config", app.GetConfigHandler).Methods("GET")
router.HandleFunc("/api/v1/config", app.UpdateConfigHandler).Methods("PUT")
router.HandleFunc("/api/v1/config", app.CreateConfigHandler).Methods("POST")
router.HandleFunc("/api/v1/config/yaml", app.GetConfigYamlHandler).Methods("GET")
router.HandleFunc("/api/v1/config/yaml", app.UpdateConfigYamlHandler).Methods("PUT")
router.HandleFunc("/api/v1/dnsmasq/config", app.GetDnsmasqConfigHandler).Methods("GET")
router.HandleFunc("/api/v1/dnsmasq/restart", app.RestartDnsmasqHandler).Methods("POST")
router.HandleFunc("/api/v1/pxe/assets", app.DownloadPXEAssetsHandler).Methods("POST")
// UI-specific endpoints
router.HandleFunc("/api/status", app.StatusHandler).Methods("GET")
// Serve static files
router.PathPrefix("/").Handler(http.FileServer(http.Dir("./static/")))
}

View File

@@ -0,0 +1,39 @@
#!/bin/bash
set -e
echo "🐳 Starting wild-cloud-central debug container..."
# Build the Docker image if it doesn't exist
if ! docker images | grep -q wild-cloud-central-test; then
echo "🔨 Building Docker image..."
docker build -t wild-cloud-central-test .
fi
echo ""
echo "🔧 Starting container with shell access..."
echo ""
echo "📍 Access points:"
echo " - Management UI: http://localhost:9080"
echo " - API directly: http://localhost:9081"
echo ""
echo "💡 Inside the container you can:"
echo " - Start services manually: /test-installation.sh"
echo " - Check logs: journalctl or service status"
echo " - Test APIs: curl http://localhost:5055/api/v1/health"
echo " - Modify config: nano /etc/wild-cloud-central/config.yaml"
echo " - View web files: ls /var/www/html/wild-central/"
echo ""
# Run container with shell access
docker run --rm -it \
-p 127.0.0.1:9081:5055 \
-p 127.0.0.1:9080:80 \
-p 127.0.0.1:9053:53/udp \
-p 127.0.0.1:9067:67/udp \
-p 127.0.0.1:9069:69/udp \
--cap-add=NET_ADMIN \
--cap-add=NET_BIND_SERVICE \
--name wild-central-debug \
wild-cloud-central-test \
/bin/bash

View File

@@ -0,0 +1,69 @@
#!/bin/bash
set -e
echo "🚀 Starting wild-cloud-central in background..."
# Build the Docker image if it doesn't exist
if ! docker images | grep -q wild-cloud-central-test; then
echo "🔨 Building Docker image..."
docker build -t wild-cloud-central-test .
fi
# Stop any existing container
docker rm -f wild-central-bg 2>/dev/null || true
echo "🌐 Starting services in background..."
# Start container in background
docker run -d \
--name wild-central-bg \
-p 127.0.0.1:9081:5055 \
-p 127.0.0.1:9080:80 \
-p 127.0.0.1:9053:53/udp \
-p 127.0.0.1:9067:67/udp \
-p 127.0.0.1:9069:69/udp \
--cap-add=NET_ADMIN \
--cap-add=NET_BIND_SERVICE \
wild-cloud-central-test \
/bin/bash -c '
# Start nginx
nginx &
# Start dnsmasq
dnsmasq --keep-in-foreground --log-facility=- &
# Start wild-cloud-central
/usr/bin/wild-cloud-central &
# Wait indefinitely
tail -f /dev/null
'
echo "⏳ Waiting for services to start..."
sleep 5
# Test if services are running
if curl -s http://localhost:9081/api/v1/health > /dev/null 2>&1; then
echo "✅ Services started successfully!"
echo ""
echo "📍 Access points (localhost only):"
echo " - Management UI: http://localhost:9080"
echo " - API: http://localhost:9081/api/v1/health"
echo " - DNS: localhost:9053 (for testing)"
echo " - DHCP: localhost:9067 (for testing)"
echo " - TFTP: localhost:9069 (for testing)"
echo ""
echo "🔧 Container management:"
echo " - View logs: docker logs wild-central-bg"
echo " - Stop services: docker stop wild-central-bg"
echo " - Remove container: docker rm wild-central-bg"
echo ""
echo "💡 Test commands:"
echo " curl http://localhost:9081/api/v1/health"
echo " dig @localhost -p 9053 wildcloud.local"
echo " curl http://localhost:9081/api/v1/dnsmasq/config"
else
echo "❌ Services failed to start. Check logs with: docker logs wild-central-bg"
exit 1
fi

View File

@@ -0,0 +1,92 @@
#!/bin/bash
set -e
echo "🚀 Starting wild-cloud-central for interactive testing..."
# Build the Docker image if it doesn't exist
if ! docker images | grep -q wild-cloud-central-test; then
echo "🔨 Building Docker image..."
docker build -t wild-cloud-central-test .
fi
echo ""
echo "🌐 Starting services... This will take a few seconds."
echo ""
echo "📍 Access points:"
echo " - Management UI: http://localhost:9080"
echo " - API directly: http://localhost:9081"
echo " - Health check: http://localhost:9081/api/v1/health"
echo ""
echo "🔧 Available API endpoints:"
echo " - GET /api/v1/health"
echo " - GET /api/v1/config"
echo " - PUT /api/v1/config"
echo " - GET /api/v1/dnsmasq/config"
echo " - POST /api/v1/dnsmasq/restart"
echo " - POST /api/v1/pxe/assets"
echo ""
echo "💡 Example commands to try:"
echo " curl http://localhost:9081/api/v1/health"
echo " curl http://localhost:9081/api/v1/config"
echo " curl http://localhost:9081/api/v1/dnsmasq/config"
echo " curl -X POST http://localhost:9081/api/v1/pxe/assets"
echo ""
echo "🛑 Press Ctrl+C to stop all services"
echo ""
# Create a custom startup script that keeps services running
docker run --rm -it \
-p 127.0.0.1:9081:5055 \
-p 127.0.0.1:9080:80 \
-p 127.0.0.1:9053:53/udp \
-p 127.0.0.1:9067:67/udp \
-p 127.0.0.1:9069:69/udp \
--cap-add=NET_ADMIN \
--cap-add=NET_BIND_SERVICE \
--name wild-central-interactive \
wild-cloud-central-test \
/bin/bash -c '
echo "🔧 Starting all services..."
# Start nginx
nginx &
NGINX_PID=$!
# Start dnsmasq
dnsmasq --keep-in-foreground --log-facility=- &
DNSMASQ_PID=$!
# Start wild-cloud-central
/usr/bin/wild-cloud-central &
SERVICE_PID=$!
# Wait for services to start
sleep 3
echo "✅ All services started!"
echo " - nginx (PID: $NGINX_PID)"
echo " - dnsmasq (PID: $DNSMASQ_PID)"
echo " - wild-cloud-central (PID: $SERVICE_PID)"
echo ""
echo "🌐 Services are now available:"
echo " - Web UI: http://localhost:9080"
echo " - API: http://localhost:9081"
echo ""
# Function to handle shutdown
shutdown() {
echo ""
echo "🛑 Shutting down services..."
kill $SERVICE_PID $DNSMASQ_PID $NGINX_PID 2>/dev/null || true
echo "✅ Shutdown complete."
exit 0
}
# Set up signal handlers
trap shutdown SIGTERM SIGINT
# Keep container running and wait for signals
echo "✨ Container is ready! Press Ctrl+C to stop."
wait
'

View File

@@ -0,0 +1,11 @@
#!/bin/bash
echo "🛑 Stopping wild-cloud-central background services..."
if docker ps | grep -q wild-central-bg; then
docker stop wild-central-bg
docker rm wild-central-bg
echo "✅ Services stopped and container removed."
else
echo " No background services running."
fi

View File

@@ -0,0 +1,20 @@
#!/bin/bash
set -e
echo "🧪 Testing wild-cloud-central Docker installation..."
# Change to project root directory
cd "$(dirname "$0")/../.."
# Build the Docker image
echo "🔨 Building Docker image..."
docker build -t wild-cloud-central-test .
# Run the container to test installation
echo "🚀 Running installation test..."
echo "Access points after container starts:"
echo " - Management UI: http://localhost:9080"
echo " - API directly: http://localhost:9055"
echo ""
docker run --rm -p 9055:5055 -p 9080:80 wild-cloud-central-test

View File

@@ -0,0 +1,146 @@
#!/bin/bash
set -e
echo "🚀 Testing wild-cloud-central installation..."
# Verify the binary was installed
echo "✅ Checking binary installation..."
if [ -f "/usr/bin/wild-cloud-central" ]; then
echo " Binary installed at /usr/bin/wild-cloud-central"
else
echo "❌ Binary not found at /usr/bin/wild-cloud-central"
exit 1
fi
# Verify config was installed
echo "✅ Checking configuration..."
if [ -f "/etc/wild-cloud-central/config.yaml" ]; then
echo " Config installed at /etc/wild-cloud-central/config.yaml"
else
echo "❌ Config not found at /etc/wild-cloud-central/config.yaml"
exit 1
fi
# Verify systemd service file was installed
echo "✅ Checking systemd service..."
if [ -f "/etc/systemd/system/wild-cloud-central.service" ]; then
echo " Service file installed at /etc/systemd/system/wild-cloud-central.service"
else
echo "❌ Service file not found"
exit 1
fi
# Verify nginx config was installed
echo "✅ Checking nginx configuration..."
if [ -f "/etc/nginx/sites-available/wild-central" ]; then
echo " Nginx config installed at /etc/nginx/sites-available/wild-central"
# Enable the site for testing
ln -sf /etc/nginx/sites-available/wild-central /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
else
echo "❌ Nginx config not found"
exit 1
fi
# Verify web assets were installed
echo "✅ Checking web assets..."
if [ -f "/var/www/html/wild-central/index.html" ]; then
echo " Web assets installed at /var/www/html/wild-central/"
else
echo "❌ Web assets not found"
exit 1
fi
# Start nginx (simulating systemd)
echo "🔧 Starting nginx..."
nginx &
NGINX_PID=$!
# Start dnsmasq (simulating systemd)
echo "🔧 Starting dnsmasq..."
dnsmasq --keep-in-foreground --log-facility=- &
DNSMASQ_PID=$!
# Start wild-cloud-central service (simulating systemd)
echo "🔧 Starting wild-cloud-central service..."
/usr/bin/wild-cloud-central &
SERVICE_PID=$!
# Wait for service to start
echo "⏳ Waiting for services to start..."
sleep 5
# Test health endpoint
echo "🩺 Testing health endpoint..."
if curl -s http://localhost:5055/api/v1/health | grep -q "healthy"; then
echo " ✅ Health check passed"
else
echo " ❌ Health check failed"
exit 1
fi
# Test configuration endpoint
echo "🔧 Testing configuration endpoint..."
CONFIG_RESPONSE=$(curl -s http://localhost:5055/api/v1/config)
if echo "$CONFIG_RESPONSE" | grep -q "Server"; then
echo " ✅ Configuration endpoint working"
else
echo " ❌ Configuration endpoint failed"
echo " Response: $CONFIG_RESPONSE"
echo " Checking if service is still running..."
if kill -0 $SERVICE_PID 2>/dev/null; then
echo " Service is running"
else
echo " Service has died"
fi
exit 1
fi
# Test dnsmasq config generation
echo "🔧 Testing dnsmasq config generation..."
if curl -s http://localhost:5055/api/v1/dnsmasq/config | grep -q "interface"; then
echo " ✅ Dnsmasq config generation working"
else
echo " ❌ Dnsmasq config generation failed"
exit 1
fi
# Test web interface accessibility (through nginx)
echo "🌐 Testing web interface..."
if curl -s http://localhost:80/ | grep -q "Wild Cloud Central"; then
echo " ✅ Web interface accessible through nginx"
else
echo " ❌ Web interface not accessible"
exit 1
fi
echo ""
echo "🎉 All installation tests passed!"
echo ""
echo "Services running:"
echo " - wild-cloud-central: http://localhost:5055"
echo " - Web interface: http://localhost:80"
echo " - API health: http://localhost:5055/api/v1/health"
echo ""
echo "Installation simulation successful! 🚀"
# Keep services running for manual testing
echo "Services will continue running. Press Ctrl+C to stop."
# Function to handle shutdown
shutdown() {
echo ""
echo "🛑 Shutting down services..."
kill $SERVICE_PID 2>/dev/null || true
kill $DNSMASQ_PID 2>/dev/null || true
kill $NGINX_PID 2>/dev/null || true
echo "Shutdown complete."
exit 0
}
# Set up signal handlers
trap shutdown SIGTERM SIGINT
# Wait for signals
wait