First swing.

This commit is contained in:
2025-10-12 17:44:54 +00:00
parent 33454bc4e1
commit e5bd3c36f5
106 changed files with 7592 additions and 1270 deletions

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
# Wild Cloud API Configuration
# This should point to your Wild Central API server
VITE_API_BASE_URL=http://localhost:5055

3
.gitignore vendored
View File

@@ -12,6 +12,9 @@ dist
dist-ssr
*.local
# Environment variables
.env
# Editor directories and files
.vscode/*
!.vscode/extensions.json

View File

@@ -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`.

View File

@@ -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<unknown>;
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 extends Record<string, string>>(): 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
<Link to="/instances/prod-cluster/dashboard">
Go to Dashboard
</Link>
```
**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
<NavLink
to="/instances/prod-cluster/dashboard"
className={({ isActive }) => isActive ? 'active-nav-link' : 'nav-link'}
>
Dashboard
</NavLink>
```
## 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 <button onClick={handleClick}>Open {instance.name}</button>;
}
```
### 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 <div>Dashboard for {instanceId}</div>;
}
```
### Sidebar Integration
```typescript
// AppSidebar using NavLink
import { NavLink } from 'react-router';
function AppSidebar() {
const { instanceId } = useParams();
return (
<nav>
<NavLink
to={`/instances/${instanceId}/dashboard`}
className={({ isActive }) => isActive ? 'active' : ''}
>
Dashboard
</NavLink>
</nav>
);
}
```
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0.0 | 2025-10-12 | Initial contract definition |

View File

@@ -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",

219
pnpm-lock.yaml generated
View File

@@ -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:

View File

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

View File

@@ -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 (
<Sidebar variant="sidebar" collapsible="icon">
@@ -110,40 +67,57 @@ export function AppSidebar({ currentTab, onTabChange, completedPhases }: AppSide
</div>
<div className="group-data-[collapsible=icon]:hidden">
<h2 className="text-lg font-bold text-foreground">Wild Cloud</h2>
<p className="text-sm text-muted-foreground">Central</p>
<p className="text-sm text-muted-foreground">{instanceId}</p>
</div>
</div>
</SidebarHeader>
<SidebarContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
isActive={currentTab === 'cloud'}
onClick={() => {
const status = getTabStatus('cloud');
if (status !== 'locked') onTabChange('cloud');
}}
disabled={getTabStatus('cloud') === 'locked'}
tooltip="Configure cloud settings and domains"
className={cn(
"transition-colors",
getTabStatus('cloud') === 'locked' && "opacity-50 cursor-not-allowed"
<NavLink to={`/instances/${instanceId}/dashboard`}>
{({ isActive }) => (
<SidebarMenuButton
isActive={isActive}
tooltip="Instance dashboard and overview"
>
<div className={cn(
"p-1 rounded-md",
isActive && "bg-primary/10"
)}>
<CloudLightning className={cn(
"h-4 w-4",
isActive && "text-primary",
!isActive && "text-muted-foreground"
)} />
</div>
<span className="truncate">Dashboard</span>
</SidebarMenuButton>
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'cloud' && "bg-primary/10",
getTabStatus('cloud') === 'locked' && "bg-muted"
)}>
<CloudLightning className={cn(
"h-4 w-4",
currentTab === 'cloud' && "text-primary",
currentTab !== 'cloud' && "text-muted-foreground"
)} />
</div>
<span className="truncate">Cloud</span>
</SidebarMenuButton>
</NavLink>
</SidebarMenuItem>
<SidebarMenuItem>
<NavLink to={`/instances/${instanceId}/cloud`}>
{({ isActive }) => (
<SidebarMenuButton
isActive={isActive}
tooltip="Configure cloud settings and domains"
>
<div className={cn(
"p-1 rounded-md",
isActive && "bg-primary/10"
)}>
<CloudLightning className={cn(
"h-4 w-4",
isActive && "text-primary",
!isActive && "text-muted-foreground"
)} />
</div>
<span className="truncate">Cloud</span>
</SidebarMenuButton>
)}
</NavLink>
</SidebarMenuItem>
<Collapsible defaultOpen className="group/collapsible">
@@ -158,110 +132,57 @@ export function AppSidebar({ currentTab, onTabChange, completedPhases }: AppSide
<CollapsibleContent>
<SidebarMenuSub>
<SidebarMenuSubItem>
<SidebarMenuSubButton
isActive={currentTab === 'central'}
onClick={() => {
const status = getTabStatus('central');
if (status !== 'locked') onTabChange('central');
}}
className={cn(
"transition-colors",
getTabStatus('central') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'central' && "bg-primary/10",
getTabStatus('central') === 'locked' && "bg-muted"
)}>
<Server className={cn(
"h-4 w-4",
currentTab === 'central' && "text-primary",
currentTab !== 'central' && "text-muted-foreground"
)} />
</div>
<span className="truncate">Central</span>
<SidebarMenuSubButton asChild>
<NavLink to={`/instances/${instanceId}/central`} className={({ isActive }) => isActive ? "data-[active=true]" : ""}>
<div className="p-1 rounded-md">
<Server className="h-4 w-4" />
</div>
<span className="truncate">Central</span>
</NavLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton
isActive={currentTab === 'dns'}
onClick={() => {
const status = getTabStatus('dns');
if (status !== 'locked') onTabChange('dns');
}}
className={cn(
"transition-colors",
getTabStatus('dns') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'dns' && "bg-primary/10",
getTabStatus('dns') === 'locked' && "bg-muted"
)}>
<Globe className={cn(
"h-4 w-4",
currentTab === 'dns' && "text-primary",
currentTab !== 'dns' && "text-muted-foreground"
)} />
</div>
<span className="truncate">DNS</span>
<SidebarMenuSubButton asChild>
<NavLink to={`/instances/${instanceId}/dns`}>
<div className="p-1 rounded-md">
<Globe className="h-4 w-4" />
</div>
<span className="truncate">DNS</span>
</NavLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton
isActive={currentTab === 'dhcp'}
onClick={() => {
const status = getTabStatus('dhcp');
if (status !== 'locked') onTabChange('dhcp');
}}
className={cn(
"transition-colors",
getTabStatus('dhcp') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'dhcp' && "bg-primary/10",
getTabStatus('dhcp') === 'locked' && "bg-muted"
)}>
<Wifi className={cn(
"h-4 w-4",
currentTab === 'dhcp' && "text-primary",
currentTab !== 'dhcp' && "text-muted-foreground"
)} />
</div>
<span className="truncate">DHCP</span>
<SidebarMenuSubButton asChild>
<NavLink to={`/instances/${instanceId}/dhcp`}>
<div className="p-1 rounded-md">
<Wifi className="h-4 w-4" />
</div>
<span className="truncate">DHCP</span>
</NavLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton
isActive={currentTab === 'pxe'}
onClick={() => {
const status = getTabStatus('pxe');
if (status !== 'locked') onTabChange('pxe');
}}
className={cn(
"transition-colors",
getTabStatus('pxe') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'pxe' && "bg-primary/10",
getTabStatus('pxe') === 'locked' && "bg-muted"
)}>
<HardDrive className={cn(
"h-4 w-4",
currentTab === 'pxe' && "text-primary",
currentTab !== 'pxe' && "text-muted-foreground"
)} />
</div>
<span className="truncate">PXE</span>
<SidebarMenuSubButton asChild>
<NavLink to={`/instances/${instanceId}/pxe`}>
<div className="p-1 rounded-md">
<HardDrive className="h-4 w-4" />
</div>
<span className="truncate">PXE</span>
</NavLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton asChild>
<NavLink to={`/instances/${instanceId}/iso`}>
<div className="p-1 rounded-md">
<Usb className="h-4 w-4" />
</div>
<span className="truncate">ISO / USB</span>
</NavLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
@@ -281,56 +202,24 @@ export function AppSidebar({ currentTab, onTabChange, completedPhases }: AppSide
<CollapsibleContent>
<SidebarMenuSub>
<SidebarMenuSubItem>
<SidebarMenuSubButton
isActive={currentTab === 'infrastructure'}
onClick={() => {
const status = getTabStatus('infrastructure');
if (status !== 'locked') onTabChange('infrastructure');
}}
className={cn(
"transition-colors",
getTabStatus('infrastructure') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'infrastructure' && "bg-primary/10",
getTabStatus('infrastructure') === 'locked' && "bg-muted"
)}>
<Play className={cn(
"h-4 w-4",
currentTab === 'infrastructure' && "text-primary",
currentTab !== 'infrastructure' && "text-muted-foreground"
)} />
</div>
<span className="truncate">Cluster Nodes</span>
<SidebarMenuSubButton asChild>
<NavLink to={`/instances/${instanceId}/infrastructure`}>
<div className="p-1 rounded-md">
<Play className="h-4 w-4" />
</div>
<span className="truncate">Cluster Nodes</span>
</NavLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
<SidebarMenuSubItem>
<SidebarMenuSubButton
isActive={currentTab === 'cluster'}
onClick={() => {
const status = getTabStatus('cluster');
if (status !== 'locked') onTabChange('cluster');
}}
className={cn(
"transition-colors",
getTabStatus('cluster') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'cluster' && "bg-primary/10",
getTabStatus('cluster') === 'locked' && "bg-muted"
)}>
<Container className={cn(
"h-4 w-4",
currentTab === 'cluster' && "text-primary",
currentTab !== 'cluster' && "text-muted-foreground"
)} />
</div>
<span className="truncate">Cluster Services</span>
<SidebarMenuSubButton asChild>
<NavLink to={`/instances/${instanceId}/cluster`}>
<div className="p-1 rounded-md">
<Container className="h-4 w-4" />
</div>
<span className="truncate">Cluster Services</span>
</NavLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
@@ -339,60 +228,24 @@ export function AppSidebar({ currentTab, onTabChange, completedPhases }: AppSide
</Collapsible>
<SidebarMenuItem>
<SidebarMenuButton
isActive={currentTab === 'apps'}
onClick={() => {
const status = getTabStatus('apps');
if (status !== 'locked') onTabChange('apps');
}}
disabled={getTabStatus('apps') === 'locked'}
tooltip="Install and manage applications"
className={cn(
"transition-colors",
getTabStatus('apps') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'apps' && "bg-primary/10",
getTabStatus('apps') === 'locked' && "bg-muted"
)}>
<AppWindow className={cn(
"h-4 w-4",
currentTab === 'apps' && "text-primary",
currentTab !== 'apps' && "text-muted-foreground"
)} />
</div>
<span className="truncate">Apps</span>
<SidebarMenuButton asChild tooltip="Install and manage applications">
<NavLink to={`/instances/${instanceId}/apps`}>
<div className="p-1 rounded-md">
<AppWindow className="h-4 w-4" />
</div>
<span className="truncate">Apps</span>
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
isActive={currentTab === 'advanced'}
onClick={() => {
const status = getTabStatus('advanced');
if (status !== 'locked') onTabChange('advanced');
}}
disabled={getTabStatus('advanced') === 'locked'}
tooltip="Advanced settings and system configuration"
className={cn(
"transition-colors",
getTabStatus('advanced') === 'locked' && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
"p-1 rounded-md",
currentTab === 'advanced' && "bg-primary/10",
getTabStatus('advanced') === 'locked' && "bg-muted"
)}>
<Settings className={cn(
"h-4 w-4",
currentTab === 'advanced' && "text-primary",
currentTab !== 'advanced' && "text-muted-foreground"
)} />
</div>
<span className="truncate">Advanced</span>
<SidebarMenuButton asChild tooltip="Advanced settings and system configuration">
<NavLink to={`/instances/${instanceId}/advanced`}>
<div className="p-1 rounded-md">
<Settings className="h-4 w-4" />
</div>
<span className="truncate">Advanced</span>
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
@@ -413,4 +266,4 @@ export function AppSidebar({ currentTab, onTabChange, completedPhases }: AppSide
<SidebarRail/>
</Sidebar>
);
}
}

View File

@@ -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<Application[]>([
{
id: 'postgres',
name: 'PostgreSQL',
description: 'Reliable, high-performance SQL database',
category: 'database',
status: 'running',
version: 'v15.4',
namespace: 'default',
replicas: 1,
resources: { cpu: '500m', memory: '1Gi' },
urls: ['postgres://postgres.wildcloud.local:5432'],
},
{
id: 'redis',
name: 'Redis',
description: 'In-memory data structure store',
category: 'database',
status: 'running',
version: 'v7.2',
namespace: 'default',
replicas: 1,
resources: { cpu: '250m', memory: '512Mi' },
},
{
id: 'traefik-dashboard',
name: 'Traefik Dashboard',
description: 'Load balancer and reverse proxy dashboard',
category: 'web',
status: 'running',
version: 'v3.0',
namespace: 'kube-system',
urls: ['https://traefik.wildcloud.local'],
},
{
id: 'grafana',
name: 'Grafana',
description: 'Monitoring and observability dashboards',
category: 'monitoring',
status: 'installing',
version: 'v10.2',
namespace: 'monitoring',
},
{
id: 'prometheus',
name: 'Prometheus',
description: 'Time-series monitoring and alerting',
category: 'monitoring',
status: 'running',
version: 'v2.45',
namespace: 'monitoring',
replicas: 1,
resources: { cpu: '1000m', memory: '2Gi' },
},
{
id: 'vault',
name: 'HashiCorp Vault',
description: 'Secrets management and encryption',
category: 'security',
status: 'available',
version: 'v1.15',
},
{
id: 'minio',
name: 'MinIO',
description: 'High-performance object storage',
category: 'storage',
status: 'available',
version: 'RELEASE.2023-12-07',
},
]);
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<string>('all');
const [configDialogOpen, setConfigDialogOpen] = useState(false);
const [selectedAppForConfig, setSelectedAppForConfig] = useState<App | null>(null);
const [backupModalOpen, setBackupModalOpen] = useState(false);
const [restoreModalOpen, setRestoreModalOpen] = useState(false);
const [selectedAppForBackup, setSelectedAppForBackup] = useState<string | null>(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 <CheckCircle className="h-5 w-5 text-green-500" />;
case 'error':
return <AlertCircle className="h-5 w-5 text-red-500" />;
case 'installing':
return <Clock className="h-5 w-5 text-blue-500 animate-spin" />;
case 'deploying':
return <Loader2 className="h-5 w-5 text-blue-500 animate-spin" />;
case 'stopped':
return <AlertCircle className="h-5 w-5 text-yellow-500" />;
default:
case 'added':
return <Settings className="h-5 w-5 text-blue-500" />;
case 'available':
return <Download className="h-5 w-5 text-muted-foreground" />;
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<string, 'secondary' | 'default' | 'success' | 'destructive' | 'warning' | 'outline'> = {
available: 'secondary',
installing: 'default',
added: 'outline',
deploying: 'default',
running: 'success',
error: 'destructive',
stopped: 'warning',
} as const;
deployed: 'outline',
};
const labels = {
const labels: Record<string, string> = {
available: 'Available',
installing: 'Installing',
added: 'Added',
deploying: 'Deploying',
running: 'Running',
error: 'Error',
stopped: 'Stopped',
deployed: 'Deployed',
};
return (
<Badge variant={variants[status] as any}>
{labels[status]}
<Badge variant={variants[status]}>
{labels[status] || status}
</Badge>
);
};
const getCategoryIcon = (category: Application['category']) => {
const getCategoryIcon = (category?: string) => {
switch (category) {
case 'database':
return <Database className="h-4 w-4" />;
@@ -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<string, string>) => {
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 (
<Card className="p-8 text-center">
<AppWindow className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">No Instance Selected</h3>
<p className="text-muted-foreground mb-4">
Please select or create an instance to manage apps.
</p>
</Card>
);
}
// Show error state
if (availableError || deployedError) {
return (
<Card className="p-8 text-center">
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">Error Loading Apps</h3>
<p className="text-muted-foreground mb-4">
{(availableError as Error)?.message || (deployedError as Error)?.message || 'An error occurred'}
</p>
<Button onClick={() => window.location.reload()}>Reload Page</Button>
</Card>
);
}
return (
<div className="space-y-6">
@@ -260,135 +305,199 @@ export function AppsComponent({ onComplete }: AppsComponentProps) {
<div className="flex items-center justify-between mb-4">
<div className="text-sm text-muted-foreground">
{runningApps} applications running • {applications.length} total available
{isLoading ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Loading apps...
</span>
) : (
`${runningApps} applications running • ${applications.length} total available`
)}
</div>
<Button size="sm">
<Plus className="h-4 w-4 mr-2" />
Add App
</Button>
</div>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{filteredApps.map((app) => (
<Card key={app.id} className="p-4">
<div className="flex items-start gap-3">
<div className="p-2 bg-muted rounded-lg">
{getCategoryIcon(app.category)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium truncate">{app.name}</h3>
{app.version && (
<Badge variant="outline" className="text-xs">
{app.version}
</Badge>
)}
{getStatusIcon(app.status)}
{isLoading ? (
<Card className="p-8 text-center">
<Loader2 className="h-12 w-12 text-primary mx-auto mb-4 animate-spin" />
<p className="text-muted-foreground">Loading applications...</p>
</Card>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{filteredApps.map((app) => (
<Card key={app.name} className="p-4">
<div className="flex items-start gap-3">
<div className="p-2 bg-muted rounded-lg">
{getCategoryIcon(app.category)}
</div>
<p className="text-sm text-muted-foreground mb-2">{app.description}</p>
{app.status === 'running' && (
<div className="space-y-1 text-xs text-muted-foreground">
{app.namespace && (
<div>Namespace: {app.namespace}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium truncate">{app.name}</h3>
{app.version && (
<Badge variant="outline" className="text-xs">
{app.version}
</Badge>
)}
{app.replicas && (
<div>Replicas: {app.replicas}</div>
{getStatusIcon(app.status?.status)}
</div>
<p className="text-sm text-muted-foreground mb-2">{app.description}</p>
{app.status?.status === 'running' && (
<div className="space-y-1 text-xs text-muted-foreground">
{app.status.namespace && (
<div>Namespace: {app.status.namespace}</div>
)}
{app.status.replicas && (
<div>Replicas: {app.status.replicas}</div>
)}
{app.status.resources && (
<div>
Resources: {app.status.resources.cpu} CPU, {app.status.resources.memory} RAM
</div>
)}
</div>
)}
{app.status?.message && (
<p className="text-xs text-muted-foreground mt-1">{app.status.message}</p>
)}
</div>
<div className="flex flex-col gap-2">
{getStatusBadge(app)}
<div className="flex flex-col gap-1">
{/* Available: not added yet */}
{!app.deploymentStatus && (
<Button
size="sm"
onClick={() => handleAppAction(app, 'configure')}
disabled={isAdding}
>
{isAdding ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Configure & Add'}
</Button>
)}
{app.resources && (
<div>Resources: {app.resources.cpu} CPU, {app.resources.memory} RAM</div>
{/* Added: in config but not deployed */}
{app.deploymentStatus === 'added' && (
<>
<Button
size="sm"
variant="outline"
onClick={() => handleAppAction(app, 'configure')}
title="Edit configuration"
>
<Settings className="h-4 w-4" />
</Button>
<Button
size="sm"
onClick={() => handleAppAction(app, 'deploy')}
disabled={isDeploying}
>
{isDeploying ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Deploy'}
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleAppAction(app, 'delete')}
disabled={isDeleting}
>
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
</Button>
</>
)}
{app.urls && app.urls.length > 0 && (
<div className="flex items-center gap-1">
<span>URLs:</span>
{app.urls.map((url, index) => (
<Button
key={index}
variant="link"
size="sm"
className="h-auto p-0 text-xs"
onClick={() => window.open(url, '_blank')}
>
<ExternalLink className="h-3 w-3 mr-1" />
Access
</Button>
))}
</div>
{/* Deployed: running in Kubernetes */}
{app.deploymentStatus === 'deployed' && (
<>
{app.status?.status === 'running' && (
<>
<Button
size="sm"
variant="outline"
onClick={() => handleAppAction(app, 'backup')}
disabled={isBackingUp}
title="Create backup"
>
<Archive className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleAppAction(app, 'restore')}
disabled={isRestoring}
title="Restore from backup"
>
<RotateCcw className="h-4 w-4" />
</Button>
</>
)}
<Button
size="sm"
variant="destructive"
onClick={() => handleAppAction(app, 'delete')}
disabled={isDeleting}
>
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
</Button>
</>
)}
</div>
)}
</div>
<div className="flex flex-col gap-2">
{getStatusBadge(app.status)}
<div className="flex gap-1">
{app.status === 'available' && (
<Button
size="sm"
onClick={() => handleAppAction(app.id, 'install')}
>
Install
</Button>
)}
{app.status === 'running' && (
<>
<Button
size="sm"
variant="outline"
onClick={() => handleAppAction(app.id, 'configure')}
>
<Settings className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleAppAction(app.id, 'stop')}
>
Stop
</Button>
</>
)}
{app.status === 'stopped' && (
<Button
size="sm"
onClick={() => handleAppAction(app.id, 'start')}
>
Start
</Button>
)}
{(app.status === 'running' || app.status === 'stopped') && (
<Button
size="sm"
variant="destructive"
onClick={() => handleAppAction(app.id, 'delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
</div>
</Card>
))}
</div>
</Card>
))}
</div>
)}
{filteredApps.length === 0 && (
{!isLoading && filteredApps.length === 0 && (
<Card className="p-8 text-center">
<AppWindow className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">No applications found</h3>
<p className="text-muted-foreground mb-4">
{searchTerm || selectedCategory !== 'all'
{searchTerm || selectedCategory !== 'all'
? 'Try adjusting your search or category filter'
: 'Install your first application to get started'
: 'No applications available to display'
}
</p>
<Button>
<Plus className="h-4 w-4 mr-2" />
Browse App Catalog
</Button>
</Card>
)}
{/* Backup Modal */}
<BackupRestoreModal
isOpen={backupModalOpen}
onClose={() => {
setBackupModalOpen(false);
setSelectedAppForBackup(null);
}}
mode="backup"
appName={selectedAppForBackup || ''}
onConfirm={handleBackupConfirm}
isPending={isBackingUp}
/>
{/* Restore Modal */}
<BackupRestoreModal
isOpen={restoreModalOpen}
onClose={() => {
setRestoreModalOpen(false);
setSelectedAppForBackup(null);
}}
mode="restore"
appName={selectedAppForBackup || ''}
backups={backups?.backups || []}
isLoading={backupsLoading}
onConfirm={handleRestoreConfirm}
isPending={isRestoring}
/>
{/* App Configuration Dialog */}
<AppConfigDialog
open={configDialogOpen}
onOpenChange={setConfigDialogOpen}
app={selectedAppForConfig}
existingConfig={selectedAppForConfig?.config}
onSave={handleConfigSave}
isSaving={isAdding}
/>
</div>
);
}

View File

@@ -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<string | null>(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 (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{mode === 'backup' ? 'Create Backup' : 'Restore from Backup'}
</DialogTitle>
<DialogDescription>
{mode === 'backup'
? `Create a backup of the ${appName} application data.`
: `Select a backup to restore for the ${appName} application.`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{mode === 'backup' ? (
<div className="p-4 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground">
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.
</p>
</div>
) : (
<div className="space-y-3">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : backups.length === 0 ? (
<div className="text-center py-8">
<AlertCircle className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-sm text-muted-foreground">
No backups available for this application.
</p>
</div>
) : (
<div className="space-y-2 max-h-64 overflow-y-auto">
{backups.map((backup) => (
<button
key={backup.id}
onClick={() => setSelectedBackupId(backup.id)}
className={`w-full p-3 rounded-lg border text-left transition-colors ${
selectedBackupId === backup.id
? 'border-primary bg-primary/10'
: 'border-border hover:bg-muted'
}`}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{formatTimestamp(backup.timestamp)}
</span>
</div>
{selectedBackupId === backup.id && (
<Badge variant="default">Selected</Badge>
)}
</div>
{backup.size && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<HardDrive className="h-3 w-3" />
<span>{backup.size}</span>
</div>
)}
</button>
))}
</div>
)}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isPending}>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={
isPending ||
(mode === 'restore' && (!selectedBackupId || backups.length === 0))
}
>
{isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{mode === 'backup' ? 'Creating...' : 'Restoring...'}
</>
) : mode === 'backup' ? (
'Create Backup'
) : (
'Restore'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -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 (
<Card className="p-8 text-center">
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">Error Loading Central Status</h3>
<p className="text-muted-foreground mb-4">
{(statusError as Error)?.message || 'An error occurred'}
</p>
<Button onClick={() => window.location.reload()}>Reload Page</Button>
</Card>
);
}
return (
<div className="space-y-6">
{/* Educational Intro Section */}
@@ -17,8 +56,8 @@ export function CentralComponent() {
What is the Central Service?
</h3>
<p className="text-blue-800 dark:text-blue-200 mb-3 leading-relaxed">
The Central Service is the "brain" of your personal cloud. It acts as the main coordinator that manages
all the different services running on your network. Think of it like the control tower at an airport -
The Central Service is the "brain" of your personal cloud. It acts as the main coordinator that manages
all the different services running on your network. Think of it like the control tower at an airport -
it keeps track of what's happening, routes traffic between services, and ensures everything works together smoothly.
</p>
<p className="text-blue-700 dark:text-blue-300 mb-4 text-sm">
@@ -37,78 +76,123 @@ export function CentralComponent() {
<div className="p-2 bg-primary/10 rounded-lg">
<Server className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">Central Service</h2>
<div className="flex-1">
<h2 className="text-2xl font-semibold">Central Service Status</h2>
<p className="text-muted-foreground">
Monitor and manage the central server service
Monitor the Wild Central server
</p>
</div>
{centralStatus && (
<Badge variant="success" className="flex items-center gap-2">
<CheckCircle className="h-4 w-4" />
{centralStatus.status === 'running' ? 'Running' : centralStatus.status}
</Badge>
)}
</div>
<div>
<h3 className="text-lg font-medium mb-4">Service Status</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
<div className="flex items-center gap-2">
<Server className="h-5 w-5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">IP Address: 192.168.8.50</span>
</div>
<div className="flex items-center gap-2">
<Network className="h-5 w-5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Network: 192.168.8.0/24</span>
</div>
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Version: 1.0.0 (update available)</span>
</div>
<div className="flex items-center gap-2">
<Clock className="h-5 w-5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Age: 12s</span>
</div>
<div className="flex items-center gap-2">
<HelpCircle className="h-5 w-5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Platform: ARM</span>
</div>
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-500" />
<span className="text-sm text-green-500">File permissions: Good</span>
</div>
{statusLoading || configLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
<div className="space-y-4">
) : (
<div className="space-y-6">
{/* Server Information */}
<div>
<Label htmlFor="ip">IP</Label>
<div className="flex w-full items-center mt-1">
<Input id="ip" value="192.168.5.80"/>
<Button variant="ghost">
<HelpCircle/>
</Button>
<h3 className="text-lg font-medium mb-4">Server Information</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Card className="p-4 border-l-4 border-l-blue-500">
<div className="flex items-start gap-3">
<Settings className="h-5 w-5 text-blue-500 mt-0.5" />
<div className="flex-1">
<div className="text-sm text-muted-foreground mb-1">Version</div>
<div className="font-medium font-mono">{centralStatus?.version || 'Unknown'}</div>
</div>
</div>
</Card>
<Card className="p-4 border-l-4 border-l-green-500">
<div className="flex items-start gap-3">
<Clock className="h-5 w-5 text-green-500 mt-0.5" />
<div className="flex-1">
<div className="text-sm text-muted-foreground mb-1">Uptime</div>
<div className="font-medium">{formatUptime(centralStatus?.uptimeSeconds)}</div>
</div>
</div>
</Card>
<Card className="p-4 border-l-4 border-l-purple-500">
<div className="flex items-start gap-3">
<Database className="h-5 w-5 text-purple-500 mt-0.5" />
<div className="flex-1">
<div className="text-sm text-muted-foreground mb-1">Instances</div>
<div className="font-medium">{centralStatus?.instances.count || 0} configured</div>
{centralStatus?.instances.names && centralStatus.instances.names.length > 0 && (
<div className="text-xs text-muted-foreground mt-1">
{centralStatus.instances.names.join(', ')}
</div>
)}
</div>
</div>
</Card>
<Card className="p-4 border-l-4 border-l-orange-500">
<div className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-orange-500 mt-0.5" />
<div className="flex-1">
<div className="text-sm text-muted-foreground mb-1">Setup Files</div>
<div className="font-medium capitalize">{centralStatus?.setupFiles || 'Unknown'}</div>
</div>
</div>
</Card>
</div>
</div>
{/* Configuration */}
<div>
<Label htmlFor="interface">Interface</Label>
<div className="flex w-full items-center mt-1">
<Input id="interface" value="eth0"/>
<Button variant="ghost">
<HelpCircle/>
</Button>
<h3 className="text-lg font-medium mb-4">Configuration</h3>
<div className="space-y-3">
<Card className="p-4 border-l-4 border-l-cyan-500">
<div className="flex items-start gap-3">
<Server className="h-5 w-5 text-cyan-500 mt-0.5" />
<div className="flex-1">
<div className="text-sm text-muted-foreground mb-1">Server Host</div>
<div className="font-medium font-mono">{serverConfig?.host || '0.0.0.0'}</div>
</div>
<div className="flex-1">
<div className="text-sm text-muted-foreground mb-1">Server Port</div>
<div className="font-medium font-mono">{serverConfig?.port || 5055}</div>
</div>
</div>
</Card>
<Card className="p-4 border-l-4 border-l-indigo-500">
<div className="flex items-start gap-3">
<HardDrive className="h-5 w-5 text-indigo-500 mt-0.5" />
<div className="flex-1">
<div className="text-sm text-muted-foreground mb-1">Data Directory</div>
<div className="font-medium font-mono text-sm break-all">
{centralStatus?.dataDir || '/var/lib/wild-central'}
</div>
</div>
</div>
</Card>
<Card className="p-4 border-l-4 border-l-pink-500">
<div className="flex items-start gap-3">
<FolderTree className="h-5 w-5 text-pink-500 mt-0.5" />
<div className="flex-1">
<div className="text-sm text-muted-foreground mb-1">Apps Directory</div>
<div className="font-medium font-mono text-sm break-all">
{centralStatus?.appsDir || '/opt/wild-cloud/apps'}
</div>
</div>
</div>
</Card>
</div>
</div>
</div>
<div className="flex gap-2 justify-end mt-4">
<Button onClick={() => console.log('Update service')}>
Update
</Button>
<Button onClick={() => console.log('Restart service')}>
Restart
</Button>
<Button onClick={() => console.log('View log')}>
View log
</Button>
</div>
</div>
)}
</Card>
</div>
);
}
}

View File

@@ -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<CloudConfig | null>(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<string, unknown>),
[childKey]: value,
},
};
});
};
// Show message if no instance is selected
if (!currentInstance) {
return (
<Card className="p-8 text-center">
<Cloud className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">No Instance Selected</h3>
<p className="text-muted-foreground mb-4">
Please select or create an instance to manage cloud configuration.
</p>
</Card>
);
}
// Show loading state
if (isLoading || !formValues) {
return (
<Card className="p-8 text-center">
<Loader2 className="h-12 w-12 text-primary mx-auto mb-4 animate-spin" />
<p className="text-muted-foreground">Loading cloud configuration...</p>
</Card>
);
}
// Show error state
if (error) {
return (
<Card className="p-8 text-center">
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">Error Loading Configuration</h3>
<p className="text-muted-foreground mb-4">
{(error as Error)?.message || 'An error occurred'}
</p>
</Card>
);
}
return (
<div className="space-y-6">
<Card className="p-6">
@@ -51,7 +183,7 @@ export function CloudComponent() {
<div className="space-y-6">
{/* Domains Section */}
<Card className="p-4 border-l-4 border-l-green-500">
<Card className="p-4 border-l-4 border-l-blue-500">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="font-medium">Domain Configuration</h3>
@@ -68,6 +200,7 @@ export function CloudComponent() {
variant="outline"
size="sm"
onClick={handleDomainsEdit}
disabled={isUpdating}
>
<Edit2 className="h-4 w-4 mr-1" />
Edit
@@ -82,8 +215,8 @@ export function CloudComponent() {
<Label htmlFor="domain-edit">Public Domain</Label>
<Input
id="domain-edit"
value={tempDomain}
onChange={(e) => 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() {
<Label htmlFor="internal-domain-edit">Internal Domain</Label>
<Input
id="internal-domain-edit"
value={tempInternalDomain}
onChange={(e) => setTempInternalDomain(e.target.value)}
value={formValues.internalDomain}
onChange={(e) => updateFormValue('internalDomain', e.target.value)}
placeholder="internal.example.com"
className="mt-1"
/>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={handleDomainsSave}>
<Check className="h-4 w-4 mr-1" />
<Button size="sm" onClick={handleDomainsSave} disabled={isUpdating}>
{isUpdating ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Check className="h-4 w-4 mr-1" />
)}
Save
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDomainsCancel}
disabled={isUpdating}
>
<X className="h-4 w-4 mr-1" />
Cancel
@@ -118,13 +256,135 @@ export function CloudComponent() {
<div>
<Label>Public Domain</Label>
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
{domainValue}
{formValues.domain}
</div>
</div>
<div>
<Label>Internal Domain</Label>
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
{internalDomainValue}
{formValues.internalDomain}
</div>
</div>
</div>
)}
</Card>
{/* Network Configuration Section */}
<Card className="p-4 border-l-4 border-l-green-500">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="font-medium">Network Configuration</h3>
<p className="text-sm text-muted-foreground">
Network settings and DHCP configuration
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm">
<HelpCircle className="h-4 w-4" />
</Button>
{!editingNetwork && (
<Button
variant="outline"
size="sm"
onClick={handleNetworkEdit}
disabled={isUpdating}
>
<Edit2 className="h-4 w-4 mr-1" />
Edit
</Button>
)}
</div>
</div>
{editingNetwork ? (
<div className="space-y-3">
<div>
<Label htmlFor="dhcp-range-edit">DHCP Range</Label>
<Input
id="dhcp-range-edit"
value={formValues.dhcpRange}
onChange={(e) => updateFormValue('dhcpRange', e.target.value)}
placeholder="192.168.1.100,192.168.1.200"
className="mt-1"
/>
<p className="text-xs text-muted-foreground mt-1">
Format: start_ip,end_ip
</p>
</div>
<div>
<Label htmlFor="dns-ip-edit">DNS Server IP</Label>
<Input
id="dns-ip-edit"
value={formValues.dns.ip}
onChange={(e) => updateFormValue('dns.ip', e.target.value)}
placeholder="192.168.1.1"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="router-ip-edit">Router IP</Label>
<Input
id="router-ip-edit"
value={formValues.router.ip}
onChange={(e) => updateFormValue('router.ip', e.target.value)}
placeholder="192.168.1.1"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="dnsmasq-interface-edit">Dnsmasq Interface</Label>
<Input
id="dnsmasq-interface-edit"
value={formValues.dnsmasq.interface}
onChange={(e) => updateFormValue('dnsmasq.interface', e.target.value)}
placeholder="eth0"
className="mt-1"
/>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={handleNetworkSave} disabled={isUpdating}>
{isUpdating ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Check className="h-4 w-4 mr-1" />
)}
Save
</Button>
<Button
variant="outline"
size="sm"
onClick={handleNetworkCancel}
disabled={isUpdating}
>
<X className="h-4 w-4 mr-1" />
Cancel
</Button>
</div>
</div>
) : (
<div className="space-y-3">
<div>
<Label>DHCP Range</Label>
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
{formValues.dhcpRange}
</div>
</div>
<div>
<Label>DNS Server IP</Label>
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
{formValues.dns.ip}
</div>
</div>
<div>
<Label>Router IP</Label>
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
{formValues.router.ip}
</div>
</div>
<div>
<Label>Dnsmasq Interface</Label>
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
{formValues.dnsmasq.interface}
</div>
</div>
</div>

View File

@@ -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<Node[]>([
{
id: 'controller-1',
name: 'Controller Node 1',
type: 'controller',
status: 'healthy',
macAddress: '00:1A:2B:3C:4D:5E',
osVersion: 'v13.0.4',
specs: {
cpu: '4 cores',
memory: '8GB RAM',
storage: '120GB SSD',
},
},
{
id: 'worker-1',
name: 'Worker Node 1',
type: 'worker',
status: 'healthy',
macAddress: '00:1A:2B:3C:4D:5F',
osVersion: 'v13.0.5',
specs: {
cpu: '8 cores',
memory: '16GB RAM',
storage: '500GB SSD',
},
},
{
id: 'worker-2',
name: 'Worker Node 2',
type: 'worker',
status: 'healthy',
macAddress: '00:1A:2B:3C:4D:60',
osVersion: 'v13.0.4',
specs: {
cpu: '8 cores',
memory: '16GB RAM',
storage: '500GB SSD',
},
},
{
id: 'node-1',
name: 'Node 1',
type: 'unassigned',
status: 'pending',
macAddress: '00:1A:2B:3C:4D:5E',
osVersion: 'v13.0.5',
specs: {
cpu: '4 cores',
memory: '8GB RAM',
storage: '120GB SSD',
},
},
{
id: 'node-2',
name: 'Node 2',
type: 'unassigned',
status: 'pending',
macAddress: '00:1A:2B:3C:4D:5F',
osVersion: 'v13.0.5',
specs: {
cpu: '8 cores',
memory: '16GB RAM',
storage: '500GB SSD',
},
},
]);
const [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 <CheckCircle className="h-5 w-5 text-green-500" />;
case 'error':
return <AlertCircle className="h-5 w-5 text-red-500" />;
case 'connecting':
return <Clock className="h-5 w-5 text-blue-500 animate-spin" />;
case 'provisioning':
return <Loader2 className="h-5 w-5 text-blue-500 animate-spin" />;
default:
return <Monitor className="h-5 w-5 text-muted-foreground" />;
}
};
const getStatusBadge = (status: Node['status']) => {
const variants = {
const getStatusBadge = (status?: string) => {
const variants: Record<string, 'secondary' | 'default' | 'success' | 'destructive'> = {
pending: 'secondary',
connecting: 'default',
connected: 'success',
provisioning: 'default',
ready: 'success',
healthy: 'success',
error: 'destructive',
} as const;
};
const labels = {
const labels: Record<string, string> = {
pending: 'Pending',
connecting: 'Connecting',
connected: 'Connected',
provisioning: 'Provisioning',
ready: 'Ready',
healthy: 'Healthy',
error: 'Error',
};
return (
<Badge variant={variants[status] as any}>
{labels[status]}
<Badge variant={variants[status || 'pending']}>
{labels[status || 'pending'] || status}
</Badge>
);
};
const getTypeIcon = (type: Node['type']) => {
return type === 'controller' ? (
const getRoleIcon = (role: string) => {
return role === 'controlplane' ? (
<Cpu className="h-4 w-4" />
) : (
<HardDrive className="h-4 w-4" />
);
};
const handleNodeAction = (nodeId: string, action: 'connect' | 'retry' | 'upgrade_node') => {
console.log(`${action} node: ${nodeId}`);
const 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 (
<Card className="p-8 text-center">
<Network className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">No Instance Selected</h3>
<p className="text-muted-foreground mb-4">
Please select or create an instance to manage nodes.
</p>
</Card>
);
}
// Show error state
if (error) {
return (
<Card className="p-8 text-center">
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">Error Loading Nodes</h3>
<p className="text-muted-foreground mb-4">
{(error as Error)?.message || 'An error occurred'}
</p>
<Button onClick={() => window.location.reload()}>Reload Page</Button>
</Card>
);
}
return (
<div className="space-y-6">
@@ -190,148 +184,148 @@ export function ClusterNodesComponent({ onComplete }: ClusterNodesComponentProps
</div>
</div>
<div className="space-y-4">
<h2 className="text-lg font-medium mb-4">Assigned Nodes ({assignedNodes.length}/{totalNodes})</h2>
{assignedNodes.map((node) => (
<Card key={node.id} className="p-4">
<div className="flex items-center gap-4">
<div className="p-2 bg-muted rounded-lg">
{getTypeIcon(node.type)}
{isLoading ? (
<Card className="p-8 text-center">
<Loader2 className="h-12 w-12 text-primary mx-auto mb-4 animate-spin" />
<p className="text-muted-foreground">Loading nodes...</p>
</Card>
) : (
<>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium">Cluster Nodes ({assignedNodes.length})</h2>
<div className="flex gap-2">
<input
type="text"
placeholder="Subnet (e.g., 192.168.1.0/24)"
value={subnet}
onChange={(e) => setSubnet(e.target.value)}
className="px-3 py-1 text-sm border rounded-lg"
/>
<Button
size="sm"
onClick={handleDiscover}
disabled={isDiscovering || discoveryStatus?.active}
>
{isDiscovering || discoveryStatus?.active ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : null}
{discoveryStatus?.active ? 'Discovering...' : 'Discover'}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleDetect}
disabled={isDetecting}
>
{isDetecting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
Auto Detect
</Button>
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium">{node.name}</h4>
<Badge variant="outline" className="text-xs">
{node.type}
</Badge>
{getStatusIcon(node.status)}
</div>
<div className="text-sm text-muted-foreground mb-2">
MAC: {node.macAddress}
{node.ipAddress && ` • IP: ${node.ipAddress}`}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Cpu className="h-3 w-3" />
{node.specs.cpu}
</span>
<span className="flex items-center gap-1">
<Monitor className="h-3 w-3" />
{node.specs.memory}
</span>
<span className="flex items-center gap-1">
<HardDrive className="h-3 w-3" />
{node.specs.storage}
</span>
{node.osVersion && (
<span className="flex items-center gap-1">
</div>
{assignedNodes.map((node) => (
<Card key={node.hostname} className="p-4">
<div className="flex items-center gap-4">
<div className="p-2 bg-muted rounded-lg">
{getRoleIcon(node.role)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium">{node.hostname}</h4>
<Badge variant="outline" className="text-xs">
OS: {node.osVersion}
{node.role}
</Badge>
</span>
)}
{getStatusIcon(node.status)}
</div>
<div className="text-sm text-muted-foreground mb-2">
IP: {node.target_ip}
</div>
{node.hardware && (
<div className="flex items-center gap-4 text-xs text-muted-foreground">
{node.hardware.cpu && (
<span className="flex items-center gap-1">
<Cpu className="h-3 w-3" />
{node.hardware.cpu}
</span>
)}
{node.hardware.memory && (
<span className="flex items-center gap-1">
<Monitor className="h-3 w-3" />
{node.hardware.memory}
</span>
)}
{node.hardware.disk && (
<span className="flex items-center gap-1">
<HardDrive className="h-3 w-3" />
{node.hardware.disk}
</span>
)}
</div>
)}
{node.talosVersion && (
<div className="text-xs text-muted-foreground mt-1">
Talos: {node.talosVersion}
{node.kubernetesVersion && ` • K8s: ${node.kubernetesVersion}`}
</div>
)}
</div>
<div className="flex items-center gap-3">
{getStatusBadge(node.status)}
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteNode(node.hostname)}
disabled={isDeleting}
>
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Remove'}
</Button>
</div>
</div>
</div>
<div className="flex items-center gap-3">
{getStatusBadge(node.status)}
{node.osVersion !== currentOsVersion && (
<Button
size="sm"
onClick={() => handleNodeAction(node.id, 'upgrade_node')}
>
Upgrade OS
</Button>
)}
{node.status === 'error' && (
<Button
size="sm"
variant="outline"
onClick={() => handleNodeAction(node.id, 'retry')}
>
Retry
</Button>
)}
</div>
</div>
</Card>
))}
</div>
</Card>
))}
<h2 className="text-lg font-medium mb-4 mt-6">Unassigned Nodes ({unassignedNodes.length}/{totalNodes})</h2>
<div className="space-y-4">
{unassignedNodes.map((node) => (
<Card key={node.id} className="p-4">
<div className="flex items-center gap-4">
<div className="p-2 bg-muted rounded-lg">
{getTypeIcon(node.type)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium">{node.name}</h4>
<Badge variant="outline" className="text-xs">
{node.type}
</Badge>
{getStatusIcon(node.status)}
</div>
<div className="text-sm text-muted-foreground mb-2">
MAC: {node.macAddress}
{node.ipAddress && ` • IP: ${node.ipAddress}`}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Cpu className="h-3 w-3" />
{node.specs.cpu}
</span>
<span className="flex items-center gap-1">
<Monitor className="h-3 w-3" />
{node.specs.memory}
</span>
<span className="flex items-center gap-1">
<HardDrive className="h-3 w-3" />
{node.specs.storage}
</span>
</div>
</div>
<div className="flex items-center gap-3">
{getStatusBadge(node.status)}
{node.status === 'pending' && (
<Button
size="sm"
onClick={() => handleNodeAction(node.id, 'connect')}
>
Assign
</Button>
)}
{node.status === 'error' && (
<Button
size="sm"
variant="outline"
onClick={() => handleNodeAction(node.id, 'retry')}
>
Retry
</Button>
)}
</div>
</div>
</Card>
))}
</div>
{isComplete && (
<div className="mt-6 p-4 bg-green-50 dark:bg-green-950 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="h-5 w-5 text-green-600" />
<h3 className="font-medium text-green-800 dark:text-green-200">
Infrastructure Ready!
</h3>
{assignedNodes.length === 0 && (
<Card className="p-8 text-center">
<Network className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">No Nodes</h3>
<p className="text-muted-foreground mb-4">
Use the discover or auto-detect buttons above to find nodes on your network.
</p>
</Card>
)}
</div>
<p className="text-sm text-green-700 dark:text-green-300 mb-3">
All nodes are connected and ready for Kubernetes installation.
</p>
<Button onClick={onComplete} className="bg-green-600 hover:bg-green-700">
Continue to Kubernetes Installation
</Button>
</div>
{discoveredIps.length > 0 && (
<div className="mt-6">
<h3 className="text-lg font-medium mb-4">Discovered IPs ({discoveredIps.length})</h3>
<div className="space-y-2">
{discoveredIps.map((ip) => (
<Card key={ip} className="p-3 flex items-center justify-between">
<span className="text-sm font-mono">{ip}</span>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => handleAddNode(ip, `node-${ip}`, 'worker')}
disabled={isAdding}
>
Add as Worker
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleAddNode(ip, `controlplane-${ip}`, 'controlplane')}
disabled={isAdding}
>
Add as Control Plane
</Button>
</div>
</Card>
))}
</div>
</div>
)}
</>
)}
</Card>

View File

@@ -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<ClusterComponent[]>([
{
id: 'talos-config',
name: 'Talos Configuration',
description: 'Generate and apply Talos cluster configuration',
status: 'pending',
},
{
id: 'kubernetes-bootstrap',
name: 'Kubernetes Bootstrap',
description: 'Initialize Kubernetes control plane',
status: 'pending',
version: 'v1.29.0',
},
{
id: 'cni-plugin',
name: 'Container Network Interface',
description: 'Install and configure Cilium CNI',
status: 'pending',
version: 'v1.14.5',
},
{
id: 'storage-class',
name: 'Storage Classes',
description: 'Configure persistent volume storage',
status: 'pending',
},
{
id: 'ingress-controller',
name: 'Ingress Controller',
description: 'Install Traefik ingress controller',
status: 'pending',
version: 'v3.0.0',
},
{
id: 'monitoring',
name: 'Cluster Monitoring',
description: 'Deploy Prometheus and Grafana stack',
status: 'pending',
},
]);
const [showLogs, setShowLogs] = useState<string | null>(null);
const getStatusIcon = (status: ClusterComponent['status']) => {
const getStatusIcon = (status?: string) => {
switch (status) {
case 'running':
case 'ready':
return <CheckCircle className="h-5 w-5 text-green-500" />;
case 'error':
return <AlertCircle className="h-5 w-5 text-red-500" />;
case 'deploying':
case 'installing':
return <Clock className="h-5 w-5 text-blue-500 animate-spin" />;
return <Loader2 className="h-5 w-5 text-blue-500 animate-spin" />;
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<string, 'secondary' | 'default' | 'success' | 'destructive' | 'outline'> = {
available: 'secondary',
deploying: 'default',
installing: 'default',
running: 'success',
ready: 'success',
error: 'destructive',
} as const;
deployed: 'outline',
};
const labels = {
pending: 'Pending',
const labels: Record<string, string> = {
available: 'Available',
deploying: 'Deploying',
installing: 'Installing',
running: 'Running',
ready: 'Ready',
error: 'Error',
deployed: 'Deployed',
};
return (
<Badge variant={variants[status] as any}>
{labels[status]}
<Badge variant={variants[status]}>
{labels[status] || status}
</Badge>
);
};
const getComponentIcon = (id: string) => {
switch (id) {
case 'talos-config':
return <FileText className="h-5 w-5" />;
case 'kubernetes-bootstrap':
return <Container className="h-5 w-5" />;
case 'cni-plugin':
return <Network className="h-5 w-5" />;
case 'storage-class':
return <Database className="h-5 w-5" />;
case 'ingress-controller':
return <Shield className="h-5 w-5" />;
case 'monitoring':
return <Terminal className="h-5 w-5" />;
default:
return <Container className="h-5 w-5" />;
const getServiceIcon = (name: string) => {
const lowerName = name.toLowerCase();
if (lowerName.includes('network') || lowerName.includes('cni') || lowerName.includes('cilium')) {
return <Network className="h-5 w-5" />;
} else if (lowerName.includes('storage') || lowerName.includes('volume')) {
return <Database className="h-5 w-5" />;
} else if (lowerName.includes('ingress') || lowerName.includes('traefik') || lowerName.includes('nginx')) {
return <Shield className="h-5 w-5" />;
} else if (lowerName.includes('monitor') || lowerName.includes('prometheus') || lowerName.includes('grafana')) {
return <Terminal className="h-5 w-5" />;
} else {
return <Container className="h-5 w-5" />;
}
};
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 (
<Card className="p-8 text-center">
<Container className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">No Instance Selected</h3>
<p className="text-muted-foreground mb-4">
Please select or create an instance to manage services.
</p>
</Card>
);
}
// Show error state
if (error) {
return (
<Card className="p-8 text-center">
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">Error Loading Services</h3>
<p className="text-muted-foreground mb-4">
{(error as Error)?.message || 'An error occurred'}
</p>
<Button onClick={() => window.location.reload()}>Reload Page</Button>
</Card>
);
}
return (
<div className="space-y-6">
@@ -167,108 +167,91 @@ export function ClusterServicesComponent({ onComplete }: ClusterServicesComponen
</div>
<div className="flex items-center justify-between mb-4">
<pre className="text-xs text-muted-foreground bg-muted p-2 rounded-lg">
endpoint: civil<br/>
endpointIp: 192.168.8.240<br/>
kubernetes:<br/>
config: /home/payne/.kube/config<br/>
context: default<br/>
loadBalancerRange: 192.168.8.240-192.168.8.250<br/>
dashboard:<br/>
adminUsername: admin<br/>
certManager:<br/>
namespace: cert-manager<br/>
cloudflare:<br/>
domain: payne.io<br/>
ownerId: cloud-payne-io-cluster<br/>
</pre>
</div>
<div className="space-y-4">
{components.map((component) => (
<div key={component.id}>
<div className="flex items-center gap-4 p-4 rounded-lg border bg-card">
<div className="p-2 bg-muted rounded-lg">
{getComponentIcon(component.id)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium">{component.name}</h3>
{component.version && (
<Badge variant="outline" className="text-xs">
{component.version}
</Badge>
)}
{getStatusIcon(component.status)}
</div>
<p className="text-sm text-muted-foreground">{component.description}</p>
</div>
<div className="flex items-center gap-3">
{getStatusBadge(component.status)}
{(component.status === 'installing' || component.status === 'error') && (
<Button
size="sm"
variant="outline"
onClick={() => setShowLogs(showLogs === component.id ? null : component.id)}
>
<Terminal className="h-4 w-4 mr-1" />
Logs
</Button>
)}
{component.status === 'pending' && (
<Button
size="sm"
onClick={() => handleComponentAction(component.id, 'install')}
>
Install
</Button>
)}
{component.status === 'error' && (
<Button
size="sm"
variant="outline"
onClick={() => handleComponentAction(component.id, 'retry')}
>
Retry
</Button>
)}
</div>
</div>
{showLogs === component.id && (
<Card className="mt-2 p-4 bg-black text-green-400 font-mono text-sm">
<div className="max-h-40 overflow-y-auto">
<div>Installing {component.name}...</div>
<div> Checking prerequisites</div>
<div> Downloading manifests</div>
{component.status === 'installing' && (
<div className="animate-pulse"> Applying configuration...</div>
)}
{component.status === 'error' && (
<div className="text-red-400"> Installation failed: timeout waiting for pods</div>
)}
</div>
</Card>
)}
</div>
))}
<div className="text-sm text-muted-foreground">
{isLoading ? (
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Loading services...
</span>
) : (
`${services.length} services available`
)}
</div>
<Button
size="sm"
onClick={handleInstallAll}
disabled={isInstallingAll || services.length === 0}
>
{isInstallingAll ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : null}
Install All
</Button>
</div>
{isComplete && (
<div className="mt-6 p-4 bg-green-50 dark:bg-green-950 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="h-5 w-5 text-green-600" />
<h3 className="font-medium text-green-800 dark:text-green-200">
Kubernetes Cluster Ready!
</h3>
</div>
<p className="text-sm text-green-700 dark:text-green-300 mb-3">
Your Kubernetes cluster is fully configured and ready for application deployment.
</p>
<Button onClick={onComplete} className="bg-green-600 hover:bg-green-700">
Continue to App Management
</Button>
{isLoading ? (
<Card className="p-8 text-center">
<Loader2 className="h-12 w-12 text-primary mx-auto mb-4 animate-spin" />
<p className="text-muted-foreground">Loading services...</p>
</Card>
) : (
<div className="space-y-4">
{services.map((service) => (
<div key={service.name}>
<div className="flex items-center gap-4 p-4 rounded-lg border bg-card">
<div className="p-2 bg-muted rounded-lg">
{getServiceIcon(service.name)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium">{service.name}</h3>
{service.version && (
<Badge variant="outline" className="text-xs">
{service.version}
</Badge>
)}
{getStatusIcon(service.status?.status)}
</div>
<p className="text-sm text-muted-foreground">{service.description}</p>
{service.status?.message && (
<p className="text-xs text-muted-foreground mt-1">{service.status.message}</p>
)}
</div>
<div className="flex items-center gap-3">
{getStatusBadge(service)}
{!service.deployed && (
<Button
size="sm"
onClick={() => handleInstallService(service.name)}
disabled={isInstalling}
>
{isInstalling ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Install'}
</Button>
)}
{service.deployed && (
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteService(service.name)}
disabled={isDeleting}
>
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Remove'}
</Button>
)}
</div>
</div>
</div>
))}
{services.length === 0 && (
<Card className="p-8 text-center">
<Container className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">No Services Available</h3>
<p className="text-muted-foreground">
No cluster services are configured for this instance.
</p>
</Card>
)}
</div>
)}
</Card>

View File

@@ -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 {

View File

@@ -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 (
<Card className={cn('p-4', className)}>
<pre className="text-xs overflow-auto max-h-96 whitespace-pre-wrap break-all">
<code>{content}</code>
</pre>
</Card>
);
}

View File

@@ -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,

View File

@@ -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 (
<Button
onClick={handleCopy}
variant={variant}
disabled={disabled}
>
{copied ? (
<>
<Check className="h-4 w-4" />
Copied
</>
) : (
<>
<Copy className="h-4 w-4" />
{label}
</>
)}
</Button>
);
}

View File

@@ -55,7 +55,7 @@ export function DhcpComponent() {
<div>
<Label htmlFor="dhcpRange">IP Range</Label>
<div className="flex w-full items-center mt-1">
<Input id="dhcpRange" value="192.168.8.100,192.168.8.239"/>
<Input id="dhcpRange" value="192.168.8.100,192.168.8.239" readOnly/>
<Button variant="ghost">
<HelpCircle/>
</Button>

View File

@@ -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 (
<Button
onClick={handleDownload}
variant={variant}
disabled={disabled}
>
<Download className="h-4 w-4" />
{label}
</Button>
);
}

View File

@@ -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 (
<Card className="p-4">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Loading instances...</span>
</div>
</Card>
);
}
if (error) {
return (
<Card className="p-4 border-red-200 bg-red-50 dark:bg-red-950/20">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-red-500" />
<span className="text-sm text-red-700 dark:text-red-300">
Error loading instances: {(error as Error).message}
</span>
</div>
</Card>
);
}
return (
<Card className="p-4">
<div className="flex items-center gap-4">
<Cloud className="h-5 w-5 text-primary" />
<div className="flex-1">
<label className="text-sm font-medium mb-1 block">Instance</label>
<select
value={currentInstance || ''}
onChange={(e) => handleSelectInstance(e.target.value)}
className="w-full px-3 py-2 border rounded-lg bg-background"
>
<option value="">Select an instance...</option>
{instances.map((instance) => (
<option key={instance} value={instance}>
{instance}
</option>
))}
</select>
</div>
{currentInstance && (
<Badge variant="success" className="whitespace-nowrap">
<Check className="h-3 w-3 mr-1" />
Active
</Badge>
)}
<Button
size="sm"
variant="outline"
onClick={() => setShowCreateForm(!showCreateForm)}
>
<Plus className="h-4 w-4 mr-1" />
New
</Button>
</div>
{showCreateForm && (
<div className="mt-4 pt-4 border-t">
<div className="flex gap-2">
<input
type="text"
placeholder="Instance name"
value={newInstanceName}
onChange={(e) => setNewInstanceName(e.target.value)}
className="flex-1 px-3 py-2 border rounded-lg"
onKeyDown={(e) => {
if (e.key === 'Enter' && newInstanceName.trim()) {
handleCreateInstance();
}
}}
/>
<Button
size="sm"
onClick={handleCreateInstance}
disabled={!newInstanceName.trim() || isCreating}
>
{isCreating ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Create'}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
setShowCreateForm(false);
setNewInstanceName('');
}}
>
Cancel
</Button>
</div>
</div>
)}
{instances.length === 0 && !showCreateForm && (
<div className="mt-4 pt-4 border-t text-center">
<p className="text-sm text-muted-foreground mb-2">
No instances found. Create your first instance to get started.
</p>
<Button size="sm" onClick={() => setShowCreateForm(true)}>
<Plus className="h-4 w-4 mr-1" />
Create Instance
</Button>
</div>
)}
</Card>
);
}

View File

@@ -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 (
<div className="relative flex items-center gap-2">
<Input
type={revealed ? 'text' : 'password'}
value={value}
onChange={onChange ? (e) => onChange(e.target.value) : undefined}
placeholder={placeholder}
readOnly={isReadOnly}
className={cn('pr-10', className)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 h-full hover:bg-transparent"
onClick={() => setRevealed(!revealed)}
aria-label={revealed ? 'Hide value' : 'Show value'}
>
{revealed ? (
<EyeOff className="h-4 w-4 text-muted-foreground" />
) : (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
</Button>
</div>
);
}

View File

@@ -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 (
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div className="space-y-1">
<CardTitle>{service.name}</CardTitle>
{service.version && (
<CardDescription className="text-xs">v{service.version}</CardDescription>
)}
</div>
{service.status && (
<Badge variant={getStatusColor(service.status.status)}>
{service.status.status}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">{service.description}</p>
{service.status?.message && (
<p className="text-xs text-muted-foreground italic">{service.status.message}</p>
)}
<div className="flex gap-2">
{canInstall && (
<Button
onClick={onInstall}
disabled={isInstalling}
size="sm"
className="w-full"
>
{isInstalling ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Installing...
</>
) : (
'Install'
)}
</Button>
)}
{isInstalled && (
<Button variant="outline" size="sm" className="w-full" disabled>
Installed
</Button>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -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 (
<Card>
<CardHeader>
<div className="flex items-start gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
{icon}
</div>
<div className="flex-1">
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="flex items-center gap-2 text-red-500 text-sm">
<AlertCircle className="h-4 w-4" />
<span>{error.message}</span>
</div>
) : (
children
)}
{action && (
<Button
onClick={action.onClick}
disabled={action.disabled || action.loading || isLoading}
className="w-full"
>
{action.loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
action.label
)}
</Button>
)}
</CardContent>
</Card>
);
}
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 (
<div className="space-y-2">
{label && <div className="text-sm font-medium">{label}</div>}
<div className="flex items-start gap-2">
<div
className={`flex-1 p-3 bg-muted rounded-lg font-mono text-sm ${
multiline ? '' : 'truncate'
}`}
>
{multiline ? (
<pre className="whitespace-pre-wrap break-all">{value}</pre>
) : (
<span className="block truncate">{value}</span>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={handleCopy}
className="shrink-0"
>
{copied ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
);
}

View File

@@ -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<string, string>;
onSave: (config: Record<string, string>) => void;
isSaving?: boolean;
}
export function AppConfigDialog({
open,
onOpenChange,
app,
existingConfig,
onSave,
isSaving = false,
}: AppConfigDialogProps) {
const [config, setConfig] = useState<Record<string, string>>({});
// Initialize config when dialog opens or app changes
useEffect(() => {
if (app && open) {
const initialConfig: Record<string, string> = {};
// 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Configure {app.name}</DialogTitle>
<DialogDescription>
{app.description}
</DialogDescription>
</DialogHeader>
{hasConfig ? (
<div className="space-y-4 py-4">
{configKeys.map((key) => {
const isRequired = app.requiredSecrets?.some(secret =>
secret.toLowerCase().includes(key.toLowerCase())
);
return (
<div key={key} className="space-y-2">
<div className="flex items-center gap-2">
<Label htmlFor={key}>
{formatLabel(key)}
{isRequired && <span className="text-red-500">*</span>}
</Label>
{isRequired && (
<span title="Required for secrets generation">
<Info className="h-3 w-3 text-muted-foreground" />
</span>
)}
</div>
<Input
id={key}
value={config[key] || ''}
onChange={(e) => handleChange(key, e.target.value)}
placeholder={String(app.defaultConfig?.[key] || '')}
required={isRequired}
/>
{isRequired && (
<p className="text-xs text-muted-foreground">
This value is used to generate application secrets
</p>
)}
</div>
);
})}
{app.dependencies && app.dependencies.length > 0 && (
<div className="mt-6 p-4 bg-blue-50 dark:bg-blue-950/20 rounded-lg border border-blue-200 dark:border-blue-800">
<h4 className="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
Dependencies
</h4>
<p className="text-sm text-blue-800 dark:text-blue-200 mb-2">
This app requires the following apps to be deployed first:
</p>
<ul className="text-sm text-blue-700 dark:text-blue-300 list-disc list-inside">
{app.dependencies.map(dep => (
<li key={dep}>{dep}</li>
))}
</ul>
</div>
)}
</div>
) : (
<div className="py-8 text-center text-muted-foreground">
<p>This app doesn't require any configuration.</p>
<p className="text-sm mt-2">Click Add to proceed with default settings.</p>
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={isSaving}
>
{isSaving ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Saving...
</>
) : (
existingConfig ? 'Update' : 'Add App'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -16,4 +16,9 @@ export { DhcpComponent } from './DhcpComponent';
export { PxeComponent } from './PxeComponent';
export { ClusterNodesComponent } from './ClusterNodesComponent';
export { ClusterServicesComponent } from './ClusterServicesComponent';
export { AppsComponent } from './AppsComponent';
export { AppsComponent } from './AppsComponent';
export { SecretInput } from './SecretInput';
export { ConfigViewer } from './ConfigViewer';
export { DownloadButton } from './DownloadButton';
export { CopyButton } from './CopyButton';
export { ServiceCard } from './ServiceCard';

View File

@@ -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 (
<Badge variant={config.variant} className={config.className}>
{showIcon && <Icon className={iconSize} />}
{config.label}
</Badge>
);
}

View File

@@ -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 (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1 min-w-0">
<Server className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<CardTitle className="text-base truncate">
{node.hostname}
</CardTitle>
<p className="text-sm text-muted-foreground mt-1 font-mono">
{node.target_ip}
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant={getRoleBadgeVariant(node.role)}>
{node.role}
</Badge>
{(node.maintenance || node.configured || node.applied) && (
<HealthIndicator
status={node.applied ? 'healthy' : node.configured ? 'degraded' : 'unhealthy'}
size="sm"
/>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
{/* Version Information */}
<div className="grid grid-cols-2 gap-2 text-sm">
{node.talosVersion && (
<div>
<span className="text-muted-foreground">Talos:</span>{' '}
<span className="font-mono text-xs">{node.talosVersion}</span>
</div>
)}
{node.kubernetesVersion && (
<div>
<span className="text-muted-foreground">K8s:</span>{' '}
<span className="font-mono text-xs">{node.kubernetesVersion}</span>
</div>
)}
</div>
{/* Hardware Information */}
{showHardware && node.hardware && (
<div className="pt-3 border-t space-y-2">
{node.hardware.cpu && (
<div className="flex items-center gap-2 text-sm">
<Cpu className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">CPU:</span>
<span className="text-xs truncate">{node.hardware.cpu}</span>
</div>
)}
{node.hardware.memory && (
<div className="flex items-center gap-2 text-sm">
<MemoryStick className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Memory:</span>
<span className="text-xs">{node.hardware.memory}</span>
</div>
)}
{node.hardware.disk && (
<div className="flex items-center gap-2 text-sm">
<HardDrive className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Disk:</span>
<span className="text-xs">{node.hardware.disk}</span>
</div>
)}
{node.hardware.manufacturer && node.hardware.model && (
<div className="text-xs text-muted-foreground pt-1">
{node.hardware.manufacturer} {node.hardware.model}
</div>
)}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -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 <Clock className="h-4 w-4 text-gray-500" />;
case 'running':
return <Loader2 className="h-4 w-4 animate-spin text-blue-500" />;
case 'completed':
return <CheckCircle className="h-4 w-4 text-green-500" />;
case 'failed':
return <AlertCircle className="h-4 w-4 text-red-500" />;
case 'cancelled':
return <XCircle className="h-4 w-4 text-orange-500" />;
default:
return null;
}
};
const getStatusBadge = () => {
const variants: Record<string, 'secondary' | 'default' | 'destructive' | 'outline'> = {
pending: 'secondary',
running: 'default',
completed: 'outline',
failed: 'destructive',
cancelled: 'secondary',
};
return (
<Badge variant={variants[operation.status]}>
{operation.status.charAt(0).toUpperCase() + operation.status.slice(1)}
</Badge>
);
};
const canCancel = operation.status === 'pending' || operation.status === 'running';
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1 min-w-0">
{getStatusIcon()}
<div className="flex-1 min-w-0">
<CardTitle className="text-base">
{operation.type}
</CardTitle>
{operation.target && (
<p className="text-sm text-muted-foreground mt-1">
Target: {operation.target}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{getStatusBadge()}
{canCancel && (
<Button
size="sm"
variant="outline"
onClick={() => cancelOperation({ operationId: operation.id, instanceName: operation.instance_name })}
disabled={isCancelling}
>
{isCancelling ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Cancel'
)}
</Button>
)}
{expandable && (
<Button
size="sm"
variant="ghost"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
{operation.message && (
<p className="text-sm text-muted-foreground">
{operation.message}
</p>
)}
{(operation.status === 'running' || operation.status === 'pending') && (
<div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span>Progress</span>
<span>{operation.progress}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all duration-300"
style={{ width: `${operation.progress}%` }}
/>
</div>
</div>
)}
{operation.error && (
<div className="p-2 bg-red-50 dark:bg-red-950/20 rounded border border-red-200 dark:border-red-800">
<p className="text-xs text-red-700 dark:text-red-300">
{operation.error}
</p>
</div>
)}
{isExpanded && (
<div className="pt-3 border-t text-xs text-muted-foreground space-y-2">
<div className="flex justify-between">
<span>Operation ID:</span>
<span className="font-mono">{operation.id}</span>
</div>
<div className="flex justify-between">
<span>Started:</span>
<span>{new Date(operation.started).toLocaleString()}</span>
</div>
{operation.completed && (
<div className="flex justify-between">
<span>Completed:</span>
<span>{new Date(operation.completed).toLocaleString()}</span>
</div>
)}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -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 <Loader2 className="h-5 w-5 animate-spin text-blue-500" />;
}
switch (operation?.status) {
case 'pending':
return <Clock className="h-5 w-5 text-gray-500" />;
case 'running':
return <Loader2 className="h-5 w-5 animate-spin text-blue-500" />;
case 'completed':
return <CheckCircle className="h-5 w-5 text-green-500" />;
case 'failed':
return <AlertCircle className="h-5 w-5 text-red-500" />;
case 'cancelled':
return <XCircle className="h-5 w-5 text-orange-500" />;
default:
return null;
}
};
const getStatusBadge = () => {
if (isLoading) {
return <Badge variant="default">Loading...</Badge>;
}
const variants: Record<string, 'secondary' | 'default' | 'success' | 'destructive' | 'warning'> = {
pending: 'secondary',
running: 'default',
completed: 'success',
failed: 'destructive',
cancelled: 'warning',
};
const labels: Record<string, string> = {
pending: 'Pending',
running: 'Running',
completed: 'Completed',
failed: 'Failed',
cancelled: 'Cancelled',
};
const status = operation?.status || 'pending';
return (
<Badge variant={variants[status]}>
{labels[status] || status}
</Badge>
);
};
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 (
<Card className="p-4 border-red-200 bg-red-50 dark:bg-red-950/20">
<div className="flex items-center gap-3">
<AlertCircle className="h-5 w-5 text-red-500" />
<div className="flex-1">
<p className="text-sm font-medium text-red-900 dark:text-red-100">
Error loading operation
</p>
<p className="text-xs text-red-700 dark:text-red-300 mt-1">
{error.message}
</p>
</div>
</div>
</Card>
);
}
if (isLoading) {
return (
<Card className="p-4">
<div className="flex items-center gap-3">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
<span className="text-sm">Loading operation status...</span>
</div>
</Card>
);
}
const progressPercentage = getProgressPercentage();
const canCancel = operation?.status === 'pending' || operation?.status === 'running';
return (
<Card className="p-4">
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{getStatusIcon()}
<div>
<p className="text-sm font-medium">
{operation?.type || 'Operation'}
</p>
{operation?.message && (
<p className="text-xs text-muted-foreground mt-0.5">
{operation.message}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{getStatusBadge()}
{canCancel && (
<Button
size="sm"
variant="outline"
onClick={() => cancel()}
disabled={isCancelling}
>
{isCancelling ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Cancel'
)}
</Button>
)}
</div>
</div>
{(operation?.status === 'running' || operation?.status === 'pending') && (
<div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span>Progress</span>
<span>{progressPercentage}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all duration-300"
style={{ width: `${progressPercentage}%` }}
/>
</div>
</div>
)}
{operation?.error && (
<div className="p-2 bg-red-50 dark:bg-red-950/20 rounded border border-red-200 dark:border-red-800">
<p className="text-xs text-red-700 dark:text-red-300">
Error: {operation.error}
</p>
</div>
)}
{showDetails && operation && (
<div className="pt-2 border-t text-xs text-muted-foreground space-y-1">
<div className="flex justify-between">
<span>Operation ID:</span>
<span className="font-mono">{operation.id}</span>
</div>
{operation.started && (
<div className="flex justify-between">
<span>Started:</span>
<span>{new Date(operation.started).toLocaleString()}</span>
</div>
)}
{operation.completed && (
<div className="flex justify-between">
<span>Completed:</span>
<span>{new Date(operation.completed).toLocaleString()}</span>
</div>
)}
</div>
)}
</div>
</Card>
);
}

View File

@@ -0,0 +1,4 @@
export { OperationCard } from './OperationCard';
export { OperationProgress } from './OperationProgress';
export { HealthIndicator } from './HealthIndicator';
export { NodeStatusCard } from './NodeStatusCard';

View File

@@ -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: {

View File

@@ -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', () => ({

View File

@@ -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', () => ({

View File

@@ -4,4 +4,17 @@ export { useHealth } from './useHealth';
export { useConfig } from './useConfig';
export { useConfigYaml } from './useConfigYaml';
export { useDnsmasq } from './useDnsmasq';
export { useAssets } from './useAssets';
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';

110
src/hooks/useApps.ts Normal file
View File

@@ -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,
};
}

View File

@@ -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;

View File

@@ -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'],
});
},
});
}

View File

@@ -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<CentralStatus> => {
return apiClient.get('/api/v1/status');
},
// Poll every 5 seconds to keep uptime current
refetchInterval: 5000,
});
}

83
src/hooks/useCluster.ts Normal file
View File

@@ -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,
};
}

View File

@@ -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'],
});
},
});
}

View File

@@ -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 {

View File

@@ -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();

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,37 @@
import { useState, createContext, useContext, ReactNode } from 'react';
interface InstanceContextValue {
currentInstance: string | null;
setCurrentInstance: (name: string | null) => void;
}
const InstanceContext = createContext<InstanceContextValue | undefined>(undefined);
export function InstanceProvider({ children }: { children: ReactNode }) {
const [currentInstance, setCurrentInstanceState] = useState<string | null>(
() => localStorage.getItem('currentInstance')
);
const setCurrentInstance = (name: string | null) => {
setCurrentInstanceState(name);
if (name) {
localStorage.setItem('currentInstance', name);
} else {
localStorage.removeItem('currentInstance');
}
};
return (
<InstanceContext.Provider value={{ currentInstance, setCurrentInstance }}>
{children}
</InstanceContext.Provider>
);
}
export function useInstanceContext() {
const context = useContext(InstanceContext);
if (context === undefined) {
throw new Error('useInstanceContext must be used within an InstanceProvider');
}
return context;
}

82
src/hooks/useInstances.ts Normal file
View File

@@ -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<string, unknown>) => 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,
};
}

91
src/hooks/useNodes.ts Normal file
View File

@@ -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,
});
}

View File

@@ -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<Operation | null>(null);
const [error, setError] = useState<Error | null>(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,
};
}

25
src/hooks/useSecrets.ts Normal file
View File

@@ -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<string, unknown>) =>
instancesApi.updateSecrets(instanceName!, secrets),
onSuccess: () => {
// Invalidate both masked and raw secrets
queryClient.invalidateQueries({
queryKey: ['instances', instanceName, 'secrets'],
});
},
});
}

83
src/hooks/useServices.ts Normal file
View File

@@ -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,
});
}

View File

@@ -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 = () => {

View File

@@ -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(
<StrictMode>
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<ThemeProvider defaultTheme="light" storageKey="wild-central-theme">
<App />
</ThemeProvider>
<InstanceProvider>
<ThemeProvider defaultTheme="light" storageKey="wild-central-theme">
<App />
</ThemeProvider>
</InstanceProvider>
</QueryClientProvider>
</ErrorBoundary>
</StrictMode>

View File

@@ -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 <Navigate to="/" replace />;
}
return (
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold">Wild Cloud</h1>
</div>
</header>
<div className="flex flex-1 flex-col gap-4 p-4">
<Outlet />
</div>
</SidebarInset>
</SidebarProvider>
);
}

14
src/router/index.tsx Normal file
View File

@@ -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';

View File

@@ -0,0 +1,10 @@
import { ErrorBoundary } from '../../components';
import { Advanced } from '../../components';
export function AdvancedPage() {
return (
<ErrorBoundary>
<Advanced />
</ErrorBoundary>
);
}

View File

@@ -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 (
<ErrorBoundary>
<AppsComponent />
</ErrorBoundary>
);
}

View File

@@ -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 (
<div className="flex items-center justify-center h-96">
<Card className="p-6">
<div className="flex items-center gap-3 text-muted-foreground">
<AlertTriangle className="h-5 w-5" />
<p>No instance selected</p>
</div>
</Card>
</div>
);
}
const services = servicesData?.services || [];
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight">Base Services</h2>
<p className="text-muted-foreground">
Manage essential cluster infrastructure services
</p>
</div>
<Button onClick={() => refetch()} variant="outline" size="sm" disabled={isLoading}>
<RefreshCw className="h-4 w-4" />
Refresh
</Button>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
Available Services
</CardTitle>
<CardDescription>
Core infrastructure services for your Wild Cloud cluster
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Skeleton className="h-48 w-full" />
<Skeleton className="h-48 w-full" />
<Skeleton className="h-48 w-full" />
<Skeleton className="h-48 w-full" />
<Skeleton className="h-48 w-full" />
<Skeleton className="h-48 w-full" />
</div>
) : services.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Package className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">No services available</p>
<p className="text-xs mt-1">Base services will appear here once configured</p>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{services.map((service) => (
<ServiceCard
key={service.name}
service={service}
onInstall={() => handleInstall(service.name)}
isInstalling={installMutation.isPending}
/>
))}
</div>
)}
</CardContent>
</Card>
<Card className="border-blue-200 bg-blue-50 dark:bg-blue-950/20 dark:border-blue-900">
<CardContent className="pt-6">
<div className="flex gap-3">
<Package className="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-blue-900 dark:text-blue-200">
About Base Services
</p>
<p className="text-sm text-blue-800 dark:text-blue-300 mt-1">
Base services provide essential infrastructure components for your cluster:
</p>
<ul className="text-sm text-blue-800 dark:text-blue-300 mt-2 space-y-1 list-disc list-inside">
<li><strong>Cilium</strong> - Network connectivity and security</li>
<li><strong>MetalLB</strong> - Load balancer for bare metal clusters</li>
<li><strong>Traefik</strong> - Ingress controller and reverse proxy</li>
<li><strong>Cert-Manager</strong> - Automatic TLS certificate management</li>
<li><strong>External-DNS</strong> - Automatic DNS record management</li>
</ul>
<p className="text-sm text-blue-800 dark:text-blue-300 mt-2">
Install these services to enable full cluster functionality.
</p>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { ErrorBoundary } from '../../components';
import { CentralComponent } from '../../components/CentralComponent';
export function CentralPage() {
return (
<ErrorBoundary>
<CentralComponent />
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,10 @@
import { ErrorBoundary } from '../../components';
import { CloudComponent } from '../../components/CloudComponent';
export function CloudPage() {
return (
<ErrorBoundary>
<CloudComponent />
</ErrorBoundary>
);
}

View File

@@ -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 (
<div className="flex items-center justify-center h-96">
<Card className="p-6">
<div className="flex items-center gap-3 text-muted-foreground">
<AlertTriangle className="h-5 w-5" />
<p>No instance selected</p>
</div>
</Card>
</div>
);
}
return (
<div className="space-y-4">
<div>
<h2 className="text-3xl font-bold tracking-tight">Cluster Access</h2>
<p className="text-muted-foreground">
Download kubeconfig and talosconfig files
</p>
</div>
{/* Kubeconfig Card */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Kubeconfig
</CardTitle>
<CardDescription>
Configuration file for accessing the Kubernetes cluster with kubectl
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{kubeconfigLoading ? (
<Skeleton className="h-20 w-full" />
) : kubeconfig?.kubeconfig ? (
<>
<div className="flex flex-wrap gap-2">
<DownloadButton
content={kubeconfig.kubeconfig}
filename={`${instanceId}-kubeconfig.yaml`}
label="Download"
/>
<CopyButton content={kubeconfig.kubeconfig} label="Copy" />
<Button
variant="outline"
onClick={() => setShowRegenerateDialog(true)}
>
<RefreshCw className="h-4 w-4" />
Regenerate
</Button>
</div>
<Collapsible open={showKubeconfigPreview} onOpenChange={setShowKubeconfigPreview}>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="w-full">
{showKubeconfigPreview ? 'Hide' : 'Show'} Preview
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<ConfigViewer content={kubeconfig.kubeconfig} className="mt-2" />
</CollapsibleContent>
</Collapsible>
<div className="text-xs text-muted-foreground space-y-1 pt-2 border-t">
<p className="font-medium">Usage:</p>
<code className="block bg-muted p-2 rounded">
kubectl --kubeconfig={instanceId}-kubeconfig.yaml get nodes
</code>
<p className="pt-2">Or set as default:</p>
<code className="block bg-muted p-2 rounded">
export KUBECONFIG=~/.kube/{instanceId}-kubeconfig.yaml
</code>
</div>
</>
) : (
<div className="text-center py-8 text-muted-foreground">
<FileText className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">Kubeconfig not available</p>
<p className="text-xs mt-1">Generate cluster configuration first</p>
</div>
)}
</CardContent>
</Card>
{/* Talosconfig Card */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Talosconfig
</CardTitle>
<CardDescription>
Configuration file for accessing Talos nodes with talosctl
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{talosconfigLoading ? (
<Skeleton className="h-20 w-full" />
) : talosconfig?.talosconfig ? (
<>
<div className="flex flex-wrap gap-2">
<DownloadButton
content={talosconfig.talosconfig}
filename={`${instanceId}-talosconfig.yaml`}
label="Download"
/>
<CopyButton content={talosconfig.talosconfig} label="Copy" />
</div>
<Collapsible open={showTalosconfigPreview} onOpenChange={setShowTalosconfigPreview}>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="w-full">
{showTalosconfigPreview ? 'Hide' : 'Show'} Preview
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<ConfigViewer content={talosconfig.talosconfig} className="mt-2" />
</CollapsibleContent>
</Collapsible>
<div className="text-xs text-muted-foreground space-y-1 pt-2 border-t">
<p className="font-medium">Usage:</p>
<code className="block bg-muted p-2 rounded">
talosctl --talosconfig={instanceId}-talosconfig.yaml get members
</code>
<p className="pt-2">Or set as default:</p>
<code className="block bg-muted p-2 rounded">
export TALOSCONFIG=~/.talos/{instanceId}-talosconfig.yaml
</code>
</div>
</>
) : (
<div className="text-center py-8 text-muted-foreground">
<FileText className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">Talosconfig not available</p>
<p className="text-xs mt-1">Generate cluster configuration first</p>
</div>
)}
</CardContent>
</Card>
{/* Regenerate Confirmation Dialog */}
<Dialog open={showRegenerateDialog} onOpenChange={setShowRegenerateDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Regenerate Kubeconfig</DialogTitle>
<DialogDescription>
This will regenerate the kubeconfig file. Any existing kubeconfig files will be invalidated.
Are you sure you want to continue?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowRegenerateDialog(false)}>
Cancel
</Button>
<Button onClick={handleRegenerate} disabled={regenerateMutation.isPending}>
{regenerateMutation.isPending ? 'Regenerating...' : 'Regenerate'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -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 (
<div className="flex items-center justify-center h-96">
<Card className="p-6">
<div className="flex items-center gap-3 text-muted-foreground">
<AlertCircle className="h-5 w-5" />
<p>No instance selected</p>
</div>
</Card>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h2 className="text-3xl font-bold tracking-tight">Cluster Health</h2>
<p className="text-muted-foreground">
Monitor health metrics and node status for {instanceId}
</p>
</div>
{/* Overall Health Status */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<HeartPulse className="h-5 w-5" />
Overall Health
</CardTitle>
<CardDescription>
Cluster health aggregated from all checks
</CardDescription>
</div>
{health && (
<HealthIndicator status={health.status} size="lg" />
)}
</div>
</CardHeader>
<CardContent>
{healthError ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<AlertCircle className="h-12 w-12 text-red-500 mb-3" />
<p className="text-sm font-medium text-red-900 dark:text-red-100">
Error loading health data
</p>
<p className="text-xs text-red-700 dark:text-red-300 mt-1">
{healthError.message}
</p>
</div>
) : healthLoading ? (
<div className="space-y-2">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
) : health && health.checks.length > 0 ? (
<div className="space-y-2">
{health.checks.map((check, index) => (
<div
key={index}
className="flex items-center justify-between p-4 rounded-lg border hover:bg-accent/50 transition-colors"
>
<div className="flex items-center gap-3 flex-1">
<HealthIndicator status={check.status} size="sm" />
<div className="flex-1">
<p className="font-medium text-sm">{check.name}</p>
{check.message && (
<p className="text-xs text-muted-foreground mt-0.5">
{check.message}
</p>
)}
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<AlertCircle className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p className="text-sm font-medium">No health data available</p>
<p className="text-xs mt-1">
Health checks will appear here once the cluster is running
</p>
</div>
)}
</CardContent>
</Card>
{/* Cluster Information */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Cluster Status</CardTitle>
</CardHeader>
<CardContent>
{statusLoading ? (
<Skeleton className="h-8 w-24" />
) : status ? (
<div>
<Badge variant={status.ready ? 'outline' : 'secondary'} className={status.ready ? 'border-green-500' : ''}>
{status.ready ? 'Ready' : 'Not Ready'}
</Badge>
<p className="text-xs text-muted-foreground mt-2">
{status.nodes} nodes total
</p>
</div>
) : (
<div className="text-sm text-muted-foreground">Unknown</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Kubernetes Version</CardTitle>
</CardHeader>
<CardContent>
{statusLoading ? (
<Skeleton className="h-8 w-32" />
) : status?.kubernetesVersion ? (
<div>
<div className="text-lg font-bold font-mono">
{status.kubernetesVersion}
</div>
</div>
) : (
<div className="text-sm text-muted-foreground">Not available</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Talos Version</CardTitle>
</CardHeader>
<CardContent>
{statusLoading ? (
<Skeleton className="h-8 w-32" />
) : status?.talosVersion ? (
<div>
<div className="text-lg font-bold font-mono">
{status.talosVersion}
</div>
</div>
) : (
<div className="text-sm text-muted-foreground">Not available</div>
)}
</CardContent>
</Card>
</div>
{/* Node Status */}
<Card>
<CardHeader>
<CardTitle>Node Status</CardTitle>
<CardDescription>
Detailed status and information for each node
</CardDescription>
</CardHeader>
<CardContent>
{nodesLoading ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Skeleton className="h-48 w-full" />
<Skeleton className="h-48 w-full" />
<Skeleton className="h-48 w-full" />
</div>
) : nodes && nodes.nodes.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{nodes.nodes.map((node) => (
<NodeStatusCard key={node.hostname} node={node} showHardware={true} />
))}
</div>
) : (
<div className="text-center py-12 text-muted-foreground">
<AlertCircle className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p className="text-sm font-medium">No nodes found</p>
<p className="text-xs mt-1">
Add nodes to your cluster to see them here
</p>
</div>
)}
</CardContent>
</Card>
{/* Auto-refresh indicator */}
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
<p>Auto-refreshing every 10 seconds</p>
</div>
</div>
);
}

View File

@@ -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 (
<ErrorBoundary>
<ClusterServicesComponent />
</ErrorBoundary>
);
}

View File

@@ -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 (
<div className="flex items-center justify-center h-96">
<Card className="p-6">
<div className="flex items-center gap-3 text-muted-foreground">
<AlertCircle className="h-5 w-5" />
<p>No instance selected</p>
</div>
</Card>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight">Dashboard</h2>
<p className="text-muted-foreground">
Overview and quick status for {instanceId}
</p>
</div>
<Button onClick={handleRefresh} variant="outline" size="sm">
<RefreshCw className="h-4 w-4" />
Refresh
</Button>
</div>
{/* Status Cards Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{/* Instance Status */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium">Instance Status</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
{instanceLoading ? (
<Skeleton className="h-8 w-24" />
) : instance ? (
<div>
<div className="text-2xl font-bold">Active</div>
<p className="text-xs text-muted-foreground mt-1">
Instance configured
</p>
</div>
) : (
<div>
<div className="text-2xl font-bold text-muted-foreground">Unknown</div>
<p className="text-xs text-muted-foreground mt-1">
Unable to load status
</p>
</div>
)}
</CardContent>
</Card>
{/* Cluster Health */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium">Cluster Health</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
{healthLoading ? (
<Skeleton className="h-8 w-24" />
) : health ? (
<div>
<div className="mb-2">
<HealthIndicator status={health.status} size="md" />
</div>
<p className="text-xs text-muted-foreground">
{health.checks.length} health checks
</p>
</div>
) : (
<div>
<div className="text-2xl font-bold text-muted-foreground">Unknown</div>
<p className="text-xs text-muted-foreground mt-1">
Health data unavailable
</p>
</div>
)}
</CardContent>
</Card>
{/* Node Count */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium">Nodes</CardTitle>
<Server className="h-4 w-4 text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
{statusLoading ? (
<Skeleton className="h-8 w-24" />
) : status ? (
<div>
<div className="text-2xl font-bold">{status.nodes}</div>
<p className="text-xs text-muted-foreground mt-1">
{status.controlPlaneNodes} control plane, {status.workerNodes} workers
</p>
</div>
) : (
<div>
<div className="text-2xl font-bold text-muted-foreground">-</div>
<p className="text-xs text-muted-foreground mt-1">
No nodes detected
</p>
</div>
)}
</CardContent>
</Card>
{/* K8s Version */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium">Kubernetes</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
{statusLoading ? (
<Skeleton className="h-8 w-24" />
) : status?.kubernetesVersion ? (
<div>
<div className="text-xl font-bold font-mono">{status.kubernetesVersion}</div>
<p className="text-xs text-muted-foreground mt-1">
{status.ready ? 'Ready' : 'Not ready'}
</p>
</div>
) : (
<div>
<div className="text-2xl font-bold text-muted-foreground">-</div>
<p className="text-xs text-muted-foreground mt-1">
Version unknown
</p>
</div>
)}
</CardContent>
</Card>
</div>
{/* Cluster Health Details */}
{health && health.checks.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Health Checks</CardTitle>
<CardDescription>
Detailed health status of cluster components
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{health.checks.map((check, index) => (
<div key={index} className="flex items-center justify-between p-3 rounded-lg border">
<div className="flex items-center gap-3">
<HealthIndicator status={check.status} size="sm" />
<span className="font-medium text-sm">{check.name}</span>
</div>
{check.message && (
<span className="text-xs text-muted-foreground">{check.message}</span>
)}
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Recent Operations */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Recent Operations</CardTitle>
<CardDescription>
Last 5 operations for this instance
</CardDescription>
</div>
<CardAction>
<Button asChild variant="outline" size="sm">
<Link to={`/instances/${instanceId}/operations`}>
View All
</Link>
</Button>
</CardAction>
</div>
</CardHeader>
<CardContent>
{operationsLoading ? (
<div className="space-y-3">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
) : operations && operations.operations.length > 0 ? (
<div className="space-y-3">
{operations.operations.map((operation) => (
<OperationCard key={operation.id} operation={operation} />
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<Activity className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">No operations found</p>
<p className="text-xs mt-1">Operations will appear here as they are created</p>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { ErrorBoundary } from '../../components';
import { DhcpComponent } from '../../components/DhcpComponent';
export function DhcpPage() {
return (
<ErrorBoundary>
<DhcpComponent />
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,10 @@
import { ErrorBoundary } from '../../components';
import { DnsComponent } from '../../components/DnsComponent';
export function DnsPage() {
return (
<ErrorBoundary>
<DnsComponent />
</ErrorBoundary>
);
}

View File

@@ -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 (
<ErrorBoundary>
<ClusterNodesComponent />
</ErrorBoundary>
);
}

View File

@@ -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<string | null>(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<string, 'secondary' | 'success' | 'destructive' | 'warning'> = {
available: 'success',
missing: 'secondary',
downloading: 'warning',
error: 'destructive',
};
const icons: Record<string, React.ReactNode> = {
available: <CheckCircle className="h-3 w-3" />,
missing: <AlertCircle className="h-3 w-3" />,
downloading: <Loader2 className="h-3 w-3 animate-spin" />,
error: <XCircle className="h-3 w-3" />,
};
return (
<Badge variant={variants[statusValue] || 'secondary'} className="flex items-center gap-1">
{icons[statusValue]}
{statusValue.charAt(0).toUpperCase() + statusValue.slice(1)}
</Badge>
);
};
const getAssetIcon = (type: string) => {
switch (type) {
case 'iso':
return <Disc className="h-5 w-5 text-primary" />;
default:
return <Disc className="h-5 w-5" />;
}
};
return (
<div className="space-y-6">
{/* Educational Intro Section */}
<Card className="p-6 bg-gradient-to-r from-purple-50 to-pink-50 dark:from-purple-950/20 dark:to-pink-950/20 border-purple-200 dark:border-purple-800">
<div className="flex items-start gap-4">
<div className="p-3 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<BookOpen className="h-6 w-6 text-purple-600 dark:text-purple-400" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-purple-900 dark:text-purple-100 mb-2">
What is a Bootable ISO?
</h3>
<p className="text-purple-800 dark:text-purple-200 mb-3 leading-relaxed">
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.
</p>
<p className="text-purple-700 dark:text-purple-300 mb-4 text-sm">
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.
</p>
<Button
variant="outline"
size="sm"
className="text-purple-700 border-purple-300 hover:bg-purple-100 dark:text-purple-300 dark:border-purple-700 dark:hover:bg-purple-900/20"
>
<ExternalLink className="h-4 w-4 mr-2" />
Learn about creating bootable USB drives
</Button>
</div>
</div>
</Card>
<Card>
<CardHeader>
<div className="flex items-center gap-4">
<div className="p-2 bg-primary/10 rounded-lg">
<Usb className="h-6 w-6 text-primary" />
</div>
<div className="flex-1">
<CardTitle>ISO Management</CardTitle>
<CardDescription>
Download Talos ISO images for creating bootable USB drives
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{!currentInstance ? (
<div className="text-center py-8">
<Usb className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">No Instance Selected</h3>
<p className="text-muted-foreground">
Please select or create an instance to manage ISO images.
</p>
</div>
) : error ? (
<div className="text-center py-8">
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">Error Loading ISO</h3>
<p className="text-muted-foreground mb-4">{(error as Error).message}</p>
<Button onClick={() => window.location.reload()}>Reload Page</Button>
</div>
) : (
<div className="space-y-6">
{/* Version Selection */}
<div>
<label className="text-sm font-medium mb-2 block">Talos Version</label>
<select
value={selectedVersion}
onChange={(e) => setSelectedVersion(e.target.value)}
className="w-full md:w-64 px-3 py-2 border rounded-lg bg-background"
>
<option value="v1.8.0">v1.8.0 (Latest)</option>
<option value="v1.7.6">v1.7.6</option>
<option value="v1.7.5">v1.7.5</option>
<option value="v1.6.7">v1.6.7</option>
</select>
</div>
{/* ISO Asset */}
<div>
<h4 className="font-medium mb-4">ISO Image</h4>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : isoAssets.length === 0 ? (
<Card className="p-8 text-center">
<Disc className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">No ISO Available</h3>
<p className="text-muted-foreground mb-4">
Download a Talos ISO to get started with USB boot.
</p>
<Button onClick={() => handleDownload('iso')} disabled={downloadAsset.isPending}>
{downloadAsset.isPending ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Download ISO
</Button>
</Card>
) : (
<div className="space-y-3">
{isoAssets.map((asset) => (
<Card key={asset.type} className="p-4">
<div className="flex items-center gap-4">
<div className="p-2 bg-muted rounded-lg">{getAssetIcon(asset.type)}</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h5 className="font-medium capitalize">Talos ISO</h5>
{getStatusBadge(asset.status)}
</div>
<div className="text-sm text-muted-foreground space-y-1">
{asset.version && <div>Version: {asset.version}</div>}
{asset.size && <div>Size: {asset.size}</div>}
{asset.path && (
<div className="font-mono text-xs truncate">{asset.path}</div>
)}
{asset.error && (
<div className="text-red-500">{asset.error}</div>
)}
</div>
</div>
<div className="flex gap-2">
{asset.status !== 'available' && asset.status !== 'downloading' && (
<Button
size="sm"
onClick={() => handleDownload(asset.type as PxeAssetType)}
disabled={
downloadAsset.isPending || downloadingType === asset.type
}
>
{downloadingType === asset.type ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Download className="h-4 w-4 mr-1" />
Download
</>
)}
</Button>
)}
{asset.status === 'available' && (
<>
<Button
size="sm"
variant="outline"
onClick={() => {
// Download the ISO file from Wild Central to user's computer
if (asset.path && currentInstance) {
window.location.href = `/api/v1/instances/${currentInstance}/pxe/assets/iso`;
}
}}
>
<Download className="h-4 w-4 mr-1" />
Download to Computer
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleDelete(asset.type as PxeAssetType)}
disabled={deleteAsset.isPending}
>
{deleteAsset.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</>
)}
</div>
</div>
</Card>
))}
</div>
)}
</div>
{/* Instructions Card */}
<Card className="p-6 bg-muted/50">
<h4 className="font-medium mb-3 flex items-center gap-2">
<Usb className="h-5 w-5" />
Next Steps
</h4>
<ol className="space-y-2 text-sm text-muted-foreground list-decimal list-inside">
<li>Download the ISO image above</li>
<li>Download a USB writing tool (e.g., Balena Etcher, Rufus, or dd)</li>
<li>Write the ISO to a USB drive (minimum 2GB)</li>
<li>Boot your target computer from the USB drive</li>
<li>Follow the Talos installation process</li>
</ol>
</Card>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -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 (
<div className="flex items-center justify-center min-h-screen">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Wild Cloud</CardTitle>
<CardDescription>
Select an instance to manage your cloud infrastructure
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Button
onClick={handleSelectInstance}
className="w-full"
size="lg"
>
<Server className="mr-2 h-5 w-5" />
{currentInstance ? `Continue to ${currentInstance}` : 'Go to Default Instance'}
</Button>
</CardContent>
</Card>
</div>
);
}

View File

@@ -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 (
<div className="flex items-center justify-center min-h-[600px]">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<AlertCircle className="h-16 w-16 text-destructive" />
</div>
<CardTitle className="text-2xl">Page Not Found</CardTitle>
<CardDescription>
The page you're looking for doesn't exist or has been moved.
</CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
<Link to="/">
<Button>
<Home className="mr-2 h-4 w-4" />
Go to Home
</Button>
</Link>
</CardContent>
</Card>
</div>
);
}

View File

@@ -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<FilterType>('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 (
<div className="flex items-center justify-center h-96">
<Card className="p-6">
<div className="flex items-center gap-3 text-muted-foreground">
<AlertCircle className="h-5 w-5" />
<p>No instance selected</p>
</div>
</Card>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h2 className="text-3xl font-bold tracking-tight">Operations</h2>
<p className="text-muted-foreground">
Monitor and manage operations for {instanceId}
</p>
</div>
{/* Summary Cards */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Running</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{runningCount}</div>
<p className="text-xs text-muted-foreground mt-1">
Active operations
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Completed</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">{completedCount}</div>
<p className="text-xs text-muted-foreground mt-1">
Successfully finished
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Failed</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">{failedCount}</div>
<p className="text-xs text-muted-foreground mt-1">
Encountered errors
</p>
</CardContent>
</Card>
</div>
{/* Filters */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5" />
Operations
</CardTitle>
<CardDescription>
Real-time operation monitoring with auto-refresh
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<div className="flex gap-1">
<Button
size="sm"
variant={filter === 'all' ? 'default' : 'outline'}
onClick={() => setFilter('all')}
>
All
<Badge variant="secondary" className="ml-2">
{data?.operations.length || 0}
</Badge>
</Button>
<Button
size="sm"
variant={filter === 'running' ? 'default' : 'outline'}
onClick={() => setFilter('running')}
>
Running
<Badge variant="secondary" className="ml-2">
{runningCount}
</Badge>
</Button>
<Button
size="sm"
variant={filter === 'completed' ? 'default' : 'outline'}
onClick={() => setFilter('completed')}
>
Completed
<Badge variant="secondary" className="ml-2">
{completedCount}
</Badge>
</Button>
<Button
size="sm"
variant={filter === 'failed' ? 'default' : 'outline'}
onClick={() => setFilter('failed')}
>
Failed
<Badge variant="secondary" className="ml-2">
{failedCount}
</Badge>
</Button>
</div>
</div>
</div>
</CardHeader>
<CardContent>
{error ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-red-500 mb-3" />
<p className="text-sm font-medium text-red-900 dark:text-red-100">
Error loading operations
</p>
<p className="text-xs text-red-700 dark:text-red-300 mt-1">
{error.message}
</p>
</div>
) : isLoading ? (
<div className="space-y-3">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-32 w-full" />
</div>
) : data && data.operations.length > 0 ? (
<div className="space-y-3">
{data.operations.map((operation) => (
<OperationCard
key={operation.id}
operation={operation}
expandable={true}
/>
))}
</div>
) : (
<div className="text-center py-12 text-muted-foreground">
<Activity className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p className="text-sm font-medium">No operations found</p>
<p className="text-xs mt-1">
{filter === 'all'
? 'Operations will appear here as they are created'
: `No ${filter} operations at this time`}
</p>
</div>
)}
</CardContent>
</Card>
{/* Auto-refresh indicator */}
<div className="text-center">
<p className="text-xs text-muted-foreground">
Auto-refreshing every 3 seconds
</p>
</div>
</div>
);
}

View File

@@ -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<PxeAssetType | null>(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<string, 'secondary' | 'default' | 'success' | 'destructive' | 'warning'> = {
available: 'success',
missing: 'secondary',
downloading: 'default',
error: 'destructive',
};
const icons: Record<string, React.ReactNode> = {
available: <CheckCircle className="h-3 w-3" />,
missing: <AlertCircle className="h-3 w-3" />,
downloading: <Loader2 className="h-3 w-3 animate-spin" />,
error: <AlertCircle className="h-3 w-3" />,
};
return (
<Badge variant={variants[statusValue] || 'secondary'} className="flex items-center gap-1">
{icons[statusValue]}
{statusValue.charAt(0).toUpperCase() + statusValue.slice(1)}
</Badge>
);
};
const getAssetIcon = (type: string) => {
switch (type) {
case 'kernel':
return <FileArchive className="h-5 w-5 text-blue-500" />;
case 'initramfs':
return <FileArchive className="h-5 w-5 text-green-500" />;
case 'iso':
return <FileArchive className="h-5 w-5 text-purple-500" />;
default:
return <FileArchive className="h-5 w-5 text-muted-foreground" />;
}
};
return (
<ErrorBoundary>
<div className="space-y-6">
{/* Educational Intro Section */}
<Card className="p-6 bg-gradient-to-r from-orange-50 to-amber-50 dark:from-orange-950/20 dark:to-amber-950/20 border-orange-200 dark:border-orange-800">
<div className="flex items-start gap-4">
<div className="p-3 bg-orange-100 dark:bg-orange-900/30 rounded-lg">
<BookOpen className="h-6 w-6 text-orange-600 dark:text-orange-400" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-orange-900 dark:text-orange-100 mb-2">
What is PXE Boot?
</h3>
<p className="text-orange-800 dark:text-orange-200 mb-3 leading-relaxed">
PXE (Preboot Execution Environment) is like having a "network installer" that can set
up computers without needing USB drives or DVDs. When you turn on a computer, instead
of booting from its hard drive, it can boot from the network and automatically install
an operating system or run diagnostics.
</p>
<p className="text-orange-700 dark:text-orange-300 mb-4 text-sm">
This is especially useful for setting up multiple computers in your cloud
infrastructure. PXE can automatically install and configure the same operating system
on many machines, making it easy to expand your personal cloud.
</p>
<Button
variant="outline"
size="sm"
className="text-orange-700 border-orange-300 hover:bg-orange-100 dark:text-orange-300 dark:border-orange-700 dark:hover:bg-orange-900/20"
>
<ExternalLink className="h-4 w-4 mr-2" />
Learn more about network booting
</Button>
</div>
</div>
</Card>
<Card>
<CardHeader>
<div className="flex items-center gap-4">
<div className="p-2 bg-primary/10 rounded-lg">
<HardDrive className="h-6 w-6 text-primary" />
</div>
<div className="flex-1">
<CardTitle>PXE Configuration</CardTitle>
<CardDescription>
Manage PXE boot assets and network boot configuration
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{!currentInstance ? (
<div className="text-center py-8">
<HardDrive className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">No Instance Selected</h3>
<p className="text-muted-foreground">
Please select or create an instance to manage PXE assets.
</p>
</div>
) : error ? (
<div className="text-center py-8">
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">Error Loading Assets</h3>
<p className="text-muted-foreground mb-4">{(error as Error).message}</p>
<Button onClick={() => window.location.reload()}>Reload Page</Button>
</div>
) : (
<div className="space-y-6">
{/* Version Selection */}
<div>
<label className="text-sm font-medium mb-2 block">Talos Version</label>
<select
value={selectedVersion}
onChange={(e) => setSelectedVersion(e.target.value)}
className="w-full md:w-64 px-3 py-2 border rounded-lg bg-background"
>
<option value="v1.8.0">v1.8.0 (Latest)</option>
<option value="v1.7.6">v1.7.6</option>
<option value="v1.7.5">v1.7.5</option>
<option value="v1.6.7">v1.6.7</option>
</select>
</div>
{/* Assets List */}
<div>
<h4 className="font-medium mb-4">Boot Assets</h4>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-3">
{data?.assets.filter((asset) => asset.type !== 'iso').map((asset) => (
<Card key={asset.type} className="p-4">
<div className="flex items-center gap-4">
<div className="p-2 bg-muted rounded-lg">{getAssetIcon(asset.type)}</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h5 className="font-medium capitalize">{asset.type}</h5>
{getStatusBadge(asset.status)}
</div>
<div className="text-sm text-muted-foreground space-y-1">
{asset.version && <div>Version: {asset.version}</div>}
{asset.size && <div>Size: {asset.size}</div>}
{asset.path && (
<div className="font-mono text-xs truncate">{asset.path}</div>
)}
{asset.error && (
<div className="text-red-500">{asset.error}</div>
)}
</div>
</div>
<div className="flex gap-2">
{asset.status !== 'available' && asset.status !== 'downloading' && (
<Button
size="sm"
onClick={() => handleDownload(asset.type as PxeAssetType)}
disabled={
downloadAsset.isPending || downloadingType === asset.type
}
>
{downloadingType === asset.type ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Download className="h-4 w-4 mr-1" />
Download
</>
)}
</Button>
)}
{asset.status === 'available' && (
<Button
size="sm"
variant="destructive"
onClick={() => handleDelete(asset.type as PxeAssetType)}
disabled={deleteAsset.isPending}
>
{deleteAsset.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
)}
</div>
</div>
</Card>
))}
</div>
)}
</div>
{/* Download All Button */}
{data?.assets && data.assets.some((a) => a.status !== 'available') && (
<div className="flex justify-end">
<Button
onClick={() => {
data.assets
.filter((a) => a.status !== 'available')
.forEach((a) => handleDownload(a.type as PxeAssetType));
}}
disabled={downloadAsset.isPending}
>
<Download className="h-4 w-4 mr-2" />
Download All Missing Assets
</Button>
</div>
)}
</div>
)}
</CardContent>
</Card>
</div>
</ErrorBoundary>
);
}

View File

@@ -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<Record<string, unknown>>({});
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<string, unknown>, 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<string, unknown>, path));
} else {
result.push({ path, value: String(value || '') });
}
}
return result;
};
const getValue = (obj: Record<string, unknown>, 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 (
<div className="flex items-center justify-center h-96">
<Card className="p-6">
<div className="flex items-center gap-3 text-muted-foreground">
<AlertTriangle className="h-5 w-5" />
<p>No instance selected</p>
</div>
</Card>
</div>
);
}
const secretsList = secrets ? flattenSecrets(secrets) : [];
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold tracking-tight">Secrets Management</h2>
<p className="text-muted-foreground">
Manage instance secrets securely
</p>
</div>
{!isEditing ? (
<Button onClick={handleEdit} disabled={isLoading}>
Edit Secrets
</Button>
) : (
<div className="flex gap-2">
<Button onClick={handleCancel} variant="outline">
<X className="h-4 w-4" />
Cancel
</Button>
<Button onClick={handleSave} disabled={updateMutation.isPending}>
<Save className="h-4 w-4" />
Save Changes
</Button>
</div>
)}
</div>
{isEditing && (
<Card className="border-yellow-500 bg-yellow-50 dark:bg-yellow-950/20">
<CardContent className="pt-6">
<div className="flex gap-3">
<AlertTriangle className="h-5 w-5 text-yellow-600 dark:text-yellow-500 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-yellow-900 dark:text-yellow-200">
Security Warning
</p>
<p className="text-sm text-yellow-800 dark:text-yellow-300 mt-1">
You are editing sensitive secrets. Make sure you are in a secure environment.
Changes will be saved immediately and cannot be undone.
</p>
</div>
</div>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Key className="h-5 w-5" />
Instance Secrets
</CardTitle>
<CardDescription>
{isEditing ? 'Edit secret values below' : 'View encrypted secrets for this instance'}
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : secretsList.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Key className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">No secrets found</p>
<p className="text-xs mt-1">Secrets will appear here once configured</p>
</div>
) : (
<div className="space-y-4">
{secretsList.map(({ path, value }) => (
<div key={path} className="space-y-2">
<Label htmlFor={path}>{path}</Label>
<SecretInput
value={isEditing ? getValue(editedSecrets, path) : value}
onChange={isEditing ? (newValue) => handleSecretChange(path, newValue) : undefined}
readOnly={!isEditing}
/>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm Save</DialogTitle>
<DialogDescription>
Are you sure you want to save these secret changes? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowConfirmDialog(false)}>
Cancel
</Button>
<Button onClick={confirmSave} disabled={updateMutation.isPending}>
{updateMutation.isPending ? 'Saving...' : 'Save Changes'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -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 (
<div className="space-y-6">
<div>
<h2 className="text-3xl font-bold tracking-tight">Utilities</h2>
<p className="text-muted-foreground">
Additional tools and utilities for cluster management
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Dashboard Token */}
<UtilityCard
title="Dashboard Access Token"
description="Retrieve your Kubernetes dashboard authentication token"
icon={<Key className="h-5 w-5 text-primary" />}
isLoading={dashboardToken.isLoading}
error={dashboardToken.error}
>
{dashboardToken.data && (
<CopyableValue
label="Token"
value={dashboardToken.data.token}
multiline
/>
)}
</UtilityCard>
{/* Cluster Versions */}
<UtilityCard
title="Cluster Version Information"
description="View Kubernetes and Talos versions running on your cluster"
icon={<Info className="h-5 w-5 text-primary" />}
isLoading={versions.isLoading}
error={versions.error}
>
{versions.data && (
<div className="space-y-3">
<div className="flex justify-between items-center p-3 bg-muted rounded-lg">
<span className="text-sm font-medium">Kubernetes</span>
<span className="text-sm font-mono">{versions.data.version}</span>
</div>
{Object.entries(versions.data)
.filter(([key]) => key !== 'version')
.map(([key, value]) => (
<div
key={key}
className="flex justify-between items-center p-3 bg-muted rounded-lg"
>
<span className="text-sm font-medium capitalize">
{key.replace(/([A-Z])/g, ' $1').trim()}
</span>
<span className="text-sm font-mono">{String(value)}</span>
</div>
))}
</div>
)}
</UtilityCard>
{/* Node IPs */}
<UtilityCard
title="Node IP Addresses"
description="List all node IP addresses in your cluster"
icon={<Network className="h-5 w-5 text-primary" />}
isLoading={nodeIPs.isLoading}
error={nodeIPs.error}
>
{nodeIPs.data && (
<div className="space-y-2">
{nodeIPs.data.ips.map((ip, index) => (
<CopyableValue key={index} value={ip} label={`Node ${index + 1}`} />
))}
{nodeIPs.data.ips.length === 0 && (
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<AlertCircle className="h-4 w-4" />
<span>No nodes found</span>
</div>
)}
</div>
)}
</UtilityCard>
{/* Control Plane IP */}
<UtilityCard
title="Control Plane IP"
description="Display the control plane endpoint IP address"
icon={<Server className="h-5 w-5 text-primary" />}
isLoading={controlPlaneIP.isLoading}
error={controlPlaneIP.error}
>
{controlPlaneIP.data && (
<CopyableValue label="Control Plane IP" value={controlPlaneIP.data.ip} />
)}
</UtilityCard>
{/* Secret Copy Utility */}
<UtilityCard
title="Copy Secret"
description="Copy a secret between namespaces or instances"
icon={<Copy className="h-5 w-5 text-primary" />}
>
<div className="space-y-4">
<div>
<label className="text-sm font-medium mb-2 block">Secret Name</label>
<input
type="text"
placeholder="e.g., my-secret"
value={secretToCopy}
onChange={(e) => setSecretToCopy(e.target.value)}
className="w-full px-3 py-2 border rounded-lg bg-background"
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">
Target Instance/Namespace
</label>
<input
type="text"
placeholder="e.g., production"
value={targetInstance}
onChange={(e) => setTargetInstance(e.target.value)}
className="w-full px-3 py-2 border rounded-lg bg-background"
/>
</div>
<Button
onClick={handleCopySecret}
disabled={!secretToCopy || !targetInstance || copySecret.isPending}
className="w-full"
>
{copySecret.isPending ? 'Copying...' : 'Copy Secret'}
</Button>
{copySecret.isSuccess && (
<div className="text-sm text-green-600 dark:text-green-400">
Secret copied successfully!
</div>
)}
{copySecret.error && (
<div className="flex items-center gap-2 text-red-500 text-sm">
<AlertCircle className="h-4 w-4" />
<span>{copySecret.error.message}</span>
</div>
)}
</div>
</UtilityCard>
</div>
</div>
);
}

111
src/router/routes.tsx Normal file
View File

@@ -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: <LandingPage />,
},
{
path: '/instances/:instanceId',
element: <InstanceLayout />,
children: [
{
index: true,
element: <Navigate to="dashboard" replace />,
},
{
path: 'dashboard',
element: <DashboardPage />,
},
{
path: 'operations',
element: <OperationsPage />,
},
{
path: 'cluster/health',
element: <ClusterHealthPage />,
},
{
path: 'cluster/access',
element: <ClusterAccessPage />,
},
{
path: 'secrets',
element: <SecretsPage />,
},
{
path: 'services',
element: <BaseServicesPage />,
},
{
path: 'utilities',
element: <UtilitiesPage />,
},
{
path: 'cloud',
element: <CloudPage />,
},
{
path: 'central',
element: <CentralPage />,
},
{
path: 'dns',
element: <DnsPage />,
},
{
path: 'dhcp',
element: <DhcpPage />,
},
{
path: 'pxe',
element: <PxePage />,
},
{
path: 'iso',
element: <IsoPage />,
},
{
path: 'infrastructure',
element: <InfrastructurePage />,
},
{
path: 'cluster',
element: <ClusterPage />,
},
{
path: 'apps',
element: <AppsPage />,
},
{
path: 'advanced',
element: <AdvancedPage />,
},
],
},
{
path: '*',
element: <NotFoundPage />,
},
];

View File

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

View File

@@ -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<T>(endpoint: string, options?: RequestInit): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
private async requestText(endpoint: string, options?: RequestInit): Promise<string> {
const url = `${this.baseUrl}${endpoint}`;
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.text();
}
async getStatus(): Promise<Status> {
return this.request<Status>('/api/status');
}
async getHealth(): Promise<HealthResponse> {
return this.request<HealthResponse>('/api/v1/health');
}
async getConfig(): Promise<ConfigResponse> {
return this.request<ConfigResponse>('/api/v1/config');
}
async getConfigYaml(): Promise<string> {
return this.requestText('/api/v1/config/yaml');
}
async updateConfigYaml(yamlContent: string): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/config/yaml', {
method: 'PUT',
headers: { 'Content-Type': 'text/plain' },
body: yamlContent
});
}
async createConfig(config: Config): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
}
async updateConfig(config: Config): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
}
async getDnsmasqConfig(): Promise<string> {
return this.requestText('/api/v1/dnsmasq/config');
}
async restartDnsmasq(): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/dnsmasq/restart', {
method: 'POST'
});
}
async downloadPXEAssets(): Promise<StatusResponse> {
return this.request<StatusResponse>('/api/v1/pxe/assets', {
method: 'POST'
});
}
}
export const apiService = new ApiService();
export default ApiService;
// Re-export everything from the modular API structure
// This file maintains backward compatibility for imports from '../services/api'
export * from './api/index';

54
src/services/api/apps.ts Normal file
View File

@@ -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<AppListResponse> {
return apiClient.get('/api/v1/apps');
},
async getAvailable(appName: string): Promise<App> {
return apiClient.get(`/api/v1/apps/${appName}`);
},
// Deployed apps (instance-specific)
async listDeployed(instanceName: string): Promise<AppListResponse> {
return apiClient.get(`/api/v1/instances/${instanceName}/apps`);
},
async add(instanceName: string, app: AppAddRequest): Promise<AppAddResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/apps`, app);
},
async deploy(instanceName: string, appName: string): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/apps/${appName}/deploy`);
},
async delete(instanceName: string, appName: string): Promise<OperationResponse> {
return apiClient.delete(`/api/v1/instances/${instanceName}/apps/${appName}`);
},
async getStatus(instanceName: string, appName: string): Promise<AppStatus> {
return apiClient.get(`/api/v1/instances/${instanceName}/apps/${appName}/status`);
},
// Backup operations
async backup(instanceName: string, appName: string): Promise<OperationResponse> {
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<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/apps/${appName}/restore`, { backup_id: backupId });
},
};

122
src/services/api/client.ts Normal file
View File

@@ -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<T>(
endpoint: string,
options?: RequestInit
): Promise<T> {
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<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, { method: 'GET' });
}
async post<T>(endpoint: string, data?: unknown): Promise<T> {
return this.request<T>(endpoint, {
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
});
}
async put<T>(endpoint: string, data?: unknown): Promise<T> {
return this.request<T>(endpoint, {
method: 'PUT',
body: data ? JSON.stringify(data) : undefined,
});
}
async patch<T>(endpoint: string, data?: unknown): Promise<T> {
return this.request<T>(endpoint, {
method: 'PATCH',
body: data ? JSON.stringify(data) : undefined,
});
}
async delete<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, { method: 'DELETE' });
}
async getText(endpoint: string): Promise<string> {
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();

View File

@@ -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<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/cluster/config/generate`, config);
},
async bootstrap(instanceName: string, node: string): Promise<OperationResponse> {
return apiClient.post<OperationResponse>(`/api/v1/instances/${instanceName}/cluster/bootstrap`, { node });
},
async configureEndpoints(instanceName: string, includeNodes = false): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/cluster/endpoints`, { include_nodes: includeNodes });
},
async getStatus(instanceName: string): Promise<ClusterStatus> {
return apiClient.get(`/api/v1/instances/${instanceName}/cluster/status`);
},
async getHealth(instanceName: string): Promise<ClusterHealthResponse> {
return apiClient.get(`/api/v1/instances/${instanceName}/cluster/health`);
},
async getKubeconfig(instanceName: string): Promise<KubeconfigResponse> {
return apiClient.get(`/api/v1/instances/${instanceName}/cluster/kubeconfig`);
},
async generateKubeconfig(instanceName: string): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/cluster/kubeconfig/generate`);
},
async getTalosconfig(instanceName: string): Promise<TalosconfigResponse> {
return apiClient.get(`/api/v1/instances/${instanceName}/cluster/talosconfig`);
},
async reset(instanceName: string, confirm: boolean): Promise<OperationResponse> {
return apiClient.post<OperationResponse>(`/api/v1/instances/${instanceName}/cluster/reset`, { confirm });
},
};

View File

@@ -0,0 +1,12 @@
import { apiClient } from './client';
import type { ContextResponse, SetContextResponse } from './types';
export const contextApi = {
async get(): Promise<ContextResponse> {
return apiClient.get('/api/v1/context');
},
async set(context: string): Promise<SetContextResponse> {
return apiClient.post<SetContextResponse>('/api/v1/context', { context });
},
};

View File

@@ -0,0 +1,28 @@
import { apiClient } from './client';
export interface DnsmasqStatus {
running: boolean;
status?: string;
}
export const dnsmasqApi = {
async getStatus(): Promise<DnsmasqStatus> {
return apiClient.get('/api/v1/dnsmasq/status');
},
async getConfig(): Promise<string> {
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');
},
};

View File

@@ -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<ClusterHealthResponse>({
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<ClusterStatus>({
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<NodeListResponse>({
queryKey: ['cluster-nodes', instanceName],
queryFn: () => nodesApi.list(instanceName),
enabled: !!instanceName,
refetchInterval: 10000, // Auto-refresh every 10 seconds
staleTime: 5000,
});
};

View File

@@ -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<GetInstanceResponse>({
queryKey: ['instance', name],
queryFn: () => instancesApi.get(name),
enabled: !!name,
staleTime: 30000, // 30 seconds
});
};
export const useInstanceOperations = (instanceName: string, limit?: number) => {
return useQuery<OperationListResponse>({
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<ClusterHealthResponse>({
queryKey: ['instance-cluster-health', instanceName],
queryFn: () => clusterApi.getHealth(instanceName),
enabled: !!instanceName,
refetchInterval: 10000, // Refresh every 10 seconds
staleTime: 5000,
});
};

View File

@@ -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<OperationListResponse>({
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<Operation>({
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'] });
},
});
};

View File

@@ -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'],
});
},
});
}

View File

@@ -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'] });
},
});
}

19
src/services/api/index.ts Normal file
View File

@@ -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';

View File

@@ -0,0 +1,49 @@
import { apiClient } from './client';
import type {
InstanceListResponse,
CreateInstanceRequest,
CreateInstanceResponse,
DeleteInstanceResponse,
GetInstanceResponse,
} from './types';
export const instancesApi = {
async list(): Promise<InstanceListResponse> {
return apiClient.get('/api/v1/instances');
},
async get(name: string): Promise<GetInstanceResponse> {
return apiClient.get(`/api/v1/instances/${name}`);
},
async create(data: CreateInstanceRequest): Promise<CreateInstanceResponse> {
return apiClient.post('/api/v1/instances', data);
},
async delete(name: string): Promise<DeleteInstanceResponse> {
return apiClient.delete(`/api/v1/instances/${name}`);
},
// Config management
async getConfig(instanceName: string): Promise<Record<string, unknown>> {
return apiClient.get(`/api/v1/instances/${instanceName}/config`);
},
async updateConfig(instanceName: string, config: Record<string, unknown>): 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<Record<string, unknown>> {
const query = raw ? '?raw=true' : '';
return apiClient.get(`/api/v1/instances/${instanceName}/secrets${query}`);
},
async updateSecrets(instanceName: string, secrets: Record<string, unknown>): Promise<{ message: string }> {
return apiClient.put(`/api/v1/instances/${instanceName}/secrets`, secrets);
},
};

57
src/services/api/nodes.ts Normal file
View File

@@ -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<NodeListResponse> {
return apiClient.get(`/api/v1/instances/${instanceName}/nodes`);
},
async get(instanceName: string, nodeName: string): Promise<Node> {
return apiClient.get(`/api/v1/instances/${instanceName}/nodes/${nodeName}`);
},
async add(instanceName: string, node: NodeAddRequest): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/nodes`, node);
},
async update(instanceName: string, nodeName: string, updates: NodeUpdateRequest): Promise<OperationResponse> {
return apiClient.put(`/api/v1/instances/${instanceName}/nodes/${nodeName}`, updates);
},
async delete(instanceName: string, nodeName: string): Promise<OperationResponse> {
return apiClient.delete(`/api/v1/instances/${instanceName}/nodes/${nodeName}`);
},
async apply(instanceName: string, nodeName: string): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/nodes/${nodeName}/apply`);
},
// Discovery
async discover(instanceName: string, subnet: string): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/nodes/discover`, { subnet });
},
async detect(instanceName: string): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/nodes/detect`);
},
async discoveryStatus(instanceName: string): Promise<DiscoveryStatus> {
return apiClient.get(`/api/v1/instances/${instanceName}/discovery`);
},
async getHardware(instanceName: string, ip: string): Promise<HardwareInfo> {
return apiClient.get(`/api/v1/instances/${instanceName}/nodes/hardware/${ip}`);
},
async fetchTemplates(instanceName: string): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/nodes/fetch-templates`);
},
};

View File

@@ -0,0 +1,23 @@
import { apiClient } from './client';
import type { Operation, OperationListResponse } from './types';
export const operationsApi = {
async list(instanceName: string): Promise<OperationListResponse> {
return apiClient.get(`/api/v1/instances/${instanceName}/operations`);
},
async get(operationId: string, instanceName?: string): Promise<Operation> {
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`);
},
};

29
src/services/api/pxe.ts Normal file
View File

@@ -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<PxeAssetsResponse> {
return apiClient.get(`/api/v1/instances/${instanceName}/pxe/assets`);
},
async getAsset(instanceName: string, type: PxeAssetType): Promise<PxeAsset> {
return apiClient.get(`/api/v1/instances/${instanceName}/pxe/assets/${type}`);
},
async downloadAsset(
instanceName: string,
request: DownloadAssetRequest
): Promise<OperationResponse> {
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}`);
},
};

View File

@@ -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<ServiceListResponse> {
return apiClient.get(`/api/v1/instances/${instanceName}/services`);
},
async get(instanceName: string, serviceName: string): Promise<Service> {
return apiClient.get(`/api/v1/instances/${instanceName}/services/${serviceName}`);
},
async install(instanceName: string, service: ServiceInstallRequest): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/services`, service);
},
async installAll(instanceName: string): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/services/install-all`);
},
async delete(instanceName: string, serviceName: string): Promise<OperationResponse> {
return apiClient.delete(`/api/v1/instances/${instanceName}/services/${serviceName}`);
},
async getStatus(instanceName: string, serviceName: string): Promise<ServiceStatus> {
return apiClient.get(`/api/v1/instances/${instanceName}/services/${serviceName}/status`);
},
async getConfig(instanceName: string, serviceName: string): Promise<Record<string, unknown>> {
return apiClient.get(`/api/v1/instances/${instanceName}/services/${serviceName}/config`);
},
// Service lifecycle
async fetch(instanceName: string, serviceName: string): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/services/${serviceName}/fetch`);
},
async compile(instanceName: string, serviceName: string): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/services/${serviceName}/compile`);
},
async deploy(instanceName: string, serviceName: string): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/services/${serviceName}/deploy`);
},
// Global service info (not instance-specific)
async getManifest(serviceName: string): Promise<ServiceManifest> {
return apiClient.get(`/api/v1/services/${serviceName}/manifest`);
},
async getGlobalConfig(serviceName: string): Promise<Record<string, unknown>> {
return apiClient.get(`/api/v1/services/${serviceName}/config`);
},
};

View File

@@ -0,0 +1,53 @@
export interface App {
name: string;
description: string;
version: string;
category?: string;
icon?: string;
requires?: AppRequirement[];
defaultConfig?: Record<string, unknown>;
requiredSecrets?: string[];
dependencies?: string[];
config?: Record<string, string>;
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<string, string>;
}
export interface AppAddResponse {
message: string;
app: string;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,12 @@
export interface ContextResponse {
context: string | null;
}
export interface SetContextRequest {
context: string;
}
export interface SetContextResponse {
context: string;
message: string;
}

View File

@@ -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';

View File

@@ -0,0 +1,27 @@
export interface Instance {
name: string;
config: Record<string, unknown>;
}
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<string, unknown>;
}

View File

@@ -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<string, unknown>;
}

Some files were not shown because too many files have changed in this diff Show More