start working on the frontend side

This commit is contained in:
Warp Agent
2025-12-24 19:53:45 +00:00
parent 3aa0169af0
commit c962a223c6
84 changed files with 14761 additions and 58 deletions

20
frontend/.eslintrc.cjs Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

41
frontend/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,7 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

59
frontend/src/App.tsx Normal file
View 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
View 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
},
}

View 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

View 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
},
}

View 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}`)
},
}

View 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>
)
}

View 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 }

View 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 }

View File

@@ -0,0 +1,6 @@
// Placeholder for toast notifications
// Will be implemented with shadcn/ui toast component
export function Toaster() {
return null
}

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

View 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()
}

View 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
View 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>,
)

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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),
}
)
)

View 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
View 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" }]
}

View 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
View 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,
},
})