First swing.
This commit is contained in:
3
.env.example
Normal file
3
.env.example
Normal 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
3
.gitignore
vendored
@@ -12,6 +12,9 @@ dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
||||
58
README.md
58
README.md
@@ -2,10 +2,66 @@
|
||||
|
||||
The Wild Cloud Web App is a web-based interface for managing Wild Cloud instances. It allows users to view and control their Wild Cloud environments, including deploying applications, monitoring resources, and configuring settings.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before starting the web app, ensure the Wild Central API is running:
|
||||
|
||||
```bash
|
||||
cd ../wild-central-api
|
||||
make dev
|
||||
```
|
||||
|
||||
The API should be accessible at `http://localhost:5055`.
|
||||
|
||||
## Development
|
||||
|
||||
### Initial Setup
|
||||
|
||||
1. Copy the example environment file:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. Update `.env` if your API is running on a different host/port:
|
||||
```bash
|
||||
VITE_API_BASE_URL=http://localhost:5055
|
||||
```
|
||||
|
||||
3. Install dependencies:
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
4. Start the development server:
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
Test: `pnpm run check`
|
||||
The web app will be available at `http://localhost:5173` (or the next available port).
|
||||
|
||||
## Other Scripts
|
||||
|
||||
```bash
|
||||
pnpm run build # Build the project
|
||||
pnpm run lint # Lint the codebase
|
||||
pnpm run preview # Preview the production build
|
||||
pnpm run type-check # Type check the codebase
|
||||
pnpm run test # Run tests
|
||||
pnpm run test:ui # Run tests with UI
|
||||
pnpm run test:coverage # Run tests with coverage report
|
||||
pnpm run build:css # Build the CSS using Tailwind
|
||||
pnpm run check # Run lint, type-check, and tests
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### `VITE_API_BASE_URL`
|
||||
|
||||
The base URL of the Wild Central API server.
|
||||
|
||||
- **Default:** `http://localhost:5055`
|
||||
- **Example:** `http://192.168.1.100:5055`
|
||||
- **Usage:** Set in `.env` file (see `.env.example` for template)
|
||||
|
||||
This variable is used by the API client to connect to the Wild Central API. If not set, it defaults to `http://localhost:5055`.
|
||||
|
||||
|
||||
470
docs/specs/routing-contract.md
Normal file
470
docs/specs/routing-contract.md
Normal 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 |
|
||||
@@ -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
219
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
140
src/App.tsx
140
src/App.tsx
@@ -1,140 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useConfig } from './hooks';
|
||||
import {
|
||||
Advanced,
|
||||
ErrorBoundary
|
||||
} from './components';
|
||||
import { CloudComponent } from './components/CloudComponent';
|
||||
import { CentralComponent } from './components/CentralComponent';
|
||||
import { DnsComponent } from './components/DnsComponent';
|
||||
import { DhcpComponent } from './components/DhcpComponent';
|
||||
import { PxeComponent } from './components/PxeComponent';
|
||||
import { ClusterNodesComponent } from './components/ClusterNodesComponent';
|
||||
import { ClusterServicesComponent } from './components/ClusterServicesComponent';
|
||||
import { AppsComponent } from './components/AppsComponent';
|
||||
import { AppSidebar } from './components/AppSidebar';
|
||||
import { SidebarProvider, SidebarInset, SidebarTrigger } from './components/ui/sidebar';
|
||||
import type { Phase, Tab } from './components/AppSidebar';
|
||||
import { RouterProvider } from 'react-router';
|
||||
import { router } from './router';
|
||||
|
||||
function App() {
|
||||
const [currentTab, setCurrentTab] = useState<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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
158
src/components/BackupRestoreModal.tsx
Normal file
158
src/components/BackupRestoreModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
17
src/components/ConfigViewer.tsx
Normal file
17
src/components/ConfigViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
49
src/components/CopyButton.tsx
Normal file
49
src/components/CopyButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
41
src/components/DownloadButton.tsx
Normal file
41
src/components/DownloadButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
136
src/components/InstanceSelector.tsx
Normal file
136
src/components/InstanceSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
src/components/SecretInput.tsx
Normal file
53
src/components/SecretInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
src/components/ServiceCard.tsx
Normal file
84
src/components/ServiceCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
src/components/UtilityCard.tsx
Normal file
120
src/components/UtilityCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
173
src/components/apps/AppConfigDialog.tsx
Normal file
173
src/components/apps/AppConfigDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
65
src/components/operations/HealthIndicator.tsx
Normal file
65
src/components/operations/HealthIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
src/components/operations/NodeStatusCard.tsx
Normal file
97
src/components/operations/NodeStatusCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
149
src/components/operations/OperationCard.tsx
Normal file
149
src/components/operations/OperationCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
204
src/components/operations/OperationProgress.tsx
Normal file
204
src/components/operations/OperationProgress.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
4
src/components/operations/index.ts
Normal file
4
src/components/operations/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { OperationCard } from './OperationCard';
|
||||
export { OperationProgress } from './OperationProgress';
|
||||
export { HealthIndicator } from './HealthIndicator';
|
||||
export { NodeStatusCard } from './NodeStatusCard';
|
||||
@@ -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: {
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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
110
src/hooks/useApps.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
40
src/hooks/useBaseServices.ts
Normal file
40
src/hooks/useBaseServices.ts
Normal 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'],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
31
src/hooks/useCentralStatus.ts
Normal file
31
src/hooks/useCentralStatus.ts
Normal 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
83
src/hooks/useCluster.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
31
src/hooks/useClusterAccess.ts
Normal file
31
src/hooks/useClusterAccess.ts
Normal 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'],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
37
src/hooks/useInstanceContext.tsx
Normal file
37
src/hooks/useInstanceContext.tsx
Normal 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
82
src/hooks/useInstances.ts
Normal 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
91
src/hooks/useNodes.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
78
src/hooks/useOperations.ts
Normal file
78
src/hooks/useOperations.ts
Normal 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
25
src/hooks/useSecrets.ts
Normal 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
83
src/hooks/useServices.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
41
src/router/InstanceLayout.tsx
Normal file
41
src/router/InstanceLayout.tsx
Normal 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
14
src/router/index.tsx
Normal 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';
|
||||
10
src/router/pages/AdvancedPage.tsx
Normal file
10
src/router/pages/AdvancedPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ErrorBoundary } from '../../components';
|
||||
import { Advanced } from '../../components';
|
||||
|
||||
export function AdvancedPage() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Advanced />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
11
src/router/pages/AppsPage.tsx
Normal file
11
src/router/pages/AppsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
src/router/pages/BaseServicesPage.tsx
Normal file
116
src/router/pages/BaseServicesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
src/router/pages/CentralPage.tsx
Normal file
10
src/router/pages/CentralPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ErrorBoundary } from '../../components';
|
||||
import { CentralComponent } from '../../components/CentralComponent';
|
||||
|
||||
export function CentralPage() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<CentralComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
10
src/router/pages/CloudPage.tsx
Normal file
10
src/router/pages/CloudPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ErrorBoundary } from '../../components';
|
||||
import { CloudComponent } from '../../components/CloudComponent';
|
||||
|
||||
export function CloudPage() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<CloudComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
210
src/router/pages/ClusterAccessPage.tsx
Normal file
210
src/router/pages/ClusterAccessPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
211
src/router/pages/ClusterHealthPage.tsx
Normal file
211
src/router/pages/ClusterHealthPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
src/router/pages/ClusterPage.tsx
Normal file
11
src/router/pages/ClusterPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
243
src/router/pages/DashboardPage.tsx
Normal file
243
src/router/pages/DashboardPage.tsx
Normal 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>
|
||||
|
||||
);
|
||||
}
|
||||
10
src/router/pages/DhcpPage.tsx
Normal file
10
src/router/pages/DhcpPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ErrorBoundary } from '../../components';
|
||||
import { DhcpComponent } from '../../components/DhcpComponent';
|
||||
|
||||
export function DhcpPage() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<DhcpComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
10
src/router/pages/DnsPage.tsx
Normal file
10
src/router/pages/DnsPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ErrorBoundary } from '../../components';
|
||||
import { DnsComponent } from '../../components/DnsComponent';
|
||||
|
||||
export function DnsPage() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<DnsComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
11
src/router/pages/InfrastructurePage.tsx
Normal file
11
src/router/pages/InfrastructurePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
290
src/router/pages/IsoPage.tsx
Normal file
290
src/router/pages/IsoPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src/router/pages/LandingPage.tsx
Normal file
40
src/router/pages/LandingPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/router/pages/NotFoundPage.tsx
Normal file
30
src/router/pages/NotFoundPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
209
src/router/pages/OperationsPage.tsx
Normal file
209
src/router/pages/OperationsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
281
src/router/pages/PxePage.tsx
Normal file
281
src/router/pages/PxePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
211
src/router/pages/SecretsPage.tsx
Normal file
211
src/router/pages/SecretsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
182
src/router/pages/UtilitiesPage.tsx
Normal file
182
src/router/pages/UtilitiesPage.tsx
Normal 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
111
src/router/routes.tsx
Normal 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 />,
|
||||
},
|
||||
];
|
||||
92
src/services/api-legacy.ts
Normal file
92
src/services/api-legacy.ts
Normal 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;
|
||||
@@ -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
54
src/services/api/apps.ts
Normal 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
122
src/services/api/client.ts
Normal 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();
|
||||
47
src/services/api/cluster.ts
Normal file
47
src/services/api/cluster.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
12
src/services/api/context.ts
Normal file
12
src/services/api/context.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
28
src/services/api/dnsmasq.ts
Normal file
28
src/services/api/dnsmasq.ts
Normal 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');
|
||||
},
|
||||
};
|
||||
33
src/services/api/hooks/useCluster.ts
Normal file
33
src/services/api/hooks/useCluster.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
40
src/services/api/hooks/useInstance.ts
Normal file
40
src/services/api/hooks/useInstance.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
58
src/services/api/hooks/useOperations.ts
Normal file
58
src/services/api/hooks/useOperations.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
57
src/services/api/hooks/usePxeAssets.ts
Normal file
57
src/services/api/hooks/usePxeAssets.ts
Normal 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'],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
47
src/services/api/hooks/useUtilities.ts
Normal file
47
src/services/api/hooks/useUtilities.ts
Normal 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
19
src/services/api/index.ts
Normal 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';
|
||||
49
src/services/api/instances.ts
Normal file
49
src/services/api/instances.ts
Normal 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
57
src/services/api/nodes.ts
Normal 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`);
|
||||
},
|
||||
};
|
||||
23
src/services/api/operations.ts
Normal file
23
src/services/api/operations.ts
Normal 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
29
src/services/api/pxe.ts
Normal 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}`);
|
||||
},
|
||||
};
|
||||
62
src/services/api/services.ts
Normal file
62
src/services/api/services.ts
Normal 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`);
|
||||
},
|
||||
};
|
||||
53
src/services/api/types/app.ts
Normal file
53
src/services/api/types/app.ts
Normal 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;
|
||||
}
|
||||
45
src/services/api/types/cluster.ts
Normal file
45
src/services/api/types/cluster.ts
Normal 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;
|
||||
}
|
||||
17
src/services/api/types/config.ts
Normal file
17
src/services/api/types/config.ts
Normal 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;
|
||||
}
|
||||
12
src/services/api/types/context.ts
Normal file
12
src/services/api/types/context.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export interface ContextResponse {
|
||||
context: string | null;
|
||||
}
|
||||
|
||||
export interface SetContextRequest {
|
||||
context: string;
|
||||
}
|
||||
|
||||
export interface SetContextResponse {
|
||||
context: string;
|
||||
message: string;
|
||||
}
|
||||
9
src/services/api/types/index.ts
Normal file
9
src/services/api/types/index.ts
Normal 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';
|
||||
27
src/services/api/types/instance.ts
Normal file
27
src/services/api/types/instance.ts
Normal 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>;
|
||||
}
|
||||
58
src/services/api/types/node.ts
Normal file
58
src/services/api/types/node.ts
Normal 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
Reference in New Issue
Block a user