Compare commits
22 Commits
33454bc4e1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebf3612c62 | ||
|
|
b324540ce0 | ||
|
|
6bbf48fe20 | ||
|
|
4307bc9996 | ||
|
|
35bc44bc32 | ||
|
|
a63519968e | ||
|
|
960282d4ed | ||
|
|
854a6023cd | ||
|
|
ee63423cab | ||
|
|
dfc7694fb9 | ||
|
|
2469acbc88 | ||
|
|
6f438901e0 | ||
|
|
35296b3bd2 | ||
|
|
1d2f0b7891 | ||
|
|
5260373fee | ||
|
|
684f29ba4f | ||
|
|
4cb8b11e59 | ||
|
|
fe226dafef | ||
|
|
f1a01f5ba4 | ||
|
|
24965d2b88 | ||
|
|
331777c5fd | ||
|
|
e5bd3c36f5 |
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
# Wild Cloud API Configuration
|
||||
# This should point to your Wild Central API server
|
||||
VITE_API_BASE_URL=http://localhost:5055
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -12,6 +12,9 @@ dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
|
||||
325
BUILDING_WILD_APP.md
Normal file
325
BUILDING_WILD_APP.md
Normal file
@@ -0,0 +1,325 @@
|
||||
# Building Wild App
|
||||
|
||||
This document describes the architecture and tooling used to build the Wild App, the web-based interface for managing Wild Cloud instances, hosted on Wild Central.
|
||||
|
||||
## Principles
|
||||
|
||||
- Stick with well known standards.
|
||||
- Keep it simple.
|
||||
- Use popular, well-maintained libraries.
|
||||
- Use components wherever possible to avoid reinventing the wheel.
|
||||
- Use TypeScript for type safety.
|
||||
|
||||
### Tooling
|
||||
## Dev Environment Requirements
|
||||
|
||||
- Node.js 20+
|
||||
- pnpm for package management
|
||||
- vite for build tooling
|
||||
- React + TypeScript
|
||||
- Tailwind CSS for styling
|
||||
- shadcn/ui for ready-made components
|
||||
- radix-ui for accessible components
|
||||
- eslint for linting
|
||||
- tsc for type checking
|
||||
- vitest for unit tests
|
||||
|
||||
#### Makefile commands
|
||||
|
||||
- Build application: `make app-build`
|
||||
- Run locally: `make app-run`
|
||||
- Format code: `make app-fmt`
|
||||
- Lint and typecheck: `make app-check`
|
||||
- Test installation: `make app-test`
|
||||
|
||||
### Scaffolding apps
|
||||
|
||||
It is important to start an app with a good base structure to avoid difficult to debug config issues.
|
||||
|
||||
This is a recommended setup.
|
||||
|
||||
#### Install pnpm if necessary:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://get.pnpm.io/install.sh | sh -
|
||||
```
|
||||
|
||||
#### Install a React + Speedy Web Compiler (SWC) + TypeScript + TailwindCSS app with vite:
|
||||
|
||||
```
|
||||
pnpm create vite@latest my-app -- --template react-swc-ts
|
||||
```
|
||||
|
||||
#### Reconfigure to use shadcn/ui (radix + tailwind components) (see https://ui.shadcn.com/docs/installation/vite)
|
||||
|
||||
##### Add tailwind.
|
||||
|
||||
```bash
|
||||
pnpm add tailwindcss @tailwindcss/vite
|
||||
```
|
||||
|
||||
##### Replace everything in src/index.css with a tailwind import:
|
||||
|
||||
```bash
|
||||
echo "@import \"tailwindcss\";" > src/index.css
|
||||
```
|
||||
|
||||
##### Edit tsconfig files
|
||||
|
||||
The current version of Vite splits TypeScript configuration into three files, two of which need to be edited. Add the baseUrl and paths properties to the compilerOptions section of the tsconfig.json and tsconfig.app.json files:
|
||||
|
||||
tsconfig.json
|
||||
|
||||
```json
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
tsconfig.app.json
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
// ...
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
##### Update vite.config.ts
|
||||
|
||||
```bash
|
||||
pnpm add -D @types/node
|
||||
```
|
||||
Then edit vite.config.ts to include the node types:
|
||||
|
||||
```ts
|
||||
import path from "path"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
##### Run the cli
|
||||
|
||||
```bash
|
||||
pnpm dlx shadcn@latest init
|
||||
```
|
||||
|
||||
##### Add components
|
||||
|
||||
```bash
|
||||
pnpm dlx shadcn@latest add button
|
||||
pnpm dlx shadcn@latest add alert-dialog
|
||||
```
|
||||
|
||||
You can then use components with `import { Button } from "@/components/ui/button"`
|
||||
|
||||
### UX Principles
|
||||
|
||||
These principles ensure consistent, intuitive interfaces that align with Wild Cloud's philosophy of simplicity and clarity. Use them as quality control when building new components.
|
||||
|
||||
#### Navigation & Structure
|
||||
|
||||
- **Use shadcn AppSideBar** as the main navigation: https://ui.shadcn.com/docs/components/sidebar
|
||||
- **Card-Based Layout**: Group related content in Card components
|
||||
- Primary cards: `p-6` padding
|
||||
- Nested cards: `p-4` padding with subtle shadows
|
||||
- Use cards to create visual hierarchy through nesting
|
||||
- **Spacing Rhythm**: Maintain consistent vertical spacing
|
||||
- Major sections: `space-y-6`
|
||||
- Related items: `space-y-4`
|
||||
- Form fields: `space-y-3`
|
||||
- Inline elements: `gap-2`, `gap-3`, or `gap-4`
|
||||
|
||||
#### Visual Design
|
||||
|
||||
- **Dark Mode**: Support both light and dark modes using Tailwind's `dark:` prefix
|
||||
- Test all components in both modes for contrast and readability
|
||||
- Use semantic color tokens that adapt to theme
|
||||
- **Status Color System**: Use semantic left border colors to categorize content
|
||||
- Blue (`border-l-blue-500`): Configuration sections
|
||||
- Green (`border-l-green-500`): Network/infrastructure
|
||||
- Red (`border-l-red-500`): Errors and warnings
|
||||
- Cyan: Educational content
|
||||
- **Icon-Text Pairing**: Pair important text with Lucide icons
|
||||
- Place icons in colored containers: `p-2 bg-primary/10 rounded-lg`
|
||||
- Provides visual anchors and improves scannability
|
||||
- **Technical Data Display**: Show technical information clearly
|
||||
- Use `font-mono` class for IPs, domains, configuration values
|
||||
- Display in `bg-muted rounded-md p-2` containers
|
||||
|
||||
#### Component Patterns
|
||||
|
||||
- **Edit/View Mode Toggle**: For configuration sections
|
||||
- Read-only: Display in `bg-muted rounded-md font-mono` containers with Edit button
|
||||
- Edit mode: Replace with form inputs in-place
|
||||
- Provides lightweight editing without context switching
|
||||
- **Drawers for Complex Forms**: Use side panels for detailed input
|
||||
- Maintains context with main content
|
||||
- Better than modals for forms that benefit from seeing related data
|
||||
- **Educational Content**: Use gradient cards for helpful information
|
||||
- Background: `from-cyan-50 to-blue-50 dark:from-cyan-900/20 dark:to-blue-900/20`
|
||||
- Include book icon and clear, concise guidance
|
||||
- Makes learning feel integrated, not intrusive
|
||||
- **Empty States**: Center content with clear next actions
|
||||
- Large icon: `h-12 w-12 text-muted-foreground`
|
||||
- Descriptive title and explanation
|
||||
- Suggest action to resolve empty state
|
||||
|
||||
#### Section Headers
|
||||
|
||||
Structure all major section headers consistently:
|
||||
|
||||
```tsx
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<IconComponent className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">Section Title</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Brief description of section purpose
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Status & Feedback
|
||||
|
||||
- **Status Badges**: Use colored badges with icons for state indication
|
||||
- Keep compact but descriptive
|
||||
- Include hover/expansion for additional detail
|
||||
- **Alert Positioning**: Place alerts near related content
|
||||
- Use semantic colors and icons (CheckCircle, AlertCircle, XCircle)
|
||||
- Include dismissible X button for manual dismissal
|
||||
- **Success Messages**: Auto-dismiss after 5 seconds
|
||||
- Green color with CheckCircle icon
|
||||
- Clear, affirmative message
|
||||
- **Error Messages**: Structured and actionable
|
||||
- Title in bold, detailed message below
|
||||
- Red color with AlertCircle icon
|
||||
- Suggest resolution when possible
|
||||
- **Loading States**: Context-appropriate indicators
|
||||
- Inline: Use `Loader2` spinner in buttons/actions
|
||||
- Full section: Card with centered spinner and descriptive text
|
||||
|
||||
#### Form Components
|
||||
|
||||
Use react-hook-form for all forms. Never duplicate component styling.
|
||||
|
||||
**Standard Form Pattern**:
|
||||
```tsx
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { Input, Label, Button } from '@/components/ui';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||
|
||||
const { register, handleSubmit, control, formState: { errors } } = useForm({
|
||||
defaultValues: { /* ... */ }
|
||||
});
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="text">Text Field</Label>
|
||||
<Input {...register('text', { required: 'Required' })} className="mt-1" />
|
||||
{errors.text && <p className="text-sm text-red-600 mt-1">{errors.text.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="select">Select Field</Label>
|
||||
<Controller
|
||||
name="select"
|
||||
control={control}
|
||||
rules={{ required: 'Required' }}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Choose..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="option1">Option 1</SelectItem>
|
||||
<SelectItem value="option2">Option 2</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
{errors.select && <p className="text-sm text-red-600 mt-1">{errors.select.message}</p>}
|
||||
</div>
|
||||
</form>
|
||||
```
|
||||
|
||||
**Rules**:
|
||||
- **Text inputs**: Use `Input` with `register()`
|
||||
- **Select dropdowns**: Use `Select` components with `Controller` (never native `<select>`)
|
||||
- **All labels**: Use `Label` with `htmlFor` attribute
|
||||
- **Never copy classes**: Components provide default styling, only add spacing like `mt-1`
|
||||
- **Form spacing**: `space-y-3` on form containers
|
||||
- **Error messages**: `text-sm text-red-600 mt-1`
|
||||
- **Multi-action forms**: Place buttons side-by-side with `flex gap-2`
|
||||
|
||||
#### Accessibility
|
||||
|
||||
- **Focus Indicators**: All interactive elements must have visible focus states
|
||||
- Use consistent `focus-visible:ring-*` styles
|
||||
- Test keyboard navigation on all new components
|
||||
- **Screen Reader Support**: Proper semantic HTML and ARIA labels
|
||||
- Use Label components for form inputs
|
||||
- Provide descriptive text for icon-only buttons
|
||||
- Test with screen readers when adding complex interactions
|
||||
|
||||
#### Progressive Disclosure
|
||||
|
||||
- **Just-in-Time Information**: Start simple, reveal details on demand
|
||||
- Summary view by default
|
||||
- Details through drawers, accordions, or inline expansion
|
||||
- Reduces initial cognitive load
|
||||
- **Educational Context**: Provide help without interrupting flow
|
||||
- Use gradient educational cards in logical places
|
||||
- Include "Learn more" links to external documentation
|
||||
- Keep content concise and actionable
|
||||
|
||||
### App Layout
|
||||
|
||||
- The sidebar let's you select which cloud instance you are curently managing from a dropdown.
|
||||
- The sidebar is divided into Central, Cluster, and Apps.
|
||||
- Central: Utilities for managing Wild Central itself.
|
||||
- Cluster: Utilities for managing the current Wild Cloud instance.
|
||||
- Managing nodes.
|
||||
- Managing services.
|
||||
- Apps: Managing the apps deployed on the current Wild Cloud instance.
|
||||
- List of apps.
|
||||
- App details.
|
||||
- App logs.
|
||||
- App metrics.
|
||||
|
||||
58
README.md
58
README.md
@@ -2,10 +2,66 @@
|
||||
|
||||
The Wild Cloud Web App is a web-based interface for managing Wild Cloud instances. It allows users to view and control their Wild Cloud environments, including deploying applications, monitoring resources, and configuring settings.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before starting the web app, ensure the Wild Central API is running:
|
||||
|
||||
```bash
|
||||
cd ../wild-central-api
|
||||
make dev
|
||||
```
|
||||
|
||||
The API should be accessible at `http://localhost:5055`.
|
||||
|
||||
## Development
|
||||
|
||||
### Initial Setup
|
||||
|
||||
1. Copy the example environment file:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. Update `.env` if your API is running on a different host/port:
|
||||
```bash
|
||||
VITE_API_BASE_URL=http://localhost:5055
|
||||
```
|
||||
|
||||
3. Install dependencies:
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
4. Start the development server:
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
Test: `pnpm run check`
|
||||
The web app will be available at `http://localhost:5173` (or the next available port).
|
||||
|
||||
## Other Scripts
|
||||
|
||||
```bash
|
||||
pnpm run build # Build the project
|
||||
pnpm run lint # Lint the codebase
|
||||
pnpm run preview # Preview the production build
|
||||
pnpm run type-check # Type check the codebase
|
||||
pnpm run test # Run tests
|
||||
pnpm run test:ui # Run tests with UI
|
||||
pnpm run test:coverage # Run tests with coverage report
|
||||
pnpm run build:css # Build the CSS using Tailwind
|
||||
pnpm run check # Run lint, type-check, and tests
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### `VITE_API_BASE_URL`
|
||||
|
||||
The base URL of the Wild Central API server.
|
||||
|
||||
- **Default:** `http://localhost:5055`
|
||||
- **Example:** `http://192.168.1.100:5055`
|
||||
- **Usage:** Set in `.env` file (see `.env.example` for template)
|
||||
|
||||
This variable is used by the API client to connect to the Wild Central API. If not set, it defaults to `http://localhost:5055`.
|
||||
|
||||
|
||||
470
docs/specs/routing-contract.md
Normal file
470
docs/specs/routing-contract.md
Normal file
@@ -0,0 +1,470 @@
|
||||
# Wild Cloud Web App Routing Contract
|
||||
|
||||
## Front Matter
|
||||
|
||||
**Module Name**: `routing`
|
||||
**Module Type**: Infrastructure
|
||||
**Version**: 1.0.0
|
||||
**Status**: Draft
|
||||
**Last Updated**: 2025-10-12
|
||||
**Owner**: Wild Cloud Development Team
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `react`: ^19.1.0
|
||||
- `react-router`: ^7.0.0 (to be added)
|
||||
|
||||
### Consumers
|
||||
|
||||
- All page components within Wild Cloud Web App
|
||||
- Navigation components (AppSidebar)
|
||||
- Instance context management
|
||||
- External navigation links
|
||||
|
||||
## Purpose
|
||||
|
||||
This module defines the routing system for the Wild Cloud web application, providing declarative navigation between pages, URL-based state management, and integration with the existing instance context system.
|
||||
|
||||
## Public API
|
||||
|
||||
### Router Configuration
|
||||
|
||||
The routing system provides a centralized router configuration that manages all application routes.
|
||||
|
||||
#### Primary Routes
|
||||
|
||||
```typescript
|
||||
interface RouteDefinition {
|
||||
path: string;
|
||||
element: React.ComponentType;
|
||||
loader?: () => Promise<unknown>;
|
||||
errorElement?: React.ComponentType;
|
||||
}
|
||||
```
|
||||
|
||||
**Root Route**:
|
||||
- **Path**: `/`
|
||||
- **Purpose**: Landing page and instance selector
|
||||
- **Authentication**: None required
|
||||
- **Data Loading**: None
|
||||
|
||||
**Instance Routes**:
|
||||
- **Path Pattern**: `/instances/:instanceId/*`
|
||||
- **Purpose**: All instance-specific pages
|
||||
- **Authentication**: Instance must exist
|
||||
- **Data Loading**: Instance configuration loaded at this level
|
||||
|
||||
#### Instance-Scoped Routes
|
||||
|
||||
All routes under `/instances/:instanceId/`:
|
||||
|
||||
| Path | Purpose | Data Dependencies |
|
||||
|------|---------|-------------------|
|
||||
| `dashboard` | Overview and quick status | Instance config, cluster status |
|
||||
| `operations` | Operation monitoring and logs | Active operations list |
|
||||
| `cluster/health` | Cluster health metrics | Node status, etcd health |
|
||||
| `cluster/access` | Kubeconfig/Talosconfig download | Instance credentials |
|
||||
| `secrets` | Secrets management interface | Instance secrets (redacted) |
|
||||
| `services` | Base services management | Installed services list |
|
||||
| `utilities` | Utilities panel | Available utilities |
|
||||
| `cloud` | Cloud configuration | Cloud settings |
|
||||
| `dns` | DNS management | DNS configuration |
|
||||
| `dhcp` | DHCP management | DHCP configuration |
|
||||
| `pxe` | PXE boot configuration | PXE assets and config |
|
||||
| `infrastructure` | Cluster nodes | Node list and status |
|
||||
| `cluster` | Cluster services | Kubernetes services |
|
||||
| `apps` | Application management | Installed apps |
|
||||
| `advanced` | Advanced settings | System configuration |
|
||||
|
||||
### Navigation Hooks
|
||||
|
||||
#### useNavigate
|
||||
|
||||
```typescript
|
||||
function useNavigate(): NavigateFunction;
|
||||
|
||||
interface NavigateFunction {
|
||||
(to: string | number, options?: NavigateOptions): void;
|
||||
}
|
||||
|
||||
interface NavigateOptions {
|
||||
replace?: boolean;
|
||||
state?: unknown;
|
||||
}
|
||||
```
|
||||
|
||||
**Purpose**: Programmatic navigation within the application.
|
||||
|
||||
**Examples**:
|
||||
```typescript
|
||||
// Navigate to a specific instance dashboard
|
||||
navigate('/instances/prod-cluster/dashboard');
|
||||
|
||||
// Navigate back
|
||||
navigate(-1);
|
||||
|
||||
// Replace current history entry
|
||||
navigate('/instances/prod-cluster/operations', { replace: true });
|
||||
```
|
||||
|
||||
**Error Conditions**:
|
||||
- Invalid paths are handled by error boundary
|
||||
- Missing instance IDs redirect to root
|
||||
|
||||
#### useParams
|
||||
|
||||
```typescript
|
||||
function useParams<T extends Record<string, string>>(): T;
|
||||
```
|
||||
|
||||
**Purpose**: Access URL parameters, primarily for extracting instance ID.
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
const { instanceId } = useParams<{ instanceId: string }>();
|
||||
```
|
||||
|
||||
**Error Conditions**:
|
||||
- Returns undefined for missing parameters
|
||||
- Type safety through TypeScript generics
|
||||
|
||||
#### useLocation
|
||||
|
||||
```typescript
|
||||
interface Location {
|
||||
pathname: string;
|
||||
search: string;
|
||||
hash: string;
|
||||
state: unknown;
|
||||
key: string;
|
||||
}
|
||||
|
||||
function useLocation(): Location;
|
||||
```
|
||||
|
||||
**Purpose**: Access current location information for conditional rendering or analytics.
|
||||
|
||||
#### useSearchParams
|
||||
|
||||
```typescript
|
||||
function useSearchParams(): [
|
||||
URLSearchParams,
|
||||
(nextInit: URLSearchParams | ((prev: URLSearchParams) => URLSearchParams)) => void
|
||||
];
|
||||
```
|
||||
|
||||
**Purpose**: Read and write URL query parameters for filters, sorting, and view state.
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const view = searchParams.get('view') || 'grid';
|
||||
setSearchParams({ view: 'list' });
|
||||
```
|
||||
|
||||
### Link Component
|
||||
|
||||
```typescript
|
||||
interface LinkProps {
|
||||
to: string;
|
||||
replace?: boolean;
|
||||
state?: unknown;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Link(props: LinkProps): JSX.Element;
|
||||
```
|
||||
|
||||
**Purpose**: Declarative navigation component for user-triggered navigation.
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
<Link to="/instances/prod-cluster/dashboard">
|
||||
Go to Dashboard
|
||||
</Link>
|
||||
```
|
||||
|
||||
**Behavior**:
|
||||
- Prevents default browser navigation
|
||||
- Supports keyboard navigation (Enter)
|
||||
- Maintains browser history
|
||||
- Supports Ctrl/Cmd+Click to open in new tab
|
||||
|
||||
### NavLink Component
|
||||
|
||||
```typescript
|
||||
interface NavLinkProps extends LinkProps {
|
||||
caseSensitive?: boolean;
|
||||
end?: boolean;
|
||||
className?: string | ((props: { isActive: boolean; isPending: boolean }) => string);
|
||||
style?: React.CSSProperties | ((props: { isActive: boolean; isPending: boolean }) => React.CSSProperties);
|
||||
}
|
||||
|
||||
function NavLink(props: NavLinkProps): JSX.Element;
|
||||
```
|
||||
|
||||
**Purpose**: Navigation links that are aware of their active state.
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
<NavLink
|
||||
to="/instances/prod-cluster/dashboard"
|
||||
className={({ isActive }) => isActive ? 'active-nav-link' : 'nav-link'}
|
||||
>
|
||||
Dashboard
|
||||
</NavLink>
|
||||
```
|
||||
|
||||
## Data Models
|
||||
|
||||
### Route Parameters
|
||||
|
||||
```typescript
|
||||
interface InstanceRouteParams {
|
||||
instanceId: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Field Specifications**:
|
||||
- `instanceId`: String identifier for the instance
|
||||
- **Required**: Yes
|
||||
- **Format**: Alphanumeric with hyphens, 1-64 characters
|
||||
- **Validation**: Must correspond to an existing instance
|
||||
- **Example**: `"prod-cluster"`, `"staging-env"`
|
||||
|
||||
### Navigation State
|
||||
|
||||
```typescript
|
||||
interface NavigationState {
|
||||
from?: string;
|
||||
returnTo?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
```
|
||||
|
||||
**Purpose**: Preserve state across navigation, such as return URLs or form data.
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
navigate('/instances/prod-cluster/secrets', {
|
||||
state: { returnTo: '/instances/prod-cluster/dashboard' }
|
||||
});
|
||||
```
|
||||
|
||||
## Error Model
|
||||
|
||||
### Route Errors
|
||||
|
||||
| Error Code | Condition | User Impact | Recovery Action |
|
||||
|------------|-----------|-------------|-----------------|
|
||||
| `ROUTE_NOT_FOUND` | Path does not match any route | 404 page displayed | Redirect to root or show available routes |
|
||||
| `INSTANCE_NOT_FOUND` | Instance ID in URL does not exist | Error boundary with message | Redirect to instance selector at `/` |
|
||||
| `INVALID_INSTANCE_ID` | Instance ID format invalid | Validation error displayed | Show error message, redirect to `/` |
|
||||
| `NAVIGATION_CANCELLED` | User cancelled pending navigation | No visible change | Continue at current route |
|
||||
| `LOADER_ERROR` | Route data loader failed | Error boundary with retry option | Show error, allow retry or navigate away |
|
||||
|
||||
### Error Response Format
|
||||
|
||||
```typescript
|
||||
interface RouteError {
|
||||
code: string;
|
||||
message: string;
|
||||
status?: number;
|
||||
cause?: Error;
|
||||
}
|
||||
```
|
||||
|
||||
**Example**:
|
||||
```typescript
|
||||
{
|
||||
code: "INSTANCE_NOT_FOUND",
|
||||
message: "Instance 'unknown-instance' does not exist",
|
||||
status: 404
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Characteristics
|
||||
|
||||
### Route Transition Times
|
||||
|
||||
- **Static Routes**: < 50ms (no data loading)
|
||||
- **Instance Routes**: < 200ms (with instance config loading)
|
||||
- **Heavy Data Routes**: < 500ms (with large data sets)
|
||||
|
||||
### Bundle Size
|
||||
|
||||
- **Router Core**: ~45KB (minified)
|
||||
- **Navigation Components**: ~5KB
|
||||
- **Per-Route Code Splitting**: Enabled by default
|
||||
|
||||
### Memory Usage
|
||||
|
||||
- **History Stack**: O(n) where n is number of navigation entries
|
||||
- **Route Cache**: Configurable, default 10 entries
|
||||
- **Cleanup**: Automatic on unmount
|
||||
|
||||
## Configuration Requirements
|
||||
|
||||
### Environment Variables
|
||||
|
||||
None required. All routing is handled client-side.
|
||||
|
||||
### Route Configuration
|
||||
|
||||
```typescript
|
||||
interface RouterConfig {
|
||||
basename?: string;
|
||||
future?: {
|
||||
v7_startTransition?: boolean;
|
||||
v7_relativeSplatPath?: boolean;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**basename**: Optional base path for deployment in subdirectories
|
||||
- **Default**: `"/"`
|
||||
- **Example**: `"/app"` if deployed to `example.com/app/`
|
||||
|
||||
## Conformance Criteria
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
1. **F-ROUTE-01**: Router SHALL render correct component for each defined route
|
||||
2. **F-ROUTE-02**: Router SHALL extract instance ID from URL parameters
|
||||
3. **F-ROUTE-03**: Navigation hooks SHALL update browser history
|
||||
4. **F-ROUTE-04**: Back/forward browser buttons SHALL work correctly
|
||||
5. **F-ROUTE-05**: Invalid routes SHALL display error boundary
|
||||
6. **F-ROUTE-06**: Instance routes SHALL validate instance existence
|
||||
7. **F-ROUTE-07**: Link components SHALL support keyboard navigation
|
||||
8. **F-ROUTE-08**: Routes SHALL support lazy loading of components
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
1. **NF-ROUTE-01**: Route transitions SHALL complete in < 200ms for cached routes
|
||||
2. **NF-ROUTE-02**: Router SHALL support browser back/forward without page reload
|
||||
3. **NF-ROUTE-03**: Navigation SHALL preserve scroll position when appropriate
|
||||
4. **NF-ROUTE-04**: Router SHALL be compatible with React 19.1+
|
||||
5. **NF-ROUTE-05**: Routes SHALL be defined declaratively in configuration
|
||||
6. **NF-ROUTE-06**: Router SHALL integrate with existing ErrorBoundary
|
||||
7. **NF-ROUTE-07**: Navigation SHALL work with InstanceContext
|
||||
|
||||
### Integration Requirements
|
||||
|
||||
1. **I-ROUTE-01**: Router SHALL integrate with InstanceContext
|
||||
2. **I-ROUTE-02**: AppSidebar SHALL use routing for navigation
|
||||
3. **I-ROUTE-03**: All page components SHALL be routed
|
||||
4. **I-ROUTE-04**: Router SHALL integrate with React Query for data loading
|
||||
5. **I-ROUTE-05**: Router SHALL support Vite code splitting
|
||||
|
||||
## API Stability
|
||||
|
||||
**Versioning Scheme**: Semantic Versioning (SemVer)
|
||||
|
||||
**Stability Level**: Stable (1.0.0+)
|
||||
|
||||
**Breaking Changes**:
|
||||
- Route path changes require major version bump
|
||||
- Hook signature changes require major version bump
|
||||
- Added routes or optional parameters are minor version bumps
|
||||
|
||||
**Deprecation Policy**:
|
||||
- Deprecated routes supported for 2 minor versions
|
||||
- Console warnings for deprecated route usage
|
||||
- Migration guide provided for breaking changes
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Route Protection
|
||||
|
||||
Instance routes SHALL verify:
|
||||
1. Instance ID exists in available instances
|
||||
2. User has permission to access instance (future)
|
||||
|
||||
### XSS Prevention
|
||||
|
||||
- All route parameters SHALL be sanitized
|
||||
- User-provided navigation state SHALL be validated
|
||||
- No executable code in route parameters
|
||||
|
||||
### CSRF Protection
|
||||
|
||||
Not applicable - all navigation is client-side without authentication tokens.
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
**Supported Browsers**:
|
||||
- Chrome/Edge: Last 2 versions
|
||||
- Firefox: Last 2 versions
|
||||
- Safari: Last 2 versions
|
||||
|
||||
**Required Browser APIs**:
|
||||
- History API
|
||||
- URL API
|
||||
- ES6+ JavaScript features
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic Navigation
|
||||
|
||||
```typescript
|
||||
// In a component
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
function InstanceCard({ instance }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleClick = () => {
|
||||
navigate(`/instances/${instance.id}/dashboard`);
|
||||
};
|
||||
|
||||
return <button onClick={handleClick}>Open {instance.name}</button>;
|
||||
}
|
||||
```
|
||||
|
||||
### Using Route Parameters
|
||||
|
||||
```typescript
|
||||
// In an instance page
|
||||
import { useParams } from 'react-router';
|
||||
import { useInstanceContext } from '../hooks';
|
||||
|
||||
function DashboardPage() {
|
||||
const { instanceId } = useParams<{ instanceId: string }>();
|
||||
const { setCurrentInstance } = useInstanceContext();
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentInstance(instanceId);
|
||||
}, [instanceId, setCurrentInstance]);
|
||||
|
||||
return <div>Dashboard for {instanceId}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Sidebar Integration
|
||||
|
||||
```typescript
|
||||
// AppSidebar using NavLink
|
||||
import { NavLink } from 'react-router';
|
||||
|
||||
function AppSidebar() {
|
||||
const { instanceId } = useParams();
|
||||
|
||||
return (
|
||||
<nav>
|
||||
<NavLink
|
||||
to={`/instances/${instanceId}/dashboard`}
|
||||
className={({ isActive }) => isActive ? 'active' : ''}
|
||||
>
|
||||
Dashboard
|
||||
</NavLink>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Version History
|
||||
|
||||
| Version | Date | Changes |
|
||||
|---------|------|---------|
|
||||
| 1.0.0 | 2025-10-12 | Initial contract definition |
|
||||
15
package.json
15
package.json
@@ -9,19 +9,24 @@
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "vitest",
|
||||
"test": "vitest --run",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"build:css": "tailwindcss -i src/index.css -o ./output.css --config ./tailwind.config.js",
|
||||
"check": "pnpm run lint && pnpm run type-check && pnpm run test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@tailwindcss/vite": "^4.1.10",
|
||||
"@tanstack/react-query": "^5.62.10",
|
||||
@@ -31,12 +36,20 @@
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.58.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.9.4",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"zod": "^3.25.67"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.0.3",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
|
||||
1294
pnpm-lock.yaml
generated
1294
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
140
src/App.tsx
140
src/App.tsx
@@ -1,140 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useConfig } from './hooks';
|
||||
import {
|
||||
Advanced,
|
||||
ErrorBoundary
|
||||
} from './components';
|
||||
import { CloudComponent } from './components/CloudComponent';
|
||||
import { CentralComponent } from './components/CentralComponent';
|
||||
import { DnsComponent } from './components/DnsComponent';
|
||||
import { DhcpComponent } from './components/DhcpComponent';
|
||||
import { PxeComponent } from './components/PxeComponent';
|
||||
import { ClusterNodesComponent } from './components/ClusterNodesComponent';
|
||||
import { ClusterServicesComponent } from './components/ClusterServicesComponent';
|
||||
import { AppsComponent } from './components/AppsComponent';
|
||||
import { AppSidebar } from './components/AppSidebar';
|
||||
import { SidebarProvider, SidebarInset, SidebarTrigger } from './components/ui/sidebar';
|
||||
import type { Phase, Tab } from './components/AppSidebar';
|
||||
import { RouterProvider } from 'react-router';
|
||||
import { router } from './router';
|
||||
|
||||
function App() {
|
||||
const [currentTab, setCurrentTab] = useState<Tab>('cloud');
|
||||
const [completedPhases, setCompletedPhases] = useState<Phase[]>([]);
|
||||
|
||||
const { config } = useConfig();
|
||||
|
||||
// Update phase state from config when it changes
|
||||
useEffect(() => {
|
||||
console.log('Config changed:', config);
|
||||
console.log('config?.wildcloud:', config?.wildcloud);
|
||||
if (config?.wildcloud?.currentPhase) {
|
||||
console.log('Setting currentTab to:', config.wildcloud.currentPhase);
|
||||
setCurrentTab(config.wildcloud.currentPhase as Phase);
|
||||
}
|
||||
if (config?.wildcloud?.completedPhases) {
|
||||
console.log('Setting completedPhases to:', config.wildcloud.completedPhases);
|
||||
setCompletedPhases(config.wildcloud.completedPhases as Phase[]);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const handlePhaseComplete = (phase: Phase) => {
|
||||
if (!completedPhases.includes(phase)) {
|
||||
setCompletedPhases(prev => [...prev, phase]);
|
||||
}
|
||||
|
||||
// Auto-advance to next phase (excluding advanced)
|
||||
const phases: Phase[] = ['setup', 'infrastructure', 'cluster', 'apps'];
|
||||
const currentIndex = phases.indexOf(phase);
|
||||
if (currentIndex < phases.length - 1) {
|
||||
setCurrentTab(phases[currentIndex + 1]);
|
||||
}
|
||||
};
|
||||
|
||||
const renderCurrentTab = () => {
|
||||
switch (currentTab) {
|
||||
case 'cloud':
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<CloudComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
case 'central':
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<CentralComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
case 'dns':
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<DnsComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
case 'dhcp':
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<DhcpComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
case 'pxe':
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<PxeComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
case 'setup':
|
||||
case 'infrastructure':
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<ClusterNodesComponent onComplete={() => handlePhaseComplete('infrastructure')} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
case 'cluster':
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<ClusterServicesComponent onComplete={() => handlePhaseComplete('cluster')} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
case 'apps':
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<AppsComponent onComplete={() => handlePhaseComplete('apps')} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
case 'advanced':
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Advanced />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<CloudComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar
|
||||
currentTab={currentTab}
|
||||
onTabChange={setCurrentTab}
|
||||
completedPhases={completedPhases}
|
||||
/>
|
||||
<SidebarInset>
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg font-semibold">Dashboard</h1>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4">
|
||||
{renderCurrentTab()}
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -8,12 +9,42 @@ import {
|
||||
} from "./ui/card";
|
||||
import { ConfigEditor } from "./ConfigEditor";
|
||||
import { Button, Input, Label } from "./ui";
|
||||
import { Check, Edit2, HelpCircle, X } from "lucide-react";
|
||||
import { Check, Edit2, HelpCircle, X, ExternalLink, Copy } from "lucide-react";
|
||||
import { useDashboardToken } from "../services/api/hooks/useUtilities";
|
||||
import { useInstance } from "../services/api";
|
||||
|
||||
export function Advanced() {
|
||||
const { instanceId } = useParams<{ instanceId: string }>();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { data: instance } = useInstance(instanceId || '');
|
||||
const { data: dashboardToken, isLoading: tokenLoading } = useDashboardToken(instanceId || '');
|
||||
|
||||
const [upstreamValue, setUpstreamValue] = useState("https://mywildcloud.org");
|
||||
const [editingUpstream, setEditingUpstream] = useState(false);
|
||||
const [tempUpstream, setTempUpstream] = useState(upstreamValue);
|
||||
|
||||
const handleCopyToken = async () => {
|
||||
if (dashboardToken?.token) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(dashboardToken.token);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy token:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenDashboard = () => {
|
||||
// Build dashboard URL from instance config
|
||||
// Dashboard is available at: https://dashboard.{cloud.internalDomain}
|
||||
const internalDomain = instance?.config?.cloud?.internalDomain;
|
||||
const dashboardUrl = internalDomain
|
||||
? `https://dashboard.${internalDomain}`
|
||||
: 'https://dashboard.internal.wild.cloud';
|
||||
window.open(dashboardUrl, '_blank');
|
||||
};
|
||||
|
||||
const handleUpstreamEdit = () => {
|
||||
setTempUpstream(upstreamValue);
|
||||
setEditingUpstream(true);
|
||||
@@ -51,6 +82,47 @@ export function Advanced() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Kubernetes Dashboard Access */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Kubernetes Dashboard</CardTitle>
|
||||
<CardDescription>
|
||||
Access the Kubernetes dashboard for advanced cluster management
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button onClick={handleOpenDashboard} disabled={!instance}>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
Open Dashboard
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCopyToken}
|
||||
disabled={tokenLoading || !dashboardToken?.token}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy Token
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{instance?.config?.cloud?.internalDomain && (
|
||||
<p className="text-xs text-muted-foreground mt-3">
|
||||
Dashboard URL: https://dashboard.{instance.config.cloud.internalDomain}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Upstream Section */}
|
||||
<Card className="p-4 border-l-4 border-l-blue-500">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
|
||||
@@ -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, Usb, Download, CheckCircle } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -15,19 +16,11 @@ import {
|
||||
} from './ui/sidebar';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { InstanceSwitcher } from './InstanceSwitcher';
|
||||
|
||||
export type Phase = 'setup' | 'infrastructure' | 'cluster' | 'apps';
|
||||
export type Tab = Phase | 'advanced' | 'cloud' | 'central' | 'dns' | 'dhcp' | 'pxe';
|
||||
|
||||
interface AppSidebarProps {
|
||||
currentTab: Tab;
|
||||
onTabChange: (tab: Tab) => void;
|
||||
completedPhases: Phase[];
|
||||
}
|
||||
|
||||
|
||||
export function AppSidebar({ currentTab, onTabChange, completedPhases }: AppSidebarProps) {
|
||||
export function AppSidebar() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { instanceId } = useParams<{ instanceId: string }>();
|
||||
|
||||
const cycleTheme = () => {
|
||||
if (theme === 'light') {
|
||||
@@ -61,89 +54,66 @@ export function AppSidebar({ currentTab, onTabChange, completedPhases }: AppSide
|
||||
}
|
||||
};
|
||||
|
||||
const getTabStatus = (tab: Tab) => {
|
||||
// Non-phase tabs (like advanced and cloud) are always available
|
||||
if (tab === 'advanced' || tab === 'cloud') {
|
||||
return 'available';
|
||||
}
|
||||
|
||||
// Central sub-tabs are available if setup phase is available or completed
|
||||
if (tab === 'central' || tab === 'dns' || tab === 'dhcp' || tab === 'pxe') {
|
||||
if (completedPhases.includes('setup')) {
|
||||
return 'completed';
|
||||
}
|
||||
return 'available';
|
||||
}
|
||||
|
||||
// For phase tabs, check completion status
|
||||
if (completedPhases.includes(tab as Phase)) {
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
// Allow access to the first phase always
|
||||
if (tab === 'setup') {
|
||||
return 'available';
|
||||
}
|
||||
|
||||
// Allow access to the next phase if the previous phase is completed
|
||||
if (tab === 'infrastructure' && completedPhases.includes('setup')) {
|
||||
return 'available';
|
||||
}
|
||||
|
||||
if (tab === 'cluster' && completedPhases.includes('infrastructure')) {
|
||||
return 'available';
|
||||
}
|
||||
|
||||
if (tab === 'apps' && completedPhases.includes('cluster')) {
|
||||
return 'available';
|
||||
}
|
||||
|
||||
return 'locked';
|
||||
};
|
||||
// If no instanceId, we're not in an instance context
|
||||
if (!instanceId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar variant="sidebar" collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<div className="flex items-center gap-2 px-2 pb-2">
|
||||
<div className="p-1 bg-primary/10 rounded-lg">
|
||||
<CloudLightning className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div className="group-data-[collapsible=icon]:hidden">
|
||||
<h2 className="text-lg font-bold text-foreground">Wild Cloud</h2>
|
||||
<p className="text-sm text-muted-foreground">Central</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2 group-data-[collapsible=icon]:px-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<InstanceSwitcher />
|
||||
</div>
|
||||
<NavLink to={`/instances/${instanceId}/cloud`}>
|
||||
{({ isActive }) => (
|
||||
<SidebarMenuButton
|
||||
isActive={isActive}
|
||||
tooltip="Configure instance settings"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</SidebarMenuButton>
|
||||
)}
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
|
||||
|
||||
<SidebarContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={currentTab === 'cloud'}
|
||||
onClick={() => {
|
||||
const status = getTabStatus('cloud');
|
||||
if (status !== 'locked') onTabChange('cloud');
|
||||
}}
|
||||
disabled={getTabStatus('cloud') === 'locked'}
|
||||
tooltip="Configure cloud settings and domains"
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
getTabStatus('cloud') === 'locked' && "opacity-50 cursor-not-allowed"
|
||||
<NavLink to={`/instances/${instanceId}/dashboard`}>
|
||||
{({ isActive }) => (
|
||||
<SidebarMenuButton
|
||||
isActive={isActive}
|
||||
tooltip="Instance dashboard and overview"
|
||||
>
|
||||
<div className={cn(
|
||||
"p-1 rounded-md",
|
||||
isActive && "bg-primary/10"
|
||||
)}>
|
||||
<CloudLightning className={cn(
|
||||
"h-4 w-4",
|
||||
isActive && "text-primary",
|
||||
!isActive && "text-muted-foreground"
|
||||
)} />
|
||||
</div>
|
||||
<span className="truncate">Dashboard</span>
|
||||
</SidebarMenuButton>
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"p-1 rounded-md",
|
||||
currentTab === 'cloud' && "bg-primary/10",
|
||||
getTabStatus('cloud') === 'locked' && "bg-muted"
|
||||
)}>
|
||||
<CloudLightning className={cn(
|
||||
"h-4 w-4",
|
||||
currentTab === 'cloud' && "text-primary",
|
||||
currentTab !== 'cloud' && "text-muted-foreground"
|
||||
)} />
|
||||
</div>
|
||||
<span className="truncate">Cloud</span>
|
||||
</SidebarMenuButton>
|
||||
</NavLink>
|
||||
</SidebarMenuItem>
|
||||
|
||||
<Collapsible defaultOpen className="group/collapsible">
|
||||
@@ -158,112 +128,48 @@ export function AppSidebar({ currentTab, onTabChange, completedPhases }: AppSide
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton
|
||||
isActive={currentTab === 'central'}
|
||||
onClick={() => {
|
||||
const status = getTabStatus('central');
|
||||
if (status !== 'locked') onTabChange('central');
|
||||
}}
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
getTabStatus('central') === 'locked' && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"p-1 rounded-md",
|
||||
currentTab === 'central' && "bg-primary/10",
|
||||
getTabStatus('central') === 'locked' && "bg-muted"
|
||||
)}>
|
||||
<Server className={cn(
|
||||
"h-4 w-4",
|
||||
currentTab === 'central' && "text-primary",
|
||||
currentTab !== 'central' && "text-muted-foreground"
|
||||
)} />
|
||||
</div>
|
||||
<span className="truncate">Central</span>
|
||||
<SidebarMenuSubButton asChild>
|
||||
<NavLink to={`/instances/${instanceId}/central`} className={({ isActive }) => isActive ? "data-[active=true]" : ""}>
|
||||
<div className="p-1 rounded-md">
|
||||
<Server className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="truncate">Central</span>
|
||||
</NavLink>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton
|
||||
isActive={currentTab === 'dns'}
|
||||
onClick={() => {
|
||||
const status = getTabStatus('dns');
|
||||
if (status !== 'locked') onTabChange('dns');
|
||||
}}
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
getTabStatus('dns') === 'locked' && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"p-1 rounded-md",
|
||||
currentTab === 'dns' && "bg-primary/10",
|
||||
getTabStatus('dns') === 'locked' && "bg-muted"
|
||||
)}>
|
||||
<Globe className={cn(
|
||||
"h-4 w-4",
|
||||
currentTab === 'dns' && "text-primary",
|
||||
currentTab !== 'dns' && "text-muted-foreground"
|
||||
)} />
|
||||
</div>
|
||||
<span className="truncate">DNS</span>
|
||||
<SidebarMenuSubButton asChild>
|
||||
<NavLink to={`/instances/${instanceId}/dns`}>
|
||||
<div className="p-1 rounded-md">
|
||||
<Globe className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="truncate">DNS</span>
|
||||
</NavLink>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
|
||||
{/* <SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton asChild>
|
||||
<NavLink to={`/instances/${instanceId}/dhcp`}>
|
||||
<div className="p-1 rounded-md">
|
||||
<Wifi className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="truncate">DHCP</span>
|
||||
</NavLink>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton
|
||||
isActive={currentTab === 'dhcp'}
|
||||
onClick={() => {
|
||||
const status = getTabStatus('dhcp');
|
||||
if (status !== 'locked') onTabChange('dhcp');
|
||||
}}
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
getTabStatus('dhcp') === 'locked' && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"p-1 rounded-md",
|
||||
currentTab === 'dhcp' && "bg-primary/10",
|
||||
getTabStatus('dhcp') === 'locked' && "bg-muted"
|
||||
)}>
|
||||
<Wifi className={cn(
|
||||
"h-4 w-4",
|
||||
currentTab === 'dhcp' && "text-primary",
|
||||
currentTab !== 'dhcp' && "text-muted-foreground"
|
||||
)} />
|
||||
</div>
|
||||
<span className="truncate">DHCP</span>
|
||||
<SidebarMenuSubButton asChild>
|
||||
<NavLink to={`/instances/${instanceId}/pxe`}>
|
||||
<div className="p-1 rounded-md">
|
||||
<HardDrive className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="truncate">PXE</span>
|
||||
</NavLink>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton
|
||||
isActive={currentTab === 'pxe'}
|
||||
onClick={() => {
|
||||
const status = getTabStatus('pxe');
|
||||
if (status !== 'locked') onTabChange('pxe');
|
||||
}}
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
getTabStatus('pxe') === 'locked' && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"p-1 rounded-md",
|
||||
currentTab === 'pxe' && "bg-primary/10",
|
||||
getTabStatus('pxe') === 'locked' && "bg-muted"
|
||||
)}>
|
||||
<HardDrive className={cn(
|
||||
"h-4 w-4",
|
||||
currentTab === 'pxe' && "text-primary",
|
||||
currentTab !== 'pxe' && "text-muted-foreground"
|
||||
)} />
|
||||
</div>
|
||||
<span className="truncate">PXE</span>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
</SidebarMenuSubItem> */}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
@@ -281,56 +187,83 @@ export function AppSidebar({ currentTab, onTabChange, completedPhases }: AppSide
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton
|
||||
isActive={currentTab === 'infrastructure'}
|
||||
onClick={() => {
|
||||
const status = getTabStatus('infrastructure');
|
||||
if (status !== 'locked') onTabChange('infrastructure');
|
||||
}}
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
getTabStatus('infrastructure') === 'locked' && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"p-1 rounded-md",
|
||||
currentTab === 'infrastructure' && "bg-primary/10",
|
||||
getTabStatus('infrastructure') === 'locked' && "bg-muted"
|
||||
)}>
|
||||
<Play className={cn(
|
||||
"h-4 w-4",
|
||||
currentTab === 'infrastructure' && "text-primary",
|
||||
currentTab !== 'infrastructure' && "text-muted-foreground"
|
||||
)} />
|
||||
</div>
|
||||
<span className="truncate">Cluster Nodes</span>
|
||||
<SidebarMenuSubButton asChild>
|
||||
<NavLink to={`/instances/${instanceId}/control`}>
|
||||
<div className="p-1 rounded-md">
|
||||
<Cpu className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="truncate">Control Nodes</span>
|
||||
</NavLink>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton
|
||||
isActive={currentTab === 'cluster'}
|
||||
onClick={() => {
|
||||
const status = getTabStatus('cluster');
|
||||
if (status !== 'locked') onTabChange('cluster');
|
||||
}}
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
getTabStatus('cluster') === 'locked' && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"p-1 rounded-md",
|
||||
currentTab === 'cluster' && "bg-primary/10",
|
||||
getTabStatus('cluster') === 'locked' && "bg-muted"
|
||||
)}>
|
||||
<Container className={cn(
|
||||
"h-4 w-4",
|
||||
currentTab === 'cluster' && "text-primary",
|
||||
currentTab !== 'cluster' && "text-muted-foreground"
|
||||
)} />
|
||||
</div>
|
||||
<span className="truncate">Cluster Services</span>
|
||||
<SidebarMenuSubButton asChild>
|
||||
<NavLink to={`/instances/${instanceId}/worker`}>
|
||||
<div className="p-1 rounded-md">
|
||||
<HardDrive className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="truncate">Worker Nodes</span>
|
||||
</NavLink>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton asChild>
|
||||
<NavLink to={`/instances/${instanceId}/cluster`}>
|
||||
<div className="p-1 rounded-md">
|
||||
<Container className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="truncate">Cluster Services</span>
|
||||
</NavLink>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton asChild>
|
||||
<NavLink to={`/instances/${instanceId}/iso`}>
|
||||
<div className="p-1 rounded-md">
|
||||
<Usb className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="truncate">ISO / USB</span>
|
||||
</NavLink>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible defaultOpen className="group/collapsible">
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton>
|
||||
<AppWindow className="h-4 w-4" />
|
||||
Apps
|
||||
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton asChild>
|
||||
<NavLink to={`/instances/${instanceId}/apps/available`}>
|
||||
<div className="p-1 rounded-md">
|
||||
<Download className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="truncate">Available</span>
|
||||
</NavLink>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
|
||||
<SidebarMenuSubItem>
|
||||
<SidebarMenuSubButton asChild>
|
||||
<NavLink to={`/instances/${instanceId}/apps/installed`}>
|
||||
<div className="p-1 rounded-md">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="truncate">Installed</span>
|
||||
</NavLink>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
</SidebarMenuSub>
|
||||
@@ -339,60 +272,13 @@ export function AppSidebar({ currentTab, onTabChange, completedPhases }: AppSide
|
||||
</Collapsible>
|
||||
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={currentTab === 'apps'}
|
||||
onClick={() => {
|
||||
const status = getTabStatus('apps');
|
||||
if (status !== 'locked') onTabChange('apps');
|
||||
}}
|
||||
disabled={getTabStatus('apps') === 'locked'}
|
||||
tooltip="Install and manage applications"
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
getTabStatus('apps') === 'locked' && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"p-1 rounded-md",
|
||||
currentTab === 'apps' && "bg-primary/10",
|
||||
getTabStatus('apps') === 'locked' && "bg-muted"
|
||||
)}>
|
||||
<AppWindow className={cn(
|
||||
"h-4 w-4",
|
||||
currentTab === 'apps' && "text-primary",
|
||||
currentTab !== 'apps' && "text-muted-foreground"
|
||||
)} />
|
||||
</div>
|
||||
<span className="truncate">Apps</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={currentTab === 'advanced'}
|
||||
onClick={() => {
|
||||
const status = getTabStatus('advanced');
|
||||
if (status !== 'locked') onTabChange('advanced');
|
||||
}}
|
||||
disabled={getTabStatus('advanced') === 'locked'}
|
||||
tooltip="Advanced settings and system configuration"
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
getTabStatus('advanced') === 'locked' && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"p-1 rounded-md",
|
||||
currentTab === 'advanced' && "bg-primary/10",
|
||||
getTabStatus('advanced') === 'locked' && "bg-muted"
|
||||
)}>
|
||||
<Settings className={cn(
|
||||
"h-4 w-4",
|
||||
currentTab === 'advanced' && "text-primary",
|
||||
currentTab !== 'advanced' && "text-muted-foreground"
|
||||
)} />
|
||||
</div>
|
||||
<span className="truncate">Advanced</span>
|
||||
<SidebarMenuButton asChild tooltip="Advanced settings and system configuration">
|
||||
<NavLink to={`/instances/${instanceId}/advanced`}>
|
||||
<div className="p-1 rounded-md">
|
||||
<Settings className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="truncate">Advanced</span>
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
@@ -413,4 +299,4 @@ export function AppSidebar({ currentTab, onTabChange, completedPhases }: AppSide
|
||||
<SidebarRail/>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,162 +1,154 @@
|
||||
import { useState } from 'react';
|
||||
import { useLocation } from 'react-router';
|
||||
import { Card } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
import {
|
||||
AppWindow,
|
||||
Database,
|
||||
Globe,
|
||||
Shield,
|
||||
BarChart3,
|
||||
MessageSquare,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
import {
|
||||
AppWindow,
|
||||
Database,
|
||||
Globe,
|
||||
Shield,
|
||||
BarChart3,
|
||||
MessageSquare,
|
||||
Search,
|
||||
ExternalLink,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
Download,
|
||||
Trash2,
|
||||
BookOpen
|
||||
BookOpen,
|
||||
Loader2,
|
||||
Archive,
|
||||
RotateCcw,
|
||||
Settings,
|
||||
Eye,
|
||||
} 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 { AppDetailModal } from './apps/AppDetailModal';
|
||||
import type { App } from '../services/api';
|
||||
|
||||
interface AppsComponentProps {
|
||||
onComplete?: () => void;
|
||||
interface MergedApp extends App {
|
||||
deploymentStatus?: 'added' | 'deployed';
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface Application {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: 'database' | 'web' | 'security' | 'monitoring' | 'communication' | 'storage';
|
||||
status: 'available' | 'installing' | 'running' | 'error' | 'stopped';
|
||||
version?: string;
|
||||
namespace?: string;
|
||||
replicas?: number;
|
||||
resources?: {
|
||||
cpu: string;
|
||||
memory: string;
|
||||
};
|
||||
urls?: string[];
|
||||
}
|
||||
type TabView = 'available' | 'installed';
|
||||
|
||||
export function AppsComponent({ onComplete }: AppsComponentProps) {
|
||||
const [applications, setApplications] = useState<Application[]>([
|
||||
{
|
||||
id: 'postgres',
|
||||
name: 'PostgreSQL',
|
||||
description: 'Reliable, high-performance SQL database',
|
||||
category: 'database',
|
||||
status: 'running',
|
||||
version: 'v15.4',
|
||||
namespace: 'default',
|
||||
replicas: 1,
|
||||
resources: { cpu: '500m', memory: '1Gi' },
|
||||
urls: ['postgres://postgres.wildcloud.local:5432'],
|
||||
},
|
||||
{
|
||||
id: 'redis',
|
||||
name: 'Redis',
|
||||
description: 'In-memory data structure store',
|
||||
category: 'database',
|
||||
status: 'running',
|
||||
version: 'v7.2',
|
||||
namespace: 'default',
|
||||
replicas: 1,
|
||||
resources: { cpu: '250m', memory: '512Mi' },
|
||||
},
|
||||
{
|
||||
id: 'traefik-dashboard',
|
||||
name: 'Traefik Dashboard',
|
||||
description: 'Load balancer and reverse proxy dashboard',
|
||||
category: 'web',
|
||||
status: 'running',
|
||||
version: 'v3.0',
|
||||
namespace: 'kube-system',
|
||||
urls: ['https://traefik.wildcloud.local'],
|
||||
},
|
||||
{
|
||||
id: 'grafana',
|
||||
name: 'Grafana',
|
||||
description: 'Monitoring and observability dashboards',
|
||||
category: 'monitoring',
|
||||
status: 'installing',
|
||||
version: 'v10.2',
|
||||
namespace: 'monitoring',
|
||||
},
|
||||
{
|
||||
id: 'prometheus',
|
||||
name: 'Prometheus',
|
||||
description: 'Time-series monitoring and alerting',
|
||||
category: 'monitoring',
|
||||
status: 'running',
|
||||
version: 'v2.45',
|
||||
namespace: 'monitoring',
|
||||
replicas: 1,
|
||||
resources: { cpu: '1000m', memory: '2Gi' },
|
||||
},
|
||||
{
|
||||
id: 'vault',
|
||||
name: 'HashiCorp Vault',
|
||||
description: 'Secrets management and encryption',
|
||||
category: 'security',
|
||||
status: 'available',
|
||||
version: 'v1.15',
|
||||
},
|
||||
{
|
||||
id: 'minio',
|
||||
name: 'MinIO',
|
||||
description: 'High-performance object storage',
|
||||
category: 'storage',
|
||||
status: 'available',
|
||||
version: 'RELEASE.2023-12-07',
|
||||
},
|
||||
]);
|
||||
export function AppsComponent() {
|
||||
const location = useLocation();
|
||||
const { currentInstance } = useInstanceContext();
|
||||
const { data: availableAppsData, isLoading: loadingAvailable, error: availableError } = useAvailableApps();
|
||||
const {
|
||||
apps: deployedApps,
|
||||
isLoading: loadingDeployed,
|
||||
error: deployedError,
|
||||
addApp,
|
||||
isAdding,
|
||||
deployApp,
|
||||
isDeploying,
|
||||
deleteApp,
|
||||
isDeleting
|
||||
} = useDeployedApps(currentInstance);
|
||||
|
||||
// Determine active tab from URL path
|
||||
const activeTab: TabView = location.pathname.endsWith('/installed') ? 'installed' : 'available';
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||
const [configDialogOpen, setConfigDialogOpen] = useState(false);
|
||||
const [selectedAppForConfig, setSelectedAppForConfig] = useState<App | null>(null);
|
||||
const [backupModalOpen, setBackupModalOpen] = useState(false);
|
||||
const [restoreModalOpen, setRestoreModalOpen] = useState(false);
|
||||
const [selectedAppForBackup, setSelectedAppForBackup] = useState<string | null>(null);
|
||||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||
const [selectedAppForDetail, setSelectedAppForDetail] = 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 with URL from deployment
|
||||
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,
|
||||
url: deployedApp?.url,
|
||||
};
|
||||
});
|
||||
|
||||
const isLoading = loadingAvailable || loadingDeployed;
|
||||
|
||||
// Filter for available apps (not added or deployed)
|
||||
const availableApps = applications.filter(app => !app.deploymentStatus);
|
||||
|
||||
// Filter for installed apps (added or deployed)
|
||||
const installedApps = applications.filter(app => app.deploymentStatus);
|
||||
|
||||
// Count running apps - apps that are deployed (not just added)
|
||||
const runningApps = installedApps.filter(app => app.deploymentStatus === 'deployed').length;
|
||||
|
||||
const getStatusIcon = (status?: string) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
||||
case 'error':
|
||||
return <AlertCircle className="h-5 w-5 text-red-500" />;
|
||||
case 'installing':
|
||||
return <Clock className="h-5 w-5 text-blue-500 animate-spin" />;
|
||||
case 'deploying':
|
||||
return <Loader2 className="h-5 w-5 text-blue-500 animate-spin" />;
|
||||
case 'stopped':
|
||||
return <AlertCircle className="h-5 w-5 text-yellow-500" />;
|
||||
default:
|
||||
case 'added':
|
||||
return <Settings className="h-5 w-5 text-blue-500" />;
|
||||
case 'deployed':
|
||||
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
||||
case 'available':
|
||||
return <Download className="h-5 w-5 text-muted-foreground" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: Application['status']) => {
|
||||
const variants = {
|
||||
const getStatusBadge = (app: MergedApp) => {
|
||||
// Determine status: runtime status > deployment status > available
|
||||
const status = app.status?.status || app.deploymentStatus || 'available';
|
||||
|
||||
const variants: Record<string, 'secondary' | 'default' | 'success' | 'destructive' | 'warning' | 'outline'> = {
|
||||
available: 'secondary',
|
||||
installing: 'default',
|
||||
added: 'outline',
|
||||
deploying: 'default',
|
||||
running: 'success',
|
||||
error: 'destructive',
|
||||
stopped: 'warning',
|
||||
} as const;
|
||||
deployed: 'outline',
|
||||
};
|
||||
|
||||
const labels = {
|
||||
const labels: Record<string, string> = {
|
||||
available: 'Available',
|
||||
installing: 'Installing',
|
||||
added: 'Added',
|
||||
deploying: 'Deploying',
|
||||
running: 'Running',
|
||||
error: 'Error',
|
||||
stopped: 'Stopped',
|
||||
deployed: 'Deployed',
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant={variants[status] as any}>
|
||||
{labels[status]}
|
||||
<Badge variant={variants[status]}>
|
||||
{labels[status] || status}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const getCategoryIcon = (category: Application['category']) => {
|
||||
const getCategoryIcon = (category?: string) => {
|
||||
switch (category) {
|
||||
case 'database':
|
||||
return <Database className="h-4 w-4" />;
|
||||
@@ -175,20 +167,122 @@ export function AppsComponent({ onComplete }: AppsComponentProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAppAction = (appId: string, action: 'install' | 'start' | 'stop' | 'delete' | 'configure') => {
|
||||
console.log(`${action} app: ${appId}`);
|
||||
// Separate component for app icon with error handling
|
||||
const AppIcon = ({ app }: { app: MergedApp }) => {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="h-12 w-12 rounded-lg bg-muted flex items-center justify-center overflow-hidden">
|
||||
{app.icon && !imageError ? (
|
||||
<img
|
||||
src={app.icon}
|
||||
alt={app.name}
|
||||
className="h-full w-full object-contain p-1"
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
) : (
|
||||
getCategoryIcon(app.category)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const handleAppAction = (app: MergedApp, action: 'configure' | 'deploy' | 'delete' | 'backup' | 'restore' | 'view') => {
|
||||
if (!currentInstance) return;
|
||||
|
||||
switch (action) {
|
||||
case 'configure':
|
||||
console.log('[AppsComponent] Configuring app:', {
|
||||
name: app.name,
|
||||
hasDefaultConfig: !!app.defaultConfig,
|
||||
defaultConfigKeys: app.defaultConfig ? Object.keys(app.defaultConfig) : [],
|
||||
fullApp: 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;
|
||||
case 'view':
|
||||
setSelectedAppForDetail(app.name);
|
||||
setDetailModalOpen(true);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfigSave = (config: Record<string, string>) => {
|
||||
if (!selectedAppForConfig) return;
|
||||
|
||||
addApp({
|
||||
name: selectedAppForConfig.name,
|
||||
config: config,
|
||||
});
|
||||
|
||||
setConfigDialogOpen(false);
|
||||
setSelectedAppForConfig(null);
|
||||
};
|
||||
|
||||
const handleBackupConfirm = () => {
|
||||
createBackup();
|
||||
};
|
||||
|
||||
const handleRestoreConfirm = (backupId?: string) => {
|
||||
if (backupId) {
|
||||
restoreBackup(backupId);
|
||||
}
|
||||
};
|
||||
|
||||
const categories = ['all', 'database', 'web', 'security', 'monitoring', 'communication', 'storage'];
|
||||
|
||||
const filteredApps = applications.filter(app => {
|
||||
|
||||
const appsToDisplay = activeTab === 'available' ? availableApps : installedApps;
|
||||
|
||||
const filteredApps = appsToDisplay.filter(app => {
|
||||
const matchesSearch = app.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
app.description.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
const matchesCategory = selectedCategory === 'all' || app.category === selectedCategory;
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
const runningApps = applications.filter(app => app.status === 'running').length;
|
||||
// Show message if no instance is selected
|
||||
if (!currentInstance) {
|
||||
return (
|
||||
<Card className="p-8 text-center">
|
||||
<AppWindow className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">No Instance Selected</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Please select or create an instance to manage apps.
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Show error state
|
||||
if (availableError || deployedError) {
|
||||
return (
|
||||
<Card className="p-8 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">Error Loading Apps</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{(availableError as Error)?.message || (deployedError as Error)?.message || 'An error occurred'}
|
||||
</p>
|
||||
<Button onClick={() => window.location.reload()}>Reload Page</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -203,12 +297,12 @@ export function AppsComponent({ onComplete }: AppsComponentProps) {
|
||||
What are Apps in your Personal Cloud?
|
||||
</h3>
|
||||
<p className="text-pink-800 dark:text-pink-200 mb-3 leading-relaxed">
|
||||
Apps are the useful programs that make your personal cloud valuable - like having a personal Netflix
|
||||
(media server), Google Drive (file storage), or Gmail (email server) running on your own hardware.
|
||||
Apps are the useful programs that make your personal cloud valuable - like having a personal Netflix
|
||||
(media server), Google Drive (file storage), or Gmail (email server) running on your own hardware.
|
||||
Instead of relying on big tech companies, you control your data and services.
|
||||
</p>
|
||||
<p className="text-pink-700 dark:text-pink-300 mb-4 text-sm">
|
||||
Your cluster can run databases, web servers, photo galleries, password managers, backup services, and much more.
|
||||
Your cluster can run databases, web servers, photo galleries, password managers, backup services, and much more.
|
||||
Each app runs in its own secure container, so they don't interfere with each other and can be easily managed.
|
||||
</p>
|
||||
<Button variant="outline" size="sm" className="text-pink-700 border-pink-300 hover:bg-pink-100 dark:text-pink-300 dark:border-pink-700 dark:hover:bg-pink-900/20">
|
||||
@@ -243,14 +337,14 @@ export function AppsComponent({ onComplete }: AppsComponentProps) {
|
||||
className="w-full pl-10 pr-4 py-2 border rounded-lg bg-background"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 overflow-x-auto">
|
||||
{categories.map(category => (
|
||||
<Button
|
||||
key={category}
|
||||
variant={selectedCategory === category ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
className="capitalize"
|
||||
className="capitalize whitespace-nowrap"
|
||||
>
|
||||
{category}
|
||||
</Button>
|
||||
@@ -260,135 +354,249 @@ export function AppsComponent({ onComplete }: AppsComponentProps) {
|
||||
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{runningApps} applications running • {applications.length} total available
|
||||
{isLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading apps...
|
||||
</span>
|
||||
) : (
|
||||
`${runningApps} applications running • ${installedApps.length} installed • ${availableApps.length} available`
|
||||
)}
|
||||
</div>
|
||||
<Button size="sm">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add App
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{filteredApps.map((app) => (
|
||||
<Card key={app.id} className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-muted rounded-lg">
|
||||
{getCategoryIcon(app.category)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-medium truncate">{app.name}</h3>
|
||||
{app.version && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{app.version}
|
||||
</Badge>
|
||||
)}
|
||||
{getStatusIcon(app.status)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-2">{app.description}</p>
|
||||
|
||||
{app.status === 'running' && (
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
{app.namespace && (
|
||||
<div>Namespace: {app.namespace}</div>
|
||||
)}
|
||||
{app.replicas && (
|
||||
<div>Replicas: {app.replicas}</div>
|
||||
)}
|
||||
{app.resources && (
|
||||
<div>Resources: {app.resources.cpu} CPU, {app.resources.memory} RAM</div>
|
||||
)}
|
||||
{app.urls && app.urls.length > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>URLs:</span>
|
||||
{app.urls.map((url, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto p-0 text-xs"
|
||||
onClick={() => window.open(url, '_blank')}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 mr-1" />
|
||||
Access
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Card className="p-8 text-center">
|
||||
<Loader2 className="h-12 w-12 text-primary mx-auto mb-4 animate-spin" />
|
||||
<p className="text-muted-foreground">Loading applications...</p>
|
||||
</Card>
|
||||
) : activeTab === 'available' ? (
|
||||
// Available Apps Grid
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredApps.map((app) => (
|
||||
<Card key={app.name} className="p-4 hover:shadow-lg transition-shadow">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<AppIcon app={app} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-medium truncate">{app.name}</h3>
|
||||
</div>
|
||||
{app.version && (
|
||||
<Badge variant="outline" className="text-xs mb-2">
|
||||
{app.version}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">{app.description}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleAppAction(app, 'configure')}
|
||||
disabled={isAdding}
|
||||
className="w-full"
|
||||
>
|
||||
{isAdding ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Configure & Add'}
|
||||
</Button>
|
||||
</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')}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// Installed Apps List
|
||||
<div className="space-y-3">
|
||||
{filteredApps.map((app) => (
|
||||
<Card key={app.name} className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AppIcon app={app} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-medium truncate">{app.name}</h3>
|
||||
{app.version && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{app.version}
|
||||
</Badge>
|
||||
)}
|
||||
{getStatusIcon(app.status?.status || app.deploymentStatus)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-2">{app.description}</p>
|
||||
|
||||
{/* Show ingress URL if available */}
|
||||
{app.url && (
|
||||
<a
|
||||
href={app.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-primary hover:underline mb-2"
|
||||
>
|
||||
Install
|
||||
</Button>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{app.url}
|
||||
</a>
|
||||
)}
|
||||
{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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredApps.length === 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{getStatusBadge(app)}
|
||||
<div className="flex flex-col gap-1">
|
||||
{/* Available: not added yet - shouldn't show here */}
|
||||
|
||||
{/* 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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Deployed: running in Kubernetes */}
|
||||
{app.deploymentStatus === 'deployed' && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleAppAction(app, 'view')}
|
||||
title="View details"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
{app.status?.status === 'running' && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleAppAction(app, 'backup')}
|
||||
disabled={isBackingUp}
|
||||
title="Create backup"
|
||||
>
|
||||
<Archive className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleAppAction(app, 'restore')}
|
||||
disabled={isRestoring}
|
||||
title="Restore from backup"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleAppAction(app, 'delete')}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && filteredApps.length === 0 && (
|
||||
<Card className="p-8 text-center">
|
||||
<AppWindow className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">No applications found</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{searchTerm || selectedCategory !== 'all'
|
||||
{searchTerm || selectedCategory !== 'all'
|
||||
? 'Try adjusting your search or category filter'
|
||||
: 'Install your first application to get started'
|
||||
: activeTab === 'available'
|
||||
? 'All available apps have been installed'
|
||||
: 'No apps are currently installed'
|
||||
}
|
||||
</p>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Browse App Catalog
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Backup Modal */}
|
||||
<BackupRestoreModal
|
||||
isOpen={backupModalOpen}
|
||||
onClose={() => {
|
||||
setBackupModalOpen(false);
|
||||
setSelectedAppForBackup(null);
|
||||
}}
|
||||
mode="backup"
|
||||
appName={selectedAppForBackup || ''}
|
||||
onConfirm={handleBackupConfirm}
|
||||
isPending={isBackingUp}
|
||||
/>
|
||||
|
||||
{/* Restore Modal */}
|
||||
<BackupRestoreModal
|
||||
isOpen={restoreModalOpen}
|
||||
onClose={() => {
|
||||
setRestoreModalOpen(false);
|
||||
setSelectedAppForBackup(null);
|
||||
}}
|
||||
mode="restore"
|
||||
appName={selectedAppForBackup || ''}
|
||||
backups={backups?.backups || []}
|
||||
isLoading={backupsLoading}
|
||||
onConfirm={handleRestoreConfirm}
|
||||
isPending={isRestoring}
|
||||
/>
|
||||
|
||||
{/* App Configuration Dialog */}
|
||||
<AppConfigDialog
|
||||
open={configDialogOpen}
|
||||
onOpenChange={setConfigDialogOpen}
|
||||
app={selectedAppForConfig}
|
||||
existingConfig={selectedAppForConfig?.config}
|
||||
onSave={handleConfigSave}
|
||||
isSaving={isAdding}
|
||||
/>
|
||||
|
||||
{/* App Detail Modal */}
|
||||
{selectedAppForDetail && currentInstance && (
|
||||
<AppDetailModal
|
||||
instanceName={currentInstance}
|
||||
appName={selectedAppForDetail}
|
||||
open={detailModalOpen}
|
||||
onClose={() => {
|
||||
setDetailModalOpen(false);
|
||||
setSelectedAppForDetail(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
158
src/components/BackupRestoreModal.tsx
Normal file
158
src/components/BackupRestoreModal.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Loader2, AlertCircle, Clock, HardDrive } from 'lucide-react';
|
||||
|
||||
interface Backup {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
interface BackupRestoreModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
mode: 'backup' | 'restore';
|
||||
appName: string;
|
||||
backups?: Backup[];
|
||||
isLoading?: boolean;
|
||||
onConfirm: (backupId?: string) => void;
|
||||
isPending?: boolean;
|
||||
}
|
||||
|
||||
export function BackupRestoreModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
mode,
|
||||
appName,
|
||||
backups = [],
|
||||
isLoading = false,
|
||||
onConfirm,
|
||||
isPending = false,
|
||||
}: BackupRestoreModalProps) {
|
||||
const [selectedBackupId, setSelectedBackupId] = useState<string | null>(null);
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (mode === 'backup') {
|
||||
onConfirm();
|
||||
} else if (mode === 'restore' && selectedBackupId) {
|
||||
onConfirm(selectedBackupId);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
try {
|
||||
return new Date(timestamp).toLocaleString();
|
||||
} catch {
|
||||
return timestamp;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === 'backup' ? 'Create Backup' : 'Restore from Backup'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{mode === 'backup'
|
||||
? `Create a backup of the ${appName} application data.`
|
||||
: `Select a backup to restore for the ${appName} application.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{mode === 'backup' ? (
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This will create a new backup of the current application state. The backup
|
||||
process may take a few minutes depending on the size of the data.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : backups.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No backups available for this application.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{backups.map((backup) => (
|
||||
<button
|
||||
key={backup.id}
|
||||
onClick={() => setSelectedBackupId(backup.id)}
|
||||
className={`w-full p-3 rounded-lg border text-left transition-colors ${
|
||||
selectedBackupId === backup.id
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{formatTimestamp(backup.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
{selectedBackupId === backup.id && (
|
||||
<Badge variant="default">Selected</Badge>
|
||||
)}
|
||||
</div>
|
||||
{backup.size && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<HardDrive className="h-3 w-3" />
|
||||
<span>{backup.size}</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={
|
||||
isPending ||
|
||||
(mode === 'restore' && (!selectedBackupId || backups.length === 0))
|
||||
}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
{mode === 'backup' ? 'Creating...' : 'Restoring...'}
|
||||
</>
|
||||
) : mode === 'backup' ? (
|
||||
'Create Backup'
|
||||
) : (
|
||||
'Restore'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,48 @@
|
||||
import { Card } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { Server, Network, Settings, Clock, HelpCircle, CheckCircle, BookOpen, ExternalLink } from 'lucide-react';
|
||||
import { Input, Label } from './ui';
|
||||
import { Server, HardDrive, Settings, Clock, CheckCircle, BookOpen, ExternalLink, Loader2, AlertCircle, Database, FolderTree } from 'lucide-react';
|
||||
import { Badge } from './ui/badge';
|
||||
import { useCentralStatus } from '../hooks/useCentralStatus';
|
||||
import { useInstanceConfig, useInstanceContext } from '../hooks';
|
||||
|
||||
export function CentralComponent() {
|
||||
const { currentInstance } = useInstanceContext();
|
||||
const { data: centralStatus, isLoading: statusLoading, error: statusError } = useCentralStatus();
|
||||
const { config: fullConfig, isLoading: configLoading } = useInstanceConfig(currentInstance);
|
||||
|
||||
const serverConfig = fullConfig?.server as { host?: string; port?: number } | undefined;
|
||||
|
||||
const formatUptime = (seconds?: number) => {
|
||||
if (!seconds) return 'Unknown';
|
||||
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
const parts = [];
|
||||
if (days > 0) parts.push(`${days}d`);
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
if (minutes > 0) parts.push(`${minutes}m`);
|
||||
if (secs > 0 || parts.length === 0) parts.push(`${secs}s`);
|
||||
|
||||
return parts.join(' ');
|
||||
};
|
||||
|
||||
// Show error state
|
||||
if (statusError) {
|
||||
return (
|
||||
<Card className="p-8 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">Error Loading Central Status</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{(statusError as Error)?.message || 'An error occurred'}
|
||||
</p>
|
||||
<Button onClick={() => window.location.reload()}>Reload Page</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Educational Intro Section */}
|
||||
@@ -17,8 +56,8 @@ export function CentralComponent() {
|
||||
What is the Central Service?
|
||||
</h3>
|
||||
<p className="text-blue-800 dark:text-blue-200 mb-3 leading-relaxed">
|
||||
The Central Service is the "brain" of your personal cloud. It acts as the main coordinator that manages
|
||||
all the different services running on your network. Think of it like the control tower at an airport -
|
||||
The Central Service is the "brain" of your personal cloud. It acts as the main coordinator that manages
|
||||
all the different services running on your network. Think of it like the control tower at an airport -
|
||||
it keeps track of what's happening, routes traffic between services, and ensures everything works together smoothly.
|
||||
</p>
|
||||
<p className="text-blue-700 dark:text-blue-300 mb-4 text-sm">
|
||||
@@ -37,78 +76,114 @@ export function CentralComponent() {
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<Server className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">Central Service</h2>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-2xl font-semibold">Central Service Status</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Monitor and manage the central server service
|
||||
Monitor the Wild Central server
|
||||
</p>
|
||||
</div>
|
||||
{centralStatus && (
|
||||
<Badge variant="success" className="flex items-center gap-2">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
{centralStatus.status === 'running' ? 'Running' : centralStatus.status}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-4">Service Status</h3>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">IP Address: 192.168.8.50</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Network className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Network: 192.168.8.0/24</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Version: 1.0.0 (update available)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Age: 12s</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<HelpCircle className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Platform: ARM</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
<span className="text-sm text-green-500">File permissions: Good</span>
|
||||
</div>
|
||||
{statusLoading || configLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Server Information */}
|
||||
<div>
|
||||
<Label htmlFor="ip">IP</Label>
|
||||
<div className="flex w-full items-center mt-1">
|
||||
<Input id="ip" value="192.168.5.80"/>
|
||||
<Button variant="ghost">
|
||||
<HelpCircle/>
|
||||
</Button>
|
||||
<h3 className="text-lg font-medium mb-4">Server Information</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Card className="p-4 border-l-4 border-l-blue-500">
|
||||
<div className="flex items-start gap-3">
|
||||
<Settings className="h-5 w-5 text-blue-500 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-muted-foreground mb-1">Version</div>
|
||||
<div className="font-medium font-mono">{centralStatus?.version || 'Unknown'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 border-l-4 border-l-green-500">
|
||||
<div className="flex items-start gap-3">
|
||||
<Clock className="h-5 w-5 text-green-500 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-muted-foreground mb-1">Uptime</div>
|
||||
<div className="font-medium">{formatUptime(centralStatus?.uptimeSeconds)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 border-l-4 border-l-purple-500">
|
||||
<div className="flex items-start gap-3">
|
||||
<Database className="h-5 w-5 text-purple-500 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-muted-foreground mb-1">Instances</div>
|
||||
<div className="font-medium">{centralStatus?.instances.count || 0} configured</div>
|
||||
{centralStatus?.instances.names && centralStatus.instances.names.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{centralStatus.instances.names.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration */}
|
||||
<div>
|
||||
<Label htmlFor="interface">Interface</Label>
|
||||
<div className="flex w-full items-center mt-1">
|
||||
<Input id="interface" value="eth0"/>
|
||||
<Button variant="ghost">
|
||||
<HelpCircle/>
|
||||
</Button>
|
||||
<h3 className="text-lg font-medium mb-4">Configuration</h3>
|
||||
<div className="space-y-3">
|
||||
<Card className="p-4 border-l-4 border-l-cyan-500">
|
||||
<div className="flex items-start gap-3">
|
||||
<Server className="h-5 w-5 text-cyan-500 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-muted-foreground mb-1">Server Host</div>
|
||||
<div className="font-medium font-mono">{serverConfig?.host || '0.0.0.0'}</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-muted-foreground mb-1">Server Port</div>
|
||||
<div className="font-medium font-mono">{serverConfig?.port || 5055}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 border-l-4 border-l-indigo-500">
|
||||
<div className="flex items-start gap-3">
|
||||
<HardDrive className="h-5 w-5 text-indigo-500 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-muted-foreground mb-1">Data Directory</div>
|
||||
<div className="font-medium font-mono text-sm break-all">
|
||||
{centralStatus?.dataDir || '/var/lib/wild-central'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 border-l-4 border-l-pink-500">
|
||||
<div className="flex items-start gap-3">
|
||||
<FolderTree className="h-5 w-5 text-pink-500 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-muted-foreground mb-1">Apps Directory</div>
|
||||
<div className="font-medium font-mono text-sm break-all">
|
||||
{centralStatus?.appsDir || '/opt/wild-cloud/apps'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end mt-4">
|
||||
<Button onClick={() => console.log('Update service')}>
|
||||
Update
|
||||
</Button>
|
||||
<Button onClick={() => console.log('Restart service')}>
|
||||
Restart
|
||||
</Button>
|
||||
<Button onClick={() => console.log('View log')}>
|
||||
View log
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,243 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card } from "./ui/card";
|
||||
import { Button } from "./ui/button";
|
||||
import { Cloud, HelpCircle, Edit2, Check, X } from "lucide-react";
|
||||
import { Cloud, HelpCircle, Edit2, Check, X, Loader2, AlertCircle } from "lucide-react";
|
||||
import { Input, Label } from "./ui";
|
||||
import { useInstanceConfig, useInstanceContext } from "../hooks";
|
||||
|
||||
interface CloudConfig {
|
||||
domain: string;
|
||||
internalDomain: string;
|
||||
dhcpRange: string;
|
||||
dns: {
|
||||
ip: string;
|
||||
};
|
||||
router: {
|
||||
ip: string;
|
||||
};
|
||||
dnsmasq: {
|
||||
interface: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ClusterConfig {
|
||||
endpointIp: string;
|
||||
hostnamePrefix?: string;
|
||||
nodes: {
|
||||
talos: {
|
||||
version: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function CloudComponent() {
|
||||
const [domainValue, setDomainValue] = useState("cloud.payne.io");
|
||||
const [internalDomainValue, setInternalDomainValue] = useState(
|
||||
"internal.cloud.payne.io"
|
||||
);
|
||||
const { currentInstance } = useInstanceContext();
|
||||
const { config: fullConfig, isLoading, error, updateConfig, isUpdating } = useInstanceConfig(currentInstance);
|
||||
|
||||
// Extract cloud and cluster config from full config
|
||||
const config = fullConfig?.cloud as CloudConfig | undefined;
|
||||
const clusterConfig = fullConfig?.cluster as ClusterConfig | undefined;
|
||||
|
||||
const [editingDomains, setEditingDomains] = useState(false);
|
||||
const [editingNetwork, setEditingNetwork] = useState(false);
|
||||
const [editingCluster, setEditingCluster] = useState(false);
|
||||
const [formValues, setFormValues] = useState<CloudConfig | null>(null);
|
||||
const [clusterFormValues, setClusterFormValues] = useState<ClusterConfig | null>(null);
|
||||
|
||||
const [tempDomain, setTempDomain] = useState(domainValue);
|
||||
const [tempInternalDomain, setTempInternalDomain] =
|
||||
useState(internalDomainValue);
|
||||
// Sync form values when config loads
|
||||
useEffect(() => {
|
||||
if (config && !formValues) {
|
||||
setFormValues(config as CloudConfig);
|
||||
}
|
||||
if (clusterConfig && !clusterFormValues) {
|
||||
setClusterFormValues(clusterConfig as ClusterConfig);
|
||||
}
|
||||
}, [config, clusterConfig, formValues, clusterFormValues]);
|
||||
|
||||
const handleDomainsEdit = () => {
|
||||
setTempDomain(domainValue);
|
||||
setTempInternalDomain(internalDomainValue);
|
||||
setEditingDomains(true);
|
||||
if (config) {
|
||||
setFormValues(config as CloudConfig);
|
||||
setEditingDomains(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDomainsSave = () => {
|
||||
setDomainValue(tempDomain);
|
||||
setInternalDomainValue(tempInternalDomain);
|
||||
setEditingDomains(false);
|
||||
const handleNetworkEdit = () => {
|
||||
if (config) {
|
||||
setFormValues(config as CloudConfig);
|
||||
setEditingNetwork(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDomainsSave = async () => {
|
||||
if (!formValues || !fullConfig) return;
|
||||
|
||||
try {
|
||||
// Update only the cloud section, preserving other config sections
|
||||
await updateConfig({
|
||||
...fullConfig,
|
||||
cloud: {
|
||||
domain: formValues.domain,
|
||||
internalDomain: formValues.internalDomain,
|
||||
dhcpRange: formValues.dhcpRange,
|
||||
dns: formValues.dns,
|
||||
router: formValues.router,
|
||||
dnsmasq: formValues.dnsmasq,
|
||||
},
|
||||
});
|
||||
setEditingDomains(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to save domains:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNetworkSave = async () => {
|
||||
if (!formValues || !fullConfig) return;
|
||||
|
||||
try {
|
||||
// Update only the cloud section, preserving other config sections
|
||||
await updateConfig({
|
||||
...fullConfig,
|
||||
cloud: {
|
||||
domain: formValues.domain,
|
||||
internalDomain: formValues.internalDomain,
|
||||
dhcpRange: formValues.dhcpRange,
|
||||
dns: formValues.dns,
|
||||
router: formValues.router,
|
||||
dnsmasq: formValues.dnsmasq,
|
||||
},
|
||||
});
|
||||
setEditingNetwork(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to save network settings:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDomainsCancel = () => {
|
||||
setTempDomain(domainValue);
|
||||
setTempInternalDomain(internalDomainValue);
|
||||
setFormValues(config as CloudConfig);
|
||||
setEditingDomains(false);
|
||||
};
|
||||
|
||||
const handleNetworkCancel = () => {
|
||||
setFormValues(config as CloudConfig);
|
||||
setEditingNetwork(false);
|
||||
};
|
||||
|
||||
const handleClusterEdit = () => {
|
||||
if (clusterConfig) {
|
||||
setClusterFormValues(clusterConfig as ClusterConfig);
|
||||
setEditingCluster(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClusterSave = async () => {
|
||||
if (!clusterFormValues || !fullConfig) return;
|
||||
|
||||
try {
|
||||
// Update only the cluster section, preserving other config sections
|
||||
await updateConfig({
|
||||
...fullConfig,
|
||||
cluster: clusterFormValues,
|
||||
});
|
||||
setEditingCluster(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to save cluster settings:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClusterCancel = () => {
|
||||
setClusterFormValues(clusterConfig as ClusterConfig);
|
||||
setEditingCluster(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,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const updateClusterFormValue = (path: string, value: string) => {
|
||||
if (!clusterFormValues) return;
|
||||
|
||||
setClusterFormValues(prev => {
|
||||
if (!prev) return prev;
|
||||
|
||||
// Handle nested paths like "nodes.talos.version"
|
||||
const keys = path.split('.');
|
||||
if (keys.length === 1) {
|
||||
return { ...prev, [keys[0]]: value };
|
||||
}
|
||||
|
||||
if (keys.length === 3 && keys[0] === 'nodes' && keys[1] === 'talos') {
|
||||
return {
|
||||
...prev,
|
||||
nodes: {
|
||||
...prev.nodes,
|
||||
talos: {
|
||||
...prev.nodes.talos,
|
||||
[keys[2]]: value,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
|
||||
// Show message if no instance is selected
|
||||
if (!currentInstance) {
|
||||
return (
|
||||
<Card className="p-8 text-center">
|
||||
<Cloud className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">No Instance Selected</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Please select or create an instance to manage cloud configuration.
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
if (isLoading || !formValues) {
|
||||
return (
|
||||
<Card className="p-8 text-center">
|
||||
<Loader2 className="h-12 w-12 text-primary mx-auto mb-4 animate-spin" />
|
||||
<p className="text-muted-foreground">Loading cloud configuration...</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Show error state
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="p-8 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">Error Loading Configuration</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{(error as Error)?.message || 'An error occurred'}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="p-6">
|
||||
@@ -51,7 +255,7 @@ export function CloudComponent() {
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Domains Section */}
|
||||
<Card className="p-4 border-l-4 border-l-green-500">
|
||||
<Card className="p-4 border-l-4 border-l-blue-500">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="font-medium">Domain Configuration</h3>
|
||||
@@ -68,6 +272,7 @@ export function CloudComponent() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDomainsEdit}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<Edit2 className="h-4 w-4 mr-1" />
|
||||
Edit
|
||||
@@ -82,8 +287,8 @@ export function CloudComponent() {
|
||||
<Label htmlFor="domain-edit">Public Domain</Label>
|
||||
<Input
|
||||
id="domain-edit"
|
||||
value={tempDomain}
|
||||
onChange={(e) => setTempDomain(e.target.value)}
|
||||
value={formValues.domain}
|
||||
onChange={(e) => updateFormValue('domain', e.target.value)}
|
||||
placeholder="example.com"
|
||||
className="mt-1"
|
||||
/>
|
||||
@@ -92,21 +297,26 @@ export function CloudComponent() {
|
||||
<Label htmlFor="internal-domain-edit">Internal Domain</Label>
|
||||
<Input
|
||||
id="internal-domain-edit"
|
||||
value={tempInternalDomain}
|
||||
onChange={(e) => setTempInternalDomain(e.target.value)}
|
||||
value={formValues.internalDomain}
|
||||
onChange={(e) => updateFormValue('internalDomain', e.target.value)}
|
||||
placeholder="internal.example.com"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleDomainsSave}>
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
<Button size="sm" onClick={handleDomainsSave} disabled={isUpdating}>
|
||||
{isUpdating ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDomainsCancel}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Cancel
|
||||
@@ -118,18 +328,254 @@ export function CloudComponent() {
|
||||
<div>
|
||||
<Label>Public Domain</Label>
|
||||
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
|
||||
{domainValue}
|
||||
{formValues.domain}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Internal Domain</Label>
|
||||
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
|
||||
{internalDomainValue}
|
||||
{formValues.internalDomain}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Network Configuration Section */}
|
||||
<Card className="p-4 border-l-4 border-l-green-500">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="font-medium">Network Configuration</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Network settings and DHCP configuration
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm">
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
{!editingNetwork && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNetworkEdit}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<Edit2 className="h-4 w-4 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingNetwork ? (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="dhcp-range-edit">DHCP Range</Label>
|
||||
<Input
|
||||
id="dhcp-range-edit"
|
||||
value={formValues.dhcpRange}
|
||||
onChange={(e) => updateFormValue('dhcpRange', e.target.value)}
|
||||
placeholder="192.168.1.100,192.168.1.200"
|
||||
className="mt-1"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Format: start_ip,end_ip
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="dns-ip-edit">DNS Server IP</Label>
|
||||
<Input
|
||||
id="dns-ip-edit"
|
||||
value={formValues.dns.ip}
|
||||
onChange={(e) => updateFormValue('dns.ip', e.target.value)}
|
||||
placeholder="192.168.1.1"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="router-ip-edit">Router IP</Label>
|
||||
<Input
|
||||
id="router-ip-edit"
|
||||
value={formValues.router.ip}
|
||||
onChange={(e) => updateFormValue('router.ip', e.target.value)}
|
||||
placeholder="192.168.1.1"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="dnsmasq-interface-edit">Dnsmasq Interface</Label>
|
||||
<Input
|
||||
id="dnsmasq-interface-edit"
|
||||
value={formValues.dnsmasq.interface}
|
||||
onChange={(e) => updateFormValue('dnsmasq.interface', e.target.value)}
|
||||
placeholder="eth0"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleNetworkSave} disabled={isUpdating}>
|
||||
{isUpdating ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNetworkCancel}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>DHCP Range</Label>
|
||||
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
|
||||
{formValues.dhcpRange}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>DNS Server IP</Label>
|
||||
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
|
||||
{formValues.dns.ip}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Router IP</Label>
|
||||
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
|
||||
{formValues.router.ip}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Dnsmasq Interface</Label>
|
||||
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
|
||||
{formValues.dnsmasq.interface}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Cluster Configuration Section */}
|
||||
{clusterFormValues && (
|
||||
<Card className="p-4 border-l-4 border-l-purple-500">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="font-medium">Cluster Configuration</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Kubernetes cluster and node settings
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm">
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
{!editingCluster && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClusterEdit}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<Edit2 className="h-4 w-4 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingCluster ? (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="endpoint-ip-edit">Cluster Endpoint IP</Label>
|
||||
<Input
|
||||
id="endpoint-ip-edit"
|
||||
value={clusterFormValues.endpointIp}
|
||||
onChange={(e) => updateClusterFormValue('endpointIp', e.target.value)}
|
||||
placeholder="192.168.1.60"
|
||||
className="mt-1"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Virtual IP for the Kubernetes API endpoint
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="hostname-prefix-edit">Hostname Prefix (Optional)</Label>
|
||||
<Input
|
||||
id="hostname-prefix-edit"
|
||||
value={clusterFormValues.hostnamePrefix || ''}
|
||||
onChange={(e) => updateClusterFormValue('hostnamePrefix', e.target.value)}
|
||||
placeholder="mycluster-"
|
||||
className="mt-1"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Prefix for auto-generated node hostnames (e.g., "mycluster-control-1")
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="talos-version-edit">Talos Version</Label>
|
||||
<Input
|
||||
id="talos-version-edit"
|
||||
value={clusterFormValues.nodes.talos.version}
|
||||
onChange={(e) => updateClusterFormValue('nodes.talos.version', e.target.value)}
|
||||
placeholder="v1.8.0"
|
||||
className="mt-1"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Talos Linux version for cluster nodes
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleClusterSave} 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={handleClusterCancel}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Cluster Endpoint IP</Label>
|
||||
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
|
||||
{clusterFormValues.endpointIp}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Hostname Prefix</Label>
|
||||
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
|
||||
{clusterFormValues.hostnamePrefix || '(none)'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Talos Version</Label>
|
||||
<div className="mt-1 p-2 bg-muted rounded-md font-mono text-sm">
|
||||
{clusterFormValues.nodes.talos.version}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,152 +1,342 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Card } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Cpu, HardDrive, Network, Monitor, Plus, CheckCircle, AlertCircle, Clock, BookOpen, ExternalLink } from 'lucide-react';
|
||||
import { Alert } from './ui/alert';
|
||||
import { Input } from './ui/input';
|
||||
import { Cpu, HardDrive, Network, Monitor, CheckCircle, AlertCircle, BookOpen, ExternalLink, Loader2 } from 'lucide-react';
|
||||
import { useInstanceContext } from '../hooks/useInstanceContext';
|
||||
import { useNodes, useDiscoveryStatus } from '../hooks/useNodes';
|
||||
import { useCluster } from '../hooks/useCluster';
|
||||
import { useClusterStatus } from '../services/api/hooks/useCluster';
|
||||
import { BootstrapModal } from './cluster/BootstrapModal';
|
||||
import { NodeStatusBadge } from './nodes/NodeStatusBadge';
|
||||
import { NodeFormDrawer } from './nodes/NodeFormDrawer';
|
||||
import type { NodeFormData } from './nodes/NodeForm';
|
||||
import type { Node, HardwareInfo, DiscoveredNode } from '../services/api/types';
|
||||
|
||||
interface ClusterNodesComponentProps {
|
||||
onComplete?: () => void;
|
||||
filterRole?: 'controlplane' | 'worker';
|
||||
hideDiscoveryWhenNodesGte?: number;
|
||||
showBootstrap?: boolean;
|
||||
}
|
||||
|
||||
interface Node {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'controller' | 'worker' | 'unassigned';
|
||||
status: 'pending' | 'connecting' | 'connected' | 'healthy' | 'error';
|
||||
ipAddress?: string;
|
||||
macAddress: string;
|
||||
osVersion?: string;
|
||||
specs: {
|
||||
cpu: string;
|
||||
memory: string;
|
||||
storage: string;
|
||||
};
|
||||
}
|
||||
export function ClusterNodesComponent({
|
||||
filterRole,
|
||||
hideDiscoveryWhenNodesGte,
|
||||
showBootstrap = true
|
||||
}: ClusterNodesComponentProps = {}) {
|
||||
const { currentInstance } = useInstanceContext();
|
||||
const {
|
||||
nodes,
|
||||
isLoading,
|
||||
error,
|
||||
addNode,
|
||||
addError,
|
||||
deleteNode,
|
||||
deleteError,
|
||||
discover,
|
||||
isDiscovering,
|
||||
discoverError: discoverMutationError,
|
||||
getHardware,
|
||||
isGettingHardware,
|
||||
getHardwareError,
|
||||
cancelDiscovery,
|
||||
isCancellingDiscovery,
|
||||
updateNode,
|
||||
applyNode,
|
||||
isApplying,
|
||||
refetch
|
||||
} = useNodes(currentInstance);
|
||||
|
||||
export function ClusterNodesComponent({ onComplete }: ClusterNodesComponentProps) {
|
||||
const [currentOsVersion, setCurrentOsVersion] = useState('v13.0.5');
|
||||
const [nodes, setNodes] = useState<Node[]>([
|
||||
{
|
||||
id: 'controller-1',
|
||||
name: 'Controller Node 1',
|
||||
type: 'controller',
|
||||
status: 'healthy',
|
||||
macAddress: '00:1A:2B:3C:4D:5E',
|
||||
osVersion: 'v13.0.4',
|
||||
specs: {
|
||||
cpu: '4 cores',
|
||||
memory: '8GB RAM',
|
||||
storage: '120GB SSD',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'worker-1',
|
||||
name: 'Worker Node 1',
|
||||
type: 'worker',
|
||||
status: 'healthy',
|
||||
macAddress: '00:1A:2B:3C:4D:5F',
|
||||
osVersion: 'v13.0.5',
|
||||
specs: {
|
||||
cpu: '8 cores',
|
||||
memory: '16GB RAM',
|
||||
storage: '500GB SSD',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'worker-2',
|
||||
name: 'Worker Node 2',
|
||||
type: 'worker',
|
||||
status: 'healthy',
|
||||
macAddress: '00:1A:2B:3C:4D:60',
|
||||
osVersion: 'v13.0.4',
|
||||
specs: {
|
||||
cpu: '8 cores',
|
||||
memory: '16GB RAM',
|
||||
storage: '500GB SSD',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'node-1',
|
||||
name: 'Node 1',
|
||||
type: 'unassigned',
|
||||
status: 'pending',
|
||||
macAddress: '00:1A:2B:3C:4D:5E',
|
||||
osVersion: 'v13.0.5',
|
||||
specs: {
|
||||
cpu: '4 cores',
|
||||
memory: '8GB RAM',
|
||||
storage: '120GB SSD',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'node-2',
|
||||
name: 'Node 2',
|
||||
type: 'unassigned',
|
||||
status: 'pending',
|
||||
macAddress: '00:1A:2B:3C:4D:5F',
|
||||
osVersion: 'v13.0.5',
|
||||
specs: {
|
||||
cpu: '8 cores',
|
||||
memory: '16GB RAM',
|
||||
storage: '500GB SSD',
|
||||
},
|
||||
},
|
||||
]);
|
||||
const {
|
||||
data: discoveryStatus
|
||||
} = useDiscoveryStatus(currentInstance);
|
||||
|
||||
const getStatusIcon = (status: Node['status']) => {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
||||
case 'error':
|
||||
return <AlertCircle className="h-5 w-5 text-red-500" />;
|
||||
case 'connecting':
|
||||
return <Clock className="h-5 w-5 text-blue-500 animate-spin" />;
|
||||
default:
|
||||
return <Monitor className="h-5 w-5 text-muted-foreground" />;
|
||||
const {
|
||||
status: clusterStatus
|
||||
} = useCluster(currentInstance);
|
||||
|
||||
const { data: clusterStatusData } = useClusterStatus(currentInstance || '');
|
||||
|
||||
const [addNodeIp, setAddNodeIp] = useState('');
|
||||
const [discoverError, setDiscoverError] = useState<string | null>(null);
|
||||
const [detectError, setDetectError] = useState<string | null>(null);
|
||||
const [discoverSuccess, setDiscoverSuccess] = useState<string | null>(null);
|
||||
const [showBootstrapModal, setShowBootstrapModal] = useState(false);
|
||||
const [bootstrapNode, setBootstrapNode] = useState<{ name: string; ip: string } | null>(null);
|
||||
const [drawerState, setDrawerState] = useState<{
|
||||
open: boolean;
|
||||
mode: 'add' | 'configure';
|
||||
node?: Node;
|
||||
detection?: HardwareInfo;
|
||||
}>({
|
||||
open: false,
|
||||
mode: 'add',
|
||||
});
|
||||
const [drawerEverOpened, setDrawerEverOpened] = useState(false);
|
||||
const [deletingNodeHostname, setDeletingNodeHostname] = useState<string | null>(null);
|
||||
|
||||
const closeDrawer = () => setDrawerState({ ...drawerState, open: false });
|
||||
|
||||
// Sync mutation errors to local state for display
|
||||
useEffect(() => {
|
||||
if (discoverMutationError) {
|
||||
const errorMsg = (discoverMutationError as any)?.message || 'Failed to discover nodes';
|
||||
setDiscoverError(errorMsg);
|
||||
}
|
||||
};
|
||||
}, [discoverMutationError]);
|
||||
|
||||
const getStatusBadge = (status: Node['status']) => {
|
||||
const variants = {
|
||||
pending: 'secondary',
|
||||
connecting: 'default',
|
||||
connected: 'success',
|
||||
healthy: 'success',
|
||||
error: 'destructive',
|
||||
} as const;
|
||||
useEffect(() => {
|
||||
if (getHardwareError) {
|
||||
const errorMsg = (getHardwareError as any)?.message || 'Failed to detect hardware';
|
||||
setDetectError(errorMsg);
|
||||
}
|
||||
}, [getHardwareError]);
|
||||
|
||||
const labels = {
|
||||
pending: 'Pending',
|
||||
connecting: 'Connecting',
|
||||
connected: 'Connected',
|
||||
healthy: 'Healthy',
|
||||
error: 'Error',
|
||||
};
|
||||
// Track previous discovery status to detect completion
|
||||
const [prevDiscoveryActive, setPrevDiscoveryActive] = useState<boolean | null>(null);
|
||||
|
||||
return (
|
||||
<Badge variant={variants[status] as any}>
|
||||
{labels[status]}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
// Handle discovery completion (when active changes from true to false)
|
||||
useEffect(() => {
|
||||
const isActive = discoveryStatus?.active ?? false;
|
||||
|
||||
const getTypeIcon = (type: Node['type']) => {
|
||||
return type === 'controller' ? (
|
||||
// Discovery just completed (was active, now inactive)
|
||||
if (prevDiscoveryActive === true && isActive === false && discoveryStatus) {
|
||||
const count = discoveryStatus.nodes_found?.length || 0;
|
||||
if (count === 0) {
|
||||
setDiscoverSuccess(`Discovery complete! No nodes were found.`);
|
||||
} else {
|
||||
setDiscoverSuccess(`Discovery complete! Found ${count} node${count !== 1 ? 's' : ''}.`);
|
||||
}
|
||||
setDiscoverError(null);
|
||||
refetch();
|
||||
|
||||
const timer = setTimeout(() => setDiscoverSuccess(null), 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
|
||||
// Update previous state
|
||||
setPrevDiscoveryActive(isActive);
|
||||
}, [discoveryStatus, prevDiscoveryActive, refetch]);
|
||||
|
||||
const getRoleIcon = (role: string) => {
|
||||
return role === 'controlplane' ? (
|
||||
<Cpu className="h-4 w-4" />
|
||||
) : (
|
||||
<HardDrive className="h-4 w-4" />
|
||||
);
|
||||
};
|
||||
|
||||
const handleNodeAction = (nodeId: string, action: 'connect' | 'retry' | 'upgrade_node') => {
|
||||
console.log(`${action} node: ${nodeId}`);
|
||||
const handleAddFromDiscovery = async (discovered: DiscoveredNode) => {
|
||||
// Fetch full hardware details for the discovered node
|
||||
try {
|
||||
const hardware = await getHardware(discovered.ip);
|
||||
setDrawerEverOpened(true);
|
||||
setDrawerState({
|
||||
open: true,
|
||||
mode: 'add',
|
||||
detection: hardware,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to detect hardware:', err);
|
||||
setDetectError((err as any)?.message || 'Failed to detect hardware');
|
||||
}
|
||||
};
|
||||
|
||||
const connectedNodes = nodes.filter(node => node.status === 'connected').length;
|
||||
const assignedNodes = nodes.filter(node => node.type !== 'unassigned');
|
||||
const unassignedNodes = nodes.filter(node => node.type === 'unassigned');
|
||||
const totalNodes = nodes.length;
|
||||
const isComplete = connectedNodes === totalNodes;
|
||||
const handleAddNode = async () => {
|
||||
if (!addNodeIp) return;
|
||||
|
||||
try {
|
||||
const hardware = await getHardware(addNodeIp);
|
||||
setDrawerEverOpened(true);
|
||||
setDrawerState({
|
||||
open: true,
|
||||
mode: 'add',
|
||||
detection: hardware,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to detect hardware:', err);
|
||||
setDetectError((err as any)?.message || 'Failed to detect hardware');
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfigureNode = async (node: Node) => {
|
||||
// Try to detect hardware if target_ip is available
|
||||
if (node.target_ip) {
|
||||
try {
|
||||
const hardware = await getHardware(node.target_ip);
|
||||
setDrawerEverOpened(true);
|
||||
setDrawerState({
|
||||
open: true,
|
||||
mode: 'configure',
|
||||
node,
|
||||
detection: hardware,
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
console.error('Failed to detect hardware:', err);
|
||||
// Fall through to open drawer without detection data
|
||||
}
|
||||
}
|
||||
|
||||
// Open drawer without detection data (either no target_ip or detection failed)
|
||||
setDrawerEverOpened(true);
|
||||
setDrawerState({
|
||||
open: true,
|
||||
mode: 'configure',
|
||||
node,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddSubmit = async (data: NodeFormData) => {
|
||||
const nodeData = {
|
||||
hostname: data.hostname,
|
||||
role: filterRole || data.role,
|
||||
disk: data.disk,
|
||||
target_ip: data.targetIp,
|
||||
interface: data.interface,
|
||||
schematic_id: data.schematicId,
|
||||
maintenance: data.maintenance,
|
||||
};
|
||||
|
||||
// Add node configuration (if this fails, error is shown and drawer stays open)
|
||||
await addNode(nodeData);
|
||||
|
||||
// Apply configuration immediately for new nodes
|
||||
try {
|
||||
await applyNode(data.hostname);
|
||||
} catch (applyError) {
|
||||
// Apply failed but node is added - user can use Apply button on card
|
||||
console.error('Failed to apply node configuration:', applyError);
|
||||
}
|
||||
|
||||
closeDrawer();
|
||||
setAddNodeIp('');
|
||||
};
|
||||
|
||||
const handleConfigureSubmit = async (data: NodeFormData) => {
|
||||
if (!drawerState.node) return;
|
||||
|
||||
await updateNode({
|
||||
nodeName: drawerState.node.hostname,
|
||||
updates: {
|
||||
role: data.role,
|
||||
target_ip: data.targetIp,
|
||||
interface: data.interface,
|
||||
schematic_id: data.schematicId,
|
||||
maintenance: data.maintenance,
|
||||
},
|
||||
});
|
||||
closeDrawer();
|
||||
};
|
||||
|
||||
const handleApply = async (data: NodeFormData) => {
|
||||
if (!drawerState.node) return;
|
||||
|
||||
await handleConfigureSubmit(data);
|
||||
await applyNode(drawerState.node.hostname);
|
||||
};
|
||||
|
||||
const handleDeleteNode = async (hostname: string) => {
|
||||
if (!currentInstance) return;
|
||||
if (confirm(`Reset and remove node ${hostname}?\n\nThis will reset the node and remove it from the cluster. The node will reboot to maintenance mode and can be reconfigured.`)) {
|
||||
setDeletingNodeHostname(hostname);
|
||||
try {
|
||||
await deleteNode(hostname);
|
||||
} finally {
|
||||
setDeletingNodeHostname(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscover = () => {
|
||||
setDiscoverError(null);
|
||||
setDiscoverSuccess(null);
|
||||
// Always use auto-detect to scan all local networks
|
||||
discover(undefined);
|
||||
};
|
||||
|
||||
|
||||
// Derive status from backend state flags for each node
|
||||
const assignedNodes = useMemo(() => {
|
||||
const allNodes = nodes.map(node => {
|
||||
// Get runtime status from cluster status
|
||||
const runtimeStatus = clusterStatusData?.node_statuses?.[node.hostname];
|
||||
|
||||
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,
|
||||
isReachable: runtimeStatus?.ready,
|
||||
inKubernetes: runtimeStatus?.ready, // Whether in cluster (from backend 'ready' field)
|
||||
kubernetesReady: runtimeStatus?.kubernetes_ready, // Whether K8s Ready condition is true
|
||||
};
|
||||
});
|
||||
|
||||
// Filter by role if specified
|
||||
if (filterRole) {
|
||||
return allNodes.filter(node => node.role === filterRole);
|
||||
}
|
||||
return allNodes;
|
||||
}, [nodes, clusterStatusData, filterRole]);
|
||||
|
||||
// Check if cluster needs bootstrap
|
||||
const needsBootstrap = useMemo(() => {
|
||||
// Find first ready control plane node
|
||||
const hasReadyControlPlane = assignedNodes.some(
|
||||
n => n.role === 'controlplane' && n.status === 'ready'
|
||||
);
|
||||
|
||||
// Check if cluster is already bootstrapped using cluster status
|
||||
// The backend checks for kubeconfig existence and cluster connectivity
|
||||
// Status is "not_bootstrapped" when kubeconfig doesn't exist
|
||||
// Any other status (ready, degraded, unreachable) means cluster is bootstrapped
|
||||
const hasBootstrapped = clusterStatus?.status !== 'not_bootstrapped';
|
||||
|
||||
return hasReadyControlPlane && !hasBootstrapped;
|
||||
}, [assignedNodes, clusterStatus]);
|
||||
|
||||
const firstReadyControl = useMemo(() => {
|
||||
return assignedNodes.find(
|
||||
n => n.role === 'controlplane' && n.status === 'ready'
|
||||
);
|
||||
}, [assignedNodes]);
|
||||
|
||||
// Show message if no instance is selected
|
||||
if (!currentInstance) {
|
||||
return (
|
||||
<Card className="p-8 text-center">
|
||||
<Network className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">No Instance Selected</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Please select or create an instance to manage nodes.
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Show error state
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="p-8 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">Error Loading Nodes</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{(error as Error)?.message || 'An error occurred'}
|
||||
</p>
|
||||
<Button onClick={() => window.location.reload()}>Reload Page</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -161,12 +351,12 @@ export function ClusterNodesComponent({ onComplete }: ClusterNodesComponentProps
|
||||
What are Cluster Nodes?
|
||||
</h3>
|
||||
<p className="text-cyan-800 dark:text-cyan-200 mb-3 leading-relaxed">
|
||||
Think of cluster nodes as the "workers" in your personal cloud factory. Each node is a separate computer
|
||||
that contributes its processing power, memory, and storage to the overall cluster. Some nodes are "controllers"
|
||||
Think of cluster nodes as the "workers" in your personal cloud factory. Each node is a separate computer
|
||||
that contributes its processing power, memory, and storage to the overall cluster. Some nodes are "controllers"
|
||||
(like managers) that coordinate the work, while others are "workers" that do the heavy lifting.
|
||||
</p>
|
||||
<p className="text-cyan-700 dark:text-cyan-300 mb-4 text-sm">
|
||||
By connecting multiple computers together as nodes, you create a powerful, resilient system where if one
|
||||
By connecting multiple computers together as nodes, you create a powerful, resilient system where if one
|
||||
computer fails, the others can pick up the work. This is how you scale your personal cloud from one machine to many.
|
||||
</p>
|
||||
<Button variant="outline" size="sm" className="text-cyan-700 border-cyan-300 hover:bg-cyan-100 dark:text-cyan-300 dark:border-cyan-700 dark:hover:bg-cyan-900/20">
|
||||
@@ -177,6 +367,32 @@ export function ClusterNodesComponent({ onComplete }: ClusterNodesComponentProps
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Bootstrap Alert */}
|
||||
{showBootstrap && needsBootstrap && firstReadyControl && (
|
||||
<Alert variant="info" className="mb-6">
|
||||
<CheckCircle className="h-5 w-5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold mb-1">First Control Plane Node Ready!</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Your first control plane node ({firstReadyControl.hostname}) is ready.
|
||||
Bootstrap the cluster to initialize etcd and start Kubernetes control plane components.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setBootstrapNode({
|
||||
name: firstReadyControl.hostname,
|
||||
ip: firstReadyControl.target_ip
|
||||
});
|
||||
setShowBootstrapModal(true);
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
Bootstrap Cluster
|
||||
</Button>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
@@ -190,189 +406,302 @@ export function ClusterNodesComponent({ onComplete }: ClusterNodesComponentProps
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-medium mb-4">Assigned Nodes ({assignedNodes.length}/{totalNodes})</h2>
|
||||
{assignedNodes.map((node) => (
|
||||
<Card key={node.id} className="p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-muted rounded-lg">
|
||||
{getTypeIcon(node.type)}
|
||||
{isLoading ? (
|
||||
<Card className="p-8 text-center">
|
||||
<Loader2 className="h-12 w-12 text-primary mx-auto mb-4 animate-spin" />
|
||||
<p className="text-muted-foreground">Loading nodes...</p>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{/* Error and Success Alerts */}
|
||||
{discoverError && (
|
||||
<Alert variant="error" onClose={() => setDiscoverError(null)} className="mb-4">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<div>
|
||||
<strong>Discovery Failed</strong>
|
||||
<p className="text-sm mt-1">{discoverError}</p>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-medium">{node.name}</h4>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{node.type}
|
||||
</Badge>
|
||||
{getStatusIcon(node.status)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mb-2">
|
||||
MAC: {node.macAddress}
|
||||
{node.ipAddress && ` • IP: ${node.ipAddress}`}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Cpu className="h-3 w-3" />
|
||||
{node.specs.cpu}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Monitor className="h-3 w-3" />
|
||||
{node.specs.memory}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3" />
|
||||
{node.specs.storage}
|
||||
</span>
|
||||
{node.osVersion && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
OS: {node.osVersion}
|
||||
</Badge>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusBadge(node.status)}
|
||||
{node.osVersion !== currentOsVersion && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleNodeAction(node.id, 'upgrade_node')}
|
||||
>
|
||||
Upgrade OS
|
||||
</Button>
|
||||
)}
|
||||
{node.status === 'error' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleNodeAction(node.id, 'retry')}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<h2 className="text-lg font-medium mb-4 mt-6">Unassigned Nodes ({unassignedNodes.length}/{totalNodes})</h2>
|
||||
<div className="space-y-4">
|
||||
{unassignedNodes.map((node) => (
|
||||
<Card key={node.id} className="p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-muted rounded-lg">
|
||||
{getTypeIcon(node.type)}
|
||||
{discoverSuccess && (
|
||||
<Alert variant="success" onClose={() => setDiscoverSuccess(null)} className="mb-4">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<div>
|
||||
<strong>Discovery Successful</strong>
|
||||
<p className="text-sm mt-1">{discoverSuccess}</p>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-medium">{node.name}</h4>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{node.type}
|
||||
</Badge>
|
||||
{getStatusIcon(node.status)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mb-2">
|
||||
MAC: {node.macAddress}
|
||||
{node.ipAddress && ` • IP: ${node.ipAddress}`}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Cpu className="h-3 w-3" />
|
||||
{node.specs.cpu}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Monitor className="h-3 w-3" />
|
||||
{node.specs.memory}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3" />
|
||||
{node.specs.storage}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusBadge(node.status)}
|
||||
{node.status === 'pending' && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleNodeAction(node.id, 'connect')}
|
||||
>
|
||||
Assign
|
||||
</Button>
|
||||
)}
|
||||
{node.status === 'error' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleNodeAction(node.id, 'retry')}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{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!
|
||||
{detectError && (
|
||||
<Alert variant="error" onClose={() => setDetectError(null)} className="mb-4">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<div>
|
||||
<strong>Auto-Detect Failed</strong>
|
||||
<p className="text-sm mt-1">{detectError}</p>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
|
||||
{addError && (
|
||||
<Alert variant="error" onClose={() => {}} className="mb-4">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<div>
|
||||
<strong>Failed to Add Node</strong>
|
||||
<p className="text-sm mt-1">{(addError as any)?.message || 'An error occurred'}</p>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{deleteError && (
|
||||
<Alert variant="error" onClose={() => {}} className="mb-4">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<div>
|
||||
<strong>Failed to Remove Node</strong>
|
||||
<p className="text-sm mt-1">{(deleteError as any)?.message || 'An error occurred'}</p>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* ADD NODES SECTION - Discovery and manual add combined */}
|
||||
{(!hideDiscoveryWhenNodesGte || assignedNodes.length < hideDiscoveryWhenNodesGte) && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 mb-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
|
||||
Add Nodes to Cluster
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Discover nodes on the network or manually add by IP address
|
||||
</p>
|
||||
|
||||
{/* Discovery button */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<Button
|
||||
onClick={handleDiscover}
|
||||
disabled={isDiscovering || discoveryStatus?.active}
|
||||
className="flex-1"
|
||||
>
|
||||
{isDiscovering || discoveryStatus?.active ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Discovering...
|
||||
</>
|
||||
) : (
|
||||
'Discover Nodes'
|
||||
)}
|
||||
</Button>
|
||||
{(isDiscovering || discoveryStatus?.active) && (
|
||||
<Button
|
||||
onClick={() => cancelDiscovery()}
|
||||
disabled={isCancellingDiscovery}
|
||||
variant="destructive"
|
||||
>
|
||||
{isCancellingDiscovery && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Discovered nodes display */}
|
||||
{discoveryStatus?.nodes_found && discoveryStatus.nodes_found.length > 0 && (
|
||||
<div className="space-y-3 mb-4">
|
||||
{discoveryStatus.nodes_found.map((discovered) => (
|
||||
<div key={discovered.ip} className="border border-gray-300 dark:border-gray-600 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium font-mono text-gray-900 dark:text-gray-100">{discovered.ip}</p>
|
||||
{discovered.version && discovered.version !== 'maintenance' && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{discovered.version}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handleAddFromDiscovery(discovered)}
|
||||
size="sm"
|
||||
>
|
||||
Add to Cluster
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual add by IP - styled like a list item */}
|
||||
<div className="border border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
type="text"
|
||||
value={addNodeIp}
|
||||
onChange={(e) => setAddNodeIp(e.target.value)}
|
||||
placeholder="192.168.8.128"
|
||||
className="flex-1 font-mono"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAddNode}
|
||||
disabled={isGettingHardware}
|
||||
size="sm"
|
||||
>
|
||||
{isGettingHardware ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Detecting...
|
||||
</>
|
||||
) : (
|
||||
'Add to Cluster'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-2">
|
||||
Add a node by IP address if not discovered automatically
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-green-700 dark:text-green-300 mb-3">
|
||||
All nodes are connected and ready for Kubernetes installation.
|
||||
</p>
|
||||
<Button onClick={onComplete} className="bg-green-600 hover:bg-green-700">
|
||||
Continue to Kubernetes Installation
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-medium">Cluster Nodes ({assignedNodes.length})</h2>
|
||||
</div>
|
||||
|
||||
{assignedNodes.map((node) => (
|
||||
<Card key={node.hostname} className="p-4 hover:shadow-md transition-shadow">
|
||||
<div className="mb-2">
|
||||
<NodeStatusBadge node={node} compact />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-muted rounded-lg">
|
||||
{getRoleIcon(node.role)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-medium">{node.hostname}</h4>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{node.role}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mb-2">
|
||||
Target: {node.target_ip}
|
||||
</div>
|
||||
{node.disk && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Disk: {node.disk}
|
||||
</div>
|
||||
)}
|
||||
{node.hardware && (
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground mt-2">
|
||||
{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.version || node.schematic_id) && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{node.version && <span>Talos: {node.version}</span>}
|
||||
{node.version && node.schematic_id && <span> • </span>}
|
||||
{node.schematic_id && (
|
||||
<span
|
||||
title={node.schematic_id}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(node.schematic_id!);
|
||||
}}
|
||||
className="cursor-pointer hover:text-primary hover:underline"
|
||||
>
|
||||
Schema: {node.schematic_id.substring(0, 8)}...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleConfigureNode(node)}
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
{node.configured && !node.applied && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => applyNode(node.hostname)}
|
||||
disabled={isApplying}
|
||||
variant="secondary"
|
||||
>
|
||||
{isApplying ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Apply'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleDeleteNode(node.hostname)}
|
||||
disabled={deletingNodeHostname === node.hostname}
|
||||
>
|
||||
{deletingNodeHostname === node.hostname ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{assignedNodes.length === 0 && (
|
||||
<Card className="p-8 text-center">
|
||||
<Network className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">No Nodes</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Use the discover or auto-detect buttons above to find nodes on your network.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-medium mb-4">PXE Boot Instructions</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Power on your nodes</p>
|
||||
<p className="text-muted-foreground">
|
||||
Ensure network boot (PXE) is enabled in BIOS/UEFI settings
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium">
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Connect to the wild-cloud network</p>
|
||||
<p className="text-muted-foreground">
|
||||
Nodes will automatically receive IP addresses via DHCP
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-6 h-6 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium">
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">Boot Talos Linux</p>
|
||||
<p className="text-muted-foreground">
|
||||
Nodes will automatically download and boot Talos Linux via PXE
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/* Bootstrap Modal */}
|
||||
{showBootstrapModal && bootstrapNode && (
|
||||
<BootstrapModal
|
||||
instanceName={currentInstance!}
|
||||
nodeName={bootstrapNode.name}
|
||||
nodeIp={bootstrapNode.ip}
|
||||
onClose={() => {
|
||||
setShowBootstrapModal(false);
|
||||
setBootstrapNode(null);
|
||||
refetch();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Node Form Drawer - only render after first open to prevent infinite loop on initial mount */}
|
||||
{drawerEverOpened && (
|
||||
<NodeFormDrawer
|
||||
open={drawerState.open}
|
||||
onClose={closeDrawer}
|
||||
mode={drawerState.mode}
|
||||
node={drawerState.mode === 'configure' ? drawerState.node : undefined}
|
||||
detection={drawerState.detection}
|
||||
onSubmit={drawerState.mode === 'add' ? handleAddSubmit : handleConfigureSubmit}
|
||||
onApply={drawerState.mode === 'configure' ? handleApply : undefined}
|
||||
instanceName={currentInstance || ''}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,127 +2,139 @@ import { useState } from 'react';
|
||||
import { Card } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Container, Shield, Network, Database, CheckCircle, AlertCircle, Clock, Terminal, FileText, BookOpen, ExternalLink } from 'lucide-react';
|
||||
import { Container, Shield, Network, Database, CheckCircle, AlertCircle, Terminal, BookOpen, ExternalLink, Loader2 } from 'lucide-react';
|
||||
import { useInstanceContext } from '../hooks/useInstanceContext';
|
||||
import { useServices } from '../hooks/useServices';
|
||||
import type { Service } from '../services/api';
|
||||
import { ServiceDetailModal } from './services/ServiceDetailModal';
|
||||
import { ServiceConfigEditor } from './services/ServiceConfigEditor';
|
||||
import { Dialog, DialogContent } from './ui/dialog';
|
||||
|
||||
interface ClusterServicesComponentProps {
|
||||
onComplete?: () => void;
|
||||
}
|
||||
export function ClusterServicesComponent() {
|
||||
const { currentInstance } = useInstanceContext();
|
||||
const {
|
||||
services,
|
||||
isLoading,
|
||||
error,
|
||||
installService,
|
||||
isInstalling,
|
||||
installAll,
|
||||
isInstallingAll,
|
||||
deleteService,
|
||||
isDeleting
|
||||
} = useServices(currentInstance);
|
||||
|
||||
interface ClusterComponent {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: 'pending' | 'installing' | 'ready' | 'error';
|
||||
version?: string;
|
||||
logs?: string[];
|
||||
}
|
||||
const [selectedService, setSelectedService] = useState<string | null>(null);
|
||||
const [configService, setConfigService] = useState<string | null>(null);
|
||||
|
||||
export function ClusterServicesComponent({ onComplete }: ClusterServicesComponentProps) {
|
||||
const [components, setComponents] = useState<ClusterComponent[]>([
|
||||
{
|
||||
id: 'talos-config',
|
||||
name: 'Talos Configuration',
|
||||
description: 'Generate and apply Talos cluster configuration',
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'kubernetes-bootstrap',
|
||||
name: 'Kubernetes Bootstrap',
|
||||
description: 'Initialize Kubernetes control plane',
|
||||
status: 'pending',
|
||||
version: 'v1.29.0',
|
||||
},
|
||||
{
|
||||
id: 'cni-plugin',
|
||||
name: 'Container Network Interface',
|
||||
description: 'Install and configure Cilium CNI',
|
||||
status: 'pending',
|
||||
version: 'v1.14.5',
|
||||
},
|
||||
{
|
||||
id: 'storage-class',
|
||||
name: 'Storage Classes',
|
||||
description: 'Configure persistent volume storage',
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'ingress-controller',
|
||||
name: 'Ingress Controller',
|
||||
description: 'Install Traefik ingress controller',
|
||||
status: 'pending',
|
||||
version: 'v3.0.0',
|
||||
},
|
||||
{
|
||||
id: 'monitoring',
|
||||
name: 'Cluster Monitoring',
|
||||
description: 'Deploy Prometheus and Grafana stack',
|
||||
status: 'pending',
|
||||
},
|
||||
]);
|
||||
|
||||
const [showLogs, setShowLogs] = useState<string | null>(null);
|
||||
|
||||
const getStatusIcon = (status: ClusterComponent['status']) => {
|
||||
const getStatusIcon = (status?: string) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
case 'ready':
|
||||
case 'deployed':
|
||||
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
||||
case 'error':
|
||||
return <AlertCircle className="h-5 w-5 text-red-500" />;
|
||||
case 'deploying':
|
||||
case 'installing':
|
||||
return <Clock className="h-5 w-5 text-blue-500 animate-spin" />;
|
||||
return <Loader2 className="h-5 w-5 text-blue-500 animate-spin" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: ClusterComponent['status']) => {
|
||||
const variants = {
|
||||
pending: 'secondary',
|
||||
installing: 'default',
|
||||
ready: 'success',
|
||||
error: 'destructive',
|
||||
} as const;
|
||||
const getStatusBadge = (service: Service) => {
|
||||
// Handle both old format (status as string) and new format (status as object)
|
||||
const status = typeof service.status === 'string' ? service.status :
|
||||
service.status?.status || (service.deployed ? 'deployed' : 'available');
|
||||
|
||||
const labels = {
|
||||
pending: 'Pending',
|
||||
const variants: Record<string, 'secondary' | 'default' | 'success' | 'destructive' | 'outline'> = {
|
||||
'not-deployed': 'secondary',
|
||||
available: 'secondary',
|
||||
deploying: 'default',
|
||||
installing: 'default',
|
||||
running: 'success',
|
||||
ready: 'success',
|
||||
deployed: 'success',
|
||||
error: 'destructive',
|
||||
};
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
'not-deployed': 'Not Deployed',
|
||||
available: 'Available',
|
||||
deploying: 'Deploying',
|
||||
installing: 'Installing',
|
||||
running: 'Running',
|
||||
ready: 'Ready',
|
||||
error: 'Error',
|
||||
deployed: 'Deployed',
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant={variants[status] as any}>
|
||||
{labels[status]}
|
||||
<Badge variant={variants[status] || 'secondary'}>
|
||||
{labels[status] || status}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const getComponentIcon = (id: string) => {
|
||||
switch (id) {
|
||||
case 'talos-config':
|
||||
return <FileText className="h-5 w-5" />;
|
||||
case 'kubernetes-bootstrap':
|
||||
return <Container className="h-5 w-5" />;
|
||||
case 'cni-plugin':
|
||||
return <Network className="h-5 w-5" />;
|
||||
case 'storage-class':
|
||||
return <Database className="h-5 w-5" />;
|
||||
case 'ingress-controller':
|
||||
return <Shield className="h-5 w-5" />;
|
||||
case 'monitoring':
|
||||
return <Terminal className="h-5 w-5" />;
|
||||
default:
|
||||
return <Container className="h-5 w-5" />;
|
||||
const getServiceIcon = (name: string) => {
|
||||
const lowerName = name.toLowerCase();
|
||||
if (lowerName.includes('network') || lowerName.includes('cni') || lowerName.includes('cilium')) {
|
||||
return <Network className="h-5 w-5" />;
|
||||
} else if (lowerName.includes('storage') || lowerName.includes('volume')) {
|
||||
return <Database className="h-5 w-5" />;
|
||||
} else if (lowerName.includes('ingress') || lowerName.includes('traefik') || lowerName.includes('nginx')) {
|
||||
return <Shield className="h-5 w-5" />;
|
||||
} else if (lowerName.includes('monitor') || lowerName.includes('prometheus') || lowerName.includes('grafana')) {
|
||||
return <Terminal className="h-5 w-5" />;
|
||||
} else {
|
||||
return <Container className="h-5 w-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
const handleComponentAction = (componentId: string, action: 'install' | 'retry') => {
|
||||
console.log(`${action} component: ${componentId}`);
|
||||
const handleInstallService = (serviceName: string) => {
|
||||
if (!currentInstance) return;
|
||||
installService({ name: serviceName });
|
||||
};
|
||||
|
||||
const readyComponents = components.filter(component => component.status === 'ready').length;
|
||||
const totalComponents = components.length;
|
||||
const isComplete = readyComponents === totalComponents;
|
||||
const handleDeleteService = (serviceName: string) => {
|
||||
if (!currentInstance) return;
|
||||
if (confirm(`Are you sure you want to delete service ${serviceName}?`)) {
|
||||
deleteService(serviceName);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstallAll = () => {
|
||||
if (!currentInstance) return;
|
||||
installAll();
|
||||
};
|
||||
|
||||
// Show message if no instance is selected
|
||||
if (!currentInstance) {
|
||||
return (
|
||||
<Card className="p-8 text-center">
|
||||
<Container className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">No Instance Selected</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Please select or create an instance to manage services.
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Show error state
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="p-8 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">Error Loading Services</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{(error as Error)?.message || 'An error occurred'}
|
||||
</p>
|
||||
<Button onClick={() => window.location.reload()}>Reload Page</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -167,108 +179,112 @@ export function ClusterServicesComponent({ onComplete }: ClusterServicesComponen
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<pre className="text-xs text-muted-foreground bg-muted p-2 rounded-lg">
|
||||
endpoint: civil<br/>
|
||||
endpointIp: 192.168.8.240<br/>
|
||||
kubernetes:<br/>
|
||||
config: /home/payne/.kube/config<br/>
|
||||
context: default<br/>
|
||||
loadBalancerRange: 192.168.8.240-192.168.8.250<br/>
|
||||
dashboard:<br/>
|
||||
adminUsername: admin<br/>
|
||||
certManager:<br/>
|
||||
namespace: cert-manager<br/>
|
||||
cloudflare:<br/>
|
||||
domain: payne.io<br/>
|
||||
ownerId: cloud-payne-io-cluster<br/>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-4">
|
||||
{components.map((component) => (
|
||||
<div key={component.id}>
|
||||
<div className="flex items-center gap-4 p-4 rounded-lg border bg-card">
|
||||
<div className="p-2 bg-muted rounded-lg">
|
||||
{getComponentIcon(component.id)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-medium">{component.name}</h3>
|
||||
{component.version && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{component.version}
|
||||
</Badge>
|
||||
)}
|
||||
{getStatusIcon(component.status)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{component.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusBadge(component.status)}
|
||||
{(component.status === 'installing' || component.status === 'error') && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowLogs(showLogs === component.id ? null : component.id)}
|
||||
>
|
||||
<Terminal className="h-4 w-4 mr-1" />
|
||||
Logs
|
||||
</Button>
|
||||
)}
|
||||
{component.status === 'pending' && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleComponentAction(component.id, 'install')}
|
||||
>
|
||||
Install
|
||||
</Button>
|
||||
)}
|
||||
{component.status === 'error' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleComponentAction(component.id, 'retry')}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showLogs === component.id && (
|
||||
<Card className="mt-2 p-4 bg-black text-green-400 font-mono text-sm">
|
||||
<div className="max-h-40 overflow-y-auto">
|
||||
<div>Installing {component.name}...</div>
|
||||
<div>✓ Checking prerequisites</div>
|
||||
<div>✓ Downloading manifests</div>
|
||||
{component.status === 'installing' && (
|
||||
<div className="animate-pulse">⏳ Applying configuration...</div>
|
||||
)}
|
||||
{component.status === 'error' && (
|
||||
<div className="text-red-400">✗ Installation failed: timeout waiting for pods</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{isLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading services...
|
||||
</span>
|
||||
) : (
|
||||
`${services.length} services available`
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleInstallAll}
|
||||
disabled={isInstallingAll || services.length === 0}
|
||||
>
|
||||
{isInstallingAll ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : null}
|
||||
Install All
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isComplete && (
|
||||
<div className="mt-6 p-4 bg-green-50 dark:bg-green-950 rounded-lg border border-green-200 dark:border-green-800">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
<h3 className="font-medium text-green-800 dark:text-green-200">
|
||||
Kubernetes Cluster Ready!
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-green-700 dark:text-green-300 mb-3">
|
||||
Your Kubernetes cluster is fully configured and ready for application deployment.
|
||||
</p>
|
||||
<Button onClick={onComplete} className="bg-green-600 hover:bg-green-700">
|
||||
Continue to App Management
|
||||
</Button>
|
||||
{isLoading ? (
|
||||
<Card className="p-8 text-center">
|
||||
<Loader2 className="h-12 w-12 text-primary mx-auto mb-4 animate-spin" />
|
||||
<p className="text-muted-foreground">Loading services...</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{services.map((service) => (
|
||||
<div key={service.name}>
|
||||
<div className="flex items-center gap-4 p-4 rounded-lg border bg-card">
|
||||
<div className="p-2 bg-muted rounded-lg">
|
||||
{getServiceIcon(service.name)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-medium">{service.name}</h3>
|
||||
{service.version && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{service.version}
|
||||
</Badge>
|
||||
)}
|
||||
{getStatusIcon(typeof service.status === 'string' ? service.status : service.status?.status)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{service.description}</p>
|
||||
{typeof service.status === 'object' && 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)}
|
||||
{((typeof service.status === 'string' && service.status === 'not-deployed') ||
|
||||
(!service.status || service.status === 'not-deployed') ||
|
||||
(typeof service.status === 'object' && service.status?.status === 'not-deployed')) && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleInstallService(service.name)}
|
||||
disabled={isInstalling}
|
||||
>
|
||||
{isInstalling ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Install'}
|
||||
</Button>
|
||||
)}
|
||||
{((typeof service.status === 'string' && service.status === 'deployed') ||
|
||||
(typeof service.status === 'object' && service.status?.status === 'deployed')) && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setSelectedService(service.name)}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
{service.hasConfig && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setConfigService(service.name)}
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleDeleteService(service.name)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Remove'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{services.length === 0 && (
|
||||
<Card className="p-8 text-center">
|
||||
<Container className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">No Services Available</h3>
|
||||
<p className="text-muted-foreground">
|
||||
No cluster services are configured for this instance.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
@@ -294,6 +310,31 @@ export function ClusterServicesComponent({ onComplete }: ClusterServicesComponen
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{selectedService && (
|
||||
<ServiceDetailModal
|
||||
instanceName={currentInstance}
|
||||
serviceName={selectedService}
|
||||
open={!!selectedService}
|
||||
onClose={() => setSelectedService(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{configService && (
|
||||
<Dialog open={!!configService} onOpenChange={(open) => !open && setConfigService(null)}>
|
||||
<DialogContent className="sm:max-w-4xl max-w-[95vw] max-h-[90vh] overflow-y-auto w-full">
|
||||
<ServiceConfigEditor
|
||||
instanceName={currentInstance}
|
||||
serviceName={configService}
|
||||
manifest={services.find(s => s.name === configService)}
|
||||
onClose={() => setConfigService(null)}
|
||||
onSuccess={() => {
|
||||
setConfigService(null);
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Settings, Save, X } from 'lucide-react';
|
||||
import { Settings } from 'lucide-react';
|
||||
import { useConfigYaml } from '../hooks';
|
||||
import { Button, Textarea } from './ui';
|
||||
import {
|
||||
|
||||
17
src/components/ConfigViewer.tsx
Normal file
17
src/components/ConfigViewer.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Card } from './ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ConfigViewerProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ConfigViewer({ content, className }: ConfigViewerProps) {
|
||||
return (
|
||||
<Card className={cn('p-4', className)}>
|
||||
<pre className="text-xs overflow-auto max-h-96 whitespace-pre-wrap break-all">
|
||||
<code>{content}</code>
|
||||
</pre>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { FileText, Check, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { useConfig, useMessages } from '../hooks';
|
||||
import { useConfig } from '../hooks';
|
||||
import { configFormSchema, defaultConfigValues, type ConfigFormData } from '../schemas/config';
|
||||
import {
|
||||
Card,
|
||||
@@ -237,6 +237,22 @@ export const ConfigurationForm = () => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cluster.hostnamePrefix"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Hostname Prefix (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="test-" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Optional prefix for node hostnames (e.g., 'test-' for unique names on LAN)
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cluster.nodes.talos.version"
|
||||
|
||||
5
src/components/ControlNodesComponent.tsx
Normal file
5
src/components/ControlNodesComponent.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ClusterNodesComponent } from './ClusterNodesComponent';
|
||||
|
||||
export function ControlNodesComponent() {
|
||||
return <ClusterNodesComponent filterRole="controlplane" hideDiscoveryWhenNodesGte={3} showBootstrap={true} />;
|
||||
}
|
||||
49
src/components/CopyButton.tsx
Normal file
49
src/components/CopyButton.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useState } from 'react';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
interface CopyButtonProps {
|
||||
content: string;
|
||||
label?: string;
|
||||
variant?: 'default' | 'outline' | 'secondary' | 'ghost';
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function CopyButton({
|
||||
content,
|
||||
label = 'Copy',
|
||||
variant = 'outline',
|
||||
disabled = false,
|
||||
}: CopyButtonProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleCopy}
|
||||
variant={variant}
|
||||
disabled={disabled}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
{label}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export function DhcpComponent() {
|
||||
<div>
|
||||
<Label htmlFor="dhcpRange">IP Range</Label>
|
||||
<div className="flex w-full items-center mt-1">
|
||||
<Input id="dhcpRange" value="192.168.8.100,192.168.8.239"/>
|
||||
<Input id="dhcpRange" value="192.168.8.100,192.168.8.239" readOnly/>
|
||||
<Button variant="ghost">
|
||||
<HelpCircle/>
|
||||
</Button>
|
||||
|
||||
41
src/components/DownloadButton.tsx
Normal file
41
src/components/DownloadButton.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Download } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
interface DownloadButtonProps {
|
||||
content: string;
|
||||
filename: string;
|
||||
label?: string;
|
||||
variant?: 'default' | 'outline' | 'secondary' | 'ghost';
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function DownloadButton({
|
||||
content,
|
||||
filename,
|
||||
label = 'Download',
|
||||
variant = 'default',
|
||||
disabled = false,
|
||||
}: DownloadButtonProps) {
|
||||
const handleDownload = () => {
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
variant={variant}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
136
src/components/InstanceSelector.tsx
Normal file
136
src/components/InstanceSelector.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useState } from 'react';
|
||||
import { Card } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Cloud, Plus, Check, Loader2, AlertCircle } from 'lucide-react';
|
||||
import { useInstanceContext } from '../hooks/useInstanceContext';
|
||||
import { useInstances } from '../hooks/useInstances';
|
||||
|
||||
export function InstanceSelector() {
|
||||
const { currentInstance, setCurrentInstance } = useInstanceContext();
|
||||
const { instances, isLoading, error, createInstance, isCreating } = useInstances();
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [newInstanceName, setNewInstanceName] = useState('');
|
||||
|
||||
const handleSelectInstance = (name: string) => {
|
||||
setCurrentInstance(name);
|
||||
};
|
||||
|
||||
const handleCreateInstance = () => {
|
||||
if (!newInstanceName.trim()) return;
|
||||
createInstance({ name: newInstanceName.trim() });
|
||||
setShowCreateForm(false);
|
||||
setNewInstanceName('');
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">Loading instances...</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="p-4 border-red-200 bg-red-50 dark:bg-red-950/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-red-500" />
|
||||
<span className="text-sm text-red-700 dark:text-red-300">
|
||||
Error loading instances: {(error as Error).message}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Cloud className="h-5 w-5 text-primary" />
|
||||
<div className="flex-1">
|
||||
<label className="text-sm font-medium mb-1 block">Instance</label>
|
||||
<select
|
||||
value={currentInstance || ''}
|
||||
onChange={(e) => handleSelectInstance(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg bg-background"
|
||||
>
|
||||
<option value="">Select an instance...</option>
|
||||
{instances.map((instance) => (
|
||||
<option key={instance} value={instance}>
|
||||
{instance}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{currentInstance && (
|
||||
<Badge variant="success" className="whitespace-nowrap">
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowCreateForm(!showCreateForm)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
New
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showCreateForm && (
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Instance name"
|
||||
value={newInstanceName}
|
||||
onChange={(e) => setNewInstanceName(e.target.value)}
|
||||
className="flex-1 px-3 py-2 border rounded-lg"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && newInstanceName.trim()) {
|
||||
handleCreateInstance();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleCreateInstance}
|
||||
disabled={!newInstanceName.trim() || isCreating}
|
||||
>
|
||||
{isCreating ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Create'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowCreateForm(false);
|
||||
setNewInstanceName('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{instances.length === 0 && !showCreateForm && (
|
||||
<div className="mt-4 pt-4 border-t text-center">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
No instances found. Create your first instance to get started.
|
||||
</p>
|
||||
<Button size="sm" onClick={() => setShowCreateForm(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Create Instance
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
216
src/components/InstanceSwitcher.tsx
Normal file
216
src/components/InstanceSwitcher.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useLocation, useParams } from 'react-router';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useInstances } from '../hooks/useInstances';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectSeparator,
|
||||
} from './ui/select';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
|
||||
const ADD_INSTANCE_VALUE = '__add_new__';
|
||||
|
||||
export function InstanceSwitcher() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { instanceId } = useParams<{ instanceId: string }>();
|
||||
const { instances, isLoading, error, createInstance, isCreating } = useInstances();
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [newInstanceName, setNewInstanceName] = useState('');
|
||||
|
||||
const handleInstanceChange = (value: string) => {
|
||||
// Check if user selected "Add new instance"
|
||||
if (value === ADD_INSTANCE_VALUE) {
|
||||
setDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!instanceId) return;
|
||||
|
||||
// Extract the page path after /instances/:instanceId
|
||||
const instancePrefix = `/instances/${instanceId}`;
|
||||
const pagePath = location.pathname.startsWith(instancePrefix)
|
||||
? location.pathname.slice(instancePrefix.length)
|
||||
: '/dashboard';
|
||||
|
||||
// Navigate to the same page in the new instance
|
||||
navigate(`/instances/${value}${pagePath || '/dashboard'}`);
|
||||
};
|
||||
|
||||
const handleCreateInstance = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newInstanceName.trim()) return;
|
||||
|
||||
createInstance(
|
||||
{ name: newInstanceName.trim() },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setDialogOpen(false);
|
||||
setNewInstanceName('');
|
||||
// Navigate to the new instance's dashboard
|
||||
navigate(`/instances/${newInstanceName.trim()}/dashboard`);
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Select disabled value="">
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="Loading..." />
|
||||
</SelectTrigger>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Select disabled value="">
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="Error loading instances" />
|
||||
</SelectTrigger>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
// No instances state - show dialog immediately
|
||||
if (!instances || instances.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<Select disabled value="">
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="No instances" />
|
||||
</SelectTrigger>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
className="mt-2 w-full h-8 text-sm"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Instance
|
||||
</Button>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleCreateInstance}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Instance</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter a name for your new Wild Cloud instance.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Instance Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="my-instance"
|
||||
value={newInstanceName}
|
||||
onChange={(e) => setNewInstanceName(e.target.value)}
|
||||
disabled={isCreating}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
disabled={isCreating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isCreating || !newInstanceName.trim()}>
|
||||
{isCreating ? 'Creating...' : 'Create Instance'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Select value={instanceId || ''} onValueChange={handleInstanceChange}>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="Select instance" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{instances.map((instance) => (
|
||||
<SelectItem key={instance} value={instance}>
|
||||
{instance}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectSeparator />
|
||||
<SelectItem value={ADD_INSTANCE_VALUE}>
|
||||
<div className="flex items-center">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add new instance...
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleCreateInstance}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Instance</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter a name for your new Wild Cloud instance.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Instance Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="my-instance"
|
||||
value={newInstanceName}
|
||||
onChange={(e) => setNewInstanceName(e.target.value)}
|
||||
disabled={isCreating}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
disabled={isCreating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isCreating || !newInstanceName.trim()}>
|
||||
{isCreating ? 'Creating...' : 'Create Instance'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
5
src/components/WorkerNodesComponent.tsx
Normal file
5
src/components/WorkerNodesComponent.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ClusterNodesComponent } from './ClusterNodesComponent';
|
||||
|
||||
export function WorkerNodesComponent() {
|
||||
return <ClusterNodesComponent filterRole="worker" hideDiscoveryWhenNodesGte={undefined} showBootstrap={false} />;
|
||||
}
|
||||
182
src/components/apps/AppConfigDialog.tsx
Normal file
182
src/components/apps/AppConfigDialog.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
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> = {};
|
||||
|
||||
// Debug logging to diagnose the issue
|
||||
console.log('[AppConfigDialog] App data:', {
|
||||
name: app.name,
|
||||
hasDefaultConfig: !!app.defaultConfig,
|
||||
defaultConfigKeys: app.defaultConfig ? Object.keys(app.defaultConfig) : [],
|
||||
hasExistingConfig: !!existingConfig,
|
||||
existingConfigKeys: existingConfig ? Object.keys(existingConfig) : [],
|
||||
});
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
608
src/components/apps/AppDetailModal.tsx
Normal file
608
src/components/apps/AppDetailModal.tsx
Normal file
@@ -0,0 +1,608 @@
|
||||
import { useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useAppEnhanced, useAppLogs, useAppEvents, useAppReadme } from '@/hooks/useApps';
|
||||
import {
|
||||
RefreshCw,
|
||||
Eye,
|
||||
Settings,
|
||||
Activity,
|
||||
FileText,
|
||||
ExternalLink,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface AppDetailModalProps {
|
||||
instanceName: string;
|
||||
appName: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type ViewMode = 'overview' | 'configuration' | 'status' | 'logs';
|
||||
|
||||
export function AppDetailModal({
|
||||
instanceName,
|
||||
appName,
|
||||
open,
|
||||
onClose,
|
||||
}: AppDetailModalProps) {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('overview');
|
||||
const [showSecrets, setShowSecrets] = useState(false);
|
||||
const [logParams, setLogParams] = useState({ tail: 100, sinceSeconds: 3600 });
|
||||
|
||||
const { data: appDetails, isLoading, refetch } = useAppEnhanced(instanceName, appName);
|
||||
const { data: logs, refetch: refetchLogs } = useAppLogs(
|
||||
instanceName,
|
||||
appName,
|
||||
viewMode === 'logs' ? logParams : undefined
|
||||
);
|
||||
const { data: eventsData } = useAppEvents(instanceName, appName, 20);
|
||||
const { data: readmeContent, isLoading: readmeLoading } = useAppReadme(instanceName, appName);
|
||||
|
||||
const getPodStatusColor = (status: string | undefined) => {
|
||||
if (!status) return 'text-muted-foreground';
|
||||
const lowerStatus = status.toLowerCase();
|
||||
if (lowerStatus.includes('running')) return 'text-green-600 dark:text-green-400';
|
||||
if (lowerStatus.includes('pending')) return 'text-yellow-600 dark:text-yellow-400';
|
||||
if (lowerStatus.includes('failed')) return 'text-red-600 dark:text-red-400';
|
||||
return 'text-muted-foreground';
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const variants: Record<string, 'success' | 'destructive' | 'warning' | 'outline'> = {
|
||||
running: 'success',
|
||||
error: 'destructive',
|
||||
deploying: 'outline',
|
||||
stopped: 'warning',
|
||||
added: 'outline',
|
||||
deployed: 'outline',
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant={variants[status] || 'outline'}>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-3">
|
||||
{appName}
|
||||
{appDetails && getStatusBadge(appDetails.status)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{appDetails?.description || 'Application details and configuration'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* View Mode Selector */}
|
||||
<div className="flex gap-2 border-b pb-4">
|
||||
<Button
|
||||
variant={viewMode === 'overview' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('overview')}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Overview
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'configuration' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('configuration')}
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Configuration
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'status' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('status')}
|
||||
>
|
||||
<Activity className="h-4 w-4 mr-2" />
|
||||
Status
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'logs' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('logs')}
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
Logs
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Overview Tab */}
|
||||
{viewMode === 'overview' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
) : appDetails ? (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Application Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Name</p>
|
||||
<p className="text-sm">{appDetails.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Version</p>
|
||||
<p className="text-sm">{appDetails.version || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Namespace</p>
|
||||
<p className="text-sm">{appDetails.namespace}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Status</p>
|
||||
<p className="text-sm">{appDetails.status}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{appDetails.url && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-1">URL</p>
|
||||
<a
|
||||
href={appDetails.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{appDetails.url}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{appDetails.description && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-1">Description</p>
|
||||
<p className="text-sm">{appDetails.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{appDetails.manifest?.dependencies && appDetails.manifest.dependencies.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">Dependencies</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{appDetails.manifest.dependencies.map((dep) => (
|
||||
<Badge key={dep} variant="outline">
|
||||
{dep}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* README Documentation */}
|
||||
{readmeContent && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
README
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{readmeLoading ? (
|
||||
<Skeleton className="h-40 w-full" />
|
||||
) : (
|
||||
<div className="prose prose-sm max-w-none dark:prose-invert overflow-auto max-h-96 p-4 bg-muted/30 rounded-lg">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
// Style code blocks
|
||||
code: ({inline, children, ...props}) => {
|
||||
return inline ? (
|
||||
<code className="bg-muted px-1 py-0.5 rounded text-sm" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
) : (
|
||||
<code className="block bg-muted p-3 rounded text-sm overflow-x-auto" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
// Make links open in new tab
|
||||
a: ({children, href, ...props}) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline" {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{readmeContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground py-8">No information available</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Configuration Tab */}
|
||||
{viewMode === 'configuration' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
) : appDetails ? (
|
||||
<>
|
||||
{/* Configuration Values */}
|
||||
{((appDetails.config && Object.keys(appDetails.config).length > 0) ||
|
||||
(appDetails.manifest?.defaultConfig && Object.keys(appDetails.manifest.defaultConfig).length > 0)) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Configuration</CardTitle>
|
||||
<CardDescription>Current configuration values</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(appDetails.config || appDetails.manifest?.defaultConfig || {}).map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between text-sm border-b pb-2">
|
||||
<span className="font-medium text-muted-foreground">{key}:</span>
|
||||
<span className="font-mono text-xs break-all">
|
||||
{typeof value === 'object' && value !== null
|
||||
? JSON.stringify(value, null, 2)
|
||||
: String(value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Secrets */}
|
||||
{appDetails.manifest?.requiredSecrets && appDetails.manifest.requiredSecrets.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center justify-between">
|
||||
<span>Secrets</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowSecrets(!showSecrets)}
|
||||
>
|
||||
{showSecrets ? 'Hide' : 'Show'}
|
||||
</Button>
|
||||
</CardTitle>
|
||||
<CardDescription>Sensitive configuration values (redacted)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{appDetails.manifest.requiredSecrets.map((secret) => (
|
||||
<div key={secret} className="flex justify-between text-sm border-b pb-2">
|
||||
<span className="font-medium text-muted-foreground">{secret}:</span>
|
||||
<span className="font-mono text-xs">
|
||||
{showSecrets ? '**hidden**' : '••••••••'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground py-8">No configuration available</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Tab */}
|
||||
{viewMode === 'status' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
) : appDetails?.runtime ? (
|
||||
<>
|
||||
{/* Replicas */}
|
||||
{appDetails.runtime.replicas && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Replicas</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-4 gap-2 text-sm">
|
||||
<div className="bg-muted rounded p-2">
|
||||
<p className="text-xs text-muted-foreground">Desired</p>
|
||||
<p className="font-semibold">{appDetails.runtime.replicas.desired}</p>
|
||||
</div>
|
||||
<div className="bg-muted rounded p-2">
|
||||
<p className="text-xs text-muted-foreground">Current</p>
|
||||
<p className="font-semibold">{appDetails.runtime.replicas.current}</p>
|
||||
</div>
|
||||
<div className="bg-muted rounded p-2">
|
||||
<p className="text-xs text-muted-foreground">Ready</p>
|
||||
<p className="font-semibold">{appDetails.runtime.replicas.ready}</p>
|
||||
</div>
|
||||
<div className="bg-muted rounded p-2">
|
||||
<p className="text-xs text-muted-foreground">Available</p>
|
||||
<p className="font-semibold">{appDetails.runtime.replicas.available}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Pods */}
|
||||
{appDetails.runtime.pods && appDetails.runtime.pods.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Pods</CardTitle>
|
||||
<CardDescription>{appDetails.runtime.pods.length} pod(s)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{appDetails.runtime.pods.map((pod) => (
|
||||
<div
|
||||
key={pod.name}
|
||||
className="border rounded-lg p-3 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{pod.name}</p>
|
||||
{pod.node && (
|
||||
<p className="text-xs text-muted-foreground">Node: {pod.node}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 ml-2">
|
||||
<Badge variant="outline" className={getPodStatusColor(pod.status)}>
|
||||
{pod.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Ready:</span>{' '}
|
||||
<span className="font-medium">{pod.ready}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Restarts:</span>{' '}
|
||||
<span className="font-medium">{pod.restarts}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Age:</span>{' '}
|
||||
<span className="font-medium">{pod.age}</span>
|
||||
</div>
|
||||
</div>
|
||||
{pod.ip && (
|
||||
<div className="text-xs mt-1">
|
||||
<span className="text-muted-foreground">IP:</span>{' '}
|
||||
<span className="font-mono">{pod.ip}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Resource Usage */}
|
||||
{appDetails.runtime.resources && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Resource Usage</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{appDetails.runtime.resources.cpu && (
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span>CPU</span>
|
||||
<span className="font-mono text-xs">
|
||||
{appDetails.runtime.resources.cpu.used} / {appDetails.runtime.resources.cpu.limit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary rounded-full h-2 transition-all"
|
||||
style={{ width: `${Math.min(appDetails.runtime.resources.cpu.percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{appDetails.runtime.resources.cpu.percentage.toFixed(1)}% used
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{appDetails.runtime.resources.memory && (
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span>Memory</span>
|
||||
<span className="font-mono text-xs">
|
||||
{appDetails.runtime.resources.memory.used} / {appDetails.runtime.resources.memory.limit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary rounded-full h-2 transition-all"
|
||||
style={{ width: `${Math.min(appDetails.runtime.resources.memory.percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{appDetails.runtime.resources.memory.percentage.toFixed(1)}% used
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{appDetails.runtime.resources.storage && (
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span>Storage</span>
|
||||
<span className="font-mono text-xs">
|
||||
{appDetails.runtime.resources.storage.used} / {appDetails.runtime.resources.storage.limit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary rounded-full h-2 transition-all"
|
||||
style={{ width: `${Math.min(appDetails.runtime.resources.storage.percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{appDetails.runtime.resources.storage.percentage.toFixed(1)}% used
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Recent Events */}
|
||||
{eventsData?.events && eventsData.events.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Recent Events</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{eventsData.events.map((event, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2 text-sm border-b pb-2">
|
||||
{event.type === 'Warning' ? (
|
||||
<AlertCircle className="h-4 w-4 text-yellow-500 mt-0.5" />
|
||||
) : (
|
||||
<CheckCircle className="h-4 w-4 text-green-500 mt-0.5" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium">{event.reason}</p>
|
||||
<p className="text-muted-foreground text-xs">{event.message}</p>
|
||||
<p className="text-muted-foreground text-xs mt-1">
|
||||
{event.timestamp} {event.count > 1 && `(${event.count}x)`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground py-8">No status information available</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logs Tab */}
|
||||
{viewMode === 'logs' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={logParams.tail}
|
||||
onChange={(e) => setLogParams({ ...logParams, tail: parseInt(e.target.value) })}
|
||||
className="px-3 py-1 border rounded text-sm"
|
||||
>
|
||||
<option value={50}>Last 50 lines</option>
|
||||
<option value={100}>Last 100 lines</option>
|
||||
<option value={200}>Last 200 lines</option>
|
||||
<option value={500}>Last 500 lines</option>
|
||||
</select>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => refetchLogs()}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="bg-black text-green-400 font-mono text-xs p-4 rounded-lg max-h-96 overflow-y-auto">
|
||||
{logs && logs.logs && Array.isArray(logs.logs) && logs.logs.length > 0 ? (
|
||||
logs.logs.map((line, idx) => {
|
||||
// Handle both string format and object format {timestamp, message, pod}
|
||||
if (typeof line === 'string') {
|
||||
return (
|
||||
<div key={idx} className="whitespace-pre-wrap break-all">
|
||||
{line}
|
||||
</div>
|
||||
);
|
||||
} else if (line && typeof line === 'object' && 'message' in line) {
|
||||
// Display timestamp and message nicely
|
||||
const timestamp = line.timestamp ? new Date(line.timestamp).toLocaleTimeString() : '';
|
||||
return (
|
||||
<div key={idx} className="whitespace-pre-wrap break-all">
|
||||
{timestamp && <span className="text-gray-500">[{timestamp}] </span>}
|
||||
{line.message}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div key={idx} className="whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(line)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})
|
||||
) : logs && typeof logs === 'object' && !Array.isArray(logs) ? (
|
||||
// Handle case where logs might be an object with different structure
|
||||
<div className="whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(logs, null, 2)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500">No logs available</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
184
src/components/cluster/BootstrapModal.tsx
Normal file
184
src/components/cluster/BootstrapModal.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../ui/dialog';
|
||||
import { Button } from '../ui/button';
|
||||
import { Alert } from '../ui/alert';
|
||||
import { CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { BootstrapProgress } from './BootstrapProgress';
|
||||
import { clusterApi } from '../../services/api/cluster';
|
||||
import { useOperation } from '../../services/api/hooks/useOperations';
|
||||
|
||||
interface BootstrapModalProps {
|
||||
instanceName: string;
|
||||
nodeName: string;
|
||||
nodeIp: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function BootstrapModal({
|
||||
instanceName,
|
||||
nodeName,
|
||||
nodeIp,
|
||||
onClose,
|
||||
}: BootstrapModalProps) {
|
||||
const [operationId, setOperationId] = useState<string | null>(null);
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
const [startError, setStartError] = useState<string | null>(null);
|
||||
const [showConfirmation, setShowConfirmation] = useState(true);
|
||||
|
||||
const { data: operation } = useOperation(instanceName, operationId || '');
|
||||
|
||||
const handleStartBootstrap = async () => {
|
||||
setIsStarting(true);
|
||||
setStartError(null);
|
||||
|
||||
try {
|
||||
const response = await clusterApi.bootstrap(instanceName, nodeName);
|
||||
setOperationId(response.operation_id);
|
||||
setShowConfirmation(false);
|
||||
} catch (err) {
|
||||
setStartError((err as Error).message || 'Failed to start bootstrap');
|
||||
} finally {
|
||||
setIsStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (operation?.status === 'completed') {
|
||||
setTimeout(() => onClose(), 2000);
|
||||
}
|
||||
}, [operation?.status, onClose]);
|
||||
|
||||
const isComplete = operation?.status === 'completed';
|
||||
const isFailed = operation?.status === 'failed';
|
||||
const isRunning = operation?.status === 'running' || operation?.status === 'pending';
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent
|
||||
className="max-w-2xl"
|
||||
showCloseButton={!isRunning}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Bootstrap Cluster</DialogTitle>
|
||||
<DialogDescription>
|
||||
Initialize the Kubernetes cluster on {nodeName} ({nodeIp})
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{showConfirmation ? (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<div>
|
||||
<strong className="font-semibold">Important</strong>
|
||||
<p className="text-sm mt-1">
|
||||
This will initialize the etcd cluster and start the control plane
|
||||
components. This operation can only be performed once per cluster and
|
||||
should be run on the first control plane node.
|
||||
</p>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
{startError && (
|
||||
<Alert variant="error" onClose={() => setStartError(null)}>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<div>
|
||||
<strong className="font-semibold">Bootstrap Failed</strong>
|
||||
<p className="text-sm mt-1">{startError}</p>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="font-medium">Before bootstrapping, ensure:</p>
|
||||
<ul className="ml-4 list-disc space-y-1 text-muted-foreground">
|
||||
<li>Node configuration has been applied successfully</li>
|
||||
<li>Node is in maintenance mode and ready</li>
|
||||
<li>This is the first control plane node</li>
|
||||
<li>No other nodes have been bootstrapped</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={isStarting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleStartBootstrap} disabled={isStarting}>
|
||||
{isStarting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Starting...
|
||||
</>
|
||||
) : (
|
||||
'Start Bootstrap'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="py-4">
|
||||
{operation && operation.details?.bootstrap ? (
|
||||
<BootstrapProgress
|
||||
progress={operation.details.bootstrap}
|
||||
error={isFailed ? operation.error : undefined}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<span className="ml-3 text-muted-foreground">
|
||||
Starting bootstrap...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isComplete && (
|
||||
<Alert variant="success">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<div>
|
||||
<strong className="font-semibold">Bootstrap Complete!</strong>
|
||||
<p className="text-sm mt-1">
|
||||
The cluster has been successfully initialized. Additional control
|
||||
plane nodes can now join automatically.
|
||||
</p>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isFailed && (
|
||||
<Alert variant="error">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<div>
|
||||
<strong className="font-semibold">Bootstrap Failed</strong>
|
||||
<p className="text-sm mt-1">
|
||||
{operation.error || 'The bootstrap process encountered an error.'}
|
||||
</p>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{isComplete || isFailed ? (
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
) : (
|
||||
<Button variant="outline" disabled>
|
||||
Bootstrap in progress...
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
115
src/components/cluster/BootstrapProgress.tsx
Normal file
115
src/components/cluster/BootstrapProgress.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { CheckCircle, AlertCircle, Loader2, Clock } from 'lucide-react';
|
||||
import { Card } from '../ui/card';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { TroubleshootingPanel } from './TroubleshootingPanel';
|
||||
import type { BootstrapProgress as BootstrapProgressType } from '../../services/api/types';
|
||||
|
||||
interface BootstrapProgressProps {
|
||||
progress: BootstrapProgressType;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const BOOTSTRAP_STEPS = [
|
||||
{ id: 0, name: 'Bootstrap Command', description: 'Running talosctl bootstrap' },
|
||||
{ id: 1, name: 'etcd Health', description: 'Verifying etcd cluster health' },
|
||||
{ id: 2, name: 'VIP Assignment', description: 'Waiting for VIP assignment' },
|
||||
{ id: 3, name: 'Control Plane', description: 'Waiting for control plane components' },
|
||||
{ id: 4, name: 'API Server', description: 'Waiting for API server on VIP' },
|
||||
{ id: 5, name: 'Cluster Access', description: 'Configuring cluster access' },
|
||||
{ id: 6, name: 'Node Registration', description: 'Verifying node registration' },
|
||||
];
|
||||
|
||||
export function BootstrapProgress({ progress, error }: BootstrapProgressProps) {
|
||||
const getStepIcon = (stepId: number) => {
|
||||
if (stepId < progress.current_step) {
|
||||
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
||||
}
|
||||
if (stepId === progress.current_step) {
|
||||
if (error) {
|
||||
return <AlertCircle className="h-5 w-5 text-red-500" />;
|
||||
}
|
||||
return <Loader2 className="h-5 w-5 text-blue-500 animate-spin" />;
|
||||
}
|
||||
return <Clock className="h-5 w-5 text-gray-400" />;
|
||||
};
|
||||
|
||||
const getStepStatus = (stepId: number) => {
|
||||
if (stepId < progress.current_step) {
|
||||
return 'completed';
|
||||
}
|
||||
if (stepId === progress.current_step) {
|
||||
return error ? 'error' : 'running';
|
||||
}
|
||||
return 'pending';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{BOOTSTRAP_STEPS.map((step) => {
|
||||
const status = getStepStatus(step.id);
|
||||
const isActive = step.id === progress.current_step;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={step.id}
|
||||
className={`p-4 ${
|
||||
isActive
|
||||
? error
|
||||
? 'border-red-300 bg-red-50 dark:bg-red-950/20'
|
||||
: 'border-blue-300 bg-blue-50 dark:bg-blue-950/20'
|
||||
: status === 'completed'
|
||||
? 'border-green-200 bg-green-50 dark:bg-green-950/20'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5">{getStepIcon(step.id)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-medium text-sm">{step.name}</h4>
|
||||
{status === 'completed' && (
|
||||
<Badge variant="success" className="text-xs">
|
||||
Complete
|
||||
</Badge>
|
||||
)}
|
||||
{status === 'running' && !error && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
In Progress
|
||||
</Badge>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Failed
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{step.description}</p>
|
||||
{isActive && !error && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
Attempt {progress.attempt} of {progress.max_attempts}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${(progress.attempt / progress.max_attempts) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error && <TroubleshootingPanel step={progress.current_step} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
src/components/cluster/TroubleshootingPanel.tsx
Normal file
61
src/components/cluster/TroubleshootingPanel.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Alert } from '../ui/alert';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
|
||||
interface TroubleshootingPanelProps {
|
||||
step: number;
|
||||
}
|
||||
|
||||
const TROUBLESHOOTING_STEPS: Record<number, string[]> = {
|
||||
1: [
|
||||
'Check etcd service status with: talosctl -n <node-ip> service etcd',
|
||||
'View etcd logs: talosctl -n <node-ip> logs etcd',
|
||||
'Verify bootstrap completed successfully',
|
||||
],
|
||||
2: [
|
||||
'Check VIP controller logs: kubectl logs -n kube-system -l k8s-app=kube-vip',
|
||||
'Verify network configuration allows VIP assignment',
|
||||
'Check that VIP range is configured correctly in cluster config',
|
||||
],
|
||||
3: [
|
||||
'Check kubelet logs: talosctl -n <node-ip> logs kubelet',
|
||||
'Verify static pod manifests: talosctl -n <node-ip> list /etc/kubernetes/manifests',
|
||||
'Try restarting kubelet: talosctl -n <node-ip> service kubelet restart',
|
||||
],
|
||||
4: [
|
||||
'Check API server logs: kubectl logs -n kube-system kube-apiserver-<node>',
|
||||
'Verify API server is running: talosctl -n <node-ip> service kubelet',
|
||||
'Test API server on node IP: curl -k https://<node-ip>:6443/healthz',
|
||||
],
|
||||
5: [
|
||||
'Check API server logs for connection errors',
|
||||
'Test API server on node IP first: curl -k https://<node-ip>:6443/healthz',
|
||||
'Verify network connectivity to VIP address',
|
||||
],
|
||||
6: [
|
||||
'Check kubelet logs: talosctl -n <node-ip> logs kubelet',
|
||||
'Verify API server is accessible: kubectl get nodes',
|
||||
'Check network connectivity between node and API server',
|
||||
],
|
||||
};
|
||||
|
||||
export function TroubleshootingPanel({ step }: TroubleshootingPanelProps) {
|
||||
const steps = TROUBLESHOOTING_STEPS[step] || [
|
||||
'Check logs for detailed error information',
|
||||
'Verify network connectivity',
|
||||
'Ensure all prerequisites are met',
|
||||
];
|
||||
|
||||
return (
|
||||
<Alert variant="error" className="mt-4">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<div>
|
||||
<strong className="font-semibold">Troubleshooting Steps</strong>
|
||||
<ul className="mt-2 ml-4 list-disc space-y-1 text-sm">
|
||||
{steps.map((troubleshootingStep, index) => (
|
||||
<li key={index}>{troubleshootingStep}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
3
src/components/cluster/index.ts
Normal file
3
src/components/cluster/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { BootstrapModal } from './BootstrapModal';
|
||||
export { BootstrapProgress } from './BootstrapProgress';
|
||||
export { TroubleshootingPanel } from './TroubleshootingPanel';
|
||||
@@ -14,6 +14,10 @@ export { CentralComponent } from './CentralComponent';
|
||||
export { DnsComponent } from './DnsComponent';
|
||||
export { DhcpComponent } from './DhcpComponent';
|
||||
export { PxeComponent } from './PxeComponent';
|
||||
export { ClusterNodesComponent } from './ClusterNodesComponent';
|
||||
export { ClusterServicesComponent } from './ClusterServicesComponent';
|
||||
export { AppsComponent } from './AppsComponent';
|
||||
export { AppsComponent } from './AppsComponent';
|
||||
export { SecretInput } from './SecretInput';
|
||||
export { ConfigViewer } from './ConfigViewer';
|
||||
export { DownloadButton } from './DownloadButton';
|
||||
export { CopyButton } from './CopyButton';
|
||||
export { ServiceCard } from './ServiceCard';
|
||||
90
src/components/nodes/HardwareDetectionDisplay.tsx
Normal file
90
src/components/nodes/HardwareDetectionDisplay.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { HardwareInfo } from '../../services/api/types';
|
||||
|
||||
interface HardwareDetectionDisplayProps {
|
||||
detection: HardwareInfo;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export function HardwareDetectionDisplay({ detection }: HardwareDetectionDisplayProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 p-4 space-y-3">
|
||||
<div className="flex items-start">
|
||||
<svg
|
||||
className="h-5 w-5 text-green-500 mt-0.5 mr-3 flex-shrink-0"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">IP Address</p>
|
||||
<p className="text-sm text-gray-900 dark:text-gray-100 font-mono">{detection.ip}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detection.interface && (
|
||||
<div className="flex items-start">
|
||||
<svg
|
||||
className="h-5 w-5 text-green-500 mt-0.5 mr-3 flex-shrink-0"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Network Interface</p>
|
||||
<p className="text-sm text-gray-900 dark:text-gray-100 font-mono">{detection.interface}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detection.disks && detection.disks.length > 0 && (
|
||||
<div className="flex items-start">
|
||||
<svg
|
||||
className="h-5 w-5 text-green-500 mt-0.5 mr-3 flex-shrink-0"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Available Disks</p>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{detection.disks.map((disk) => (
|
||||
<li key={disk.path} className="text-sm text-gray-900 dark:text-gray-100">
|
||||
<span className="font-mono">{disk.path}</span>
|
||||
{disk.size > 0 && (
|
||||
<span className="text-gray-500 dark:text-gray-400 ml-2">
|
||||
({formatBytes(disk.size)})
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1349
src/components/nodes/NodeForm.test.tsx
Normal file
1349
src/components/nodes/NodeForm.test.tsx
Normal file
File diff suppressed because it is too large
Load Diff
615
src/components/nodes/NodeForm.tsx
Normal file
615
src/components/nodes/NodeForm.tsx
Normal file
@@ -0,0 +1,615 @@
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useInstanceConfig } from '../../hooks/useInstances';
|
||||
import { useNodes } from '../../hooks/useNodes';
|
||||
import type { HardwareInfo } from '../../services/api/types';
|
||||
import { Input, Label, Button } from '../ui';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../ui/select';
|
||||
|
||||
export interface NodeFormData {
|
||||
hostname: string;
|
||||
role: 'controlplane' | 'worker';
|
||||
disk: string;
|
||||
targetIp: string;
|
||||
interface?: string;
|
||||
schematicId?: string;
|
||||
maintenance: boolean;
|
||||
}
|
||||
|
||||
interface NodeFormProps {
|
||||
initialValues?: Partial<NodeFormData>;
|
||||
detection?: HardwareInfo;
|
||||
onSubmit: (data: NodeFormData) => Promise<void>;
|
||||
onApply?: (data: NodeFormData) => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
submitLabel?: string;
|
||||
showApplyButton?: boolean;
|
||||
instanceName?: string;
|
||||
}
|
||||
|
||||
function getInitialValues(
|
||||
initial?: Partial<NodeFormData>,
|
||||
detection?: HardwareInfo,
|
||||
nodes?: Array<{ role: string; hostname?: string }>,
|
||||
hostnamePrefix?: string
|
||||
): NodeFormData {
|
||||
// Determine default role: controlplane unless there are already 3+ control nodes
|
||||
let defaultRole: 'controlplane' | 'worker' = 'controlplane';
|
||||
if (nodes) {
|
||||
const controlPlaneCount = nodes.filter(n => n.role === 'controlplane').length;
|
||||
if (controlPlaneCount >= 3) {
|
||||
defaultRole = 'worker';
|
||||
}
|
||||
}
|
||||
|
||||
const role = initial?.role || defaultRole;
|
||||
|
||||
// Generate default hostname based on role and existing nodes
|
||||
let defaultHostname = '';
|
||||
if (!initial?.hostname) {
|
||||
const prefix = hostnamePrefix || '';
|
||||
|
||||
// Generate a hostname even if nodes is not loaded yet
|
||||
// The useEffect will fix it later when data is available
|
||||
if (role === 'controlplane') {
|
||||
if (nodes) {
|
||||
// Find next control plane number
|
||||
const controlNumbers = nodes
|
||||
.filter(n => n.role === 'controlplane')
|
||||
.map(n => {
|
||||
const match = n.hostname?.match(new RegExp(`${prefix}control-(\\d+)$`));
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
})
|
||||
.filter((n): n is number => n !== null);
|
||||
|
||||
const nextNumber = controlNumbers.length > 0 ? Math.max(...controlNumbers) + 1 : 1;
|
||||
defaultHostname = `${prefix}control-${nextNumber}`;
|
||||
} else {
|
||||
// No nodes loaded yet, default to 1
|
||||
defaultHostname = `${prefix}control-1`;
|
||||
}
|
||||
} else {
|
||||
if (nodes) {
|
||||
// Find next worker number
|
||||
const workerNumbers = nodes
|
||||
.filter(n => n.role === 'worker')
|
||||
.map(n => {
|
||||
const match = n.hostname?.match(new RegExp(`${prefix}worker-(\\d+)$`));
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
})
|
||||
.filter((n): n is number => n !== null);
|
||||
|
||||
const nextNumber = workerNumbers.length > 0 ? Math.max(...workerNumbers) + 1 : 1;
|
||||
defaultHostname = `${prefix}worker-${nextNumber}`;
|
||||
} else {
|
||||
// No nodes loaded yet, default to 1
|
||||
defaultHostname = `${prefix}worker-1`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-select first disk if none specified
|
||||
let defaultDisk = initial?.disk || detection?.selected_disk || '';
|
||||
if (!defaultDisk && detection?.disks && detection.disks.length > 0) {
|
||||
defaultDisk = detection.disks[0].path;
|
||||
}
|
||||
|
||||
// Auto-select first interface if none specified
|
||||
let defaultInterface = initial?.interface || detection?.interface || '';
|
||||
if (!defaultInterface && detection?.interfaces && detection.interfaces.length > 0) {
|
||||
defaultInterface = detection.interfaces[0];
|
||||
}
|
||||
|
||||
return {
|
||||
hostname: initial?.hostname || defaultHostname,
|
||||
role,
|
||||
disk: defaultDisk,
|
||||
targetIp: initial?.targetIp || detection?.ip || '', // Auto-fill from detection
|
||||
interface: defaultInterface,
|
||||
schematicId: initial?.schematicId || '',
|
||||
maintenance: initial?.maintenance ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
export function NodeForm({
|
||||
initialValues,
|
||||
detection,
|
||||
onSubmit,
|
||||
onApply,
|
||||
onCancel,
|
||||
submitLabel = 'Save',
|
||||
showApplyButton = false,
|
||||
instanceName,
|
||||
}: NodeFormProps) {
|
||||
// Track if we're editing an existing node (has initial hostname from backend)
|
||||
const isExistingNode = Boolean(initialValues?.hostname);
|
||||
const { config: instanceConfig } = useInstanceConfig(instanceName);
|
||||
const { nodes } = useNodes(instanceName);
|
||||
|
||||
const hostnamePrefix = instanceConfig?.cluster?.hostnamePrefix || '';
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
control,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<NodeFormData>({
|
||||
defaultValues: getInitialValues(initialValues, detection, nodes, hostnamePrefix),
|
||||
});
|
||||
|
||||
const schematicId = watch('schematicId');
|
||||
const role = watch('role');
|
||||
const hostname = watch('hostname');
|
||||
|
||||
// Reset form when switching between different nodes in configure mode
|
||||
// This ensures select boxes and all fields show the current values
|
||||
// Use refs to track both the hostname and mode to avoid unnecessary resets
|
||||
const prevHostnameRef = useRef<string | undefined>(undefined);
|
||||
const prevModeRef = useRef<'add' | 'configure' | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const currentHostname = initialValues?.hostname;
|
||||
const currentMode = initialValues?.hostname ? 'configure' : 'add';
|
||||
|
||||
// Only reset if we're actually switching between different nodes in configure mode
|
||||
// or switching from add to configure mode (or vice versa)
|
||||
const modeChanged = currentMode !== prevModeRef.current;
|
||||
const hostnameChanged = currentMode === 'configure' && currentHostname !== prevHostnameRef.current;
|
||||
|
||||
if (modeChanged || hostnameChanged) {
|
||||
prevHostnameRef.current = currentHostname;
|
||||
prevModeRef.current = currentMode;
|
||||
const newValues = getInitialValues(initialValues, detection, nodes, hostnamePrefix);
|
||||
reset(newValues);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialValues, detection, nodes, hostnamePrefix]);
|
||||
|
||||
// Set default role based on existing control plane nodes
|
||||
useEffect(() => {
|
||||
if (!initialValues?.role && nodes) {
|
||||
const controlPlaneCount = nodes.filter(n => n.role === 'controlplane').length;
|
||||
const defaultRole: 'controlplane' | 'worker' = controlPlaneCount >= 3 ? 'worker' : 'controlplane';
|
||||
const currentRole = watch('role');
|
||||
|
||||
// Only update if the current role is still the initial default and we now have node data
|
||||
if (currentRole === 'controlplane' && controlPlaneCount >= 3) {
|
||||
setValue('role', defaultRole);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [nodes, initialValues?.role]);
|
||||
|
||||
// Pre-populate schematic ID from cluster config if available
|
||||
useEffect(() => {
|
||||
if (!schematicId && instanceConfig?.cluster?.nodes?.talos?.schematicId) {
|
||||
setValue('schematicId', instanceConfig.cluster.nodes.talos.schematicId);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [instanceConfig, schematicId]);
|
||||
|
||||
// Auto-generate hostname when role changes (only for NEW nodes without initial hostname)
|
||||
useEffect(() => {
|
||||
if (!nodes) return;
|
||||
|
||||
// Don't auto-generate if this is an existing node with initial hostname
|
||||
// This check must happen FIRST to prevent regeneration when hostnamePrefix loads
|
||||
if (isExistingNode) return;
|
||||
|
||||
const prefix = hostnamePrefix || '';
|
||||
const currentHostname = watch('hostname');
|
||||
|
||||
if (!currentHostname) return;
|
||||
|
||||
// Check if current hostname follows our naming pattern WITH prefix
|
||||
const hostnameMatch = currentHostname.match(new RegExp(`^${prefix}(control|worker)-(\\d+)$`));
|
||||
|
||||
// If no match with prefix, check if it matches WITHOUT prefix (generated before prefix was loaded)
|
||||
const hostnameMatchNoPrefix = !hostnameMatch && prefix ?
|
||||
currentHostname.match(/^(control|worker)-(\d+)$/) : null;
|
||||
|
||||
// Check if this is a generated hostname (either with or without prefix)
|
||||
const isGeneratedHostname = hostnameMatch !== null || hostnameMatchNoPrefix !== null;
|
||||
|
||||
// Use whichever match succeeded
|
||||
const activeMatch = hostnameMatch || hostnameMatchNoPrefix;
|
||||
|
||||
// Check if the role prefix in the hostname matches the current role
|
||||
const hostnameRolePrefix = activeMatch?.[1]; // 'control' or 'worker'
|
||||
const expectedRolePrefix = role === 'controlplane' ? 'control' : 'worker';
|
||||
const roleMatches = hostnameRolePrefix === expectedRolePrefix;
|
||||
|
||||
// Check if the hostname has the expected prefix
|
||||
const hasCorrectPrefix = hostnameMatch !== null;
|
||||
|
||||
// Auto-update hostname if it was previously auto-generated AND either:
|
||||
// 1. The role prefix doesn't match (e.g., hostname is "control-1" but role is "worker")
|
||||
// 2. The hostname is missing the prefix (e.g., "control-1" instead of "test-control-1")
|
||||
// 3. The number needs updating (existing logic)
|
||||
if (isGeneratedHostname && (!roleMatches || !hasCorrectPrefix)) {
|
||||
// Role changed, need to regenerate with correct prefix
|
||||
if (role === 'controlplane') {
|
||||
const controlNumbers = nodes
|
||||
.filter(n => n.role === 'controlplane')
|
||||
.map(n => {
|
||||
const match = n.hostname?.match(new RegExp(`${prefix}control-(\\d+)$`));
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
})
|
||||
.filter((n): n is number => n !== null);
|
||||
|
||||
const nextNumber = controlNumbers.length > 0 ? Math.max(...controlNumbers) + 1 : 1;
|
||||
const newHostname = `${prefix}control-${nextNumber}`;
|
||||
setValue('hostname', newHostname);
|
||||
} else {
|
||||
const workerNumbers = nodes
|
||||
.filter(n => n.role === 'worker')
|
||||
.map(n => {
|
||||
const match = n.hostname?.match(new RegExp(`${prefix}worker-(\\d+)$`));
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
})
|
||||
.filter((n): n is number => n !== null);
|
||||
|
||||
const nextNumber = workerNumbers.length > 0 ? Math.max(...workerNumbers) + 1 : 1;
|
||||
const newHostname = `${prefix}worker-${nextNumber}`;
|
||||
setValue('hostname', newHostname);
|
||||
}
|
||||
} else if (isGeneratedHostname && roleMatches && hasCorrectPrefix) {
|
||||
// Role matches and prefix is correct, but check if the number needs updating (original logic)
|
||||
if (role === 'controlplane') {
|
||||
const controlNumbers = nodes
|
||||
.filter(n => n.role === 'controlplane')
|
||||
.map(n => {
|
||||
const match = n.hostname?.match(new RegExp(`${prefix}control-(\\d+)$`));
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
})
|
||||
.filter((n): n is number => n !== null);
|
||||
|
||||
const nextNumber = controlNumbers.length > 0 ? Math.max(...controlNumbers) + 1 : 1;
|
||||
const newHostname = `${prefix}control-${nextNumber}`;
|
||||
if (currentHostname !== newHostname) {
|
||||
setValue('hostname', newHostname);
|
||||
}
|
||||
} else {
|
||||
const workerNumbers = nodes
|
||||
.filter(n => n.role === 'worker')
|
||||
.map(n => {
|
||||
const match = n.hostname?.match(new RegExp(`${prefix}worker-(\\d+)$`));
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
})
|
||||
.filter((n): n is number => n !== null);
|
||||
|
||||
const nextNumber = workerNumbers.length > 0 ? Math.max(...workerNumbers) + 1 : 1;
|
||||
const newHostname = `${prefix}worker-${nextNumber}`;
|
||||
if (currentHostname !== newHostname) {
|
||||
setValue('hostname', newHostname);
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [role, nodes, hostnamePrefix, isExistingNode]);
|
||||
|
||||
// Auto-calculate target IP for control plane nodes
|
||||
useEffect(() => {
|
||||
// Skip if this is an existing node (configure mode)
|
||||
if (initialValues?.targetIp) return;
|
||||
|
||||
// Skip if there's a detection IP (hardware detection provides the actual IP)
|
||||
if (detection?.ip) return;
|
||||
|
||||
// Skip if there's already a targetIp from detection
|
||||
const currentTargetIp = watch('targetIp');
|
||||
if (currentTargetIp && role === 'worker') return; // For workers, keep any existing value
|
||||
|
||||
const clusterConfig = instanceConfig?.cluster as any;
|
||||
const vip = clusterConfig?.nodes?.control?.vip as string | undefined;
|
||||
|
||||
if (role === 'controlplane' && vip) {
|
||||
|
||||
// Parse VIP to get base and last octet
|
||||
const vipParts = vip.split('.');
|
||||
if (vipParts.length !== 4) return;
|
||||
|
||||
const vipLastOctet = parseInt(vipParts[3], 10);
|
||||
if (isNaN(vipLastOctet)) return;
|
||||
|
||||
const vipPrefix = vipParts.slice(0, 3).join('.');
|
||||
|
||||
// Find all control plane IPs in the same subnet range
|
||||
const usedOctets = nodes
|
||||
.filter(node => node.role === 'controlplane' && node.target_ip)
|
||||
.map(node => {
|
||||
const parts = node.target_ip.split('.');
|
||||
if (parts.length !== 4) return null;
|
||||
// Only consider IPs in the same subnet
|
||||
if (parts.slice(0, 3).join('.') !== vipPrefix) return null;
|
||||
const octet = parseInt(parts[3], 10);
|
||||
return isNaN(octet) ? null : octet;
|
||||
})
|
||||
.filter((octet): octet is number => octet !== null && octet > vipLastOctet);
|
||||
|
||||
// Find the first available IP after VIP
|
||||
let nextOctet = vipLastOctet + 1;
|
||||
|
||||
// Sort used octets to find gaps
|
||||
const sortedOctets = [...usedOctets].sort((a, b) => a - b);
|
||||
|
||||
// Check for gaps in the sequence starting from VIP+1
|
||||
for (const usedOctet of sortedOctets) {
|
||||
if (usedOctet === nextOctet) {
|
||||
nextOctet++;
|
||||
} else if (usedOctet > nextOctet) {
|
||||
// Found a gap, use it
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure we don't exceed valid IP range
|
||||
if (nextOctet > 254) {
|
||||
console.warn('No available IPs in subnet after VIP');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the calculated IP
|
||||
setValue('targetIp', `${vipPrefix}.${nextOctet}`);
|
||||
} else if (role === 'worker' && !detection?.ip) {
|
||||
// For worker nodes without detection, only clear if it looks like an auto-calculated control plane IP
|
||||
const currentTargetIp = watch('targetIp');
|
||||
// Only clear if it looks like an auto-calculated IP (matches VIP pattern)
|
||||
if (currentTargetIp && vip) {
|
||||
const vipPrefix = vip.split('.').slice(0, 3).join('.');
|
||||
if (currentTargetIp.startsWith(vipPrefix)) {
|
||||
setValue('targetIp', '');
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [role, instanceConfig, nodes, initialValues?.targetIp, detection?.ip]);
|
||||
|
||||
// Build disk options from both detection and initial values
|
||||
const diskOptions = (() => {
|
||||
const options = [...(detection?.disks || [])];
|
||||
// If configuring existing node, ensure its disk is in options
|
||||
if (initialValues?.disk && !options.some(d => d.path === initialValues.disk)) {
|
||||
options.push({ path: initialValues.disk, size: 0 });
|
||||
}
|
||||
return options;
|
||||
})();
|
||||
|
||||
// Build interface options from both detection and initial values
|
||||
const interfaceOptions = (() => {
|
||||
const options = [...(detection?.interfaces || [])];
|
||||
// If configuring existing node, ensure its interface is in options
|
||||
if (initialValues?.interface && !options.includes(initialValues.interface)) {
|
||||
options.push(initialValues.interface);
|
||||
}
|
||||
// Also add detection.interface if present
|
||||
if (detection?.interface && !options.includes(detection.interface)) {
|
||||
options.push(detection.interface);
|
||||
}
|
||||
return options;
|
||||
})();
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="role">Role</Label>
|
||||
<Controller
|
||||
name="role"
|
||||
control={control}
|
||||
rules={{ required: 'Role is required' }}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value || ''} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Select role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="controlplane">Control Plane</SelectItem>
|
||||
<SelectItem value="worker">Worker</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
{errors.role && <p className="text-sm text-red-600 mt-1">{errors.role.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="hostname">Hostname</Label>
|
||||
<Input
|
||||
id="hostname"
|
||||
type="text"
|
||||
{...register('hostname', {
|
||||
required: 'Hostname is required',
|
||||
pattern: {
|
||||
value: /^[a-z0-9-]+$/,
|
||||
message: 'Hostname must contain only lowercase letters, numbers, and hyphens',
|
||||
},
|
||||
})}
|
||||
className="mt-1"
|
||||
/>
|
||||
{errors.hostname && (
|
||||
<p className="text-sm text-red-600 mt-1">{errors.hostname.message}</p>
|
||||
)}
|
||||
{hostname && hostname.match(/^.*?(control|worker)-\d+$/) && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Auto-generated based on role and existing nodes
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="disk">Disk</Label>
|
||||
{diskOptions.length > 0 ? (
|
||||
<Controller
|
||||
name="disk"
|
||||
control={control}
|
||||
rules={{ required: 'Disk is required' }}
|
||||
render={({ field }) => {
|
||||
// Ensure we have a value - use the field value or fall back to first option
|
||||
const value = field.value || (diskOptions.length > 0 ? diskOptions[0].path : '');
|
||||
return (
|
||||
<Select value={value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Select a disk" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{diskOptions.map((disk) => (
|
||||
<SelectItem key={disk.path} value={disk.path}>
|
||||
{disk.path}
|
||||
{disk.size > 0 && ` (${formatBytes(disk.size)})`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Controller
|
||||
name="disk"
|
||||
control={control}
|
||||
rules={{ required: 'Disk is required' }}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id="disk"
|
||||
type="text"
|
||||
value={field.value || ''}
|
||||
onChange={field.onChange}
|
||||
className="mt-1"
|
||||
placeholder="/dev/sda"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{errors.disk && <p className="text-sm text-red-600 mt-1">{errors.disk.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="targetIp">Target IP Address</Label>
|
||||
<Input
|
||||
id="targetIp"
|
||||
type="text"
|
||||
{...register('targetIp')}
|
||||
className="mt-1"
|
||||
/>
|
||||
{errors.targetIp && (
|
||||
<p className="text-sm text-red-600 mt-1">{errors.targetIp.message}</p>
|
||||
)}
|
||||
{role === 'controlplane' && (instanceConfig?.cluster as any)?.nodes?.control?.vip && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Auto-calculated from VIP ({(instanceConfig?.cluster as any)?.nodes?.control?.vip})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="interface">Network Interface</Label>
|
||||
{interfaceOptions.length > 0 ? (
|
||||
<Controller
|
||||
name="interface"
|
||||
control={control}
|
||||
render={({ field }) => {
|
||||
// Ensure we have a value - use the field value or fall back to first option
|
||||
const value = field.value || (interfaceOptions.length > 0 ? interfaceOptions[0] : '');
|
||||
return (
|
||||
<Select value={value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Select interface..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{interfaceOptions.map((iface) => (
|
||||
<SelectItem key={iface} value={iface}>
|
||||
{iface}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Controller
|
||||
name="interface"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
id="interface"
|
||||
type="text"
|
||||
value={field.value || ''}
|
||||
onChange={field.onChange}
|
||||
className="mt-1"
|
||||
placeholder="eth0"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="schematicId">Schematic ID (Optional)</Label>
|
||||
<Input
|
||||
id="schematicId"
|
||||
type="text"
|
||||
{...register('schematicId')}
|
||||
className="mt-1 font-mono text-xs"
|
||||
placeholder="abc123def456..."
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Leave blank to use default Talos configuration
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
reset();
|
||||
onCancel();
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
{showApplyButton && onApply ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSubmit(onApply)}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1"
|
||||
>
|
||||
{isSubmitting ? 'Applying...' : 'Apply Configuration'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex-1"
|
||||
>
|
||||
{isSubmitting ? 'Saving...' : submitLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
392
src/components/nodes/NodeForm.unit.test.tsx
Normal file
392
src/components/nodes/NodeForm.unit.test.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { NodeFormData } from './NodeForm';
|
||||
import { createMockNode, createMockNodes, createMockHardwareInfo } from '../../test/utils/nodeFormTestUtils';
|
||||
import type { HardwareInfo } from '../../services/api/types';
|
||||
|
||||
function getInitialValues(
|
||||
initial?: Partial<NodeFormData>,
|
||||
detection?: HardwareInfo,
|
||||
nodes?: Array<{ role: string; hostname?: string }>,
|
||||
hostnamePrefix?: string
|
||||
): NodeFormData {
|
||||
let defaultRole: 'controlplane' | 'worker' = 'controlplane';
|
||||
if (nodes) {
|
||||
const controlPlaneCount = nodes.filter(n => n.role === 'controlplane').length;
|
||||
if (controlPlaneCount >= 3) {
|
||||
defaultRole = 'worker';
|
||||
}
|
||||
}
|
||||
|
||||
const role = initial?.role || defaultRole;
|
||||
|
||||
let defaultHostname = '';
|
||||
if (!initial?.hostname && nodes && hostnamePrefix !== undefined) {
|
||||
const prefix = hostnamePrefix || '';
|
||||
if (role === 'controlplane') {
|
||||
const controlNumbers = nodes
|
||||
.filter(n => n.role === 'controlplane')
|
||||
.map(n => {
|
||||
const match = n.hostname?.match(new RegExp(`${prefix}control-(\\d+)$`));
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
})
|
||||
.filter((n): n is number => n !== null);
|
||||
|
||||
const nextNumber = controlNumbers.length > 0 ? Math.max(...controlNumbers) + 1 : 1;
|
||||
defaultHostname = `${prefix}control-${nextNumber}`;
|
||||
} else {
|
||||
const workerNumbers = nodes
|
||||
.filter(n => n.role === 'worker')
|
||||
.map(n => {
|
||||
const match = n.hostname?.match(new RegExp(`${prefix}worker-(\\d+)$`));
|
||||
return match ? parseInt(match[1], 10) : null;
|
||||
})
|
||||
.filter((n): n is number => n !== null);
|
||||
|
||||
const nextNumber = workerNumbers.length > 0 ? Math.max(...workerNumbers) + 1 : 1;
|
||||
defaultHostname = `${prefix}worker-${nextNumber}`;
|
||||
}
|
||||
}
|
||||
|
||||
let defaultDisk = initial?.disk || detection?.selected_disk || '';
|
||||
if (!defaultDisk && detection?.disks && detection.disks.length > 0) {
|
||||
defaultDisk = detection.disks[0].path;
|
||||
}
|
||||
|
||||
let defaultInterface = initial?.interface || detection?.interface || '';
|
||||
if (!defaultInterface && detection?.interfaces && detection.interfaces.length > 0) {
|
||||
defaultInterface = detection.interfaces[0];
|
||||
}
|
||||
|
||||
return {
|
||||
hostname: initial?.hostname || defaultHostname,
|
||||
role,
|
||||
disk: defaultDisk,
|
||||
targetIp: initial?.targetIp || '',
|
||||
currentIp: initial?.currentIp || detection?.ip || '',
|
||||
interface: defaultInterface,
|
||||
schematicId: initial?.schematicId || '',
|
||||
maintenance: initial?.maintenance ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
describe('getInitialValues', () => {
|
||||
describe('Role Selection', () => {
|
||||
it('defaults to controlplane when no nodes exist', () => {
|
||||
const result = getInitialValues(undefined, undefined, [], 'test-');
|
||||
expect(result.role).toBe('controlplane');
|
||||
});
|
||||
|
||||
it('defaults to controlplane when fewer than 3 control nodes exist', () => {
|
||||
const nodes = createMockNodes(2, 'controlplane');
|
||||
const result = getInitialValues(undefined, undefined, nodes, 'test-');
|
||||
expect(result.role).toBe('controlplane');
|
||||
});
|
||||
|
||||
it('defaults to worker when 3 or more control nodes exist', () => {
|
||||
const nodes = createMockNodes(3, 'controlplane');
|
||||
const result = getInitialValues(undefined, undefined, nodes, 'test-');
|
||||
expect(result.role).toBe('worker');
|
||||
});
|
||||
|
||||
it('respects explicit role in initial values', () => {
|
||||
const nodes = createMockNodes(3, 'controlplane');
|
||||
const result = getInitialValues({ role: 'controlplane' }, undefined, nodes, 'test-');
|
||||
expect(result.role).toBe('controlplane');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hostname Generation', () => {
|
||||
it('generates first control node hostname', () => {
|
||||
const result = getInitialValues(undefined, undefined, [], 'test-');
|
||||
expect(result.hostname).toBe('test-control-1');
|
||||
});
|
||||
|
||||
it('generates second control node hostname', () => {
|
||||
const nodes = [createMockNode({ hostname: 'test-control-1', role: 'controlplane' })];
|
||||
const result = getInitialValues(undefined, undefined, nodes, 'test-');
|
||||
expect(result.hostname).toBe('test-control-2');
|
||||
});
|
||||
|
||||
it('generates third control node hostname', () => {
|
||||
const nodes = [
|
||||
createMockNode({ hostname: 'test-control-1', role: 'controlplane' }),
|
||||
createMockNode({ hostname: 'test-control-2', role: 'controlplane' }),
|
||||
];
|
||||
const result = getInitialValues(undefined, undefined, nodes, 'test-');
|
||||
expect(result.hostname).toBe('test-control-3');
|
||||
});
|
||||
|
||||
it('generates first worker node hostname', () => {
|
||||
const nodes = createMockNodes(3, 'controlplane');
|
||||
const result = getInitialValues({ role: 'worker' }, undefined, nodes, 'test-');
|
||||
expect(result.hostname).toBe('test-worker-1');
|
||||
});
|
||||
|
||||
it('generates second worker node hostname', () => {
|
||||
const nodes = [
|
||||
...createMockNodes(3, 'controlplane'),
|
||||
createMockNode({ hostname: 'test-worker-1', role: 'worker' }),
|
||||
];
|
||||
const result = getInitialValues({ role: 'worker' }, undefined, nodes, 'test-');
|
||||
expect(result.hostname).toBe('test-worker-2');
|
||||
});
|
||||
|
||||
it('handles empty hostname prefix', () => {
|
||||
const result = getInitialValues(undefined, undefined, [], '');
|
||||
expect(result.hostname).toBe('control-1');
|
||||
});
|
||||
|
||||
it('handles gaps in hostname numbering for control nodes', () => {
|
||||
const nodes = [
|
||||
createMockNode({ hostname: 'test-control-1', role: 'controlplane' }),
|
||||
createMockNode({ hostname: 'test-control-3', role: 'controlplane' }),
|
||||
];
|
||||
const result = getInitialValues(undefined, undefined, nodes, 'test-');
|
||||
expect(result.hostname).toBe('test-control-4');
|
||||
});
|
||||
|
||||
it('handles gaps in hostname numbering for worker nodes', () => {
|
||||
const nodes = [
|
||||
...createMockNodes(3, 'controlplane'),
|
||||
createMockNode({ hostname: 'test-worker-1', role: 'worker' }),
|
||||
createMockNode({ hostname: 'test-worker-5', role: 'worker' }),
|
||||
];
|
||||
const result = getInitialValues({ role: 'worker' }, undefined, nodes, 'test-');
|
||||
expect(result.hostname).toBe('test-worker-6');
|
||||
});
|
||||
|
||||
it('preserves initial hostname when provided', () => {
|
||||
const result = getInitialValues(
|
||||
{ hostname: 'custom-hostname' },
|
||||
undefined,
|
||||
[],
|
||||
'test-'
|
||||
);
|
||||
expect(result.hostname).toBe('custom-hostname');
|
||||
});
|
||||
|
||||
it('does not generate hostname when hostnamePrefix is undefined', () => {
|
||||
const result = getInitialValues(undefined, undefined, [], undefined);
|
||||
expect(result.hostname).toBe('');
|
||||
});
|
||||
|
||||
it('does not generate hostname when initial hostname is provided', () => {
|
||||
const result = getInitialValues(
|
||||
{ hostname: 'existing-node' },
|
||||
undefined,
|
||||
[],
|
||||
'test-'
|
||||
);
|
||||
expect(result.hostname).toBe('existing-node');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Disk Selection', () => {
|
||||
it('uses disk from initial values', () => {
|
||||
const result = getInitialValues(
|
||||
{ disk: '/dev/nvme0n1' },
|
||||
createMockHardwareInfo(),
|
||||
[],
|
||||
'test-'
|
||||
);
|
||||
expect(result.disk).toBe('/dev/nvme0n1');
|
||||
});
|
||||
|
||||
it('uses selected_disk from detection', () => {
|
||||
const detection = createMockHardwareInfo({ selected_disk: '/dev/sdb' });
|
||||
const result = getInitialValues(undefined, detection, [], 'test-');
|
||||
expect(result.disk).toBe('/dev/sdb');
|
||||
});
|
||||
|
||||
it('auto-selects first disk from detection when no selected_disk', () => {
|
||||
const detection = createMockHardwareInfo({ selected_disk: undefined });
|
||||
const result = getInitialValues(undefined, detection, [], 'test-');
|
||||
expect(result.disk).toBe('/dev/sda');
|
||||
});
|
||||
|
||||
it('returns empty string when no disk info available', () => {
|
||||
const result = getInitialValues(undefined, undefined, [], 'test-');
|
||||
expect(result.disk).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string when detection has no disks', () => {
|
||||
const detection = createMockHardwareInfo({ disks: [], selected_disk: undefined });
|
||||
const result = getInitialValues(undefined, detection, [], 'test-');
|
||||
expect(result.disk).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Interface Selection', () => {
|
||||
it('uses interface from initial values', () => {
|
||||
const result = getInitialValues(
|
||||
{ interface: 'eth2' },
|
||||
createMockHardwareInfo(),
|
||||
[],
|
||||
'test-'
|
||||
);
|
||||
expect(result.interface).toBe('eth2');
|
||||
});
|
||||
|
||||
it('uses interface from detection', () => {
|
||||
const detection = createMockHardwareInfo({ interface: 'eth1' });
|
||||
const result = getInitialValues(undefined, detection, [], 'test-');
|
||||
expect(result.interface).toBe('eth1');
|
||||
});
|
||||
|
||||
it('auto-selects first interface from detection when no interface set', () => {
|
||||
const detection = createMockHardwareInfo({ interface: undefined });
|
||||
const result = getInitialValues(undefined, detection, [], 'test-');
|
||||
expect(result.interface).toBe('eth0');
|
||||
});
|
||||
|
||||
it('returns empty string when no interface info available', () => {
|
||||
const result = getInitialValues(undefined, undefined, [], 'test-');
|
||||
expect(result.interface).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string when detection has no interfaces', () => {
|
||||
const detection = createMockHardwareInfo({ interface: undefined, interfaces: [] });
|
||||
const result = getInitialValues(undefined, detection, [], 'test-');
|
||||
expect(result.interface).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('IP Address Handling', () => {
|
||||
it('does not auto-fill targetIp', () => {
|
||||
const result = getInitialValues(undefined, createMockHardwareInfo(), [], 'test-');
|
||||
expect(result.targetIp).toBe('');
|
||||
});
|
||||
|
||||
it('preserves initial targetIp', () => {
|
||||
const result = getInitialValues(
|
||||
{ targetIp: '192.168.1.200' },
|
||||
createMockHardwareInfo(),
|
||||
[],
|
||||
'test-'
|
||||
);
|
||||
expect(result.targetIp).toBe('192.168.1.200');
|
||||
});
|
||||
|
||||
it('auto-fills currentIp from detection', () => {
|
||||
const detection = createMockHardwareInfo({ ip: '192.168.1.75' });
|
||||
const result = getInitialValues(undefined, detection, [], 'test-');
|
||||
expect(result.currentIp).toBe('192.168.1.75');
|
||||
});
|
||||
|
||||
it('preserves initial currentIp over detection', () => {
|
||||
const detection = createMockHardwareInfo({ ip: '192.168.1.75' });
|
||||
const result = getInitialValues({ currentIp: '192.168.1.80' }, detection, [], 'test-');
|
||||
expect(result.currentIp).toBe('192.168.1.80');
|
||||
});
|
||||
|
||||
it('returns empty currentIp when no detection', () => {
|
||||
const result = getInitialValues(undefined, undefined, [], 'test-');
|
||||
expect(result.currentIp).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SchematicId Handling', () => {
|
||||
it('uses initial schematicId when provided', () => {
|
||||
const result = getInitialValues({ schematicId: 'custom-123' }, undefined, [], 'test-');
|
||||
expect(result.schematicId).toBe('custom-123');
|
||||
});
|
||||
|
||||
it('returns empty string when no initial schematicId', () => {
|
||||
const result = getInitialValues(undefined, undefined, [], 'test-');
|
||||
expect(result.schematicId).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Maintenance Mode', () => {
|
||||
it('defaults to true when not provided', () => {
|
||||
const result = getInitialValues(undefined, undefined, [], 'test-');
|
||||
expect(result.maintenance).toBe(true);
|
||||
});
|
||||
|
||||
it('respects explicit true value', () => {
|
||||
const result = getInitialValues({ maintenance: true }, undefined, [], 'test-');
|
||||
expect(result.maintenance).toBe(true);
|
||||
});
|
||||
|
||||
it('respects explicit false value', () => {
|
||||
const result = getInitialValues({ maintenance: false }, undefined, [], 'test-');
|
||||
expect(result.maintenance).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Combined Scenarios', () => {
|
||||
it('handles adding first control node with full detection', () => {
|
||||
const detection = createMockHardwareInfo();
|
||||
const result = getInitialValues(undefined, detection, [], 'prod-');
|
||||
|
||||
expect(result).toEqual({
|
||||
hostname: 'prod-control-1',
|
||||
role: 'controlplane',
|
||||
disk: '/dev/sda',
|
||||
targetIp: '',
|
||||
currentIp: '192.168.1.50',
|
||||
interface: 'eth0',
|
||||
schematicId: '',
|
||||
maintenance: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles configuring existing node (all initial values)', () => {
|
||||
const initial: Partial<NodeFormData> = {
|
||||
hostname: 'existing-control-1',
|
||||
role: 'controlplane',
|
||||
disk: '/dev/nvme0n1',
|
||||
targetIp: '192.168.1.105',
|
||||
currentIp: '192.168.1.60',
|
||||
interface: 'eth1',
|
||||
schematicId: 'existing-schematic-456',
|
||||
maintenance: false,
|
||||
};
|
||||
|
||||
const result = getInitialValues(initial, undefined, [], 'test-');
|
||||
|
||||
expect(result).toEqual(initial);
|
||||
});
|
||||
|
||||
it('handles adding second control node with partial detection', () => {
|
||||
const nodes = [createMockNode({ hostname: 'test-control-1', role: 'controlplane' })];
|
||||
const detection = createMockHardwareInfo({
|
||||
interfaces: ['enp0s1'],
|
||||
interface: 'enp0s1'
|
||||
});
|
||||
|
||||
const result = getInitialValues(undefined, detection, nodes, 'test-');
|
||||
|
||||
expect(result.hostname).toBe('test-control-2');
|
||||
expect(result.role).toBe('controlplane');
|
||||
expect(result.interface).toBe('enp0s1');
|
||||
});
|
||||
|
||||
it('handles missing detection data', () => {
|
||||
const result = getInitialValues(undefined, undefined, [], 'test-');
|
||||
|
||||
expect(result).toEqual({
|
||||
hostname: 'test-control-1',
|
||||
role: 'controlplane',
|
||||
disk: '',
|
||||
targetIp: '',
|
||||
currentIp: '',
|
||||
interface: '',
|
||||
schematicId: '',
|
||||
maintenance: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles partial detection data', () => {
|
||||
const detection: HardwareInfo = {
|
||||
ip: '192.168.1.50',
|
||||
};
|
||||
|
||||
const result = getInitialValues(undefined, detection, [], 'test-');
|
||||
|
||||
expect(result.currentIp).toBe('192.168.1.50');
|
||||
expect(result.disk).toBe('');
|
||||
expect(result.interface).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
67
src/components/nodes/NodeFormDrawer.tsx
Normal file
67
src/components/nodes/NodeFormDrawer.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Drawer } from '../ui/drawer';
|
||||
import { HardwareDetectionDisplay } from './HardwareDetectionDisplay';
|
||||
import { NodeForm, type NodeFormData } from './NodeForm';
|
||||
import { NodeStatusBadge } from './NodeStatusBadge';
|
||||
import type { Node, HardwareInfo } from '../../services/api/types';
|
||||
|
||||
interface NodeFormDrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
mode: 'add' | 'configure';
|
||||
node?: Node;
|
||||
detection?: HardwareInfo;
|
||||
onSubmit: (data: NodeFormData) => Promise<void>;
|
||||
onApply?: (data: NodeFormData) => Promise<void>;
|
||||
instanceName?: string;
|
||||
}
|
||||
|
||||
export function NodeFormDrawer({
|
||||
open,
|
||||
onClose,
|
||||
mode,
|
||||
node,
|
||||
detection,
|
||||
onSubmit,
|
||||
onApply,
|
||||
instanceName,
|
||||
}: NodeFormDrawerProps) {
|
||||
const title = mode === 'add' ? 'Add Node to Cluster' : `Configure ${node?.hostname}`;
|
||||
|
||||
return (
|
||||
<Drawer open={open} onClose={onClose} title={title}>
|
||||
{detection && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Hardware Detection Results
|
||||
</h3>
|
||||
<HardwareDetectionDisplay detection={detection} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'configure' && node && (
|
||||
<div className="mb-6">
|
||||
<NodeStatusBadge node={node} showAction />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<NodeForm
|
||||
initialValues={node ? {
|
||||
hostname: node.hostname,
|
||||
role: node.role,
|
||||
disk: node.disk,
|
||||
targetIp: node.target_ip,
|
||||
interface: node.interface,
|
||||
schematicId: node.schematic_id,
|
||||
maintenance: node.maintenance ?? true,
|
||||
} : undefined}
|
||||
detection={detection}
|
||||
onSubmit={onSubmit}
|
||||
onApply={onApply}
|
||||
onCancel={onClose}
|
||||
submitLabel={mode === 'add' ? 'Add Node' : 'Save'}
|
||||
showApplyButton={mode === 'configure'}
|
||||
instanceName={instanceName}
|
||||
/>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
66
src/components/nodes/NodeStatusBadge.tsx
Normal file
66
src/components/nodes/NodeStatusBadge.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
ClockIcon,
|
||||
ArrowPathIcon,
|
||||
DocumentCheckIcon,
|
||||
CheckCircleIcon,
|
||||
HeartIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
ExclamationTriangleIcon,
|
||||
XCircleIcon,
|
||||
QuestionMarkCircleIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import type { Node } from '../../services/api/types';
|
||||
import { deriveNodeStatus } from '../../utils/deriveNodeStatus';
|
||||
import { statusDesigns } from '../../config/nodeStatus';
|
||||
|
||||
interface NodeStatusBadgeProps {
|
||||
node: Node;
|
||||
showAction?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const iconComponents = {
|
||||
MagnifyingGlassIcon,
|
||||
ClockIcon,
|
||||
ArrowPathIcon,
|
||||
DocumentCheckIcon,
|
||||
CheckCircleIcon,
|
||||
HeartIcon,
|
||||
WrenchScrewdriverIcon,
|
||||
ExclamationTriangleIcon,
|
||||
XCircleIcon,
|
||||
QuestionMarkCircleIcon
|
||||
};
|
||||
|
||||
export function NodeStatusBadge({ node, showAction = false, compact = false }: NodeStatusBadgeProps) {
|
||||
const status = deriveNodeStatus(node);
|
||||
const design = statusDesigns[status];
|
||||
const IconComponent = iconComponents[design.icon as keyof typeof iconComponents];
|
||||
|
||||
const isSpinning = ['configuring', 'applying', 'provisioning', 'reprovisioning'].includes(status);
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium ${design.color} ${design.bgColor}`}>
|
||||
<IconComponent className={`h-3.5 w-3.5 ${isSpinning ? 'animate-spin' : ''}`} />
|
||||
<span>{design.label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`inline-flex flex-col gap-1 px-3 py-2 rounded-lg ${design.color} ${design.bgColor}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<IconComponent className={`h-5 w-5 ${isSpinning ? 'animate-spin' : ''}`} />
|
||||
<span className="text-sm font-semibold">{design.label}</span>
|
||||
</div>
|
||||
<p className="text-xs opacity-90">{design.description}</p>
|
||||
{showAction && design.nextAction && (
|
||||
<p className="text-xs font-medium mt-1">
|
||||
→ {design.nextAction}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
206
src/components/operations/OperationProgress.tsx
Normal file
206
src/components/operations/OperationProgress.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
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 {
|
||||
instanceName: string;
|
||||
operationId: string;
|
||||
onComplete?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
showDetails?: boolean;
|
||||
}
|
||||
|
||||
export function OperationProgress({
|
||||
instanceName,
|
||||
operationId,
|
||||
onComplete,
|
||||
onError,
|
||||
showDetails = true
|
||||
}: OperationProgressProps) {
|
||||
const { operation, error, isLoading, cancel, isCancelling } = useOperation(instanceName, 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';
|
||||
215
src/components/services/ServiceConfigEditor.tsx
Normal file
215
src/components/services/ServiceConfigEditor.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Loader2, Save } from 'lucide-react';
|
||||
import { useServiceConfig, useServiceStatus } from '@/hooks/useServices';
|
||||
import type { ServiceManifest } from '@/services/api/types';
|
||||
|
||||
interface ServiceConfigEditorProps {
|
||||
instanceName: string;
|
||||
serviceName: string;
|
||||
manifest?: ServiceManifest;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function ServiceConfigEditor({
|
||||
instanceName,
|
||||
serviceName,
|
||||
manifest: _manifest, // Ignore the prop, fetch from status instead
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: ServiceConfigEditorProps) {
|
||||
// Suppress unused variable warning - kept for API compatibility
|
||||
void _manifest;
|
||||
const { config, isLoading: configLoading, updateConfig, isUpdating } = useServiceConfig(instanceName, serviceName);
|
||||
const { data: statusData, isLoading: statusLoading } = useServiceStatus(instanceName, serviceName);
|
||||
|
||||
// Use manifest from status endpoint which includes full serviceConfig
|
||||
const manifest = statusData?.manifest;
|
||||
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
||||
const [redeploy, setRedeploy] = useState(true);
|
||||
const [fetch, setFetch] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
// Initialize form data when config loads
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
setFormData(config);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
try {
|
||||
await updateConfig({ config: formData, redeploy, fetch });
|
||||
setSuccess(true);
|
||||
if (onSuccess) {
|
||||
setTimeout(() => {
|
||||
onSuccess();
|
||||
}, 1500);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update configuration');
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (key: string, value: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const getDisplayValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value, null, 4);
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const isObjectValue = (value: unknown): boolean => {
|
||||
return value !== null && value !== undefined && typeof value === 'object';
|
||||
};
|
||||
|
||||
const isLoading = configLoading || statusLoading;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Get configurable keys from serviceConfig definitions
|
||||
const configKeys = manifest?.serviceConfig
|
||||
? Object.keys(manifest.serviceConfig).map(key => manifest.serviceConfig![key].path)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Edit Service Configuration</h2>
|
||||
<p className="text-sm text-muted-foreground">{serviceName}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-2">
|
||||
{configKeys.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No configuration options available for this service.
|
||||
</p>
|
||||
) : (
|
||||
configKeys.map((key) => {
|
||||
const value = formData[key];
|
||||
const isObject = isObjectValue(value);
|
||||
|
||||
// Find the config definition for this path
|
||||
const configDef = manifest?.serviceConfig
|
||||
? Object.values(manifest.serviceConfig).find(def => def.path === key)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div key={key} className="space-y-2">
|
||||
<Label htmlFor={key}>
|
||||
{configDef?.prompt || key}
|
||||
{configDef?.default && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
(default: {configDef.default})
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
{isObject ? (
|
||||
<Textarea
|
||||
id={key}
|
||||
value={getDisplayValue(value)}
|
||||
onChange={(e) => handleInputChange(key, e.target.value)}
|
||||
placeholder={configDef?.default || ''}
|
||||
rows={5}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={key}
|
||||
value={getDisplayValue(value)}
|
||||
onChange={(e) => handleInputChange(key, e.target.value)}
|
||||
placeholder={configDef?.default || ''}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
<div className="space-y-2 pt-4 border-t">
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="redeploy-checkbox"
|
||||
checked={redeploy}
|
||||
onChange={(e) => setRedeploy(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<Label htmlFor="redeploy-checkbox" className="cursor-pointer">
|
||||
Redeploy service after updating configuration
|
||||
</Label>
|
||||
</div>
|
||||
{redeploy && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="fetch-checkbox"
|
||||
checked={fetch}
|
||||
onChange={(e) => setFetch(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<Label htmlFor="fetch-checkbox" className="cursor-pointer">
|
||||
Fetch fresh templates from directory before redeploying
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="rounded-md bg-green-500/10 p-3 text-sm text-green-600 dark:text-green-400">
|
||||
Configuration updated successfully!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<Button type="button" variant="outline" onClick={onClose} disabled={isUpdating}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isUpdating}>
|
||||
{isUpdating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Save Changes
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
239
src/components/services/ServiceDetailModal.tsx
Normal file
239
src/components/services/ServiceDetailModal.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ServiceStatusBadge } from './ServiceStatusBadge';
|
||||
import { ServiceLogViewer } from './ServiceLogViewer';
|
||||
import { useServiceStatus } from '@/hooks/useServices';
|
||||
import { RefreshCw, FileText, Eye } from 'lucide-react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
|
||||
interface ServiceDetailModalProps {
|
||||
instanceName: string;
|
||||
serviceName: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ServiceDetailModal({
|
||||
instanceName,
|
||||
serviceName,
|
||||
open,
|
||||
onClose,
|
||||
}: ServiceDetailModalProps) {
|
||||
const { data: status, isLoading, refetch } = useServiceStatus(instanceName, serviceName);
|
||||
const [viewMode, setViewMode] = useState<'details' | 'logs'>('details');
|
||||
|
||||
const getPodStatusColor = (status: string) => {
|
||||
if (status.toLowerCase().includes('running')) return 'text-green-600 dark:text-green-400';
|
||||
if (status.toLowerCase().includes('pending')) return 'text-yellow-600 dark:text-yellow-400';
|
||||
if (status.toLowerCase().includes('failed')) return 'text-red-600 dark:text-red-400';
|
||||
return 'text-muted-foreground';
|
||||
};
|
||||
|
||||
const getContainerNames = () => {
|
||||
// Return empty array - let the backend auto-detect the container
|
||||
// This avoids passing invalid container names like 'main' which don't exist
|
||||
return [];
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-3">
|
||||
{serviceName}
|
||||
{status && <ServiceStatusBadge status={status.deploymentStatus} />}
|
||||
</DialogTitle>
|
||||
<DialogDescription>Service details and configuration</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* View Mode Selector */}
|
||||
<div className="flex gap-2 border-b pb-4">
|
||||
<Button
|
||||
variant={viewMode === 'details' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('details')}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Details
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'logs' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('logs')}
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
Logs
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Details View */}
|
||||
{viewMode === 'details' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
) : status ? (
|
||||
<>
|
||||
{/* Status Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Status Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Service Name</p>
|
||||
<p className="text-sm">{status.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Namespace</p>
|
||||
<p className="text-sm">{status.namespace}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status.replicas && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-2">Replicas</p>
|
||||
<div className="grid grid-cols-4 gap-2 text-sm">
|
||||
<div className="bg-muted rounded p-2">
|
||||
<p className="text-xs text-muted-foreground">Desired</p>
|
||||
<p className="font-semibold">{status.replicas.desired}</p>
|
||||
</div>
|
||||
<div className="bg-muted rounded p-2">
|
||||
<p className="text-xs text-muted-foreground">Current</p>
|
||||
<p className="font-semibold">{status.replicas.current}</p>
|
||||
</div>
|
||||
<div className="bg-muted rounded p-2">
|
||||
<p className="text-xs text-muted-foreground">Ready</p>
|
||||
<p className="font-semibold">{status.replicas.ready}</p>
|
||||
</div>
|
||||
<div className="bg-muted rounded p-2">
|
||||
<p className="text-xs text-muted-foreground">Available</p>
|
||||
<p className="font-semibold">{status.replicas.available}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pods Section */}
|
||||
{status.pods && status.pods.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Pods</CardTitle>
|
||||
<CardDescription>{status.pods.length} pod(s)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{status.pods.map((pod) => (
|
||||
<div
|
||||
key={pod.name}
|
||||
className="border rounded-lg p-3 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{pod.name}</p>
|
||||
{pod.node && (
|
||||
<p className="text-xs text-muted-foreground">Node: {pod.node}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 ml-2">
|
||||
<Badge variant="outline" className={getPodStatusColor(pod.status)}>
|
||||
{pod.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Ready:</span>{' '}
|
||||
<span className="font-medium">{pod.ready}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Restarts:</span>{' '}
|
||||
<span className="font-medium">{pod.restarts}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Age:</span>{' '}
|
||||
<span className="font-medium">{pod.age}</span>
|
||||
</div>
|
||||
</div>
|
||||
{pod.ip && (
|
||||
<div className="text-xs mt-1">
|
||||
<span className="text-muted-foreground">IP:</span>{' '}
|
||||
<span className="font-mono">{pod.ip}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Configuration Preview */}
|
||||
{status.config && Object.keys(status.config).length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Current Configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(status.config).map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between text-sm">
|
||||
<span className="font-medium text-muted-foreground">{key}:</span>
|
||||
<span className="font-mono text-xs">
|
||||
{typeof value === 'object' && value !== null
|
||||
? JSON.stringify(value, null, 2)
|
||||
: String(value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground py-8">No status information available</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logs View */}
|
||||
{viewMode === 'logs' && (
|
||||
<ServiceLogViewer
|
||||
instanceName={instanceName}
|
||||
serviceName={serviceName}
|
||||
containers={getContainerNames()}
|
||||
onClose={() => setViewMode('details')}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
236
src/components/services/ServiceLogViewer.tsx
Normal file
236
src/components/services/ServiceLogViewer.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { servicesApi } from '@/services/api';
|
||||
import { Copy, Download, RefreshCw, X } from 'lucide-react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
interface ServiceLogViewerProps {
|
||||
instanceName: string;
|
||||
serviceName: string;
|
||||
containers?: string[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ServiceLogViewer({
|
||||
instanceName,
|
||||
serviceName,
|
||||
containers = [],
|
||||
onClose,
|
||||
}: ServiceLogViewerProps) {
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [follow, setFollow] = useState(false);
|
||||
const [tail, setTail] = useState(100);
|
||||
const [container, setContainer] = useState<string | undefined>(containers[0]);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||
const logsContainerRef = useRef<HTMLDivElement>(null);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
|
||||
// Scroll to bottom when logs change and autoScroll is enabled
|
||||
useEffect(() => {
|
||||
if (autoScroll && logsEndRef.current) {
|
||||
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [logs, autoScroll]);
|
||||
|
||||
// Fetch initial buffered logs
|
||||
const fetchLogs = useCallback(async () => {
|
||||
try {
|
||||
const url = servicesApi.getLogsUrl(instanceName, serviceName, tail, false, container);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch logs: ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
// API returns { lines: string[] }
|
||||
if (data.lines && Array.isArray(data.lines)) {
|
||||
setLogs(data.lines);
|
||||
} else {
|
||||
setLogs([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching logs:', error);
|
||||
setLogs([`Error: ${error instanceof Error ? error.message : 'Failed to fetch logs'}`]);
|
||||
}
|
||||
}, [instanceName, serviceName, tail, container]);
|
||||
|
||||
// Set up SSE streaming when follow is enabled
|
||||
useEffect(() => {
|
||||
if (follow) {
|
||||
const url = servicesApi.getLogsUrl(instanceName, serviceName, tail, true, container);
|
||||
const eventSource = new EventSource(url);
|
||||
eventSourceRef.current = eventSource;
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
const line = event.data;
|
||||
if (line && line.trim() !== '') {
|
||||
setLogs((prev) => [...prev, line]);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE error:', error);
|
||||
eventSource.close();
|
||||
setFollow(false);
|
||||
};
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
};
|
||||
} else {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [follow, instanceName, serviceName, tail, container]);
|
||||
|
||||
// Fetch initial logs on mount and when parameters change
|
||||
useEffect(() => {
|
||||
if (!follow) {
|
||||
fetchLogs();
|
||||
}
|
||||
}, [fetchLogs, follow]);
|
||||
|
||||
const handleCopyLogs = () => {
|
||||
const text = logs.join('\n');
|
||||
navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
const handleDownloadLogs = () => {
|
||||
const text = logs.join('\n');
|
||||
const blob = new Blob([text], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${serviceName}-logs.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleClearLogs = () => {
|
||||
setLogs([]);
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
setLogs([]);
|
||||
fetchLogs();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col h-full max-h-[80vh]">
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Service Logs: {serviceName}</CardTitle>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 mt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="tail-select">Lines:</Label>
|
||||
<Select value={tail.toString()} onValueChange={(v) => setTail(Number(v))}>
|
||||
<SelectTrigger id="tail-select" className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
<SelectItem value="200">200</SelectItem>
|
||||
<SelectItem value="500">500</SelectItem>
|
||||
<SelectItem value="1000">1000</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{containers.length > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="container-select">Container:</Label>
|
||||
<Select value={container} onValueChange={setContainer}>
|
||||
<SelectTrigger id="container-select" className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{containers.map((c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
{c}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="follow-checkbox"
|
||||
checked={follow}
|
||||
onChange={(e) => setFollow(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<Label htmlFor="follow-checkbox">Follow</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="autoscroll-checkbox"
|
||||
checked={autoScroll}
|
||||
onChange={(e) => setAutoScroll(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<Label htmlFor="autoscroll-checkbox">Auto-scroll</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={follow}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleCopyLogs}>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleDownloadLogs}>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleClearLogs}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 overflow-hidden p-0">
|
||||
<div
|
||||
ref={logsContainerRef}
|
||||
className="h-full overflow-y-auto bg-slate-950 dark:bg-slate-900 p-4 font-mono text-xs text-green-400"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-slate-500">No logs available</div>
|
||||
) : (
|
||||
logs.map((line, index) => (
|
||||
<div key={index} className="whitespace-pre-wrap break-all">
|
||||
{line}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
42
src/components/services/ServiceStatusBadge.tsx
Normal file
42
src/components/services/ServiceStatusBadge.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { CheckCircle, AlertCircle, Loader2, XCircle } from 'lucide-react';
|
||||
|
||||
interface ServiceStatusBadgeProps {
|
||||
status: 'Ready' | 'Progressing' | 'Degraded' | 'NotFound';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ServiceStatusBadge({ status, className }: ServiceStatusBadgeProps) {
|
||||
const statusConfig = {
|
||||
Ready: {
|
||||
variant: 'success' as const,
|
||||
icon: CheckCircle,
|
||||
label: 'Ready',
|
||||
},
|
||||
Progressing: {
|
||||
variant: 'warning' as const,
|
||||
icon: Loader2,
|
||||
label: 'Progressing',
|
||||
},
|
||||
Degraded: {
|
||||
variant: 'destructive' as const,
|
||||
icon: AlertCircle,
|
||||
label: 'Degraded',
|
||||
},
|
||||
NotFound: {
|
||||
variant: 'secondary' as const,
|
||||
icon: XCircle,
|
||||
label: 'Not Found',
|
||||
},
|
||||
};
|
||||
|
||||
const config = statusConfig[status];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<Badge variant={config.variant} className={className}>
|
||||
<Icon className={status === 'Progressing' ? 'animate-spin' : ''} />
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
4
src/components/services/index.ts
Normal file
4
src/components/services/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { ServiceStatusBadge } from './ServiceStatusBadge';
|
||||
export { ServiceLogViewer } from './ServiceLogViewer';
|
||||
export { ServiceConfigEditor } from './ServiceConfigEditor';
|
||||
export { ServiceDetailModal } from './ServiceDetailModal';
|
||||
76
src/components/ui/alert.tsx
Normal file
76
src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
const alertVariants = cva(
|
||||
'relative w-full rounded-lg border p-4 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg+div]:translate-y-[-3px] [&:has(svg)]:pl-11',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-background text-foreground border-border',
|
||||
success: 'bg-green-50 text-green-900 border-green-200 dark:bg-green-950/20 dark:text-green-100 dark:border-green-800',
|
||||
error: 'bg-red-50 text-red-900 border-red-200 dark:bg-red-950/20 dark:text-red-100 dark:border-red-800',
|
||||
warning: 'bg-yellow-50 text-yellow-900 border-yellow-200 dark:bg-yellow-950/20 dark:text-yellow-100 dark:border-yellow-800',
|
||||
info: 'bg-blue-50 text-blue-900 border-blue-200 dark:bg-blue-950/20 dark:text-blue-100 dark:border-blue-800',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface AlertProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof alertVariants> {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
|
||||
({ className, variant, onClose, children, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={alertVariants({ variant, className })}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
Alert.displayName = 'Alert';
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={`mb-1 font-medium leading-none tracking-tight ${className || ''}`}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertTitle.displayName = 'AlertTitle';
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`text-sm [&_p]:leading-relaxed ${className || ''}`}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDescription.displayName = 'AlertDescription';
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
@@ -17,6 +17,10 @@ const badgeVariants = cva(
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
success:
|
||||
"border-transparent bg-green-500 text-white [a&]:hover:bg-green-600 dark:bg-green-600 dark:[a&]:hover:bg-green-700",
|
||||
warning:
|
||||
"border-transparent bg-yellow-500 text-white [a&]:hover:bg-yellow-600 dark:bg-yellow-600 dark:[a&]:hover:bg-yellow-700",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
30
src/components/ui/checkbox.tsx
Normal file
30
src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
95
src/components/ui/drawer.tsx
Normal file
95
src/components/ui/drawer.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
|
||||
interface DrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
export function Drawer({ open, onClose, title, children, footer }: DrawerProps) {
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && open) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [open, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Overlay with fade transition */}
|
||||
<div
|
||||
className={`
|
||||
fixed inset-0 z-50
|
||||
bg-black/50 backdrop-blur-sm
|
||||
transition-opacity duration-300 ease-in-out
|
||||
${open ? 'opacity-100' : 'opacity-0 pointer-events-none'}
|
||||
`}
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Drawer panel with slide transition */}
|
||||
<div
|
||||
className={`
|
||||
fixed inset-y-0 right-0 z-50
|
||||
w-full max-w-md
|
||||
transform transition-transform duration-300 ease-in-out
|
||||
${open ? 'translate-x-0' : 'translate-x-full'}
|
||||
`}
|
||||
>
|
||||
<div className="flex h-full flex-col bg-background shadow-xl">
|
||||
{/* Header */}
|
||||
<div className="border-b border-border px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Close drawer"
|
||||
>
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-6">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{footer && (
|
||||
<div className="border-t border-border px-6 py-4">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export { Button, buttonVariants } from './button';
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from './card';
|
||||
export { Badge, badgeVariants } from './badge';
|
||||
export { Alert, AlertTitle, AlertDescription } from './alert';
|
||||
export { Input } from './input';
|
||||
export { Label } from './label';
|
||||
export { Textarea } from './textarea';
|
||||
|
||||
@@ -8,8 +8,8 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground bg-transparent dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:bg-transparent dark:focus-visible:bg-input/30",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
|
||||
158
src/components/ui/select.tsx
Normal file
158
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
29
src/components/ui/switch.tsx
Normal file
29
src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
64
src/components/ui/tabs.tsx
Normal file
64
src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
161
src/config/nodeStatus.ts
Normal file
161
src/config/nodeStatus.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { NodeStatus, type StatusDesign } from '../types/nodeStatus';
|
||||
|
||||
export const statusDesigns: Record<NodeStatus, StatusDesign> = {
|
||||
[NodeStatus.DISCOVERED]: {
|
||||
status: NodeStatus.DISCOVERED,
|
||||
color: "text-purple-700",
|
||||
bgColor: "bg-purple-50",
|
||||
icon: "MagnifyingGlassIcon",
|
||||
label: "Discovered",
|
||||
description: "Node detected on network but not yet configured",
|
||||
nextAction: "Configure node settings",
|
||||
severity: "info"
|
||||
},
|
||||
|
||||
[NodeStatus.PENDING]: {
|
||||
status: NodeStatus.PENDING,
|
||||
color: "text-gray-700",
|
||||
bgColor: "bg-gray-50",
|
||||
icon: "ClockIcon",
|
||||
label: "Pending",
|
||||
description: "Node awaiting configuration",
|
||||
nextAction: "Configure and apply settings",
|
||||
severity: "neutral"
|
||||
},
|
||||
|
||||
[NodeStatus.CONFIGURING]: {
|
||||
status: NodeStatus.CONFIGURING,
|
||||
color: "text-blue-700",
|
||||
bgColor: "bg-blue-50",
|
||||
icon: "ArrowPathIcon",
|
||||
label: "Configuring",
|
||||
description: "Node configuration in progress",
|
||||
severity: "info"
|
||||
},
|
||||
|
||||
[NodeStatus.CONFIGURED]: {
|
||||
status: NodeStatus.CONFIGURED,
|
||||
color: "text-indigo-700",
|
||||
bgColor: "bg-indigo-50",
|
||||
icon: "DocumentCheckIcon",
|
||||
label: "Configured",
|
||||
description: "Node configured but not yet applied",
|
||||
nextAction: "Apply configuration to node",
|
||||
severity: "info"
|
||||
},
|
||||
|
||||
[NodeStatus.APPLYING]: {
|
||||
status: NodeStatus.APPLYING,
|
||||
color: "text-blue-700",
|
||||
bgColor: "bg-blue-50",
|
||||
icon: "ArrowPathIcon",
|
||||
label: "Applying",
|
||||
description: "Applying configuration to node",
|
||||
severity: "info"
|
||||
},
|
||||
|
||||
[NodeStatus.PROVISIONING]: {
|
||||
status: NodeStatus.PROVISIONING,
|
||||
color: "text-blue-700",
|
||||
bgColor: "bg-blue-50",
|
||||
icon: "ArrowPathIcon",
|
||||
label: "Provisioning",
|
||||
description: "Node is being provisioned with Talos",
|
||||
severity: "info"
|
||||
},
|
||||
|
||||
[NodeStatus.READY]: {
|
||||
status: NodeStatus.READY,
|
||||
color: "text-green-700",
|
||||
bgColor: "bg-green-50",
|
||||
icon: "CheckCircleIcon",
|
||||
label: "Ready",
|
||||
description: "Node is ready and operational",
|
||||
severity: "success"
|
||||
},
|
||||
|
||||
[NodeStatus.HEALTHY]: {
|
||||
status: NodeStatus.HEALTHY,
|
||||
color: "text-emerald-700",
|
||||
bgColor: "bg-emerald-50",
|
||||
icon: "HeartIcon",
|
||||
label: "Healthy",
|
||||
description: "Node is healthy and part of Kubernetes cluster",
|
||||
severity: "success"
|
||||
},
|
||||
|
||||
[NodeStatus.MAINTENANCE]: {
|
||||
status: NodeStatus.MAINTENANCE,
|
||||
color: "text-yellow-700",
|
||||
bgColor: "bg-yellow-50",
|
||||
icon: "WrenchScrewdriverIcon",
|
||||
label: "Maintenance",
|
||||
description: "Node is in maintenance mode",
|
||||
severity: "warning"
|
||||
},
|
||||
|
||||
[NodeStatus.REPROVISIONING]: {
|
||||
status: NodeStatus.REPROVISIONING,
|
||||
color: "text-orange-700",
|
||||
bgColor: "bg-orange-50",
|
||||
icon: "ArrowPathIcon",
|
||||
label: "Reprovisioning",
|
||||
description: "Node is being reprovisioned",
|
||||
severity: "warning"
|
||||
},
|
||||
|
||||
[NodeStatus.UNREACHABLE]: {
|
||||
status: NodeStatus.UNREACHABLE,
|
||||
color: "text-red-700",
|
||||
bgColor: "bg-red-50",
|
||||
icon: "ExclamationTriangleIcon",
|
||||
label: "Unreachable",
|
||||
description: "Node cannot be contacted",
|
||||
nextAction: "Check network connectivity",
|
||||
severity: "error"
|
||||
},
|
||||
|
||||
[NodeStatus.DEGRADED]: {
|
||||
status: NodeStatus.DEGRADED,
|
||||
color: "text-orange-700",
|
||||
bgColor: "bg-orange-50",
|
||||
icon: "ExclamationTriangleIcon",
|
||||
label: "Degraded",
|
||||
description: "Node is experiencing issues",
|
||||
nextAction: "Check node health",
|
||||
severity: "warning"
|
||||
},
|
||||
|
||||
[NodeStatus.FAILED]: {
|
||||
status: NodeStatus.FAILED,
|
||||
color: "text-red-700",
|
||||
bgColor: "bg-red-50",
|
||||
icon: "XCircleIcon",
|
||||
label: "Failed",
|
||||
description: "Node operation failed",
|
||||
nextAction: "Review logs and retry",
|
||||
severity: "error"
|
||||
},
|
||||
|
||||
[NodeStatus.UNKNOWN]: {
|
||||
status: NodeStatus.UNKNOWN,
|
||||
color: "text-gray-700",
|
||||
bgColor: "bg-gray-50",
|
||||
icon: "QuestionMarkCircleIcon",
|
||||
label: "Unknown",
|
||||
description: "Node status cannot be determined",
|
||||
nextAction: "Check node connection",
|
||||
severity: "neutral"
|
||||
},
|
||||
|
||||
[NodeStatus.ORPHANED]: {
|
||||
status: NodeStatus.ORPHANED,
|
||||
color: "text-purple-700",
|
||||
bgColor: "bg-purple-50",
|
||||
icon: "ExclamationTriangleIcon",
|
||||
label: "Orphaned",
|
||||
description: "Node exists in Kubernetes but not in configuration",
|
||||
nextAction: "Add to configuration or remove from cluster",
|
||||
severity: "warning"
|
||||
}
|
||||
};
|
||||
@@ -3,10 +3,10 @@ import { renderHook, waitFor, act } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
import { useConfig } from '../useConfig';
|
||||
import { apiService } from '../../services/api';
|
||||
import { apiService } from '../../services/api-legacy';
|
||||
|
||||
// Mock the API service
|
||||
vi.mock('../../services/api', () => ({
|
||||
vi.mock('../../services/api-legacy', () => ({
|
||||
apiService: {
|
||||
getConfig: vi.fn(),
|
||||
createConfig: vi.fn(),
|
||||
@@ -56,7 +56,7 @@ describe('useConfig', () => {
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(apiService.getConfig).mockResolvedValue(mockConfigResponse);
|
||||
(apiService.getConfig as ReturnType<typeof vi.fn>).mockResolvedValue(mockConfigResponse);
|
||||
|
||||
const { result } = renderHook(() => useConfig(), {
|
||||
wrapper: createWrapper(),
|
||||
@@ -81,7 +81,7 @@ describe('useConfig', () => {
|
||||
message: 'No configuration found',
|
||||
};
|
||||
|
||||
vi.mocked(apiService.getConfig).mockResolvedValue(mockConfigResponse);
|
||||
(apiService.getConfig as ReturnType<typeof vi.fn>).mockResolvedValue(mockConfigResponse);
|
||||
|
||||
const { result } = renderHook(() => useConfig(), {
|
||||
wrapper: createWrapper(),
|
||||
@@ -122,8 +122,8 @@ describe('useConfig', () => {
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(apiService.getConfig).mockResolvedValue(mockConfigResponse);
|
||||
vi.mocked(apiService.createConfig).mockResolvedValue(mockCreateResponse);
|
||||
(apiService.getConfig as ReturnType<typeof vi.fn>).mockResolvedValue(mockConfigResponse);
|
||||
(apiService.createConfig as ReturnType<typeof vi.fn>).mockResolvedValue(mockCreateResponse);
|
||||
|
||||
const { result } = renderHook(() => useConfig(), {
|
||||
wrapper: createWrapper(),
|
||||
@@ -149,7 +149,7 @@ describe('useConfig', () => {
|
||||
|
||||
it('should handle error when fetching config fails', async () => {
|
||||
const mockError = new Error('Network error');
|
||||
vi.mocked(apiService.getConfig).mockRejectedValue(mockError);
|
||||
(apiService.getConfig as ReturnType<typeof vi.fn>).mockRejectedValue(mockError);
|
||||
|
||||
const { result } = renderHook(() => useConfig(), {
|
||||
wrapper: createWrapper(),
|
||||
|
||||
@@ -3,10 +3,10 @@ import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import React from 'react';
|
||||
import { useStatus } from '../useStatus';
|
||||
import { apiService } from '../../services/api';
|
||||
import { apiService } from '../../services/api-legacy';
|
||||
|
||||
// Mock the API service
|
||||
vi.mock('../../services/api', () => ({
|
||||
vi.mock('../../services/api-legacy', () => ({
|
||||
apiService: {
|
||||
getStatus: vi.fn(),
|
||||
},
|
||||
@@ -40,7 +40,7 @@ describe('useStatus', () => {
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
vi.mocked(apiService.getStatus).mockResolvedValue(mockStatus);
|
||||
(apiService.getStatus as ReturnType<typeof vi.fn>).mockResolvedValue(mockStatus);
|
||||
|
||||
const { result } = renderHook(() => useStatus(), {
|
||||
wrapper: createWrapper(),
|
||||
@@ -60,7 +60,7 @@ describe('useStatus', () => {
|
||||
|
||||
it('should handle error when fetching status fails', async () => {
|
||||
const mockError = new Error('Network error');
|
||||
vi.mocked(apiService.getStatus).mockRejectedValue(mockError);
|
||||
(apiService.getStatus as ReturnType<typeof vi.fn>).mockRejectedValue(mockError);
|
||||
|
||||
const { result } = renderHook(() => useStatus(), {
|
||||
wrapper: createWrapper(),
|
||||
@@ -82,7 +82,7 @@ describe('useStatus', () => {
|
||||
timestamp: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
vi.mocked(apiService.getStatus).mockResolvedValue(mockStatus);
|
||||
(apiService.getStatus as ReturnType<typeof vi.fn>).mockResolvedValue(mockStatus);
|
||||
|
||||
const { result } = renderHook(() => useStatus(), {
|
||||
wrapper: createWrapper(),
|
||||
|
||||
@@ -4,4 +4,17 @@ export { useHealth } from './useHealth';
|
||||
export { useConfig } from './useConfig';
|
||||
export { useConfigYaml } from './useConfigYaml';
|
||||
export { useDnsmasq } from './useDnsmasq';
|
||||
export { useAssets } from './useAssets';
|
||||
export { useAssets } from './useAssets';
|
||||
|
||||
// New API hooks
|
||||
export { useInstanceContext, InstanceProvider } from './useInstanceContext';
|
||||
export { useInstances, useInstance, useInstanceConfig } from './useInstances';
|
||||
export { useNodes, useDiscoveryStatus, useNodeHardware } from './useNodes';
|
||||
export { useCluster } from './useCluster';
|
||||
export { useAvailableApps, useAvailableApp, useDeployedApps, useAppStatus, useAppBackups } from './useApps';
|
||||
export { useServices, useServiceStatus, useServiceManifest } from './useServices';
|
||||
export { useOperations, useOperation } from './useOperations';
|
||||
export { useSecrets, useUpdateSecrets } from './useSecrets';
|
||||
export { useKubeconfig, useTalosconfig, useRegenerateKubeconfig } from './useClusterAccess';
|
||||
export { useBaseServices, useServiceStatus as useBaseServiceStatus, useInstallService } from './useBaseServices';
|
||||
export { useCentralStatus } from './useCentralStatus';
|
||||
165
src/hooks/useApps.ts
Normal file
165
src/hooks/useApps.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// Enhanced hooks for app details and runtime status
|
||||
export function useAppEnhanced(instanceName: string | null | undefined, appName: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['instances', instanceName, 'apps', appName, 'enhanced'],
|
||||
queryFn: () => appsApi.getEnhanced(instanceName!, appName!),
|
||||
enabled: !!instanceName && !!appName,
|
||||
refetchInterval: 10000, // Poll every 10 seconds
|
||||
});
|
||||
}
|
||||
|
||||
export function useAppRuntime(instanceName: string | null | undefined, appName: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['instances', instanceName, 'apps', appName, 'runtime'],
|
||||
queryFn: () => appsApi.getRuntime(instanceName!, appName!),
|
||||
enabled: !!instanceName && !!appName,
|
||||
refetchInterval: 5000, // Poll every 5 seconds
|
||||
});
|
||||
}
|
||||
|
||||
export function useAppLogs(
|
||||
instanceName: string | null | undefined,
|
||||
appName: string | null | undefined,
|
||||
params?: { tail?: number; sinceSeconds?: number; pod?: string }
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ['instances', instanceName, 'apps', appName, 'logs', params],
|
||||
queryFn: () => appsApi.getLogs(instanceName!, appName!, params),
|
||||
enabled: !!instanceName && !!appName,
|
||||
refetchInterval: false, // Manual refresh only
|
||||
});
|
||||
}
|
||||
|
||||
export function useAppEvents(
|
||||
instanceName: string | null | undefined,
|
||||
appName: string | null | undefined,
|
||||
limit?: number
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ['instances', instanceName, 'apps', appName, 'events', limit],
|
||||
queryFn: () => appsApi.getEvents(instanceName!, appName!, limit),
|
||||
enabled: !!instanceName && !!appName,
|
||||
refetchInterval: 10000, // Poll every 10 seconds
|
||||
});
|
||||
}
|
||||
|
||||
export function useAppReadme(instanceName: string | null | undefined, appName: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['instances', instanceName, 'apps', appName, 'readme'],
|
||||
queryFn: () => appsApi.getReadme(instanceName!, appName!),
|
||||
enabled: !!instanceName && !!appName,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - READMEs don't change often
|
||||
retry: false, // Don't retry if README not found (404)
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { apiService } from '../services/api';
|
||||
import { apiService } from '../services/api-legacy';
|
||||
|
||||
interface AssetsResponse {
|
||||
status: string;
|
||||
|
||||
40
src/hooks/useBaseServices.ts
Normal file
40
src/hooks/useBaseServices.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { servicesApi } from '../services/api';
|
||||
import type { ServiceInstallRequest } from '../services/api/types';
|
||||
|
||||
export function useBaseServices(instanceName: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['instances', instanceName, 'services'],
|
||||
queryFn: () => servicesApi.list(instanceName!),
|
||||
enabled: !!instanceName,
|
||||
refetchInterval: 5000, // Poll every 5 seconds to get status updates
|
||||
});
|
||||
}
|
||||
|
||||
export function useServiceStatus(instanceName: string | null | undefined, serviceName: string) {
|
||||
return useQuery({
|
||||
queryKey: ['instances', instanceName, 'services', serviceName, 'status'],
|
||||
queryFn: () => servicesApi.getStatus(instanceName!, serviceName),
|
||||
enabled: !!instanceName && !!serviceName,
|
||||
refetchInterval: 5000, // Poll during deployment
|
||||
});
|
||||
}
|
||||
|
||||
export function useInstallService(instanceName: string | null | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (service: ServiceInstallRequest) =>
|
||||
servicesApi.install(instanceName!, service),
|
||||
onSuccess: () => {
|
||||
// Invalidate services list to get updated status
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['instances', instanceName, 'services'],
|
||||
});
|
||||
// Also invalidate operations to show new operation
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['instances', instanceName, 'operations'],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
31
src/hooks/useCentralStatus.ts
Normal file
31
src/hooks/useCentralStatus.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiClient } from '../services/api/client';
|
||||
|
||||
interface CentralStatus {
|
||||
status: string;
|
||||
version: string;
|
||||
uptime: string;
|
||||
uptimeSeconds: number;
|
||||
dataDir: string;
|
||||
appsDir: string;
|
||||
setupFiles: string;
|
||||
instances: {
|
||||
count: number;
|
||||
names: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch Wild Central server status
|
||||
* @returns Central server status information
|
||||
*/
|
||||
export function useCentralStatus() {
|
||||
return useQuery({
|
||||
queryKey: ['central', 'status'],
|
||||
queryFn: async (): Promise<CentralStatus> => {
|
||||
return apiClient.get('/api/v1/status');
|
||||
},
|
||||
// Poll every 5 seconds to keep uptime current
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
}
|
||||
83
src/hooks/useCluster.ts
Normal file
83
src/hooks/useCluster.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { clusterApi } from '../services/api';
|
||||
import type { ClusterConfig } from '../services/api';
|
||||
|
||||
export function useCluster(instanceName: string | null | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ['instances', instanceName, 'cluster', 'status'],
|
||||
queryFn: () => clusterApi.getStatus(instanceName!),
|
||||
enabled: !!instanceName,
|
||||
});
|
||||
|
||||
const healthQuery = useQuery({
|
||||
queryKey: ['instances', instanceName, 'cluster', 'health'],
|
||||
queryFn: () => clusterApi.getHealth(instanceName!),
|
||||
enabled: !!instanceName,
|
||||
});
|
||||
|
||||
const kubeconfigQuery = useQuery({
|
||||
queryKey: ['instances', instanceName, 'cluster', 'kubeconfig'],
|
||||
queryFn: () => clusterApi.getKubeconfig(instanceName!),
|
||||
enabled: !!instanceName,
|
||||
});
|
||||
|
||||
const talosconfigQuery = useQuery({
|
||||
queryKey: ['instances', instanceName, 'cluster', 'talosconfig'],
|
||||
queryFn: () => clusterApi.getTalosconfig(instanceName!),
|
||||
enabled: !!instanceName,
|
||||
});
|
||||
|
||||
const generateConfigMutation = useMutation({
|
||||
mutationFn: (config: ClusterConfig) => clusterApi.generateConfig(instanceName!, config),
|
||||
});
|
||||
|
||||
const bootstrapMutation = useMutation({
|
||||
mutationFn: (node: string) => clusterApi.bootstrap(instanceName!, node),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'cluster'] });
|
||||
},
|
||||
});
|
||||
|
||||
const configureEndpointsMutation = useMutation({
|
||||
mutationFn: (includeNodes: boolean) => clusterApi.configureEndpoints(instanceName!, includeNodes),
|
||||
});
|
||||
|
||||
const generateKubeconfigMutation = useMutation({
|
||||
mutationFn: () => clusterApi.generateKubeconfig(instanceName!),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'cluster', 'kubeconfig'] });
|
||||
},
|
||||
});
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: () => clusterApi.reset(instanceName!, true),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'cluster'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
status: statusQuery.data,
|
||||
isLoadingStatus: statusQuery.isLoading,
|
||||
health: healthQuery.data,
|
||||
isLoadingHealth: healthQuery.isLoading,
|
||||
kubeconfig: kubeconfigQuery.data?.kubeconfig,
|
||||
talosconfig: talosconfigQuery.data?.talosconfig,
|
||||
generateConfig: generateConfigMutation.mutate,
|
||||
isGeneratingConfig: generateConfigMutation.isPending,
|
||||
generateConfigResult: generateConfigMutation.data,
|
||||
bootstrap: bootstrapMutation.mutate,
|
||||
isBootstrapping: bootstrapMutation.isPending,
|
||||
bootstrapResult: bootstrapMutation.data,
|
||||
configureEndpoints: configureEndpointsMutation.mutate,
|
||||
isConfiguringEndpoints: configureEndpointsMutation.isPending,
|
||||
generateKubeconfig: generateKubeconfigMutation.mutate,
|
||||
isGeneratingKubeconfig: generateKubeconfigMutation.isPending,
|
||||
reset: resetMutation.mutate,
|
||||
isResetting: resetMutation.isPending,
|
||||
refetchStatus: statusQuery.refetch,
|
||||
refetchHealth: healthQuery.refetch,
|
||||
};
|
||||
}
|
||||
31
src/hooks/useClusterAccess.ts
Normal file
31
src/hooks/useClusterAccess.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { clusterApi } from '../services/api';
|
||||
|
||||
export function useKubeconfig(instanceName: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['instances', instanceName, 'kubeconfig'],
|
||||
queryFn: () => clusterApi.getKubeconfig(instanceName!),
|
||||
enabled: !!instanceName,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTalosconfig(instanceName: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['instances', instanceName, 'talosconfig'],
|
||||
queryFn: () => clusterApi.getTalosconfig(instanceName!),
|
||||
enabled: !!instanceName,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRegenerateKubeconfig(instanceName: string | null | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => clusterApi.generateKubeconfig(instanceName!),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['instances', instanceName, 'kubeconfig'],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiService } from '../services/api';
|
||||
import { apiService } from '../services/api-legacy';
|
||||
import type { Config } from '../types';
|
||||
|
||||
interface ConfigResponse {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiService } from '../services/api';
|
||||
import { apiService } from '../services/api-legacy';
|
||||
|
||||
export const useConfigYaml = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { apiService } from '../services/api';
|
||||
import { apiService } from '../services/api-legacy';
|
||||
|
||||
interface DnsmasqResponse {
|
||||
status: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { apiService } from '../services/api';
|
||||
import { apiService } from '../services/api-legacy';
|
||||
|
||||
interface HealthResponse {
|
||||
service: string;
|
||||
|
||||
37
src/hooks/useInstanceContext.tsx
Normal file
37
src/hooks/useInstanceContext.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useState, createContext, useContext, ReactNode } from 'react';
|
||||
|
||||
interface InstanceContextValue {
|
||||
currentInstance: string | null;
|
||||
setCurrentInstance: (name: string | null) => void;
|
||||
}
|
||||
|
||||
const InstanceContext = createContext<InstanceContextValue | undefined>(undefined);
|
||||
|
||||
export function InstanceProvider({ children }: { children: ReactNode }) {
|
||||
const [currentInstance, setCurrentInstanceState] = useState<string | null>(
|
||||
() => localStorage.getItem('currentInstance')
|
||||
);
|
||||
|
||||
const setCurrentInstance = (name: string | null) => {
|
||||
setCurrentInstanceState(name);
|
||||
if (name) {
|
||||
localStorage.setItem('currentInstance', name);
|
||||
} else {
|
||||
localStorage.removeItem('currentInstance');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<InstanceContext.Provider value={{ currentInstance, setCurrentInstance }}>
|
||||
{children}
|
||||
</InstanceContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useInstanceContext() {
|
||||
const context = useContext(InstanceContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useInstanceContext must be used within an InstanceProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
82
src/hooks/useInstances.ts
Normal file
82
src/hooks/useInstances.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { instancesApi } from '../services/api';
|
||||
import type { CreateInstanceRequest } from '../services/api';
|
||||
|
||||
export function useInstances() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ['instances'],
|
||||
queryFn: instancesApi.list,
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateInstanceRequest) => instancesApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['instances'] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (name: string) => instancesApi.delete(name),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['instances'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
instances: listQuery.data?.instances || [],
|
||||
isLoading: listQuery.isLoading,
|
||||
error: listQuery.error,
|
||||
refetch: listQuery.refetch,
|
||||
createInstance: createMutation.mutate,
|
||||
isCreating: createMutation.isPending,
|
||||
createError: createMutation.error,
|
||||
deleteInstance: deleteMutation.mutate,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
deleteError: deleteMutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useInstance(instanceName: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['instances', instanceName],
|
||||
queryFn: () => instancesApi.get(instanceName!),
|
||||
enabled: !!instanceName,
|
||||
});
|
||||
}
|
||||
|
||||
export function useInstanceConfig(instanceName: string | null | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const configQuery = useQuery({
|
||||
queryKey: ['instances', instanceName, 'config'],
|
||||
queryFn: () => instancesApi.getConfig(instanceName!),
|
||||
enabled: !!instanceName,
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (config: Record<string, unknown>) => instancesApi.updateConfig(instanceName!, config),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'config'] });
|
||||
},
|
||||
});
|
||||
|
||||
const batchUpdateMutation = useMutation({
|
||||
mutationFn: (updates: Array<{path: string; value: unknown}>) =>
|
||||
instancesApi.batchUpdateConfig(instanceName!, updates),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'config'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
config: configQuery.data,
|
||||
isLoading: configQuery.isLoading,
|
||||
error: configQuery.error,
|
||||
updateConfig: updateMutation.mutate,
|
||||
isUpdating: updateMutation.isPending,
|
||||
batchUpdate: batchUpdateMutation.mutate,
|
||||
isBatchUpdating: batchUpdateMutation.isPending,
|
||||
};
|
||||
}
|
||||
135
src/hooks/useNodes.ts
Normal file
135
src/hooks/useNodes.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
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),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'discovery'] });
|
||||
},
|
||||
});
|
||||
|
||||
const detectMutation = useMutation({
|
||||
mutationFn: (ip: string) => nodesApi.detect(instanceName!, ip),
|
||||
});
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (node: NodeAddRequest) => nodesApi.add(instanceName!, node),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'nodes'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
// Don't refetch on error to avoid showing inconsistent state
|
||||
console.error('Failed to add node:', error);
|
||||
},
|
||||
});
|
||||
|
||||
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'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
// Don't refetch on error to avoid showing inconsistent state
|
||||
console.error('Failed to delete node:', error);
|
||||
},
|
||||
});
|
||||
|
||||
const applyMutation = useMutation({
|
||||
mutationFn: (nodeName: string) => nodesApi.apply(instanceName!, nodeName),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'nodes'] });
|
||||
},
|
||||
});
|
||||
|
||||
const fetchTemplatesMutation = useMutation({
|
||||
mutationFn: () => nodesApi.fetchTemplates(instanceName!),
|
||||
});
|
||||
|
||||
const cancelDiscoveryMutation = useMutation({
|
||||
mutationFn: () => nodesApi.cancelDiscovery(instanceName!),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'discovery'] });
|
||||
},
|
||||
});
|
||||
|
||||
const getHardwareMutation = useMutation({
|
||||
mutationFn: (ip: string) => nodesApi.getHardware(instanceName!, ip),
|
||||
});
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: (nodeName: string) => nodesApi.reset(instanceName!, nodeName),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'nodes'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
nodes: nodesQuery.data?.nodes || [],
|
||||
isLoading: nodesQuery.isLoading,
|
||||
error: nodesQuery.error,
|
||||
refetch: nodesQuery.refetch,
|
||||
discover: discoverMutation.mutate,
|
||||
isDiscovering: discoverMutation.isPending,
|
||||
discoverResult: discoverMutation.data,
|
||||
discoverError: discoverMutation.error,
|
||||
detect: detectMutation.mutate,
|
||||
isDetecting: detectMutation.isPending,
|
||||
detectResult: detectMutation.data,
|
||||
detectError: detectMutation.error,
|
||||
getHardware: getHardwareMutation.mutateAsync,
|
||||
isGettingHardware: getHardwareMutation.isPending,
|
||||
getHardwareError: getHardwareMutation.error,
|
||||
addNode: addMutation.mutate,
|
||||
isAdding: addMutation.isPending,
|
||||
addError: addMutation.error,
|
||||
updateNode: updateMutation.mutate,
|
||||
isUpdating: updateMutation.isPending,
|
||||
deleteNode: deleteMutation.mutate,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
deleteError: deleteMutation.error,
|
||||
applyNode: applyMutation.mutate,
|
||||
isApplying: applyMutation.isPending,
|
||||
fetchTemplates: fetchTemplatesMutation.mutate,
|
||||
isFetchingTemplates: fetchTemplatesMutation.isPending,
|
||||
cancelDiscovery: cancelDiscoveryMutation.mutate,
|
||||
isCancellingDiscovery: cancelDiscoveryMutation.isPending,
|
||||
resetNode: resetMutation.mutate,
|
||||
isResetting: resetMutation.isPending,
|
||||
resetError: resetMutation.error,
|
||||
};
|
||||
}
|
||||
|
||||
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(instanceName: string | null | undefined, operationId: string | null | undefined) {
|
||||
const [operation, setOperation] = useState<Operation | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!instanceName || !operationId) return;
|
||||
|
||||
// Fetch initial state
|
||||
operationsApi.get(instanceName, operationId).then(setOperation).catch(setError);
|
||||
|
||||
// Set up SSE stream
|
||||
const eventSource = operationsApi.createStream(instanceName, 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();
|
||||
};
|
||||
}, [instanceName, operationId, queryClient]);
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!instanceName || !operationId) {
|
||||
throw new Error('Cannot cancel operation: instance name or operation ID not available');
|
||||
}
|
||||
return operationsApi.cancel(instanceName, operationId);
|
||||
},
|
||||
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'],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
111
src/hooks/useServices.ts
Normal file
111
src/hooks/useServices.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
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 useServiceConfig(instanceName: string | null | undefined, serviceName: string | null | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const configQuery = useQuery({
|
||||
queryKey: ['instances', instanceName, 'services', serviceName, 'config'],
|
||||
queryFn: () => servicesApi.getConfig(instanceName!, serviceName!),
|
||||
enabled: !!instanceName && !!serviceName,
|
||||
});
|
||||
|
||||
const updateConfigMutation = useMutation({
|
||||
mutationFn: (request: { config: Record<string, any>; redeploy?: boolean }) =>
|
||||
servicesApi.updateConfig(instanceName!, serviceName!, request),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'services', serviceName, 'config'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'services', serviceName, 'status'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['instances', instanceName, 'services'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
config: configQuery.data,
|
||||
isLoading: configQuery.isLoading,
|
||||
error: configQuery.error,
|
||||
updateConfig: updateConfigMutation.mutateAsync,
|
||||
isUpdating: updateConfigMutation.isPending,
|
||||
};
|
||||
}
|
||||
|
||||
export function useServiceManifest(serviceName: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['services', serviceName, 'manifest'],
|
||||
queryFn: () => servicesApi.getManifest(serviceName!),
|
||||
enabled: !!serviceName,
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { apiService } from '../services/api';
|
||||
import { apiService } from '../services/api-legacy';
|
||||
import type { Status } from '../types';
|
||||
|
||||
export const useStatus = () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { InstanceProvider } from './hooks';
|
||||
import { queryClient } from './lib/queryClient';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
|
||||
@@ -15,9 +16,11 @@ root.render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider defaultTheme="light" storageKey="wild-central-theme">
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
<InstanceProvider>
|
||||
<ThemeProvider defaultTheme="light" storageKey="wild-central-theme">
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</InstanceProvider>
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>
|
||||
|
||||
41
src/router/InstanceLayout.tsx
Normal file
41
src/router/InstanceLayout.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Outlet, useParams, Navigate } from 'react-router';
|
||||
import { useInstanceContext } from '../hooks/useInstanceContext';
|
||||
import { AppSidebar } from '../components/AppSidebar';
|
||||
import { SidebarProvider, SidebarInset, SidebarTrigger } from '../components/ui/sidebar';
|
||||
|
||||
export function InstanceLayout() {
|
||||
const { instanceId } = useParams<{ instanceId: string }>();
|
||||
const { setCurrentInstance } = useInstanceContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (instanceId) {
|
||||
setCurrentInstance(instanceId);
|
||||
}
|
||||
return () => {
|
||||
// Don't clear instance on unmount - let it persist
|
||||
// This allows the instance to stay selected when navigating
|
||||
};
|
||||
}, [instanceId, setCurrentInstance]);
|
||||
|
||||
if (!instanceId) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg font-semibold">Wild Cloud</h1>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4">
|
||||
<Outlet />
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
14
src/router/index.tsx
Normal file
14
src/router/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createBrowserRouter } from 'react-router';
|
||||
import { routes } from './routes';
|
||||
|
||||
export const router = createBrowserRouter(routes, {
|
||||
future: {
|
||||
v7_startTransition: true,
|
||||
v7_relativeSplatPath: true,
|
||||
},
|
||||
});
|
||||
|
||||
export { routes };
|
||||
export * from './InstanceLayout';
|
||||
export * from './pages/LandingPage';
|
||||
export * from './pages/NotFoundPage';
|
||||
10
src/router/pages/AdvancedPage.tsx
Normal file
10
src/router/pages/AdvancedPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ErrorBoundary } from '../../components';
|
||||
import { Advanced } from '../../components';
|
||||
|
||||
export function AdvancedPage() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Advanced />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
11
src/router/pages/AppsPage.tsx
Normal file
11
src/router/pages/AppsPage.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ErrorBoundary } from '../../components';
|
||||
import { AppsComponent } from '../../components/AppsComponent';
|
||||
|
||||
export function AppsPage() {
|
||||
// Note: onComplete callback removed as phase management will be handled differently with routing
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<AppsComponent />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
299
src/router/pages/AssetsIsoPage.tsx
Normal file
299
src/router/pages/AssetsIsoPage.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import {
|
||||
Download,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Disc,
|
||||
BookOpen,
|
||||
ExternalLink,
|
||||
CheckCircle,
|
||||
Usb,
|
||||
ArrowLeft,
|
||||
CloudLightning,
|
||||
} from 'lucide-react';
|
||||
import { useAssetList, useDownloadAsset, useAssetStatus } from '../../services/api/hooks/useAssets';
|
||||
import { assetsApi } from '../../services/api/assets';
|
||||
|
||||
export function AssetsIsoPage() {
|
||||
const { data, isLoading, error } = useAssetList();
|
||||
const downloadAsset = useDownloadAsset();
|
||||
const [selectedSchematicId] = useState<string | null>(null);
|
||||
const [selectedVersion, setSelectedVersion] = useState('v1.8.0');
|
||||
const { data: statusData } = useAssetStatus(selectedSchematicId);
|
||||
|
||||
// Select the first schematic by default if available
|
||||
const schematic = data?.schematics?.[0] || null;
|
||||
const schematicId = schematic?.schematic_id || null;
|
||||
|
||||
// Get the ISO asset
|
||||
const isoAsset = schematic?.assets.find((asset) => asset.type === 'iso');
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!schematicId) return;
|
||||
|
||||
try {
|
||||
await downloadAsset.mutateAsync({
|
||||
schematicId,
|
||||
request: { version: selectedVersion, assets: ['iso'] },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Download failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (downloaded: boolean, downloading?: boolean) => {
|
||||
if (downloading) {
|
||||
return (
|
||||
<Badge variant="warning" className="flex items-center gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Downloading
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (downloaded) {
|
||||
return (
|
||||
<Badge variant="success" className="flex items-center gap-1">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Available
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="secondary" className="flex items-center gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
Missing
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const getDownloadProgress = () => {
|
||||
if (!statusData?.progress?.iso) return null;
|
||||
|
||||
const progress = statusData.progress.iso;
|
||||
if (progress.status === 'downloading' && progress.bytes_downloaded && progress.total_bytes) {
|
||||
const percentage = (progress.bytes_downloaded / progress.total_bytes) * 100;
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<div className="flex justify-between text-sm text-muted-foreground mb-1">
|
||||
<span>Downloading...</span>
|
||||
<span>{percentage.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="border-b">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<CloudLightning className="h-6 w-6 text-primary" />
|
||||
<span className="text-lg font-bold">Wild Cloud</span>
|
||||
</Link>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<span className="text-sm font-medium">ISO Management</span>
|
||||
</div>
|
||||
<Link to="/">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Home
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<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"
|
||||
onClick={() => window.open('https://www.talos.dev/latest/talos-guides/install/bare-metal-platforms/digital-rebar/', '_blank')}
|
||||
>
|
||||
<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>
|
||||
{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>
|
||||
|
||||
{/* 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>
|
||||
) : !isoAsset ? (
|
||||
<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} 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>
|
||||
) : (
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-muted rounded-lg">
|
||||
<Disc className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h5 className="font-medium capitalize">Talos ISO</h5>
|
||||
{getStatusBadge(isoAsset.downloaded, statusData?.downloading)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
{schematic?.version && <div>Version: {schematic.version}</div>}
|
||||
{isoAsset.size && <div>Size: {(isoAsset.size / 1024 / 1024).toFixed(2)} MB</div>}
|
||||
{isoAsset.path && (
|
||||
<div className="font-mono text-xs truncate">{isoAsset.path}</div>
|
||||
)}
|
||||
</div>
|
||||
{getDownloadProgress()}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{!isoAsset.downloaded && !statusData?.downloading && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleDownload}
|
||||
disabled={downloadAsset.isPending}
|
||||
>
|
||||
{downloadAsset.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
Download
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{isoAsset.downloaded && schematicId && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
window.location.href = assetsApi.getAssetUrl(schematicId, 'iso');
|
||||
}}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
Download to Computer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</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>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
299
src/router/pages/AssetsPxePage.tsx
Normal file
299
src/router/pages/AssetsPxePage.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import {
|
||||
HardDrive,
|
||||
BookOpen,
|
||||
ExternalLink,
|
||||
Download,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
FileArchive,
|
||||
ArrowLeft,
|
||||
CloudLightning,
|
||||
} from 'lucide-react';
|
||||
import { useAssetList, useDownloadAsset, useAssetStatus } from '../../services/api/hooks/useAssets';
|
||||
import type { AssetType } from '../../services/api/types/asset';
|
||||
|
||||
export function AssetsPxePage() {
|
||||
const { data, isLoading, error } = useAssetList();
|
||||
const downloadAsset = useDownloadAsset();
|
||||
const [selectedVersion, setSelectedVersion] = useState('v1.8.0');
|
||||
|
||||
// Select the first schematic by default if available
|
||||
const schematic = data?.schematics?.[0] || null;
|
||||
const schematicId = schematic?.schematic_id || null;
|
||||
const { data: statusData } = useAssetStatus(schematicId);
|
||||
|
||||
// Get PXE assets (kernel and initramfs)
|
||||
const pxeAssets = schematic?.assets.filter((asset) => asset.type !== 'iso') || [];
|
||||
|
||||
const handleDownload = async (assetType: AssetType) => {
|
||||
if (!schematicId) return;
|
||||
|
||||
try {
|
||||
await downloadAsset.mutateAsync({
|
||||
schematicId,
|
||||
request: { version: selectedVersion, assets: [assetType] },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Download failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadAll = async () => {
|
||||
if (!schematicId) return;
|
||||
|
||||
try {
|
||||
await downloadAsset.mutateAsync({
|
||||
schematicId,
|
||||
request: { version: selectedVersion, assets: ['kernel', 'initramfs'] },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Download failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (downloaded: boolean, downloading?: boolean) => {
|
||||
if (downloading) {
|
||||
return (
|
||||
<Badge variant="default" className="flex items-center gap-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Downloading
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (downloaded) {
|
||||
return (
|
||||
<Badge variant="success" className="flex items-center gap-1">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Available
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="secondary" className="flex items-center gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
Missing
|
||||
</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" />;
|
||||
default:
|
||||
return <FileArchive className="h-5 w-5 text-muted-foreground" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getDownloadProgress = (assetType: AssetType) => {
|
||||
if (!statusData?.progress?.[assetType]) return null;
|
||||
|
||||
const progress = statusData.progress[assetType];
|
||||
if (progress?.status === 'downloading' && progress.bytes_downloaded && progress.total_bytes) {
|
||||
const percentage = (progress.bytes_downloaded / progress.total_bytes) * 100;
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<div className="flex justify-between text-sm text-muted-foreground mb-1">
|
||||
<span>Downloading...</span>
|
||||
<span>{percentage.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const isAssetDownloading = (assetType: AssetType) => {
|
||||
return statusData?.progress?.[assetType]?.status === 'downloading';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="border-b">
|
||||
<div className="container mx-auto px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<CloudLightning className="h-6 w-6 text-primary" />
|
||||
<span className="text-lg font-bold">Wild Cloud</span>
|
||||
</Link>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<span className="text-sm font-medium">PXE Management</span>
|
||||
</div>
|
||||
<Link to="/">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Home
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<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"
|
||||
onClick={() => window.open('https://www.talos.dev/latest/talos-guides/install/bare-metal-platforms/pxe/', '_blank')}
|
||||
>
|
||||
<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>
|
||||
{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">
|
||||
{pxeAssets.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.downloaded, isAssetDownloading(asset.type as AssetType))}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
{schematic?.version && <div>Version: {schematic.version}</div>}
|
||||
{asset.size && <div>Size: {(asset.size / 1024 / 1024).toFixed(2)} MB</div>}
|
||||
{asset.path && (
|
||||
<div className="font-mono text-xs truncate">{asset.path}</div>
|
||||
)}
|
||||
</div>
|
||||
{getDownloadProgress(asset.type as AssetType)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{!asset.downloaded && !isAssetDownloading(asset.type as AssetType) && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleDownload(asset.type as AssetType)}
|
||||
disabled={downloadAsset.isPending}
|
||||
>
|
||||
{downloadAsset.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
Download
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Download All Button */}
|
||||
{pxeAssets.length > 0 && pxeAssets.some((a) => !a.downloaded) && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleDownloadAll}
|
||||
disabled={downloadAsset.isPending || statusData?.downloading}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download All Missing Assets
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</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.status === 'ready' ? 'outline' : 'secondary'} className={status.status === 'ready' ? 'border-green-500' : ''}>
|
||||
{status.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>
|
||||
);
|
||||
}
|
||||
10
src/router/pages/ControlNodesPage.tsx
Normal file
10
src/router/pages/ControlNodesPage.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ErrorBoundary } from '../../components';
|
||||
import { ControlNodesComponent } from '../../components/ControlNodesComponent';
|
||||
|
||||
export function ControlNodesPage() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<ControlNodesComponent />
|
||||
</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.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>
|
||||
);
|
||||
}
|
||||
351
src/router/pages/IsoPage.tsx
Normal file
351
src/router/pages/IsoPage.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
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,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Disc,
|
||||
BookOpen,
|
||||
ExternalLink,
|
||||
CheckCircle,
|
||||
Usb,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { useAssetList, useDownloadAsset, useDeleteAsset } from '../../services/api/hooks/useAssets';
|
||||
import { assetsApi } from '../../services/api/assets';
|
||||
import type { Platform, Asset } from '../../services/api/types/asset';
|
||||
|
||||
// Helper function to extract platform from filename
|
||||
// Filename format: metal-amd64.iso
|
||||
function extractPlatformFromPath(path: string): string {
|
||||
const filename = path.split('/').pop() || '';
|
||||
const match = filename.match(/-(amd64|arm64)\./);
|
||||
return match ? match[1] : 'unknown';
|
||||
}
|
||||
|
||||
// Type for ISO asset with schematic and version info
|
||||
interface IsoAssetWithMetadata extends Asset {
|
||||
schematic_id: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export function IsoPage() {
|
||||
const { data, isLoading, error, refetch } = useAssetList();
|
||||
const downloadAsset = useDownloadAsset();
|
||||
const deleteAsset = useDeleteAsset();
|
||||
|
||||
const [schematicId, setSchematicId] = useState('434a0300db532066f1098e05ac068159371d00f0aba0a3103a0e826e83825c82');
|
||||
const [selectedVersion, setSelectedVersion] = useState('v1.11.5');
|
||||
const [selectedPlatform, setSelectedPlatform] = useState<Platform>('amd64');
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!schematicId) {
|
||||
alert('Please enter a schematic ID');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
await downloadAsset.mutateAsync({
|
||||
schematicId,
|
||||
version: selectedVersion,
|
||||
request: {
|
||||
platform: selectedPlatform,
|
||||
asset_types: ['iso']
|
||||
},
|
||||
});
|
||||
// Refresh the list after download
|
||||
await refetch();
|
||||
} catch (err) {
|
||||
console.error('Download failed:', err);
|
||||
alert(`Download failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (schematicIdToDelete: string, versionToDelete: string) => {
|
||||
if (!confirm(`Are you sure you want to delete ${schematicIdToDelete}@${versionToDelete} and all its assets? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteAsset.mutateAsync({ schematicId: schematicIdToDelete, version: versionToDelete });
|
||||
await refetch();
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err);
|
||||
alert(`Delete failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Find all ISO assets from all assets (schematic@version combinations)
|
||||
const isoAssets = data?.assets?.flatMap(asset => {
|
||||
// Get ALL ISO assets for this schematic@version
|
||||
const isoAssetsForAsset = asset.assets.filter(a => a.type === 'iso');
|
||||
return isoAssetsForAsset.map(isoAsset => ({
|
||||
...isoAsset,
|
||||
schematic_id: asset.schematic_id,
|
||||
version: asset.version
|
||||
}));
|
||||
}) || [];
|
||||
|
||||
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"
|
||||
onClick={() => window.open('https://www.balena.io/etcher/', '_blank')}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
Download Balena Etcher
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Download New ISO Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<Disc className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<CardTitle>Download Talos ISO</CardTitle>
|
||||
<CardDescription>
|
||||
Specify the schematic ID, version, and platform to download a Talos ISO image
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Platform Selection */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Platform</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="platform"
|
||||
value="amd64"
|
||||
checked={selectedPlatform === 'amd64'}
|
||||
onChange={(e) => setSelectedPlatform(e.target.value as Platform)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span>amd64 (Intel/AMD 64-bit)</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="platform"
|
||||
value="arm64"
|
||||
checked={selectedPlatform === 'arm64'}
|
||||
onChange={(e) => setSelectedPlatform(e.target.value as Platform)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span>arm64 (ARM 64-bit)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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.11.5">v1.11.5</option>
|
||||
<option value="v1.11.4">v1.11.4</option>
|
||||
<option value="v1.11.3">v1.11.3</option>
|
||||
<option value="v1.11.2">v1.11.2</option>
|
||||
<option value="v1.11.1">v1.11.1</option>
|
||||
<option value="v1.11.0">v1.11.0</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Schematic ID Input */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">
|
||||
Schematic ID
|
||||
<span className="text-muted-foreground ml-2">(64-character hex string)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={schematicId}
|
||||
onChange={(e) => setSchematicId(e.target.value)}
|
||||
placeholder="e.g., 434a0300db532066f1098e05ac068159371d00f0aba0a3103a0e826e83825c82"
|
||||
className="w-full px-3 py-2 border rounded-lg bg-background font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Get your schematic ID from the{' '}
|
||||
<a
|
||||
href="https://factory.talos.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Talos Image Factory
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Download Button */}
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading || !schematicId}
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
{isDownloading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Downloading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download ISO
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Downloaded ISOs Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Downloaded ISO Images</CardTitle>
|
||||
<CardDescription>Available ISO images on Wild Central</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</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 ISOs</h3>
|
||||
<p className="text-muted-foreground mb-4">{(error as Error).message}</p>
|
||||
<Button onClick={() => refetch()}>Retry</Button>
|
||||
</div>
|
||||
) : isoAssets.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Disc className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">No ISOs Downloaded</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Download a Talos ISO using the form above to get started.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{isoAssets.map((asset: IsoAssetWithMetadata) => {
|
||||
const platform = extractPlatformFromPath(asset.path || '');
|
||||
// Use composite key for React key
|
||||
const compositeKey = `${asset.schematic_id}@${asset.version}`;
|
||||
return (
|
||||
<Card key={compositeKey} className="p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-muted rounded-lg">
|
||||
<Disc className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h5 className="font-medium">Talos ISO</h5>
|
||||
<Badge variant="outline">{asset.version}</Badge>
|
||||
<Badge variant="outline" className="uppercase">{platform}</Badge>
|
||||
{asset.downloaded ? (
|
||||
<Badge variant="success" className="flex items-center gap-1">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Downloaded
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="flex items-center gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
Missing
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<div className="font-mono text-xs truncate">
|
||||
{asset.schematic_id}@{asset.version}
|
||||
</div>
|
||||
{asset.size && (
|
||||
<div>Size: {(asset.size / 1024 / 1024).toFixed(2)} MB</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{asset.downloaded && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
window.location.href = assetsApi.getAssetUrl(asset.schematic_id, asset.version, 'iso');
|
||||
}}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
Download to Computer
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={() => handleDelete(asset.schematic_id, asset.version)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 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>Get your schematic ID from Talos Image Factory</li>
|
||||
<li>Download the ISO image using the form 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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user