From e5bd3c36f5c585a985ece1a05089b8f1837af4fb Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Sun, 12 Oct 2025 17:44:54 +0000 Subject: [PATCH] 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({