start working on the frontend side
This commit is contained in:
20
frontend/.eslintrc.cjs
Normal file
20
frontend/.eslintrc.cjs
Normal file
@@ -0,0 +1,20 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
},
|
||||
}
|
||||
|
||||
25
frontend/.gitignore
vendored
Normal file
25
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
67
frontend/README.md
Normal file
67
frontend/README.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Calypso Frontend
|
||||
|
||||
React + Vite + TypeScript frontend for AtlasOS - Calypso backup appliance.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18+ and npm
|
||||
- Backend API running on `http://localhost:8080`
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Start development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The frontend will be available at `http://localhost:3000`
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
The production build will be in the `dist/` directory.
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `src/api/` - API client and queries
|
||||
- `src/components/` - Reusable UI components
|
||||
- `src/pages/` - Page components
|
||||
- `src/store/` - Zustand state management
|
||||
- `src/types/` - TypeScript type definitions
|
||||
- `src/utils/` - Utility functions
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ React 18 with TypeScript
|
||||
- ✅ Vite for fast development
|
||||
- ✅ TailwindCSS for styling
|
||||
- ✅ TanStack Query for data fetching
|
||||
- ✅ React Router for navigation
|
||||
- ✅ Zustand for state management
|
||||
- ✅ Axios for HTTP requests
|
||||
- ⏳ WebSocket for real-time events (coming soon)
|
||||
- ⏳ shadcn/ui components (coming soon)
|
||||
|
||||
## Development
|
||||
|
||||
The Vite dev server is configured to proxy API requests to the backend:
|
||||
|
||||
- `/api/*` → `http://localhost:8080/api/*`
|
||||
- `/ws/*` → `ws://localhost:8080/ws/*`
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Install shadcn/ui components
|
||||
2. Implement WebSocket client
|
||||
3. Build out all page components
|
||||
4. Add charts and visualizations
|
||||
5. Implement real-time updates
|
||||
|
||||
14
frontend/index.html
Normal file
14
frontend/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AtlasOS - Calypso</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
4882
frontend/package-lock.json
generated
Normal file
4882
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
frontend/package.json
Normal file
41
frontend/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "calypso-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "AtlasOS - Calypso Frontend (React + Vite)",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"@tanstack/react-query": "^5.12.0",
|
||||
"axios": "^1.6.2",
|
||||
"zustand": "^4.4.7",
|
||||
"clsx": "^2.0.0",
|
||||
"tailwind-merge": "^2.1.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"recharts": "^2.10.3",
|
||||
"date-fns": "^2.30.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||
"@typescript-eslint/parser": "^6.14.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
|
||||
7
frontend/postcss.config.js
Normal file
7
frontend/postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
59
frontend/src/App.tsx
Normal file
59
frontend/src/App.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { Toaster } from '@/components/ui/toaster'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import LoginPage from '@/pages/Login'
|
||||
import Dashboard from '@/pages/Dashboard'
|
||||
import StoragePage from '@/pages/Storage'
|
||||
import AlertsPage from '@/pages/Alerts'
|
||||
import Layout from '@/components/Layout'
|
||||
|
||||
// Create a client
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Protected Route Component
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated } = useAuthStore()
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="storage" element={<StoragePage />} />
|
||||
<Route path="alerts" element={<AlertsPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<Toaster />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
35
frontend/src/api/auth.ts
Normal file
35
frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string
|
||||
user: {
|
||||
id: string
|
||||
username: string
|
||||
email: string
|
||||
full_name?: string
|
||||
roles: string[]
|
||||
permissions: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
login: async (credentials: LoginRequest): Promise<LoginResponse> => {
|
||||
const response = await apiClient.post<LoginResponse>('/auth/login', credentials)
|
||||
return response.data
|
||||
},
|
||||
|
||||
logout: async (): Promise<void> => {
|
||||
await apiClient.post('/auth/logout')
|
||||
},
|
||||
|
||||
getMe: async (): Promise<LoginResponse['user']> => {
|
||||
const response = await apiClient.get<LoginResponse['user']>('/auth/me')
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
39
frontend/src/api/client.ts
Normal file
39
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import axios from 'axios'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Request interceptor to add auth token
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = useAuthStore.getState().token
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// Response interceptor to handle errors
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Unauthorized - clear auth and redirect to login
|
||||
useAuthStore.getState().clearAuth()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default apiClient
|
||||
|
||||
89
frontend/src/api/monitoring.ts
Normal file
89
frontend/src/api/monitoring.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export type AlertSeverity = 'info' | 'warning' | 'critical'
|
||||
export type AlertSource = 'storage' | 'tape' | 'iscsi' | 'system' | 'task'
|
||||
|
||||
export interface Alert {
|
||||
id: string
|
||||
severity: AlertSeverity
|
||||
source: AlertSource
|
||||
title: string
|
||||
message: string
|
||||
resource_type?: string
|
||||
resource_id?: string
|
||||
is_acknowledged: boolean
|
||||
acknowledged_by?: string
|
||||
acknowledged_at?: string
|
||||
resolved_at?: string
|
||||
created_at: string
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface AlertFilters {
|
||||
severity?: AlertSeverity
|
||||
source?: AlertSource
|
||||
is_acknowledged?: boolean
|
||||
resource_type?: string
|
||||
resource_id?: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface Metrics {
|
||||
system: {
|
||||
cpu_usage_percent: number
|
||||
memory_usage_percent: number
|
||||
disk_usage_percent: number
|
||||
}
|
||||
storage: {
|
||||
total_repositories: number
|
||||
total_capacity_bytes: number
|
||||
used_capacity_bytes: number
|
||||
}
|
||||
scst: {
|
||||
total_targets: number
|
||||
total_luns: number
|
||||
active_sessions: number
|
||||
}
|
||||
tape: {
|
||||
physical_libraries: number
|
||||
physical_drives: number
|
||||
virtual_libraries: number
|
||||
virtual_drives: number
|
||||
}
|
||||
tasks: {
|
||||
total_tasks: number
|
||||
pending_tasks: number
|
||||
running_tasks: number
|
||||
completed_tasks: number
|
||||
failed_tasks: number
|
||||
avg_duration_sec: number
|
||||
}
|
||||
}
|
||||
|
||||
export const monitoringApi = {
|
||||
listAlerts: async (filters?: AlertFilters): Promise<{ alerts: Alert[] }> => {
|
||||
const response = await apiClient.get<{ alerts: Alert[] }>('/monitoring/alerts', {
|
||||
params: filters,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
getAlert: async (id: string): Promise<Alert> => {
|
||||
const response = await apiClient.get<Alert>(`/monitoring/alerts/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
acknowledgeAlert: async (id: string): Promise<void> => {
|
||||
await apiClient.post(`/monitoring/alerts/${id}/acknowledge`)
|
||||
},
|
||||
|
||||
resolveAlert: async (id: string): Promise<void> => {
|
||||
await apiClient.post(`/monitoring/alerts/${id}/resolve`)
|
||||
},
|
||||
|
||||
getMetrics: async (): Promise<Metrics> => {
|
||||
const response = await apiClient.get<Metrics>('/monitoring/metrics')
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
86
frontend/src/api/storage.ts
Normal file
86
frontend/src/api/storage.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface PhysicalDisk {
|
||||
id: string
|
||||
device_path: string
|
||||
vendor?: string
|
||||
model?: string
|
||||
serial_number?: string
|
||||
size_bytes: number
|
||||
sector_size?: number
|
||||
is_ssd: boolean
|
||||
health_status: string
|
||||
is_used: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface VolumeGroup {
|
||||
id: string
|
||||
name: string
|
||||
size_bytes: number
|
||||
free_bytes: number
|
||||
physical_volumes: string[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Repository {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
volume_group: string
|
||||
logical_volume: string
|
||||
size_bytes: number
|
||||
used_bytes: number
|
||||
filesystem_type?: string
|
||||
mount_point?: string
|
||||
is_active: boolean
|
||||
warning_threshold_percent: number
|
||||
critical_threshold_percent: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export const storageApi = {
|
||||
listDisks: async (): Promise<PhysicalDisk[]> => {
|
||||
const response = await apiClient.get<PhysicalDisk[]>('/storage/disks')
|
||||
return response.data
|
||||
},
|
||||
|
||||
syncDisks: async (): Promise<{ task_id: string }> => {
|
||||
const response = await apiClient.post<{ task_id: string }>('/storage/disks/sync')
|
||||
return response.data
|
||||
},
|
||||
|
||||
listVolumeGroups: async (): Promise<VolumeGroup[]> => {
|
||||
const response = await apiClient.get<VolumeGroup[]>('/storage/volume-groups')
|
||||
return response.data
|
||||
},
|
||||
|
||||
listRepositories: async (): Promise<Repository[]> => {
|
||||
const response = await apiClient.get<Repository[]>('/storage/repositories')
|
||||
return response.data
|
||||
},
|
||||
|
||||
getRepository: async (id: string): Promise<Repository> => {
|
||||
const response = await apiClient.get<Repository>(`/storage/repositories/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
createRepository: async (data: {
|
||||
name: string
|
||||
description?: string
|
||||
volume_group: string
|
||||
size_gb: number
|
||||
filesystem_type?: string
|
||||
}): Promise<{ task_id: string }> => {
|
||||
const response = await apiClient.post<{ task_id: string }>('/storage/repositories', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
deleteRepository: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/storage/repositories/${id}`)
|
||||
},
|
||||
}
|
||||
|
||||
101
frontend/src/components/Layout.tsx
Normal file
101
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Outlet, Link, useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { LogOut, Menu } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function Layout() {
|
||||
const { user, clearAuth } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
|
||||
const handleLogout = () => {
|
||||
clearAuth()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/', icon: '📊' },
|
||||
{ name: 'Storage', href: '/storage', icon: '💾' },
|
||||
{ name: 'Tape Libraries', href: '/tape', icon: '📼' },
|
||||
{ name: 'iSCSI Targets', href: '/iscsi', icon: '🔌' },
|
||||
{ name: 'Tasks', href: '/tasks', icon: '⚙️' },
|
||||
{ name: 'Alerts', href: '/alerts', icon: '🔔' },
|
||||
{ name: 'System', href: '/system', icon: '🖥️' },
|
||||
]
|
||||
|
||||
if (user?.roles.includes('admin')) {
|
||||
navigation.push({ name: 'IAM', href: '/iam', icon: '👥' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={`fixed inset-y-0 left-0 z-50 w-64 bg-gray-900 text-white transition-transform duration-300 ${
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-800">
|
||||
<h1 className="text-xl font-bold">Calypso</h1>
|
||||
<button
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="lg:hidden text-gray-400 hover:text-white"
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex-1 p-4 space-y-2">
|
||||
{navigation.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className="flex items-center space-x-3 px-4 py-2 rounded-lg hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
<span>{item.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="p-4 border-t border-gray-800">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{user?.username}</p>
|
||||
<p className="text-xs text-gray-400">{user?.roles.join(', ')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center space-x-2 px-4 py-2 rounded-lg hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className={`transition-all duration-300 ${sidebarOpen ? 'lg:ml-64' : 'ml-0'}`}>
|
||||
{/* Top bar */}
|
||||
<div className="bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="lg:hidden text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
39
frontend/src/components/ui/button.tsx
Normal file
39
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"
|
||||
size?: "default" | "sm" | "lg" | "icon"
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = "default", size = "default", ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90": variant === "default",
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90": variant === "destructive",
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground": variant === "outline",
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80": variant === "secondary",
|
||||
"hover:bg-accent hover:text-accent-foreground": variant === "ghost",
|
||||
"text-primary underline-offset-4 hover:underline": variant === "link",
|
||||
"h-10 px-4 py-2": size === "default",
|
||||
"h-9 rounded-md px-3": size === "sm",
|
||||
"h-11 rounded-md px-8": size === "lg",
|
||||
"h-10 w-10": size === "icon",
|
||||
},
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button }
|
||||
|
||||
79
frontend/src/components/ui/card.tsx
Normal file
79
frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
|
||||
6
frontend/src/components/ui/toaster.tsx
Normal file
6
frontend/src/components/ui/toaster.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
// Placeholder for toast notifications
|
||||
// Will be implemented with shadcn/ui toast component
|
||||
export function Toaster() {
|
||||
return null
|
||||
}
|
||||
|
||||
74
frontend/src/hooks/useWebSocket.ts
Normal file
74
frontend/src/hooks/useWebSocket.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
|
||||
export interface WebSocketEvent {
|
||||
type: 'alert' | 'task' | 'metric' | 'event'
|
||||
data: any
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export function useWebSocket(url: string) {
|
||||
const [isConnected, setIsConnected] = useState(false)
|
||||
const [lastMessage, setLastMessage] = useState<WebSocketEvent | null>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
const { token } = useAuthStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
|
||||
const connect = () => {
|
||||
try {
|
||||
// Convert HTTP URL to WebSocket URL
|
||||
const wsUrl = url.replace('http://', 'ws://').replace('https://', 'wss://')
|
||||
const ws = new WebSocket(`${wsUrl}?token=${token}`)
|
||||
|
||||
ws.onopen = () => {
|
||||
setIsConnected(true)
|
||||
console.log('WebSocket connected')
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
setLastMessage(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error)
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
setIsConnected(false)
|
||||
console.log('WebSocket disconnected')
|
||||
|
||||
// Reconnect after 3 seconds
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
connect()
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
wsRef.current = ws
|
||||
} catch (error) {
|
||||
console.error('Failed to create WebSocket:', error)
|
||||
}
|
||||
}
|
||||
|
||||
connect()
|
||||
|
||||
return () => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
}
|
||||
}
|
||||
}, [url, token])
|
||||
|
||||
return { isConnected, lastMessage }
|
||||
}
|
||||
|
||||
60
frontend/src/index.css
Normal file
60
frontend/src/index.css
Normal file
@@ -0,0 +1,60 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
31
frontend/src/lib/format.ts
Normal file
31
frontend/src/lib/format.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Format bytes to human-readable string
|
||||
*/
|
||||
export function formatBytes(bytes: number, decimals: number = 2): string {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
|
||||
const k = 1024
|
||||
const dm = decimals < 0 ? 0 : decimals
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date to relative time (e.g., "2 hours ago")
|
||||
*/
|
||||
export function formatRelativeTime(date: string | Date): string {
|
||||
const now = new Date()
|
||||
const then = typeof date === 'string' ? new Date(date) : date
|
||||
const diffInSeconds = Math.floor((now.getTime() - then.getTime()) / 1000)
|
||||
|
||||
if (diffInSeconds < 60) return 'just now'
|
||||
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} minutes ago`
|
||||
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} hours ago`
|
||||
if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)} days ago`
|
||||
|
||||
return then.toLocaleDateString()
|
||||
}
|
||||
|
||||
7
frontend/src/lib/utils.ts
Normal file
7
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
11
frontend/src/main.tsx
Normal file
11
frontend/src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
159
frontend/src/pages/Alerts.tsx
Normal file
159
frontend/src/pages/Alerts.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { monitoringApi, Alert } from '@/api/monitoring'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Bell, CheckCircle, XCircle, AlertTriangle, Info } from 'lucide-react'
|
||||
import { formatRelativeTime } from '@/lib/format'
|
||||
import { useState } from 'react'
|
||||
|
||||
const severityIcons = {
|
||||
info: Info,
|
||||
warning: AlertTriangle,
|
||||
critical: XCircle,
|
||||
}
|
||||
|
||||
const severityColors = {
|
||||
info: 'bg-blue-100 text-blue-800 border-blue-200',
|
||||
warning: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
critical: 'bg-red-100 text-red-800 border-red-200',
|
||||
}
|
||||
|
||||
export default function AlertsPage() {
|
||||
const [filter, setFilter] = useState<'all' | 'unacknowledged'>('unacknowledged')
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['alerts', filter],
|
||||
queryFn: () =>
|
||||
monitoringApi.listAlerts(
|
||||
filter === 'unacknowledged' ? { is_acknowledged: false } : undefined
|
||||
),
|
||||
})
|
||||
|
||||
const acknowledgeMutation = useMutation({
|
||||
mutationFn: monitoringApi.acknowledgeAlert,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['alerts'] })
|
||||
},
|
||||
})
|
||||
|
||||
const resolveMutation = useMutation({
|
||||
mutationFn: monitoringApi.resolveAlert,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['alerts'] })
|
||||
},
|
||||
})
|
||||
|
||||
const alerts = data?.alerts || []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Alerts</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Monitor system alerts and notifications
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant={filter === 'all' ? 'default' : 'outline'}
|
||||
onClick={() => setFilter('all')}
|
||||
>
|
||||
All Alerts
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === 'unacknowledged' ? 'default' : 'outline'}
|
||||
onClick={() => setFilter('unacknowledged')}
|
||||
>
|
||||
Unacknowledged
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Bell className="h-5 w-5 mr-2" />
|
||||
Active Alerts
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{isLoading
|
||||
? 'Loading...'
|
||||
: `${alerts.length} ${filter === 'unacknowledged' ? 'unacknowledged' : ''} alert${alerts.length !== 1 ? 's' : ''}`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-gray-500">Loading alerts...</p>
|
||||
) : alerts.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{alerts.map((alert: Alert) => {
|
||||
const Icon = severityIcons[alert.severity]
|
||||
return (
|
||||
<div
|
||||
key={alert.id}
|
||||
className={`border rounded-lg p-4 ${severityColors[alert.severity]}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-3 flex-1">
|
||||
<Icon className="h-5 w-5 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<h3 className="font-semibold">{alert.title}</h3>
|
||||
<span className="text-xs px-2 py-1 bg-white/50 rounded">
|
||||
{alert.source}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm mb-2">{alert.message}</p>
|
||||
<div className="flex items-center space-x-4 text-xs">
|
||||
<span>{formatRelativeTime(alert.created_at)}</span>
|
||||
{alert.resource_type && (
|
||||
<span>
|
||||
{alert.resource_type}: {alert.resource_id}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2 ml-4">
|
||||
{!alert.is_acknowledged && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => acknowledgeMutation.mutate(alert.id)}
|
||||
disabled={acknowledgeMutation.isPending}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-1" />
|
||||
Acknowledge
|
||||
</Button>
|
||||
)}
|
||||
{!alert.resolved_at && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => resolveMutation.mutate(alert.id)}
|
||||
disabled={resolveMutation.isPending}
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-1" />
|
||||
Resolve
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Bell className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-sm text-gray-500">No alerts found</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
190
frontend/src/pages/Dashboard.tsx
Normal file
190
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import apiClient from '@/api/client'
|
||||
import { monitoringApi } from '@/api/monitoring'
|
||||
import { storageApi } from '@/api/storage'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Activity, Database, AlertTriangle, HardDrive } from 'lucide-react'
|
||||
import { formatBytes } from '@/lib/format'
|
||||
|
||||
export default function Dashboard() {
|
||||
const { data: health } = useQuery({
|
||||
queryKey: ['health'],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get('/health')
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: metrics } = useQuery({
|
||||
queryKey: ['metrics'],
|
||||
queryFn: monitoringApi.getMetrics,
|
||||
})
|
||||
|
||||
const { data: alerts } = useQuery({
|
||||
queryKey: ['alerts', 'dashboard'],
|
||||
queryFn: () => monitoringApi.listAlerts({ is_acknowledged: false, limit: 5 }),
|
||||
})
|
||||
|
||||
const { data: repositories } = useQuery({
|
||||
queryKey: ['storage', 'repositories'],
|
||||
queryFn: storageApi.listRepositories,
|
||||
})
|
||||
|
||||
const unacknowledgedAlerts = alerts?.alerts?.length || 0
|
||||
const totalRepos = repositories?.length || 0
|
||||
const totalStorage = repositories?.reduce((sum, repo) => sum + repo.size_bytes, 0) || 0
|
||||
const usedStorage = repositories?.reduce((sum, repo) => sum + repo.used_bytes, 0) || 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Overview of your Calypso backup appliance
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* System Health */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Activity className="h-5 w-5 mr-2" />
|
||||
System Health
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{health && (
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span
|
||||
className={`h-4 w-4 rounded-full ${
|
||||
health.status === 'healthy'
|
||||
? 'bg-green-500'
|
||||
: health.status === 'degraded'
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-red-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-lg font-semibold capitalize">{health.status}</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600">Service: {health.service}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Overview Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Storage Repositories</CardTitle>
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalRepos}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatBytes(usedStorage)} / {formatBytes(totalStorage)} used
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Active Alerts</CardTitle>
|
||||
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{unacknowledgedAlerts}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{unacknowledgedAlerts === 0 ? 'All clear' : 'Requires attention'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{metrics && (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">iSCSI Targets</CardTitle>
|
||||
<HardDrive className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{metrics.scst.total_targets}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{metrics.scst.active_sessions} active sessions
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Tasks</CardTitle>
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{metrics.tasks.running_tasks}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{metrics.tasks.pending_tasks} pending
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Actions</CardTitle>
|
||||
<CardDescription>Common operations</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<Link to="/storage" className="w-full">
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<Database className="h-4 w-4 mr-2" />
|
||||
Manage Storage
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/alerts" className="w-full">
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<AlertTriangle className="h-4 w-4 mr-2" />
|
||||
View Alerts
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent Alerts */}
|
||||
{alerts && alerts.alerts.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Alerts</CardTitle>
|
||||
<CardDescription>Latest unacknowledged alerts</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{alerts.alerts.slice(0, 3).map((alert) => (
|
||||
<div key={alert.id} className="text-sm">
|
||||
<p className="font-medium">{alert.title}</p>
|
||||
<p className="text-xs text-gray-500">{alert.message}</p>
|
||||
</div>
|
||||
))}
|
||||
{alerts.alerts.length > 3 && (
|
||||
<Link to="/alerts">
|
||||
<Button variant="link" className="p-0 h-auto">
|
||||
View all alerts →
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
95
frontend/src/pages/Login.tsx
Normal file
95
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const setAuth = useAuthStore((state) => state.setAuth)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: authApi.login,
|
||||
onSuccess: (data) => {
|
||||
setAuth(data.token, data.user)
|
||||
navigate('/')
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setError(err.response?.data?.error || 'Login failed')
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
loginMutation.mutate({ username, password })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-lg shadow-md">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
AtlasOS - Calypso
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Sign in to your account
|
||||
</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="username" className="sr-only">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loginMutation.isPending}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loginMutation.isPending ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
199
frontend/src/pages/Storage.tsx
Normal file
199
frontend/src/pages/Storage.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { storageApi } from '@/api/storage'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { RefreshCw, HardDrive, Database } from 'lucide-react'
|
||||
import { formatBytes } from '@/lib/format'
|
||||
|
||||
export default function StoragePage() {
|
||||
const { data: disks, isLoading: disksLoading } = useQuery({
|
||||
queryKey: ['storage', 'disks'],
|
||||
queryFn: storageApi.listDisks,
|
||||
})
|
||||
|
||||
const { data: repositories, isLoading: reposLoading } = useQuery({
|
||||
queryKey: ['storage', 'repositories'],
|
||||
queryFn: storageApi.listRepositories,
|
||||
})
|
||||
|
||||
const { data: volumeGroups, isLoading: vgsLoading } = useQuery({
|
||||
queryKey: ['storage', 'volume-groups'],
|
||||
queryFn: storageApi.listVolumeGroups,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Storage Management</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Manage disk repositories and volume groups
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Repositories */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Database className="h-5 w-5 mr-2" />
|
||||
Disk Repositories
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{reposLoading ? 'Loading...' : `${repositories?.length || 0} repositories`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{reposLoading ? (
|
||||
<p className="text-sm text-gray-500">Loading repositories...</p>
|
||||
) : repositories && repositories.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{repositories.map((repo) => {
|
||||
const usagePercent = (repo.used_bytes / repo.size_bytes) * 100
|
||||
return (
|
||||
<div key={repo.id} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-semibold">{repo.name}</h3>
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs ${
|
||||
repo.is_active
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{repo.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-3">{repo.description}</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Capacity</span>
|
||||
<span>
|
||||
{formatBytes(repo.used_bytes)} / {formatBytes(repo.size_bytes)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full ${
|
||||
usagePercent >= repo.critical_threshold_percent
|
||||
? 'bg-red-500'
|
||||
: usagePercent >= repo.warning_threshold_percent
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-green-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(usagePercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{usagePercent.toFixed(1)}% used
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">No repositories found</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Physical Disks */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<HardDrive className="h-5 w-5 mr-2" />
|
||||
Physical Disks
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{disksLoading ? 'Loading...' : `${disks?.length || 0} disks detected`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{disksLoading ? (
|
||||
<p className="text-sm text-gray-500">Loading disks...</p>
|
||||
) : disks && disks.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{disks.map((disk) => (
|
||||
<div key={disk.id} className="border rounded-lg p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">{disk.device_path}</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{disk.vendor} {disk.model} - {formatBytes(disk.size_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{disk.is_ssd && (
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded">
|
||||
SSD
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`px-2 py-1 text-xs rounded ${
|
||||
disk.is_used
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-green-100 text-green-800'
|
||||
}`}
|
||||
>
|
||||
{disk.is_used ? 'In Use' : 'Available'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">No disks found</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Volume Groups */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Volume Groups</CardTitle>
|
||||
<CardDescription>
|
||||
{vgsLoading ? 'Loading...' : `${volumeGroups?.length || 0} volume groups`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{vgsLoading ? (
|
||||
<p className="text-sm text-gray-500">Loading volume groups...</p>
|
||||
) : volumeGroups && volumeGroups.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{volumeGroups.map((vg) => {
|
||||
const freePercent = (vg.free_bytes / vg.size_bytes) * 100
|
||||
return (
|
||||
<div key={vg.id} className="border rounded-lg p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">{vg.name}</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{formatBytes(vg.free_bytes)} free of {formatBytes(vg.size_bytes)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-32 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-green-500 h-2 rounded-full"
|
||||
style={{ width: `${freePercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">No volume groups found</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
38
frontend/src/store/auth.ts
Normal file
38
frontend/src/store/auth.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist, createJSONStorage } from 'zustand/middleware'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
username: string
|
||||
email: string
|
||||
full_name?: string
|
||||
roles: string[]
|
||||
permissions: string[]
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
user: User | null
|
||||
isAuthenticated: boolean
|
||||
setAuth: (token: string, user: User) => void
|
||||
clearAuth: () => void
|
||||
updateUser: (user: User) => void
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
token: null,
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
setAuth: (token, user) => set({ token, user, isAuthenticated: true }),
|
||||
clearAuth: () => set({ token: null, user: null, isAuthenticated: false }),
|
||||
updateUser: (user) => set({ user }),
|
||||
}),
|
||||
{
|
||||
name: 'calypso-auth',
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
54
frontend/tailwind.config.js
Normal file
54
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,54 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
'./index.html',
|
||||
'./src/**/*.{js,ts,jsx,tsx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
32
frontend/tsconfig.json
Normal file
32
frontend/tsconfig.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
33
frontend/vite.config.ts
Normal file
33
frontend/vite.config.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0', // Listen on all interfaces to allow access via IP address
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8080',
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user