working on storage dashboard
This commit is contained in:
@@ -1,10 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html class="dark" 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>
|
||||
<!-- Material Symbols -->
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -32,7 +32,10 @@ export interface Metrics {
|
||||
system: {
|
||||
cpu_usage_percent: number
|
||||
memory_usage_percent: number
|
||||
memory_used_bytes: number
|
||||
memory_total_bytes: number
|
||||
disk_usage_percent: number
|
||||
uptime_seconds: number
|
||||
}
|
||||
storage: {
|
||||
total_repositories: number
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface PhysicalDisk {
|
||||
is_ssd: boolean
|
||||
health_status: string
|
||||
is_used: boolean
|
||||
attached_to_pool?: string // Pool name if disk is used in a ZFS pool
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -44,8 +45,8 @@ export interface Repository {
|
||||
|
||||
export const storageApi = {
|
||||
listDisks: async (): Promise<PhysicalDisk[]> => {
|
||||
const response = await apiClient.get<PhysicalDisk[]>('/storage/disks')
|
||||
return response.data
|
||||
const response = await apiClient.get<{ disks: PhysicalDisk[] | null }>('/storage/disks')
|
||||
return response.data.disks || []
|
||||
},
|
||||
|
||||
syncDisks: async (): Promise<{ task_id: string }> => {
|
||||
@@ -54,13 +55,13 @@ export const storageApi = {
|
||||
},
|
||||
|
||||
listVolumeGroups: async (): Promise<VolumeGroup[]> => {
|
||||
const response = await apiClient.get<VolumeGroup[]>('/storage/volume-groups')
|
||||
return response.data
|
||||
const response = await apiClient.get<{ volume_groups: VolumeGroup[] | null }>('/storage/volume-groups')
|
||||
return response.data.volume_groups || []
|
||||
},
|
||||
|
||||
listRepositories: async (): Promise<Repository[]> => {
|
||||
const response = await apiClient.get<Repository[]>('/storage/repositories')
|
||||
return response.data
|
||||
const response = await apiClient.get<{ repositories: Repository[] | null }>('/storage/repositories')
|
||||
return response.data.repositories || []
|
||||
},
|
||||
|
||||
getRepository: async (id: string): Promise<Repository> => {
|
||||
@@ -84,3 +85,92 @@ export const storageApi = {
|
||||
},
|
||||
}
|
||||
|
||||
export interface ZFSPool {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
raid_level: string // stripe, mirror, raidz, raidz2, raidz3
|
||||
disks: string[] // device paths
|
||||
spare_disks?: string[] // spare disk paths
|
||||
size_bytes: number
|
||||
used_bytes: number
|
||||
compression: string // off, lz4, zstd, gzip
|
||||
deduplication: boolean
|
||||
auto_expand: boolean
|
||||
scrub_interval: number // days
|
||||
is_active: boolean
|
||||
health_status: string // online, degraded, faulted, offline
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by: string
|
||||
}
|
||||
|
||||
export const zfsApi = {
|
||||
listPools: async (): Promise<ZFSPool[]> => {
|
||||
const response = await apiClient.get<{ pools: ZFSPool[] | null }>('/storage/zfs/pools')
|
||||
return response.data.pools || []
|
||||
},
|
||||
|
||||
getPool: async (id: string): Promise<ZFSPool> => {
|
||||
const response = await apiClient.get<ZFSPool>(`/storage/zfs/pools/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
createPool: async (data: {
|
||||
name: string
|
||||
description?: string
|
||||
raid_level: string
|
||||
disks: string[]
|
||||
compression?: string
|
||||
deduplication?: boolean
|
||||
auto_expand?: boolean
|
||||
}): Promise<ZFSPool> => {
|
||||
const response = await apiClient.post<ZFSPool>('/storage/zfs/pools', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
deletePool: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/storage/zfs/pools/${id}`)
|
||||
},
|
||||
|
||||
addSpareDisk: async (id: string, disks: string[]): Promise<void> => {
|
||||
await apiClient.post(`/storage/zfs/pools/${id}/spare`, { disks })
|
||||
},
|
||||
|
||||
listDatasets: async (poolId: string): Promise<ZFSDataset[]> => {
|
||||
const response = await apiClient.get<{ datasets: ZFSDataset[] | null }>(`/storage/zfs/pools/${poolId}/datasets`)
|
||||
return response.data.datasets || []
|
||||
},
|
||||
|
||||
createDataset: async (poolId: string, data: {
|
||||
name: string
|
||||
type: 'filesystem' | 'volume'
|
||||
compression?: string
|
||||
quota?: number
|
||||
reservation?: number
|
||||
mount_point?: string
|
||||
}): Promise<ZFSDataset> => {
|
||||
const response = await apiClient.post<ZFSDataset>(`/storage/zfs/pools/${poolId}/datasets`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
deleteDataset: async (poolId: string, datasetName: string): Promise<void> => {
|
||||
await apiClient.delete(`/storage/zfs/pools/${poolId}/datasets/${datasetName}`)
|
||||
},
|
||||
}
|
||||
|
||||
export interface ZFSDataset {
|
||||
name: string
|
||||
pool: string
|
||||
type: string // filesystem, volume, snapshot
|
||||
mount_point: string
|
||||
used_bytes: number
|
||||
available_bytes: number
|
||||
referenced_bytes: number
|
||||
compression: string
|
||||
deduplication: string
|
||||
quota: number // -1 for unlimited
|
||||
reservation: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
|
||||
24
frontend/src/api/tasks.ts
Normal file
24
frontend/src/api/tasks.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface Task {
|
||||
id: string
|
||||
type: string
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
|
||||
progress: number
|
||||
message: string
|
||||
error_message?: string
|
||||
created_by?: string
|
||||
started_at?: string
|
||||
completed_at?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
export const tasksApi = {
|
||||
getTask: async (id: string): Promise<Task> => {
|
||||
const response = await apiClient.get<Task>(`/tasks/${id}`)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import { Outlet, Link, useNavigate } from 'react-router-dom'
|
||||
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { LogOut, Menu } from 'lucide-react'
|
||||
import {
|
||||
LogOut,
|
||||
Menu,
|
||||
LayoutDashboard,
|
||||
HardDrive,
|
||||
Database,
|
||||
Network,
|
||||
Settings,
|
||||
Bell,
|
||||
Server,
|
||||
Users
|
||||
} from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function Layout() {
|
||||
const { user, clearAuth } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
|
||||
const handleLogout = () => {
|
||||
@@ -14,84 +26,100 @@ export default function Layout() {
|
||||
}
|
||||
|
||||
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: '🖥️' },
|
||||
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
|
||||
{ name: 'Storage', href: '/storage', icon: HardDrive },
|
||||
{ name: 'Tape Libraries', href: '/tape', icon: Database },
|
||||
{ name: 'iSCSI Targets', href: '/iscsi', icon: Network },
|
||||
{ name: 'Tasks', href: '/tasks', icon: Settings },
|
||||
{ name: 'Alerts', href: '/alerts', icon: Bell },
|
||||
{ name: 'System', href: '/system', icon: Server },
|
||||
]
|
||||
|
||||
if (user?.roles.includes('admin')) {
|
||||
navigation.push({ name: 'IAM', href: '/iam', icon: '👥' })
|
||||
navigation.push({ name: 'IAM', href: '/iam', icon: Users })
|
||||
}
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href === '/') {
|
||||
return location.pathname === '/'
|
||||
}
|
||||
return location.pathname.startsWith(href)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-background-dark">
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className={`fixed inset-y-0 left-0 z-50 w-64 bg-gray-900 text-white transition-transform duration-300 ${
|
||||
className={`fixed inset-y-0 left-0 z-50 w-64 bg-background-dark border-r border-border-dark 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>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-border-dark">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
|
||||
<span className="text-white font-bold text-sm">C</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-black text-white font-display tracking-tight">Calypso</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="lg:hidden text-gray-400 hover:text-white"
|
||||
className="lg:hidden text-text-secondary hover:text-white transition-colors"
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
<Menu className="h-5 w-5" />
|
||||
</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>
|
||||
))}
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto custom-scrollbar">
|
||||
{navigation.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = isActive(item.href)
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 rounded-lg transition-all ${
|
||||
active
|
||||
? 'bg-primary/20 text-primary border-l-2 border-primary'
|
||||
: 'text-text-secondary hover:bg-card-dark hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`h-5 w-5 ${active ? 'text-primary' : ''}`} />
|
||||
<span className={`text-sm font-medium ${active ? 'font-semibold' : ''}`}>
|
||||
{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>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-border-dark bg-[#0d1419]">
|
||||
<div className="mb-3 px-2">
|
||||
<p className="text-sm font-semibold text-white mb-0.5">{user?.username}</p>
|
||||
<p className="text-xs text-text-secondary font-mono">
|
||||
{user?.roles.join(', ').toUpperCase()}
|
||||
</p>
|
||||
</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"
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 rounded-lg text-text-secondary hover:bg-card-dark hover:text-white transition-colors border border-border-dark"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>Logout</span>
|
||||
<span className="text-sm font-medium">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>
|
||||
|
||||
<div className={`transition-all duration-300 ${sidebarOpen ? 'lg:ml-64' : 'ml-0'} bg-background-dark`}>
|
||||
{/* Top bar - removed for dashboard design */}
|
||||
|
||||
{/* Page content */}
|
||||
<main className="p-6">
|
||||
<main className="min-h-screen">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
{
|
||||
"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",
|
||||
"border border-border-dark bg-card-dark hover:bg-[#233648] hover:text-white text-white": 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",
|
||||
|
||||
@@ -8,7 +8,7 @@ const Card = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
"rounded-lg border border-border-dark bg-card-dark text-white shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -49,7 +49,7 @@ const CardDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
className={cn("text-sm text-text-secondary", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@200..800&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -55,6 +57,68 @@
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: 'Manrope', sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar for dark theme */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: #111a22;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #324d67;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #476685;
|
||||
}
|
||||
|
||||
/* Electric glow animation for buttons */
|
||||
@keyframes electric-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 8px rgba(19, 127, 236, 0.4),
|
||||
0 0 16px rgba(19, 127, 236, 0.3),
|
||||
0 0 24px rgba(19, 127, 236, 0.2),
|
||||
inset 0 0 8px rgba(19, 127, 236, 0.1);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 12px rgba(19, 127, 236, 0.6),
|
||||
0 0 24px rgba(19, 127, 236, 0.4),
|
||||
0 0 36px rgba(19, 127, 236, 0.3),
|
||||
inset 0 0 12px rgba(19, 127, 236, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes electric-border {
|
||||
0%, 100% {
|
||||
border-color: rgba(19, 127, 236, 0.3);
|
||||
}
|
||||
50% {
|
||||
border-color: rgba(19, 127, 236, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
.electric-glow {
|
||||
animation: electric-glow 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.electric-glow-border {
|
||||
animation: electric-border 2.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ const severityIcons = {
|
||||
}
|
||||
|
||||
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',
|
||||
info: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
warning: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
|
||||
critical: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||
}
|
||||
|
||||
export default function AlertsPage() {
|
||||
@@ -47,11 +47,11 @@ export default function AlertsPage() {
|
||||
const alerts = data?.alerts || []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6 min-h-screen bg-background-dark p-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">
|
||||
<h1 className="text-3xl font-bold text-white">Alerts</h1>
|
||||
<p className="mt-2 text-sm text-text-secondary">
|
||||
Monitor system alerts and notifications
|
||||
</p>
|
||||
</div>
|
||||
@@ -85,7 +85,7 @@ export default function AlertsPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-gray-500">Loading alerts...</p>
|
||||
<p className="text-sm text-text-secondary">Loading alerts...</p>
|
||||
) : alerts.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{alerts.map((alert: Alert) => {
|
||||
@@ -93,19 +93,19 @@ export default function AlertsPage() {
|
||||
return (
|
||||
<div
|
||||
key={alert.id}
|
||||
className={`border rounded-lg p-4 ${severityColors[alert.severity]}`}
|
||||
className={`border rounded-lg p-4 bg-card-dark ${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">
|
||||
<h3 className="font-semibold text-white">{alert.title}</h3>
|
||||
<span className="text-xs px-2 py-1 bg-[#233648] border border-border-dark rounded text-text-secondary">
|
||||
{alert.source}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm mb-2">{alert.message}</p>
|
||||
<p className="text-sm mb-2 text-text-secondary">{alert.message}</p>
|
||||
<div className="flex items-center space-x-4 text-xs">
|
||||
<span>{formatRelativeTime(alert.created_at)}</span>
|
||||
{alert.resource_type && (
|
||||
@@ -147,8 +147,8 @@ export default function AlertsPage() {
|
||||
</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>
|
||||
<Bell className="h-12 w-12 text-text-secondary mx-auto mb-4" />
|
||||
<p className="text-sm text-text-secondary">No alerts found</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -1,190 +1,609 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
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'
|
||||
import {
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
Clock,
|
||||
Activity,
|
||||
RefreshCw,
|
||||
TrendingDown,
|
||||
CheckCircle2,
|
||||
AlertTriangle
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
|
||||
export default function Dashboard() {
|
||||
const [activeTab, setActiveTab] = useState<'jobs' | 'logs' | 'alerts'>('jobs')
|
||||
const [networkDataPoints, setNetworkDataPoints] = useState<Array<{ time: string; inbound: number; outbound: number }>>([])
|
||||
const refreshInterval = 5
|
||||
|
||||
const { data: health } = useQuery({
|
||||
queryKey: ['health'],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get('/health')
|
||||
return response.data
|
||||
},
|
||||
refetchInterval: refreshInterval * 1000,
|
||||
})
|
||||
|
||||
const { data: metrics } = useQuery({
|
||||
queryKey: ['metrics'],
|
||||
queryFn: monitoringApi.getMetrics,
|
||||
refetchInterval: refreshInterval * 1000,
|
||||
})
|
||||
|
||||
const { data: alerts } = useQuery({
|
||||
queryKey: ['alerts', 'dashboard'],
|
||||
queryFn: () => monitoringApi.listAlerts({ is_acknowledged: false, limit: 5 }),
|
||||
queryFn: () => monitoringApi.listAlerts({ is_acknowledged: false, limit: 10 }),
|
||||
refetchInterval: refreshInterval * 1000,
|
||||
})
|
||||
|
||||
const { data: repositories } = useQuery({
|
||||
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
|
||||
// Calculate uptime (mock for now, would come from metrics)
|
||||
const uptime = metrics?.system?.uptime_seconds || 0
|
||||
const days = Math.floor(uptime / 86400)
|
||||
const hours = Math.floor((uptime % 86400) / 3600)
|
||||
const minutes = Math.floor((uptime % 3600) / 60)
|
||||
|
||||
// Mock active jobs (would come from tasks API)
|
||||
const activeJobs = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Daily Backup: VM-Cluster-01',
|
||||
type: 'Replication',
|
||||
progress: 45,
|
||||
speed: '145 MB/s',
|
||||
status: 'running',
|
||||
eta: '1h 12m',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'ZFS Scrub: Pool-01',
|
||||
type: 'Maintenance',
|
||||
progress: 78,
|
||||
speed: '1.2 GB/s',
|
||||
status: 'running',
|
||||
},
|
||||
]
|
||||
|
||||
// Mock system logs
|
||||
const systemLogs = [
|
||||
{ time: '10:45:22', level: 'INFO', source: 'systemd', message: 'Started User Manager for UID 1000.' },
|
||||
{ time: '10:45:15', level: 'WARN', source: 'smartd', message: 'Device: /dev/ada5, SMART Usage Attribute: 194 Temperature_Celsius changed from 38 to 41' },
|
||||
{ time: '10:44:58', level: 'INFO', source: 'kernel', message: 'ix0: link state changed to UP' },
|
||||
{ time: '10:42:10', level: 'INFO', source: 'zfs', message: 'zfs_arc_reclaim_thread: reclaiming 157286400 bytes ...' },
|
||||
]
|
||||
|
||||
const totalStorage = Array.isArray(repositories) ? repositories.reduce((sum, repo) => sum + (repo?.size_bytes || 0), 0) : 0
|
||||
const usedStorage = Array.isArray(repositories) ? repositories.reduce((sum, repo) => sum + (repo?.used_bytes || 0), 0) : 0
|
||||
const storagePercent = totalStorage > 0 ? (usedStorage / totalStorage) * 100 : 0
|
||||
|
||||
// Initialize network data
|
||||
useEffect(() => {
|
||||
// Generate initial 30 data points
|
||||
const initialData = []
|
||||
const now = Date.now()
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const time = new Date(now - i * 5000)
|
||||
const minutes = time.getMinutes().toString().padStart(2, '0')
|
||||
const seconds = time.getSeconds().toString().padStart(2, '0')
|
||||
|
||||
const baseInbound = 800 + Math.random() * 400
|
||||
const baseOutbound = 400 + Math.random() * 200
|
||||
|
||||
initialData.push({
|
||||
time: `${minutes}:${seconds}`,
|
||||
inbound: Math.round(baseInbound),
|
||||
outbound: Math.round(baseOutbound),
|
||||
})
|
||||
}
|
||||
setNetworkDataPoints(initialData)
|
||||
|
||||
// Update data every 5 seconds
|
||||
const interval = setInterval(() => {
|
||||
setNetworkDataPoints((prev) => {
|
||||
const now = new Date()
|
||||
const minutes = now.getMinutes().toString().padStart(2, '0')
|
||||
const seconds = now.getSeconds().toString().padStart(2, '0')
|
||||
|
||||
const baseInbound = 800 + Math.random() * 400
|
||||
const baseOutbound = 400 + Math.random() * 200
|
||||
|
||||
const newPoint = {
|
||||
time: `${minutes}:${seconds}`,
|
||||
inbound: Math.round(baseInbound),
|
||||
outbound: Math.round(baseOutbound),
|
||||
}
|
||||
|
||||
// Keep only last 30 points
|
||||
const updated = [...prev.slice(1), newPoint]
|
||||
return updated
|
||||
})
|
||||
}, 5000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
// Calculate current and peak throughput
|
||||
const currentThroughput = useMemo(() => {
|
||||
if (networkDataPoints.length === 0) return { inbound: 0, outbound: 0, total: 0 }
|
||||
const last = networkDataPoints[networkDataPoints.length - 1]
|
||||
return {
|
||||
inbound: last.inbound,
|
||||
outbound: last.outbound,
|
||||
total: last.inbound + last.outbound,
|
||||
}
|
||||
}, [networkDataPoints])
|
||||
|
||||
const peakThroughput = useMemo(() => {
|
||||
if (networkDataPoints.length === 0) return 0
|
||||
return Math.max(...networkDataPoints.map((d) => d.inbound + d.outbound))
|
||||
}, [networkDataPoints])
|
||||
|
||||
const systemStatus = health?.status === 'healthy' ? 'System Healthy' : 'System Degraded'
|
||||
const isHealthy = health?.status === 'healthy'
|
||||
|
||||
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 className="min-h-screen bg-background-dark text-white overflow-hidden">
|
||||
{/* Header */}
|
||||
<header className="flex-none px-6 py-5 border-b border-border-dark bg-background-dark/95 backdrop-blur z-10">
|
||||
<div className="flex flex-wrap justify-between items-end gap-3 max-w-[1600px] mx-auto">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-white text-3xl font-black tracking-tight">System Monitor</h2>
|
||||
<p className="text-text-secondary text-sm">Real-time telemetry, storage health, and system event logs</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-card-dark rounded-lg border border-border-dark">
|
||||
<span className="relative flex h-2 w-2">
|
||||
{isHealthy && (
|
||||
<>
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
|
||||
</>
|
||||
)}
|
||||
{!isHealthy && (
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-yellow-500"></span>
|
||||
)}
|
||||
</span>
|
||||
<span className={`text-xs font-medium ${isHealthy ? 'text-emerald-400' : 'text-yellow-400'}`}>
|
||||
{systemStatus}
|
||||
</span>
|
||||
</div>
|
||||
<button className="flex items-center gap-2 h-10 px-4 bg-card-dark hover:bg-[#233648] border border-border-dark text-white text-sm font-bold rounded-lg transition-colors">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
<span>Refresh: {refreshInterval}s</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-6">
|
||||
<div className="flex flex-col gap-6 max-w-[1600px] mx-auto pb-10">
|
||||
{/* Top Stats Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* CPU */}
|
||||
<div className="flex flex-col gap-2 rounded-xl p-5 border border-border-dark bg-card-dark">
|
||||
<div className="flex justify-between items-start">
|
||||
<p className="text-text-secondary text-sm font-medium">CPU Load</p>
|
||||
<Cpu className="text-text-secondary w-5 h-5" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<div className="flex items-end gap-3 mt-1">
|
||||
<p className="text-white text-3xl font-bold">
|
||||
{metrics?.system?.cpu_usage_percent?.toFixed(0) || 0}%
|
||||
</p>
|
||||
<span className="text-emerald-500 text-sm font-medium mb-1 flex items-center">
|
||||
<TrendingDown className="w-4 h-4 mr-1" />
|
||||
2%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full bg-[#233648] rounded-full mt-3 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full transition-all"
|
||||
style={{ width: `${metrics?.system?.cpu_usage_percent || 0}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RAM */}
|
||||
<div className="flex flex-col gap-2 rounded-xl p-5 border border-border-dark bg-card-dark">
|
||||
<div className="flex justify-between items-start">
|
||||
<p className="text-text-secondary text-sm font-medium">RAM Usage</p>
|
||||
<MemoryStick className="text-text-secondary w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex items-end gap-3 mt-1">
|
||||
<p className="text-white text-3xl font-bold">
|
||||
{formatBytes(metrics?.system?.memory_used_bytes || 0, 1)}
|
||||
</p>
|
||||
<span className="text-text-secondary text-xs mb-2">
|
||||
/ {formatBytes(metrics?.system?.memory_total_bytes || 0, 1)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full bg-[#233648] rounded-full mt-3 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-500 rounded-full transition-all"
|
||||
style={{ width: `${metrics?.system?.memory_usage_percent || 0}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Storage Health */}
|
||||
<div className="flex flex-col gap-2 rounded-xl p-5 border border-border-dark bg-card-dark">
|
||||
<div className="flex justify-between items-start">
|
||||
<p className="text-text-secondary text-sm font-medium">Storage Status</p>
|
||||
<CheckCircle2 className="text-emerald-500 w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex items-end gap-3 mt-1">
|
||||
<p className="text-white text-3xl font-bold">Online</p>
|
||||
<span className="text-text-secondary text-sm font-medium mb-1">No Errors</span>
|
||||
</div>
|
||||
<div className="flex gap-1 mt-3">
|
||||
<div className="h-1.5 flex-1 bg-emerald-500 rounded-l-full"></div>
|
||||
<div className="h-1.5 flex-1 bg-emerald-500"></div>
|
||||
<div className="h-1.5 flex-1 bg-emerald-500"></div>
|
||||
<div className="h-1.5 flex-1 bg-emerald-500 rounded-r-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Uptime */}
|
||||
<div className="flex flex-col gap-2 rounded-xl p-5 border border-border-dark bg-card-dark">
|
||||
<div className="flex justify-between items-start">
|
||||
<p className="text-text-secondary text-sm font-medium">System Uptime</p>
|
||||
<Clock className="text-text-secondary w-5 h-5" />
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<p className="text-white text-3xl font-bold">
|
||||
{days}d {hours}h {minutes}m
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-text-secondary text-xs mt-3">Last reboot: Manual Patching</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle Section: Charts & Storage */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
{/* Charts Column (2/3) */}
|
||||
<div className="xl:col-span-2 flex flex-col gap-6">
|
||||
{/* Network Chart */}
|
||||
<div className="bg-card-dark border border-border-dark rounded-xl p-6 shadow-sm">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h3 className="text-white text-lg font-bold">Network Throughput</h3>
|
||||
<p className="text-text-secondary text-sm">Inbound vs Outbound (eth0)</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-white text-2xl font-bold leading-tight">
|
||||
{(currentThroughput.total / 1000).toFixed(1)} Gbps
|
||||
</p>
|
||||
<p className="text-emerald-500 text-sm">Peak: {(peakThroughput / 1000).toFixed(1)} Gbps</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[200px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={networkDataPoints} margin={{ top: 5, right: 10, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#324d67" opacity={0.3} />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="#92adc9"
|
||||
style={{ fontSize: '11px' }}
|
||||
tick={{ fill: '#92adc9' }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#92adc9"
|
||||
style={{ fontSize: '11px' }}
|
||||
tick={{ fill: '#92adc9' }}
|
||||
label={{ value: 'Mbps', angle: -90, position: 'insideLeft', fill: '#92adc9', style: { fontSize: '11px' } }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#1a2632',
|
||||
border: '1px solid #324d67',
|
||||
borderRadius: '8px',
|
||||
color: '#fff',
|
||||
}}
|
||||
labelStyle={{ color: '#92adc9', fontSize: '12px' }}
|
||||
itemStyle={{ color: '#fff', fontSize: '12px' }}
|
||||
formatter={(value: number) => [`${value} Mbps`, '']}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: '12px', color: '#92adc9' }}
|
||||
iconType="line"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="inbound"
|
||||
stroke="#137fec"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
name="Inbound"
|
||||
activeDot={{ r: 4 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="outbound"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
name="Outbound"
|
||||
activeDot={{ r: 4 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Storage Chart */}
|
||||
<div className="bg-card-dark border border-border-dark rounded-xl p-6 shadow-sm">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h3 className="text-white text-lg font-bold">Storage Capacity</h3>
|
||||
<p className="text-text-secondary text-sm">Repository usage</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-white text-2xl font-bold leading-tight">
|
||||
{storagePercent.toFixed(1)}%
|
||||
</p>
|
||||
<p className="text-text-secondary text-sm">Target: <90%</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[150px] w-full relative">
|
||||
<div className="w-full h-full bg-[#111a22] rounded flex items-center justify-center">
|
||||
<div className="w-full px-4">
|
||||
<div className="w-full bg-[#233648] h-2 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="bg-primary h-full rounded-full transition-all"
|
||||
style={{ width: `${storagePercent}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Storage Info Column (1/3) */}
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="bg-card-dark border border-border-dark rounded-xl p-6 h-full shadow-sm flex flex-col">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-white text-lg font-bold">Storage Overview</h3>
|
||||
<span className="bg-[#233648] text-white text-xs px-2 py-1 rounded border border-border-dark">
|
||||
{repositories?.length || 0} Repos
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-border-dark">
|
||||
<div className="flex justify-between text-sm text-text-secondary">
|
||||
<span>Total Capacity</span>
|
||||
<span className="text-white font-bold">{formatBytes(totalStorage)}</span>
|
||||
</div>
|
||||
<div className="w-full bg-[#233648] h-2 rounded-full mt-2 overflow-hidden">
|
||||
<div
|
||||
className="bg-primary h-full transition-all"
|
||||
style={{ width: `${storagePercent}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-text-secondary mt-1">
|
||||
<span>Used: {formatBytes(usedStorage)}</span>
|
||||
<span>Free: {formatBytes(totalStorage - usedStorage)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Section: Tabs & Logs */}
|
||||
<div className="bg-card-dark border border-border-dark rounded-xl shadow-sm overflow-hidden flex flex-col h-[400px]">
|
||||
{/* Tabs Header */}
|
||||
<div className="flex border-b border-border-dark bg-[#161f29]">
|
||||
<button
|
||||
onClick={() => setActiveTab('jobs')}
|
||||
className={`px-6 py-4 text-sm font-bold transition-colors ${
|
||||
activeTab === 'jobs'
|
||||
? 'text-primary border-b-2 border-primary bg-card-dark'
|
||||
: 'text-text-secondary hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Active Jobs{' '}
|
||||
{activeJobs.length > 0 && (
|
||||
<span className="ml-2 bg-primary/20 text-primary px-1.5 py-0.5 rounded text-xs">
|
||||
{activeJobs.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('logs')}
|
||||
className={`px-6 py-4 text-sm font-medium transition-colors ${
|
||||
activeTab === 'logs'
|
||||
? 'text-primary border-b-2 border-primary bg-card-dark'
|
||||
: 'text-text-secondary hover:text-white'
|
||||
}`}
|
||||
>
|
||||
System Logs
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('alerts')}
|
||||
className={`px-6 py-4 text-sm font-medium transition-colors ${
|
||||
activeTab === 'alerts'
|
||||
? 'text-primary border-b-2 border-primary bg-card-dark'
|
||||
: 'text-text-secondary hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Alerts History
|
||||
</button>
|
||||
<div className="flex-1 flex justify-end items-center px-4">
|
||||
<div className="relative">
|
||||
<Activity className="absolute left-2 top-1.5 text-text-secondary w-4 h-4" />
|
||||
<input
|
||||
className="bg-[#111a22] border border-border-dark rounded-md py-1 pl-8 pr-3 text-sm text-white focus:outline-none focus:border-primary w-48 transition-all"
|
||||
placeholder="Search logs..."
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
{activeTab === 'jobs' && (
|
||||
<div className="p-0">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead className="bg-[#1a2632] text-xs uppercase text-text-secondary font-medium sticky top-0 z-10">
|
||||
<tr>
|
||||
<th className="px-6 py-3 border-b border-border-dark">Job Name</th>
|
||||
<th className="px-6 py-3 border-b border-border-dark">Type</th>
|
||||
<th className="px-6 py-3 border-b border-border-dark w-1/3">Progress</th>
|
||||
<th className="px-6 py-3 border-b border-border-dark">Speed</th>
|
||||
<th className="px-6 py-3 border-b border-border-dark">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-sm divide-y divide-border-dark">
|
||||
{activeJobs.map((job) => (
|
||||
<tr key={job.id} className="group hover:bg-[#233648] transition-colors">
|
||||
<td className="px-6 py-4 font-medium text-white">{job.name}</td>
|
||||
<td className="px-6 py-4 text-text-secondary">{job.type}</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-full bg-[#111a22] rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className="bg-primary h-full rounded-full relative overflow-hidden"
|
||||
style={{ width: `${job.progress}%` }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-white/20 animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-white">{job.progress}%</span>
|
||||
</div>
|
||||
{job.eta && (
|
||||
<p className="text-[10px] text-text-secondary mt-1">ETA: {job.eta}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-text-secondary font-mono">{job.speed}</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-primary/20 text-primary">
|
||||
Running
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'logs' && (
|
||||
<>
|
||||
<div className="px-6 py-2 bg-[#161f29] border-y border-border-dark flex items-center justify-between">
|
||||
<h4 className="text-xs uppercase text-text-secondary font-bold tracking-wider">
|
||||
Recent System Events
|
||||
</h4>
|
||||
<button className="text-xs text-primary hover:text-white transition-colors">
|
||||
View All Logs
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar bg-[#111a22]">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<tbody className="text-sm font-mono divide-y divide-border-dark/50">
|
||||
{systemLogs.map((log, idx) => (
|
||||
<tr key={idx} className="group hover:bg-[#233648] transition-colors">
|
||||
<td className="px-6 py-2 text-text-secondary w-32 whitespace-nowrap">
|
||||
{log.time}
|
||||
</td>
|
||||
<td className="px-6 py-2 w-24">
|
||||
<span
|
||||
className={
|
||||
log.level === 'INFO'
|
||||
? 'text-emerald-500'
|
||||
: log.level === 'WARN'
|
||||
? 'text-yellow-500'
|
||||
: 'text-red-500'
|
||||
}
|
||||
>
|
||||
{log.level}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-2 w-32 text-white">{log.source}</td>
|
||||
<td className="px-6 py-2 text-text-secondary truncate max-w-lg">
|
||||
{log.message}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'alerts' && (
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar bg-[#111a22] p-6">
|
||||
{alerts?.alerts && alerts.alerts.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{alerts.alerts.map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className="bg-[#1a2632] border border-border-dark rounded-lg p-4 hover:bg-[#233648] transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<AlertTriangle
|
||||
className={`w-4 h-4 ${
|
||||
alert.severity === 'critical'
|
||||
? 'text-red-500'
|
||||
: alert.severity === 'warning'
|
||||
? 'text-yellow-500'
|
||||
: 'text-blue-500'
|
||||
}`}
|
||||
/>
|
||||
<h4 className="text-white font-medium">{alert.title}</h4>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
alert.severity === 'critical'
|
||||
? 'bg-red-500/20 text-red-400'
|
||||
: alert.severity === 'warning'
|
||||
? 'bg-yellow-500/20 text-yellow-400'
|
||||
: 'bg-blue-500/20 text-blue-400'
|
||||
}`}
|
||||
>
|
||||
{alert.severity}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-text-secondary text-sm">{alert.message}</p>
|
||||
<p className="text-text-secondary text-xs mt-2 font-mono">
|
||||
{new Date(alert.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<CheckCircle2 className="w-12 h-12 text-emerald-500 mx-auto mb-4" />
|
||||
<p className="text-text-secondary">No alerts</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,17 +25,17 @@ export default function ISCSITargetDetail() {
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-sm text-gray-500">Loading target details...</div>
|
||||
return <div className="text-sm text-text-secondary min-h-screen bg-background-dark p-6">Loading target details...</div>
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div className="text-sm text-red-500">Target not found</div>
|
||||
return <div className="text-sm text-red-400 min-h-screen bg-background-dark p-6">Target not found</div>
|
||||
}
|
||||
|
||||
const { target, luns } = data
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6 min-h-screen bg-background-dark p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
@@ -44,9 +44,9 @@ export default function ISCSITargetDetail() {
|
||||
Back
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 font-mono text-lg">{target.iqn}</h1>
|
||||
<h1 className="text-3xl font-bold text-white font-mono text-lg">{target.iqn}</h1>
|
||||
{target.alias && (
|
||||
<p className="mt-1 text-sm text-gray-600">{target.alias}</p>
|
||||
<p className="mt-1 text-sm text-text-secondary">{target.alias}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -70,14 +70,14 @@ export default function ISCSITargetDetail() {
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Status:</span>
|
||||
<span className={target.is_active ? 'text-green-600' : 'text-gray-600'}>
|
||||
<span className="text-text-secondary">Status:</span>
|
||||
<span className={target.is_active ? 'text-green-400' : 'text-text-secondary'}>
|
||||
{target.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">IQN:</span>
|
||||
<span className="font-mono text-xs">{target.iqn}</span>
|
||||
<span className="text-text-secondary">IQN:</span>
|
||||
<span className="font-mono text-xs text-white">{target.iqn}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -90,12 +90,12 @@ export default function ISCSITargetDetail() {
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Total LUNs:</span>
|
||||
<span className="font-medium">{luns.length}</span>
|
||||
<span className="text-text-secondary">Total LUNs:</span>
|
||||
<span className="font-medium text-white">{luns.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Active:</span>
|
||||
<span className="font-medium">
|
||||
<span className="text-text-secondary">Active:</span>
|
||||
<span className="font-medium text-white">
|
||||
{luns.filter((l) => l.is_active).length}
|
||||
</span>
|
||||
</div>
|
||||
@@ -150,38 +150,38 @@ export default function ISCSITargetDetail() {
|
||||
{luns.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<thead className="bg-[#1a2632]">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
||||
LUN #
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
||||
Handler
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
||||
Device Path
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
||||
Type
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
<tbody className="bg-card-dark divide-y divide-border-dark">
|
||||
{luns.map((lun) => (
|
||||
<tr key={lun.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<tr key={lun.id} className="hover:bg-[#233648]">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-white">
|
||||
{lun.lun_number}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">
|
||||
{lun.handler}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-xs">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-xs text-white">
|
||||
{lun.device_path}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">
|
||||
{lun.device_type}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
@@ -203,7 +203,7 @@ export default function ISCSITargetDetail() {
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<HardDrive className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-sm text-gray-500 mb-4">No LUNs configured</p>
|
||||
<p className="text-sm text-text-secondary mb-4">No LUNs configured</p>
|
||||
<Button variant="outline" onClick={() => setShowAddLUN(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add First LUN
|
||||
@@ -414,7 +414,7 @@ function AddInitiatorForm({ targetId, onClose, onSuccess }: AddInitiatorFormProp
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
<p className="mt-1 text-xs text-text-secondary">
|
||||
Format: iqn.YYYY-MM.reverse.domain:identifier
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -24,11 +24,11 @@ export default function ISCSITargets() {
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6 min-h-screen bg-background-dark p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">iSCSI Targets</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
<h1 className="text-3xl font-bold text-white">iSCSI Targets</h1>
|
||||
<p className="mt-2 text-sm text-text-secondary">
|
||||
Manage SCST iSCSI targets, LUNs, and initiators
|
||||
</p>
|
||||
</div>
|
||||
@@ -61,7 +61,7 @@ export default function ISCSITargets() {
|
||||
|
||||
{/* Targets List */}
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-gray-500">Loading targets...</p>
|
||||
<p className="text-sm text-text-secondary">Loading targets...</p>
|
||||
) : targets && targets.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{targets.map((target) => (
|
||||
@@ -72,8 +72,8 @@ export default function ISCSITargets() {
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<Server className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No iSCSI Targets</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
<h3 className="text-lg font-medium text-white mb-2">No iSCSI Targets</h3>
|
||||
<p className="text-sm text-text-secondary mb-4">
|
||||
Create your first iSCSI target to start exporting storage
|
||||
</p>
|
||||
<Button onClick={() => setShowCreateForm(true)}>
|
||||
@@ -111,14 +111,14 @@ function TargetCard({ target }: TargetCardProps) {
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Status:</span>
|
||||
<span className={target.is_active ? 'text-green-600' : 'text-gray-600'}>
|
||||
<span className="text-text-secondary">Status:</span>
|
||||
<span className={target.is_active ? 'text-green-400' : 'text-text-secondary'}>
|
||||
{target.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Created:</span>
|
||||
<span className="text-gray-700">
|
||||
<span className="text-text-secondary">Created:</span>
|
||||
<span className="text-white">
|
||||
{new Date(target.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
@@ -183,7 +183,7 @@ function CreateTargetForm({ onClose, onSuccess }: CreateTargetFormProps) {
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
<p className="mt-1 text-xs text-text-secondary">
|
||||
Format: iqn.YYYY-MM.reverse.domain:identifier
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -29,20 +29,20 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
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 className="min-h-screen flex items-center justify-center bg-background-dark">
|
||||
<div className="max-w-md w-full space-y-8 p-8 bg-card-dark border border-border-dark rounded-lg shadow-md">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-white">
|
||||
AtlasOS - Calypso
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
<p className="mt-2 text-center text-sm text-text-secondary">
|
||||
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 className="rounded-md bg-red-500/10 border border-red-500/30 p-4">
|
||||
<p className="text-sm text-red-400 font-medium">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
@@ -55,7 +55,7 @@ export default function LoginPage() {
|
||||
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"
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-border-dark bg-[#111a22] placeholder-text-secondary text-white rounded-t-md focus:outline-none focus:ring-primary focus:border-primary focus:z-10 sm:text-sm"
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
@@ -70,7 +70,7 @@ export default function LoginPage() {
|
||||
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"
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-border-dark bg-[#111a22] placeholder-text-secondary text-white rounded-b-md focus:outline-none focus:ring-primary focus:border-primary focus:z-10 sm:text-sm"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
@@ -82,9 +82,22 @@ export default function LoginPage() {
|
||||
<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"
|
||||
className="relative group relative w-full flex justify-center py-2.5 px-4 border border-primary/30 bg-card-dark text-white text-sm font-bold rounded-lg hover:bg-[#233648] transition-all overflow-hidden electric-glow electric-glow-border disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loginMutation.isPending ? 'Signing in...' : 'Sign in'}
|
||||
{/* Electric glow gradient overlay */}
|
||||
<span className="absolute inset-0 bg-gradient-to-r from-primary/0 via-primary/15 to-primary/0 opacity-60"></span>
|
||||
{/* Shimmer effect */}
|
||||
<span className="absolute inset-0 bg-gradient-to-r from-transparent via-white/5 to-transparent animate-[shimmer_3s_infinite]"></span>
|
||||
<span className="relative z-10 flex items-center gap-2">
|
||||
{loginMutation.isPending ? (
|
||||
<>
|
||||
<span className="material-symbols-outlined text-[18px] animate-spin">refresh</span>
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
'Sign in'
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,11 +22,11 @@ export default function TapeLibraries() {
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6 min-h-screen bg-background-dark p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Tape Libraries</h1>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
<h1 className="text-3xl font-bold text-white">Tape Libraries</h1>
|
||||
<p className="mt-2 text-sm text-text-secondary">
|
||||
Manage physical and virtual tape libraries
|
||||
</p>
|
||||
</div>
|
||||
@@ -49,14 +49,14 @@ export default function TapeLibraries() {
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200">
|
||||
<div className="border-b border-border-dark">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('vtl')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'vtl'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-text-secondary hover:text-white hover:border-border-dark'
|
||||
}`}
|
||||
>
|
||||
Virtual Tape Libraries ({vtlLibraries?.length || 0})
|
||||
@@ -65,8 +65,8 @@ export default function TapeLibraries() {
|
||||
onClick={() => setActiveTab('physical')}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'physical'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-text-secondary hover:text-white hover:border-border-dark'
|
||||
}`}
|
||||
>
|
||||
Physical Libraries ({physicalLibraries?.length || 0})
|
||||
@@ -78,7 +78,7 @@ export default function TapeLibraries() {
|
||||
{activeTab === 'vtl' && (
|
||||
<div>
|
||||
{loadingVTL ? (
|
||||
<p className="text-sm text-gray-500">Loading VTL libraries...</p>
|
||||
<p className="text-sm text-text-secondary">Loading VTL libraries...</p>
|
||||
) : vtlLibraries && vtlLibraries.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{vtlLibraries.map((library: VirtualTapeLibrary) => (
|
||||
@@ -89,8 +89,8 @@ export default function TapeLibraries() {
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<HardDrive className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No Virtual Tape Libraries</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
<h3 className="text-lg font-medium text-white mb-2">No Virtual Tape Libraries</h3>
|
||||
<p className="text-sm text-text-secondary mb-4">
|
||||
Create your first virtual tape library to get started
|
||||
</p>
|
||||
<Link to="/tape/vtl/create">
|
||||
@@ -108,7 +108,7 @@ export default function TapeLibraries() {
|
||||
{activeTab === 'physical' && (
|
||||
<div>
|
||||
{loadingPhysical ? (
|
||||
<p className="text-sm text-gray-500">Loading physical libraries...</p>
|
||||
<p className="text-sm text-text-secondary">Loading physical libraries...</p>
|
||||
) : physicalLibraries && physicalLibraries.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{physicalLibraries.map((library: PhysicalTapeLibrary) => (
|
||||
@@ -119,8 +119,8 @@ export default function TapeLibraries() {
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<HardDrive className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No Physical Tape Libraries</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
<h3 className="text-lg font-medium text-white mb-2">No Physical Tape Libraries</h3>
|
||||
<p className="text-sm text-text-secondary mb-4">
|
||||
Discover physical tape libraries connected to the system
|
||||
</p>
|
||||
<Button variant="outline">
|
||||
@@ -168,17 +168,17 @@ function LibraryCard({ library, type }: LibraryCardProps) {
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Slots:</span>
|
||||
<span className="font-medium">{library.slot_count}</span>
|
||||
<span className="text-text-secondary">Slots:</span>
|
||||
<span className="font-medium text-white">{library.slot_count}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Drives:</span>
|
||||
<span className="font-medium">{library.drive_count}</span>
|
||||
<span className="text-text-secondary">Drives:</span>
|
||||
<span className="font-medium text-white">{library.drive_count}</span>
|
||||
</div>
|
||||
{isPhysical && 'vendor' in library && library.vendor && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Vendor:</span>
|
||||
<span className="font-medium">{library.vendor} {library.model}</span>
|
||||
<span className="text-text-secondary">Vendor:</span>
|
||||
<span className="font-medium text-white">{library.vendor} {library.model}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -28,17 +28,17 @@ export default function VTLDetail() {
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-sm text-gray-500">Loading library details...</div>
|
||||
return <div className="text-sm text-text-secondary min-h-screen bg-background-dark p-6">Loading library details...</div>
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div className="text-sm text-red-500">Library not found</div>
|
||||
return <div className="text-sm text-red-400 min-h-screen bg-background-dark p-6">Library not found</div>
|
||||
}
|
||||
|
||||
const { library, drives, tapes } = data
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6 min-h-screen bg-background-dark p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
@@ -47,8 +47,8 @@ export default function VTLDetail() {
|
||||
Back
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">{library.name}</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
<h1 className="text-3xl font-bold text-white">{library.name}</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">
|
||||
Virtual Tape Library • {library.slot_count} slots • {library.drive_count} drives
|
||||
</p>
|
||||
</div>
|
||||
@@ -75,18 +75,18 @@ export default function VTLDetail() {
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Status:</span>
|
||||
<span className={library.is_active ? 'text-green-600' : 'text-gray-600'}>
|
||||
<span className="text-text-secondary">Status:</span>
|
||||
<span className={library.is_active ? 'text-green-400' : 'text-text-secondary'}>
|
||||
{library.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">mhVTL ID:</span>
|
||||
<span className="font-medium">{library.mhvtl_library_id}</span>
|
||||
<span className="text-text-secondary">mhVTL ID:</span>
|
||||
<span className="font-medium text-white">{library.mhvtl_library_id}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Storage Path:</span>
|
||||
<span className="font-mono text-xs">{library.storage_path}</span>
|
||||
<span className="text-text-secondary">Storage Path:</span>
|
||||
<span className="font-mono text-xs text-white">{library.storage_path}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -99,16 +99,16 @@ export default function VTLDetail() {
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Total Slots:</span>
|
||||
<span className="font-medium">{library.slot_count}</span>
|
||||
<span className="text-text-secondary">Total Slots:</span>
|
||||
<span className="font-medium text-white">{library.slot_count}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Used Slots:</span>
|
||||
<span className="font-medium">{tapes.length}</span>
|
||||
<span className="text-text-secondary">Used Slots:</span>
|
||||
<span className="font-medium text-white">{tapes.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Free Slots:</span>
|
||||
<span className="font-medium">{library.slot_count - tapes.length}</span>
|
||||
<span className="text-text-secondary">Free Slots:</span>
|
||||
<span className="font-medium text-white">{library.slot_count - tapes.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -121,18 +121,18 @@ export default function VTLDetail() {
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Total Drives:</span>
|
||||
<span className="font-medium">{library.drive_count}</span>
|
||||
<span className="text-text-secondary">Total Drives:</span>
|
||||
<span className="font-medium text-white">{library.drive_count}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Idle:</span>
|
||||
<span className="font-medium">
|
||||
<span className="text-text-secondary">Idle:</span>
|
||||
<span className="font-medium text-white">
|
||||
{drives.filter((d) => d.status === 'idle').length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Ready:</span>
|
||||
<span className="font-medium">
|
||||
<span className="text-text-secondary">Ready:</span>
|
||||
<span className="font-medium text-white">
|
||||
{drives.filter((d) => d.status === 'ready').length}
|
||||
</span>
|
||||
</div>
|
||||
@@ -169,7 +169,7 @@ export default function VTLDetail() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">No drives configured</p>
|
||||
<p className="text-sm text-text-secondary">No drives configured</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -194,35 +194,35 @@ export default function VTLDetail() {
|
||||
{tapes.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<thead className="bg-[#1a2632]">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
||||
Barcode
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
||||
Slot
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
||||
Size
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
<tbody className="bg-card-dark divide-y divide-border-dark">
|
||||
{tapes.map((tape) => (
|
||||
<tr key={tape.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<tr key={tape.id} className="hover:bg-[#233648]">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-white">
|
||||
{tape.barcode}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">
|
||||
{tape.slot_number}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-text-secondary">
|
||||
{formatBytes(tape.size_bytes)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
@@ -259,7 +259,7 @@ export default function VTLDetail() {
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">No tapes created yet</p>
|
||||
<p className="text-sm text-text-secondary">No tapes created yet</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -299,12 +299,12 @@ function DriveCard({ drive, tapes, isSelected, onSelect }: DriveCardProps) {
|
||||
</span>
|
||||
</div>
|
||||
{currentTape ? (
|
||||
<div className="text-sm text-gray-600">
|
||||
<p>Tape: {currentTape.barcode}</p>
|
||||
<p className="text-xs text-gray-500">{formatBytes(currentTape.size_bytes)}</p>
|
||||
<div className="text-sm text-text-secondary">
|
||||
<p className="text-white">Tape: {currentTape.barcode}</p>
|
||||
<p className="text-xs text-text-secondary">{formatBytes(currentTape.size_bytes)}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">No tape loaded</p>
|
||||
<p className="text-sm text-text-secondary">No tape loaded</p>
|
||||
)}
|
||||
{isSelected && (
|
||||
<div className="mt-2 pt-2 border-t">
|
||||
|
||||
@@ -14,7 +14,7 @@ export default {
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
DEFAULT: "#137fec",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
@@ -41,11 +41,20 @@ export default {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
// Dark theme colors from example
|
||||
"background-dark": "#111a22",
|
||||
"card-dark": "#1a2632",
|
||||
"border-dark": "#324d67",
|
||||
"text-secondary": "#92adc9",
|
||||
},
|
||||
fontFamily: {
|
||||
display: ["Manrope", "sans-serif"],
|
||||
mono: ["JetBrains Mono", "monospace"],
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
lg: "0.5rem",
|
||||
xl: "0.75rem",
|
||||
DEFAULT: "0.25rem",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user