From e5bd3c36f5c585a985ece1a05089b8f1837af4fb Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Sun, 12 Oct 2025 17:44:54 +0000 Subject: [PATCH 1/2] First swing. --- .env.example | 3 + .gitignore | 3 + README.md | 58 +- docs/specs/routing-contract.md | 470 +++++++++++++++ package.json | 6 +- pnpm-lock.yaml | 219 +++++++ src/App.tsx | 140 +---- src/components/AppSidebar.tsx | 387 ++++-------- src/components/AppsComponent.tsx | 561 +++++++++++------- src/components/BackupRestoreModal.tsx | 158 +++++ src/components/CentralComponent.tsx | 210 +++++-- src/components/CloudComponent.tsx | 314 +++++++++- src/components/ClusterNodesComponent.tsx | 480 ++++++++------- src/components/ClusterServicesComponent.tsx | 363 ++++++------ src/components/ConfigEditor.tsx | 2 +- src/components/ConfigViewer.tsx | 17 + src/components/ConfigurationForm.tsx | 2 +- src/components/CopyButton.tsx | 49 ++ src/components/DhcpComponent.tsx | 2 +- src/components/DownloadButton.tsx | 41 ++ src/components/InstanceSelector.tsx | 136 +++++ src/components/SecretInput.tsx | 53 ++ src/components/ServiceCard.tsx | 84 +++ src/components/UtilityCard.tsx | 120 ++++ src/components/apps/AppConfigDialog.tsx | 173 ++++++ src/components/index.ts | 7 +- src/components/operations/HealthIndicator.tsx | 65 ++ src/components/operations/NodeStatusCard.tsx | 97 +++ src/components/operations/OperationCard.tsx | 149 +++++ .../operations/OperationProgress.tsx | 204 +++++++ src/components/operations/index.ts | 4 + src/components/ui/badge.tsx | 4 + src/hooks/__tests__/useConfig.test.ts | 2 +- src/hooks/__tests__/useStatus.test.ts | 2 +- src/hooks/index.ts | 15 +- src/hooks/useApps.ts | 110 ++++ src/hooks/useAssets.ts | 2 +- src/hooks/useBaseServices.ts | 40 ++ src/hooks/useCentralStatus.ts | 31 + src/hooks/useCluster.ts | 83 +++ src/hooks/useClusterAccess.ts | 31 + src/hooks/useConfig.ts | 2 +- src/hooks/useConfigYaml.ts | 2 +- src/hooks/useDnsmasq.ts | 2 +- src/hooks/useHealth.ts | 2 +- src/hooks/useInstanceContext.tsx | 37 ++ src/hooks/useInstances.ts | 82 +++ src/hooks/useNodes.ts | 91 +++ src/hooks/useOperations.ts | 78 +++ src/hooks/useSecrets.ts | 25 + src/hooks/useServices.ts | 83 +++ src/hooks/useStatus.ts | 2 +- src/main.tsx | 9 +- src/router/InstanceLayout.tsx | 41 ++ src/router/index.tsx | 14 + src/router/pages/AdvancedPage.tsx | 10 + src/router/pages/AppsPage.tsx | 11 + src/router/pages/BaseServicesPage.tsx | 116 ++++ src/router/pages/CentralPage.tsx | 10 + src/router/pages/CloudPage.tsx | 10 + src/router/pages/ClusterAccessPage.tsx | 210 +++++++ src/router/pages/ClusterHealthPage.tsx | 211 +++++++ src/router/pages/ClusterPage.tsx | 11 + src/router/pages/DashboardPage.tsx | 243 ++++++++ src/router/pages/DhcpPage.tsx | 10 + src/router/pages/DnsPage.tsx | 10 + src/router/pages/InfrastructurePage.tsx | 11 + src/router/pages/IsoPage.tsx | 290 +++++++++ src/router/pages/LandingPage.tsx | 40 ++ src/router/pages/NotFoundPage.tsx | 30 + src/router/pages/OperationsPage.tsx | 209 +++++++ src/router/pages/PxePage.tsx | 281 +++++++++ src/router/pages/SecretsPage.tsx | 211 +++++++ src/router/pages/UtilitiesPage.tsx | 182 ++++++ src/router/routes.tsx | 111 ++++ src/services/api-legacy.ts | 92 +++ src/services/api.ts | 95 +-- src/services/api/apps.ts | 54 ++ src/services/api/client.ts | 122 ++++ src/services/api/cluster.ts | 47 ++ src/services/api/context.ts | 12 + src/services/api/dnsmasq.ts | 28 + src/services/api/hooks/useCluster.ts | 33 ++ src/services/api/hooks/useInstance.ts | 40 ++ src/services/api/hooks/useOperations.ts | 58 ++ src/services/api/hooks/usePxeAssets.ts | 57 ++ src/services/api/hooks/useUtilities.ts | 47 ++ src/services/api/index.ts | 19 + src/services/api/instances.ts | 49 ++ src/services/api/nodes.ts | 57 ++ src/services/api/operations.ts | 23 + src/services/api/pxe.ts | 29 + src/services/api/services.ts | 62 ++ src/services/api/types/app.ts | 53 ++ src/services/api/types/cluster.ts | 45 ++ src/services/api/types/config.ts | 17 + src/services/api/types/context.ts | 12 + src/services/api/types/index.ts | 9 + src/services/api/types/instance.ts | 27 + src/services/api/types/node.ts | 58 ++ src/services/api/types/operation.ts | 21 + src/services/api/types/pxe.ts | 27 + src/services/api/types/service.ts | 29 + src/services/api/utilities.ts | 41 ++ src/utils/yamlParser.ts | 14 +- vitest.config.ts | 1 - 106 files changed, 7592 insertions(+), 1270 deletions(-) create mode 100644 .env.example create mode 100644 docs/specs/routing-contract.md create mode 100644 src/components/BackupRestoreModal.tsx create mode 100644 src/components/ConfigViewer.tsx create mode 100644 src/components/CopyButton.tsx create mode 100644 src/components/DownloadButton.tsx create mode 100644 src/components/InstanceSelector.tsx create mode 100644 src/components/SecretInput.tsx create mode 100644 src/components/ServiceCard.tsx create mode 100644 src/components/UtilityCard.tsx create mode 100644 src/components/apps/AppConfigDialog.tsx create mode 100644 src/components/operations/HealthIndicator.tsx create mode 100644 src/components/operations/NodeStatusCard.tsx create mode 100644 src/components/operations/OperationCard.tsx create mode 100644 src/components/operations/OperationProgress.tsx create mode 100644 src/components/operations/index.ts create mode 100644 src/hooks/useApps.ts create mode 100644 src/hooks/useBaseServices.ts create mode 100644 src/hooks/useCentralStatus.ts create mode 100644 src/hooks/useCluster.ts create mode 100644 src/hooks/useClusterAccess.ts create mode 100644 src/hooks/useInstanceContext.tsx create mode 100644 src/hooks/useInstances.ts create mode 100644 src/hooks/useNodes.ts create mode 100644 src/hooks/useOperations.ts create mode 100644 src/hooks/useSecrets.ts create mode 100644 src/hooks/useServices.ts create mode 100644 src/router/InstanceLayout.tsx create mode 100644 src/router/index.tsx create mode 100644 src/router/pages/AdvancedPage.tsx create mode 100644 src/router/pages/AppsPage.tsx create mode 100644 src/router/pages/BaseServicesPage.tsx create mode 100644 src/router/pages/CentralPage.tsx create mode 100644 src/router/pages/CloudPage.tsx create mode 100644 src/router/pages/ClusterAccessPage.tsx create mode 100644 src/router/pages/ClusterHealthPage.tsx create mode 100644 src/router/pages/ClusterPage.tsx create mode 100644 src/router/pages/DashboardPage.tsx create mode 100644 src/router/pages/DhcpPage.tsx create mode 100644 src/router/pages/DnsPage.tsx create mode 100644 src/router/pages/InfrastructurePage.tsx create mode 100644 src/router/pages/IsoPage.tsx create mode 100644 src/router/pages/LandingPage.tsx create mode 100644 src/router/pages/NotFoundPage.tsx create mode 100644 src/router/pages/OperationsPage.tsx create mode 100644 src/router/pages/PxePage.tsx create mode 100644 src/router/pages/SecretsPage.tsx create mode 100644 src/router/pages/UtilitiesPage.tsx create mode 100644 src/router/routes.tsx create mode 100644 src/services/api-legacy.ts create mode 100644 src/services/api/apps.ts create mode 100644 src/services/api/client.ts create mode 100644 src/services/api/cluster.ts create mode 100644 src/services/api/context.ts create mode 100644 src/services/api/dnsmasq.ts create mode 100644 src/services/api/hooks/useCluster.ts create mode 100644 src/services/api/hooks/useInstance.ts create mode 100644 src/services/api/hooks/useOperations.ts create mode 100644 src/services/api/hooks/usePxeAssets.ts create mode 100644 src/services/api/hooks/useUtilities.ts create mode 100644 src/services/api/index.ts create mode 100644 src/services/api/instances.ts create mode 100644 src/services/api/nodes.ts create mode 100644 src/services/api/operations.ts create mode 100644 src/services/api/pxe.ts create mode 100644 src/services/api/services.ts create mode 100644 src/services/api/types/app.ts create mode 100644 src/services/api/types/cluster.ts create mode 100644 src/services/api/types/config.ts create mode 100644 src/services/api/types/context.ts create mode 100644 src/services/api/types/index.ts create mode 100644 src/services/api/types/instance.ts create mode 100644 src/services/api/types/node.ts create mode 100644 src/services/api/types/operation.ts create mode 100644 src/services/api/types/pxe.ts create mode 100644 src/services/api/types/service.ts create mode 100644 src/services/api/utilities.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ad2dc6b --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Wild Cloud API Configuration +# This should point to your Wild Central API server +VITE_API_BASE_URL=http://localhost:5055 diff --git a/.gitignore b/.gitignore index a547bf3..a3fe099 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ dist dist-ssr *.local +# Environment variables +.env + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/README.md b/README.md index 834865d..08853d6 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,66 @@ The Wild Cloud Web App is a web-based interface for managing Wild Cloud instances. It allows users to view and control their Wild Cloud environments, including deploying applications, monitoring resources, and configuring settings. +## Prerequisites + +Before starting the web app, ensure the Wild Central API is running: + +```bash +cd ../wild-central-api +make dev +``` + +The API should be accessible at `http://localhost:5055`. + ## Development +### Initial Setup + +1. Copy the example environment file: +```bash +cp .env.example .env +``` + +2. Update `.env` if your API is running on a different host/port: +```bash +VITE_API_BASE_URL=http://localhost:5055 +``` + +3. Install dependencies: +```bash +pnpm install +``` + +4. Start the development server: ```bash pnpm run dev ``` -Test: `pnpm run check` +The web app will be available at `http://localhost:5173` (or the next available port). + +## Other Scripts + +```bash +pnpm run build # Build the project +pnpm run lint # Lint the codebase +pnpm run preview # Preview the production build +pnpm run type-check # Type check the codebase +pnpm run test # Run tests +pnpm run test:ui # Run tests with UI +pnpm run test:coverage # Run tests with coverage report +pnpm run build:css # Build the CSS using Tailwind +pnpm run check # Run lint, type-check, and tests +``` + +## Environment Variables + +### `VITE_API_BASE_URL` + +The base URL of the Wild Central API server. + +- **Default:** `http://localhost:5055` +- **Example:** `http://192.168.1.100:5055` +- **Usage:** Set in `.env` file (see `.env.example` for template) + +This variable is used by the API client to connect to the Wild Central API. If not set, it defaults to `http://localhost:5055`. + diff --git a/docs/specs/routing-contract.md b/docs/specs/routing-contract.md new file mode 100644 index 0000000..c1a65bc --- /dev/null +++ b/docs/specs/routing-contract.md @@ -0,0 +1,470 @@ +# Wild Cloud Web App Routing Contract + +## Front Matter + +**Module Name**: `routing` +**Module Type**: Infrastructure +**Version**: 1.0.0 +**Status**: Draft +**Last Updated**: 2025-10-12 +**Owner**: Wild Cloud Development Team + +### Dependencies + +- `react`: ^19.1.0 +- `react-router`: ^7.0.0 (to be added) + +### Consumers + +- All page components within Wild Cloud Web App +- Navigation components (AppSidebar) +- Instance context management +- External navigation links + +## Purpose + +This module defines the routing system for the Wild Cloud web application, providing declarative navigation between pages, URL-based state management, and integration with the existing instance context system. + +## Public API + +### Router Configuration + +The routing system provides a centralized router configuration that manages all application routes. + +#### Primary Routes + +```typescript +interface RouteDefinition { + path: string; + element: React.ComponentType; + loader?: () => Promise; + errorElement?: React.ComponentType; +} +``` + +**Root Route**: +- **Path**: `/` +- **Purpose**: Landing page and instance selector +- **Authentication**: None required +- **Data Loading**: None + +**Instance Routes**: +- **Path Pattern**: `/instances/:instanceId/*` +- **Purpose**: All instance-specific pages +- **Authentication**: Instance must exist +- **Data Loading**: Instance configuration loaded at this level + +#### Instance-Scoped Routes + +All routes under `/instances/:instanceId/`: + +| Path | Purpose | Data Dependencies | +|------|---------|-------------------| +| `dashboard` | Overview and quick status | Instance config, cluster status | +| `operations` | Operation monitoring and logs | Active operations list | +| `cluster/health` | Cluster health metrics | Node status, etcd health | +| `cluster/access` | Kubeconfig/Talosconfig download | Instance credentials | +| `secrets` | Secrets management interface | Instance secrets (redacted) | +| `services` | Base services management | Installed services list | +| `utilities` | Utilities panel | Available utilities | +| `cloud` | Cloud configuration | Cloud settings | +| `dns` | DNS management | DNS configuration | +| `dhcp` | DHCP management | DHCP configuration | +| `pxe` | PXE boot configuration | PXE assets and config | +| `infrastructure` | Cluster nodes | Node list and status | +| `cluster` | Cluster services | Kubernetes services | +| `apps` | Application management | Installed apps | +| `advanced` | Advanced settings | System configuration | + +### Navigation Hooks + +#### useNavigate + +```typescript +function useNavigate(): NavigateFunction; + +interface NavigateFunction { + (to: string | number, options?: NavigateOptions): void; +} + +interface NavigateOptions { + replace?: boolean; + state?: unknown; +} +``` + +**Purpose**: Programmatic navigation within the application. + +**Examples**: +```typescript +// Navigate to a specific instance dashboard +navigate('/instances/prod-cluster/dashboard'); + +// Navigate back +navigate(-1); + +// Replace current history entry +navigate('/instances/prod-cluster/operations', { replace: true }); +``` + +**Error Conditions**: +- Invalid paths are handled by error boundary +- Missing instance IDs redirect to root + +#### useParams + +```typescript +function useParams>(): T; +``` + +**Purpose**: Access URL parameters, primarily for extracting instance ID. + +**Example**: +```typescript +const { instanceId } = useParams<{ instanceId: string }>(); +``` + +**Error Conditions**: +- Returns undefined for missing parameters +- Type safety through TypeScript generics + +#### useLocation + +```typescript +interface Location { + pathname: string; + search: string; + hash: string; + state: unknown; + key: string; +} + +function useLocation(): Location; +``` + +**Purpose**: Access current location information for conditional rendering or analytics. + +#### useSearchParams + +```typescript +function useSearchParams(): [ + URLSearchParams, + (nextInit: URLSearchParams | ((prev: URLSearchParams) => URLSearchParams)) => void +]; +``` + +**Purpose**: Read and write URL query parameters for filters, sorting, and view state. + +**Example**: +```typescript +const [searchParams, setSearchParams] = useSearchParams(); +const view = searchParams.get('view') || 'grid'; +setSearchParams({ view: 'list' }); +``` + +### Link Component + +```typescript +interface LinkProps { + to: string; + replace?: boolean; + state?: unknown; + children: React.ReactNode; + className?: string; +} + +function Link(props: LinkProps): JSX.Element; +``` + +**Purpose**: Declarative navigation component for user-triggered navigation. + +**Example**: +```typescript + + Go to Dashboard + +``` + +**Behavior**: +- Prevents default browser navigation +- Supports keyboard navigation (Enter) +- Maintains browser history +- Supports Ctrl/Cmd+Click to open in new tab + +### NavLink Component + +```typescript +interface NavLinkProps extends LinkProps { + caseSensitive?: boolean; + end?: boolean; + className?: string | ((props: { isActive: boolean; isPending: boolean }) => string); + style?: React.CSSProperties | ((props: { isActive: boolean; isPending: boolean }) => React.CSSProperties); +} + +function NavLink(props: NavLinkProps): JSX.Element; +``` + +**Purpose**: Navigation links that are aware of their active state. + +**Example**: +```typescript + isActive ? 'active-nav-link' : 'nav-link'} +> + Dashboard + +``` + +## Data Models + +### Route Parameters + +```typescript +interface InstanceRouteParams { + instanceId: string; +} +``` + +**Field Specifications**: +- `instanceId`: String identifier for the instance + - **Required**: Yes + - **Format**: Alphanumeric with hyphens, 1-64 characters + - **Validation**: Must correspond to an existing instance + - **Example**: `"prod-cluster"`, `"staging-env"` + +### Navigation State + +```typescript +interface NavigationState { + from?: string; + returnTo?: string; + [key: string]: unknown; +} +``` + +**Purpose**: Preserve state across navigation, such as return URLs or form data. + +**Example**: +```typescript +navigate('/instances/prod-cluster/secrets', { + state: { returnTo: '/instances/prod-cluster/dashboard' } +}); +``` + +## Error Model + +### Route Errors + +| Error Code | Condition | User Impact | Recovery Action | +|------------|-----------|-------------|-----------------| +| `ROUTE_NOT_FOUND` | Path does not match any route | 404 page displayed | Redirect to root or show available routes | +| `INSTANCE_NOT_FOUND` | Instance ID in URL does not exist | Error boundary with message | Redirect to instance selector at `/` | +| `INVALID_INSTANCE_ID` | Instance ID format invalid | Validation error displayed | Show error message, redirect to `/` | +| `NAVIGATION_CANCELLED` | User cancelled pending navigation | No visible change | Continue at current route | +| `LOADER_ERROR` | Route data loader failed | Error boundary with retry option | Show error, allow retry or navigate away | + +### Error Response Format + +```typescript +interface RouteError { + code: string; + message: string; + status?: number; + cause?: Error; +} +``` + +**Example**: +```typescript +{ + code: "INSTANCE_NOT_FOUND", + message: "Instance 'unknown-instance' does not exist", + status: 404 +} +``` + +## Performance Characteristics + +### Route Transition Times + +- **Static Routes**: < 50ms (no data loading) +- **Instance Routes**: < 200ms (with instance config loading) +- **Heavy Data Routes**: < 500ms (with large data sets) + +### Bundle Size + +- **Router Core**: ~45KB (minified) +- **Navigation Components**: ~5KB +- **Per-Route Code Splitting**: Enabled by default + +### Memory Usage + +- **History Stack**: O(n) where n is number of navigation entries +- **Route Cache**: Configurable, default 10 entries +- **Cleanup**: Automatic on unmount + +## Configuration Requirements + +### Environment Variables + +None required. All routing is handled client-side. + +### Route Configuration + +```typescript +interface RouterConfig { + basename?: string; + future?: { + v7_startTransition?: boolean; + v7_relativeSplatPath?: boolean; + }; +} +``` + +**basename**: Optional base path for deployment in subdirectories +- **Default**: `"/"` +- **Example**: `"/app"` if deployed to `example.com/app/` + +## Conformance Criteria + +### Functional Requirements + +1. **F-ROUTE-01**: Router SHALL render correct component for each defined route +2. **F-ROUTE-02**: Router SHALL extract instance ID from URL parameters +3. **F-ROUTE-03**: Navigation hooks SHALL update browser history +4. **F-ROUTE-04**: Back/forward browser buttons SHALL work correctly +5. **F-ROUTE-05**: Invalid routes SHALL display error boundary +6. **F-ROUTE-06**: Instance routes SHALL validate instance existence +7. **F-ROUTE-07**: Link components SHALL support keyboard navigation +8. **F-ROUTE-08**: Routes SHALL support lazy loading of components + +### Non-Functional Requirements + +1. **NF-ROUTE-01**: Route transitions SHALL complete in < 200ms for cached routes +2. **NF-ROUTE-02**: Router SHALL support browser back/forward without page reload +3. **NF-ROUTE-03**: Navigation SHALL preserve scroll position when appropriate +4. **NF-ROUTE-04**: Router SHALL be compatible with React 19.1+ +5. **NF-ROUTE-05**: Routes SHALL be defined declaratively in configuration +6. **NF-ROUTE-06**: Router SHALL integrate with existing ErrorBoundary +7. **NF-ROUTE-07**: Navigation SHALL work with InstanceContext + +### Integration Requirements + +1. **I-ROUTE-01**: Router SHALL integrate with InstanceContext +2. **I-ROUTE-02**: AppSidebar SHALL use routing for navigation +3. **I-ROUTE-03**: All page components SHALL be routed +4. **I-ROUTE-04**: Router SHALL integrate with React Query for data loading +5. **I-ROUTE-05**: Router SHALL support Vite code splitting + +## API Stability + +**Versioning Scheme**: Semantic Versioning (SemVer) + +**Stability Level**: Stable (1.0.0+) + +**Breaking Changes**: +- Route path changes require major version bump +- Hook signature changes require major version bump +- Added routes or optional parameters are minor version bumps + +**Deprecation Policy**: +- Deprecated routes supported for 2 minor versions +- Console warnings for deprecated route usage +- Migration guide provided for breaking changes + +## Security Considerations + +### Route Protection + +Instance routes SHALL verify: +1. Instance ID exists in available instances +2. User has permission to access instance (future) + +### XSS Prevention + +- All route parameters SHALL be sanitized +- User-provided navigation state SHALL be validated +- No executable code in route parameters + +### CSRF Protection + +Not applicable - all navigation is client-side without authentication tokens. + +## Browser Compatibility + +**Supported Browsers**: +- Chrome/Edge: Last 2 versions +- Firefox: Last 2 versions +- Safari: Last 2 versions + +**Required Browser APIs**: +- History API +- URL API +- ES6+ JavaScript features + +## Examples + +### Basic Navigation + +```typescript +// In a component +import { useNavigate } from 'react-router'; + +function InstanceCard({ instance }) { + const navigate = useNavigate(); + + const handleClick = () => { + navigate(`/instances/${instance.id}/dashboard`); + }; + + return ; +} +``` + +### Using Route Parameters + +```typescript +// In an instance page +import { useParams } from 'react-router'; +import { useInstanceContext } from '../hooks'; + +function DashboardPage() { + const { instanceId } = useParams<{ instanceId: string }>(); + const { setCurrentInstance } = useInstanceContext(); + + useEffect(() => { + setCurrentInstance(instanceId); + }, [instanceId, setCurrentInstance]); + + return
Dashboard for {instanceId}
; +} +``` + +### Sidebar Integration + +```typescript +// AppSidebar using NavLink +import { NavLink } from 'react-router'; + +function AppSidebar() { + const { instanceId } = useParams(); + + return ( + + ); +} +``` + +## Version History + +| Version | Date | Changes | +|---------|------|---------| +| 1.0.0 | 2025-10-12 | Initial contract definition | diff --git a/package.json b/package.json index f310015..0097678 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "lint": "eslint .", "preview": "vite preview", "type-check": "tsc --noEmit", - "test": "vitest", + "test": "vitest --run", "test:ui": "vitest --ui", "test:coverage": "vitest --coverage", "build:css": "tailwindcss -i src/index.css -o ./output.css --config ./tailwind.config.js", @@ -31,12 +31,16 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-hook-form": "^7.58.1", + "react-router": "^7.9.4", + "react-router-dom": "^7.9.4", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.10", "zod": "^3.25.67" }, "devDependencies": { "@eslint/js": "^9.25.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", "@types/node": "^24.0.3", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1f2ec1..7cd3bc2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,12 @@ importers: react-hook-form: specifier: ^7.58.1 version: 7.58.1(react@19.1.0) + react-router: + specifier: ^7.9.4 + version: 7.9.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react-router-dom: + specifier: ^7.9.4 + version: 7.9.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0) tailwind-merge: specifier: ^3.3.1 version: 3.3.1 @@ -66,6 +72,12 @@ importers: '@eslint/js': specifier: ^9.25.0 version: 9.29.0 + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@types/node': specifier: ^24.0.3 version: 24.0.3 @@ -111,6 +123,9 @@ importers: packages: + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} @@ -191,6 +206,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -990,6 +1009,32 @@ packages: peerDependencies: react: ^18 || ^19 + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.0': + resolution: {integrity: sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1139,10 +1184,18 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1150,6 +1203,13 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1222,6 +1282,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@1.0.2: + resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} + engines: {node: '>=18'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1230,6 +1294,9 @@ packages: resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssstyle@5.3.1: resolution: {integrity: sha512-g5PC9Aiph9eiczFpcgUhd9S4UUO3F+LHGRIi5NUMZ+4xtoIYbHNZwZnWA2JsFGe8OU8nl4WyaEFiZuGuxlutJQ==} engines: {node: '>=20'} @@ -1260,6 +1327,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.0.4: resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} engines: {node: '>=8'} @@ -1267,6 +1338,12 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + electron-to-chromium@1.5.169: resolution: {integrity: sha512-q7SQx6mkLy0GTJK9K9OiWeaBMV4XQtBSdf6MJUzDB/H/5tFXfIiX38Lci1Kl6SsgiEhz1SQI1ejEOU5asWEhwQ==} @@ -1472,6 +1549,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1625,6 +1706,10 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} @@ -1639,6 +1724,10 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -1726,6 +1815,10 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -1744,6 +1837,9 @@ packages: peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -1768,6 +1864,23 @@ packages: '@types/react': optional: true + react-router-dom@7.9.4: + resolution: {integrity: sha512-f30P6bIkmYvnHHa5Gcu65deIXoA2+r3Eb6PJIAddvsT9aGlchMatJ51GgpU470aSqRRbFX22T70yQNUGuW3DfA==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.9.4: + resolution: {integrity: sha512-SD3G8HKviFHg9xj7dNODUKDFgpG4xqD5nhyd0mYoB5iISepuZAvzSr8ywxgxKJ52yRzf/HWtVHc9AWwoTbljvA==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -1782,6 +1895,10 @@ packages: resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} engines: {node: '>=0.10.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -1824,6 +1941,9 @@ packages: engines: {node: '>=10'} hasBin: true + set-cookie-parser@2.7.1: + resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1845,6 +1965,10 @@ packages: std-env@3.9.0: resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -2116,6 +2240,8 @@ packages: snapshots: + '@adobe/css-tools@4.4.4': {} + '@ampproject/remapping@2.3.0': dependencies: '@jridgewell/gen-mapping': 0.3.8 @@ -2226,6 +2352,8 @@ snapshots: '@babel/core': 7.27.4 '@babel/helper-plugin-utils': 7.27.1 + '@babel/runtime@7.28.4': {} + '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 @@ -2855,6 +2983,38 @@ snapshots: '@tanstack/query-core': 5.80.7 react: 19.1.0 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/runtime': 7.28.4 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@babel/runtime': 7.28.4 + '@testing-library/dom': 10.4.1 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.8 + '@types/react-dom': 19.1.6(@types/react@19.1.8) + + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.27.5 @@ -3061,16 +3221,26 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-regex@5.0.1: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + argparse@2.0.1: {} aria-hidden@1.2.6: dependencies: tslib: 2.8.1 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + assertion-error@2.0.1: {} balanced-match@1.0.2: {} @@ -3138,6 +3308,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie@1.0.2: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -3149,6 +3321,8 @@ snapshots: mdn-data: 2.12.2 source-map-js: 1.2.1 + css.escape@1.5.1: {} + cssstyle@5.3.1(postcss@8.5.6): dependencies: '@asamuzakjp/css-color': 4.0.5 @@ -3174,10 +3348,16 @@ snapshots: deep-is@0.1.4: {} + dequal@2.0.3: {} + detect-libc@2.0.4: {} detect-node-es@1.1.0: {} + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + electron-to-chromium@1.5.169: {} enhanced-resolve@5.18.1: @@ -3406,6 +3586,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + is-extglob@2.1.1: {} is-glob@4.0.3: @@ -3538,6 +3720,8 @@ snapshots: dependencies: react: 19.1.0 + lz-string@1.5.0: {} + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 @@ -3551,6 +3735,8 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + min-indent@1.0.1: {} + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -3622,6 +3808,12 @@ snapshots: prelude-ls@1.2.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + punycode@2.3.1: {} queue-microtask@1.2.3: {} @@ -3635,6 +3827,8 @@ snapshots: dependencies: react: 19.1.0 + react-is@17.0.2: {} + react-refresh@0.17.0: {} react-remove-scroll-bar@2.3.8(@types/react@19.1.8)(react@19.1.0): @@ -3656,6 +3850,20 @@ snapshots: optionalDependencies: '@types/react': 19.1.8 + react-router-dom@7.9.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-router: 7.9.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + + react-router@7.9.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + cookie: 1.0.2 + react: 19.1.0 + set-cookie-parser: 2.7.1 + optionalDependencies: + react-dom: 19.1.0(react@19.1.0) + react-style-singleton@2.2.3(@types/react@19.1.8)(react@19.1.0): dependencies: get-nonce: 1.0.1 @@ -3666,6 +3874,11 @@ snapshots: react@19.1.0: {} + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + require-from-string@2.0.2: {} resolve-from@4.0.0: {} @@ -3716,6 +3929,8 @@ snapshots: semver@7.7.2: {} + set-cookie-parser@2.7.1: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -3730,6 +3945,10 @@ snapshots: std-env@3.9.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@3.1.1: {} strip-literal@3.1.0: diff --git a/src/App.tsx b/src/App.tsx index 4879a4b..ed23a3a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,140 +1,8 @@ -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'; +import { RouterProvider } from 'react-router'; +import { router } from './router'; function App() { - const [currentTab, setCurrentTab] = useState('cloud'); - const [completedPhases, setCompletedPhases] = useState([]); - - 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 ( - - - - ); - case 'central': - return ( - - - - ); - case 'dns': - return ( - - - - ); - case 'dhcp': - return ( - - - - ); - case 'pxe': - return ( - - - - ); - case 'setup': - case 'infrastructure': - return ( - - handlePhaseComplete('infrastructure')} /> - - ); - case 'cluster': - return ( - - handlePhaseComplete('cluster')} /> - - ); - case 'apps': - return ( - - handlePhaseComplete('apps')} /> - - ); - case 'advanced': - return ( - - - - ); - default: - return ( - - - - ); - } - }; - - return ( - - - -
- -
-

Dashboard

-
-
-
- {renderCurrentTab()} -
-
-
- ); + return ; } -export default App; \ No newline at end of file +export default App; diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index d2ab6e5..072b088 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -1,4 +1,5 @@ -import { CheckCircle, Lock, Server, Play, Container, AppWindow, Settings, CloudLightning, Sun, Moon, Monitor, ChevronDown, Globe, Wifi, HardDrive } from 'lucide-react'; +import { NavLink, useParams } from 'react-router'; +import { Server, Play, Container, AppWindow, Settings, CloudLightning, Sun, Moon, Monitor, ChevronDown, Globe, Wifi, HardDrive, Usb } from 'lucide-react'; import { cn } from '../lib/utils'; import { Sidebar, @@ -16,18 +17,9 @@ import { 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) { +export function AppSidebar() { const { theme, setTheme } = useTheme(); + const { instanceId } = useParams<{ instanceId: string }>(); const cycleTheme = () => { if (theme === 'light') { @@ -61,45 +53,10 @@ export function AppSidebar({ currentTab, onTabChange, completedPhases }: AppSide } }; - 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'; - }; + // If no instanceId, we're not in an instance context + if (!instanceId) { + return null; + } return ( @@ -110,40 +67,57 @@ export function AppSidebar({ currentTab, onTabChange, completedPhases }: AppSide

Wild Cloud

-

Central

+

{instanceId}

- + - { - 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" + + {({ isActive }) => ( + +
+ +
+ Dashboard +
)} - > -
- -
- Cloud -
+ +
+ + + + {({ isActive }) => ( + +
+ +
+ Cloud +
+ )} +
@@ -158,110 +132,57 @@ export function AppSidebar({ currentTab, onTabChange, completedPhases }: AppSide - { - const status = getTabStatus('central'); - if (status !== 'locked') onTabChange('central'); - }} - className={cn( - "transition-colors", - getTabStatus('central') === 'locked' && "opacity-50 cursor-not-allowed" - )} - > -
- -
- Central + + isActive ? "data-[active=true]" : ""}> +
+ +
+ Central +
- { - const status = getTabStatus('dns'); - if (status !== 'locked') onTabChange('dns'); - }} - className={cn( - "transition-colors", - getTabStatus('dns') === 'locked' && "opacity-50 cursor-not-allowed" - )} - > -
- -
- DNS + + +
+ +
+ DNS +
- { - const status = getTabStatus('dhcp'); - if (status !== 'locked') onTabChange('dhcp'); - }} - className={cn( - "transition-colors", - getTabStatus('dhcp') === 'locked' && "opacity-50 cursor-not-allowed" - )} - > -
- -
- DHCP + + +
+ +
+ DHCP +
- { - const status = getTabStatus('pxe'); - if (status !== 'locked') onTabChange('pxe'); - }} - className={cn( - "transition-colors", - getTabStatus('pxe') === 'locked' && "opacity-50 cursor-not-allowed" - )} - > -
- -
- PXE + + +
+ +
+ PXE +
+
+
+ + + + +
+ +
+ ISO / USB +
@@ -281,56 +202,24 @@ export function AppSidebar({ currentTab, onTabChange, completedPhases }: AppSide - { - const status = getTabStatus('infrastructure'); - if (status !== 'locked') onTabChange('infrastructure'); - }} - className={cn( - "transition-colors", - getTabStatus('infrastructure') === 'locked' && "opacity-50 cursor-not-allowed" - )} - > -
- -
- Cluster Nodes + + +
+ +
+ Cluster Nodes +
- { - const status = getTabStatus('cluster'); - if (status !== 'locked') onTabChange('cluster'); - }} - className={cn( - "transition-colors", - getTabStatus('cluster') === 'locked' && "opacity-50 cursor-not-allowed" - )} - > -
- -
- Cluster Services + + +
+ +
+ Cluster Services +
@@ -339,60 +228,24 @@ export function AppSidebar({ currentTab, onTabChange, completedPhases }: AppSide
- { - 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" - )} - > -
- -
- Apps + + +
+ +
+ Apps +
- { - 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" - )} - > -
- -
- Advanced + + +
+ +
+ Advanced +
@@ -413,4 +266,4 @@ export function AppSidebar({ currentTab, onTabChange, completedPhases }: AppSide
); -} \ No newline at end of file +} diff --git a/src/components/AppsComponent.tsx b/src/components/AppsComponent.tsx index 3002f9a..82b9ff0 100644 --- a/src/components/AppsComponent.tsx +++ b/src/components/AppsComponent.tsx @@ -2,161 +2,131 @@ 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, +import { + AppWindow, + Database, + Globe, + Shield, + BarChart3, + MessageSquare, + Search, ExternalLink, CheckCircle, AlertCircle, - Clock, Download, Trash2, - BookOpen + BookOpen, + Loader2, + Archive, + RotateCcw, + Settings, } from 'lucide-react'; +import { useInstanceContext } from '../hooks/useInstanceContext'; +import { useAvailableApps, useDeployedApps, useAppBackups } from '../hooks/useApps'; +import { BackupRestoreModal } from './BackupRestoreModal'; +import { AppConfigDialog } from './apps/AppConfigDialog'; +import type { App } from '../services/api'; -interface AppsComponentProps { - onComplete?: () => void; +interface MergedApp extends App { + deploymentStatus?: 'added' | 'deployed'; } -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([ - { - 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', - }, - ]); +export function AppsComponent() { + const { currentInstance } = useInstanceContext(); + const { data: availableAppsData, isLoading: loadingAvailable, error: availableError } = useAvailableApps(); + const { + apps: deployedApps, + isLoading: loadingDeployed, + error: deployedError, + addApp, + isAdding, + deployApp, + isDeploying, + deleteApp, + isDeleting + } = useDeployedApps(currentInstance); const [searchTerm, setSearchTerm] = useState(''); const [selectedCategory, setSelectedCategory] = useState('all'); + const [configDialogOpen, setConfigDialogOpen] = useState(false); + const [selectedAppForConfig, setSelectedAppForConfig] = useState(null); + const [backupModalOpen, setBackupModalOpen] = useState(false); + const [restoreModalOpen, setRestoreModalOpen] = useState(false); + const [selectedAppForBackup, setSelectedAppForBackup] = useState(null); - const getStatusIcon = (status: Application['status']) => { + // Fetch backups for the selected app + const { + backups, + isLoading: backupsLoading, + backup: createBackup, + isBackingUp, + restore: restoreBackup, + isRestoring, + } = useAppBackups(currentInstance, selectedAppForBackup); + + // Merge available and deployed apps + // DeployedApps now includes status: 'added' | 'deployed' + const applications: MergedApp[] = (availableAppsData?.apps || []).map(app => { + const deployedApp = deployedApps.find(d => d.name === app.name); + return { + ...app, + deploymentStatus: deployedApp?.status as 'added' | 'deployed' | undefined, // 'added' or 'deployed' from API + }; + }); + + const isLoading = loadingAvailable || loadingDeployed; + + const getStatusIcon = (status?: string) => { switch (status) { case 'running': return ; case 'error': return ; - case 'installing': - return ; + case 'deploying': + return ; case 'stopped': return ; - default: + case 'added': + return ; + case 'available': return ; + default: + return null; } }; - const getStatusBadge = (status: Application['status']) => { - const variants = { + const getStatusBadge = (app: MergedApp) => { + // Determine status: runtime status > deployment status > available + const status = app.status?.status || app.deploymentStatus || 'available'; + + const variants: Record = { available: 'secondary', - installing: 'default', + added: 'outline', + deploying: 'default', running: 'success', error: 'destructive', stopped: 'warning', - } as const; + deployed: 'outline', + }; - const labels = { + const labels: Record = { available: 'Available', - installing: 'Installing', + added: 'Added', + deploying: 'Deploying', running: 'Running', error: 'Error', stopped: 'Stopped', + deployed: 'Deployed', }; return ( - - {labels[status]} + + {labels[status] || status} ); }; - const getCategoryIcon = (category: Application['category']) => { + const getCategoryIcon = (category?: string) => { switch (category) { case 'database': return ; @@ -175,12 +145,60 @@ export function AppsComponent({ onComplete }: AppsComponentProps) { } }; - const handleAppAction = (appId: string, action: 'install' | 'start' | 'stop' | 'delete' | 'configure') => { - console.log(`${action} app: ${appId}`); + const handleAppAction = (app: MergedApp, action: 'configure' | 'deploy' | 'delete' | 'backup' | 'restore') => { + if (!currentInstance) return; + + switch (action) { + case 'configure': + // Open config dialog for adding or reconfiguring app + setSelectedAppForConfig(app); + setConfigDialogOpen(true); + break; + case 'deploy': + deployApp(app.name); + break; + case 'delete': + if (confirm(`Are you sure you want to delete ${app.name}?`)) { + deleteApp(app.name); + } + break; + case 'backup': + setSelectedAppForBackup(app.name); + setBackupModalOpen(true); + break; + case 'restore': + setSelectedAppForBackup(app.name); + setRestoreModalOpen(true); + break; + } + }; + + const handleConfigSave = (config: Record) => { + if (!selectedAppForConfig) return; + + // Call addApp with the configuration + addApp({ + name: selectedAppForConfig.name, + config: config, + }); + + // Close dialog + setConfigDialogOpen(false); + setSelectedAppForConfig(null); + }; + + const handleBackupConfirm = () => { + createBackup(); + }; + + const handleRestoreConfirm = (backupId?: string) => { + if (backupId) { + restoreBackup(backupId); + } }; 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()); @@ -188,7 +206,34 @@ export function AppsComponent({ onComplete }: AppsComponentProps) { return matchesSearch && matchesCategory; }); - const runningApps = applications.filter(app => app.status === 'running').length; + const runningApps = applications.filter(app => app.status?.status === 'running').length; + + // Show message if no instance is selected + if (!currentInstance) { + return ( + + +

No Instance Selected

+

+ Please select or create an instance to manage apps. +

+
+ ); + } + + // Show error state + if (availableError || deployedError) { + return ( + + +

Error Loading Apps

+

+ {(availableError as Error)?.message || (deployedError as Error)?.message || 'An error occurred'} +

+ +
+ ); + } return (
@@ -260,135 +305,199 @@ export function AppsComponent({ onComplete }: AppsComponentProps) {
- {runningApps} applications running • {applications.length} total available + {isLoading ? ( + + + Loading apps... + + ) : ( + `${runningApps} applications running • ${applications.length} total available` + )}
-
-
- {filteredApps.map((app) => ( - -
-
- {getCategoryIcon(app.category)} -
-
-
-

{app.name}

- {app.version && ( - - {app.version} - - )} - {getStatusIcon(app.status)} + {isLoading ? ( + + +

Loading applications...

+
+ ) : ( +
+ {filteredApps.map((app) => ( + +
+
+ {getCategoryIcon(app.category)}
-

{app.description}

- - {app.status === 'running' && ( -
- {app.namespace && ( -
Namespace: {app.namespace}
+
+
+

{app.name}

+ {app.version && ( + + {app.version} + )} - {app.replicas && ( -
Replicas: {app.replicas}
+ {getStatusIcon(app.status?.status)} +
+

{app.description}

+ + {app.status?.status === 'running' && ( +
+ {app.status.namespace && ( +
Namespace: {app.status.namespace}
+ )} + {app.status.replicas && ( +
Replicas: {app.status.replicas}
+ )} + {app.status.resources && ( +
+ Resources: {app.status.resources.cpu} CPU, {app.status.resources.memory} RAM +
+ )} +
+ )} + {app.status?.message && ( +

{app.status.message}

+ )} +
+ +
+ {getStatusBadge(app)} +
+ {/* Available: not added yet */} + {!app.deploymentStatus && ( + )} - {app.resources && ( -
Resources: {app.resources.cpu} CPU, {app.resources.memory} RAM
+ + {/* Added: in config but not deployed */} + {app.deploymentStatus === 'added' && ( + <> + + + + )} - {app.urls && app.urls.length > 0 && ( -
- URLs: - {app.urls.map((url, index) => ( - - ))} -
+ + {/* Deployed: running in Kubernetes */} + {app.deploymentStatus === 'deployed' && ( + <> + {app.status?.status === 'running' && ( + <> + + + + )} + + )}
- )} -
- -
- {getStatusBadge(app.status)} -
- {app.status === 'available' && ( - - )} - {app.status === 'running' && ( - <> - - - - )} - {app.status === 'stopped' && ( - - )} - {(app.status === 'running' || app.status === 'stopped') && ( - - )}
-
- - ))} -
+
+ ))} +
+ )} - {filteredApps.length === 0 && ( + {!isLoading && filteredApps.length === 0 && (

No applications found

- {searchTerm || selectedCategory !== 'all' + {searchTerm || selectedCategory !== 'all' ? 'Try adjusting your search or category filter' - : 'Install your first application to get started' + : 'No applications available to display' }

-
)} + + {/* Backup Modal */} + { + setBackupModalOpen(false); + setSelectedAppForBackup(null); + }} + mode="backup" + appName={selectedAppForBackup || ''} + onConfirm={handleBackupConfirm} + isPending={isBackingUp} + /> + + {/* Restore Modal */} + { + setRestoreModalOpen(false); + setSelectedAppForBackup(null); + }} + mode="restore" + appName={selectedAppForBackup || ''} + backups={backups?.backups || []} + isLoading={backupsLoading} + onConfirm={handleRestoreConfirm} + isPending={isRestoring} + /> + + {/* App Configuration Dialog */} +
); } \ No newline at end of file diff --git a/src/components/BackupRestoreModal.tsx b/src/components/BackupRestoreModal.tsx new file mode 100644 index 0000000..0e115c1 --- /dev/null +++ b/src/components/BackupRestoreModal.tsx @@ -0,0 +1,158 @@ +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from './ui/dialog'; +import { Button } from './ui/button'; +import { Badge } from './ui/badge'; +import { Loader2, AlertCircle, Clock, HardDrive } from 'lucide-react'; + +interface Backup { + id: string; + timestamp: string; + size?: string; +} + +interface BackupRestoreModalProps { + isOpen: boolean; + onClose: () => void; + mode: 'backup' | 'restore'; + appName: string; + backups?: Backup[]; + isLoading?: boolean; + onConfirm: (backupId?: string) => void; + isPending?: boolean; +} + +export function BackupRestoreModal({ + isOpen, + onClose, + mode, + appName, + backups = [], + isLoading = false, + onConfirm, + isPending = false, +}: BackupRestoreModalProps) { + const [selectedBackupId, setSelectedBackupId] = useState(null); + + const handleConfirm = () => { + if (mode === 'backup') { + onConfirm(); + } else if (mode === 'restore' && selectedBackupId) { + onConfirm(selectedBackupId); + } + onClose(); + }; + + const formatTimestamp = (timestamp: string) => { + try { + return new Date(timestamp).toLocaleString(); + } catch { + return timestamp; + } + }; + + return ( + + + + + {mode === 'backup' ? 'Create Backup' : 'Restore from Backup'} + + + {mode === 'backup' + ? `Create a backup of the ${appName} application data.` + : `Select a backup to restore for the ${appName} application.`} + + + +
+ {mode === 'backup' ? ( +
+

+ This will create a new backup of the current application state. The backup + process may take a few minutes depending on the size of the data. +

+
+ ) : ( +
+ {isLoading ? ( +
+ +
+ ) : backups.length === 0 ? ( +
+ +

+ No backups available for this application. +

+
+ ) : ( +
+ {backups.map((backup) => ( + + ))} +
+ )} +
+ )} +
+ + + + + +
+
+ ); +} diff --git a/src/components/CentralComponent.tsx b/src/components/CentralComponent.tsx index 85c78ee..ce64ca1 100644 --- a/src/components/CentralComponent.tsx +++ b/src/components/CentralComponent.tsx @@ -1,9 +1,48 @@ 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'; +import { Server, HardDrive, Settings, Clock, CheckCircle, BookOpen, ExternalLink, Loader2, AlertCircle, Database, FolderTree } from 'lucide-react'; +import { Badge } from './ui/badge'; +import { useCentralStatus } from '../hooks/useCentralStatus'; +import { useInstanceConfig, useInstanceContext } from '../hooks'; export function CentralComponent() { + const { currentInstance } = useInstanceContext(); + const { data: centralStatus, isLoading: statusLoading, error: statusError } = useCentralStatus(); + const { config: fullConfig, isLoading: configLoading } = useInstanceConfig(currentInstance); + + const serverConfig = fullConfig?.server as { host?: string; port?: number } | undefined; + + const formatUptime = (seconds?: number) => { + if (!seconds) return 'Unknown'; + + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + + const parts = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (secs > 0 || parts.length === 0) parts.push(`${secs}s`); + + return parts.join(' '); + }; + + // Show error state + if (statusError) { + return ( + + +

Error Loading Central Status

+

+ {(statusError as Error)?.message || 'An error occurred'} +

+ +
+ ); + } + return (
{/* Educational Intro Section */} @@ -17,8 +56,8 @@ export function CentralComponent() { What is the Central Service?

- 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 - + 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.

@@ -37,78 +76,123 @@ export function CentralComponent() {

-
-

Central Service

+
+

Central Service Status

- Monitor and manage the central server service + Monitor the Wild Central server

+ {centralStatus && ( + + + {centralStatus.status === 'running' ? 'Running' : centralStatus.status} + + )}
-
-

Service Status

- -
-
- - IP Address: 192.168.8.50 -
-
- - Network: 192.168.8.0/24 -
-
- - Version: 1.0.0 (update available) -
-
- - Age: 12s -
-
- - Platform: ARM -
-
- - File permissions: Good -
+ {statusLoading || configLoading ? ( +
+
- -
+ ) : ( +
+ {/* Server Information */}
- -
- - +

Server Information

+
+ +
+ +
+
Version
+
{centralStatus?.version || 'Unknown'}
+
+
+
+ + +
+ +
+
Uptime
+
{formatUptime(centralStatus?.uptimeSeconds)}
+
+
+
+ + +
+ +
+
Instances
+
{centralStatus?.instances.count || 0} configured
+ {centralStatus?.instances.names && centralStatus.instances.names.length > 0 && ( +
+ {centralStatus.instances.names.join(', ')} +
+ )} +
+
+
+ + +
+ +
+
Setup Files
+
{centralStatus?.setupFiles || 'Unknown'}
+
+
+
+ + {/* Configuration */}
- -
- - +

Configuration

+
+ +
+ +
+
Server Host
+
{serverConfig?.host || '0.0.0.0'}
+
+
+
Server Port
+
{serverConfig?.port || 5055}
+
+
+
+ + +
+ +
+
Data Directory
+
+ {centralStatus?.dataDir || '/var/lib/wild-central'} +
+
+
+
+ + +
+ +
+
Apps Directory
+
+ {centralStatus?.appsDir || '/opt/wild-cloud/apps'} +
+
+
+
- -
- - - -
-
+ )}
); -} \ No newline at end of file +} diff --git a/src/components/CloudComponent.tsx b/src/components/CloudComponent.tsx index bdd32e9..9fc75f0 100644 --- a/src/components/CloudComponent.tsx +++ b/src/components/CloudComponent.tsx @@ -1,39 +1,171 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Card } from "./ui/card"; import { Button } from "./ui/button"; -import { Cloud, HelpCircle, Edit2, Check, X } from "lucide-react"; +import { Cloud, HelpCircle, Edit2, Check, X, Loader2, AlertCircle } from "lucide-react"; import { Input, Label } from "./ui"; +import { useInstanceConfig, useInstanceContext } from "../hooks"; + +interface CloudConfig { + domain: string; + internalDomain: string; + dhcpRange: string; + dns: { + ip: string; + }; + router: { + ip: string; + }; + dnsmasq: { + interface: string; + }; +} export function CloudComponent() { - const [domainValue, setDomainValue] = useState("cloud.payne.io"); - const [internalDomainValue, setInternalDomainValue] = useState( - "internal.cloud.payne.io" - ); + const { currentInstance } = useInstanceContext(); + const { config: fullConfig, isLoading, error, updateConfig, isUpdating } = useInstanceConfig(currentInstance); + + // Extract cloud config from full config + const config = fullConfig?.cloud as CloudConfig | undefined; const [editingDomains, setEditingDomains] = useState(false); + const [editingNetwork, setEditingNetwork] = useState(false); + const [formValues, setFormValues] = useState(null); - const [tempDomain, setTempDomain] = useState(domainValue); - const [tempInternalDomain, setTempInternalDomain] = - useState(internalDomainValue); + // Sync form values when config loads + useEffect(() => { + if (config && !formValues) { + setFormValues(config as CloudConfig); + } + }, [config, formValues]); const handleDomainsEdit = () => { - setTempDomain(domainValue); - setTempInternalDomain(internalDomainValue); - setEditingDomains(true); + if (config) { + setFormValues(config as CloudConfig); + setEditingDomains(true); + } }; - const handleDomainsSave = () => { - setDomainValue(tempDomain); - setInternalDomainValue(tempInternalDomain); - setEditingDomains(false); + const handleNetworkEdit = () => { + if (config) { + setFormValues(config as CloudConfig); + setEditingNetwork(true); + } + }; + + const handleDomainsSave = async () => { + if (!formValues || !fullConfig) return; + + try { + // Update only the cloud section, preserving other config sections + await updateConfig({ + ...fullConfig, + cloud: { + domain: formValues.domain, + internalDomain: formValues.internalDomain, + dhcpRange: formValues.dhcpRange, + dns: formValues.dns, + router: formValues.router, + dnsmasq: formValues.dnsmasq, + }, + }); + setEditingDomains(false); + } catch (err) { + console.error('Failed to save domains:', err); + } + }; + + const handleNetworkSave = async () => { + if (!formValues || !fullConfig) return; + + try { + // Update only the cloud section, preserving other config sections + await updateConfig({ + ...fullConfig, + cloud: { + domain: formValues.domain, + internalDomain: formValues.internalDomain, + dhcpRange: formValues.dhcpRange, + dns: formValues.dns, + router: formValues.router, + dnsmasq: formValues.dnsmasq, + }, + }); + setEditingNetwork(false); + } catch (err) { + console.error('Failed to save network settings:', err); + } }; const handleDomainsCancel = () => { - setTempDomain(domainValue); - setTempInternalDomain(internalDomainValue); + setFormValues(config as CloudConfig); setEditingDomains(false); }; + const handleNetworkCancel = () => { + setFormValues(config as CloudConfig); + setEditingNetwork(false); + }; + + const updateFormValue = (path: string, value: string) => { + if (!formValues) return; + + setFormValues(prev => { + if (!prev) return prev; + + // Handle nested paths like "dns.ip" + const keys = path.split('.'); + if (keys.length === 1) { + return { ...prev, [keys[0]]: value }; + } + + // Handle nested object updates + const [parentKey, childKey] = keys; + return { + ...prev, + [parentKey]: { + ...(prev[parentKey as keyof CloudConfig] as Record), + [childKey]: value, + }, + }; + }); + }; + + // Show message if no instance is selected + if (!currentInstance) { + return ( + + +

No Instance Selected

+

+ Please select or create an instance to manage cloud configuration. +

+
+ ); + } + + // Show loading state + if (isLoading || !formValues) { + return ( + + +

Loading cloud configuration...

+
+ ); + } + + // Show error state + if (error) { + return ( + + +

Error Loading Configuration

+

+ {(error as Error)?.message || 'An error occurred'} +

+
+ ); + } + return (
@@ -51,7 +183,7 @@ export function CloudComponent() {
{/* Domains Section */} - +

Domain Configuration

@@ -68,6 +200,7 @@ export function CloudComponent() { variant="outline" size="sm" onClick={handleDomainsEdit} + disabled={isUpdating} > Edit @@ -82,8 +215,8 @@ export function CloudComponent() { setTempDomain(e.target.value)} + value={formValues.domain} + onChange={(e) => updateFormValue('domain', e.target.value)} placeholder="example.com" className="mt-1" /> @@ -92,21 +225,26 @@ export function CloudComponent() { setTempInternalDomain(e.target.value)} + value={formValues.internalDomain} + onChange={(e) => updateFormValue('internalDomain', e.target.value)} placeholder="internal.example.com" className="mt-1" />
-
+ )} + + + {/* Network Configuration Section */} + +
+
+

Network Configuration

+

+ Network settings and DHCP configuration +

+
+
+ + {!editingNetwork && ( + + )} +
+
+ + {editingNetwork ? ( +
+
+ + updateFormValue('dhcpRange', e.target.value)} + placeholder="192.168.1.100,192.168.1.200" + className="mt-1" + /> +

+ Format: start_ip,end_ip +

+
+
+ + updateFormValue('dns.ip', e.target.value)} + placeholder="192.168.1.1" + className="mt-1" + /> +
+
+ + updateFormValue('router.ip', e.target.value)} + placeholder="192.168.1.1" + className="mt-1" + /> +
+
+ + updateFormValue('dnsmasq.interface', e.target.value)} + placeholder="eth0" + className="mt-1" + /> +
+
+ + +
+
+ ) : ( +
+
+ +
+ {formValues.dhcpRange} +
+
+
+ +
+ {formValues.dns.ip} +
+
+
+ +
+ {formValues.router.ip} +
+
+
+ +
+ {formValues.dnsmasq.interface}
diff --git a/src/components/ClusterNodesComponent.tsx b/src/components/ClusterNodesComponent.tsx index 2d7165d..0da371a 100644 --- a/src/components/ClusterNodesComponent.tsx +++ b/src/components/ClusterNodesComponent.tsx @@ -2,151 +2,145 @@ 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'; +import { Cpu, HardDrive, Network, Monitor, CheckCircle, AlertCircle, BookOpen, ExternalLink, Loader2 } from 'lucide-react'; +import { useInstanceContext } from '../hooks/useInstanceContext'; +import { useNodes, useDiscoveryStatus } from '../hooks/useNodes'; -interface ClusterNodesComponentProps { - onComplete?: () => void; -} +export function ClusterNodesComponent() { + const { currentInstance } = useInstanceContext(); + const { + nodes, + isLoading, + error, + addNode, + isAdding, + deleteNode, + isDeleting, + discover, + isDiscovering, + detect, + isDetecting + } = useNodes(currentInstance); -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; - }; -} + const { + data: discoveryStatus + } = useDiscoveryStatus(currentInstance); -export function ClusterNodesComponent({ onComplete }: ClusterNodesComponentProps) { - const [currentOsVersion, setCurrentOsVersion] = useState('v13.0.5'); - const [nodes, setNodes] = useState([ - { - 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 [subnet, setSubnet] = useState('192.168.1.0/24'); - const getStatusIcon = (status: Node['status']) => { + const getStatusIcon = (status?: string) => { switch (status) { - case 'connected': + case 'ready': + case 'healthy': return ; case 'error': return ; case 'connecting': - return ; + case 'provisioning': + return ; default: return ; } }; - const getStatusBadge = (status: Node['status']) => { - const variants = { + const getStatusBadge = (status?: string) => { + const variants: Record = { pending: 'secondary', connecting: 'default', - connected: 'success', + provisioning: 'default', + ready: 'success', healthy: 'success', error: 'destructive', - } as const; + }; - const labels = { + const labels: Record = { pending: 'Pending', connecting: 'Connecting', - connected: 'Connected', + provisioning: 'Provisioning', + ready: 'Ready', healthy: 'Healthy', error: 'Error', }; return ( - - {labels[status]} + + {labels[status || 'pending'] || status} ); }; - const getTypeIcon = (type: Node['type']) => { - return type === 'controller' ? ( + const getRoleIcon = (role: string) => { + return role === 'controlplane' ? ( ) : ( ); }; - const handleNodeAction = (nodeId: string, action: 'connect' | 'retry' | 'upgrade_node') => { - console.log(`${action} node: ${nodeId}`); + const handleAddNode = (ip: string, hostname: string, role: 'controlplane' | 'worker') => { + if (!currentInstance) return; + addNode({ target_ip: ip, hostname, role, disk: '/dev/sda' }); }; - 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; + const handleDeleteNode = (hostname: string) => { + if (!currentInstance) return; + if (confirm(`Are you sure you want to remove node ${hostname}?`)) { + deleteNode(hostname); + } + }; + + const handleDiscover = () => { + if (!currentInstance) return; + discover(subnet); + }; + + const handleDetect = () => { + if (!currentInstance) return; + detect(); + }; + + // Derive status from backend state flags for each node + const assignedNodes = nodes.map(node => { + let status = 'pending'; + if (node.maintenance) { + status = 'provisioning'; + } else if (node.configured && !node.applied) { + status = 'connecting'; + } else if (node.applied) { + status = 'ready'; + } + return { ...node, status }; + }); + + // Extract IPs from discovered nodes + const discoveredIps = discoveryStatus?.nodes_found?.map(n => n.ip) || []; + + // Show message if no instance is selected + if (!currentInstance) { + return ( + + +

No Instance Selected

+

+ Please select or create an instance to manage nodes. +

+
+ ); + } + + // Show error state + if (error) { + return ( + + +

Error Loading Nodes

+

+ {(error as Error)?.message || 'An error occurred'} +

+ +
+ ); + } return (
@@ -190,148 +184,148 @@ export function ClusterNodesComponent({ onComplete }: ClusterNodesComponentProps
-
-

Assigned Nodes ({assignedNodes.length}/{totalNodes})

- {assignedNodes.map((node) => ( - -
-
- {getTypeIcon(node.type)} + {isLoading ? ( + + +

Loading nodes...

+
+ ) : ( + <> +
+
+

Cluster Nodes ({assignedNodes.length})

+
+ setSubnet(e.target.value)} + className="px-3 py-1 text-sm border rounded-lg" + /> + +
-
-
-

{node.name}

- - {node.type} - - {getStatusIcon(node.status)} -
-
- MAC: {node.macAddress} - {node.ipAddress && ` • IP: ${node.ipAddress}`} -
-
- - - {node.specs.cpu} - - - - {node.specs.memory} - - - - {node.specs.storage} - - {node.osVersion && ( - +
+ + {assignedNodes.map((node) => ( + +
+
+ {getRoleIcon(node.role)} +
+
+
+

{node.hostname}

- OS: {node.osVersion} + {node.role} - - )} + {getStatusIcon(node.status)} +
+
+ IP: {node.target_ip} +
+ {node.hardware && ( +
+ {node.hardware.cpu && ( + + + {node.hardware.cpu} + + )} + {node.hardware.memory && ( + + + {node.hardware.memory} + + )} + {node.hardware.disk && ( + + + {node.hardware.disk} + + )} +
+ )} + {node.talosVersion && ( +
+ Talos: {node.talosVersion} + {node.kubernetesVersion && ` • K8s: ${node.kubernetesVersion}`} +
+ )} +
+
+ {getStatusBadge(node.status)} + +
-
-
- {getStatusBadge(node.status)} - {node.osVersion !== currentOsVersion && ( - - )} - {node.status === 'error' && ( - - )} -
-
- - ))} -
+ + ))} -

Unassigned Nodes ({unassignedNodes.length}/{totalNodes})

-
- {unassignedNodes.map((node) => ( - -
-
- {getTypeIcon(node.type)} -
-
-
-

{node.name}

- - {node.type} - - {getStatusIcon(node.status)} -
-
- MAC: {node.macAddress} - {node.ipAddress && ` • IP: ${node.ipAddress}`} -
-
- - - {node.specs.cpu} - - - - {node.specs.memory} - - - - {node.specs.storage} - -
-
-
- {getStatusBadge(node.status)} - {node.status === 'pending' && ( - - )} - {node.status === 'error' && ( - - )} -
-
-
- ))} -
- - {isComplete && ( -
-
- -

- Infrastructure Ready! -

+ {assignedNodes.length === 0 && ( + + +

No Nodes

+

+ Use the discover or auto-detect buttons above to find nodes on your network. +

+
+ )}
-

- All nodes are connected and ready for Kubernetes installation. -

- -
+ + {discoveredIps.length > 0 && ( +
+

Discovered IPs ({discoveredIps.length})

+
+ {discoveredIps.map((ip) => ( + + {ip} +
+ + +
+
+ ))} +
+
+ )} + )} diff --git a/src/components/ClusterServicesComponent.tsx b/src/components/ClusterServicesComponent.tsx index 786cb8b..fa9cc6a 100644 --- a/src/components/ClusterServicesComponent.tsx +++ b/src/components/ClusterServicesComponent.tsx @@ -1,128 +1,128 @@ -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'; +import { Container, Shield, Network, Database, CheckCircle, AlertCircle, Terminal, BookOpen, ExternalLink, Loader2 } from 'lucide-react'; +import { useInstanceContext } from '../hooks/useInstanceContext'; +import { useServices } from '../hooks/useServices'; +import type { Service } from '../services/api'; -interface ClusterServicesComponentProps { - onComplete?: () => void; -} +export function ClusterServicesComponent() { + const { currentInstance } = useInstanceContext(); + const { + services, + isLoading, + error, + installService, + isInstalling, + installAll, + isInstallingAll, + deleteService, + isDeleting + } = useServices(currentInstance); -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([ - { - 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(null); - - const getStatusIcon = (status: ClusterComponent['status']) => { + const getStatusIcon = (status?: string) => { switch (status) { + case 'running': case 'ready': return ; case 'error': return ; + case 'deploying': case 'installing': - return ; + return ; default: return null; } }; - const getStatusBadge = (status: ClusterComponent['status']) => { - const variants = { - pending: 'secondary', + const getStatusBadge = (service: Service) => { + const status = service.status?.status || (service.deployed ? 'deployed' : 'available'); + + const variants: Record = { + available: 'secondary', + deploying: 'default', installing: 'default', + running: 'success', ready: 'success', error: 'destructive', - } as const; + deployed: 'outline', + }; - const labels = { - pending: 'Pending', + const labels: Record = { + available: 'Available', + deploying: 'Deploying', installing: 'Installing', + running: 'Running', ready: 'Ready', error: 'Error', + deployed: 'Deployed', }; return ( - - {labels[status]} + + {labels[status] || status} ); }; - const getComponentIcon = (id: string) => { - switch (id) { - case 'talos-config': - return ; - case 'kubernetes-bootstrap': - return ; - case 'cni-plugin': - return ; - case 'storage-class': - return ; - case 'ingress-controller': - return ; - case 'monitoring': - return ; - default: - return ; + const getServiceIcon = (name: string) => { + const lowerName = name.toLowerCase(); + if (lowerName.includes('network') || lowerName.includes('cni') || lowerName.includes('cilium')) { + return ; + } else if (lowerName.includes('storage') || lowerName.includes('volume')) { + return ; + } else if (lowerName.includes('ingress') || lowerName.includes('traefik') || lowerName.includes('nginx')) { + return ; + } else if (lowerName.includes('monitor') || lowerName.includes('prometheus') || lowerName.includes('grafana')) { + return ; + } else { + return ; } }; - const handleComponentAction = (componentId: string, action: 'install' | 'retry') => { - console.log(`${action} component: ${componentId}`); + const handleInstallService = (serviceName: string) => { + if (!currentInstance) return; + installService({ name: serviceName }); }; - const readyComponents = components.filter(component => component.status === 'ready').length; - const totalComponents = components.length; - const isComplete = readyComponents === totalComponents; + const handleDeleteService = (serviceName: string) => { + if (!currentInstance) return; + if (confirm(`Are you sure you want to delete service ${serviceName}?`)) { + deleteService(serviceName); + } + }; + + const handleInstallAll = () => { + if (!currentInstance) return; + installAll(); + }; + + // Show message if no instance is selected + if (!currentInstance) { + return ( + + +

No Instance Selected

+

+ Please select or create an instance to manage services. +

+
+ ); + } + + // Show error state + if (error) { + return ( + + +

Error Loading Services

+

+ {(error as Error)?.message || 'An error occurred'} +

+ +
+ ); + } return (
@@ -167,108 +167,91 @@ export function ClusterServicesComponent({ onComplete }: ClusterServicesComponen
-
-          endpoint: civil
- endpointIp: 192.168.8.240
- kubernetes:
- config: /home/payne/.kube/config
- context: default
- loadBalancerRange: 192.168.8.240-192.168.8.250
- dashboard:
- adminUsername: admin
- certManager:
- namespace: cert-manager
- cloudflare:
- domain: payne.io
- ownerId: cloud-payne-io-cluster
-
-
- - -
- {components.map((component) => ( -
-
-
- {getComponentIcon(component.id)} -
-
-
-

{component.name}

- {component.version && ( - - {component.version} - - )} - {getStatusIcon(component.status)} -
-

{component.description}

-
-
- {getStatusBadge(component.status)} - {(component.status === 'installing' || component.status === 'error') && ( - - )} - {component.status === 'pending' && ( - - )} - {component.status === 'error' && ( - - )} -
-
- - {showLogs === component.id && ( - -
-
Installing {component.name}...
-
✓ Checking prerequisites
-
✓ Downloading manifests
- {component.status === 'installing' && ( -
⏳ Applying configuration...
- )} - {component.status === 'error' && ( -
✗ Installation failed: timeout waiting for pods
- )} -
-
- )} -
- ))} +
+ {isLoading ? ( + + + Loading services... + + ) : ( + `${services.length} services available` + )} +
+
- {isComplete && ( -
-
- -

- Kubernetes Cluster Ready! -

-
-

- Your Kubernetes cluster is fully configured and ready for application deployment. -

- + {isLoading ? ( + + +

Loading services...

+
+ ) : ( +
+ {services.map((service) => ( +
+
+
+ {getServiceIcon(service.name)} +
+
+
+

{service.name}

+ {service.version && ( + + {service.version} + + )} + {getStatusIcon(service.status?.status)} +
+

{service.description}

+ {service.status?.message && ( +

{service.status.message}

+ )} +
+
+ {getStatusBadge(service)} + {!service.deployed && ( + + )} + {service.deployed && ( + + )} +
+
+
+ ))} + + {services.length === 0 && ( + + +

No Services Available

+

+ No cluster services are configured for this instance. +

+
+ )}
)} diff --git a/src/components/ConfigEditor.tsx b/src/components/ConfigEditor.tsx index 1f41877..3a39962 100644 --- a/src/components/ConfigEditor.tsx +++ b/src/components/ConfigEditor.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { Settings, Save, X } from 'lucide-react'; +import { Settings } from 'lucide-react'; import { useConfigYaml } from '../hooks'; import { Button, Textarea } from './ui'; import { diff --git a/src/components/ConfigViewer.tsx b/src/components/ConfigViewer.tsx new file mode 100644 index 0000000..3117083 --- /dev/null +++ b/src/components/ConfigViewer.tsx @@ -0,0 +1,17 @@ +import { Card } from './ui/card'; +import { cn } from '@/lib/utils'; + +interface ConfigViewerProps { + content: string; + className?: string; +} + +export function ConfigViewer({ content, className }: ConfigViewerProps) { + return ( + +
+        {content}
+      
+
+ ); +} diff --git a/src/components/ConfigurationForm.tsx b/src/components/ConfigurationForm.tsx index 6df8f53..b5717fc 100644 --- a/src/components/ConfigurationForm.tsx +++ b/src/components/ConfigurationForm.tsx @@ -1,7 +1,7 @@ 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 { useConfig } from '../hooks'; import { configFormSchema, defaultConfigValues, type ConfigFormData } from '../schemas/config'; import { Card, diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx new file mode 100644 index 0000000..bb22d48 --- /dev/null +++ b/src/components/CopyButton.tsx @@ -0,0 +1,49 @@ +import { useState } from 'react'; +import { Copy, Check } from 'lucide-react'; +import { Button } from './ui/button'; + +interface CopyButtonProps { + content: string; + label?: string; + variant?: 'default' | 'outline' | 'secondary' | 'ghost'; + disabled?: boolean; +} + +export function CopyButton({ + content, + label = 'Copy', + variant = 'outline', + disabled = false, +}: CopyButtonProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(content); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (error) { + console.error('Failed to copy:', error); + } + }; + + return ( + + ); +} diff --git a/src/components/DhcpComponent.tsx b/src/components/DhcpComponent.tsx index 2e10cd0..c0f093b 100644 --- a/src/components/DhcpComponent.tsx +++ b/src/components/DhcpComponent.tsx @@ -55,7 +55,7 @@ export function DhcpComponent() {
- + diff --git a/src/components/DownloadButton.tsx b/src/components/DownloadButton.tsx new file mode 100644 index 0000000..1f3798c --- /dev/null +++ b/src/components/DownloadButton.tsx @@ -0,0 +1,41 @@ +import { Download } from 'lucide-react'; +import { Button } from './ui/button'; + +interface DownloadButtonProps { + content: string; + filename: string; + label?: string; + variant?: 'default' | 'outline' | 'secondary' | 'ghost'; + disabled?: boolean; +} + +export function DownloadButton({ + content, + filename, + label = 'Download', + variant = 'default', + disabled = false, +}: DownloadButtonProps) { + const handleDownload = () => { + const blob = new Blob([content], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }; + + return ( + + ); +} diff --git a/src/components/InstanceSelector.tsx b/src/components/InstanceSelector.tsx new file mode 100644 index 0000000..bebfc69 --- /dev/null +++ b/src/components/InstanceSelector.tsx @@ -0,0 +1,136 @@ +import { useState } from 'react'; +import { Card } from './ui/card'; +import { Button } from './ui/button'; +import { Badge } from './ui/badge'; +import { Cloud, Plus, Check, Loader2, AlertCircle } from 'lucide-react'; +import { useInstanceContext } from '../hooks/useInstanceContext'; +import { useInstances } from '../hooks/useInstances'; + +export function InstanceSelector() { + const { currentInstance, setCurrentInstance } = useInstanceContext(); + const { instances, isLoading, error, createInstance, isCreating } = useInstances(); + const [showCreateForm, setShowCreateForm] = useState(false); + const [newInstanceName, setNewInstanceName] = useState(''); + + const handleSelectInstance = (name: string) => { + setCurrentInstance(name); + }; + + const handleCreateInstance = () => { + if (!newInstanceName.trim()) return; + createInstance({ name: newInstanceName.trim() }); + setShowCreateForm(false); + setNewInstanceName(''); + }; + + if (isLoading) { + return ( + +
+ + Loading instances... +
+
+ ); + } + + if (error) { + return ( + +
+ + + Error loading instances: {(error as Error).message} + +
+
+ ); + } + + return ( + +
+ +
+ + +
+ + {currentInstance && ( + + + Active + + )} + + +
+ + {showCreateForm && ( +
+
+ setNewInstanceName(e.target.value)} + className="flex-1 px-3 py-2 border rounded-lg" + onKeyDown={(e) => { + if (e.key === 'Enter' && newInstanceName.trim()) { + handleCreateInstance(); + } + }} + /> + + +
+
+ )} + + {instances.length === 0 && !showCreateForm && ( +
+

+ No instances found. Create your first instance to get started. +

+ +
+ )} +
+ ); +} diff --git a/src/components/SecretInput.tsx b/src/components/SecretInput.tsx new file mode 100644 index 0000000..0712e9a --- /dev/null +++ b/src/components/SecretInput.tsx @@ -0,0 +1,53 @@ +import { useState } from 'react'; +import { Eye, EyeOff } from 'lucide-react'; +import { Input } from './ui/input'; +import { Button } from './ui/button'; +import { cn } from '@/lib/utils'; + +interface SecretInputProps { + value: string; + onChange?: (value: string) => void; + placeholder?: string; + readOnly?: boolean; + className?: string; +} + +export function SecretInput({ + value, + onChange, + placeholder = '••••••••', + readOnly = false, + className, +}: SecretInputProps) { + const [revealed, setRevealed] = useState(false); + + // If no onChange handler provided, the field should be read-only + const isReadOnly = readOnly || !onChange; + + return ( +
+ onChange(e.target.value) : undefined} + placeholder={placeholder} + readOnly={isReadOnly} + className={cn('pr-10', className)} + /> + +
+ ); +} diff --git a/src/components/ServiceCard.tsx b/src/components/ServiceCard.tsx new file mode 100644 index 0000000..3584a27 --- /dev/null +++ b/src/components/ServiceCard.tsx @@ -0,0 +1,84 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card'; +import { Badge } from './ui/badge'; +import { Button } from './ui/button'; +import { Loader2 } from 'lucide-react'; +import type { Service } from '@/services/api/types'; + +interface ServiceCardProps { + service: Service; + onInstall?: () => void; + isInstalling?: boolean; +} + +export function ServiceCard({ service, onInstall, isInstalling = false }: ServiceCardProps) { + const getStatusColor = (status?: string) => { + switch (status) { + case 'running': + return 'default'; + case 'deploying': + return 'secondary'; + case 'error': + return 'destructive'; + case 'stopped': + return 'outline'; + default: + return 'outline'; + } + }; + + const isInstalled = service.deployed || service.status?.status === 'running'; + const canInstall = !isInstalled && !isInstalling; + + return ( + + +
+
+ {service.name} + {service.version && ( + v{service.version} + )} +
+ {service.status && ( + + {service.status.status} + + )} +
+
+ +

{service.description}

+ + {service.status?.message && ( +

{service.status.message}

+ )} + +
+ {canInstall && ( + + )} + + {isInstalled && ( + + )} +
+
+
+ ); +} diff --git a/src/components/UtilityCard.tsx b/src/components/UtilityCard.tsx new file mode 100644 index 0000000..3d7d4a2 --- /dev/null +++ b/src/components/UtilityCard.tsx @@ -0,0 +1,120 @@ +import { ReactNode } from 'react'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from './ui/card'; +import { Button } from './ui/button'; +import { Loader2, Copy, Check, AlertCircle } from 'lucide-react'; +import { useState } from 'react'; + +interface UtilityCardProps { + title: string; + description: string; + icon: ReactNode; + action?: { + label: string; + onClick: () => void; + disabled?: boolean; + loading?: boolean; + }; + children?: ReactNode; + error?: Error | null; + isLoading?: boolean; +} + +export function UtilityCard({ + title, + description, + icon, + action, + children, + error, + isLoading, +}: UtilityCardProps) { + return ( + + +
+
+ {icon} +
+
+ {title} + {description} +
+
+
+ + {isLoading ? ( +
+ +
+ ) : error ? ( +
+ + {error.message} +
+ ) : ( + children + )} + {action && ( + + )} +
+
+ ); +} + +interface CopyableValueProps { + value: string; + label?: string; + multiline?: boolean; +} + +export function CopyableValue({ value, label, multiline = false }: CopyableValueProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+ {label &&
{label}
} +
+
+ {multiline ? ( +
{value}
+ ) : ( + {value} + )} +
+ +
+
+ ); +} diff --git a/src/components/apps/AppConfigDialog.tsx b/src/components/apps/AppConfigDialog.tsx new file mode 100644 index 0000000..7d9388c --- /dev/null +++ b/src/components/apps/AppConfigDialog.tsx @@ -0,0 +1,173 @@ +import { useState, useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from '../ui/dialog'; +import { Button } from '../ui/button'; +import { Input } from '../ui/input'; +import { Label } from '../ui/label'; +import { Loader2, Info } from 'lucide-react'; +import type { App } from '../../services/api'; + +interface AppConfigDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + app: App | null; + existingConfig?: Record; + onSave: (config: Record) => void; + isSaving?: boolean; +} + +export function AppConfigDialog({ + open, + onOpenChange, + app, + existingConfig, + onSave, + isSaving = false, +}: AppConfigDialogProps) { + const [config, setConfig] = useState>({}); + + // Initialize config when dialog opens or app changes + useEffect(() => { + if (app && open) { + const initialConfig: Record = {}; + + // Start with default config + if (app.defaultConfig) { + Object.entries(app.defaultConfig).forEach(([key, value]) => { + initialConfig[key] = String(value); + }); + } + + // Override with existing config if provided + if (existingConfig) { + Object.entries(existingConfig).forEach(([key, value]) => { + initialConfig[key] = value; + }); + } + + setConfig(initialConfig); + } + }, [app, existingConfig, open]); + + const handleSave = () => { + onSave(config); + }; + + const handleChange = (key: string, value: string) => { + setConfig(prev => ({ ...prev, [key]: value })); + }; + + // Convert snake_case to Title Case for labels + const formatLabel = (key: string): string => { + return key + .split('_') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + }; + + if (!app) return null; + + const configKeys = Object.keys(app.defaultConfig || {}); + const hasConfig = configKeys.length > 0; + + return ( + + + + Configure {app.name} + + {app.description} + + + + {hasConfig ? ( +
+ {configKeys.map((key) => { + const isRequired = app.requiredSecrets?.some(secret => + secret.toLowerCase().includes(key.toLowerCase()) + ); + + return ( +
+
+ + {isRequired && ( + + + + )} +
+ handleChange(key, e.target.value)} + placeholder={String(app.defaultConfig?.[key] || '')} + required={isRequired} + /> + {isRequired && ( +

+ This value is used to generate application secrets +

+ )} +
+ ); + })} + + {app.dependencies && app.dependencies.length > 0 && ( +
+

+ Dependencies +

+

+ This app requires the following apps to be deployed first: +

+
    + {app.dependencies.map(dep => ( +
  • {dep}
  • + ))} +
+
+ )} +
+ ) : ( +
+

This app doesn't require any configuration.

+

Click Add to proceed with default settings.

+
+ )} + + + + + +
+
+ ); +} diff --git a/src/components/index.ts b/src/components/index.ts index 448d02d..64304c1 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -16,4 +16,9 @@ export { DhcpComponent } from './DhcpComponent'; export { PxeComponent } from './PxeComponent'; export { ClusterNodesComponent } from './ClusterNodesComponent'; export { ClusterServicesComponent } from './ClusterServicesComponent'; -export { AppsComponent } from './AppsComponent'; \ No newline at end of file +export { AppsComponent } from './AppsComponent'; +export { SecretInput } from './SecretInput'; +export { ConfigViewer } from './ConfigViewer'; +export { DownloadButton } from './DownloadButton'; +export { CopyButton } from './CopyButton'; +export { ServiceCard } from './ServiceCard'; \ No newline at end of file diff --git a/src/components/operations/HealthIndicator.tsx b/src/components/operations/HealthIndicator.tsx new file mode 100644 index 0000000..ee0b5b4 --- /dev/null +++ b/src/components/operations/HealthIndicator.tsx @@ -0,0 +1,65 @@ +import { Badge } from '../ui/badge'; +import { CheckCircle, AlertTriangle, XCircle } from 'lucide-react'; + +interface HealthIndicatorProps { + status: 'healthy' | 'degraded' | 'unhealthy' | 'passing' | 'warning' | 'failing'; + size?: 'sm' | 'md' | 'lg'; + showIcon?: boolean; +} + +export function HealthIndicator({ status, size = 'md', showIcon = true }: HealthIndicatorProps) { + const getHealthConfig = () => { + // Normalize status to common values + const normalizedStatus = + status === 'passing' ? 'healthy' : + status === 'warning' ? 'degraded' : + status === 'failing' ? 'unhealthy' : + status; + + switch (normalizedStatus) { + case 'healthy': + return { + variant: 'outline' as const, + icon: CheckCircle, + className: 'border-green-500 text-green-700 dark:text-green-400', + label: 'Healthy', + }; + case 'degraded': + return { + variant: 'secondary' as const, + icon: AlertTriangle, + className: 'border-yellow-500 text-yellow-700 dark:text-yellow-400', + label: 'Degraded', + }; + case 'unhealthy': + return { + variant: 'destructive' as const, + icon: XCircle, + className: 'border-red-500', + label: 'Unhealthy', + }; + default: + return { + variant: 'secondary' as const, + icon: AlertTriangle, + className: '', + label: status.charAt(0).toUpperCase() + status.slice(1), + }; + } + }; + + const config = getHealthConfig(); + const Icon = config.icon; + + const iconSize = + size === 'sm' ? 'h-3 w-3' : + size === 'lg' ? 'h-5 w-5' : + 'h-4 w-4'; + + return ( + + {showIcon && } + {config.label} + + ); +} diff --git a/src/components/operations/NodeStatusCard.tsx b/src/components/operations/NodeStatusCard.tsx new file mode 100644 index 0000000..1b8ef73 --- /dev/null +++ b/src/components/operations/NodeStatusCard.tsx @@ -0,0 +1,97 @@ +import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; +import { Badge } from '../ui/badge'; +import { Server, Cpu, HardDrive, MemoryStick } from 'lucide-react'; +import type { Node } from '../../services/api/types'; +import { HealthIndicator } from './HealthIndicator'; + +interface NodeStatusCardProps { + node: Node; + showHardware?: boolean; +} + +export function NodeStatusCard({ node, showHardware = true }: NodeStatusCardProps) { + const getRoleBadgeVariant = (role: string) => { + return role === 'controlplane' ? 'default' : 'secondary'; + }; + + return ( + + +
+
+ +
+ + {node.hostname} + +

+ {node.target_ip} +

+
+
+
+ + {node.role} + + {(node.maintenance || node.configured || node.applied) && ( + + )} +
+
+
+ + + {/* Version Information */} +
+ {node.talosVersion && ( +
+ Talos:{' '} + {node.talosVersion} +
+ )} + {node.kubernetesVersion && ( +
+ K8s:{' '} + {node.kubernetesVersion} +
+ )} +
+ + {/* Hardware Information */} + {showHardware && node.hardware && ( +
+ {node.hardware.cpu && ( +
+ + CPU: + {node.hardware.cpu} +
+ )} + {node.hardware.memory && ( +
+ + Memory: + {node.hardware.memory} +
+ )} + {node.hardware.disk && ( +
+ + Disk: + {node.hardware.disk} +
+ )} + {node.hardware.manufacturer && node.hardware.model && ( +
+ {node.hardware.manufacturer} {node.hardware.model} +
+ )} +
+ )} +
+
+ ); +} diff --git a/src/components/operations/OperationCard.tsx b/src/components/operations/OperationCard.tsx new file mode 100644 index 0000000..7406465 --- /dev/null +++ b/src/components/operations/OperationCard.tsx @@ -0,0 +1,149 @@ +import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; +import { Badge } from '../ui/badge'; +import { Button } from '../ui/button'; +import { Loader2, CheckCircle, AlertCircle, XCircle, Clock, ChevronDown, ChevronUp } from 'lucide-react'; +import { useCancelOperation, type Operation } from '../../services/api'; +import { useState } from 'react'; + +interface OperationCardProps { + operation: Operation; + expandable?: boolean; +} + +export function OperationCard({ operation, expandable = false }: OperationCardProps) { + const [isExpanded, setIsExpanded] = useState(false); + const { mutate: cancelOperation, isPending: isCancelling } = useCancelOperation(); + + const getStatusIcon = () => { + switch (operation.status) { + case 'pending': + return ; + case 'running': + return ; + case 'completed': + return ; + case 'failed': + return ; + case 'cancelled': + return ; + default: + return null; + } + }; + + const getStatusBadge = () => { + const variants: Record = { + pending: 'secondary', + running: 'default', + completed: 'outline', + failed: 'destructive', + cancelled: 'secondary', + }; + + return ( + + {operation.status.charAt(0).toUpperCase() + operation.status.slice(1)} + + ); + }; + + const canCancel = operation.status === 'pending' || operation.status === 'running'; + + return ( + + +
+
+ {getStatusIcon()} +
+ + {operation.type} + + {operation.target && ( +

+ Target: {operation.target} +

+ )} +
+
+
+ {getStatusBadge()} + {canCancel && ( + + )} + {expandable && ( + + )} +
+
+
+ + + {operation.message && ( +

+ {operation.message} +

+ )} + + {(operation.status === 'running' || operation.status === 'pending') && ( +
+
+ Progress + {operation.progress}% +
+
+
+
+
+ )} + + {operation.error && ( +
+

+ {operation.error} +

+
+ )} + + {isExpanded && ( +
+
+ Operation ID: + {operation.id} +
+
+ Started: + {new Date(operation.started).toLocaleString()} +
+ {operation.completed && ( +
+ Completed: + {new Date(operation.completed).toLocaleString()} +
+ )} +
+ )} + + + ); +} diff --git a/src/components/operations/OperationProgress.tsx b/src/components/operations/OperationProgress.tsx new file mode 100644 index 0000000..13651d2 --- /dev/null +++ b/src/components/operations/OperationProgress.tsx @@ -0,0 +1,204 @@ +import { Card } from '../ui/card'; +import { Button } from '../ui/button'; +import { Badge } from '../ui/badge'; +import { Loader2, CheckCircle, AlertCircle, XCircle, Clock } from 'lucide-react'; +import { useOperation } from '../../hooks/useOperations'; + +interface OperationProgressProps { + operationId: string; + onComplete?: () => void; + onError?: (error: string) => void; + showDetails?: boolean; +} + +export function OperationProgress({ + operationId, + onComplete, + onError, + showDetails = true +}: OperationProgressProps) { + const { operation, error, isLoading, cancel, isCancelling } = useOperation(operationId); + + // Handle operation completion + if (operation?.status === 'completed' && onComplete) { + setTimeout(onComplete, 100); // Delay slightly to ensure state updates + } + + // Handle operation error + if (operation?.status === 'failed' && onError && operation.error) { + setTimeout(() => onError(operation.error!), 100); + } + + const getStatusIcon = () => { + if (isLoading) { + return ; + } + + switch (operation?.status) { + case 'pending': + return ; + case 'running': + return ; + case 'completed': + return ; + case 'failed': + return ; + case 'cancelled': + return ; + default: + return null; + } + }; + + const getStatusBadge = () => { + if (isLoading) { + return Loading...; + } + + const variants: Record = { + pending: 'secondary', + running: 'default', + completed: 'success', + failed: 'destructive', + cancelled: 'warning', + }; + + const labels: Record = { + pending: 'Pending', + running: 'Running', + completed: 'Completed', + failed: 'Failed', + cancelled: 'Cancelled', + }; + + const status = operation?.status || 'pending'; + + return ( + + {labels[status] || status} + + ); + }; + + const getProgressPercentage = () => { + if (!operation) return 0; + if (operation.status === 'completed') return 100; + if (operation.status === 'failed' || operation.status === 'cancelled') return 0; + return operation.progress || 0; + }; + + if (error) { + return ( + +
+ +
+

+ Error loading operation +

+

+ {error.message} +

+
+
+
+ ); + } + + if (isLoading) { + return ( + +
+ + Loading operation status... +
+
+ ); + } + + const progressPercentage = getProgressPercentage(); + const canCancel = operation?.status === 'pending' || operation?.status === 'running'; + + return ( + +
+
+
+ {getStatusIcon()} +
+

+ {operation?.type || 'Operation'} +

+ {operation?.message && ( +

+ {operation.message} +

+ )} +
+
+
+ {getStatusBadge()} + {canCancel && ( + + )} +
+
+ + {(operation?.status === 'running' || operation?.status === 'pending') && ( +
+
+ Progress + {progressPercentage}% +
+
+
+
+
+ )} + + {operation?.error && ( +
+

+ Error: {operation.error} +

+
+ )} + + {showDetails && operation && ( +
+
+ Operation ID: + {operation.id} +
+ {operation.started && ( +
+ Started: + {new Date(operation.started).toLocaleString()} +
+ )} + {operation.completed && ( +
+ Completed: + {new Date(operation.completed).toLocaleString()} +
+ )} +
+ )} +
+ + ); +} diff --git a/src/components/operations/index.ts b/src/components/operations/index.ts new file mode 100644 index 0000000..98bc028 --- /dev/null +++ b/src/components/operations/index.ts @@ -0,0 +1,4 @@ +export { OperationCard } from './OperationCard'; +export { OperationProgress } from './OperationProgress'; +export { HealthIndicator } from './HealthIndicator'; +export { NodeStatusCard } from './NodeStatusCard'; diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index 0205413..3604cb1 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -17,6 +17,10 @@ const badgeVariants = cva( "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", + success: + "border-transparent bg-green-500 text-white [a&]:hover:bg-green-600 dark:bg-green-600 dark:[a&]:hover:bg-green-700", + warning: + "border-transparent bg-yellow-500 text-white [a&]:hover:bg-yellow-600 dark:bg-yellow-600 dark:[a&]:hover:bg-yellow-700", }, }, defaultVariants: { diff --git a/src/hooks/__tests__/useConfig.test.ts b/src/hooks/__tests__/useConfig.test.ts index 186b9c8..5b57033 100644 --- a/src/hooks/__tests__/useConfig.test.ts +++ b/src/hooks/__tests__/useConfig.test.ts @@ -3,7 +3,7 @@ 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'; +import { apiService } from '../../services/api-legacy'; // Mock the API service vi.mock('../../services/api', () => ({ diff --git a/src/hooks/__tests__/useStatus.test.ts b/src/hooks/__tests__/useStatus.test.ts index 1564c06..953d541 100644 --- a/src/hooks/__tests__/useStatus.test.ts +++ b/src/hooks/__tests__/useStatus.test.ts @@ -3,7 +3,7 @@ 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'; +import { apiService } from '../../services/api-legacy'; // Mock the API service vi.mock('../../services/api', () => ({ diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 31d8dd5..46a3fa8 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -4,4 +4,17 @@ export { useHealth } from './useHealth'; export { useConfig } from './useConfig'; export { useConfigYaml } from './useConfigYaml'; export { useDnsmasq } from './useDnsmasq'; -export { useAssets } from './useAssets'; \ No newline at end of file +export { useAssets } from './useAssets'; + +// New API hooks +export { useInstanceContext, InstanceProvider } from './useInstanceContext'; +export { useInstances, useInstance, useInstanceConfig } from './useInstances'; +export { useNodes, useDiscoveryStatus, useNodeHardware } from './useNodes'; +export { useCluster } from './useCluster'; +export { useAvailableApps, useAvailableApp, useDeployedApps, useAppStatus, useAppBackups } from './useApps'; +export { useServices, useServiceStatus, useServiceManifest } from './useServices'; +export { useOperations, useOperation } from './useOperations'; +export { useSecrets, useUpdateSecrets } from './useSecrets'; +export { useKubeconfig, useTalosconfig, useRegenerateKubeconfig } from './useClusterAccess'; +export { useBaseServices, useServiceStatus as useBaseServiceStatus, useInstallService } from './useBaseServices'; +export { useCentralStatus } from './useCentralStatus'; \ No newline at end of file diff --git a/src/hooks/useApps.ts b/src/hooks/useApps.ts new file mode 100644 index 0000000..fd50a00 --- /dev/null +++ b/src/hooks/useApps.ts @@ -0,0 +1,110 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { appsApi } from '../services/api'; +import type { AppAddRequest } from '../services/api'; + +export function useAvailableApps() { + return useQuery({ + queryKey: ['apps', 'available'], + queryFn: appsApi.listAvailable, + }); +} + +export function useAvailableApp(appName: string | null | undefined) { + return useQuery({ + queryKey: ['apps', 'available', appName], + queryFn: () => appsApi.getAvailable(appName!), + enabled: !!appName, + }); +} + +export function useDeployedApps(instanceName: string | null | undefined) { + const queryClient = useQueryClient(); + + const appsQuery = useQuery({ + queryKey: ['instances', instanceName, 'apps'], + queryFn: () => appsApi.listDeployed(instanceName!), + enabled: !!instanceName, + // Poll every 3 seconds to catch deployment status changes + refetchInterval: 3000, + }); + + const addMutation = useMutation({ + mutationFn: (app: AppAddRequest) => appsApi.add(instanceName!, app), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'apps'] }); + }, + }); + + const deployMutation = useMutation({ + mutationFn: (appName: string) => appsApi.deploy(instanceName!, appName), + onSuccess: () => { + // Deployment is async, so start polling for updates + queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'apps'] }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (appName: string) => appsApi.delete(instanceName!, appName), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'apps'] }); + }, + }); + + return { + apps: appsQuery.data?.apps || [], + isLoading: appsQuery.isLoading, + error: appsQuery.error, + refetch: appsQuery.refetch, + addApp: addMutation.mutate, + isAdding: addMutation.isPending, + addResult: addMutation.data, + deployApp: deployMutation.mutate, + isDeploying: deployMutation.isPending, + deployResult: deployMutation.data, + deleteApp: deleteMutation.mutate, + isDeleting: deleteMutation.isPending, + }; +} + +export function useAppStatus(instanceName: string | null | undefined, appName: string | null | undefined) { + return useQuery({ + queryKey: ['instances', instanceName, 'apps', appName, 'status'], + queryFn: () => appsApi.getStatus(instanceName!, appName!), + enabled: !!instanceName && !!appName, + refetchInterval: 5000, // Poll every 5 seconds + }); +} + +export function useAppBackups(instanceName: string | null | undefined, appName: string | null | undefined) { + const queryClient = useQueryClient(); + + const backupsQuery = useQuery({ + queryKey: ['instances', instanceName, 'apps', appName, 'backups'], + queryFn: () => appsApi.listBackups(instanceName!, appName!), + enabled: !!instanceName && !!appName, + }); + + const backupMutation = useMutation({ + mutationFn: () => appsApi.backup(instanceName!, appName!), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['instances', instanceName, 'apps', appName, 'backups'] + }); + }, + }); + + const restoreMutation = useMutation({ + mutationFn: (backupId: string) => appsApi.restore(instanceName!, appName!, backupId), + }); + + return { + backups: backupsQuery.data, + isLoading: backupsQuery.isLoading, + backup: backupMutation.mutate, + isBackingUp: backupMutation.isPending, + backupResult: backupMutation.data, + restore: restoreMutation.mutate, + isRestoring: restoreMutation.isPending, + restoreResult: restoreMutation.data, + }; +} diff --git a/src/hooks/useAssets.ts b/src/hooks/useAssets.ts index f3c3e5e..baa5e40 100644 --- a/src/hooks/useAssets.ts +++ b/src/hooks/useAssets.ts @@ -1,5 +1,5 @@ import { useMutation } from '@tanstack/react-query'; -import { apiService } from '../services/api'; +import { apiService } from '../services/api-legacy'; interface AssetsResponse { status: string; diff --git a/src/hooks/useBaseServices.ts b/src/hooks/useBaseServices.ts new file mode 100644 index 0000000..f720f99 --- /dev/null +++ b/src/hooks/useBaseServices.ts @@ -0,0 +1,40 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { servicesApi } from '../services/api'; +import type { ServiceInstallRequest } from '../services/api/types'; + +export function useBaseServices(instanceName: string | null | undefined) { + return useQuery({ + queryKey: ['instances', instanceName, 'services'], + queryFn: () => servicesApi.list(instanceName!), + enabled: !!instanceName, + refetchInterval: 5000, // Poll every 5 seconds to get status updates + }); +} + +export function useServiceStatus(instanceName: string | null | undefined, serviceName: string) { + return useQuery({ + queryKey: ['instances', instanceName, 'services', serviceName, 'status'], + queryFn: () => servicesApi.getStatus(instanceName!, serviceName), + enabled: !!instanceName && !!serviceName, + refetchInterval: 5000, // Poll during deployment + }); +} + +export function useInstallService(instanceName: string | null | undefined) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (service: ServiceInstallRequest) => + servicesApi.install(instanceName!, service), + onSuccess: () => { + // Invalidate services list to get updated status + queryClient.invalidateQueries({ + queryKey: ['instances', instanceName, 'services'], + }); + // Also invalidate operations to show new operation + queryClient.invalidateQueries({ + queryKey: ['instances', instanceName, 'operations'], + }); + }, + }); +} diff --git a/src/hooks/useCentralStatus.ts b/src/hooks/useCentralStatus.ts new file mode 100644 index 0000000..506da8c --- /dev/null +++ b/src/hooks/useCentralStatus.ts @@ -0,0 +1,31 @@ +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '../services/api/client'; + +interface CentralStatus { + status: string; + version: string; + uptime: string; + uptimeSeconds: number; + dataDir: string; + appsDir: string; + setupFiles: string; + instances: { + count: number; + names: string[]; + }; +} + +/** + * Hook to fetch Wild Central server status + * @returns Central server status information + */ +export function useCentralStatus() { + return useQuery({ + queryKey: ['central', 'status'], + queryFn: async (): Promise => { + return apiClient.get('/api/v1/status'); + }, + // Poll every 5 seconds to keep uptime current + refetchInterval: 5000, + }); +} diff --git a/src/hooks/useCluster.ts b/src/hooks/useCluster.ts new file mode 100644 index 0000000..eb49d5b --- /dev/null +++ b/src/hooks/useCluster.ts @@ -0,0 +1,83 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { clusterApi } from '../services/api'; +import type { ClusterConfig } from '../services/api'; + +export function useCluster(instanceName: string | null | undefined) { + const queryClient = useQueryClient(); + + const statusQuery = useQuery({ + queryKey: ['instances', instanceName, 'cluster', 'status'], + queryFn: () => clusterApi.getStatus(instanceName!), + enabled: !!instanceName, + }); + + const healthQuery = useQuery({ + queryKey: ['instances', instanceName, 'cluster', 'health'], + queryFn: () => clusterApi.getHealth(instanceName!), + enabled: !!instanceName, + }); + + const kubeconfigQuery = useQuery({ + queryKey: ['instances', instanceName, 'cluster', 'kubeconfig'], + queryFn: () => clusterApi.getKubeconfig(instanceName!), + enabled: !!instanceName, + }); + + const talosconfigQuery = useQuery({ + queryKey: ['instances', instanceName, 'cluster', 'talosconfig'], + queryFn: () => clusterApi.getTalosconfig(instanceName!), + enabled: !!instanceName, + }); + + const generateConfigMutation = useMutation({ + mutationFn: (config: ClusterConfig) => clusterApi.generateConfig(instanceName!, config), + }); + + const bootstrapMutation = useMutation({ + mutationFn: (node: string) => clusterApi.bootstrap(instanceName!, node), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'cluster'] }); + }, + }); + + const configureEndpointsMutation = useMutation({ + mutationFn: (includeNodes: boolean) => clusterApi.configureEndpoints(instanceName!, includeNodes), + }); + + const generateKubeconfigMutation = useMutation({ + mutationFn: () => clusterApi.generateKubeconfig(instanceName!), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'cluster', 'kubeconfig'] }); + }, + }); + + const resetMutation = useMutation({ + mutationFn: () => clusterApi.reset(instanceName!, true), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'cluster'] }); + }, + }); + + return { + status: statusQuery.data, + isLoadingStatus: statusQuery.isLoading, + health: healthQuery.data, + isLoadingHealth: healthQuery.isLoading, + kubeconfig: kubeconfigQuery.data?.kubeconfig, + talosconfig: talosconfigQuery.data?.talosconfig, + generateConfig: generateConfigMutation.mutate, + isGeneratingConfig: generateConfigMutation.isPending, + generateConfigResult: generateConfigMutation.data, + bootstrap: bootstrapMutation.mutate, + isBootstrapping: bootstrapMutation.isPending, + bootstrapResult: bootstrapMutation.data, + configureEndpoints: configureEndpointsMutation.mutate, + isConfiguringEndpoints: configureEndpointsMutation.isPending, + generateKubeconfig: generateKubeconfigMutation.mutate, + isGeneratingKubeconfig: generateKubeconfigMutation.isPending, + reset: resetMutation.mutate, + isResetting: resetMutation.isPending, + refetchStatus: statusQuery.refetch, + refetchHealth: healthQuery.refetch, + }; +} diff --git a/src/hooks/useClusterAccess.ts b/src/hooks/useClusterAccess.ts new file mode 100644 index 0000000..cb9b1b2 --- /dev/null +++ b/src/hooks/useClusterAccess.ts @@ -0,0 +1,31 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { clusterApi } from '../services/api'; + +export function useKubeconfig(instanceName: string | null | undefined) { + return useQuery({ + queryKey: ['instances', instanceName, 'kubeconfig'], + queryFn: () => clusterApi.getKubeconfig(instanceName!), + enabled: !!instanceName, + }); +} + +export function useTalosconfig(instanceName: string | null | undefined) { + return useQuery({ + queryKey: ['instances', instanceName, 'talosconfig'], + queryFn: () => clusterApi.getTalosconfig(instanceName!), + enabled: !!instanceName, + }); +} + +export function useRegenerateKubeconfig(instanceName: string | null | undefined) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => clusterApi.generateKubeconfig(instanceName!), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['instances', instanceName, 'kubeconfig'], + }); + }, + }); +} diff --git a/src/hooks/useConfig.ts b/src/hooks/useConfig.ts index 01db5ed..e46011b 100644 --- a/src/hooks/useConfig.ts +++ b/src/hooks/useConfig.ts @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { apiService } from '../services/api'; +import { apiService } from '../services/api-legacy'; import type { Config } from '../types'; interface ConfigResponse { diff --git a/src/hooks/useConfigYaml.ts b/src/hooks/useConfigYaml.ts index 002e181..c747556 100644 --- a/src/hooks/useConfigYaml.ts +++ b/src/hooks/useConfigYaml.ts @@ -1,5 +1,5 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { apiService } from '../services/api'; +import { apiService } from '../services/api-legacy'; export const useConfigYaml = () => { const queryClient = useQueryClient(); diff --git a/src/hooks/useDnsmasq.ts b/src/hooks/useDnsmasq.ts index 56fbb9e..5933967 100644 --- a/src/hooks/useDnsmasq.ts +++ b/src/hooks/useDnsmasq.ts @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useMutation } from '@tanstack/react-query'; -import { apiService } from '../services/api'; +import { apiService } from '../services/api-legacy'; interface DnsmasqResponse { status: string; diff --git a/src/hooks/useHealth.ts b/src/hooks/useHealth.ts index 6037107..23cb66f 100644 --- a/src/hooks/useHealth.ts +++ b/src/hooks/useHealth.ts @@ -1,5 +1,5 @@ import { useMutation } from '@tanstack/react-query'; -import { apiService } from '../services/api'; +import { apiService } from '../services/api-legacy'; interface HealthResponse { service: string; diff --git a/src/hooks/useInstanceContext.tsx b/src/hooks/useInstanceContext.tsx new file mode 100644 index 0000000..dfb13c9 --- /dev/null +++ b/src/hooks/useInstanceContext.tsx @@ -0,0 +1,37 @@ +import { useState, createContext, useContext, ReactNode } from 'react'; + +interface InstanceContextValue { + currentInstance: string | null; + setCurrentInstance: (name: string | null) => void; +} + +const InstanceContext = createContext(undefined); + +export function InstanceProvider({ children }: { children: ReactNode }) { + const [currentInstance, setCurrentInstanceState] = useState( + () => localStorage.getItem('currentInstance') + ); + + const setCurrentInstance = (name: string | null) => { + setCurrentInstanceState(name); + if (name) { + localStorage.setItem('currentInstance', name); + } else { + localStorage.removeItem('currentInstance'); + } + }; + + return ( + + {children} + + ); +} + +export function useInstanceContext() { + const context = useContext(InstanceContext); + if (context === undefined) { + throw new Error('useInstanceContext must be used within an InstanceProvider'); + } + return context; +} diff --git a/src/hooks/useInstances.ts b/src/hooks/useInstances.ts new file mode 100644 index 0000000..92912d0 --- /dev/null +++ b/src/hooks/useInstances.ts @@ -0,0 +1,82 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { instancesApi } from '../services/api'; +import type { CreateInstanceRequest } from '../services/api'; + +export function useInstances() { + const queryClient = useQueryClient(); + + const listQuery = useQuery({ + queryKey: ['instances'], + queryFn: instancesApi.list, + }); + + const createMutation = useMutation({ + mutationFn: (data: CreateInstanceRequest) => instancesApi.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['instances'] }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (name: string) => instancesApi.delete(name), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['instances'] }); + }, + }); + + return { + instances: listQuery.data?.instances || [], + isLoading: listQuery.isLoading, + error: listQuery.error, + refetch: listQuery.refetch, + createInstance: createMutation.mutate, + isCreating: createMutation.isPending, + createError: createMutation.error, + deleteInstance: deleteMutation.mutate, + isDeleting: deleteMutation.isPending, + deleteError: deleteMutation.error, + }; +} + +export function useInstance(instanceName: string | null | undefined) { + return useQuery({ + queryKey: ['instances', instanceName], + queryFn: () => instancesApi.get(instanceName!), + enabled: !!instanceName, + }); +} + +export function useInstanceConfig(instanceName: string | null | undefined) { + const queryClient = useQueryClient(); + + const configQuery = useQuery({ + queryKey: ['instances', instanceName, 'config'], + queryFn: () => instancesApi.getConfig(instanceName!), + enabled: !!instanceName, + }); + + const updateMutation = useMutation({ + mutationFn: (config: Record) => instancesApi.updateConfig(instanceName!, config), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'config'] }); + }, + }); + + const batchUpdateMutation = useMutation({ + mutationFn: (updates: Array<{path: string; value: unknown}>) => + instancesApi.batchUpdateConfig(instanceName!, updates), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'config'] }); + }, + }); + + return { + config: configQuery.data, + isLoading: configQuery.isLoading, + error: configQuery.error, + updateConfig: updateMutation.mutate, + isUpdating: updateMutation.isPending, + batchUpdate: batchUpdateMutation.mutate, + isBatchUpdating: batchUpdateMutation.isPending, + }; +} diff --git a/src/hooks/useNodes.ts b/src/hooks/useNodes.ts new file mode 100644 index 0000000..3dccd72 --- /dev/null +++ b/src/hooks/useNodes.ts @@ -0,0 +1,91 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { nodesApi } from '../services/api'; +import type { NodeAddRequest, NodeUpdateRequest } from '../services/api'; + +export function useNodes(instanceName: string | null | undefined) { + const queryClient = useQueryClient(); + + const nodesQuery = useQuery({ + queryKey: ['instances', instanceName, 'nodes'], + queryFn: () => nodesApi.list(instanceName!), + enabled: !!instanceName, + }); + + const discoverMutation = useMutation({ + mutationFn: (subnet: string) => nodesApi.discover(instanceName!, subnet), + }); + + const detectMutation = useMutation({ + mutationFn: () => nodesApi.detect(instanceName!), + }); + + const addMutation = useMutation({ + mutationFn: (node: NodeAddRequest) => nodesApi.add(instanceName!, node), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'nodes'] }); + }, + }); + + const updateMutation = useMutation({ + mutationFn: ({ nodeName, updates }: { nodeName: string; updates: NodeUpdateRequest }) => + nodesApi.update(instanceName!, nodeName, updates), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'nodes'] }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (nodeName: string) => nodesApi.delete(instanceName!, nodeName), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'nodes'] }); + }, + }); + + const applyMutation = useMutation({ + mutationFn: (nodeName: string) => nodesApi.apply(instanceName!, nodeName), + }); + + const fetchTemplatesMutation = useMutation({ + mutationFn: () => nodesApi.fetchTemplates(instanceName!), + }); + + return { + nodes: nodesQuery.data?.nodes || [], + isLoading: nodesQuery.isLoading, + error: nodesQuery.error, + refetch: nodesQuery.refetch, + discover: discoverMutation.mutate, + isDiscovering: discoverMutation.isPending, + discoverResult: discoverMutation.data, + detect: detectMutation.mutate, + isDetecting: detectMutation.isPending, + detectResult: detectMutation.data, + addNode: addMutation.mutate, + isAdding: addMutation.isPending, + updateNode: updateMutation.mutate, + isUpdating: updateMutation.isPending, + deleteNode: deleteMutation.mutate, + isDeleting: deleteMutation.isPending, + applyNode: applyMutation.mutate, + isApplying: applyMutation.isPending, + fetchTemplates: fetchTemplatesMutation.mutate, + isFetchingTemplates: fetchTemplatesMutation.isPending, + }; +} + +export function useDiscoveryStatus(instanceName: string | null | undefined) { + return useQuery({ + queryKey: ['instances', instanceName, 'discovery'], + queryFn: () => nodesApi.discoveryStatus(instanceName!), + enabled: !!instanceName, + refetchInterval: (query) => (query.state.data?.active ? 1000 : false), + }); +} + +export function useNodeHardware(instanceName: string | null | undefined, ip: string | null | undefined) { + return useQuery({ + queryKey: ['instances', instanceName, 'nodes', 'hardware', ip], + queryFn: () => nodesApi.getHardware(instanceName!, ip!), + enabled: !!instanceName && !!ip, + }); +} diff --git a/src/hooks/useOperations.ts b/src/hooks/useOperations.ts new file mode 100644 index 0000000..33ff3d9 --- /dev/null +++ b/src/hooks/useOperations.ts @@ -0,0 +1,78 @@ +import { useEffect, useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { operationsApi } from '../services/api'; +import type { Operation } from '../services/api'; + +export function useOperations(instanceName: string | null | undefined) { + return useQuery({ + queryKey: ['instances', instanceName, 'operations'], + queryFn: () => operationsApi.list(instanceName!), + enabled: !!instanceName, + refetchInterval: 2000, // Poll every 2 seconds + }); +} + +export function useOperation(operationId: string | null | undefined) { + const [operation, setOperation] = useState(null); + const [error, setError] = useState(null); + const queryClient = useQueryClient(); + + useEffect(() => { + if (!operationId) return; + + // Fetch initial state + operationsApi.get(operationId).then(setOperation).catch(setError); + + // Set up SSE stream + const eventSource = operationsApi.createStream(operationId); + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + setOperation(data); + + // Invalidate relevant queries when operation completes + if (data.status === 'completed' || data.status === 'failed') { + eventSource.close(); + // Invalidate queries based on operation type + if (data.instance_name) { + queryClient.invalidateQueries({ + queryKey: ['instances', data.instance_name] + }); + } + } + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to parse operation update')); + } + }; + + eventSource.onerror = () => { + setError(new Error('Operation stream failed')); + eventSource.close(); + }; + + return () => { + eventSource.close(); + }; + }, [operationId, queryClient]); + + const cancelMutation = useMutation({ + mutationFn: () => { + if (!operation?.instance_name) { + throw new Error('Cannot cancel operation: instance name not available'); + } + return operationsApi.cancel(operationId!, operation.instance_name); + }, + onSuccess: () => { + // Operation state will be updated via SSE + }, + }); + + return { + operation, + error, + isLoading: !operation && !error, + cancel: cancelMutation.mutate, + isCancelling: cancelMutation.isPending, + }; +} diff --git a/src/hooks/useSecrets.ts b/src/hooks/useSecrets.ts new file mode 100644 index 0000000..9f7e514 --- /dev/null +++ b/src/hooks/useSecrets.ts @@ -0,0 +1,25 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { instancesApi } from '../services/api'; + +export function useSecrets(instanceName: string | null | undefined, raw = false) { + return useQuery({ + queryKey: ['instances', instanceName, 'secrets', raw ? 'raw' : 'masked'], + queryFn: () => instancesApi.getSecrets(instanceName!, raw), + enabled: !!instanceName, + }); +} + +export function useUpdateSecrets(instanceName: string | null | undefined) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (secrets: Record) => + instancesApi.updateSecrets(instanceName!, secrets), + onSuccess: () => { + // Invalidate both masked and raw secrets + queryClient.invalidateQueries({ + queryKey: ['instances', instanceName, 'secrets'], + }); + }, + }); +} diff --git a/src/hooks/useServices.ts b/src/hooks/useServices.ts new file mode 100644 index 0000000..c9cbbad --- /dev/null +++ b/src/hooks/useServices.ts @@ -0,0 +1,83 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { servicesApi } from '../services/api'; +import type { ServiceInstallRequest } from '../services/api'; + +export function useServices(instanceName: string | null | undefined) { + const queryClient = useQueryClient(); + + const servicesQuery = useQuery({ + queryKey: ['instances', instanceName, 'services'], + queryFn: () => servicesApi.list(instanceName!), + enabled: !!instanceName, + }); + + const installMutation = useMutation({ + mutationFn: (service: ServiceInstallRequest) => servicesApi.install(instanceName!, service), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'services'] }); + }, + }); + + const installAllMutation = useMutation({ + mutationFn: () => servicesApi.installAll(instanceName!), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'services'] }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (serviceName: string) => servicesApi.delete(instanceName!, serviceName), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'services'] }); + }, + }); + + const fetchMutation = useMutation({ + mutationFn: (serviceName: string) => servicesApi.fetch(instanceName!, serviceName), + }); + + const compileMutation = useMutation({ + mutationFn: (serviceName: string) => servicesApi.compile(instanceName!, serviceName), + }); + + const deployMutation = useMutation({ + mutationFn: (serviceName: string) => servicesApi.deploy(instanceName!, serviceName), + }); + + return { + services: servicesQuery.data?.services || [], + isLoading: servicesQuery.isLoading, + error: servicesQuery.error, + refetch: servicesQuery.refetch, + installService: installMutation.mutate, + isInstalling: installMutation.isPending, + installResult: installMutation.data, + installAll: installAllMutation.mutate, + isInstallingAll: installAllMutation.isPending, + deleteService: deleteMutation.mutate, + isDeleting: deleteMutation.isPending, + fetch: fetchMutation.mutate, + isFetching: fetchMutation.isPending, + compile: compileMutation.mutate, + isCompiling: compileMutation.isPending, + deploy: deployMutation.mutate, + isDeploying: deployMutation.isPending, + }; +} + +export function useServiceStatus(instanceName: string | null | undefined, serviceName: string | null | undefined) { + return useQuery({ + queryKey: ['instances', instanceName, 'services', serviceName, 'status'], + queryFn: () => servicesApi.getStatus(instanceName!, serviceName!), + enabled: !!instanceName && !!serviceName, + refetchInterval: 5000, // Poll every 5 seconds + }); +} + +export function useServiceManifest(serviceName: string | null | undefined) { + return useQuery({ + queryKey: ['services', serviceName, 'manifest'], + queryFn: () => servicesApi.getManifest(serviceName!), + enabled: !!serviceName, + }); +} diff --git a/src/hooks/useStatus.ts b/src/hooks/useStatus.ts index fd5a3b0..f91ed91 100644 --- a/src/hooks/useStatus.ts +++ b/src/hooks/useStatus.ts @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import { apiService } from '../services/api'; +import { apiService } from '../services/api-legacy'; import type { Status } from '../types'; export const useStatus = () => { diff --git a/src/main.tsx b/src/main.tsx index 7ba9fd1..12713eb 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,6 +4,7 @@ import { QueryClientProvider } from '@tanstack/react-query'; import './index.css'; import App from './App'; import { ThemeProvider } from './contexts/ThemeContext'; +import { InstanceProvider } from './hooks'; import { queryClient } from './lib/queryClient'; import { ErrorBoundary } from './components/ErrorBoundary'; @@ -15,9 +16,11 @@ root.render( - - - + + + + + diff --git a/src/router/InstanceLayout.tsx b/src/router/InstanceLayout.tsx new file mode 100644 index 0000000..18198cf --- /dev/null +++ b/src/router/InstanceLayout.tsx @@ -0,0 +1,41 @@ +import { useEffect } from 'react'; +import { Outlet, useParams, Navigate } from 'react-router'; +import { useInstanceContext } from '../hooks/useInstanceContext'; +import { AppSidebar } from '../components/AppSidebar'; +import { SidebarProvider, SidebarInset, SidebarTrigger } from '../components/ui/sidebar'; + +export function InstanceLayout() { + const { instanceId } = useParams<{ instanceId: string }>(); + const { setCurrentInstance } = useInstanceContext(); + + useEffect(() => { + if (instanceId) { + setCurrentInstance(instanceId); + } + return () => { + // Don't clear instance on unmount - let it persist + // This allows the instance to stay selected when navigating + }; + }, [instanceId, setCurrentInstance]); + + if (!instanceId) { + return ; + } + + return ( + + + +
+ +
+

Wild Cloud

+
+
+
+ +
+
+
+ ); +} diff --git a/src/router/index.tsx b/src/router/index.tsx new file mode 100644 index 0000000..0b47e23 --- /dev/null +++ b/src/router/index.tsx @@ -0,0 +1,14 @@ +import { createBrowserRouter } from 'react-router'; +import { routes } from './routes'; + +export const router = createBrowserRouter(routes, { + future: { + v7_startTransition: true, + v7_relativeSplatPath: true, + }, +}); + +export { routes }; +export * from './InstanceLayout'; +export * from './pages/LandingPage'; +export * from './pages/NotFoundPage'; diff --git a/src/router/pages/AdvancedPage.tsx b/src/router/pages/AdvancedPage.tsx new file mode 100644 index 0000000..1a84c29 --- /dev/null +++ b/src/router/pages/AdvancedPage.tsx @@ -0,0 +1,10 @@ +import { ErrorBoundary } from '../../components'; +import { Advanced } from '../../components'; + +export function AdvancedPage() { + return ( + + + + ); +} diff --git a/src/router/pages/AppsPage.tsx b/src/router/pages/AppsPage.tsx new file mode 100644 index 0000000..318db18 --- /dev/null +++ b/src/router/pages/AppsPage.tsx @@ -0,0 +1,11 @@ +import { ErrorBoundary } from '../../components'; +import { AppsComponent } from '../../components/AppsComponent'; + +export function AppsPage() { + // Note: onComplete callback removed as phase management will be handled differently with routing + return ( + + + + ); +} diff --git a/src/router/pages/BaseServicesPage.tsx b/src/router/pages/BaseServicesPage.tsx new file mode 100644 index 0000000..8bb1ab0 --- /dev/null +++ b/src/router/pages/BaseServicesPage.tsx @@ -0,0 +1,116 @@ +import { useParams } from 'react-router-dom'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../../components/ui/card'; +import { Button } from '../../components/ui/button'; +import { Skeleton } from '../../components/ui/skeleton'; +import { ServiceCard } from '../../components/ServiceCard'; +import { Package, AlertTriangle, RefreshCw } from 'lucide-react'; +import { useBaseServices, useInstallService } from '../../hooks/useBaseServices'; + +export function BaseServicesPage() { + const { instanceId } = useParams<{ instanceId: string }>(); + const { data: servicesData, isLoading, refetch } = useBaseServices(instanceId); + const installMutation = useInstallService(instanceId); + + const handleInstall = async (serviceName: string) => { + await installMutation.mutateAsync({ name: serviceName }); + }; + + if (!instanceId) { + return ( +
+ +
+ +

No instance selected

+
+
+
+ ); + } + + const services = servicesData?.services || []; + + return ( +
+
+
+

Base Services

+

+ Manage essential cluster infrastructure services +

+
+ +
+ + + + + + Available Services + + + Core infrastructure services for your Wild Cloud cluster + + + + {isLoading ? ( +
+ + + + + + +
+ ) : services.length === 0 ? ( +
+ +

No services available

+

Base services will appear here once configured

+
+ ) : ( +
+ {services.map((service) => ( + handleInstall(service.name)} + isInstalling={installMutation.isPending} + /> + ))} +
+ )} +
+
+ + + +
+ +
+

+ About Base Services +

+

+ Base services provide essential infrastructure components for your cluster: +

+
    +
  • Cilium - Network connectivity and security
  • +
  • MetalLB - Load balancer for bare metal clusters
  • +
  • Traefik - Ingress controller and reverse proxy
  • +
  • Cert-Manager - Automatic TLS certificate management
  • +
  • External-DNS - Automatic DNS record management
  • +
+

+ Install these services to enable full cluster functionality. +

+
+
+
+
+
+ ); +} diff --git a/src/router/pages/CentralPage.tsx b/src/router/pages/CentralPage.tsx new file mode 100644 index 0000000..de909a5 --- /dev/null +++ b/src/router/pages/CentralPage.tsx @@ -0,0 +1,10 @@ +import { ErrorBoundary } from '../../components'; +import { CentralComponent } from '../../components/CentralComponent'; + +export function CentralPage() { + return ( + + + + ); +} diff --git a/src/router/pages/CloudPage.tsx b/src/router/pages/CloudPage.tsx new file mode 100644 index 0000000..c1e4e2e --- /dev/null +++ b/src/router/pages/CloudPage.tsx @@ -0,0 +1,10 @@ +import { ErrorBoundary } from '../../components'; +import { CloudComponent } from '../../components/CloudComponent'; + +export function CloudPage() { + return ( + + + + ); +} diff --git a/src/router/pages/ClusterAccessPage.tsx b/src/router/pages/ClusterAccessPage.tsx new file mode 100644 index 0000000..e180dcb --- /dev/null +++ b/src/router/pages/ClusterAccessPage.tsx @@ -0,0 +1,210 @@ +import { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../../components/ui/card'; +import { Button } from '../../components/ui/button'; +import { Skeleton } from '../../components/ui/skeleton'; +import { DownloadButton } from '../../components/DownloadButton'; +import { CopyButton } from '../../components/CopyButton'; +import { ConfigViewer } from '../../components/ConfigViewer'; +import { FileText, AlertTriangle, RefreshCw } from 'lucide-react'; +import { useKubeconfig, useTalosconfig, useRegenerateKubeconfig } from '../../hooks/useClusterAccess'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '../../components/ui/dialog'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../../components/ui/collapsible'; + +export function ClusterAccessPage() { + const { instanceId } = useParams<{ instanceId: string }>(); + const [showKubeconfigPreview, setShowKubeconfigPreview] = useState(false); + const [showTalosconfigPreview, setShowTalosconfigPreview] = useState(false); + const [showRegenerateDialog, setShowRegenerateDialog] = useState(false); + + const { data: kubeconfig, isLoading: kubeconfigLoading, refetch: refetchKubeconfig } = useKubeconfig(instanceId); + const { data: talosconfig, isLoading: talosconfigLoading } = useTalosconfig(instanceId); + const regenerateMutation = useRegenerateKubeconfig(instanceId); + + const handleRegenerate = async () => { + await regenerateMutation.mutateAsync(); + await refetchKubeconfig(); + setShowRegenerateDialog(false); + }; + + if (!instanceId) { + return ( +
+ +
+ +

No instance selected

+
+
+
+ ); + } + + return ( +
+
+

Cluster Access

+

+ Download kubeconfig and talosconfig files +

+
+ + {/* Kubeconfig Card */} + + +
+
+ + + Kubeconfig + + + Configuration file for accessing the Kubernetes cluster with kubectl + +
+
+
+ + {kubeconfigLoading ? ( + + ) : kubeconfig?.kubeconfig ? ( + <> +
+ + + +
+ + + + + + + + + + +
+

Usage:

+ + kubectl --kubeconfig={instanceId}-kubeconfig.yaml get nodes + +

Or set as default:

+ + export KUBECONFIG=~/.kube/{instanceId}-kubeconfig.yaml + +
+ + ) : ( +
+ +

Kubeconfig not available

+

Generate cluster configuration first

+
+ )} +
+
+ + {/* Talosconfig Card */} + + +
+
+ + + Talosconfig + + + Configuration file for accessing Talos nodes with talosctl + +
+
+
+ + {talosconfigLoading ? ( + + ) : talosconfig?.talosconfig ? ( + <> +
+ + +
+ + + + + + + + + + +
+

Usage:

+ + talosctl --talosconfig={instanceId}-talosconfig.yaml get members + +

Or set as default:

+ + export TALOSCONFIG=~/.talos/{instanceId}-talosconfig.yaml + +
+ + ) : ( +
+ +

Talosconfig not available

+

Generate cluster configuration first

+
+ )} +
+
+ + {/* Regenerate Confirmation Dialog */} + + + + Regenerate Kubeconfig + + This will regenerate the kubeconfig file. Any existing kubeconfig files will be invalidated. + Are you sure you want to continue? + + + + + + + + +
+ ); +} diff --git a/src/router/pages/ClusterHealthPage.tsx b/src/router/pages/ClusterHealthPage.tsx new file mode 100644 index 0000000..cc40134 --- /dev/null +++ b/src/router/pages/ClusterHealthPage.tsx @@ -0,0 +1,211 @@ +import { useParams } from 'react-router-dom'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../../components/ui/card'; +import { Badge } from '../../components/ui/badge'; +import { Skeleton } from '../../components/ui/skeleton'; +import { HeartPulse, AlertCircle, Clock } from 'lucide-react'; +import { useClusterHealth, useClusterStatus, useClusterNodes } from '../../services/api'; +import { HealthIndicator } from '../../components/operations/HealthIndicator'; +import { NodeStatusCard } from '../../components/operations/NodeStatusCard'; + +export function ClusterHealthPage() { + const { instanceId } = useParams<{ instanceId: string }>(); + + const { data: health, isLoading: healthLoading, error: healthError } = useClusterHealth(instanceId || ''); + const { data: status, isLoading: statusLoading } = useClusterStatus(instanceId || ''); + const { data: nodes, isLoading: nodesLoading } = useClusterNodes(instanceId || ''); + + if (!instanceId) { + return ( +
+ +
+ +

No instance selected

+
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+

Cluster Health

+

+ Monitor health metrics and node status for {instanceId} +

+
+ + {/* Overall Health Status */} + + +
+
+ + + Overall Health + + + Cluster health aggregated from all checks + +
+ {health && ( + + )} +
+
+ + {healthError ? ( +
+ +

+ Error loading health data +

+

+ {healthError.message} +

+
+ ) : healthLoading ? ( +
+ + + +
+ ) : health && health.checks.length > 0 ? ( +
+ {health.checks.map((check, index) => ( +
+
+ +
+

{check.name}

+ {check.message && ( +

+ {check.message} +

+ )} +
+
+
+ ))} +
+ ) : ( +
+ +

No health data available

+

+ Health checks will appear here once the cluster is running +

+
+ )} +
+
+ + {/* Cluster Information */} +
+ + + Cluster Status + + + {statusLoading ? ( + + ) : status ? ( +
+ + {status.ready ? 'Ready' : 'Not Ready'} + +

+ {status.nodes} nodes total +

+
+ ) : ( +
Unknown
+ )} +
+
+ + + + Kubernetes Version + + + {statusLoading ? ( + + ) : status?.kubernetesVersion ? ( +
+
+ {status.kubernetesVersion} +
+
+ ) : ( +
Not available
+ )} +
+
+ + + + Talos Version + + + {statusLoading ? ( + + ) : status?.talosVersion ? ( +
+
+ {status.talosVersion} +
+
+ ) : ( +
Not available
+ )} +
+
+
+ + {/* Node Status */} + + + Node Status + + Detailed status and information for each node + + + + {nodesLoading ? ( +
+ + + +
+ ) : nodes && nodes.nodes.length > 0 ? ( +
+ {nodes.nodes.map((node) => ( + + ))} +
+ ) : ( +
+ +

No nodes found

+

+ Add nodes to your cluster to see them here +

+
+ )} +
+
+ + {/* Auto-refresh indicator */} +
+ +

Auto-refreshing every 10 seconds

+
+
+ ); +} diff --git a/src/router/pages/ClusterPage.tsx b/src/router/pages/ClusterPage.tsx new file mode 100644 index 0000000..c5a1a26 --- /dev/null +++ b/src/router/pages/ClusterPage.tsx @@ -0,0 +1,11 @@ +import { ErrorBoundary } from '../../components'; +import { ClusterServicesComponent } from '../../components/ClusterServicesComponent'; + +export function ClusterPage() { + // Note: onComplete callback removed as phase management will be handled differently with routing + return ( + + + + ); +} diff --git a/src/router/pages/DashboardPage.tsx b/src/router/pages/DashboardPage.tsx new file mode 100644 index 0000000..13b7b98 --- /dev/null +++ b/src/router/pages/DashboardPage.tsx @@ -0,0 +1,243 @@ +import { useParams, Link } from 'react-router-dom'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardAction } from '../../components/ui/card'; +import { Button } from '../../components/ui/button'; +import { Skeleton } from '../../components/ui/skeleton'; +import { Activity, Server, AlertCircle, RefreshCw, FileText, TrendingUp } from 'lucide-react'; +import { useInstance, useInstanceOperations, useInstanceClusterHealth, useClusterStatus } from '../../services/api'; +import { OperationCard } from '../../components/operations/OperationCard'; +import { HealthIndicator } from '../../components/operations/HealthIndicator'; + +export function DashboardPage() { + const { instanceId } = useParams<{ instanceId: string }>(); + + const { data: instance, isLoading: instanceLoading, refetch: refetchInstance } = useInstance(instanceId || ''); + const { data: operations, isLoading: operationsLoading } = useInstanceOperations(instanceId || '', 5); + const { data: health, isLoading: healthLoading } = useInstanceClusterHealth(instanceId || ''); + const { data: status, isLoading: statusLoading } = useClusterStatus(instanceId || ''); + + const handleRefresh = () => { + refetchInstance(); + }; + + if (!instanceId) { + return ( +
+ +
+ +

No instance selected

+
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Dashboard

+

+ Overview and quick status for {instanceId} +

+
+ +
+ + {/* Status Cards Grid */} +
+ {/* Instance Status */} + + +
+ Instance Status + +
+
+ + {instanceLoading ? ( + + ) : instance ? ( +
+
Active
+

+ Instance configured +

+
+ ) : ( +
+
Unknown
+

+ Unable to load status +

+
+ )} +
+
+ + {/* Cluster Health */} + + +
+ Cluster Health + +
+
+ + {healthLoading ? ( + + ) : health ? ( +
+
+ +
+

+ {health.checks.length} health checks +

+
+ ) : ( +
+
Unknown
+

+ Health data unavailable +

+
+ )} +
+
+ + {/* Node Count */} + + +
+ Nodes + +
+
+ + {statusLoading ? ( + + ) : status ? ( +
+
{status.nodes}
+

+ {status.controlPlaneNodes} control plane, {status.workerNodes} workers +

+
+ ) : ( +
+
-
+

+ No nodes detected +

+
+ )} +
+
+ + {/* K8s Version */} + + +
+ Kubernetes + +
+
+ + {statusLoading ? ( + + ) : status?.kubernetesVersion ? ( +
+
{status.kubernetesVersion}
+

+ {status.ready ? 'Ready' : 'Not ready'} +

+
+ ) : ( +
+
-
+

+ Version unknown +

+
+ )} +
+
+
+ + {/* Cluster Health Details */} + {health && health.checks.length > 0 && ( + + + Health Checks + + Detailed health status of cluster components + + + +
+ {health.checks.map((check, index) => ( +
+
+ + {check.name} +
+ {check.message && ( + {check.message} + )} +
+ ))} +
+
+
+ )} + + {/* Recent Operations */} + + +
+
+ Recent Operations + + Last 5 operations for this instance + +
+ + + +
+
+ + {operationsLoading ? ( +
+ + + +
+ ) : operations && operations.operations.length > 0 ? ( +
+ {operations.operations.map((operation) => ( + + ))} +
+ ) : ( +
+ +

No operations found

+

Operations will appear here as they are created

+
+ )} +
+
+
+ + ); +} diff --git a/src/router/pages/DhcpPage.tsx b/src/router/pages/DhcpPage.tsx new file mode 100644 index 0000000..851c422 --- /dev/null +++ b/src/router/pages/DhcpPage.tsx @@ -0,0 +1,10 @@ +import { ErrorBoundary } from '../../components'; +import { DhcpComponent } from '../../components/DhcpComponent'; + +export function DhcpPage() { + return ( + + + + ); +} diff --git a/src/router/pages/DnsPage.tsx b/src/router/pages/DnsPage.tsx new file mode 100644 index 0000000..8633090 --- /dev/null +++ b/src/router/pages/DnsPage.tsx @@ -0,0 +1,10 @@ +import { ErrorBoundary } from '../../components'; +import { DnsComponent } from '../../components/DnsComponent'; + +export function DnsPage() { + return ( + + + + ); +} diff --git a/src/router/pages/InfrastructurePage.tsx b/src/router/pages/InfrastructurePage.tsx new file mode 100644 index 0000000..86749a9 --- /dev/null +++ b/src/router/pages/InfrastructurePage.tsx @@ -0,0 +1,11 @@ +import { ErrorBoundary } from '../../components'; +import { ClusterNodesComponent } from '../../components/ClusterNodesComponent'; + +export function InfrastructurePage() { + // Note: onComplete callback removed as phase management will be handled differently with routing + return ( + + + + ); +} diff --git a/src/router/pages/IsoPage.tsx b/src/router/pages/IsoPage.tsx new file mode 100644 index 0000000..65dd394 --- /dev/null +++ b/src/router/pages/IsoPage.tsx @@ -0,0 +1,290 @@ +import { useState } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card'; +import { Button } from '../../components/ui/button'; +import { Badge } from '../../components/ui/badge'; +import { + Download, + Trash2, + AlertCircle, + Loader2, + Disc, + BookOpen, + ExternalLink, + CheckCircle, + XCircle, + Usb, +} from 'lucide-react'; +import { usePxeAssets, useDownloadPxeAsset, useDeletePxeAsset } from '../../services/api/hooks/usePxeAssets'; +import { useInstanceContext } from '../../hooks'; +import type { PxeAssetType } from '../../services/api/types/pxe'; + +export function IsoPage() { + const { currentInstance } = useInstanceContext(); + const { data, isLoading, error } = usePxeAssets(currentInstance); + const downloadAsset = useDownloadPxeAsset(); + const deleteAsset = useDeletePxeAsset(); + const [downloadingType, setDownloadingType] = useState(null); + const [selectedVersion, setSelectedVersion] = useState('v1.8.0'); + + // Filter to show only ISO assets + const isoAssets = data?.assets.filter((asset) => asset.type === 'iso') || []; + + const handleDownload = async (type: PxeAssetType) => { + if (!currentInstance) return; + + setDownloadingType(type); + try { + const url = `https://github.com/siderolabs/talos/releases/download/${selectedVersion}/metal-amd64.iso`; + await downloadAsset.mutateAsync({ + instanceName: currentInstance, + request: { type, version: selectedVersion, url }, + }); + } catch (err) { + console.error('Download failed:', err); + } finally { + setDownloadingType(null); + } + }; + + const handleDelete = async (type: PxeAssetType) => { + if (!currentInstance) return; + + await deleteAsset.mutateAsync({ instanceName: currentInstance, type }); + }; + + const getStatusBadge = (status?: string) => { + const statusValue = status || 'missing'; + const variants: Record = { + available: 'success', + missing: 'secondary', + downloading: 'warning', + error: 'destructive', + }; + + const icons: Record = { + available: , + missing: , + downloading: , + error: , + }; + + return ( + + {icons[statusValue]} + {statusValue.charAt(0).toUpperCase() + statusValue.slice(1)} + + ); + }; + + const getAssetIcon = (type: string) => { + switch (type) { + case 'iso': + return ; + default: + return ; + } + }; + + return ( +
+ {/* Educational Intro Section */} + +
+
+ +
+
+

+ What is a Bootable ISO? +

+

+ A bootable ISO is a special disk image file that can be written to a USB drive or DVD to create + installation media. When you boot a computer from this USB drive, it can install or run an + operating system directly from the drive without needing anything pre-installed. +

+

+ This is perfect for setting up individual computers in your cloud infrastructure. Download the + Talos ISO here, write it to a USB drive using tools like Balena Etcher or Rufus, then boot + your computer from the USB to install Talos Linux. +

+ +
+
+
+ + + +
+
+ +
+
+ ISO Management + + Download Talos ISO images for creating bootable USB drives + +
+
+
+ + {!currentInstance ? ( +
+ +

No Instance Selected

+

+ Please select or create an instance to manage ISO images. +

+
+ ) : error ? ( +
+ +

Error Loading ISO

+

{(error as Error).message}

+ +
+ ) : ( +
+ {/* Version Selection */} +
+ + +
+ + {/* ISO Asset */} +
+

ISO Image

+ {isLoading ? ( +
+ +
+ ) : isoAssets.length === 0 ? ( + + +

No ISO Available

+

+ Download a Talos ISO to get started with USB boot. +

+ +
+ ) : ( +
+ {isoAssets.map((asset) => ( + +
+
{getAssetIcon(asset.type)}
+
+
+
Talos ISO
+ {getStatusBadge(asset.status)} +
+
+ {asset.version &&
Version: {asset.version}
} + {asset.size &&
Size: {asset.size}
} + {asset.path && ( +
{asset.path}
+ )} + {asset.error && ( +
{asset.error}
+ )} +
+
+
+ {asset.status !== 'available' && asset.status !== 'downloading' && ( + + )} + {asset.status === 'available' && ( + <> + + + + )} +
+
+
+ ))} +
+ )} +
+ + {/* Instructions Card */} + +

+ + Next Steps +

+
    +
  1. Download the ISO image above
  2. +
  3. Download a USB writing tool (e.g., Balena Etcher, Rufus, or dd)
  4. +
  5. Write the ISO to a USB drive (minimum 2GB)
  6. +
  7. Boot your target computer from the USB drive
  8. +
  9. Follow the Talos installation process
  10. +
+
+
+ )} +
+
+
+ ); +} diff --git a/src/router/pages/LandingPage.tsx b/src/router/pages/LandingPage.tsx new file mode 100644 index 0000000..f7a8aed --- /dev/null +++ b/src/router/pages/LandingPage.tsx @@ -0,0 +1,40 @@ +import { useNavigate } from 'react-router'; +import { useInstanceContext } from '../../hooks/useInstanceContext'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../../components/ui/card'; +import { Button } from '../../components/ui/button'; +import { Server } from 'lucide-react'; + +export function LandingPage() { + const navigate = useNavigate(); + const { currentInstance } = useInstanceContext(); + + // For now, we'll use a default instance + // In the future, this will show an instance selector + const handleSelectInstance = () => { + const instanceId = currentInstance || 'default'; + navigate(`/instances/${instanceId}/dashboard`); + }; + + return ( +
+ + + Wild Cloud + + Select an instance to manage your cloud infrastructure + + + + + + +
+ ); +} diff --git a/src/router/pages/NotFoundPage.tsx b/src/router/pages/NotFoundPage.tsx new file mode 100644 index 0000000..4ef4fba --- /dev/null +++ b/src/router/pages/NotFoundPage.tsx @@ -0,0 +1,30 @@ +import { Link } from 'react-router'; +import { AlertCircle, Home } from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../../components/ui/card'; +import { Button } from '../../components/ui/button'; + +export function NotFoundPage() { + return ( +
+ + +
+ +
+ Page Not Found + + The page you're looking for doesn't exist or has been moved. + +
+ + + + + +
+
+ ); +} diff --git a/src/router/pages/OperationsPage.tsx b/src/router/pages/OperationsPage.tsx new file mode 100644 index 0000000..312745c --- /dev/null +++ b/src/router/pages/OperationsPage.tsx @@ -0,0 +1,209 @@ +import { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../../components/ui/card'; +import { Button } from '../../components/ui/button'; +import { Badge } from '../../components/ui/badge'; +import { Skeleton } from '../../components/ui/skeleton'; +import { Activity, AlertCircle, Filter } from 'lucide-react'; +import { useOperations } from '../../services/api'; +import { OperationCard } from '../../components/operations/OperationCard'; + +type FilterType = 'all' | 'running' | 'completed' | 'failed'; + +export function OperationsPage() { + const { instanceId } = useParams<{ instanceId: string }>(); + const [filter, setFilter] = useState('all'); + + const filterForApi = filter === 'all' ? undefined : filter; + const { data, isLoading, error } = useOperations(instanceId || '', filterForApi); + + const getFilterCount = (type: FilterType) => { + if (!data) return 0; + if (type === 'all') return data.operations.length; + + if (type === 'running') { + return data.operations.filter(op => + op.status === 'running' || op.status === 'pending' + ).length; + } + + return data.operations.filter(op => op.status === type).length; + }; + + const runningCount = getFilterCount('running'); + const completedCount = getFilterCount('completed'); + const failedCount = getFilterCount('failed'); + + if (!instanceId) { + return ( +
+ +
+ +

No instance selected

+
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+

Operations

+

+ Monitor and manage operations for {instanceId} +

+
+ + {/* Summary Cards */} +
+ + + Running + + +
{runningCount}
+

+ Active operations +

+
+
+ + + + Completed + + +
{completedCount}
+

+ Successfully finished +

+
+
+ + + + Failed + + +
{failedCount}
+

+ Encountered errors +

+
+
+
+ + {/* Filters */} + + +
+
+ + + Operations + + + Real-time operation monitoring with auto-refresh + +
+
+ +
+ + + + +
+
+
+
+ + + {error ? ( +
+ +

+ Error loading operations +

+

+ {error.message} +

+
+ ) : isLoading ? ( +
+ + + +
+ ) : data && data.operations.length > 0 ? ( +
+ {data.operations.map((operation) => ( + + ))} +
+ ) : ( +
+ +

No operations found

+

+ {filter === 'all' + ? 'Operations will appear here as they are created' + : `No ${filter} operations at this time`} +

+
+ )} +
+
+ + {/* Auto-refresh indicator */} +
+

+ Auto-refreshing every 3 seconds +

+
+
+ ); +} diff --git a/src/router/pages/PxePage.tsx b/src/router/pages/PxePage.tsx new file mode 100644 index 0000000..8f904fa --- /dev/null +++ b/src/router/pages/PxePage.tsx @@ -0,0 +1,281 @@ +import { useState } from 'react'; +import { ErrorBoundary } from '../../components'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../../components/ui/card'; +import { Button } from '../../components/ui/button'; +import { Badge } from '../../components/ui/badge'; +import { + HardDrive, + BookOpen, + ExternalLink, + Download, + Trash2, + Loader2, + CheckCircle, + AlertCircle, + FileArchive, +} from 'lucide-react'; +import { useInstanceContext } from '../../hooks/useInstanceContext'; +import { + usePxeAssets, + useDownloadPxeAsset, + useDeletePxeAsset, +} from '../../services/api'; +import type { PxeAssetType } from '../../services/api'; + +export function PxePage() { + const { currentInstance } = useInstanceContext(); + const { data, isLoading, error } = usePxeAssets(currentInstance); + const downloadAsset = useDownloadPxeAsset(); + const deleteAsset = useDeletePxeAsset(); + + const [selectedVersion, setSelectedVersion] = useState('v1.8.0'); + const [downloadingType, setDownloadingType] = useState(null); + + const handleDownload = (type: PxeAssetType) => { + if (!currentInstance) return; + + setDownloadingType(type); + + // Build URL based on asset type + let url = ''; + if (type === 'kernel') { + url = `https://github.com/siderolabs/talos/releases/download/${selectedVersion}/kernel-amd64`; + } else if (type === 'initramfs') { + url = `https://github.com/siderolabs/talos/releases/download/${selectedVersion}/initramfs-amd64.xz`; + } + + downloadAsset.mutate( + { + instanceName: currentInstance, + request: { type, version: selectedVersion, url }, + }, + { + onSettled: () => setDownloadingType(null), + } + ); + }; + + const handleDelete = (type: PxeAssetType) => { + if (!currentInstance) return; + + if (confirm(`Are you sure you want to delete the ${type} asset?`)) { + deleteAsset.mutate({ instanceName: currentInstance, type }); + } + }; + + const getStatusBadge = (status?: string) => { + // Default to 'missing' if status is undefined + const statusValue = status || 'missing'; + + const variants: Record = { + available: 'success', + missing: 'secondary', + downloading: 'default', + error: 'destructive', + }; + + const icons: Record = { + available: , + missing: , + downloading: , + error: , + }; + + return ( + + {icons[statusValue]} + {statusValue.charAt(0).toUpperCase() + statusValue.slice(1)} + + ); + }; + + const getAssetIcon = (type: string) => { + switch (type) { + case 'kernel': + return ; + case 'initramfs': + return ; + case 'iso': + return ; + default: + return ; + } + }; + + return ( + +
+ {/* Educational Intro Section */} + +
+
+ +
+
+

+ What is PXE Boot? +

+

+ 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. +

+

+ 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. +

+ +
+
+
+ + + +
+
+ +
+
+ PXE Configuration + + Manage PXE boot assets and network boot configuration + +
+
+
+ + {!currentInstance ? ( +
+ +

No Instance Selected

+

+ Please select or create an instance to manage PXE assets. +

+
+ ) : error ? ( +
+ +

Error Loading Assets

+

{(error as Error).message}

+ +
+ ) : ( +
+ {/* Version Selection */} +
+ + +
+ + {/* Assets List */} +
+

Boot Assets

+ {isLoading ? ( +
+ +
+ ) : ( +
+ {data?.assets.filter((asset) => asset.type !== 'iso').map((asset) => ( + +
+
{getAssetIcon(asset.type)}
+
+
+
{asset.type}
+ {getStatusBadge(asset.status)} +
+
+ {asset.version &&
Version: {asset.version}
} + {asset.size &&
Size: {asset.size}
} + {asset.path && ( +
{asset.path}
+ )} + {asset.error && ( +
{asset.error}
+ )} +
+
+
+ {asset.status !== 'available' && asset.status !== 'downloading' && ( + + )} + {asset.status === 'available' && ( + + )} +
+
+
+ ))} +
+ )} +
+ + {/* Download All Button */} + {data?.assets && data.assets.some((a) => a.status !== 'available') && ( +
+ +
+ )} +
+ )} +
+
+
+
+ ); +} diff --git a/src/router/pages/SecretsPage.tsx b/src/router/pages/SecretsPage.tsx new file mode 100644 index 0000000..534f125 --- /dev/null +++ b/src/router/pages/SecretsPage.tsx @@ -0,0 +1,211 @@ +import { useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../../components/ui/card'; +import { Button } from '../../components/ui/button'; +import { Label } from '../../components/ui/label'; +import { Skeleton } from '../../components/ui/skeleton'; +import { SecretInput } from '../../components/SecretInput'; +import { Key, AlertTriangle, Save, X } from 'lucide-react'; +import { useSecrets, useUpdateSecrets } from '../../hooks/useSecrets'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '../../components/ui/dialog'; + +export function SecretsPage() { + const { instanceId } = useParams<{ instanceId: string }>(); + const [isEditing, setIsEditing] = useState(false); + const [editedSecrets, setEditedSecrets] = useState>({}); + const [showConfirmDialog, setShowConfirmDialog] = useState(false); + + const { data: secrets, isLoading } = useSecrets(instanceId, true); + const updateMutation = useUpdateSecrets(instanceId); + + const handleEdit = () => { + setEditedSecrets(secrets || {}); + setIsEditing(true); + }; + + const handleCancel = () => { + setIsEditing(false); + setEditedSecrets({}); + }; + + const handleSave = () => { + setShowConfirmDialog(true); + }; + + const confirmSave = async () => { + await updateMutation.mutateAsync(editedSecrets); + setShowConfirmDialog(false); + setIsEditing(false); + setEditedSecrets({}); + }; + + const handleSecretChange = (path: string, value: string) => { + setEditedSecrets((prev) => { + const updated = { ...prev }; + // Support nested paths using dot notation + const keys = path.split('.'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let current: any = updated; + for (let i = 0; i < keys.length - 1; i++) { + if (!current[keys[i]]) current[keys[i]] = {}; + current = current[keys[i]]; + } + current[keys[keys.length - 1]] = value; + return updated; + }); + }; + + // Flatten nested object into dot-notation paths + const flattenSecrets = (obj: Record, prefix = ''): Array<{ path: string; value: string }> => { + const result: Array<{ path: string; value: string }> = []; + for (const [key, value] of Object.entries(obj)) { + const path = prefix ? `${prefix}.${key}` : key; + if (value && typeof value === 'object' && !Array.isArray(value)) { + result.push(...flattenSecrets(value as Record, path)); + } else { + result.push({ path, value: String(value || '') }); + } + } + return result; + }; + + const getValue = (obj: Record, path: string): string => { + const keys = path.split('.'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let current: any = obj; + for (const key of keys) { + if (!current || typeof current !== 'object') return ''; + current = current[key]; + } + return String(current || ''); + }; + + if (!instanceId) { + return ( +
+ +
+ +

No instance selected

+
+
+
+ ); + } + + const secretsList = secrets ? flattenSecrets(secrets) : []; + + return ( +
+
+
+

Secrets Management

+

+ Manage instance secrets securely +

+
+ {!isEditing ? ( + + ) : ( +
+ + +
+ )} +
+ + {isEditing && ( + + +
+ +
+

+ Security Warning +

+

+ You are editing sensitive secrets. Make sure you are in a secure environment. + Changes will be saved immediately and cannot be undone. +

+
+
+
+
+ )} + + + + + + Instance Secrets + + + {isEditing ? 'Edit secret values below' : 'View encrypted secrets for this instance'} + + + + {isLoading ? ( +
+ + + +
+ ) : secretsList.length === 0 ? ( +
+ +

No secrets found

+

Secrets will appear here once configured

+
+ ) : ( +
+ {secretsList.map(({ path, value }) => ( +
+ + handleSecretChange(path, newValue) : undefined} + readOnly={!isEditing} + /> +
+ ))} +
+ )} +
+
+ + + + + Confirm Save + + Are you sure you want to save these secret changes? This action cannot be undone. + + + + + + + + +
+ ); +} diff --git a/src/router/pages/UtilitiesPage.tsx b/src/router/pages/UtilitiesPage.tsx new file mode 100644 index 0000000..b890a5d --- /dev/null +++ b/src/router/pages/UtilitiesPage.tsx @@ -0,0 +1,182 @@ +import { useState } from 'react'; +import { UtilityCard, CopyableValue } from '../../components/UtilityCard'; +import { Button } from '../../components/ui/button'; +import { + Key, + Info, + Network, + Server, + Copy, + AlertCircle, +} from 'lucide-react'; +import { + useDashboardToken, + useClusterVersions, + useNodeIPs, + useControlPlaneIP, + useCopySecret, +} from '../../services/api/hooks/useUtilities'; + +export function UtilitiesPage() { + const [secretToCopy, setSecretToCopy] = useState(''); + const [targetInstance, setTargetInstance] = useState(''); + + const dashboardToken = useDashboardToken(); + const versions = useClusterVersions(); + const nodeIPs = useNodeIPs(); + const controlPlaneIP = useControlPlaneIP(); + const copySecret = useCopySecret(); + + const handleCopySecret = () => { + if (secretToCopy && targetInstance) { + copySecret.mutate({ secret: secretToCopy, targetInstance }); + } + }; + + return ( +
+
+

Utilities

+

+ Additional tools and utilities for cluster management +

+
+ +
+ {/* Dashboard Token */} + } + isLoading={dashboardToken.isLoading} + error={dashboardToken.error} + > + {dashboardToken.data && ( + + )} + + + {/* Cluster Versions */} + } + isLoading={versions.isLoading} + error={versions.error} + > + {versions.data && ( +
+
+ Kubernetes + {versions.data.version} +
+ {Object.entries(versions.data) + .filter(([key]) => key !== 'version') + .map(([key, value]) => ( +
+ + {key.replace(/([A-Z])/g, ' $1').trim()} + + {String(value)} +
+ ))} +
+ )} +
+ + {/* Node IPs */} + } + isLoading={nodeIPs.isLoading} + error={nodeIPs.error} + > + {nodeIPs.data && ( +
+ {nodeIPs.data.ips.map((ip, index) => ( + + ))} + {nodeIPs.data.ips.length === 0 && ( +
+ + No nodes found +
+ )} +
+ )} +
+ + {/* Control Plane IP */} + } + isLoading={controlPlaneIP.isLoading} + error={controlPlaneIP.error} + > + {controlPlaneIP.data && ( + + )} + + + {/* Secret Copy Utility */} + } + > +
+
+ + setSecretToCopy(e.target.value)} + className="w-full px-3 py-2 border rounded-lg bg-background" + /> +
+
+ + setTargetInstance(e.target.value)} + className="w-full px-3 py-2 border rounded-lg bg-background" + /> +
+ + {copySecret.isSuccess && ( +
+ Secret copied successfully! +
+ )} + {copySecret.error && ( +
+ + {copySecret.error.message} +
+ )} +
+
+
+
+ ); +} diff --git a/src/router/routes.tsx b/src/router/routes.tsx new file mode 100644 index 0000000..cfd7ca5 --- /dev/null +++ b/src/router/routes.tsx @@ -0,0 +1,111 @@ +import { Navigate } from 'react-router'; +import type { RouteObject } from 'react-router'; +import { InstanceLayout } from './InstanceLayout'; +import { LandingPage } from './pages/LandingPage'; +import { NotFoundPage } from './pages/NotFoundPage'; +import { DashboardPage } from './pages/DashboardPage'; +import { OperationsPage } from './pages/OperationsPage'; +import { ClusterHealthPage } from './pages/ClusterHealthPage'; +import { ClusterAccessPage } from './pages/ClusterAccessPage'; +import { SecretsPage } from './pages/SecretsPage'; +import { BaseServicesPage } from './pages/BaseServicesPage'; +import { UtilitiesPage } from './pages/UtilitiesPage'; +import { CloudPage } from './pages/CloudPage'; +import { CentralPage } from './pages/CentralPage'; +import { DnsPage } from './pages/DnsPage'; +import { DhcpPage } from './pages/DhcpPage'; +import { PxePage } from './pages/PxePage'; +import { IsoPage } from './pages/IsoPage'; +import { InfrastructurePage } from './pages/InfrastructurePage'; +import { ClusterPage } from './pages/ClusterPage'; +import { AppsPage } from './pages/AppsPage'; +import { AdvancedPage } from './pages/AdvancedPage'; + +export const routes: RouteObject[] = [ + { + path: '/', + element: , + }, + { + path: '/instances/:instanceId', + element: , + children: [ + { + index: true, + element: , + }, + { + path: 'dashboard', + element: , + }, + { + path: 'operations', + element: , + }, + { + path: 'cluster/health', + element: , + }, + { + path: 'cluster/access', + element: , + }, + { + path: 'secrets', + element: , + }, + { + path: 'services', + element: , + }, + { + path: 'utilities', + element: , + }, + { + path: 'cloud', + element: , + }, + { + path: 'central', + element: , + }, + { + path: 'dns', + element: , + }, + { + path: 'dhcp', + element: , + }, + { + path: 'pxe', + element: , + }, + { + path: 'iso', + element: , + }, + { + path: 'infrastructure', + element: , + }, + { + path: 'cluster', + element: , + }, + { + path: 'apps', + element: , + }, + { + path: 'advanced', + element: , + }, + ], + }, + { + path: '*', + element: , + }, +]; diff --git a/src/services/api-legacy.ts b/src/services/api-legacy.ts new file mode 100644 index 0000000..c53444c --- /dev/null +++ b/src/services/api-legacy.ts @@ -0,0 +1,92 @@ +import type { Status, ConfigResponse, Config, HealthResponse, StatusResponse } from '../types'; + +const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5055'; + +class ApiService { + private baseUrl: string; + + constructor(baseUrl: string = API_BASE) { + this.baseUrl = baseUrl; + } + + private async request(endpoint: string, options?: RequestInit): Promise { + 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 { + 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 { + return this.request('/api/status'); + } + + async getHealth(): Promise { + return this.request('/api/v1/health'); + } + + async getConfig(): Promise { + return this.request('/api/v1/config'); + } + + async getConfigYaml(): Promise { + return this.requestText('/api/v1/config/yaml'); + } + + async updateConfigYaml(yamlContent: string): Promise { + return this.request('/api/v1/config/yaml', { + method: 'PUT', + headers: { 'Content-Type': 'text/plain' }, + body: yamlContent + }); + } + + async createConfig(config: Config): Promise { + return this.request('/api/v1/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config) + }); + } + + async updateConfig(config: Config): Promise { + return this.request('/api/v1/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config) + }); + } + + async getDnsmasqConfig(): Promise { + return this.requestText('/api/v1/dnsmasq/config'); + } + + async restartDnsmasq(): Promise { + return this.request('/api/v1/dnsmasq/restart', { + method: 'POST' + }); + } + + async downloadPXEAssets(): Promise { + return this.request('/api/v1/pxe/assets', { + method: 'POST' + }); + } +} + +export const apiService = new ApiService(); +export default ApiService; \ No newline at end of file diff --git a/src/services/api.ts b/src/services/api.ts index 3c69b0d..9d598a1 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -1,92 +1,3 @@ -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(endpoint: string, options?: RequestInit): Promise { - 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 { - 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 { - return this.request('/api/status'); - } - - async getHealth(): Promise { - return this.request('/api/v1/health'); - } - - async getConfig(): Promise { - return this.request('/api/v1/config'); - } - - async getConfigYaml(): Promise { - return this.requestText('/api/v1/config/yaml'); - } - - async updateConfigYaml(yamlContent: string): Promise { - return this.request('/api/v1/config/yaml', { - method: 'PUT', - headers: { 'Content-Type': 'text/plain' }, - body: yamlContent - }); - } - - async createConfig(config: Config): Promise { - return this.request('/api/v1/config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(config) - }); - } - - async updateConfig(config: Config): Promise { - return this.request('/api/v1/config', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(config) - }); - } - - async getDnsmasqConfig(): Promise { - return this.requestText('/api/v1/dnsmasq/config'); - } - - async restartDnsmasq(): Promise { - return this.request('/api/v1/dnsmasq/restart', { - method: 'POST' - }); - } - - async downloadPXEAssets(): Promise { - return this.request('/api/v1/pxe/assets', { - method: 'POST' - }); - } -} - -export const apiService = new ApiService(); -export default ApiService; \ No newline at end of file +// Re-export everything from the modular API structure +// This file maintains backward compatibility for imports from '../services/api' +export * from './api/index'; diff --git a/src/services/api/apps.ts b/src/services/api/apps.ts new file mode 100644 index 0000000..070c895 --- /dev/null +++ b/src/services/api/apps.ts @@ -0,0 +1,54 @@ +import { apiClient } from './client'; +import type { + AppListResponse, + App, + AppAddRequest, + AppAddResponse, + AppStatus, + OperationResponse, +} from './types'; + +export const appsApi = { + // Available apps (from catalog) + async listAvailable(): Promise { + return apiClient.get('/api/v1/apps'); + }, + + async getAvailable(appName: string): Promise { + return apiClient.get(`/api/v1/apps/${appName}`); + }, + + // Deployed apps (instance-specific) + async listDeployed(instanceName: string): Promise { + return apiClient.get(`/api/v1/instances/${instanceName}/apps`); + }, + + async add(instanceName: string, app: AppAddRequest): Promise { + return apiClient.post(`/api/v1/instances/${instanceName}/apps`, app); + }, + + async deploy(instanceName: string, appName: string): Promise { + return apiClient.post(`/api/v1/instances/${instanceName}/apps/${appName}/deploy`); + }, + + async delete(instanceName: string, appName: string): Promise { + return apiClient.delete(`/api/v1/instances/${instanceName}/apps/${appName}`); + }, + + async getStatus(instanceName: string, appName: string): Promise { + return apiClient.get(`/api/v1/instances/${instanceName}/apps/${appName}/status`); + }, + + // Backup operations + async backup(instanceName: string, appName: string): Promise { + return apiClient.post(`/api/v1/instances/${instanceName}/apps/${appName}/backup`); + }, + + async listBackups(instanceName: string, appName: string): Promise<{ backups: Array<{ id: string; timestamp: string; size?: string }> }> { + return apiClient.get(`/api/v1/instances/${instanceName}/apps/${appName}/backup`); + }, + + async restore(instanceName: string, appName: string, backupId: string): Promise { + return apiClient.post(`/api/v1/instances/${instanceName}/apps/${appName}/restore`, { backup_id: backupId }); + }, +}; diff --git a/src/services/api/client.ts b/src/services/api/client.ts new file mode 100644 index 0000000..0ea3eb2 --- /dev/null +++ b/src/services/api/client.ts @@ -0,0 +1,122 @@ +export class ApiError extends Error { + constructor( + message: string, + public statusCode: number, + public details?: unknown + ) { + super(message); + this.name = 'ApiError'; + } +} + +export class ApiClient { + constructor(private baseUrl: string = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5055') {} + + private async request( + endpoint: string, + options?: RequestInit + ): Promise { + const url = `${this.baseUrl}${endpoint}`; + + try { + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new ApiError( + errorData.error || `HTTP ${response.status}: ${response.statusText}`, + response.status, + errorData + ); + } + + // Handle 204 No Content + if (response.status === 204) { + return {} as T; + } + + return response.json(); + } catch (error) { + if (error instanceof ApiError) { + throw error; + } + throw new ApiError( + error instanceof Error ? error.message : 'Network error', + 0 + ); + } + } + + async get(endpoint: string): Promise { + return this.request(endpoint, { method: 'GET' }); + } + + async post(endpoint: string, data?: unknown): Promise { + return this.request(endpoint, { + method: 'POST', + body: data ? JSON.stringify(data) : undefined, + }); + } + + async put(endpoint: string, data?: unknown): Promise { + return this.request(endpoint, { + method: 'PUT', + body: data ? JSON.stringify(data) : undefined, + }); + } + + async patch(endpoint: string, data?: unknown): Promise { + return this.request(endpoint, { + method: 'PATCH', + body: data ? JSON.stringify(data) : undefined, + }); + } + + async delete(endpoint: string): Promise { + return this.request(endpoint, { method: 'DELETE' }); + } + + async getText(endpoint: string): Promise { + const url = `${this.baseUrl}${endpoint}`; + const response = await fetch(url); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new ApiError( + errorData.error || `HTTP ${response.status}: ${response.statusText}`, + response.status, + errorData + ); + } + + return response.text(); + } + + async putText(endpoint: string, text: string): Promise<{ message?: string; [key: string]: unknown }> { + const url = `${this.baseUrl}${endpoint}`; + const response = await fetch(url, { + method: 'PUT', + headers: { 'Content-Type': 'text/plain' }, + body: text, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new ApiError( + errorData.error || `HTTP ${response.status}: ${response.statusText}`, + response.status, + errorData + ); + } + + return response.json(); + } +} + +export const apiClient = new ApiClient(); diff --git a/src/services/api/cluster.ts b/src/services/api/cluster.ts new file mode 100644 index 0000000..fe465d8 --- /dev/null +++ b/src/services/api/cluster.ts @@ -0,0 +1,47 @@ +import { apiClient } from './client'; +import type { + ClusterConfig, + ClusterStatus, + ClusterHealthResponse, + KubeconfigResponse, + TalosconfigResponse, + OperationResponse, +} from './types'; + +export const clusterApi = { + async generateConfig(instanceName: string, config: ClusterConfig): Promise { + return apiClient.post(`/api/v1/instances/${instanceName}/cluster/config/generate`, config); + }, + + async bootstrap(instanceName: string, node: string): Promise { + return apiClient.post(`/api/v1/instances/${instanceName}/cluster/bootstrap`, { node }); + }, + + async configureEndpoints(instanceName: string, includeNodes = false): Promise { + return apiClient.post(`/api/v1/instances/${instanceName}/cluster/endpoints`, { include_nodes: includeNodes }); + }, + + async getStatus(instanceName: string): Promise { + return apiClient.get(`/api/v1/instances/${instanceName}/cluster/status`); + }, + + async getHealth(instanceName: string): Promise { + return apiClient.get(`/api/v1/instances/${instanceName}/cluster/health`); + }, + + async getKubeconfig(instanceName: string): Promise { + return apiClient.get(`/api/v1/instances/${instanceName}/cluster/kubeconfig`); + }, + + async generateKubeconfig(instanceName: string): Promise { + return apiClient.post(`/api/v1/instances/${instanceName}/cluster/kubeconfig/generate`); + }, + + async getTalosconfig(instanceName: string): Promise { + return apiClient.get(`/api/v1/instances/${instanceName}/cluster/talosconfig`); + }, + + async reset(instanceName: string, confirm: boolean): Promise { + return apiClient.post(`/api/v1/instances/${instanceName}/cluster/reset`, { confirm }); + }, +}; diff --git a/src/services/api/context.ts b/src/services/api/context.ts new file mode 100644 index 0000000..7c774ec --- /dev/null +++ b/src/services/api/context.ts @@ -0,0 +1,12 @@ +import { apiClient } from './client'; +import type { ContextResponse, SetContextResponse } from './types'; + +export const contextApi = { + async get(): Promise { + return apiClient.get('/api/v1/context'); + }, + + async set(context: string): Promise { + return apiClient.post('/api/v1/context', { context }); + }, +}; diff --git a/src/services/api/dnsmasq.ts b/src/services/api/dnsmasq.ts new file mode 100644 index 0000000..2863756 --- /dev/null +++ b/src/services/api/dnsmasq.ts @@ -0,0 +1,28 @@ +import { apiClient } from './client'; + +export interface DnsmasqStatus { + running: boolean; + status?: string; +} + +export const dnsmasqApi = { + async getStatus(): Promise { + return apiClient.get('/api/v1/dnsmasq/status'); + }, + + async getConfig(): Promise { + return apiClient.getText('/api/v1/dnsmasq/config'); + }, + + async restart(): Promise<{ message: string }> { + return apiClient.post('/api/v1/dnsmasq/restart'); + }, + + async generate(): Promise<{ message: string }> { + return apiClient.post('/api/v1/dnsmasq/generate'); + }, + + async update(): Promise<{ message: string }> { + return apiClient.post('/api/v1/dnsmasq/update'); + }, +}; diff --git a/src/services/api/hooks/useCluster.ts b/src/services/api/hooks/useCluster.ts new file mode 100644 index 0000000..2b41c37 --- /dev/null +++ b/src/services/api/hooks/useCluster.ts @@ -0,0 +1,33 @@ +import { useQuery } from '@tanstack/react-query'; +import { clusterApi, nodesApi } from '..'; +import type { ClusterHealthResponse, ClusterStatus, NodeListResponse } from '../types'; + +export const useClusterHealth = (instanceName: string) => { + return useQuery({ + queryKey: ['cluster-health', instanceName], + queryFn: () => clusterApi.getHealth(instanceName), + enabled: !!instanceName, + refetchInterval: 10000, // Auto-refresh every 10 seconds + staleTime: 5000, + }); +}; + +export const useClusterStatus = (instanceName: string) => { + return useQuery({ + queryKey: ['cluster-status', instanceName], + queryFn: () => clusterApi.getStatus(instanceName), + enabled: !!instanceName, + refetchInterval: 10000, // Auto-refresh every 10 seconds + staleTime: 5000, + }); +}; + +export const useClusterNodes = (instanceName: string) => { + return useQuery({ + queryKey: ['cluster-nodes', instanceName], + queryFn: () => nodesApi.list(instanceName), + enabled: !!instanceName, + refetchInterval: 10000, // Auto-refresh every 10 seconds + staleTime: 5000, + }); +}; diff --git a/src/services/api/hooks/useInstance.ts b/src/services/api/hooks/useInstance.ts new file mode 100644 index 0000000..80ea474 --- /dev/null +++ b/src/services/api/hooks/useInstance.ts @@ -0,0 +1,40 @@ +import { useQuery } from '@tanstack/react-query'; +import { instancesApi, operationsApi, clusterApi } from '..'; +import type { GetInstanceResponse, OperationListResponse, ClusterHealthResponse } from '../types'; + +export const useInstance = (name: string) => { + return useQuery({ + queryKey: ['instance', name], + queryFn: () => instancesApi.get(name), + enabled: !!name, + staleTime: 30000, // 30 seconds + }); +}; + +export const useInstanceOperations = (instanceName: string, limit?: number) => { + return useQuery({ + queryKey: ['instance-operations', instanceName], + queryFn: async () => { + const response = await operationsApi.list(instanceName); + if (limit) { + return { + operations: response.operations.slice(0, limit) + }; + } + return response; + }, + enabled: !!instanceName, + refetchInterval: 3000, // Poll every 3 seconds + staleTime: 1000, + }); +}; + +export const useInstanceClusterHealth = (instanceName: string) => { + return useQuery({ + queryKey: ['instance-cluster-health', instanceName], + queryFn: () => clusterApi.getHealth(instanceName), + enabled: !!instanceName, + refetchInterval: 10000, // Refresh every 10 seconds + staleTime: 5000, + }); +}; diff --git a/src/services/api/hooks/useOperations.ts b/src/services/api/hooks/useOperations.ts new file mode 100644 index 0000000..c97f7d4 --- /dev/null +++ b/src/services/api/hooks/useOperations.ts @@ -0,0 +1,58 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { operationsApi } from '../operations'; +import type { OperationListResponse, Operation } from '../types'; + +export const useOperations = (instanceName: string, filter?: 'running' | 'completed' | 'failed') => { + return useQuery({ + queryKey: ['operations', instanceName, filter], + queryFn: async () => { + const response = await operationsApi.list(instanceName); + + if (filter) { + const filtered = response.operations.filter(op => { + if (filter === 'running') return op.status === 'running' || op.status === 'pending'; + if (filter === 'completed') return op.status === 'completed'; + if (filter === 'failed') return op.status === 'failed'; + return true; + }); + return { operations: filtered }; + } + + return response; + }, + enabled: !!instanceName, + refetchInterval: 3000, // Poll every 3 seconds for real-time updates + staleTime: 1000, + }); +}; + +export const useOperation = (operationId: string) => { + return useQuery({ + queryKey: ['operation', operationId], + queryFn: () => operationsApi.get(operationId), + enabled: !!operationId, + refetchInterval: (query) => { + // Stop polling if operation is completed, failed, or cancelled + const status = query.state.data?.status; + if (status === 'completed' || status === 'failed' || status === 'cancelled') { + return false; + } + return 2000; // Poll every 2 seconds while running + }, + staleTime: 1000, + }); +}; + +export const useCancelOperation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ operationId, instanceName }: { operationId: string; instanceName: string }) => + operationsApi.cancel(operationId, instanceName), + onSuccess: (_, { operationId }) => { + // Invalidate operation queries to refresh data + queryClient.invalidateQueries({ queryKey: ['operation', operationId] }); + queryClient.invalidateQueries({ queryKey: ['operations'] }); + }, + }); +}; diff --git a/src/services/api/hooks/usePxeAssets.ts b/src/services/api/hooks/usePxeAssets.ts new file mode 100644 index 0000000..a25c274 --- /dev/null +++ b/src/services/api/hooks/usePxeAssets.ts @@ -0,0 +1,57 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { pxeApi } from '../pxe'; +import type { DownloadAssetRequest, PxeAssetType } from '../types'; + +export function usePxeAssets(instanceName: string | null | undefined) { + return useQuery({ + queryKey: ['instances', instanceName, 'pxe', 'assets'], + queryFn: () => pxeApi.listAssets(instanceName!), + enabled: !!instanceName, + refetchInterval: 5000, // Poll every 5 seconds to track download status + }); +} + +export function usePxeAsset( + instanceName: string | null | undefined, + assetType: PxeAssetType | null | undefined +) { + return useQuery({ + queryKey: ['instances', instanceName, 'pxe', 'assets', assetType], + queryFn: () => pxeApi.getAsset(instanceName!, assetType!), + enabled: !!instanceName && !!assetType, + }); +} + +export function useDownloadPxeAsset() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + instanceName, + request, + }: { + instanceName: string; + request: DownloadAssetRequest; + }) => pxeApi.downloadAsset(instanceName, request), + onSuccess: (_data, variables) => { + // Invalidate assets list to show downloading status + queryClient.invalidateQueries({ + queryKey: ['instances', variables.instanceName, 'pxe', 'assets'], + }); + }, + }); +} + +export function useDeletePxeAsset() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ instanceName, type }: { instanceName: string; type: PxeAssetType }) => + pxeApi.deleteAsset(instanceName, type), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: ['instances', variables.instanceName, 'pxe', 'assets'], + }); + }, + }); +} diff --git a/src/services/api/hooks/useUtilities.ts b/src/services/api/hooks/useUtilities.ts new file mode 100644 index 0000000..220a173 --- /dev/null +++ b/src/services/api/hooks/useUtilities.ts @@ -0,0 +1,47 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { utilitiesApi } from '../utilities'; + +export function useDashboardToken() { + return useQuery({ + queryKey: ['utilities', 'dashboard', 'token'], + queryFn: utilitiesApi.getDashboardToken, + staleTime: 5 * 60 * 1000, // 5 minutes + }); +} + +export function useClusterVersions() { + return useQuery({ + queryKey: ['utilities', 'version'], + queryFn: utilitiesApi.getVersion, + staleTime: 10 * 60 * 1000, // 10 minutes + }); +} + +export function useNodeIPs() { + return useQuery({ + queryKey: ['utilities', 'nodes', 'ips'], + queryFn: utilitiesApi.getNodeIPs, + staleTime: 30 * 1000, // 30 seconds + }); +} + +export function useControlPlaneIP() { + return useQuery({ + queryKey: ['utilities', 'controlplane', 'ip'], + queryFn: utilitiesApi.getControlPlaneIP, + staleTime: 60 * 1000, // 1 minute + }); +} + +export function useCopySecret() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ secret, targetInstance }: { secret: string; targetInstance: string }) => + utilitiesApi.copySecret(secret, targetInstance), + onSuccess: () => { + // Invalidate secrets queries + queryClient.invalidateQueries({ queryKey: ['secrets'] }); + }, + }); +} diff --git a/src/services/api/index.ts b/src/services/api/index.ts new file mode 100644 index 0000000..564cd15 --- /dev/null +++ b/src/services/api/index.ts @@ -0,0 +1,19 @@ +export { apiClient, ApiError } from './client'; +export * from './types'; +export { instancesApi } from './instances'; +export { contextApi } from './context'; +export { nodesApi } from './nodes'; +export { clusterApi } from './cluster'; +export { appsApi } from './apps'; +export { servicesApi } from './services'; +export { operationsApi } from './operations'; +export { dnsmasqApi } from './dnsmasq'; +export { utilitiesApi } from './utilities'; +export { pxeApi } from './pxe'; + +// React Query hooks +export { useInstance, useInstanceOperations, useInstanceClusterHealth } from './hooks/useInstance'; +export { useOperations, useOperation, useCancelOperation } from './hooks/useOperations'; +export { useClusterHealth, useClusterStatus, useClusterNodes } from './hooks/useCluster'; +export { useDashboardToken, useClusterVersions, useNodeIPs, useControlPlaneIP, useCopySecret } from './hooks/useUtilities'; +export { usePxeAssets, useDownloadPxeAsset, useDeletePxeAsset } from './hooks/usePxeAssets'; diff --git a/src/services/api/instances.ts b/src/services/api/instances.ts new file mode 100644 index 0000000..6f914a1 --- /dev/null +++ b/src/services/api/instances.ts @@ -0,0 +1,49 @@ +import { apiClient } from './client'; +import type { + InstanceListResponse, + CreateInstanceRequest, + CreateInstanceResponse, + DeleteInstanceResponse, + GetInstanceResponse, +} from './types'; + +export const instancesApi = { + async list(): Promise { + return apiClient.get('/api/v1/instances'); + }, + + async get(name: string): Promise { + return apiClient.get(`/api/v1/instances/${name}`); + }, + + async create(data: CreateInstanceRequest): Promise { + return apiClient.post('/api/v1/instances', data); + }, + + async delete(name: string): Promise { + return apiClient.delete(`/api/v1/instances/${name}`); + }, + + // Config management + async getConfig(instanceName: string): Promise> { + return apiClient.get(`/api/v1/instances/${instanceName}/config`); + }, + + async updateConfig(instanceName: string, config: Record): Promise<{ message: string }> { + return apiClient.put(`/api/v1/instances/${instanceName}/config`, config); + }, + + async batchUpdateConfig(instanceName: string, updates: Array<{path: string; value: unknown}>): Promise<{ message: string; updated?: number }> { + return apiClient.patch(`/api/v1/instances/${instanceName}/config`, { updates }); + }, + + // Secrets management + async getSecrets(instanceName: string, raw = false): Promise> { + const query = raw ? '?raw=true' : ''; + return apiClient.get(`/api/v1/instances/${instanceName}/secrets${query}`); + }, + + async updateSecrets(instanceName: string, secrets: Record): Promise<{ message: string }> { + return apiClient.put(`/api/v1/instances/${instanceName}/secrets`, secrets); + }, +}; diff --git a/src/services/api/nodes.ts b/src/services/api/nodes.ts new file mode 100644 index 0000000..1498bca --- /dev/null +++ b/src/services/api/nodes.ts @@ -0,0 +1,57 @@ +import { apiClient } from './client'; +import type { + NodeListResponse, + NodeAddRequest, + NodeUpdateRequest, + Node, + HardwareInfo, + DiscoveryStatus, + OperationResponse, +} from './types'; + +export const nodesApi = { + async list(instanceName: string): Promise { + return apiClient.get(`/api/v1/instances/${instanceName}/nodes`); + }, + + async get(instanceName: string, nodeName: string): Promise { + return apiClient.get(`/api/v1/instances/${instanceName}/nodes/${nodeName}`); + }, + + async add(instanceName: string, node: NodeAddRequest): Promise { + return apiClient.post(`/api/v1/instances/${instanceName}/nodes`, node); + }, + + async update(instanceName: string, nodeName: string, updates: NodeUpdateRequest): Promise { + return apiClient.put(`/api/v1/instances/${instanceName}/nodes/${nodeName}`, updates); + }, + + async delete(instanceName: string, nodeName: string): Promise { + return apiClient.delete(`/api/v1/instances/${instanceName}/nodes/${nodeName}`); + }, + + async apply(instanceName: string, nodeName: string): Promise { + return apiClient.post(`/api/v1/instances/${instanceName}/nodes/${nodeName}/apply`); + }, + + // Discovery + async discover(instanceName: string, subnet: string): Promise { + return apiClient.post(`/api/v1/instances/${instanceName}/nodes/discover`, { subnet }); + }, + + async detect(instanceName: string): Promise { + return apiClient.post(`/api/v1/instances/${instanceName}/nodes/detect`); + }, + + async discoveryStatus(instanceName: string): Promise { + return apiClient.get(`/api/v1/instances/${instanceName}/discovery`); + }, + + async getHardware(instanceName: string, ip: string): Promise { + return apiClient.get(`/api/v1/instances/${instanceName}/nodes/hardware/${ip}`); + }, + + async fetchTemplates(instanceName: string): Promise { + return apiClient.post(`/api/v1/instances/${instanceName}/nodes/fetch-templates`); + }, +}; diff --git a/src/services/api/operations.ts b/src/services/api/operations.ts new file mode 100644 index 0000000..244300f --- /dev/null +++ b/src/services/api/operations.ts @@ -0,0 +1,23 @@ +import { apiClient } from './client'; +import type { Operation, OperationListResponse } from './types'; + +export const operationsApi = { + async list(instanceName: string): Promise { + return apiClient.get(`/api/v1/instances/${instanceName}/operations`); + }, + + async get(operationId: string, instanceName?: string): Promise { + const params = instanceName ? `?instance=${instanceName}` : ''; + return apiClient.get(`/api/v1/operations/${operationId}${params}`); + }, + + async cancel(operationId: string, instanceName: string): Promise<{ message: string }> { + return apiClient.post(`/api/v1/operations/${operationId}/cancel?instance=${instanceName}`); + }, + + // SSE stream for operation updates + createStream(operationId: string): EventSource { + const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5055'; + return new EventSource(`${baseUrl}/api/v1/operations/${operationId}/stream`); + }, +}; diff --git a/src/services/api/pxe.ts b/src/services/api/pxe.ts new file mode 100644 index 0000000..0d0a869 --- /dev/null +++ b/src/services/api/pxe.ts @@ -0,0 +1,29 @@ +import { apiClient } from './client'; +import type { + PxeAssetsResponse, + PxeAsset, + DownloadAssetRequest, + OperationResponse, + PxeAssetType, +} from './types'; + +export const pxeApi = { + async listAssets(instanceName: string): Promise { + return apiClient.get(`/api/v1/instances/${instanceName}/pxe/assets`); + }, + + async getAsset(instanceName: string, type: PxeAssetType): Promise { + return apiClient.get(`/api/v1/instances/${instanceName}/pxe/assets/${type}`); + }, + + async downloadAsset( + instanceName: string, + request: DownloadAssetRequest + ): Promise { + return apiClient.post(`/api/v1/instances/${instanceName}/pxe/assets/download`, request); + }, + + async deleteAsset(instanceName: string, type: PxeAssetType): Promise<{ message: string }> { + return apiClient.delete(`/api/v1/instances/${instanceName}/pxe/assets/${type}`); + }, +}; diff --git a/src/services/api/services.ts b/src/services/api/services.ts new file mode 100644 index 0000000..9e2f71d --- /dev/null +++ b/src/services/api/services.ts @@ -0,0 +1,62 @@ +import { apiClient } from './client'; +import type { + ServiceListResponse, + Service, + ServiceStatus, + ServiceManifest, + ServiceInstallRequest, + OperationResponse, +} from './types'; + +export const servicesApi = { + // Instance services + async list(instanceName: string): Promise { + return apiClient.get(`/api/v1/instances/${instanceName}/services`); + }, + + async get(instanceName: string, serviceName: string): Promise { + return apiClient.get(`/api/v1/instances/${instanceName}/services/${serviceName}`); + }, + + async install(instanceName: string, service: ServiceInstallRequest): Promise { + return apiClient.post(`/api/v1/instances/${instanceName}/services`, service); + }, + + async installAll(instanceName: string): Promise { + return apiClient.post(`/api/v1/instances/${instanceName}/services/install-all`); + }, + + async delete(instanceName: string, serviceName: string): Promise { + return apiClient.delete(`/api/v1/instances/${instanceName}/services/${serviceName}`); + }, + + async getStatus(instanceName: string, serviceName: string): Promise { + return apiClient.get(`/api/v1/instances/${instanceName}/services/${serviceName}/status`); + }, + + async getConfig(instanceName: string, serviceName: string): Promise> { + return apiClient.get(`/api/v1/instances/${instanceName}/services/${serviceName}/config`); + }, + + // Service lifecycle + async fetch(instanceName: string, serviceName: string): Promise { + return apiClient.post(`/api/v1/instances/${instanceName}/services/${serviceName}/fetch`); + }, + + async compile(instanceName: string, serviceName: string): Promise { + return apiClient.post(`/api/v1/instances/${instanceName}/services/${serviceName}/compile`); + }, + + async deploy(instanceName: string, serviceName: string): Promise { + return apiClient.post(`/api/v1/instances/${instanceName}/services/${serviceName}/deploy`); + }, + + // Global service info (not instance-specific) + async getManifest(serviceName: string): Promise { + return apiClient.get(`/api/v1/services/${serviceName}/manifest`); + }, + + async getGlobalConfig(serviceName: string): Promise> { + return apiClient.get(`/api/v1/services/${serviceName}/config`); + }, +}; diff --git a/src/services/api/types/app.ts b/src/services/api/types/app.ts new file mode 100644 index 0000000..22bfe2a --- /dev/null +++ b/src/services/api/types/app.ts @@ -0,0 +1,53 @@ +export interface App { + name: string; + description: string; + version: string; + category?: string; + icon?: string; + requires?: AppRequirement[]; + defaultConfig?: Record; + requiredSecrets?: string[]; + dependencies?: string[]; + config?: Record; + status?: AppStatus; +} + +export interface AppRequirement { + name: string; +} + +export interface DeployedApp { + name: string; + status: 'added' | 'deployed'; + version?: string; + namespace?: string; + url?: string; +} + +export interface AppStatus { + status: 'available' | 'added' | 'deploying' | 'deployed' | 'running' | 'error' | 'stopped'; + message?: string; + namespace?: string; + replicas?: number; + resources?: AppResources; +} + +export interface AppResources { + cpu?: string; + memory?: string; + storage?: string; +} + +export interface AppListResponse { + apps: App[]; +} + +export interface AppAddRequest { + name: string; + config?: Record; +} + +export interface AppAddResponse { + message: string; + app: string; +} diff --git a/src/services/api/types/cluster.ts b/src/services/api/types/cluster.ts new file mode 100644 index 0000000..d8fa85b --- /dev/null +++ b/src/services/api/types/cluster.ts @@ -0,0 +1,45 @@ +export interface ClusterConfig { + clusterName: string; + vip: string; + version?: string; +} + +export interface ClusterStatus { + ready: boolean; + nodes: number; + controlPlaneNodes: number; + workerNodes: number; + kubernetesVersion?: string; + talosVersion?: string; +} + +export interface HealthCheck { + name: string; + status: 'passing' | 'warning' | 'failing'; + message: string; +} + +export interface ClusterHealthResponse { + status: 'healthy' | 'degraded' | 'unhealthy'; + checks: HealthCheck[]; +} + +export interface KubeconfigResponse { + kubeconfig: string; +} + +export interface TalosconfigResponse { + talosconfig: string; +} + +export interface ClusterBootstrapRequest { + node: string; +} + +export interface ClusterEndpointsRequest { + include_nodes?: boolean; +} + +export interface ClusterResetRequest { + confirm: boolean; +} diff --git a/src/services/api/types/config.ts b/src/services/api/types/config.ts new file mode 100644 index 0000000..f103273 --- /dev/null +++ b/src/services/api/types/config.ts @@ -0,0 +1,17 @@ +export interface ConfigUpdate { + path: string; + value: unknown; +} + +export interface ConfigUpdateBatchRequest { + updates: ConfigUpdate[]; +} + +export interface ConfigUpdateResponse { + message: string; + updated?: number; +} + +export interface SecretsResponse { + [key: string]: string; +} diff --git a/src/services/api/types/context.ts b/src/services/api/types/context.ts new file mode 100644 index 0000000..6d8d6d2 --- /dev/null +++ b/src/services/api/types/context.ts @@ -0,0 +1,12 @@ +export interface ContextResponse { + context: string | null; +} + +export interface SetContextRequest { + context: string; +} + +export interface SetContextResponse { + context: string; + message: string; +} diff --git a/src/services/api/types/index.ts b/src/services/api/types/index.ts new file mode 100644 index 0000000..5e23c8b --- /dev/null +++ b/src/services/api/types/index.ts @@ -0,0 +1,9 @@ +export * from './instance'; +export * from './context'; +export * from './operation'; +export * from './config'; +export * from './node'; +export * from './cluster'; +export * from './app'; +export * from './service'; +export * from './pxe'; diff --git a/src/services/api/types/instance.ts b/src/services/api/types/instance.ts new file mode 100644 index 0000000..ac0ddbb --- /dev/null +++ b/src/services/api/types/instance.ts @@ -0,0 +1,27 @@ +export interface Instance { + name: string; + config: Record; +} + +export interface InstanceListResponse { + instances: string[]; +} + +export interface CreateInstanceRequest { + name: string; +} + +export interface CreateInstanceResponse { + name: string; + message: string; + warning?: string; +} + +export interface DeleteInstanceResponse { + message: string; +} + +export interface GetInstanceResponse { + name: string; + config: Record; +} diff --git a/src/services/api/types/node.ts b/src/services/api/types/node.ts new file mode 100644 index 0000000..6ac49fb --- /dev/null +++ b/src/services/api/types/node.ts @@ -0,0 +1,58 @@ +export interface Node { + hostname: string; + target_ip: string; + role: 'controlplane' | 'worker'; + current_ip?: string; + interface?: string; + disk?: string; + version?: string; + schematic_id?: string; + // Backend state flags for deriving status + maintenance?: boolean; + configured?: boolean; + applied?: boolean; + // Optional fields (not yet returned by API) + hardware?: HardwareInfo; + talosVersion?: string; + kubernetesVersion?: string; +} + +export interface HardwareInfo { + cpu?: string; + memory?: string; + disk?: string; + manufacturer?: string; + model?: string; +} + +export interface DiscoveredNode { + ip: string; + hostname?: string; + maintenance_mode?: boolean; + version?: string; + interface?: string; + disks?: string[]; +} + +export interface DiscoveryStatus { + active: boolean; + started_at?: string; + nodes_found?: DiscoveredNode[]; + error?: string; +} + +export interface NodeListResponse { + nodes: Node[]; +} + +export interface NodeAddRequest { + hostname: string; + target_ip: string; + role: 'controlplane' | 'worker'; + disk?: string; +} + +export interface NodeUpdateRequest { + role?: 'controlplane' | 'worker'; + config?: Record; +} diff --git a/src/services/api/types/operation.ts b/src/services/api/types/operation.ts new file mode 100644 index 0000000..cd562e7 --- /dev/null +++ b/src/services/api/types/operation.ts @@ -0,0 +1,21 @@ +export interface Operation { + id: string; + instance_name: string; + type: string; + target: string; + status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'; + message: string; + progress: number; + started: string; + completed?: string; + error?: string; +} + +export interface OperationListResponse { + operations: Operation[]; +} + +export interface OperationResponse { + operation_id: string; + message: string; +} diff --git a/src/services/api/types/pxe.ts b/src/services/api/types/pxe.ts new file mode 100644 index 0000000..5391a9c --- /dev/null +++ b/src/services/api/types/pxe.ts @@ -0,0 +1,27 @@ +export type PxeAssetType = 'kernel' | 'initramfs' | 'iso'; + +export type PxeAssetStatus = 'available' | 'missing' | 'downloading' | 'error'; + +export interface PxeAsset { + type: PxeAssetType; + status: PxeAssetStatus; + version?: string; + size?: string; + path?: string; + error?: string; +} + +export interface PxeAssetsResponse { + assets: PxeAsset[]; +} + +export interface DownloadAssetRequest { + type: PxeAssetType; + version?: string; + url: string; +} + +export interface OperationResponse { + operation_id: string; + message: string; +} diff --git a/src/services/api/types/service.ts b/src/services/api/types/service.ts new file mode 100644 index 0000000..15e79c3 --- /dev/null +++ b/src/services/api/types/service.ts @@ -0,0 +1,29 @@ +export interface Service { + name: string; + description: string; + version?: string; + status?: ServiceStatus; + deployed?: boolean; +} + +export interface ServiceStatus { + status: 'available' | 'deploying' | 'running' | 'error' | 'stopped'; + message?: string; + namespace?: string; + ready?: boolean; +} + +export interface ServiceListResponse { + services: Service[]; +} + +export interface ServiceManifest { + name: string; + version: string; + description: string; + config: Record; +} + +export interface ServiceInstallRequest { + name: string; +} diff --git a/src/services/api/utilities.ts b/src/services/api/utilities.ts new file mode 100644 index 0000000..f6db57e --- /dev/null +++ b/src/services/api/utilities.ts @@ -0,0 +1,41 @@ +import { apiClient } from './client'; + +export interface HealthResponse { + status: string; + [key: string]: unknown; +} + +export interface VersionResponse { + version: string; + [key: string]: unknown; +} + +export const utilitiesApi = { + async health(): Promise { + return apiClient.get('/api/v1/utilities/health'); + }, + + async instanceHealth(instanceName: string): Promise { + return apiClient.get(`/api/v1/instances/${instanceName}/utilities/health`); + }, + + async getDashboardToken(): Promise<{ token: string }> { + return apiClient.get('/api/v1/utilities/dashboard/token'); + }, + + async getNodeIPs(): Promise<{ ips: string[] }> { + return apiClient.get('/api/v1/utilities/nodes/ips'); + }, + + async getControlPlaneIP(): Promise<{ ip: string }> { + return apiClient.get('/api/v1/utilities/controlplane/ip'); + }, + + async copySecret(secret: string, targetInstance: string): Promise<{ message: string }> { + return apiClient.post(`/api/v1/utilities/secrets/${secret}/copy`, { target: targetInstance }); + }, + + async getVersion(): Promise { + return apiClient.get('/api/v1/utilities/version'); + }, +}; diff --git a/src/utils/yamlParser.ts b/src/utils/yamlParser.ts index 7042f3c..b7efd6e 100644 --- a/src/utils/yamlParser.ts +++ b/src/utils/yamlParser.ts @@ -39,20 +39,20 @@ export const parseSimpleYaml = (yamlText: string): Config => { 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; + if (currentSubsection === 'dns') (config.cloud.dns as Record)[key] = cleanValue; + else if (currentSubsection === 'router') (config.cloud.router as Record)[key] = cleanValue; + else if (currentSubsection === 'dnsmasq') (config.cloud.dnsmasq as Record)[key] = cleanValue; + else (config.cloud as Record)[key] = cleanValue; } else if (currentSection === 'cluster') { if (currentSubsection === 'nodes') { // Skip nodes level } else if (currentSubsection === 'talos') { - (config.cluster.nodes.talos as any)[key] = cleanValue; + (config.cluster.nodes.talos as Record)[key] = cleanValue; } else { - (config.cluster as any)[key] = cleanValue; + (config.cluster as Record)[key] = cleanValue; } } else if (currentSection === 'server') { - (config.server as any)[key] = key === 'port' ? parseInt(cleanValue) : cleanValue; + (config.server as Record)[key] = key === 'port' ? parseInt(cleanValue) : cleanValue; } } } diff --git a/vitest.config.ts b/vitest.config.ts index 112e798..7e4c58b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,7 +1,6 @@ /// import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; -import { resolve } from 'path'; import { fileURLToPath, URL } from 'node:url'; export default defineConfig({ From 331777c5fd6c52218acdd4729c8c40ba53a58818 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Sun, 12 Oct 2025 20:16:45 +0000 Subject: [PATCH 2/2] Better support for Talos ISO downloads. --- BUILDING_WILD_APP.md | 167 ++++++++++ src/components/AppSidebar.tsx | 4 +- src/router/pages/AssetsIsoPage.tsx | 301 ++++++++++++++++++ src/router/pages/AssetsPxePage.tsx | 299 ++++++++++++++++++ src/router/pages/IsoPage.tsx | 456 ++++++++++++++++------------ src/router/pages/LandingPage.tsx | 87 ++++-- src/router/routes.tsx | 11 + src/services/api/assets.ts | 42 +++ src/services/api/hooks/useAssets.ts | 58 ++++ src/services/api/index.ts | 2 + src/services/api/types/asset.ts | 38 +++ src/services/api/types/index.ts | 1 + 12 files changed, 1245 insertions(+), 221 deletions(-) create mode 100644 BUILDING_WILD_APP.md create mode 100644 src/router/pages/AssetsIsoPage.tsx create mode 100644 src/router/pages/AssetsPxePage.tsx create mode 100644 src/services/api/assets.ts create mode 100644 src/services/api/hooks/useAssets.ts create mode 100644 src/services/api/types/asset.ts diff --git a/BUILDING_WILD_APP.md b/BUILDING_WILD_APP.md new file mode 100644 index 0000000..6425359 --- /dev/null +++ b/BUILDING_WILD_APP.md @@ -0,0 +1,167 @@ +# Building Wild App + +This document describes the architecture and tooling used to build the Wild App, the web-based interface for managing Wild Cloud instances, hosted on Wild Central. + +## Principles + +- Stick with well known standards. +- Keep it simple. +- Use popular, well-maintained libraries. +- Use components wherever possible to avoid reinventing the wheel. +- Use TypeScript for type safety. + +### Tooling +## Dev Environment Requirements + +- Node.js 20+ +- pnpm for package management +- vite for build tooling +- React + TypeScript +- Tailwind CSS for styling +- shadcn/ui for ready-made components +- radix-ui for accessible components +- eslint for linting +- tsc for type checking +- vitest for unit tests + +#### Makefile commands + +- Build application: `make app-build` +- Run locally: `make app-run` +- Format code: `make app-fmt` +- Lint and typecheck: `make app-check` +- Test installation: `make app-test` + +### Scaffolding apps + +It is important to start an app with a good base structure to avoid difficult to debug config issues. + +This is a recommended setup. + +#### Install pnpm if necessary: + +```bash +curl -fsSL https://get.pnpm.io/install.sh | sh - +``` + +#### Install a React + Speedy Web Compiler (SWC) + TypeScript + TailwindCSS app with vite: + +``` +pnpm create vite@latest my-app -- --template react-swc-ts +``` + +#### Reconfigure to use shadcn/ui (radix + tailwind components) (see https://ui.shadcn.com/docs/installation/vite) + +##### Add tailwind. + +```bash +pnpm add tailwindcss @tailwindcss/vite +``` + +##### Replace everything in src/index.css with a tailwind import: + +```bash +echo "@import \"tailwindcss\";" > src/index.css +``` + +##### Edit tsconfig files + +The current version of Vite splits TypeScript configuration into three files, two of which need to be edited. Add the baseUrl and paths properties to the compilerOptions section of the tsconfig.json and tsconfig.app.json files: + +tsconfig.json + +```json +{ + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.node.json" + } + ], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} +``` + +tsconfig.app.json + +```json +{ + "compilerOptions": { + // ... + "baseUrl": ".", + "paths": { + "@/*": [ + "./src/*" + ] + } + // ... + } +} +``` + +##### Update vite.config.ts + +```bash +pnpm add -D @types/node +``` +Then edit vite.config.ts to include the node types: + +```ts +import path from "path" +import tailwindcss from "@tailwindcss/vite" +import react from "@vitejs/plugin-react" +import { defineConfig } from "vite" + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}) +``` + +##### Run the cli + +```bash +pnpm dlx shadcn@latest init +``` + +##### Add components + +```bash +pnpm dlx shadcn@latest add button +pnpm dlx shadcn@latest add alert-dialog +``` + +You can then use components with `import { Button } from "@/components/ui/button"` + +### UI Principles + +- Use shadcn AppSideBar as the main navigation for the app: https://ui.shadcn.com/docs/components/sidebar +- Support light and dark mode with Tailwind's built-in dark mode support: https://tailwindcss.com/docs/dark-mode + +### App Layout + +- The sidebar let's you select which cloud instance you are curently managing from a dropdown. +- The sidebar is divided into Central, Cluster, and Apps. + - Central: Utilities for managing Wild Central itself. + - Cluster: Utilities for managing the current Wild Cloud instance. + - Managing nodes. + - Managing services. + - Apps: Managing the apps deployed on the current Wild Cloud instance. + - List of apps. + - App details. + - App logs. + - App metrics. + diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 072b088..3da76c1 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -153,7 +153,7 @@ export function AppSidebar() { - + {/*
@@ -173,7 +173,7 @@ export function AppSidebar() { PXE - + */} diff --git a/src/router/pages/AssetsIsoPage.tsx b/src/router/pages/AssetsIsoPage.tsx new file mode 100644 index 0000000..63b39f8 --- /dev/null +++ b/src/router/pages/AssetsIsoPage.tsx @@ -0,0 +1,301 @@ +import { useState } from 'react'; +import { Link } from 'react-router'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card'; +import { Button } from '../../components/ui/button'; +import { Badge } from '../../components/ui/badge'; +import { + Download, + AlertCircle, + Loader2, + Disc, + BookOpen, + ExternalLink, + CheckCircle, + XCircle, + Usb, + ArrowLeft, + CloudLightning, +} from 'lucide-react'; +import { useAssetList, useDownloadAsset, useAssetStatus } from '../../services/api/hooks/useAssets'; +import { assetsApi } from '../../services/api/assets'; +import type { AssetType } from '../../services/api/types/asset'; + +export function AssetsIsoPage() { + const { data, isLoading, error } = useAssetList(); + const downloadAsset = useDownloadAsset(); + const [selectedSchematicId, setSelectedSchematicId] = useState(null); + const [selectedVersion, setSelectedVersion] = useState('v1.8.0'); + const { data: statusData } = useAssetStatus(selectedSchematicId); + + // Select the first schematic by default if available + const schematic = data?.schematics?.[0] || null; + const schematicId = schematic?.schematic_id || null; + + // Get the ISO asset + const isoAsset = schematic?.assets.find((asset) => asset.type === 'iso'); + + const handleDownload = async () => { + if (!schematicId) return; + + try { + await downloadAsset.mutateAsync({ + schematicId, + request: { version: selectedVersion, assets: ['iso'] }, + }); + } catch (err) { + console.error('Download failed:', err); + } + }; + + const getStatusBadge = (downloaded: boolean, downloading?: boolean) => { + if (downloading) { + return ( + + + Downloading + + ); + } + + if (downloaded) { + return ( + + + Available + + ); + } + + return ( + + + Missing + + ); + }; + + const getDownloadProgress = () => { + if (!statusData?.progress?.iso) return null; + + const progress = statusData.progress.iso; + if (progress.status === 'downloading' && progress.bytes_downloaded && progress.total_bytes) { + const percentage = (progress.bytes_downloaded / progress.total_bytes) * 100; + return ( +
+
+ Downloading... + {percentage.toFixed(1)}% +
+
+
+
+
+ ); + } + + return null; + }; + + return ( +
+ {/* Header */} +
+
+
+
+ + + Wild Cloud + + / + ISO Management +
+ + + +
+
+
+ + {/* Main Content */} +
+
+ {/* Educational Intro Section */} + +
+
+ +
+
+

+ What is a Bootable ISO? +

+

+ A bootable ISO is a special disk image file that can be written to a USB drive or DVD to create + installation media. When you boot a computer from this USB drive, it can install or run an + operating system directly from the drive without needing anything pre-installed. +

+

+ This is perfect for setting up individual computers in your cloud infrastructure. Download the + Talos ISO here, write it to a USB drive using tools like Balena Etcher or Rufus, then boot + your computer from the USB to install Talos Linux. +

+ +
+
+
+ + + +
+
+ +
+
+ ISO Management + + Download Talos ISO images for creating bootable USB drives + +
+
+
+ + {error ? ( +
+ +

Error Loading Assets

+

{(error as Error).message}

+ +
+ ) : ( +
+ {/* Version Selection */} +
+ + +
+ + {/* ISO Asset */} +
+

ISO Image

+ {isLoading ? ( +
+ +
+ ) : !isoAsset ? ( + + +

No ISO Available

+

+ Download a Talos ISO to get started with USB boot. +

+ +
+ ) : ( + +
+
+ +
+
+
+
Talos ISO
+ {getStatusBadge(isoAsset.downloaded, statusData?.downloading)} +
+
+ {schematic?.version &&
Version: {schematic.version}
} + {isoAsset.size &&
Size: {(isoAsset.size / 1024 / 1024).toFixed(2)} MB
} + {isoAsset.path && ( +
{isoAsset.path}
+ )} +
+ {getDownloadProgress()} +
+
+ {!isoAsset.downloaded && !statusData?.downloading && ( + + )} + {isoAsset.downloaded && schematicId && ( + + )} +
+
+
+ )} +
+ + {/* Instructions Card */} + +

+ + Next Steps +

+
    +
  1. Download the ISO image above
  2. +
  3. Download a USB writing tool (e.g., Balena Etcher, Rufus, or dd)
  4. +
  5. Write the ISO to a USB drive (minimum 2GB)
  6. +
  7. Boot your target computer from the USB drive
  8. +
  9. Follow the Talos installation process
  10. +
+
+
+ )} +
+
+
+
+
+ ); +} diff --git a/src/router/pages/AssetsPxePage.tsx b/src/router/pages/AssetsPxePage.tsx new file mode 100644 index 0000000..405030f --- /dev/null +++ b/src/router/pages/AssetsPxePage.tsx @@ -0,0 +1,299 @@ +import { useState } from 'react'; +import { Link } from 'react-router'; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../../components/ui/card'; +import { Button } from '../../components/ui/button'; +import { Badge } from '../../components/ui/badge'; +import { + HardDrive, + BookOpen, + ExternalLink, + Download, + Loader2, + CheckCircle, + AlertCircle, + FileArchive, + ArrowLeft, + CloudLightning, +} from 'lucide-react'; +import { useAssetList, useDownloadAsset, useAssetStatus } from '../../services/api/hooks/useAssets'; +import type { AssetType } from '../../services/api/types/asset'; + +export function AssetsPxePage() { + const { data, isLoading, error } = useAssetList(); + const downloadAsset = useDownloadAsset(); + const [selectedVersion, setSelectedVersion] = useState('v1.8.0'); + + // Select the first schematic by default if available + const schematic = data?.schematics?.[0] || null; + const schematicId = schematic?.schematic_id || null; + const { data: statusData } = useAssetStatus(schematicId); + + // Get PXE assets (kernel and initramfs) + const pxeAssets = schematic?.assets.filter((asset) => asset.type !== 'iso') || []; + + const handleDownload = async (assetType: AssetType) => { + if (!schematicId) return; + + try { + await downloadAsset.mutateAsync({ + schematicId, + request: { version: selectedVersion, assets: [assetType] }, + }); + } catch (err) { + console.error('Download failed:', err); + } + }; + + const handleDownloadAll = async () => { + if (!schematicId) return; + + try { + await downloadAsset.mutateAsync({ + schematicId, + request: { version: selectedVersion, assets: ['kernel', 'initramfs'] }, + }); + } catch (err) { + console.error('Download failed:', err); + } + }; + + const getStatusBadge = (downloaded: boolean, downloading?: boolean) => { + if (downloading) { + return ( + + + Downloading + + ); + } + + if (downloaded) { + return ( + + + Available + + ); + } + + return ( + + + Missing + + ); + }; + + const getAssetIcon = (type: string) => { + switch (type) { + case 'kernel': + return ; + case 'initramfs': + return ; + default: + return ; + } + }; + + const getDownloadProgress = (assetType: AssetType) => { + if (!statusData?.progress?.[assetType]) return null; + + const progress = statusData.progress[assetType]; + if (progress?.status === 'downloading' && progress.bytes_downloaded && progress.total_bytes) { + const percentage = (progress.bytes_downloaded / progress.total_bytes) * 100; + return ( +
+
+ Downloading... + {percentage.toFixed(1)}% +
+
+
+
+
+ ); + } + + return null; + }; + + const isAssetDownloading = (assetType: AssetType) => { + return statusData?.progress?.[assetType]?.status === 'downloading'; + }; + + return ( +
+ {/* Header */} +
+
+
+
+ + + Wild Cloud + + / + PXE Management +
+ + + +
+
+
+ + {/* Main Content */} +
+
+ {/* Educational Intro Section */} + +
+
+ +
+
+

+ What is PXE Boot? +

+

+ 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. +

+

+ 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. +

+ +
+
+
+ + + +
+
+ +
+
+ PXE Configuration + + Manage PXE boot assets and network boot configuration + +
+
+
+ + {error ? ( +
+ +

Error Loading Assets

+

{(error as Error).message}

+ +
+ ) : ( +
+ {/* Version Selection */} +
+ + +
+ + {/* Assets List */} +
+

Boot Assets

+ {isLoading ? ( +
+ +
+ ) : ( +
+ {pxeAssets.map((asset) => ( + +
+
{getAssetIcon(asset.type)}
+
+
+
{asset.type}
+ {getStatusBadge(asset.downloaded, isAssetDownloading(asset.type as AssetType))} +
+
+ {schematic?.version &&
Version: {schematic.version}
} + {asset.size &&
Size: {(asset.size / 1024 / 1024).toFixed(2)} MB
} + {asset.path && ( +
{asset.path}
+ )} +
+ {getDownloadProgress(asset.type as AssetType)} +
+
+ {!asset.downloaded && !isAssetDownloading(asset.type as AssetType) && ( + + )} +
+
+
+ ))} +
+ )} +
+ + {/* Download All Button */} + {pxeAssets.length > 0 && pxeAssets.some((a) => !a.downloaded) && ( +
+ +
+ )} +
+ )} +
+
+
+
+
+ ); +} diff --git a/src/router/pages/IsoPage.tsx b/src/router/pages/IsoPage.tsx index 65dd394..bace905 100644 --- a/src/router/pages/IsoPage.tsx +++ b/src/router/pages/IsoPage.tsx @@ -4,87 +4,97 @@ import { Button } from '../../components/ui/button'; import { Badge } from '../../components/ui/badge'; import { Download, - Trash2, AlertCircle, Loader2, Disc, BookOpen, ExternalLink, CheckCircle, - XCircle, Usb, + Trash2, } from 'lucide-react'; -import { usePxeAssets, useDownloadPxeAsset, useDeletePxeAsset } from '../../services/api/hooks/usePxeAssets'; -import { useInstanceContext } from '../../hooks'; -import type { PxeAssetType } from '../../services/api/types/pxe'; +import { useAssetList, useDownloadAsset, useDeleteAsset } from '../../services/api/hooks/useAssets'; +import { assetsApi } from '../../services/api/assets'; +import type { Platform } from '../../services/api/types/asset'; + +// Helper function to extract version from ISO filename +// Filename format: talos-v1.11.2-metal-amd64.iso +function extractVersionFromPath(path: string): string { + const filename = path.split('/').pop() || ''; + const match = filename.match(/talos-(v\d+\.\d+\.\d+)-metal/); + return match ? match[1] : 'unknown'; +} + +// Helper function to extract platform from ISO filename +// Filename format: talos-v1.11.2-metal-amd64.iso +function extractPlatformFromPath(path: string): string { + const filename = path.split('/').pop() || ''; + const match = filename.match(/-(amd64|arm64)\.iso$/); + return match ? match[1] : 'unknown'; +} export function IsoPage() { - const { currentInstance } = useInstanceContext(); - const { data, isLoading, error } = usePxeAssets(currentInstance); - const downloadAsset = useDownloadPxeAsset(); - const deleteAsset = useDeletePxeAsset(); - const [downloadingType, setDownloadingType] = useState(null); - const [selectedVersion, setSelectedVersion] = useState('v1.8.0'); + const { data, isLoading, error, refetch } = useAssetList(); + const downloadAsset = useDownloadAsset(); + const deleteAsset = useDeleteAsset(); - // Filter to show only ISO assets - const isoAssets = data?.assets.filter((asset) => asset.type === 'iso') || []; + const [schematicId, setSchematicId] = useState(''); + const [selectedVersion, setSelectedVersion] = useState('v1.11.2'); + const [selectedPlatform, setSelectedPlatform] = useState('amd64'); + const [isDownloading, setIsDownloading] = useState(false); - const handleDownload = async (type: PxeAssetType) => { - if (!currentInstance) return; + const handleDownload = async () => { + if (!schematicId) { + alert('Please enter a schematic ID'); + return; + } - setDownloadingType(type); + setIsDownloading(true); try { - const url = `https://github.com/siderolabs/talos/releases/download/${selectedVersion}/metal-amd64.iso`; await downloadAsset.mutateAsync({ - instanceName: currentInstance, - request: { type, version: selectedVersion, url }, + schematicId, + request: { + version: selectedVersion, + platform: selectedPlatform, + assets: ['iso'] + }, }); + // Refresh the list after download + await refetch(); } catch (err) { console.error('Download failed:', err); + alert(`Download failed: ${err instanceof Error ? err.message : 'Unknown error'}`); } finally { - setDownloadingType(null); + setIsDownloading(false); } }; - const handleDelete = async (type: PxeAssetType) => { - if (!currentInstance) return; + const handleDelete = async (schematicIdToDelete: string) => { + if (!confirm('Are you sure you want to delete this schematic and all its assets? This action cannot be undone.')) { + return; + } - await deleteAsset.mutateAsync({ instanceName: currentInstance, type }); - }; - - const getStatusBadge = (status?: string) => { - const statusValue = status || 'missing'; - const variants: Record = { - available: 'success', - missing: 'secondary', - downloading: 'warning', - error: 'destructive', - }; - - const icons: Record = { - available: , - missing: , - downloading: , - error: , - }; - - return ( - - {icons[statusValue]} - {statusValue.charAt(0).toUpperCase() + statusValue.slice(1)} - - ); - }; - - const getAssetIcon = (type: string) => { - switch (type) { - case 'iso': - return ; - default: - return ; + try { + await deleteAsset.mutateAsync(schematicIdToDelete); + await refetch(); + } catch (err) { + console.error('Delete failed:', err); + alert(`Delete failed: ${err instanceof Error ? err.message : 'Unknown error'}`); } }; + // Find all ISO assets from all schematics (including multiple ISOs per schematic) + const isoAssets = data?.schematics + .flatMap(schematic => { + // Get ALL ISO assets for this schematic (not just the first one) + const isoAssetsForSchematic = schematic.assets.filter(asset => asset.type === 'iso'); + return isoAssetsForSchematic.map(isoAsset => ({ + ...isoAsset, + schematic_id: schematic.schematic_id, + version: schematic.version + })); + }) || []; + return (
{/* Educational Intro Section */} @@ -111,180 +121,230 @@ export function IsoPage() { 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" + onClick={() => window.open('https://www.balena.io/etcher/', '_blank')} > - Learn about creating bootable USB drives + Download Balena Etcher
+ {/* Download New ISO Section */}
- +
- ISO Management + Download Talos ISO - Download Talos ISO images for creating bootable USB drives + Specify the schematic ID, version, and platform to download a Talos ISO image
+ + {/* Schematic ID Input */} +
+ + setSchematicId(e.target.value)} + placeholder="e.g., 434a0300db532066f1098e05ac068159371d00f0aba0a3103a0e826e83825c82" + className="w-full px-3 py-2 border rounded-lg bg-background font-mono text-sm" + /> +

+ Get your schematic ID from the{' '} + + Talos Image Factory + +

+
+ + {/* Version Selection */} +
+ + +
+ + {/* Platform Selection */} +
+ +
+ + +
+
+ + {/* Download Button */} + +
+
+ + {/* Downloaded ISOs Section */} + + + Downloaded ISO Images + Available ISO images on Wild Central + - {!currentInstance ? ( -
- -

No Instance Selected

-

- Please select or create an instance to manage ISO images. -

+ {isLoading ? ( +
+
) : error ? (
-

Error Loading ISO

+

Error Loading ISOs

{(error as Error).message}

- + +
+ ) : isoAssets.length === 0 ? ( +
+ +

No ISOs Downloaded

+

+ Download a Talos ISO using the form above to get started. +

) : ( -
- {/* Version Selection */} -
- - -
- - {/* ISO Asset */} -
-

ISO Image

- {isLoading ? ( -
- -
- ) : isoAssets.length === 0 ? ( - - -

No ISO Available

-

- Download a Talos ISO to get started with USB boot. -

- -
- ) : ( -
- {isoAssets.map((asset) => ( - -
-
{getAssetIcon(asset.type)}
-
-
-
Talos ISO
- {getStatusBadge(asset.status)} -
-
- {asset.version &&
Version: {asset.version}
} - {asset.size &&
Size: {asset.size}
} - {asset.path && ( -
{asset.path}
- )} - {asset.error && ( -
{asset.error}
- )} -
-
-
- {asset.status !== 'available' && asset.status !== 'downloading' && ( - - )} - {asset.status === 'available' && ( - <> - - - - )} -
+
+ {isoAssets.map((asset: any) => { + const version = extractVersionFromPath(asset.path || ''); + const platform = extractPlatformFromPath(asset.path || ''); + return ( + +
+
+ +
+
+
+
Talos ISO
+ {version} + {platform} + {asset.downloaded ? ( + + + Downloaded + + ) : ( + + + Missing + + )}
- - ))} -
- )} -
- - {/* Instructions Card */} - -

- - Next Steps -

-
    -
  1. Download the ISO image above
  2. -
  3. Download a USB writing tool (e.g., Balena Etcher, Rufus, or dd)
  4. -
  5. Write the ISO to a USB drive (minimum 2GB)
  6. -
  7. Boot your target computer from the USB drive
  8. -
  9. Follow the Talos installation process
  10. -
-
+
+
+ Schematic: {asset.schematic_id} +
+ {asset.size && ( +
Size: {(asset.size / 1024 / 1024).toFixed(2)} MB
+ )} +
+
+ {asset.downloaded && ( +
+ + +
+ )} +
+
+ ); + })}
)} + + {/* Instructions Card */} + +

+ + Next Steps +

+
    +
  1. Get your schematic ID from Talos Image Factory
  2. +
  3. Download the ISO image using the form above
  4. +
  5. Download a USB writing tool (e.g., Balena Etcher, Rufus, or dd)
  6. +
  7. Write the ISO to a USB drive (minimum 2GB)
  8. +
  9. Boot your target computer from the USB drive
  10. +
  11. Follow the Talos installation process
  12. +
+
); } diff --git a/src/router/pages/LandingPage.tsx b/src/router/pages/LandingPage.tsx index f7a8aed..7656aef 100644 --- a/src/router/pages/LandingPage.tsx +++ b/src/router/pages/LandingPage.tsx @@ -1,8 +1,8 @@ -import { useNavigate } from 'react-router'; +import { useNavigate, Link } from 'react-router'; import { useInstanceContext } from '../../hooks/useInstanceContext'; import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../../components/ui/card'; import { Button } from '../../components/ui/button'; -import { Server } from 'lucide-react'; +import { Server, Usb, HardDrive, CloudLightning } from 'lucide-react'; export function LandingPage() { const navigate = useNavigate(); @@ -16,25 +16,70 @@ export function LandingPage() { }; return ( -
- - - Wild Cloud - - Select an instance to manage your cloud infrastructure - - - - - - +
+
+
+
+ +

Wild Cloud

+
+

+ Manage your cloud infrastructure with ease +

+
+ +
+ + + Cloud Instance + + Manage your Wild Cloud instance + + + + + + + + + + Boot Assets + + Download Talos installation media + + + + + + + + + + + +
+
); } diff --git a/src/router/routes.tsx b/src/router/routes.tsx index cfd7ca5..83af516 100644 --- a/src/router/routes.tsx +++ b/src/router/routes.tsx @@ -20,12 +20,23 @@ import { InfrastructurePage } from './pages/InfrastructurePage'; import { ClusterPage } from './pages/ClusterPage'; import { AppsPage } from './pages/AppsPage'; import { AdvancedPage } from './pages/AdvancedPage'; +import { AssetsIsoPage } from './pages/AssetsIsoPage'; +import { AssetsPxePage } from './pages/AssetsPxePage'; export const routes: RouteObject[] = [ { path: '/', element: , }, + // Centralized asset routes (not under instance context) + { + path: '/iso', + element: , + }, + { + path: '/pxe', + element: , + }, { path: '/instances/:instanceId', element: , diff --git a/src/services/api/assets.ts b/src/services/api/assets.ts new file mode 100644 index 0000000..3bc202f --- /dev/null +++ b/src/services/api/assets.ts @@ -0,0 +1,42 @@ +import { apiClient } from './client'; +import type { AssetListResponse, Schematic, DownloadAssetRequest, AssetStatusResponse } from './types/asset'; + +// Get API base URL +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5055'; + +export const assetsApi = { + // List all schematics + list: async (): Promise => { + const response = await apiClient.get('/api/v1/assets'); + return response as AssetListResponse; + }, + + // Get schematic details + get: async (schematicId: string): Promise => { + const response = await apiClient.get(`/api/v1/assets/${schematicId}`); + return response as Schematic; + }, + + // Download assets for a schematic + download: async (schematicId: string, request: DownloadAssetRequest): Promise<{ message: string }> => { + const response = await apiClient.post(`/api/v1/assets/${schematicId}/download`, request); + return response as { message: string }; + }, + + // Get download status + status: async (schematicId: string): Promise => { + const response = await apiClient.get(`/api/v1/assets/${schematicId}/status`); + return response as AssetStatusResponse; + }, + + // Get download URL for an asset (includes base URL for direct download) + getAssetUrl: (schematicId: string, assetType: 'kernel' | 'initramfs' | 'iso'): string => { + return `${API_BASE_URL}/api/v1/assets/${schematicId}/pxe/${assetType}`; + }, + + // Delete a schematic and all its assets + delete: async (schematicId: string): Promise<{ message: string }> => { + const response = await apiClient.delete(`/api/v1/assets/${schematicId}`); + return response as { message: string }; + }, +}; diff --git a/src/services/api/hooks/useAssets.ts b/src/services/api/hooks/useAssets.ts new file mode 100644 index 0000000..ce16456 --- /dev/null +++ b/src/services/api/hooks/useAssets.ts @@ -0,0 +1,58 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { assetsApi } from '../assets'; +import type { DownloadAssetRequest } from '../types/asset'; + +export function useAssetList() { + return useQuery({ + queryKey: ['assets'], + queryFn: assetsApi.list, + }); +} + +export function useAsset(schematicId: string | null | undefined) { + return useQuery({ + queryKey: ['assets', schematicId], + queryFn: () => assetsApi.get(schematicId!), + enabled: !!schematicId, + }); +} + +export function useAssetStatus(schematicId: string | null | undefined) { + return useQuery({ + queryKey: ['assets', schematicId, 'status'], + queryFn: () => assetsApi.status(schematicId!), + enabled: !!schematicId, + refetchInterval: (query) => { + const data = query.state.data; + // Poll every 2 seconds if downloading + return data?.downloading ? 2000 : false; + }, + }); +} + +export function useDownloadAsset() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ schematicId, request }: { schematicId: string; request: DownloadAssetRequest }) => + assetsApi.download(schematicId, request), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: ['assets'] }); + queryClient.invalidateQueries({ queryKey: ['assets', variables.schematicId] }); + queryClient.invalidateQueries({ queryKey: ['assets', variables.schematicId, 'status'] }); + }, + }); +} + +export function useDeleteAsset() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (schematicId: string) => assetsApi.delete(schematicId), + onSuccess: (_, schematicId) => { + queryClient.invalidateQueries({ queryKey: ['assets'] }); + queryClient.invalidateQueries({ queryKey: ['assets', schematicId] }); + queryClient.invalidateQueries({ queryKey: ['assets', schematicId, 'status'] }); + }, + }); +} diff --git a/src/services/api/index.ts b/src/services/api/index.ts index 564cd15..2ff7786 100644 --- a/src/services/api/index.ts +++ b/src/services/api/index.ts @@ -10,6 +10,7 @@ export { operationsApi } from './operations'; export { dnsmasqApi } from './dnsmasq'; export { utilitiesApi } from './utilities'; export { pxeApi } from './pxe'; +export { assetsApi } from './assets'; // React Query hooks export { useInstance, useInstanceOperations, useInstanceClusterHealth } from './hooks/useInstance'; @@ -17,3 +18,4 @@ export { useOperations, useOperation, useCancelOperation } from './hooks/useOper export { useClusterHealth, useClusterStatus, useClusterNodes } from './hooks/useCluster'; export { useDashboardToken, useClusterVersions, useNodeIPs, useControlPlaneIP, useCopySecret } from './hooks/useUtilities'; export { usePxeAssets, useDownloadPxeAsset, useDeletePxeAsset } from './hooks/usePxeAssets'; +export { useAssetList, useAsset, useAssetStatus, useDownloadAsset } from './hooks/useAssets'; diff --git a/src/services/api/types/asset.ts b/src/services/api/types/asset.ts new file mode 100644 index 0000000..ec895ad --- /dev/null +++ b/src/services/api/types/asset.ts @@ -0,0 +1,38 @@ +export type AssetType = 'kernel' | 'initramfs' | 'iso'; +export type Platform = 'amd64' | 'arm64'; + +// Simplified Asset interface matching backend +export interface Asset { + type: string; + path: string; + size: number; + sha256: string; + downloaded: boolean; +} + +// Schematic representation matching backend +export interface Schematic { + schematic_id: string; + version: string; + path: string; + assets: Asset[]; +} + +export interface AssetListResponse { + schematics: Schematic[]; +} + +export interface DownloadAssetRequest { + version: string; + platform?: Platform; + assets?: AssetType[]; + force?: boolean; +} + +// Simplified status response matching backend +export interface AssetStatusResponse { + schematic_id: string; + version: string; + assets: Record; + complete: boolean; +} diff --git a/src/services/api/types/index.ts b/src/services/api/types/index.ts index 5e23c8b..5549482 100644 --- a/src/services/api/types/index.ts +++ b/src/services/api/types/index.ts @@ -7,3 +7,4 @@ export * from './cluster'; export * from './app'; export * from './service'; export * from './pxe'; +export * from './asset';