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
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.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.
|
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
|
## 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
|
```bash
|
||||||
pnpm run dev
|
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 .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
"test": "vitest",
|
"test": "vitest --run",
|
||||||
"test:ui": "vitest --ui",
|
"test:ui": "vitest --ui",
|
||||||
"test:coverage": "vitest --coverage",
|
"test:coverage": "vitest --coverage",
|
||||||
"build:css": "tailwindcss -i src/index.css -o ./output.css --config ./tailwind.config.js",
|
"build:css": "tailwindcss -i src/index.css -o ./output.css --config ./tailwind.config.js",
|
||||||
@@ -31,12 +31,16 @@
|
|||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-hook-form": "^7.58.1",
|
"react-hook-form": "^7.58.1",
|
||||||
|
"react-router": "^7.9.4",
|
||||||
|
"react-router-dom": "^7.9.4",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.10",
|
"tailwindcss": "^4.1.10",
|
||||||
"zod": "^3.25.67"
|
"zod": "^3.25.67"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.25.0",
|
"@eslint/js": "^9.25.0",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
"@types/node": "^24.0.3",
|
"@types/node": "^24.0.3",
|
||||||
"@types/react": "^19.1.2",
|
"@types/react": "^19.1.2",
|
||||||
"@types/react-dom": "^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:
|
react-hook-form:
|
||||||
specifier: ^7.58.1
|
specifier: ^7.58.1
|
||||||
version: 7.58.1(react@19.1.0)
|
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:
|
tailwind-merge:
|
||||||
specifier: ^3.3.1
|
specifier: ^3.3.1
|
||||||
version: 3.3.1
|
version: 3.3.1
|
||||||
@@ -66,6 +72,12 @@ importers:
|
|||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: ^9.25.0
|
specifier: ^9.25.0
|
||||||
version: 9.29.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':
|
'@types/node':
|
||||||
specifier: ^24.0.3
|
specifier: ^24.0.3
|
||||||
version: 24.0.3
|
version: 24.0.3
|
||||||
@@ -111,6 +123,9 @@ importers:
|
|||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
|
'@adobe/css-tools@4.4.4':
|
||||||
|
resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==}
|
||||||
|
|
||||||
'@ampproject/remapping@2.3.0':
|
'@ampproject/remapping@2.3.0':
|
||||||
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
|
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
@@ -191,6 +206,10 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@babel/core': ^7.0.0-0
|
'@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':
|
'@babel/template@7.27.2':
|
||||||
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
|
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -990,6 +1009,32 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^18 || ^19
|
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':
|
'@types/babel__core@7.20.5':
|
||||||
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
||||||
|
|
||||||
@@ -1139,10 +1184,18 @@ packages:
|
|||||||
ajv@6.12.6:
|
ajv@6.12.6:
|
||||||
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
|
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:
|
ansi-styles@4.3.0:
|
||||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
ansi-styles@5.2.0:
|
||||||
|
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
argparse@2.0.1:
|
argparse@2.0.1:
|
||||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||||
|
|
||||||
@@ -1150,6 +1203,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||||
engines: {node: '>=10'}
|
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:
|
assertion-error@2.0.1:
|
||||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -1222,6 +1282,10 @@ packages:
|
|||||||
convert-source-map@2.0.0:
|
convert-source-map@2.0.0:
|
||||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
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:
|
cross-spawn@7.0.6:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -1230,6 +1294,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==}
|
resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==}
|
||||||
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
|
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:
|
cssstyle@5.3.1:
|
||||||
resolution: {integrity: sha512-g5PC9Aiph9eiczFpcgUhd9S4UUO3F+LHGRIi5NUMZ+4xtoIYbHNZwZnWA2JsFGe8OU8nl4WyaEFiZuGuxlutJQ==}
|
resolution: {integrity: sha512-g5PC9Aiph9eiczFpcgUhd9S4UUO3F+LHGRIi5NUMZ+4xtoIYbHNZwZnWA2JsFGe8OU8nl4WyaEFiZuGuxlutJQ==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
@@ -1260,6 +1327,10 @@ packages:
|
|||||||
deep-is@0.1.4:
|
deep-is@0.1.4:
|
||||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
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:
|
detect-libc@2.0.4:
|
||||||
resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==}
|
resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1267,6 +1338,12 @@ packages:
|
|||||||
detect-node-es@1.1.0:
|
detect-node-es@1.1.0:
|
||||||
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
|
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:
|
electron-to-chromium@1.5.169:
|
||||||
resolution: {integrity: sha512-q7SQx6mkLy0GTJK9K9OiWeaBMV4XQtBSdf6MJUzDB/H/5tFXfIiX38Lci1Kl6SsgiEhz1SQI1ejEOU5asWEhwQ==}
|
resolution: {integrity: sha512-q7SQx6mkLy0GTJK9K9OiWeaBMV4XQtBSdf6MJUzDB/H/5tFXfIiX38Lci1Kl6SsgiEhz1SQI1ejEOU5asWEhwQ==}
|
||||||
|
|
||||||
@@ -1472,6 +1549,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
||||||
engines: {node: '>=0.8.19'}
|
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:
|
is-extglob@2.1.1:
|
||||||
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1625,6 +1706,10 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
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:
|
magic-string@0.30.17:
|
||||||
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
|
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
|
||||||
|
|
||||||
@@ -1639,6 +1724,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||||
engines: {node: '>=8.6'}
|
engines: {node: '>=8.6'}
|
||||||
|
|
||||||
|
min-indent@1.0.1:
|
||||||
|
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
minimatch@3.1.2:
|
minimatch@3.1.2:
|
||||||
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
||||||
|
|
||||||
@@ -1726,6 +1815,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
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:
|
punycode@2.3.1:
|
||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -1744,6 +1837,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17 || ^18 || ^19
|
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:
|
react-refresh@0.17.0:
|
||||||
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1768,6 +1864,23 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
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:
|
react-style-singleton@2.2.3:
|
||||||
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
|
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -1782,6 +1895,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==}
|
resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
redent@3.0.0:
|
||||||
|
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
require-from-string@2.0.2:
|
require-from-string@2.0.2:
|
||||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1824,6 +1941,9 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
set-cookie-parser@2.7.1:
|
||||||
|
resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==}
|
||||||
|
|
||||||
shebang-command@2.0.0:
|
shebang-command@2.0.0:
|
||||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1845,6 +1965,10 @@ packages:
|
|||||||
std-env@3.9.0:
|
std-env@3.9.0:
|
||||||
resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==}
|
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:
|
strip-json-comments@3.1.1:
|
||||||
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -2116,6 +2240,8 @@ packages:
|
|||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
|
'@adobe/css-tools@4.4.4': {}
|
||||||
|
|
||||||
'@ampproject/remapping@2.3.0':
|
'@ampproject/remapping@2.3.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/gen-mapping': 0.3.8
|
'@jridgewell/gen-mapping': 0.3.8
|
||||||
@@ -2226,6 +2352,8 @@ snapshots:
|
|||||||
'@babel/core': 7.27.4
|
'@babel/core': 7.27.4
|
||||||
'@babel/helper-plugin-utils': 7.27.1
|
'@babel/helper-plugin-utils': 7.27.1
|
||||||
|
|
||||||
|
'@babel/runtime@7.28.4': {}
|
||||||
|
|
||||||
'@babel/template@7.27.2':
|
'@babel/template@7.27.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.27.1
|
'@babel/code-frame': 7.27.1
|
||||||
@@ -2855,6 +2983,38 @@ snapshots:
|
|||||||
'@tanstack/query-core': 5.80.7
|
'@tanstack/query-core': 5.80.7
|
||||||
react: 19.1.0
|
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':
|
'@types/babel__core@7.20.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/parser': 7.27.5
|
'@babel/parser': 7.27.5
|
||||||
@@ -3061,16 +3221,26 @@ snapshots:
|
|||||||
json-schema-traverse: 0.4.1
|
json-schema-traverse: 0.4.1
|
||||||
uri-js: 4.4.1
|
uri-js: 4.4.1
|
||||||
|
|
||||||
|
ansi-regex@5.0.1: {}
|
||||||
|
|
||||||
ansi-styles@4.3.0:
|
ansi-styles@4.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-convert: 2.0.1
|
color-convert: 2.0.1
|
||||||
|
|
||||||
|
ansi-styles@5.2.0: {}
|
||||||
|
|
||||||
argparse@2.0.1: {}
|
argparse@2.0.1: {}
|
||||||
|
|
||||||
aria-hidden@1.2.6:
|
aria-hidden@1.2.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
aria-query@5.3.0:
|
||||||
|
dependencies:
|
||||||
|
dequal: 2.0.3
|
||||||
|
|
||||||
|
aria-query@5.3.2: {}
|
||||||
|
|
||||||
assertion-error@2.0.1: {}
|
assertion-error@2.0.1: {}
|
||||||
|
|
||||||
balanced-match@1.0.2: {}
|
balanced-match@1.0.2: {}
|
||||||
@@ -3138,6 +3308,8 @@ snapshots:
|
|||||||
|
|
||||||
convert-source-map@2.0.0: {}
|
convert-source-map@2.0.0: {}
|
||||||
|
|
||||||
|
cookie@1.0.2: {}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
@@ -3149,6 +3321,8 @@ snapshots:
|
|||||||
mdn-data: 2.12.2
|
mdn-data: 2.12.2
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
css.escape@1.5.1: {}
|
||||||
|
|
||||||
cssstyle@5.3.1(postcss@8.5.6):
|
cssstyle@5.3.1(postcss@8.5.6):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@asamuzakjp/css-color': 4.0.5
|
'@asamuzakjp/css-color': 4.0.5
|
||||||
@@ -3174,10 +3348,16 @@ snapshots:
|
|||||||
|
|
||||||
deep-is@0.1.4: {}
|
deep-is@0.1.4: {}
|
||||||
|
|
||||||
|
dequal@2.0.3: {}
|
||||||
|
|
||||||
detect-libc@2.0.4: {}
|
detect-libc@2.0.4: {}
|
||||||
|
|
||||||
detect-node-es@1.1.0: {}
|
detect-node-es@1.1.0: {}
|
||||||
|
|
||||||
|
dom-accessibility-api@0.5.16: {}
|
||||||
|
|
||||||
|
dom-accessibility-api@0.6.3: {}
|
||||||
|
|
||||||
electron-to-chromium@1.5.169: {}
|
electron-to-chromium@1.5.169: {}
|
||||||
|
|
||||||
enhanced-resolve@5.18.1:
|
enhanced-resolve@5.18.1:
|
||||||
@@ -3406,6 +3586,8 @@ snapshots:
|
|||||||
|
|
||||||
imurmurhash@0.1.4: {}
|
imurmurhash@0.1.4: {}
|
||||||
|
|
||||||
|
indent-string@4.0.0: {}
|
||||||
|
|
||||||
is-extglob@2.1.1: {}
|
is-extglob@2.1.1: {}
|
||||||
|
|
||||||
is-glob@4.0.3:
|
is-glob@4.0.3:
|
||||||
@@ -3538,6 +3720,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.1.0
|
react: 19.1.0
|
||||||
|
|
||||||
|
lz-string@1.5.0: {}
|
||||||
|
|
||||||
magic-string@0.30.17:
|
magic-string@0.30.17:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.0
|
'@jridgewell/sourcemap-codec': 1.5.0
|
||||||
@@ -3551,6 +3735,8 @@ snapshots:
|
|||||||
braces: 3.0.3
|
braces: 3.0.3
|
||||||
picomatch: 2.3.1
|
picomatch: 2.3.1
|
||||||
|
|
||||||
|
min-indent@1.0.1: {}
|
||||||
|
|
||||||
minimatch@3.1.2:
|
minimatch@3.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 1.1.12
|
brace-expansion: 1.1.12
|
||||||
@@ -3622,6 +3808,12 @@ snapshots:
|
|||||||
|
|
||||||
prelude-ls@1.2.1: {}
|
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: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
queue-microtask@1.2.3: {}
|
queue-microtask@1.2.3: {}
|
||||||
@@ -3635,6 +3827,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.1.0
|
react: 19.1.0
|
||||||
|
|
||||||
|
react-is@17.0.2: {}
|
||||||
|
|
||||||
react-refresh@0.17.0: {}
|
react-refresh@0.17.0: {}
|
||||||
|
|
||||||
react-remove-scroll-bar@2.3.8(@types/react@19.1.8)(react@19.1.0):
|
react-remove-scroll-bar@2.3.8(@types/react@19.1.8)(react@19.1.0):
|
||||||
@@ -3656,6 +3850,20 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.1.8
|
'@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):
|
react-style-singleton@2.2.3(@types/react@19.1.8)(react@19.1.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
get-nonce: 1.0.1
|
get-nonce: 1.0.1
|
||||||
@@ -3666,6 +3874,11 @@ snapshots:
|
|||||||
|
|
||||||
react@19.1.0: {}
|
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: {}
|
require-from-string@2.0.2: {}
|
||||||
|
|
||||||
resolve-from@4.0.0: {}
|
resolve-from@4.0.0: {}
|
||||||
@@ -3716,6 +3929,8 @@ snapshots:
|
|||||||
|
|
||||||
semver@7.7.2: {}
|
semver@7.7.2: {}
|
||||||
|
|
||||||
|
set-cookie-parser@2.7.1: {}
|
||||||
|
|
||||||
shebang-command@2.0.0:
|
shebang-command@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
shebang-regex: 3.0.0
|
shebang-regex: 3.0.0
|
||||||
@@ -3730,6 +3945,10 @@ snapshots:
|
|||||||
|
|
||||||
std-env@3.9.0: {}
|
std-env@3.9.0: {}
|
||||||
|
|
||||||
|
strip-indent@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
min-indent: 1.0.1
|
||||||
|
|
||||||
strip-json-comments@3.1.1: {}
|
strip-json-comments@3.1.1: {}
|
||||||
|
|
||||||
strip-literal@3.1.0:
|
strip-literal@3.1.0:
|
||||||
|
|||||||
140
src/App.tsx
140
src/App.tsx
@@ -1,140 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { RouterProvider } from 'react-router';
|
||||||
import { useConfig } from './hooks';
|
import { router } from './router';
|
||||||
import {
|
|
||||||
Advanced,
|
|
||||||
ErrorBoundary
|
|
||||||
} from './components';
|
|
||||||
import { CloudComponent } from './components/CloudComponent';
|
|
||||||
import { CentralComponent } from './components/CentralComponent';
|
|
||||||
import { DnsComponent } from './components/DnsComponent';
|
|
||||||
import { DhcpComponent } from './components/DhcpComponent';
|
|
||||||
import { PxeComponent } from './components/PxeComponent';
|
|
||||||
import { ClusterNodesComponent } from './components/ClusterNodesComponent';
|
|
||||||
import { ClusterServicesComponent } from './components/ClusterServicesComponent';
|
|
||||||
import { AppsComponent } from './components/AppsComponent';
|
|
||||||
import { AppSidebar } from './components/AppSidebar';
|
|
||||||
import { SidebarProvider, SidebarInset, SidebarTrigger } from './components/ui/sidebar';
|
|
||||||
import type { Phase, Tab } from './components/AppSidebar';
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [currentTab, setCurrentTab] = useState<Tab>('cloud');
|
return <RouterProvider router={router} />;
|
||||||
const [completedPhases, setCompletedPhases] = useState<Phase[]>([]);
|
|
||||||
|
|
||||||
const { config } = useConfig();
|
|
||||||
|
|
||||||
// Update phase state from config when it changes
|
|
||||||
useEffect(() => {
|
|
||||||
console.log('Config changed:', config);
|
|
||||||
console.log('config?.wildcloud:', config?.wildcloud);
|
|
||||||
if (config?.wildcloud?.currentPhase) {
|
|
||||||
console.log('Setting currentTab to:', config.wildcloud.currentPhase);
|
|
||||||
setCurrentTab(config.wildcloud.currentPhase as Phase);
|
|
||||||
}
|
|
||||||
if (config?.wildcloud?.completedPhases) {
|
|
||||||
console.log('Setting completedPhases to:', config.wildcloud.completedPhases);
|
|
||||||
setCompletedPhases(config.wildcloud.completedPhases as Phase[]);
|
|
||||||
}
|
|
||||||
}, [config]);
|
|
||||||
|
|
||||||
const handlePhaseComplete = (phase: Phase) => {
|
|
||||||
if (!completedPhases.includes(phase)) {
|
|
||||||
setCompletedPhases(prev => [...prev, phase]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-advance to next phase (excluding advanced)
|
|
||||||
const phases: Phase[] = ['setup', 'infrastructure', 'cluster', 'apps'];
|
|
||||||
const currentIndex = phases.indexOf(phase);
|
|
||||||
if (currentIndex < phases.length - 1) {
|
|
||||||
setCurrentTab(phases[currentIndex + 1]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderCurrentTab = () => {
|
|
||||||
switch (currentTab) {
|
|
||||||
case 'cloud':
|
|
||||||
return (
|
|
||||||
<ErrorBoundary>
|
|
||||||
<CloudComponent />
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
case 'central':
|
|
||||||
return (
|
|
||||||
<ErrorBoundary>
|
|
||||||
<CentralComponent />
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
case 'dns':
|
|
||||||
return (
|
|
||||||
<ErrorBoundary>
|
|
||||||
<DnsComponent />
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
case 'dhcp':
|
|
||||||
return (
|
|
||||||
<ErrorBoundary>
|
|
||||||
<DhcpComponent />
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
case 'pxe':
|
|
||||||
return (
|
|
||||||
<ErrorBoundary>
|
|
||||||
<PxeComponent />
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
case 'setup':
|
|
||||||
case 'infrastructure':
|
|
||||||
return (
|
|
||||||
<ErrorBoundary>
|
|
||||||
<ClusterNodesComponent onComplete={() => handlePhaseComplete('infrastructure')} />
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
case 'cluster':
|
|
||||||
return (
|
|
||||||
<ErrorBoundary>
|
|
||||||
<ClusterServicesComponent onComplete={() => handlePhaseComplete('cluster')} />
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
case 'apps':
|
|
||||||
return (
|
|
||||||
<ErrorBoundary>
|
|
||||||
<AppsComponent onComplete={() => handlePhaseComplete('apps')} />
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
case 'advanced':
|
|
||||||
return (
|
|
||||||
<ErrorBoundary>
|
|
||||||
<Advanced />
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return (
|
|
||||||
<ErrorBoundary>
|
|
||||||
<CloudComponent />
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarProvider>
|
|
||||||
<AppSidebar
|
|
||||||
currentTab={currentTab}
|
|
||||||
onTabChange={setCurrentTab}
|
|
||||||
completedPhases={completedPhases}
|
|
||||||
/>
|
|
||||||
<SidebarInset>
|
|
||||||
<header className="flex h-16 shrink-0 items-center gap-2 px-4">
|
|
||||||
<SidebarTrigger className="-ml-1" />
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h1 className="text-lg font-semibold">Dashboard</h1>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4">
|
|
||||||
{renderCurrentTab()}
|
|
||||||
</div>
|
|
||||||
</SidebarInset>
|
|
||||||
</SidebarProvider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
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 { cn } from '../lib/utils';
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
@@ -16,18 +17,9 @@ import {
|
|||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
|
|
||||||
export type Phase = 'setup' | 'infrastructure' | 'cluster' | 'apps';
|
export function AppSidebar() {
|
||||||
export type Tab = Phase | 'advanced' | 'cloud' | 'central' | 'dns' | 'dhcp' | 'pxe';
|
|
||||||
|
|
||||||
interface AppSidebarProps {
|
|
||||||
currentTab: Tab;
|
|
||||||
onTabChange: (tab: Tab) => void;
|
|
||||||
completedPhases: Phase[];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function AppSidebar({ currentTab, onTabChange, completedPhases }: AppSidebarProps) {
|
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
const { instanceId } = useParams<{ instanceId: string }>();
|
||||||
|
|
||||||
const cycleTheme = () => {
|
const cycleTheme = () => {
|
||||||
if (theme === 'light') {
|
if (theme === 'light') {
|
||||||
@@ -61,45 +53,10 @@ export function AppSidebar({ currentTab, onTabChange, completedPhases }: AppSide
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTabStatus = (tab: Tab) => {
|
// If no instanceId, we're not in an instance context
|
||||||
// Non-phase tabs (like advanced and cloud) are always available
|
if (!instanceId) {
|
||||||
if (tab === 'advanced' || tab === 'cloud') {
|
return null;
|
||||||
return 'available';
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Central sub-tabs are available if setup phase is available or completed
|
|
||||||
if (tab === 'central' || tab === 'dns' || tab === 'dhcp' || tab === 'pxe') {
|
|
||||||
if (completedPhases.includes('setup')) {
|
|
||||||
return 'completed';
|
|
||||||
}
|
|
||||||
return 'available';
|
|
||||||
}
|
|
||||||
|
|
||||||
// For phase tabs, check completion status
|
|
||||||
if (completedPhases.includes(tab as Phase)) {
|
|
||||||
return 'completed';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow access to the first phase always
|
|
||||||
if (tab === 'setup') {
|
|
||||||
return 'available';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow access to the next phase if the previous phase is completed
|
|
||||||
if (tab === 'infrastructure' && completedPhases.includes('setup')) {
|
|
||||||
return 'available';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tab === 'cluster' && completedPhases.includes('infrastructure')) {
|
|
||||||
return 'available';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tab === 'apps' && completedPhases.includes('cluster')) {
|
|
||||||
return 'available';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'locked';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar variant="sidebar" collapsible="icon">
|
<Sidebar variant="sidebar" collapsible="icon">
|
||||||
@@ -110,40 +67,57 @@ export function AppSidebar({ currentTab, onTabChange, completedPhases }: AppSide
|
|||||||
</div>
|
</div>
|
||||||
<div className="group-data-[collapsible=icon]:hidden">
|
<div className="group-data-[collapsible=icon]:hidden">
|
||||||
<h2 className="text-lg font-bold text-foreground">Wild Cloud</h2>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
|
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<NavLink to={`/instances/${instanceId}/dashboard`}>
|
||||||
isActive={currentTab === 'cloud'}
|
{({ isActive }) => (
|
||||||
onClick={() => {
|
<SidebarMenuButton
|
||||||
const status = getTabStatus('cloud');
|
isActive={isActive}
|
||||||
if (status !== 'locked') onTabChange('cloud');
|
tooltip="Instance dashboard and overview"
|
||||||
}}
|
>
|
||||||
disabled={getTabStatus('cloud') === 'locked'}
|
<div className={cn(
|
||||||
tooltip="Configure cloud settings and domains"
|
"p-1 rounded-md",
|
||||||
className={cn(
|
isActive && "bg-primary/10"
|
||||||
"transition-colors",
|
)}>
|
||||||
getTabStatus('cloud') === 'locked' && "opacity-50 cursor-not-allowed"
|
<CloudLightning className={cn(
|
||||||
|
"h-4 w-4",
|
||||||
|
isActive && "text-primary",
|
||||||
|
!isActive && "text-muted-foreground"
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
<span className="truncate">Dashboard</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
)}
|
)}
|
||||||
>
|
</NavLink>
|
||||||
<div className={cn(
|
</SidebarMenuItem>
|
||||||
"p-1 rounded-md",
|
|
||||||
currentTab === 'cloud' && "bg-primary/10",
|
<SidebarMenuItem>
|
||||||
getTabStatus('cloud') === 'locked' && "bg-muted"
|
<NavLink to={`/instances/${instanceId}/cloud`}>
|
||||||
)}>
|
{({ isActive }) => (
|
||||||
<CloudLightning className={cn(
|
<SidebarMenuButton
|
||||||
"h-4 w-4",
|
isActive={isActive}
|
||||||
currentTab === 'cloud' && "text-primary",
|
tooltip="Configure cloud settings and domains"
|
||||||
currentTab !== 'cloud' && "text-muted-foreground"
|
>
|
||||||
)} />
|
<div className={cn(
|
||||||
</div>
|
"p-1 rounded-md",
|
||||||
<span className="truncate">Cloud</span>
|
isActive && "bg-primary/10"
|
||||||
</SidebarMenuButton>
|
)}>
|
||||||
|
<CloudLightning className={cn(
|
||||||
|
"h-4 w-4",
|
||||||
|
isActive && "text-primary",
|
||||||
|
!isActive && "text-muted-foreground"
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
<span className="truncate">Cloud</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
|
||||||
<Collapsible defaultOpen className="group/collapsible">
|
<Collapsible defaultOpen className="group/collapsible">
|
||||||
@@ -158,110 +132,57 @@ export function AppSidebar({ currentTab, onTabChange, completedPhases }: AppSide
|
|||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<SidebarMenuSub>
|
<SidebarMenuSub>
|
||||||
<SidebarMenuSubItem>
|
<SidebarMenuSubItem>
|
||||||
<SidebarMenuSubButton
|
<SidebarMenuSubButton asChild>
|
||||||
isActive={currentTab === 'central'}
|
<NavLink to={`/instances/${instanceId}/central`} className={({ isActive }) => isActive ? "data-[active=true]" : ""}>
|
||||||
onClick={() => {
|
<div className="p-1 rounded-md">
|
||||||
const status = getTabStatus('central');
|
<Server className="h-4 w-4" />
|
||||||
if (status !== 'locked') onTabChange('central');
|
</div>
|
||||||
}}
|
<span className="truncate">Central</span>
|
||||||
className={cn(
|
</NavLink>
|
||||||
"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>
|
</SidebarMenuSubButton>
|
||||||
</SidebarMenuSubItem>
|
</SidebarMenuSubItem>
|
||||||
|
|
||||||
<SidebarMenuSubItem>
|
<SidebarMenuSubItem>
|
||||||
<SidebarMenuSubButton
|
<SidebarMenuSubButton asChild>
|
||||||
isActive={currentTab === 'dns'}
|
<NavLink to={`/instances/${instanceId}/dns`}>
|
||||||
onClick={() => {
|
<div className="p-1 rounded-md">
|
||||||
const status = getTabStatus('dns');
|
<Globe className="h-4 w-4" />
|
||||||
if (status !== 'locked') onTabChange('dns');
|
</div>
|
||||||
}}
|
<span className="truncate">DNS</span>
|
||||||
className={cn(
|
</NavLink>
|
||||||
"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>
|
</SidebarMenuSubButton>
|
||||||
</SidebarMenuSubItem>
|
</SidebarMenuSubItem>
|
||||||
|
|
||||||
<SidebarMenuSubItem>
|
<SidebarMenuSubItem>
|
||||||
<SidebarMenuSubButton
|
<SidebarMenuSubButton asChild>
|
||||||
isActive={currentTab === 'dhcp'}
|
<NavLink to={`/instances/${instanceId}/dhcp`}>
|
||||||
onClick={() => {
|
<div className="p-1 rounded-md">
|
||||||
const status = getTabStatus('dhcp');
|
<Wifi className="h-4 w-4" />
|
||||||
if (status !== 'locked') onTabChange('dhcp');
|
</div>
|
||||||
}}
|
<span className="truncate">DHCP</span>
|
||||||
className={cn(
|
</NavLink>
|
||||||
"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>
|
</SidebarMenuSubButton>
|
||||||
</SidebarMenuSubItem>
|
</SidebarMenuSubItem>
|
||||||
|
|
||||||
<SidebarMenuSubItem>
|
<SidebarMenuSubItem>
|
||||||
<SidebarMenuSubButton
|
<SidebarMenuSubButton asChild>
|
||||||
isActive={currentTab === 'pxe'}
|
<NavLink to={`/instances/${instanceId}/pxe`}>
|
||||||
onClick={() => {
|
<div className="p-1 rounded-md">
|
||||||
const status = getTabStatus('pxe');
|
<HardDrive className="h-4 w-4" />
|
||||||
if (status !== 'locked') onTabChange('pxe');
|
</div>
|
||||||
}}
|
<span className="truncate">PXE</span>
|
||||||
className={cn(
|
</NavLink>
|
||||||
"transition-colors",
|
</SidebarMenuSubButton>
|
||||||
getTabStatus('pxe') === 'locked' && "opacity-50 cursor-not-allowed"
|
</SidebarMenuSubItem>
|
||||||
)}
|
|
||||||
>
|
<SidebarMenuSubItem>
|
||||||
<div className={cn(
|
<SidebarMenuSubButton asChild>
|
||||||
"p-1 rounded-md",
|
<NavLink to={`/instances/${instanceId}/iso`}>
|
||||||
currentTab === 'pxe' && "bg-primary/10",
|
<div className="p-1 rounded-md">
|
||||||
getTabStatus('pxe') === 'locked' && "bg-muted"
|
<Usb className="h-4 w-4" />
|
||||||
)}>
|
</div>
|
||||||
<HardDrive className={cn(
|
<span className="truncate">ISO / USB</span>
|
||||||
"h-4 w-4",
|
</NavLink>
|
||||||
currentTab === 'pxe' && "text-primary",
|
|
||||||
currentTab !== 'pxe' && "text-muted-foreground"
|
|
||||||
)} />
|
|
||||||
</div>
|
|
||||||
<span className="truncate">PXE</span>
|
|
||||||
</SidebarMenuSubButton>
|
</SidebarMenuSubButton>
|
||||||
</SidebarMenuSubItem>
|
</SidebarMenuSubItem>
|
||||||
</SidebarMenuSub>
|
</SidebarMenuSub>
|
||||||
@@ -281,56 +202,24 @@ export function AppSidebar({ currentTab, onTabChange, completedPhases }: AppSide
|
|||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<SidebarMenuSub>
|
<SidebarMenuSub>
|
||||||
<SidebarMenuSubItem>
|
<SidebarMenuSubItem>
|
||||||
<SidebarMenuSubButton
|
<SidebarMenuSubButton asChild>
|
||||||
isActive={currentTab === 'infrastructure'}
|
<NavLink to={`/instances/${instanceId}/infrastructure`}>
|
||||||
onClick={() => {
|
<div className="p-1 rounded-md">
|
||||||
const status = getTabStatus('infrastructure');
|
<Play className="h-4 w-4" />
|
||||||
if (status !== 'locked') onTabChange('infrastructure');
|
</div>
|
||||||
}}
|
<span className="truncate">Cluster Nodes</span>
|
||||||
className={cn(
|
</NavLink>
|
||||||
"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>
|
</SidebarMenuSubButton>
|
||||||
</SidebarMenuSubItem>
|
</SidebarMenuSubItem>
|
||||||
|
|
||||||
<SidebarMenuSubItem>
|
<SidebarMenuSubItem>
|
||||||
<SidebarMenuSubButton
|
<SidebarMenuSubButton asChild>
|
||||||
isActive={currentTab === 'cluster'}
|
<NavLink to={`/instances/${instanceId}/cluster`}>
|
||||||
onClick={() => {
|
<div className="p-1 rounded-md">
|
||||||
const status = getTabStatus('cluster');
|
<Container className="h-4 w-4" />
|
||||||
if (status !== 'locked') onTabChange('cluster');
|
</div>
|
||||||
}}
|
<span className="truncate">Cluster Services</span>
|
||||||
className={cn(
|
</NavLink>
|
||||||
"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>
|
</SidebarMenuSubButton>
|
||||||
</SidebarMenuSubItem>
|
</SidebarMenuSubItem>
|
||||||
</SidebarMenuSub>
|
</SidebarMenuSub>
|
||||||
@@ -339,60 +228,24 @@ export function AppSidebar({ currentTab, onTabChange, completedPhases }: AppSide
|
|||||||
</Collapsible>
|
</Collapsible>
|
||||||
|
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton asChild tooltip="Install and manage applications">
|
||||||
isActive={currentTab === 'apps'}
|
<NavLink to={`/instances/${instanceId}/apps`}>
|
||||||
onClick={() => {
|
<div className="p-1 rounded-md">
|
||||||
const status = getTabStatus('apps');
|
<AppWindow className="h-4 w-4" />
|
||||||
if (status !== 'locked') onTabChange('apps');
|
</div>
|
||||||
}}
|
<span className="truncate">Apps</span>
|
||||||
disabled={getTabStatus('apps') === 'locked'}
|
</NavLink>
|
||||||
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>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton asChild tooltip="Advanced settings and system configuration">
|
||||||
isActive={currentTab === 'advanced'}
|
<NavLink to={`/instances/${instanceId}/advanced`}>
|
||||||
onClick={() => {
|
<div className="p-1 rounded-md">
|
||||||
const status = getTabStatus('advanced');
|
<Settings className="h-4 w-4" />
|
||||||
if (status !== 'locked') onTabChange('advanced');
|
</div>
|
||||||
}}
|
<span className="truncate">Advanced</span>
|
||||||
disabled={getTabStatus('advanced') === 'locked'}
|
</NavLink>
|
||||||
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>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
@@ -413,4 +266,4 @@ export function AppSidebar({ currentTab, onTabChange, completedPhases }: AppSide
|
|||||||
<SidebarRail/>
|
<SidebarRail/>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,161 +2,131 @@ import { useState } from 'react';
|
|||||||
import { Card } from './ui/card';
|
import { Card } from './ui/card';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
import {
|
import {
|
||||||
AppWindow,
|
AppWindow,
|
||||||
Database,
|
Database,
|
||||||
Globe,
|
Globe,
|
||||||
Shield,
|
Shield,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Plus,
|
Search,
|
||||||
Search,
|
|
||||||
Settings,
|
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Clock,
|
|
||||||
Download,
|
Download,
|
||||||
Trash2,
|
Trash2,
|
||||||
BookOpen
|
BookOpen,
|
||||||
|
Loader2,
|
||||||
|
Archive,
|
||||||
|
RotateCcw,
|
||||||
|
Settings,
|
||||||
} from 'lucide-react';
|
} 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 {
|
interface MergedApp extends App {
|
||||||
onComplete?: () => void;
|
deploymentStatus?: 'added' | 'deployed';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Application {
|
export function AppsComponent() {
|
||||||
id: string;
|
const { currentInstance } = useInstanceContext();
|
||||||
name: string;
|
const { data: availableAppsData, isLoading: loadingAvailable, error: availableError } = useAvailableApps();
|
||||||
description: string;
|
const {
|
||||||
category: 'database' | 'web' | 'security' | 'monitoring' | 'communication' | 'storage';
|
apps: deployedApps,
|
||||||
status: 'available' | 'installing' | 'running' | 'error' | 'stopped';
|
isLoading: loadingDeployed,
|
||||||
version?: string;
|
error: deployedError,
|
||||||
namespace?: string;
|
addApp,
|
||||||
replicas?: number;
|
isAdding,
|
||||||
resources?: {
|
deployApp,
|
||||||
cpu: string;
|
isDeploying,
|
||||||
memory: string;
|
deleteApp,
|
||||||
};
|
isDeleting
|
||||||
urls?: string[];
|
} = useDeployedApps(currentInstance);
|
||||||
}
|
|
||||||
|
|
||||||
export function AppsComponent({ onComplete }: AppsComponentProps) {
|
|
||||||
const [applications, setApplications] = useState<Application[]>([
|
|
||||||
{
|
|
||||||
id: 'postgres',
|
|
||||||
name: 'PostgreSQL',
|
|
||||||
description: 'Reliable, high-performance SQL database',
|
|
||||||
category: 'database',
|
|
||||||
status: 'running',
|
|
||||||
version: 'v15.4',
|
|
||||||
namespace: 'default',
|
|
||||||
replicas: 1,
|
|
||||||
resources: { cpu: '500m', memory: '1Gi' },
|
|
||||||
urls: ['postgres://postgres.wildcloud.local:5432'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'redis',
|
|
||||||
name: 'Redis',
|
|
||||||
description: 'In-memory data structure store',
|
|
||||||
category: 'database',
|
|
||||||
status: 'running',
|
|
||||||
version: 'v7.2',
|
|
||||||
namespace: 'default',
|
|
||||||
replicas: 1,
|
|
||||||
resources: { cpu: '250m', memory: '512Mi' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'traefik-dashboard',
|
|
||||||
name: 'Traefik Dashboard',
|
|
||||||
description: 'Load balancer and reverse proxy dashboard',
|
|
||||||
category: 'web',
|
|
||||||
status: 'running',
|
|
||||||
version: 'v3.0',
|
|
||||||
namespace: 'kube-system',
|
|
||||||
urls: ['https://traefik.wildcloud.local'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'grafana',
|
|
||||||
name: 'Grafana',
|
|
||||||
description: 'Monitoring and observability dashboards',
|
|
||||||
category: 'monitoring',
|
|
||||||
status: 'installing',
|
|
||||||
version: 'v10.2',
|
|
||||||
namespace: 'monitoring',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'prometheus',
|
|
||||||
name: 'Prometheus',
|
|
||||||
description: 'Time-series monitoring and alerting',
|
|
||||||
category: 'monitoring',
|
|
||||||
status: 'running',
|
|
||||||
version: 'v2.45',
|
|
||||||
namespace: 'monitoring',
|
|
||||||
replicas: 1,
|
|
||||||
resources: { cpu: '1000m', memory: '2Gi' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'vault',
|
|
||||||
name: 'HashiCorp Vault',
|
|
||||||
description: 'Secrets management and encryption',
|
|
||||||
category: 'security',
|
|
||||||
status: 'available',
|
|
||||||
version: 'v1.15',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'minio',
|
|
||||||
name: 'MinIO',
|
|
||||||
description: 'High-performance object storage',
|
|
||||||
category: 'storage',
|
|
||||||
status: 'available',
|
|
||||||
version: 'RELEASE.2023-12-07',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
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) {
|
switch (status) {
|
||||||
case 'running':
|
case 'running':
|
||||||
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
||||||
case 'error':
|
case 'error':
|
||||||
return <AlertCircle className="h-5 w-5 text-red-500" />;
|
return <AlertCircle className="h-5 w-5 text-red-500" />;
|
||||||
case 'installing':
|
case 'deploying':
|
||||||
return <Clock className="h-5 w-5 text-blue-500 animate-spin" />;
|
return <Loader2 className="h-5 w-5 text-blue-500 animate-spin" />;
|
||||||
case 'stopped':
|
case 'stopped':
|
||||||
return <AlertCircle className="h-5 w-5 text-yellow-500" />;
|
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" />;
|
return <Download className="h-5 w-5 text-muted-foreground" />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusBadge = (status: Application['status']) => {
|
const getStatusBadge = (app: MergedApp) => {
|
||||||
const variants = {
|
// 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',
|
available: 'secondary',
|
||||||
installing: 'default',
|
added: 'outline',
|
||||||
|
deploying: 'default',
|
||||||
running: 'success',
|
running: 'success',
|
||||||
error: 'destructive',
|
error: 'destructive',
|
||||||
stopped: 'warning',
|
stopped: 'warning',
|
||||||
} as const;
|
deployed: 'outline',
|
||||||
|
};
|
||||||
|
|
||||||
const labels = {
|
const labels: Record<string, string> = {
|
||||||
available: 'Available',
|
available: 'Available',
|
||||||
installing: 'Installing',
|
added: 'Added',
|
||||||
|
deploying: 'Deploying',
|
||||||
running: 'Running',
|
running: 'Running',
|
||||||
error: 'Error',
|
error: 'Error',
|
||||||
stopped: 'Stopped',
|
stopped: 'Stopped',
|
||||||
|
deployed: 'Deployed',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge variant={variants[status] as any}>
|
<Badge variant={variants[status]}>
|
||||||
{labels[status]}
|
{labels[status] || status}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCategoryIcon = (category: Application['category']) => {
|
const getCategoryIcon = (category?: string) => {
|
||||||
switch (category) {
|
switch (category) {
|
||||||
case 'database':
|
case 'database':
|
||||||
return <Database className="h-4 w-4" />;
|
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') => {
|
const handleAppAction = (app: MergedApp, action: 'configure' | 'deploy' | 'delete' | 'backup' | 'restore') => {
|
||||||
console.log(`${action} app: ${appId}`);
|
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 categories = ['all', 'database', 'web', 'security', 'monitoring', 'communication', 'storage'];
|
||||||
|
|
||||||
const filteredApps = applications.filter(app => {
|
const filteredApps = applications.filter(app => {
|
||||||
const matchesSearch = app.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
const matchesSearch = app.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
app.description.toLowerCase().includes(searchTerm.toLowerCase());
|
app.description.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
@@ -188,7 +206,34 @@ export function AppsComponent({ onComplete }: AppsComponentProps) {
|
|||||||
return matchesSearch && matchesCategory;
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<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="flex items-center justify-between mb-4">
|
||||||
<div className="text-sm text-muted-foreground">
|
<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>
|
</div>
|
||||||
<Button size="sm">
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
Add App
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
{isLoading ? (
|
||||||
{filteredApps.map((app) => (
|
<Card className="p-8 text-center">
|
||||||
<Card key={app.id} className="p-4">
|
<Loader2 className="h-12 w-12 text-primary mx-auto mb-4 animate-spin" />
|
||||||
<div className="flex items-start gap-3">
|
<p className="text-muted-foreground">Loading applications...</p>
|
||||||
<div className="p-2 bg-muted rounded-lg">
|
</Card>
|
||||||
{getCategoryIcon(app.category)}
|
) : (
|
||||||
</div>
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
<div className="flex-1 min-w-0">
|
{filteredApps.map((app) => (
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<Card key={app.name} className="p-4">
|
||||||
<h3 className="font-medium truncate">{app.name}</h3>
|
<div className="flex items-start gap-3">
|
||||||
{app.version && (
|
<div className="p-2 bg-muted rounded-lg">
|
||||||
<Badge variant="outline" className="text-xs">
|
{getCategoryIcon(app.category)}
|
||||||
{app.version}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{getStatusIcon(app.status)}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground mb-2">{app.description}</p>
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
{app.status === 'running' && (
|
<h3 className="font-medium truncate">{app.name}</h3>
|
||||||
<div className="space-y-1 text-xs text-muted-foreground">
|
{app.version && (
|
||||||
{app.namespace && (
|
<Badge variant="outline" className="text-xs">
|
||||||
<div>Namespace: {app.namespace}</div>
|
{app.version}
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{app.replicas && (
|
{getStatusIcon(app.status?.status)}
|
||||||
<div>Replicas: {app.replicas}</div>
|
</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">
|
{/* Deployed: running in Kubernetes */}
|
||||||
<span>URLs:</span>
|
{app.deploymentStatus === 'deployed' && (
|
||||||
{app.urls.map((url, index) => (
|
<>
|
||||||
<Button
|
{app.status?.status === 'running' && (
|
||||||
key={index}
|
<>
|
||||||
variant="link"
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-auto p-0 text-xs"
|
variant="outline"
|
||||||
onClick={() => window.open(url, '_blank')}
|
onClick={() => handleAppAction(app, 'backup')}
|
||||||
>
|
disabled={isBackingUp}
|
||||||
<ExternalLink className="h-3 w-3 mr-1" />
|
title="Create backup"
|
||||||
Access
|
>
|
||||||
</Button>
|
<Archive className="h-4 w-4" />
|
||||||
))}
|
</Button>
|
||||||
</div>
|
<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>
|
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
</Card>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{filteredApps.length === 0 && (
|
{!isLoading && filteredApps.length === 0 && (
|
||||||
<Card className="p-8 text-center">
|
<Card className="p-8 text-center">
|
||||||
<AppWindow className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
<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>
|
<h3 className="text-lg font-medium mb-2">No applications found</h3>
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
{searchTerm || selectedCategory !== 'all'
|
{searchTerm || selectedCategory !== 'all'
|
||||||
? 'Try adjusting your search or category filter'
|
? 'Try adjusting your search or category filter'
|
||||||
: 'Install your first application to get started'
|
: 'No applications available to display'
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
<Button>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
Browse App Catalog
|
|
||||||
</Button>
|
|
||||||
</Card>
|
</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>
|
</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 { Card } from './ui/card';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Server, Network, Settings, Clock, HelpCircle, CheckCircle, BookOpen, ExternalLink } from 'lucide-react';
|
import { Server, HardDrive, Settings, Clock, CheckCircle, BookOpen, ExternalLink, Loader2, AlertCircle, Database, FolderTree } from 'lucide-react';
|
||||||
import { Input, Label } from './ui';
|
import { Badge } from './ui/badge';
|
||||||
|
import { useCentralStatus } from '../hooks/useCentralStatus';
|
||||||
|
import { useInstanceConfig, useInstanceContext } from '../hooks';
|
||||||
|
|
||||||
export function CentralComponent() {
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Educational Intro Section */}
|
{/* Educational Intro Section */}
|
||||||
@@ -17,8 +56,8 @@ export function CentralComponent() {
|
|||||||
What is the Central Service?
|
What is the Central Service?
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-blue-800 dark:text-blue-200 mb-3 leading-relaxed">
|
<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
|
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 -
|
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.
|
it keeps track of what's happening, routes traffic between services, and ensures everything works together smoothly.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-blue-700 dark:text-blue-300 mb-4 text-sm">
|
<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">
|
<div className="p-2 bg-primary/10 rounded-lg">
|
||||||
<Server className="h-6 w-6 text-primary" />
|
<Server className="h-6 w-6 text-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<h2 className="text-2xl font-semibold">Central Service</h2>
|
<h2 className="text-2xl font-semibold">Central Service Status</h2>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Monitor and manage the central server service
|
Monitor the Wild Central server
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div>
|
{statusLoading || configLoading ? (
|
||||||
<h3 className="text-lg font-medium mb-4">Service Status</h3>
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Server className="h-5 w-5 text-muted-foreground" />
|
|
||||||
<span className="text-sm text-muted-foreground">IP Address: 192.168.8.50</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Network className="h-5 w-5 text-muted-foreground" />
|
|
||||||
<span className="text-sm text-muted-foreground">Network: 192.168.8.0/24</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Settings className="h-5 w-5 text-muted-foreground" />
|
|
||||||
<span className="text-sm text-muted-foreground">Version: 1.0.0 (update available)</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Clock className="h-5 w-5 text-muted-foreground" />
|
|
||||||
<span className="text-sm text-muted-foreground">Age: 12s</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<HelpCircle className="h-5 w-5 text-muted-foreground" />
|
|
||||||
<span className="text-sm text-muted-foreground">Platform: ARM</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
|
||||||
<span className="text-sm text-green-500">File permissions: Good</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
|
{/* Server Information */}
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="ip">IP</Label>
|
<h3 className="text-lg font-medium mb-4">Server Information</h3>
|
||||||
<div className="flex w-full items-center mt-1">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<Input id="ip" value="192.168.5.80"/>
|
<Card className="p-4 border-l-4 border-l-blue-500">
|
||||||
<Button variant="ghost">
|
<div className="flex items-start gap-3">
|
||||||
<HelpCircle/>
|
<Settings className="h-5 w-5 text-blue-500 mt-0.5" />
|
||||||
</Button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Configuration */}
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="interface">Interface</Label>
|
<h3 className="text-lg font-medium mb-4">Configuration</h3>
|
||||||
<div className="flex w-full items-center mt-1">
|
<div className="space-y-3">
|
||||||
<Input id="interface" value="eth0"/>
|
<Card className="p-4 border-l-4 border-l-cyan-500">
|
||||||
<Button variant="ghost">
|
<div className="flex items-start gap-3">
|
||||||
<HelpCircle/>
|
<Server className="h-5 w-5 text-cyan-500 mt-0.5" />
|
||||||
</Button>
|
<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>
|
||||||
</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>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,171 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Card } from "./ui/card";
|
import { Card } from "./ui/card";
|
||||||
import { Button } from "./ui/button";
|
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 { 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() {
|
export function CloudComponent() {
|
||||||
const [domainValue, setDomainValue] = useState("cloud.payne.io");
|
const { currentInstance } = useInstanceContext();
|
||||||
const [internalDomainValue, setInternalDomainValue] = useState(
|
const { config: fullConfig, isLoading, error, updateConfig, isUpdating } = useInstanceConfig(currentInstance);
|
||||||
"internal.cloud.payne.io"
|
|
||||||
);
|
// Extract cloud config from full config
|
||||||
|
const config = fullConfig?.cloud as CloudConfig | undefined;
|
||||||
|
|
||||||
const [editingDomains, setEditingDomains] = useState(false);
|
const [editingDomains, setEditingDomains] = useState(false);
|
||||||
|
const [editingNetwork, setEditingNetwork] = useState(false);
|
||||||
|
const [formValues, setFormValues] = useState<CloudConfig | null>(null);
|
||||||
|
|
||||||
const [tempDomain, setTempDomain] = useState(domainValue);
|
// Sync form values when config loads
|
||||||
const [tempInternalDomain, setTempInternalDomain] =
|
useEffect(() => {
|
||||||
useState(internalDomainValue);
|
if (config && !formValues) {
|
||||||
|
setFormValues(config as CloudConfig);
|
||||||
|
}
|
||||||
|
}, [config, formValues]);
|
||||||
|
|
||||||
const handleDomainsEdit = () => {
|
const handleDomainsEdit = () => {
|
||||||
setTempDomain(domainValue);
|
if (config) {
|
||||||
setTempInternalDomain(internalDomainValue);
|
setFormValues(config as CloudConfig);
|
||||||
setEditingDomains(true);
|
setEditingDomains(true);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDomainsSave = () => {
|
const handleNetworkEdit = () => {
|
||||||
setDomainValue(tempDomain);
|
if (config) {
|
||||||
setInternalDomainValue(tempInternalDomain);
|
setFormValues(config as CloudConfig);
|
||||||
setEditingDomains(false);
|
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 = () => {
|
const handleDomainsCancel = () => {
|
||||||
setTempDomain(domainValue);
|
setFormValues(config as CloudConfig);
|
||||||
setTempInternalDomain(internalDomainValue);
|
|
||||||
setEditingDomains(false);
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
@@ -51,7 +183,7 @@ export function CloudComponent() {
|
|||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Domains Section */}
|
{/* 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 className="flex items-center justify-between mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium">Domain Configuration</h3>
|
<h3 className="font-medium">Domain Configuration</h3>
|
||||||
@@ -68,6 +200,7 @@ export function CloudComponent() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleDomainsEdit}
|
onClick={handleDomainsEdit}
|
||||||
|
disabled={isUpdating}
|
||||||
>
|
>
|
||||||
<Edit2 className="h-4 w-4 mr-1" />
|
<Edit2 className="h-4 w-4 mr-1" />
|
||||||
Edit
|
Edit
|
||||||
@@ -82,8 +215,8 @@ export function CloudComponent() {
|
|||||||
<Label htmlFor="domain-edit">Public Domain</Label>
|
<Label htmlFor="domain-edit">Public Domain</Label>
|
||||||
<Input
|
<Input
|
||||||
id="domain-edit"
|
id="domain-edit"
|
||||||
value={tempDomain}
|
value={formValues.domain}
|
||||||
onChange={(e) => setTempDomain(e.target.value)}
|
onChange={(e) => updateFormValue('domain', e.target.value)}
|
||||||
placeholder="example.com"
|
placeholder="example.com"
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
@@ -92,21 +225,26 @@ export function CloudComponent() {
|
|||||||
<Label htmlFor="internal-domain-edit">Internal Domain</Label>
|
<Label htmlFor="internal-domain-edit">Internal Domain</Label>
|
||||||
<Input
|
<Input
|
||||||
id="internal-domain-edit"
|
id="internal-domain-edit"
|
||||||
value={tempInternalDomain}
|
value={formValues.internalDomain}
|
||||||
onChange={(e) => setTempInternalDomain(e.target.value)}
|
onChange={(e) => updateFormValue('internalDomain', e.target.value)}
|
||||||
placeholder="internal.example.com"
|
placeholder="internal.example.com"
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button size="sm" onClick={handleDomainsSave}>
|
<Button size="sm" onClick={handleDomainsSave} disabled={isUpdating}>
|
||||||
<Check className="h-4 w-4 mr-1" />
|
{isUpdating ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check className="h-4 w-4 mr-1" />
|
||||||
|
)}
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleDomainsCancel}
|
onClick={handleDomainsCancel}
|
||||||
|
disabled={isUpdating}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4 mr-1" />
|
<X className="h-4 w-4 mr-1" />
|
||||||
Cancel
|
Cancel
|
||||||
@@ -118,13 +256,135 @@ export function CloudComponent() {
|
|||||||
<div>
|
<div>
|
||||||
<Label>Public Domain</Label>
|
<Label>Public Domain</Label>
|
||||||
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
|
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
|
||||||
{domainValue}
|
{formValues.domain}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Internal Domain</Label>
|
<Label>Internal Domain</Label>
|
||||||
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,151 +2,145 @@ import { useState } from 'react';
|
|||||||
import { Card } from './ui/card';
|
import { Card } from './ui/card';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Badge } from './ui/badge';
|
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 {
|
export function ClusterNodesComponent() {
|
||||||
onComplete?: () => void;
|
const { currentInstance } = useInstanceContext();
|
||||||
}
|
const {
|
||||||
|
nodes,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
addNode,
|
||||||
|
isAdding,
|
||||||
|
deleteNode,
|
||||||
|
isDeleting,
|
||||||
|
discover,
|
||||||
|
isDiscovering,
|
||||||
|
detect,
|
||||||
|
isDetecting
|
||||||
|
} = useNodes(currentInstance);
|
||||||
|
|
||||||
interface Node {
|
const {
|
||||||
id: string;
|
data: discoveryStatus
|
||||||
name: string;
|
} = useDiscoveryStatus(currentInstance);
|
||||||
type: 'controller' | 'worker' | 'unassigned';
|
|
||||||
status: 'pending' | 'connecting' | 'connected' | 'healthy' | 'error';
|
|
||||||
ipAddress?: string;
|
|
||||||
macAddress: string;
|
|
||||||
osVersion?: string;
|
|
||||||
specs: {
|
|
||||||
cpu: string;
|
|
||||||
memory: string;
|
|
||||||
storage: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ClusterNodesComponent({ onComplete }: ClusterNodesComponentProps) {
|
const [subnet, setSubnet] = useState('192.168.1.0/24');
|
||||||
const [currentOsVersion, setCurrentOsVersion] = useState('v13.0.5');
|
|
||||||
const [nodes, setNodes] = useState<Node[]>([
|
|
||||||
{
|
|
||||||
id: 'controller-1',
|
|
||||||
name: 'Controller Node 1',
|
|
||||||
type: 'controller',
|
|
||||||
status: 'healthy',
|
|
||||||
macAddress: '00:1A:2B:3C:4D:5E',
|
|
||||||
osVersion: 'v13.0.4',
|
|
||||||
specs: {
|
|
||||||
cpu: '4 cores',
|
|
||||||
memory: '8GB RAM',
|
|
||||||
storage: '120GB SSD',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'worker-1',
|
|
||||||
name: 'Worker Node 1',
|
|
||||||
type: 'worker',
|
|
||||||
status: 'healthy',
|
|
||||||
macAddress: '00:1A:2B:3C:4D:5F',
|
|
||||||
osVersion: 'v13.0.5',
|
|
||||||
specs: {
|
|
||||||
cpu: '8 cores',
|
|
||||||
memory: '16GB RAM',
|
|
||||||
storage: '500GB SSD',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'worker-2',
|
|
||||||
name: 'Worker Node 2',
|
|
||||||
type: 'worker',
|
|
||||||
status: 'healthy',
|
|
||||||
macAddress: '00:1A:2B:3C:4D:60',
|
|
||||||
osVersion: 'v13.0.4',
|
|
||||||
specs: {
|
|
||||||
cpu: '8 cores',
|
|
||||||
memory: '16GB RAM',
|
|
||||||
storage: '500GB SSD',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'node-1',
|
|
||||||
name: 'Node 1',
|
|
||||||
type: 'unassigned',
|
|
||||||
status: 'pending',
|
|
||||||
macAddress: '00:1A:2B:3C:4D:5E',
|
|
||||||
osVersion: 'v13.0.5',
|
|
||||||
specs: {
|
|
||||||
cpu: '4 cores',
|
|
||||||
memory: '8GB RAM',
|
|
||||||
storage: '120GB SSD',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'node-2',
|
|
||||||
name: 'Node 2',
|
|
||||||
type: 'unassigned',
|
|
||||||
status: 'pending',
|
|
||||||
macAddress: '00:1A:2B:3C:4D:5F',
|
|
||||||
osVersion: 'v13.0.5',
|
|
||||||
specs: {
|
|
||||||
cpu: '8 cores',
|
|
||||||
memory: '16GB RAM',
|
|
||||||
storage: '500GB SSD',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const getStatusIcon = (status: Node['status']) => {
|
const getStatusIcon = (status?: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'connected':
|
case 'ready':
|
||||||
|
case 'healthy':
|
||||||
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
||||||
case 'error':
|
case 'error':
|
||||||
return <AlertCircle className="h-5 w-5 text-red-500" />;
|
return <AlertCircle className="h-5 w-5 text-red-500" />;
|
||||||
case 'connecting':
|
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:
|
default:
|
||||||
return <Monitor className="h-5 w-5 text-muted-foreground" />;
|
return <Monitor className="h-5 w-5 text-muted-foreground" />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusBadge = (status: Node['status']) => {
|
const getStatusBadge = (status?: string) => {
|
||||||
const variants = {
|
const variants: Record<string, 'secondary' | 'default' | 'success' | 'destructive'> = {
|
||||||
pending: 'secondary',
|
pending: 'secondary',
|
||||||
connecting: 'default',
|
connecting: 'default',
|
||||||
connected: 'success',
|
provisioning: 'default',
|
||||||
|
ready: 'success',
|
||||||
healthy: 'success',
|
healthy: 'success',
|
||||||
error: 'destructive',
|
error: 'destructive',
|
||||||
} as const;
|
};
|
||||||
|
|
||||||
const labels = {
|
const labels: Record<string, string> = {
|
||||||
pending: 'Pending',
|
pending: 'Pending',
|
||||||
connecting: 'Connecting',
|
connecting: 'Connecting',
|
||||||
connected: 'Connected',
|
provisioning: 'Provisioning',
|
||||||
|
ready: 'Ready',
|
||||||
healthy: 'Healthy',
|
healthy: 'Healthy',
|
||||||
error: 'Error',
|
error: 'Error',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge variant={variants[status] as any}>
|
<Badge variant={variants[status || 'pending']}>
|
||||||
{labels[status]}
|
{labels[status || 'pending'] || status}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTypeIcon = (type: Node['type']) => {
|
const getRoleIcon = (role: string) => {
|
||||||
return type === 'controller' ? (
|
return role === 'controlplane' ? (
|
||||||
<Cpu className="h-4 w-4" />
|
<Cpu className="h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
<HardDrive className="h-4 w-4" />
|
<HardDrive className="h-4 w-4" />
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNodeAction = (nodeId: string, action: 'connect' | 'retry' | 'upgrade_node') => {
|
const handleAddNode = (ip: string, hostname: string, role: 'controlplane' | 'worker') => {
|
||||||
console.log(`${action} node: ${nodeId}`);
|
if (!currentInstance) return;
|
||||||
|
addNode({ target_ip: ip, hostname, role, disk: '/dev/sda' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const connectedNodes = nodes.filter(node => node.status === 'connected').length;
|
const handleDeleteNode = (hostname: string) => {
|
||||||
const assignedNodes = nodes.filter(node => node.type !== 'unassigned');
|
if (!currentInstance) return;
|
||||||
const unassignedNodes = nodes.filter(node => node.type === 'unassigned');
|
if (confirm(`Are you sure you want to remove node ${hostname}?`)) {
|
||||||
const totalNodes = nodes.length;
|
deleteNode(hostname);
|
||||||
const isComplete = connectedNodes === totalNodes;
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -190,148 +184,148 @@ export function ClusterNodesComponent({ onComplete }: ClusterNodesComponentProps
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
{isLoading ? (
|
||||||
<h2 className="text-lg font-medium mb-4">Assigned Nodes ({assignedNodes.length}/{totalNodes})</h2>
|
<Card className="p-8 text-center">
|
||||||
{assignedNodes.map((node) => (
|
<Loader2 className="h-12 w-12 text-primary mx-auto mb-4 animate-spin" />
|
||||||
<Card key={node.id} className="p-4">
|
<p className="text-muted-foreground">Loading nodes...</p>
|
||||||
<div className="flex items-center gap-4">
|
</Card>
|
||||||
<div className="p-2 bg-muted rounded-lg">
|
) : (
|
||||||
{getTypeIcon(node.type)}
|
<>
|
||||||
|
<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>
|
||||||
<div className="flex-1">
|
</div>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<h4 className="font-medium">{node.name}</h4>
|
{assignedNodes.map((node) => (
|
||||||
<Badge variant="outline" className="text-xs">
|
<Card key={node.hostname} className="p-4">
|
||||||
{node.type}
|
<div className="flex items-center gap-4">
|
||||||
</Badge>
|
<div className="p-2 bg-muted rounded-lg">
|
||||||
{getStatusIcon(node.status)}
|
{getRoleIcon(node.role)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground mb-2">
|
<div className="flex-1">
|
||||||
MAC: {node.macAddress}
|
<div className="flex items-center gap-2 mb-1">
|
||||||
{node.ipAddress && ` • IP: ${node.ipAddress}`}
|
<h4 className="font-medium">{node.hostname}</h4>
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Cpu className="h-3 w-3" />
|
|
||||||
{node.specs.cpu}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Monitor className="h-3 w-3" />
|
|
||||||
{node.specs.memory}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<HardDrive className="h-3 w-3" />
|
|
||||||
{node.specs.storage}
|
|
||||||
</span>
|
|
||||||
{node.osVersion && (
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
OS: {node.osVersion}
|
{node.role}
|
||||||
</Badge>
|
</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>
|
</Card>
|
||||||
<div className="flex items-center gap-3">
|
))}
|
||||||
{getStatusBadge(node.status)}
|
|
||||||
{node.osVersion !== currentOsVersion && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleNodeAction(node.id, 'upgrade_node')}
|
|
||||||
>
|
|
||||||
Upgrade OS
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{node.status === 'error' && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleNodeAction(node.id, 'retry')}
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 className="text-lg font-medium mb-4 mt-6">Unassigned Nodes ({unassignedNodes.length}/{totalNodes})</h2>
|
{assignedNodes.length === 0 && (
|
||||||
<div className="space-y-4">
|
<Card className="p-8 text-center">
|
||||||
{unassignedNodes.map((node) => (
|
<Network className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
<Card key={node.id} className="p-4">
|
<h3 className="text-lg font-medium mb-2">No Nodes</h3>
|
||||||
<div className="flex items-center gap-4">
|
<p className="text-muted-foreground mb-4">
|
||||||
<div className="p-2 bg-muted rounded-lg">
|
Use the discover or auto-detect buttons above to find nodes on your network.
|
||||||
{getTypeIcon(node.type)}
|
</p>
|
||||||
</div>
|
</Card>
|
||||||
<div className="flex-1">
|
)}
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<h4 className="font-medium">{node.name}</h4>
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{node.type}
|
|
||||||
</Badge>
|
|
||||||
{getStatusIcon(node.status)}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground mb-2">
|
|
||||||
MAC: {node.macAddress}
|
|
||||||
{node.ipAddress && ` • IP: ${node.ipAddress}`}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Cpu className="h-3 w-3" />
|
|
||||||
{node.specs.cpu}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Monitor className="h-3 w-3" />
|
|
||||||
{node.specs.memory}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<HardDrive className="h-3 w-3" />
|
|
||||||
{node.specs.storage}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{getStatusBadge(node.status)}
|
|
||||||
{node.status === 'pending' && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleNodeAction(node.id, 'connect')}
|
|
||||||
>
|
|
||||||
Assign
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{node.status === 'error' && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleNodeAction(node.id, 'retry')}
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isComplete && (
|
|
||||||
<div className="mt-6 p-4 bg-green-50 dark:bg-green-950 rounded-lg border border-green-200 dark:border-green-800">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
|
||||||
<h3 className="font-medium text-green-800 dark:text-green-200">
|
|
||||||
Infrastructure Ready!
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-green-700 dark:text-green-300 mb-3">
|
|
||||||
All nodes are connected and ready for Kubernetes installation.
|
{discoveredIps.length > 0 && (
|
||||||
</p>
|
<div className="mt-6">
|
||||||
<Button onClick={onComplete} className="bg-green-600 hover:bg-green-700">
|
<h3 className="text-lg font-medium mb-4">Discovered IPs ({discoveredIps.length})</h3>
|
||||||
Continue to Kubernetes Installation
|
<div className="space-y-2">
|
||||||
</Button>
|
{discoveredIps.map((ip) => (
|
||||||
</div>
|
<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>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -1,128 +1,128 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Card } from './ui/card';
|
import { Card } from './ui/card';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Badge } from './ui/badge';
|
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 {
|
export function ClusterServicesComponent() {
|
||||||
onComplete?: () => void;
|
const { currentInstance } = useInstanceContext();
|
||||||
}
|
const {
|
||||||
|
services,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
installService,
|
||||||
|
isInstalling,
|
||||||
|
installAll,
|
||||||
|
isInstallingAll,
|
||||||
|
deleteService,
|
||||||
|
isDeleting
|
||||||
|
} = useServices(currentInstance);
|
||||||
|
|
||||||
interface ClusterComponent {
|
const getStatusIcon = (status?: string) => {
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
status: 'pending' | 'installing' | 'ready' | 'error';
|
|
||||||
version?: string;
|
|
||||||
logs?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ClusterServicesComponent({ onComplete }: ClusterServicesComponentProps) {
|
|
||||||
const [components, setComponents] = useState<ClusterComponent[]>([
|
|
||||||
{
|
|
||||||
id: 'talos-config',
|
|
||||||
name: 'Talos Configuration',
|
|
||||||
description: 'Generate and apply Talos cluster configuration',
|
|
||||||
status: 'pending',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'kubernetes-bootstrap',
|
|
||||||
name: 'Kubernetes Bootstrap',
|
|
||||||
description: 'Initialize Kubernetes control plane',
|
|
||||||
status: 'pending',
|
|
||||||
version: 'v1.29.0',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'cni-plugin',
|
|
||||||
name: 'Container Network Interface',
|
|
||||||
description: 'Install and configure Cilium CNI',
|
|
||||||
status: 'pending',
|
|
||||||
version: 'v1.14.5',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'storage-class',
|
|
||||||
name: 'Storage Classes',
|
|
||||||
description: 'Configure persistent volume storage',
|
|
||||||
status: 'pending',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'ingress-controller',
|
|
||||||
name: 'Ingress Controller',
|
|
||||||
description: 'Install Traefik ingress controller',
|
|
||||||
status: 'pending',
|
|
||||||
version: 'v3.0.0',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'monitoring',
|
|
||||||
name: 'Cluster Monitoring',
|
|
||||||
description: 'Deploy Prometheus and Grafana stack',
|
|
||||||
status: 'pending',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const [showLogs, setShowLogs] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const getStatusIcon = (status: ClusterComponent['status']) => {
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
|
case 'running':
|
||||||
case 'ready':
|
case 'ready':
|
||||||
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
||||||
case 'error':
|
case 'error':
|
||||||
return <AlertCircle className="h-5 w-5 text-red-500" />;
|
return <AlertCircle className="h-5 w-5 text-red-500" />;
|
||||||
|
case 'deploying':
|
||||||
case 'installing':
|
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:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusBadge = (status: ClusterComponent['status']) => {
|
const getStatusBadge = (service: Service) => {
|
||||||
const variants = {
|
const status = service.status?.status || (service.deployed ? 'deployed' : 'available');
|
||||||
pending: 'secondary',
|
|
||||||
|
const variants: Record<string, 'secondary' | 'default' | 'success' | 'destructive' | 'outline'> = {
|
||||||
|
available: 'secondary',
|
||||||
|
deploying: 'default',
|
||||||
installing: 'default',
|
installing: 'default',
|
||||||
|
running: 'success',
|
||||||
ready: 'success',
|
ready: 'success',
|
||||||
error: 'destructive',
|
error: 'destructive',
|
||||||
} as const;
|
deployed: 'outline',
|
||||||
|
};
|
||||||
|
|
||||||
const labels = {
|
const labels: Record<string, string> = {
|
||||||
pending: 'Pending',
|
available: 'Available',
|
||||||
|
deploying: 'Deploying',
|
||||||
installing: 'Installing',
|
installing: 'Installing',
|
||||||
|
running: 'Running',
|
||||||
ready: 'Ready',
|
ready: 'Ready',
|
||||||
error: 'Error',
|
error: 'Error',
|
||||||
|
deployed: 'Deployed',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge variant={variants[status] as any}>
|
<Badge variant={variants[status]}>
|
||||||
{labels[status]}
|
{labels[status] || status}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getComponentIcon = (id: string) => {
|
const getServiceIcon = (name: string) => {
|
||||||
switch (id) {
|
const lowerName = name.toLowerCase();
|
||||||
case 'talos-config':
|
if (lowerName.includes('network') || lowerName.includes('cni') || lowerName.includes('cilium')) {
|
||||||
return <FileText className="h-5 w-5" />;
|
return <Network className="h-5 w-5" />;
|
||||||
case 'kubernetes-bootstrap':
|
} else if (lowerName.includes('storage') || lowerName.includes('volume')) {
|
||||||
return <Container className="h-5 w-5" />;
|
return <Database className="h-5 w-5" />;
|
||||||
case 'cni-plugin':
|
} else if (lowerName.includes('ingress') || lowerName.includes('traefik') || lowerName.includes('nginx')) {
|
||||||
return <Network className="h-5 w-5" />;
|
return <Shield className="h-5 w-5" />;
|
||||||
case 'storage-class':
|
} else if (lowerName.includes('monitor') || lowerName.includes('prometheus') || lowerName.includes('grafana')) {
|
||||||
return <Database className="h-5 w-5" />;
|
return <Terminal className="h-5 w-5" />;
|
||||||
case 'ingress-controller':
|
} else {
|
||||||
return <Shield className="h-5 w-5" />;
|
return <Container className="h-5 w-5" />;
|
||||||
case 'monitoring':
|
|
||||||
return <Terminal className="h-5 w-5" />;
|
|
||||||
default:
|
|
||||||
return <Container className="h-5 w-5" />;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleComponentAction = (componentId: string, action: 'install' | 'retry') => {
|
const handleInstallService = (serviceName: string) => {
|
||||||
console.log(`${action} component: ${componentId}`);
|
if (!currentInstance) return;
|
||||||
|
installService({ name: serviceName });
|
||||||
};
|
};
|
||||||
|
|
||||||
const readyComponents = components.filter(component => component.status === 'ready').length;
|
const handleDeleteService = (serviceName: string) => {
|
||||||
const totalComponents = components.length;
|
if (!currentInstance) return;
|
||||||
const isComplete = readyComponents === totalComponents;
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -167,108 +167,91 @@ export function ClusterServicesComponent({ onComplete }: ClusterServicesComponen
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<pre className="text-xs text-muted-foreground bg-muted p-2 rounded-lg">
|
<div className="text-sm text-muted-foreground">
|
||||||
endpoint: civil<br/>
|
{isLoading ? (
|
||||||
endpointIp: 192.168.8.240<br/>
|
<span className="flex items-center gap-2">
|
||||||
kubernetes:<br/>
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
config: /home/payne/.kube/config<br/>
|
Loading services...
|
||||||
context: default<br/>
|
</span>
|
||||||
loadBalancerRange: 192.168.8.240-192.168.8.250<br/>
|
) : (
|
||||||
dashboard:<br/>
|
`${services.length} services available`
|
||||||
adminUsername: admin<br/>
|
)}
|
||||||
certManager:<br/>
|
</div>
|
||||||
namespace: cert-manager<br/>
|
<Button
|
||||||
cloudflare:<br/>
|
size="sm"
|
||||||
domain: payne.io<br/>
|
onClick={handleInstallAll}
|
||||||
ownerId: cloud-payne-io-cluster<br/>
|
disabled={isInstallingAll || services.length === 0}
|
||||||
</pre>
|
>
|
||||||
</div>
|
{isInstallingAll ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
|
) : null}
|
||||||
<div className="space-y-4">
|
Install All
|
||||||
{components.map((component) => (
|
</Button>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{isComplete && (
|
{isLoading ? (
|
||||||
<div className="mt-6 p-4 bg-green-50 dark:bg-green-950 rounded-lg border border-green-200 dark:border-green-800">
|
<Card className="p-8 text-center">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<Loader2 className="h-12 w-12 text-primary mx-auto mb-4 animate-spin" />
|
||||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
<p className="text-muted-foreground">Loading services...</p>
|
||||||
<h3 className="font-medium text-green-800 dark:text-green-200">
|
</Card>
|
||||||
Kubernetes Cluster Ready!
|
) : (
|
||||||
</h3>
|
<div className="space-y-4">
|
||||||
</div>
|
{services.map((service) => (
|
||||||
<p className="text-sm text-green-700 dark:text-green-300 mb-3">
|
<div key={service.name}>
|
||||||
Your Kubernetes cluster is fully configured and ready for application deployment.
|
<div className="flex items-center gap-4 p-4 rounded-lg border bg-card">
|
||||||
</p>
|
<div className="p-2 bg-muted rounded-lg">
|
||||||
<Button onClick={onComplete} className="bg-green-600 hover:bg-green-700">
|
{getServiceIcon(service.name)}
|
||||||
Continue to App Management
|
</div>
|
||||||
</Button>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Settings, Save, X } from 'lucide-react';
|
import { Settings } from 'lucide-react';
|
||||||
import { useConfigYaml } from '../hooks';
|
import { useConfigYaml } from '../hooks';
|
||||||
import { Button, Textarea } from './ui';
|
import { Button, Textarea } from './ui';
|
||||||
import {
|
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 { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { FileText, Check, AlertCircle, Loader2 } from 'lucide-react';
|
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 { configFormSchema, defaultConfigValues, type ConfigFormData } from '../schemas/config';
|
||||||
import {
|
import {
|
||||||
Card,
|
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>
|
<div>
|
||||||
<Label htmlFor="dhcpRange">IP Range</Label>
|
<Label htmlFor="dhcpRange">IP Range</Label>
|
||||||
<div className="flex w-full items-center mt-1">
|
<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">
|
<Button variant="ghost">
|
||||||
<HelpCircle/>
|
<HelpCircle/>
|
||||||
</Button>
|
</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 { PxeComponent } from './PxeComponent';
|
||||||
export { ClusterNodesComponent } from './ClusterNodesComponent';
|
export { ClusterNodesComponent } from './ClusterNodesComponent';
|
||||||
export { ClusterServicesComponent } from './ClusterServicesComponent';
|
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",
|
"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:
|
outline:
|
||||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
"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: {
|
defaultVariants: {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { renderHook, waitFor, act } from '@testing-library/react';
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useConfig } from '../useConfig';
|
import { useConfig } from '../useConfig';
|
||||||
import { apiService } from '../../services/api';
|
import { apiService } from '../../services/api-legacy';
|
||||||
|
|
||||||
// Mock the API service
|
// Mock the API service
|
||||||
vi.mock('../../services/api', () => ({
|
vi.mock('../../services/api', () => ({
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { renderHook, waitFor } from '@testing-library/react';
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useStatus } from '../useStatus';
|
import { useStatus } from '../useStatus';
|
||||||
import { apiService } from '../../services/api';
|
import { apiService } from '../../services/api-legacy';
|
||||||
|
|
||||||
// Mock the API service
|
// Mock the API service
|
||||||
vi.mock('../../services/api', () => ({
|
vi.mock('../../services/api', () => ({
|
||||||
|
|||||||
@@ -4,4 +4,17 @@ export { useHealth } from './useHealth';
|
|||||||
export { useConfig } from './useConfig';
|
export { useConfig } from './useConfig';
|
||||||
export { useConfigYaml } from './useConfigYaml';
|
export { useConfigYaml } from './useConfigYaml';
|
||||||
export { useDnsmasq } from './useDnsmasq';
|
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 { useMutation } from '@tanstack/react-query';
|
||||||
import { apiService } from '../services/api';
|
import { apiService } from '../services/api-legacy';
|
||||||
|
|
||||||
interface AssetsResponse {
|
interface AssetsResponse {
|
||||||
status: string;
|
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 { useState, useEffect } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { apiService } from '../services/api';
|
import { apiService } from '../services/api-legacy';
|
||||||
import type { Config } from '../types';
|
import type { Config } from '../types';
|
||||||
|
|
||||||
interface ConfigResponse {
|
interface ConfigResponse {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { apiService } from '../services/api';
|
import { apiService } from '../services/api-legacy';
|
||||||
|
|
||||||
export const useConfigYaml = () => {
|
export const useConfigYaml = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { apiService } from '../services/api';
|
import { apiService } from '../services/api-legacy';
|
||||||
|
|
||||||
interface DnsmasqResponse {
|
interface DnsmasqResponse {
|
||||||
status: string;
|
status: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { apiService } from '../services/api';
|
import { apiService } from '../services/api-legacy';
|
||||||
|
|
||||||
interface HealthResponse {
|
interface HealthResponse {
|
||||||
service: string;
|
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 { useQuery } from '@tanstack/react-query';
|
||||||
import { apiService } from '../services/api';
|
import { apiService } from '../services/api-legacy';
|
||||||
import type { Status } from '../types';
|
import type { Status } from '../types';
|
||||||
|
|
||||||
export const useStatus = () => {
|
export const useStatus = () => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { QueryClientProvider } from '@tanstack/react-query';
|
|||||||
import './index.css';
|
import './index.css';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import { ThemeProvider } from './contexts/ThemeContext';
|
import { ThemeProvider } from './contexts/ThemeContext';
|
||||||
|
import { InstanceProvider } from './hooks';
|
||||||
import { queryClient } from './lib/queryClient';
|
import { queryClient } from './lib/queryClient';
|
||||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||||
|
|
||||||
@@ -15,9 +16,11 @@ root.render(
|
|||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ThemeProvider defaultTheme="light" storageKey="wild-central-theme">
|
<InstanceProvider>
|
||||||
<App />
|
<ThemeProvider defaultTheme="light" storageKey="wild-central-theme">
|
||||||
</ThemeProvider>
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
|
</InstanceProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</StrictMode>
|
</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';
|
// Re-export everything from the modular API structure
|
||||||
|
// This file maintains backward compatibility for imports from '../services/api'
|
||||||
const API_BASE = 'http://localhost:5055';
|
export * from './api/index';
|
||||||
|
|
||||||
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;
|
|
||||||
|
|||||||
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