Visual redesign: design system, refined ops console aesthetic
- Full component library (Button, Input, Label, Select, Modal, Table, Badge, Card, etc.) - Tailwind design tokens: IBM Plex Sans + JetBrains Mono, teal accent, semantic status colors - NavBar with logo, responsive hamburger menu, real logout - All pages redesigned: Login, Dashboard (KPI cards), Machines, SyncPairs, JobHistory, JobDetail, SSHKeys, Settings - Fixed: hover:bg-gray-750 dead class, window.location.href navigation bug - Replaced alert()/confirm() with sonner toasts and accessible modals - Added ErrorBoundary, skip link, accessible modal dialogs (Radix) - Icons: lucide-react throughout, copy/download buttons - 1.0.5 → 1.0.6
This commit is contained in:
+188
-45
@@ -1,14 +1,48 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate, NavLink, Outlet } from 'react-router-dom';
|
||||
import {
|
||||
BrowserRouter,
|
||||
Routes,
|
||||
Route,
|
||||
Navigate,
|
||||
NavLink,
|
||||
Outlet,
|
||||
useNavigate,
|
||||
} from 'react-router-dom';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Toaster } from 'sonner';
|
||||
import {
|
||||
Database,
|
||||
LayoutDashboard,
|
||||
Server,
|
||||
GitCompare,
|
||||
History,
|
||||
Key,
|
||||
Settings as SettingsIcon,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import { Spinner } from './components/ui/Spinner';
|
||||
import { Button } from './components/ui/Button';
|
||||
import { cn } from './lib/utils';
|
||||
import Login from './pages/Login';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Machines from './pages/Machines';
|
||||
import SyncPairs from './pages/SyncPairs';
|
||||
import JobHistory from './pages/JobHistory';
|
||||
import JobDetail from './pages/JobDetail';
|
||||
import Settings from './pages/Settings';
|
||||
import SettingsPage from './pages/Settings';
|
||||
import SSHKeys from './pages/SSHKeys';
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ to: '/machines', label: 'Machines', icon: Server },
|
||||
{ to: '/sync-pairs', label: 'Sync Pairs', icon: GitCompare },
|
||||
{ to: '/jobs', label: 'Jobs', icon: History },
|
||||
{ to: '/ssh-keys', label: 'SSH Keys', icon: Key },
|
||||
{ to: '/settings', label: 'Settings', icon: SettingsIcon },
|
||||
];
|
||||
|
||||
function ProtectedRoute({ children }: { children: JSX.Element }) {
|
||||
const [authed, setAuthed] = useState<boolean | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -16,54 +50,155 @@ function ProtectedRoute({ children }: { children: JSX.Element }) {
|
||||
.then(r => setAuthed(r.ok))
|
||||
.catch(() => setAuthed(false));
|
||||
}, []);
|
||||
if (authed === null) return <div className="p-4">Loading...</div>;
|
||||
if (authed === null) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-canvas">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return authed ? children : <Navigate to="/login" />;
|
||||
}
|
||||
|
||||
function NavBar() {
|
||||
const navItems = [
|
||||
{ to: '/', label: 'Dashboard' },
|
||||
{ to: '/machines', label: 'Machines' },
|
||||
{ to: '/sync-pairs', label: 'Sync Pairs' },
|
||||
{ to: '/jobs', label: 'Jobs' },
|
||||
{ to: '/ssh-keys', label: 'SSH Keys' },
|
||||
{ to: '/settings', label: 'Settings' },
|
||||
];
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
|
||||
} catch {}
|
||||
localStorage.removeItem('auth');
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="sticky top-0 z-50 bg-gray-900 border-b border-gray-700">
|
||||
<header className="sticky top-0 z-50 bg-surface border-b border-border">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="flex items-center h-14 gap-1">
|
||||
<span className="text-white font-bold text-lg mr-4">SyncServer</span>
|
||||
{navItems.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'text-white bg-gray-800'
|
||||
: 'text-gray-400 hover:text-white hover:bg-gray-800'
|
||||
}`
|
||||
}
|
||||
<div className="flex items-center h-14 gap-6">
|
||||
<a
|
||||
href="/"
|
||||
className="flex items-center gap-2.5 text-fg font-bold text-base hover:text-accent transition-colors shrink-0"
|
||||
>
|
||||
<div className="rounded-card bg-accent/10 p-1">
|
||||
<Database className="h-4 w-4 text-accent" />
|
||||
</div>
|
||||
<span className="hidden sm:inline">SyncServer</span>
|
||||
</a>
|
||||
|
||||
<nav className="hidden md:flex items-center gap-0.5 flex-1">
|
||||
{navItems.map(item => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-card text-sm font-medium transition-all duration-150',
|
||||
isActive
|
||||
? 'bg-surface-raised text-fg'
|
||||
: 'text-fg-muted hover:text-fg hover:bg-surface-raised'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={handleLogout}
|
||||
className="text-fg-muted hover:text-fg hidden sm:flex"
|
||||
title="Logout"
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
<LogOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setMobileOpen(v => !v)}
|
||||
className="md:hidden text-fg-muted"
|
||||
>
|
||||
{mobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mobileOpen && (
|
||||
<nav className="md:hidden pb-3 flex flex-col gap-0.5 animate-slide-up">
|
||||
{navItems.map(item => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-card text-sm font-medium transition-all',
|
||||
isActive
|
||||
? 'bg-surface-raised text-fg'
|
||||
: 'text-fg-muted hover:text-fg hover:bg-surface-raised'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => {
|
||||
setMobileOpen(false);
|
||||
handleLogout();
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-card text-sm font-medium text-fg-muted hover:text-fg hover:bg-surface-raised mt-1 border-t border-border pt-3"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Logout
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function Layout() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900">
|
||||
<div className="min-h-screen bg-canvas">
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:rounded-card focus:bg-surface-raised focus:text-fg focus:outline-none focus:ring-2 focus:ring-accent/40"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
<NavBar />
|
||||
<main>
|
||||
<main id="main-content" className="max-w-7xl mx-auto px-4 py-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
error: 'bg-surface-raised border border-rose-500/30 text-fg',
|
||||
success: 'bg-surface-raised border border-emerald-500/30 text-fg',
|
||||
warning: 'bg-surface-raised border border-amber-500/30 text-fg',
|
||||
info: 'bg-surface-raised border border-sky-500/30 text-fg',
|
||||
},
|
||||
}}
|
||||
theme="dark"
|
||||
richColors
|
||||
closeButton
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -71,19 +206,27 @@ function Layout() {
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route element={<ProtectedRoute><Layout /></ProtectedRoute>}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/machines" element={<Machines />} />
|
||||
<Route path="/sync-pairs" element={<SyncPairs />} />
|
||||
<Route path="/jobs" element={<JobHistory />} />
|
||||
<Route path="/jobs/:id" element={<JobDetail />} />
|
||||
<Route path="/ssh-keys" element={<SSHKeys />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
<ErrorBoundary>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/machines" element={<Machines />} />
|
||||
<Route path="/sync-pairs" element={<SyncPairs />} />
|
||||
<Route path="/jobs" element={<JobHistory />} />
|
||||
<Route path="/jobs/:id" element={<JobDetail />} />
|
||||
<Route path="/ssh-keys" element={<SSHKeys />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import * as React from 'react'
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react'
|
||||
import { Button } from './ui/Button'
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: React.ReactNode
|
||||
fallback?: React.ReactNode
|
||||
}
|
||||
|
||||
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('ErrorBoundary caught:', error, errorInfo)
|
||||
}
|
||||
|
||||
handleReload = () => {
|
||||
this.setState({ hasError: false, error: null })
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-4 p-8 text-center">
|
||||
<div className="rounded-full bg-rose-500/10 p-4 text-rose-400">
|
||||
<AlertTriangle className="h-8 w-8" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-lg font-semibold text-fg">Something went wrong</h2>
|
||||
<p className="text-sm text-fg-muted max-w-md">
|
||||
{this.state.error?.message ?? 'An unexpected error occurred'}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={this.handleReload} className="gap-2">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Reload page
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
export { ErrorBoundary }
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { badgeVariants, type BadgeVariant } from '@/lib/status'
|
||||
import { statusLabel } from '@/lib/status'
|
||||
|
||||
interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLSpanElement>,
|
||||
VariantProps<typeof badgeVariants> {
|
||||
label?: string
|
||||
}
|
||||
|
||||
const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(
|
||||
({ className, variant, label, children, ...props }, ref) => {
|
||||
const displayLabel = label ?? (typeof variant === 'string' ? statusLabel(variant) : children)
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
>
|
||||
{displayLabel}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
)
|
||||
Badge.displayName = 'Badge'
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
export type { BadgeVariant }
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 rounded-card font-medium transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-canvas disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary:
|
||||
'bg-accent text-accent-foreground hover:bg-accent-hover shadow-sm hover:shadow-glow',
|
||||
secondary:
|
||||
'bg-surface-raised text-fg border border-border hover:bg-surface-hover hover:border-border/80',
|
||||
ghost:
|
||||
'text-fg-muted hover:text-fg hover:bg-surface-raised',
|
||||
danger:
|
||||
'bg-rose-500/15 text-rose-400 border border-rose-500/30 hover:bg-rose-500/25 hover:border-rose-500/50 hover:shadow-glow-error',
|
||||
'danger-solid':
|
||||
'bg-rose-500 text-white hover:bg-rose-600 shadow-sm',
|
||||
link:
|
||||
'text-accent underline-offset-4 hover:underline hover:text-accent-hover',
|
||||
},
|
||||
size: {
|
||||
sm: 'h-8 px-3 text-xs',
|
||||
md: 'h-9 px-4 text-sm',
|
||||
lg: 'h-11 px-6 text-base',
|
||||
icon: 'h-9 w-9',
|
||||
'icon-sm': 'h-8 w-8',
|
||||
'icon-lg': 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
loading?: boolean
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, loading, disabled, asChild = false, children, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
disabled={asChild ? undefined : (disabled || loading)}
|
||||
{...props}
|
||||
>
|
||||
{loading && !asChild && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{children}
|
||||
</Comp>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,75 @@
|
||||
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-card border border-border bg-surface shadow-card',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = 'Card'
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col gap-1 p-5', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLHeadingElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('text-sm font-semibold text-fg', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-xs text-fg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
const CardBody = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-5 pt-0', className)} {...props} />
|
||||
))
|
||||
CardBody.displayName = 'CardBody'
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center gap-3 p-5 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardBody, CardFooter }
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as React from 'react'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import { Button } from './Button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CopyButtonProps
|
||||
extends Omit<React.ComponentProps<typeof Button>, 'variant' | 'size' | 'children'> {
|
||||
text: string
|
||||
displayText?: string
|
||||
successDuration?: number
|
||||
}
|
||||
|
||||
const CopyButton = React.forwardRef<HTMLButtonElement, CopyButtonProps>(
|
||||
(
|
||||
{ className, text, displayText, successDuration = 2000, ...props },
|
||||
ref
|
||||
) => {
|
||||
const [copied, setCopied] = React.useState(false)
|
||||
|
||||
const handleCopy = React.useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), successDuration)
|
||||
} catch {
|
||||
}
|
||||
}, [text, successDuration])
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className={cn('gap-1.5 text-fg-muted hover:text-fg', className)}
|
||||
{...props}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-3.5 w-3.5 text-emerald-400" />
|
||||
<span className="text-emerald-400">Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
<span>{displayText ?? 'Copy'}</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
)
|
||||
CopyButton.displayName = 'CopyButton'
|
||||
|
||||
export { CopyButton }
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface EmptyStateProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
icon?: React.ReactNode
|
||||
title: string
|
||||
description?: string
|
||||
action?: React.ReactNode
|
||||
}
|
||||
|
||||
const EmptyState = React.forwardRef<HTMLDivElement, EmptyStateProps>(
|
||||
({ className, icon, title, description, action, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center gap-3 py-12 text-center',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{icon && (
|
||||
<div className="rounded-full bg-surface-raised p-3 text-fg-subtle">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-fg">{title}</p>
|
||||
{description && (
|
||||
<p className="text-xs text-fg-muted max-w-xs">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{action && <div className="mt-1">{action}</div>}
|
||||
</div>
|
||||
)
|
||||
)
|
||||
EmptyState.displayName = 'EmptyState'
|
||||
|
||||
export { EmptyState }
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
error?: boolean
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, error, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-card border bg-surface-raised px-3 py-2 text-sm text-fg placeholder:text-fg-subtle transition-colors',
|
||||
'border-border hover:border-border/80',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent/50',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
error && 'border-rose-500/50 focus:ring-rose-500/30 focus:border-rose-500/50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
|
||||
({ className, required, children, ...props }, ref) => (
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-sm font-medium text-fg-muted leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{required && <span className="ml-1 text-rose-400">*</span>}
|
||||
</label>
|
||||
)
|
||||
)
|
||||
Label.displayName = 'Label'
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,132 @@
|
||||
import * as React from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Modal = DialogPrimitive.Root
|
||||
const ModalTrigger = DialogPrimitive.Trigger
|
||||
const ModalPortal = DialogPrimitive.Portal
|
||||
const ModalClose = DialogPrimitive.Close
|
||||
|
||||
const ModalOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
|
||||
'data-[state=open]:animate-fade-in',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ModalOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const ModalContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
}
|
||||
>(({ className, children, size = 'md', ...props }, ref) => {
|
||||
const sizes = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-xl',
|
||||
}
|
||||
return (
|
||||
<ModalPortal>
|
||||
<ModalOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%]',
|
||||
'w-full max-h-[90vh] overflow-y-auto rounded-card border border-border bg-surface shadow-card animate-scale-in',
|
||||
'focus:outline-none',
|
||||
sizes[size],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-accent/40 focus:ring-offset-2 focus:ring-offset-surface disabled:pointer-events-none">
|
||||
<X className="h-4 w-4 text-fg-muted" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</ModalPortal>
|
||||
)
|
||||
})
|
||||
ModalContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const ModalHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col gap-1.5 p-5 pb-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ModalHeader.displayName = 'ModalHeader'
|
||||
|
||||
const ModalTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-base font-semibold text-fg', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ModalTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const ModalDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-fg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ModalDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
const ModalBody = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-5', className)} {...props} />
|
||||
))
|
||||
ModalBody.displayName = 'ModalBody'
|
||||
|
||||
const ModalFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center justify-end gap-3 p-5 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ModalFooter.displayName = 'ModalFooter'
|
||||
|
||||
export {
|
||||
Modal,
|
||||
ModalPortal,
|
||||
ModalOverlay,
|
||||
ModalTrigger,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
ModalDescription,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface PageHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
title: string
|
||||
description?: string
|
||||
actions?: React.ReactNode
|
||||
}
|
||||
|
||||
const PageHeader = React.forwardRef<HTMLDivElement, PageHeaderProps>(
|
||||
({ className, title, description, actions, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-2xl font-bold text-fg tracking-tight">{title}</h1>
|
||||
{description && (
|
||||
<p className="mt-1 text-sm text-fg-muted">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{actions && (
|
||||
<div className="flex items-center gap-2 mt-3 sm:mt-0 shrink-0">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)
|
||||
PageHeader.displayName = 'PageHeader'
|
||||
|
||||
export { PageHeader }
|
||||
@@ -0,0 +1,95 @@
|
||||
import * as React from 'react'
|
||||
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||
import { ChevronDown, Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center justify-between rounded-card border border-border bg-surface-raised px-3 py-2 text-sm text-fg placeholder:text-fg-subtle transition-colors',
|
||||
'hover:border-border/80 focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent/50',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
' [&>span]:line-clamp-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 text-fg-subtle" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-card border border-border bg-surface shadow-card animate-scale-in',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-pointer select-none items-center rounded px-2 py-1.5 text-sm text-fg-muted outline-none',
|
||||
'hover:bg-surface-raised hover:text-fg',
|
||||
'focus:bg-surface-raised focus:text-fg',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4 text-accent" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: 'text' | 'circular' | 'rectangular'
|
||||
width?: string | number
|
||||
height?: string | number
|
||||
}
|
||||
|
||||
const Skeleton = React.forwardRef<HTMLDivElement, SkeletonProps>(
|
||||
({ className, variant = 'rectangular', width, height, style, ...props }, ref) => {
|
||||
const baseClass =
|
||||
variant === 'circular'
|
||||
? 'rounded-full'
|
||||
: variant === 'text'
|
||||
? 'rounded'
|
||||
: 'rounded-card'
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'animate-pulse bg-surface-raised',
|
||||
baseClass,
|
||||
className
|
||||
)}
|
||||
style={{ width, height, ...style }}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Skeleton.displayName = 'Skeleton'
|
||||
|
||||
export { Skeleton }
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Spinner = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & { size?: 'sm' | 'md' | 'lg' }
|
||||
>(({ className, size = 'md', ...props }, ref) => {
|
||||
const sizes = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-6 w-6',
|
||||
lg: 'h-8 w-8',
|
||||
}
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
className={cn('animate-spin text-accent', sizes[size], className)}
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-full w-full"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
Spinner.displayName = 'Spinner'
|
||||
|
||||
export { Spinner }
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as React from 'react'
|
||||
import * as SwitchPrimitive from '@radix-ui/react-switch'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-canvas',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'data-[state=checked]:bg-accent data-[state=unchecked]:bg-surface-raised data-[state=unchecked]:border-border',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-4 w-4 rounded-full bg-fg-subtle shadow-lg ring-0 transition-transform',
|
||||
'translate-x-0 data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitive.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
@@ -0,0 +1,118 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = 'Table'
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = 'TableHeader'
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn('[&_tr:last-child]:border-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = 'TableBody'
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-t bg-surface-raised font-medium [&>tr]:last:border-b-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = 'TableFooter'
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-b border-border/50 transition-colors',
|
||||
'hover:bg-surface-raised/50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = 'TableRow'
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-10 px-4 text-left align-middle text-xs font-semibold text-fg-muted uppercase tracking-wider',
|
||||
'[&:has([role=checkbox])]:pr-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = 'TableHead'
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn('px-4 py-3 align-middle text-sm text-fg', '[&:has([role=checkbox])]:pr-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = 'TableCell'
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn('mt-4 text-sm text-fg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = 'TableCaption'
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
error?: boolean
|
||||
}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, error, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[80px] w-full rounded-card border bg-surface-raised px-3 py-2 text-sm text-fg placeholder:text-fg-subtle transition-colors resize-none',
|
||||
'border-border hover:border-border/80',
|
||||
'focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent/50',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
error && 'border-rose-500/50 focus:ring-rose-500/30 focus:border-rose-500/50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = 'Textarea'
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,36 @@
|
||||
export { Button, buttonVariants } from './Button'
|
||||
export { Badge, badgeVariants } from './Badge'
|
||||
export { Spinner } from './Spinner'
|
||||
export { Skeleton } from './Skeleton'
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardBody, CardFooter } from './Card'
|
||||
export { Input } from './Input'
|
||||
export { Label } from './Label'
|
||||
export { Textarea } from './Textarea'
|
||||
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem } from './Select'
|
||||
export { Switch } from './Switch'
|
||||
export {
|
||||
Modal,
|
||||
ModalPortal,
|
||||
ModalOverlay,
|
||||
ModalTrigger,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
ModalDescription,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
} from './Modal'
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
} from './Table'
|
||||
export { EmptyState } from './EmptyState'
|
||||
export { PageHeader } from './PageHeader'
|
||||
export { CopyButton } from './CopyButton'
|
||||
@@ -1,3 +1,64 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-canvas text-fg font-sans;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
::selection {
|
||||
@apply bg-accent/30 text-fg;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
@apply outline-none ring-2 ring-accent/40 ring-offset-2 ring-offset-canvas;
|
||||
}
|
||||
|
||||
input[type='search']::-webkit-search-decoration,
|
||||
input[type='search']::-webkit-search-cancel-button,
|
||||
input[type='search']::-webkit-search-results-button,
|
||||
input[type='search']::-webkit-search-results-decoration {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #3f3f46 transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: #3f3f46;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #52525b;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
export const badgeVariants = cva(
|
||||
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
running: 'bg-emerald-500/15 text-emerald-400 border border-emerald-500/30',
|
||||
pending: 'bg-amber-500/15 text-amber-400 border border-amber-500/30',
|
||||
waking: 'bg-amber-500/15 text-amber-400 border border-amber-500/30',
|
||||
success: 'bg-emerald-500/15 text-emerald-400 border border-emerald-500/30',
|
||||
error: 'bg-rose-500/15 text-rose-400 border border-rose-500/30',
|
||||
info: 'bg-sky-500/15 text-sky-400 border border-sky-500/30',
|
||||
neutral: 'bg-zinc-500/15 text-zinc-400 border border-zinc-500/30',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'neutral',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export type BadgeVariant = VariantProps<typeof badgeVariants>['variant']
|
||||
|
||||
const STATUS_TO_VARIANT: Record<string, BadgeVariant> = {
|
||||
running: 'running',
|
||||
pending: 'pending',
|
||||
waking: 'waking',
|
||||
success: 'success',
|
||||
error: 'error',
|
||||
failed: 'error',
|
||||
completed: 'success',
|
||||
cancelled: 'neutral',
|
||||
info: 'info',
|
||||
unknown: 'neutral',
|
||||
}
|
||||
|
||||
export function statusVariant(status: string): BadgeVariant {
|
||||
return STATUS_TO_VARIANT[status.toLowerCase()] ?? 'neutral'
|
||||
}
|
||||
|
||||
export function statusLabel(status: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
running: 'Running',
|
||||
pending: 'Pending',
|
||||
waking: 'Waking',
|
||||
success: 'Success',
|
||||
error: 'Error',
|
||||
failed: 'Failed',
|
||||
completed: 'Completed',
|
||||
cancelled: 'Cancelled',
|
||||
info: 'Info',
|
||||
unknown: 'Unknown',
|
||||
}
|
||||
return labels[status.toLowerCase()] ?? status
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
const seconds = Math.floor(ms / 1000)
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = seconds % 60
|
||||
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const remainingMinutes = minutes % 60
|
||||
return `${hours}h ${remainingMinutes}m`
|
||||
}
|
||||
|
||||
export function formatRelativeTime(date: Date | string): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - d.getTime()
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
if (seconds < 60) return 'just now'
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 7) return `${days}d ago`
|
||||
return d.toLocaleDateString()
|
||||
}
|
||||
+147
-61
@@ -1,18 +1,33 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Server, Activity, HardDrive, Clock, Plus } from 'lucide-react';
|
||||
import { api, Machine, Job } from '../api/client';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card, CardBody } from '@/components/ui/Card';
|
||||
import { PageHeader } from '@/components/ui/PageHeader';
|
||||
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/Table';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
import { statusVariant, statusLabel } from '@/lib/status';
|
||||
import { formatRelativeTime } from '@/lib/utils';
|
||||
|
||||
export default function Dashboard() {
|
||||
const [machines, setMachines] = useState<Machine[]>([]);
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
api<Machine[]>('/api/machines'),
|
||||
api<Job[]>('/api/jobs?limit=5'),
|
||||
]).then(([m, j]) => {
|
||||
setMachines(m);
|
||||
setJobs(j);
|
||||
}).catch(() => {});
|
||||
])
|
||||
.then(([m, j]) => {
|
||||
setMachines(m);
|
||||
setJobs(j);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const online = machines.filter(m => m.status.startsWith('online')).length;
|
||||
@@ -20,67 +35,138 @@ export default function Dashboard() {
|
||||
if (!j.started_at) return false;
|
||||
return j.started_at.startsWith(new Date().toISOString().split('T')[0]);
|
||||
}).length;
|
||||
const runningJobs = jobs.filter(j =>
|
||||
['running', 'queued', 'waking_up'].includes(j.status)
|
||||
).length;
|
||||
|
||||
const kpis = [
|
||||
{
|
||||
label: 'Total Machines',
|
||||
value: machines.length,
|
||||
icon: Server,
|
||||
className: 'text-sky-400',
|
||||
bgClass: 'bg-sky-500/10',
|
||||
},
|
||||
{
|
||||
label: 'Online',
|
||||
value: online,
|
||||
icon: Activity,
|
||||
className: 'text-emerald-400',
|
||||
bgClass: 'bg-emerald-500/10',
|
||||
accent: online > 0,
|
||||
},
|
||||
{
|
||||
label: 'Jobs Today',
|
||||
value: todayJobs,
|
||||
icon: Clock,
|
||||
className: 'text-amber-400',
|
||||
bgClass: 'bg-amber-500/10',
|
||||
},
|
||||
{
|
||||
label: 'Running',
|
||||
value: runningJobs,
|
||||
icon: HardDrive,
|
||||
className: 'text-accent',
|
||||
bgClass: 'bg-accent/10',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
|
||||
<div className="grid grid-cols-3 gap-4 mb-8">
|
||||
<div className="bg-gray-800 rounded-lg p-4">
|
||||
<div className="text-gray-400 text-sm">Machines</div>
|
||||
<div className="text-3xl font-bold">{machines.length}</div>
|
||||
</div>
|
||||
<div className="bg-gray-800 rounded-lg p-4">
|
||||
<div className="text-gray-400 text-sm">Online</div>
|
||||
<div className="text-3xl font-bold text-green-500">{online}</div>
|
||||
</div>
|
||||
<div className="bg-gray-800 rounded-lg p-4">
|
||||
<div className="text-gray-400 text-sm">Jobs Today</div>
|
||||
<div className="text-3xl font-bold text-blue-500">{todayJobs}</div>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
description="Overview of your sync infrastructure"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{loading
|
||||
? Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardBody>
|
||||
<Skeleton className="h-4 w-20 mb-3" />
|
||||
<Skeleton className="h-8 w-12" />
|
||||
</CardBody>
|
||||
</Card>
|
||||
))
|
||||
: kpis.map(kpi => {
|
||||
const Icon = kpi.icon;
|
||||
return (
|
||||
<Card key={kpi.label} className="transition-shadow hover:shadow-card-hover">
|
||||
<CardBody>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<span className="text-xs font-medium text-fg-muted uppercase tracking-wider">
|
||||
{kpi.label}
|
||||
</span>
|
||||
<div className={`rounded-card p-1.5 ${kpi.bgClass}`}>
|
||||
<Icon className={`h-3.5 w-3.5 ${kpi.className}`} />
|
||||
</div>
|
||||
</div>
|
||||
<p className={`text-3xl font-bold ${kpi.className}`}>
|
||||
{kpi.value}
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="bg-gray-800 rounded-lg p-4">
|
||||
<h2 className="text-lg font-semibold mb-3">Recent Jobs</h2>
|
||||
{jobs.length === 0 ? <p className="text-gray-500">No jobs yet</p> : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-400 border-b border-gray-700">
|
||||
<th className="pb-2">ID</th>
|
||||
<th className="pb-2">Sync Pair</th>
|
||||
<th className="pb-2">Status</th>
|
||||
<th className="pb-2">Started</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map(j => (
|
||||
<tr key={j.id} className="border-b border-gray-700/50">
|
||||
<td className="py-2">{j.id}</td>
|
||||
<td className="py-2">{j.sync_pair_id}</td>
|
||||
<td className="py-2">
|
||||
<StatusBadge status={j.status} />
|
||||
</td>
|
||||
<td className="py-2">{j.started_at ? new Date(j.started_at).toLocaleString() : '-'}</td>
|
||||
</tr>
|
||||
|
||||
<Card>
|
||||
<div className="p-5 pb-0 flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-fg">Recent Jobs</h2>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to="/jobs">View all</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : jobs.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<HardDrive className="h-5 w-5" />}
|
||||
title="No jobs yet"
|
||||
description="Sync pairs will appear here once jobs are executed"
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Sync Pair</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Started</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{jobs.map(j => (
|
||||
<TableRow key={j.id}>
|
||||
<TableCell>
|
||||
<Link
|
||||
to={`/jobs/${j.id}`}
|
||||
className="text-accent hover:text-accent-hover font-mono text-xs"
|
||||
>
|
||||
#{j.id}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-fg-muted">
|
||||
Pair {j.sync_pair_id}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant(j.status)} label={statusLabel(j.status)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-fg-muted text-xs">
|
||||
{j.started_at ? formatRelativeTime(j.started_at) : '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
queued: 'bg-gray-600',
|
||||
waking_up: 'bg-yellow-600',
|
||||
running: 'bg-blue-600',
|
||||
success: 'bg-green-600',
|
||||
failed: 'bg-red-600',
|
||||
cancelled: 'bg-gray-600',
|
||||
};
|
||||
return (
|
||||
<span className={`${colors[status] || 'bg-gray-600'} text-white text-xs px-2 py-0.5 rounded`}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
+255
-79
@@ -1,6 +1,32 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { api, Job, LogLine, SyncPair } from '../api/client';
|
||||
import { api } from '../api/client';
|
||||
import type { Job, LogLine, SyncPair } from '../api/client';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Switch } from '@/components/ui/Switch';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Spinner } from '@/components/ui/Spinner';
|
||||
import { CopyButton } from '@/components/ui/CopyButton';
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
ModalDescription,
|
||||
ModalFooter,
|
||||
} from '@/components/ui/Modal';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Download,
|
||||
XCircle,
|
||||
ScrollText,
|
||||
Terminal,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { statusVariant, statusLabel } from '@/lib/status';
|
||||
import { formatDuration } from '@/lib/utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SSEEvent {
|
||||
type: string;
|
||||
@@ -15,22 +41,26 @@ export default function JobDetail() {
|
||||
const [job, setJob] = useState<Job | null>(null);
|
||||
const [pair, setPair] = useState<SyncPair | null>(null);
|
||||
const [logs, setLogs] = useState<LogLine[]>([]);
|
||||
const [lines, setLines] = useState<{ stream: string; text: string }[]>([]);
|
||||
const [liveLines, setLiveLines] = useState<{ stream: string; text: string }[]>([]);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
const esRef = useRef<EventSource | null>(null);
|
||||
const jobId = Number(id);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [cancelModal, setCancelModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadJob();
|
||||
if (jobId) {
|
||||
loadLogs(0);
|
||||
const es = new EventSource(`/api/jobs/${jobId}/log/stream?job_id=${jobId}`);
|
||||
const es = new EventSource(
|
||||
`/api/jobs/${jobId}/log/stream?job_id=${jobId}`
|
||||
);
|
||||
esRef.current = es;
|
||||
es.onmessage = (e) => {
|
||||
const evt: SSEEvent = JSON.parse(e.data);
|
||||
if (evt.type === 'log') {
|
||||
setLines(prev => [...prev, { stream: evt.stream!, text: evt.line! }]);
|
||||
setLiveLines(prev => [...prev, { stream: evt.stream!, text: evt.line! }]);
|
||||
}
|
||||
if (evt.type === 'status') {
|
||||
setJob(prev => prev ? { ...prev, status: evt.status! } : prev);
|
||||
@@ -44,7 +74,7 @@ export default function JobDetail() {
|
||||
if (autoScroll && logEndRef.current) {
|
||||
logEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [lines, autoScroll]);
|
||||
}, [liveLines, autoScroll]);
|
||||
|
||||
async function loadJob() {
|
||||
try {
|
||||
@@ -53,12 +83,17 @@ export default function JobDetail() {
|
||||
const pairs = await api<SyncPair[]>('/api/sync-pairs');
|
||||
const p = pairs.find((sp: SyncPair) => sp.id === j.sync_pair_id);
|
||||
setPair(p || null);
|
||||
} catch {}
|
||||
} catch {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLogs(offset: number) {
|
||||
try {
|
||||
const ls = await api<LogLine[]>(`/api/jobs/${id}/log?offset=${offset}&limit=1000`);
|
||||
const ls = await api<LogLine[]>(
|
||||
`/api/jobs/${id}/log?offset=${offset}&limit=1000`
|
||||
);
|
||||
if (offset === 0) {
|
||||
setLogs(ls);
|
||||
} else {
|
||||
@@ -68,100 +103,241 @@ export default function JobDetail() {
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
if (!confirm('Cancel this job?')) return;
|
||||
try {
|
||||
await api(`/api/jobs/${id}/cancel`, { method: 'POST' });
|
||||
toast.success('Job cancelled');
|
||||
setCancelModal(false);
|
||||
loadJob();
|
||||
} catch { alert('Cancel failed'); }
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
function statusColor(s: string) {
|
||||
const map: Record<string, string> = {
|
||||
queued: 'bg-gray-600', waking_up: 'bg-yellow-600', running: 'bg-blue-600',
|
||||
success: 'bg-green-600', failed: 'bg-red-600', cancelled: 'bg-gray-600',
|
||||
};
|
||||
return map[s] || 'bg-gray-600';
|
||||
function downloadLog() {
|
||||
const allLines = [
|
||||
...logs.map(l => `[${l.timestamp}] [${l.stream}] ${l.content}`),
|
||||
...liveLines.map(l => `[LIVE] [${l.stream}] ${l.text}`),
|
||||
];
|
||||
const blob = new Blob([allLines.join('\n')], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `job-${id}.log`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function duration(j: Job) {
|
||||
if (!j.started_at) return '-';
|
||||
const start = new Date(j.started_at).getTime();
|
||||
const end = j.finished_at ? new Date(j.finished_at).getTime() : Date.now();
|
||||
const secs = Math.round((end - start) / 1000);
|
||||
if (secs < 60) return `${secs}s`;
|
||||
const mins = Math.floor(secs / 60);
|
||||
const rem = secs % 60;
|
||||
if (mins < 60) return `${mins}m ${rem}s`;
|
||||
return `${Math.floor(mins / 60)}h ${mins % 60}m`;
|
||||
const copyLog = useCallback(() => {
|
||||
const text = [
|
||||
...logs.map(l => `[${l.timestamp}] [${l.stream}] ${l.content}`),
|
||||
...liveLines.map(l => `[LIVE] [${l.stream}] ${l.text}`),
|
||||
].join('\n');
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success('Log copied to clipboard');
|
||||
}, [logs, liveLines]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[50vh]">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!job) return <div className="p-6 text-gray-400">Loading...</div>;
|
||||
if (!job) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-3">
|
||||
<p className="text-fg-muted">Job not found</p>
|
||||
<Button variant="secondary" asChild>
|
||||
<Link to="/jobs">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Jobs
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalLines = logs.length + liveLines.length;
|
||||
|
||||
return (
|
||||
<div className="p-6 h-screen flex flex-col">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Link to="/jobs" className="text-gray-400 hover:text-white text-sm">← Job History</Link>
|
||||
<h1 className="text-2xl font-bold">Job #{job.id}</h1>
|
||||
<span className={`${statusColor(job.status)} text-white text-xs px-2 py-0.5 rounded`}>
|
||||
{job.status}
|
||||
</span>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to="/jobs">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Job History
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-xl font-bold text-fg font-mono">#{job.id}</h1>
|
||||
<Badge variant={statusVariant(job.status)} label={statusLabel(job.status)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 rounded-lg p-4 mb-4 grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-gray-400 text-xs">Sync Pair</div>
|
||||
<div className="text-white font-medium">{pair?.name || `Pair ${job.sync_pair_id}`}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400 text-xs">Trigger</div>
|
||||
<div className="text-white">{job.trigger_type}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400 text-xs">Duration</div>
|
||||
<div className="text-white">{duration(job)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-400 text-xs">Started</div>
|
||||
<div className="text-white text-xs">{job.started_at ? new Date(job.started_at).toLocaleString() : '-'}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ label: 'Sync Pair', value: pair?.name || `Pair ${job.sync_pair_id}` },
|
||||
{ label: 'Trigger', value: job.trigger_type },
|
||||
{
|
||||
label: 'Duration',
|
||||
value: job.started_at
|
||||
? formatDuration(
|
||||
(job.finished_at
|
||||
? new Date(job.finished_at).getTime()
|
||||
: Date.now()) -
|
||||
new Date(job.started_at).getTime()
|
||||
)
|
||||
: '-',
|
||||
},
|
||||
{
|
||||
label: 'Started',
|
||||
value: job.started_at
|
||||
? new Date(job.started_at).toLocaleString()
|
||||
: '-',
|
||||
},
|
||||
].map(item => (
|
||||
<Card key={item.label}>
|
||||
<div className="p-4">
|
||||
<div className="text-xs font-medium text-fg-muted uppercase tracking-wider mb-1">
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-fg truncate">
|
||||
{item.value}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{['queued', 'waking_up', 'running'].includes(job.status) && (
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button onClick={cancel} className="bg-red-600 hover:bg-red-700 text-white px-4 py-1.5 rounded text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
<label className="flex items-center gap-2 text-gray-400 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={autoScroll} onChange={e => setAutoScroll(e.target.checked)} />
|
||||
Auto-scroll
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => setCancelModal(true)}
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
Cancel Job
|
||||
</Button>
|
||||
<div className="flex items-center gap-2 text-sm text-fg-muted">
|
||||
<Switch
|
||||
id="auto-scroll"
|
||||
checked={autoScroll}
|
||||
onCheckedChange={setAutoScroll}
|
||||
/>
|
||||
<label htmlFor="auto-scroll" className="cursor-pointer">
|
||||
Auto-scroll
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 bg-gray-900 rounded-lg overflow-hidden flex flex-col min-h-0">
|
||||
<div className="bg-gray-800 px-4 py-2 flex items-center justify-between">
|
||||
<span className="text-gray-400 text-xs font-mono">Output</span>
|
||||
<span className="text-gray-500 text-xs">{lines.length + logs.length} lines</span>
|
||||
<Card className="flex flex-col min-h-0">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="h-4 w-4 text-fg-subtle" />
|
||||
<span className="text-xs font-semibold text-fg-muted uppercase tracking-wider">
|
||||
Output
|
||||
</span>
|
||||
<span className="text-xs text-fg-subtle">
|
||||
{totalLines} line{totalLines !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<CopyButton
|
||||
text={[
|
||||
...logs.map(l => `[${l.timestamp}] [${l.stream}] ${l.content}`),
|
||||
...liveLines.map(l => `[LIVE] [${l.stream}] ${l.text}`),
|
||||
].join('\n')}
|
||||
displayText="Copy log"
|
||||
/>
|
||||
<Button variant="ghost" size="icon-sm" onClick={downloadLog} title="Download log">
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 font-mono text-xs space-y-0.5">
|
||||
{logs.map(l => (
|
||||
<div key={l.id} className={l.stream === 'stderr' ? 'text-red-400' : 'text-gray-300'}>
|
||||
<span className="text-gray-600 mr-2">{((): string => {
|
||||
const d = new Date(l.timestamp);
|
||||
return `${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}:${d.getSeconds().toString().padStart(2,'0')}`;
|
||||
})()}</span>
|
||||
{l.content}
|
||||
<div className="flex-1 overflow-y-auto p-4 font-mono text-xs space-y-0.5 scrollbar-thin min-h-[300px] max-h-[60vh]">
|
||||
{logs.length === 0 && liveLines.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-fg-subtle gap-2">
|
||||
<ScrollText className="h-6 w-6" />
|
||||
<p>No log output yet</p>
|
||||
</div>
|
||||
))}
|
||||
{lines.map((l, i) => (
|
||||
<div key={`live-${i}`} className={l.stream === 'stderr' ? 'text-red-400' : 'text-gray-300'}>
|
||||
<span className="text-gray-600 mr-2">LIVE</span>
|
||||
{l.text}
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
<>
|
||||
{logs.map(l => (
|
||||
<LogLine
|
||||
key={l.id}
|
||||
stream={l.stream}
|
||||
content={l.content}
|
||||
timestamp={l.timestamp}
|
||||
/>
|
||||
))}
|
||||
{liveLines.map((l, i) => (
|
||||
<div
|
||||
key={`live-${i}`}
|
||||
className={cn(
|
||||
'flex gap-2',
|
||||
l.stream === 'stderr'
|
||||
? 'text-rose-400'
|
||||
: 'text-fg-muted'
|
||||
)}
|
||||
>
|
||||
<span className="text-accent shrink-0">LIVE</span>
|
||||
<span className="break-all">{l.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Modal open={cancelModal} onOpenChange={setCancelModal}>
|
||||
<ModalContent size="sm">
|
||||
<ModalHeader>
|
||||
<ModalTitle>Cancel Job</ModalTitle>
|
||||
<ModalDescription>
|
||||
Are you sure you want to cancel job #{job.id}? This action cannot
|
||||
be undone.
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
<ModalFooter>
|
||||
<Button variant="secondary" onClick={() => setCancelModal(false)}>
|
||||
Keep Running
|
||||
</Button>
|
||||
<Button variant="danger-solid" onClick={cancel}>
|
||||
Cancel Job
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogLine({
|
||||
stream,
|
||||
content,
|
||||
timestamp,
|
||||
}: {
|
||||
stream: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
}) {
|
||||
const d = new Date(timestamp);
|
||||
const timeStr = `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}:${d.getSeconds().toString().padStart(2, '0')}`;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-2',
|
||||
stream === 'stderr' ? 'text-rose-400' : 'text-fg-muted'
|
||||
)}
|
||||
>
|
||||
<span className="text-fg-subtle shrink-0">{timeStr}</span>
|
||||
<span className="break-all">{content}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+241
-102
@@ -1,6 +1,25 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api, Job, SyncPair } from '../api/client';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Select, SelectValue, SelectTrigger, SelectContent, SelectItem } from '@/components/ui/Select';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { PageHeader } from '@/components/ui/PageHeader';
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from '@/components/ui/Table';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
import { History, XCircle, ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { statusVariant, statusLabel } from '@/lib/status';
|
||||
import { formatDuration } from '@/lib/utils';
|
||||
|
||||
export default function JobHistory() {
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
@@ -10,6 +29,7 @@ export default function JobHistory() {
|
||||
const [filterRange, setFilterRange] = useState('7d');
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const limit = 50;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -21,6 +41,7 @@ export default function JobHistory() {
|
||||
}, [filterStatus, filterPair, filterRange, page]);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
let url = `/api/jobs?limit=${limit}&offset=${page * limit}`;
|
||||
if (filterStatus) url += `&status=${filterStatus}`;
|
||||
@@ -40,19 +61,26 @@ export default function JobHistory() {
|
||||
if (totalCount) setTotal(Number(totalCount));
|
||||
const data = await res.json();
|
||||
setJobs(data);
|
||||
} catch {}
|
||||
} catch {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPairs() {
|
||||
try { setPairs(await api<SyncPair[]>('/api/sync-pairs')); } catch {}
|
||||
try {
|
||||
setPairs(await api<SyncPair[]>('/api/sync-pairs'));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function cancel(id: number) {
|
||||
if (!confirm('Cancel this job?')) return;
|
||||
try {
|
||||
await api(`/api/jobs/${id}/cancel`, { method: 'POST' });
|
||||
toast.success('Job cancelled');
|
||||
load();
|
||||
} catch { alert('Cancel failed'); }
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
function pairName(id: number) {
|
||||
@@ -60,110 +88,221 @@ export default function JobHistory() {
|
||||
return p ? p.name : `Pair ${id}`;
|
||||
}
|
||||
|
||||
function statusColor(s: string) {
|
||||
const map: Record<string, string> = {
|
||||
queued: 'bg-gray-600', waking_up: 'bg-yellow-600', running: 'bg-blue-600',
|
||||
success: 'bg-green-600', failed: 'bg-red-600', cancelled: 'bg-gray-600',
|
||||
};
|
||||
return map[s] || 'bg-gray-600';
|
||||
}
|
||||
|
||||
function duration(j: Job) {
|
||||
if (!j.started_at) return '-';
|
||||
const start = new Date(j.started_at).getTime();
|
||||
const end = j.finished_at ? new Date(j.finished_at).getTime() : Date.now();
|
||||
const secs = Math.round((end - start) / 1000);
|
||||
if (secs < 60) return `${secs}s`;
|
||||
const mins = Math.floor(secs / 60);
|
||||
const rem = secs % 60;
|
||||
if (mins < 60) return `${mins}m ${rem}s`;
|
||||
return `${Math.floor(mins / 60)}h ${mins % 60}m`;
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
const FilterChip = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) => (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger className="w-auto min-w-[140px]">
|
||||
<SelectValue placeholder={label} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{label}</SelectItem>
|
||||
{label === 'All Pairs' &&
|
||||
pairs.map(p => (
|
||||
<SelectItem key={p.id} value={p.id.toString()}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
{label === 'All Statuses' &&
|
||||
[
|
||||
{ value: 'queued', label: 'Queued' },
|
||||
{ value: 'waking_up', label: 'Waking Up' },
|
||||
{ value: 'running', label: 'Running' },
|
||||
{ value: 'success', label: 'Success' },
|
||||
{ value: 'failed', label: 'Failed' },
|
||||
{ value: 'cancelled', label: 'Cancelled' },
|
||||
].map(s => (
|
||||
<SelectItem key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
{label === 'Time Range' &&
|
||||
[
|
||||
{ value: '24h', label: 'Last 24h' },
|
||||
{ value: '7d', label: 'Last 7 days' },
|
||||
{ value: '30d', label: 'Last 30 days' },
|
||||
{ value: 'all', label: 'All time' },
|
||||
].map(r => (
|
||||
<SelectItem key={r.value} value={r.value}>
|
||||
{r.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Job History</h1>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<select value={filterPair} onChange={e => { setFilterPair(e.target.value); setPage(0); }}
|
||||
className="bg-gray-700 text-white rounded px-2 py-1.5">
|
||||
<option value="">All Pairs</option>
|
||||
{pairs.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
<select value={filterStatus} onChange={e => { setFilterStatus(e.target.value); setPage(0); }}
|
||||
className="bg-gray-700 text-white rounded px-2 py-1.5">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="queued">Queued</option>
|
||||
<option value="waking_up">Waking Up</option>
|
||||
<option value="running">Running</option>
|
||||
<option value="success">Success</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
<select value={filterRange} onChange={e => { setFilterRange(e.target.value); setPage(0); }}
|
||||
className="bg-gray-700 text-white rounded px-2 py-1.5">
|
||||
<option value="24h">Last 24h</option>
|
||||
<option value="7d">Last 7 days</option>
|
||||
<option value="30d">Last 30 days</option>
|
||||
<option value="all">All time</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Job History"
|
||||
description={`${total} job${total !== 1 ? 's' : ''} found`}
|
||||
actions={
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => load()}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="bg-gray-800 rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-700">
|
||||
<tr className="text-left text-gray-400">
|
||||
<th className="p-3">ID</th>
|
||||
<th className="p-3">Sync Pair</th>
|
||||
<th className="p-3">Trigger</th>
|
||||
<th className="p-3">Status</th>
|
||||
<th className="p-3">Duration</th>
|
||||
<th className="p-3">Started</th>
|
||||
<th className="p-3">Finished</th>
|
||||
<th className="p-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map(j => (
|
||||
<tr key={j.id} className="border-t border-gray-700 hover:bg-gray-750 cursor-pointer"
|
||||
onClick={() => window.location.href = `/jobs/${j.id}`}>
|
||||
<td className="p-3 text-blue-400 hover:text-blue-300">
|
||||
<Link to={`/jobs/${j.id}`}>#{j.id}</Link>
|
||||
</td>
|
||||
<td className="p-3">{pairName(j.sync_pair_id)}</td>
|
||||
<td className="p-3">{j.trigger_type}</td>
|
||||
<td className="p-3">
|
||||
<span className={`${statusColor(j.status)} text-white text-xs px-2 py-0.5 rounded`}>
|
||||
{j.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3 text-gray-400 text-xs">{duration(j)}</td>
|
||||
<td className="p-3 text-xs">{j.started_at ? new Date(j.started_at).toLocaleString() : '-'}</td>
|
||||
<td className="p-3 text-xs">{j.finished_at ? new Date(j.finished_at).toLocaleString() : '-'}</td>
|
||||
<td className="p-3" onClick={e => e.stopPropagation()}>
|
||||
{['queued', 'waking_up', 'running'].includes(j.status) && (
|
||||
<button onClick={() => cancel(j.id)} className="text-red-400 hover:text-red-300 text-xs">Cancel</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{jobs.length === 0 && <tr><td colSpan={8} className="p-4 text-center text-gray-500">No jobs</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="bg-gray-800 px-4 py-3 flex items-center justify-between border-t border-gray-700">
|
||||
<button onClick={() => setPage(p => Math.max(0, p - 1))} disabled={page === 0}
|
||||
className="text-sm text-gray-400 hover:text-white disabled:opacity-50">← Previous</button>
|
||||
<span className="text-gray-400 text-sm">{page + 1} / {totalPages} ({total} total)</span>
|
||||
<button onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1}
|
||||
className="text-sm text-gray-400 hover:text-white disabled:opacity-50">Next →</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<FilterChip label="All Pairs" value={filterPair} onChange={v => { setFilterPair(v); setPage(0); }} />
|
||||
<FilterChip label="All Statuses" value={filterStatus} onChange={v => { setFilterStatus(v); setPage(0); }} />
|
||||
<FilterChip label="Time Range" value={filterRange} onChange={v => { setFilterRange(v); setPage(0); }} />
|
||||
{(filterStatus || filterPair || filterRange !== '7d') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => { setFilterStatus(''); setFilterPair(''); setFilterRange('7d'); setPage(0); }}
|
||||
className="text-fg-subtle"
|
||||
>
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-5 space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : jobs.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<History className="h-5 w-5" />}
|
||||
title="No jobs found"
|
||||
description={
|
||||
filterStatus || filterPair || filterRange !== '7d'
|
||||
? 'Try adjusting your filters'
|
||||
: 'Sync pairs will appear here once jobs are executed'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Sync Pair</TableHead>
|
||||
<TableHead>Trigger</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead>Started</TableHead>
|
||||
<TableHead>Finished</TableHead>
|
||||
<TableHead className="w-20">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{jobs.map(j => (
|
||||
<TableRow key={j.id}>
|
||||
<TableCell>
|
||||
<Link
|
||||
to={`/jobs/${j.id}`}
|
||||
className="text-accent hover:text-accent-hover font-mono text-xs"
|
||||
>
|
||||
#{j.id}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-fg-muted">
|
||||
{pairName(j.sync_pair_id)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-xs text-fg-muted capitalize">
|
||||
{j.trigger_type}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={statusVariant(j.status)}
|
||||
label={statusLabel(j.status)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-fg-muted font-mono text-xs">
|
||||
{j.started_at
|
||||
? formatDuration(
|
||||
(j.finished_at
|
||||
? new Date(j.finished_at).getTime()
|
||||
: Date.now()) -
|
||||
new Date(j.started_at).getTime()
|
||||
)
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-fg-muted text-xs">
|
||||
{j.started_at
|
||||
? new Date(j.started_at).toLocaleString()
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-fg-muted text-xs">
|
||||
{j.finished_at
|
||||
? new Date(j.finished_at).toLocaleString()
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{['queued', 'waking_up', 'running'].includes(j.status) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => cancel(j.id)}
|
||||
className="text-rose-400 hover:text-rose-300 hover:bg-rose-500/10"
|
||||
title="Cancel job"
|
||||
>
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-5 py-3 border-t border-border">
|
||||
<div className="text-xs text-fg-muted">
|
||||
Page {page + 1} of {totalPages} ({total} total)
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon-sm"
|
||||
onClick={() => setPage(p => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon-sm"
|
||||
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn('rounded-card border border-border bg-surface shadow-card', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+87
-29
@@ -1,15 +1,26 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Database, Eye, EyeOff } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Label } from '@/components/ui/Label';
|
||||
import { Card, CardBody } from '@/components/ui/Card';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function Login() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
if (!username.trim() || !password.trim()) {
|
||||
toast.error('Username and password are required');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
@@ -21,40 +32,87 @@ export default function Login() {
|
||||
navigate('/');
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setError(data.error || 'Login failed');
|
||||
toast.error(data.error || 'Login failed');
|
||||
}
|
||||
} catch {
|
||||
setError('Network error');
|
||||
toast.error('Network error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-950">
|
||||
<form onSubmit={handleSubmit} className="bg-gray-900 p-8 rounded-lg w-80 shadow-xl">
|
||||
<h1 className="text-2xl font-bold mb-6 text-white">SyncServer</h1>
|
||||
{error && <div className="bg-red-900 text-red-200 p-2 rounded mb-4 text-sm">{error}</div>}
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-400 text-sm mb-1">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
className="w-full bg-gray-800 text-white rounded px-3 py-2 border border-gray-700 focus:border-blue-500 outline-none"
|
||||
/>
|
||||
<div className="min-h-screen flex items-center justify-center bg-canvas relative overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.03]"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at 30% 40%, #2dd4bf 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 60%, #2dd4bf 0%, transparent 40%)`,
|
||||
}}
|
||||
/>
|
||||
<div className="relative w-full max-w-sm mx-4">
|
||||
<div className="flex flex-col items-center mb-8 animate-fade-in">
|
||||
<div className="rounded-card bg-accent/10 p-3 mb-4">
|
||||
<Database className="h-8 w-8 text-accent" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-fg tracking-tight">SyncServer</h1>
|
||||
<p className="text-fg-muted text-sm mt-1">Sign in to your account</p>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label className="block text-gray-400 text-sm mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="w-full bg-gray-800 text-white rounded px-3 py-2 border border-gray-700 focus:border-blue-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="w-full bg-blue-600 hover:bg-blue-700 text-white rounded py-2 font-medium">
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<Card className="animate-scale-in" style={{ animationDelay: '50ms' }}>
|
||||
<CardBody>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="username" required>
|
||||
Username
|
||||
</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="admin"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password" required>
|
||||
Password
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(v => !v)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-fg-subtle hover:text-fg-muted transition-colors"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" loading={loading}>
|
||||
Sign In
|
||||
</Button>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+371
-97
@@ -1,18 +1,72 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, Machine, SSHKey } from '../api/client';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Label } from '@/components/ui/Label';
|
||||
import { Select, SelectValue, SelectTrigger, SelectContent, SelectItem } from '@/components/ui/Select';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
ModalDescription,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
} from '@/components/ui/Modal';
|
||||
import { PageHeader } from '@/components/ui/PageHeader';
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from '@/components/ui/Table';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { CopyButton } from '@/components/ui/CopyButton';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Pencil, Trash2, Plus, Server } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type MachineForm = {
|
||||
id: number | undefined;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
ssh_user: string;
|
||||
ssh_key_id: number | null;
|
||||
mac_address: string;
|
||||
wol_enabled: boolean;
|
||||
wake_timeout_seconds: number;
|
||||
wake_check_interval_seconds: number;
|
||||
};
|
||||
|
||||
const defaultForm: MachineForm = {
|
||||
id: undefined,
|
||||
name: '',
|
||||
host: '',
|
||||
port: 22,
|
||||
ssh_user: 'root',
|
||||
ssh_key_id: null,
|
||||
mac_address: '',
|
||||
wol_enabled: false,
|
||||
wake_timeout_seconds: 120,
|
||||
wake_check_interval_seconds: 5,
|
||||
};
|
||||
|
||||
export default function Machines() {
|
||||
const [machines, setMachines] = useState<Machine[]>([]);
|
||||
const [sshKeys, setSSHKeys] = useState<SSHKey[]>([]);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
id: undefined as number | undefined, name: '', host: '', port: 22, ssh_user: 'root',
|
||||
ssh_key_id: null as number | null,
|
||||
mac_address: '', wol_enabled: false,
|
||||
wake_timeout_seconds: 120, wake_check_interval_seconds: 5,
|
||||
});
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<MachineForm>(defaultForm);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
@@ -25,46 +79,78 @@ export default function Machines() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
id: form.id || null, name: form.name, host: form.host, port: Number(form.port),
|
||||
ssh_user: form.ssh_user, ssh_key_id: form.ssh_key_id,
|
||||
mac_address: form.mac_address || null,
|
||||
wol_enabled: Boolean(form.wol_enabled),
|
||||
wake_timeout_seconds: Number(form.wake_timeout_seconds),
|
||||
wake_check_interval_seconds: Number(form.wake_check_interval_seconds),
|
||||
};
|
||||
if (form.mac_address && !/^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$/.test(form.mac_address)) {
|
||||
alert('Invalid MAC address format');
|
||||
return;
|
||||
}
|
||||
await api(form.id ? `/api/machines/${form.id}` : '/api/machines', {
|
||||
method: form.id ? 'PUT' : 'POST',
|
||||
body: payload,
|
||||
});
|
||||
setShowForm(false);
|
||||
setForm({ id: undefined, name: '', host: '', port: 22, ssh_user: 'root', ssh_key_id: null, mac_address: '', wol_enabled: false, wake_timeout_seconds: 120, wake_check_interval_seconds: 5 });
|
||||
load();
|
||||
} catch (e: unknown) { alert((e as Error).message); }
|
||||
function openCreate() {
|
||||
setForm(defaultForm);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function edit(m: Machine) {
|
||||
function openEdit(m: Machine) {
|
||||
setForm({
|
||||
id: m.id, name: m.name, host: m.host, port: m.port,
|
||||
ssh_user: m.ssh_user, ssh_key_id: m.ssh_key_id,
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
host: m.host,
|
||||
port: m.port,
|
||||
ssh_user: m.ssh_user,
|
||||
ssh_key_id: m.ssh_key_id,
|
||||
mac_address: m.mac_address || '',
|
||||
wol_enabled: m.wol_enabled,
|
||||
wake_timeout_seconds: m.wake_timeout_seconds,
|
||||
wake_check_interval_seconds: m.wake_check_interval_seconds,
|
||||
});
|
||||
setShowForm(true);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
if (!confirm('Delete machine?')) return;
|
||||
try { await api(`/api/machines/${id}`, { method: 'DELETE' }); load(); } catch { alert('Delete failed'); }
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!form.name.trim() || !form.host.trim()) {
|
||||
toast.error('Name and host are required');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
form.mac_address &&
|
||||
!/^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$/.test(form.mac_address)
|
||||
) {
|
||||
toast.error('Invalid MAC address format (AA:BB:CC:DD:EE:FF)');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
id: form.id || null,
|
||||
name: form.name,
|
||||
host: form.host,
|
||||
port: Number(form.port),
|
||||
ssh_user: form.ssh_user,
|
||||
ssh_key_id: form.ssh_key_id,
|
||||
mac_address: form.mac_address || null,
|
||||
wol_enabled: Boolean(form.wol_enabled),
|
||||
wake_timeout_seconds: Number(form.wake_timeout_seconds),
|
||||
wake_check_interval_seconds: Number(form.wake_check_interval_seconds),
|
||||
};
|
||||
await api(form.id ? `/api/machines/${form.id}` : '/api/machines', {
|
||||
method: form.id ? 'PUT' : 'POST',
|
||||
body: payload,
|
||||
});
|
||||
setModalOpen(false);
|
||||
toast.success(form.id ? 'Machine updated' : 'Machine created');
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (deleteId === null) return;
|
||||
try {
|
||||
await api(`/api/machines/${deleteId}`, { method: 'DELETE' });
|
||||
toast.success('Machine deleted');
|
||||
setDeleteId(null);
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
function keyLabel(id: number | null) {
|
||||
@@ -74,68 +160,256 @@ export default function Machines() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">Machines</h1>
|
||||
<button onClick={() => setShowForm(true)} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">
|
||||
Add Machine
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Machines"
|
||||
description="Remote machines reachable via SSH"
|
||||
actions={
|
||||
<Button onClick={openCreate} size="sm">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Machine
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<form onSubmit={handleSubmit} className="bg-gray-800 p-6 rounded-lg w-[480px] space-y-3">
|
||||
<h2 className="text-lg font-bold">Machine</h2>
|
||||
<input placeholder="Name" value={form.name} onChange={e => setForm({...form, name: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" required />
|
||||
<input placeholder="Host / IP" value={form.host} onChange={e => setForm({...form, host: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" required />
|
||||
<input placeholder="SSH Port" type="number" value={form.port} onChange={e => setForm({...form, port: +e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" />
|
||||
<input placeholder="SSH User" value={form.ssh_user} onChange={e => setForm({...form, ssh_user: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" />
|
||||
<select value={form.ssh_key_id ?? ''} onChange={e => setForm({...form, ssh_key_id: e.target.value ? Number(e.target.value) : null})}
|
||||
className="w-full bg-gray-700 rounded px-3 py-2 text-white">
|
||||
<option value="">Server Key (default)</option>
|
||||
{sshKeys.map(k => <option key={k.id} value={k.id}>{k.label} {k.in_use ? '(in use)' : ''}</option>)}
|
||||
</select>
|
||||
<input placeholder="MAC Address (AA:BB:CC:DD:EE:FF)" value={form.mac_address} onChange={e => setForm({...form, mac_address: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" />
|
||||
<label className="flex items-center gap-2 text-gray-300">
|
||||
<input type="checkbox" checked={form.wol_enabled} onChange={e => setForm({...form, wol_enabled: e.target.checked})} />
|
||||
Enable Wake-on-LAN
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded flex-1">Save</button>
|
||||
<button type="button" onClick={() => setShowForm(false)} className="bg-gray-600 hover:bg-gray-500 text-white px-4 py-2 rounded">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
<Card>
|
||||
<div className="p-0">
|
||||
{machines.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<Server className="h-5 w-5" />}
|
||||
title="No machines"
|
||||
description="Add a remote machine to start syncing data"
|
||||
action={
|
||||
<Button onClick={openCreate} size="sm">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Machine
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Host</TableHead>
|
||||
<TableHead>SSH Key</TableHead>
|
||||
<TableHead>WoL</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{machines.map(m => (
|
||||
<TableRow key={m.id}>
|
||||
<TableCell className="font-medium">{m.name}</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-mono text-xs text-fg-muted">
|
||||
{m.host}:{m.port}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-fg-muted text-xs">
|
||||
{keyLabel(m.ssh_key_id)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{m.wol_enabled ? (
|
||||
<Badge variant="info" label="Yes" />
|
||||
) : (
|
||||
<span className="text-fg-subtle text-xs">No</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={m.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => openEdit(m)}
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setDeleteId(m.id)}
|
||||
className="text-rose-400 hover:text-rose-300 hover:bg-rose-500/10"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<table className="w-full text-sm bg-gray-800 rounded-lg overflow-hidden">
|
||||
<thead className="bg-gray-700">
|
||||
<tr className="text-left text-gray-400">
|
||||
<th className="p-3">Name</th>
|
||||
<th className="p-3">Host</th>
|
||||
<th className="p-3">SSH Key</th>
|
||||
<th className="p-3">WoL</th>
|
||||
<th className="p-3">Status</th>
|
||||
<th className="p-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{machines.map(m => (
|
||||
<tr key={m.id} className="border-t border-gray-700">
|
||||
<td className="p-3 font-medium">{m.name}</td>
|
||||
<td className="p-3">{m.host}:{m.port}</td>
|
||||
<td className="p-3 text-gray-400 text-xs">{keyLabel(m.ssh_key_id)}</td>
|
||||
<td className="p-3">{m.wol_enabled ? 'Yes' : 'No'}</td>
|
||||
<td className="p-3 text-gray-400">{m.status}</td>
|
||||
<td className="p-3">
|
||||
<button onClick={() => edit(m)} className="text-blue-400 hover:text-blue-300 mr-3">Edit</button>
|
||||
<button onClick={() => remove(m.id)} className="text-red-400 hover:text-red-300">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{machines.length === 0 && <tr><td colSpan={6} className="p-4 text-center text-gray-500">No machines</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
<Modal open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<ModalContent size="md">
|
||||
<ModalHeader>
|
||||
<ModalTitle>{form.id ? 'Edit Machine' : 'Add Machine'}</ModalTitle>
|
||||
<ModalDescription>
|
||||
{form.id
|
||||
? 'Update the configuration for this machine'
|
||||
: 'Configure a new remote machine for syncing'}
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<ModalBody className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="name" required>
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="backup-nas"
|
||||
value={form.name}
|
||||
onChange={e => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="host">Host / IP</Label>
|
||||
<Input
|
||||
id="host"
|
||||
placeholder="192.168.1.100"
|
||||
value={form.host}
|
||||
onChange={e => setForm({ ...form, host: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="port">SSH Port</Label>
|
||||
<Input
|
||||
id="port"
|
||||
type="number"
|
||||
placeholder="22"
|
||||
value={form.port}
|
||||
onChange={e =>
|
||||
setForm({ ...form, port: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ssh_user">SSH User</Label>
|
||||
<Input
|
||||
id="ssh_user"
|
||||
placeholder="root"
|
||||
value={form.ssh_user}
|
||||
onChange={e =>
|
||||
setForm({ ...form, ssh_user: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ssh_key_id">SSH Key</Label>
|
||||
<Select
|
||||
value={form.ssh_key_id?.toString() ?? ''}
|
||||
onValueChange={v =>
|
||||
setForm({ ...form, ssh_key_id: v ? Number(v) : null })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="ssh_key_id">
|
||||
<SelectValue placeholder="Server Key" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Server Key (default)</SelectItem>
|
||||
{sshKeys.map(k => (
|
||||
<SelectItem key={k.id} value={k.id.toString()}>
|
||||
{k.label} {k.in_use ? '(in use)' : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="mac_address">MAC Address</Label>
|
||||
<Input
|
||||
id="mac_address"
|
||||
placeholder="AA:BB:CC:DD:EE:FF"
|
||||
value={form.mac_address}
|
||||
onChange={e =>
|
||||
setForm({ ...form, mac_address: e.target.value })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-fg-subtle">
|
||||
Required for Wake-on-LAN
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-card p-3 transition-colors',
|
||||
form.wol_enabled
|
||||
? 'bg-accent/5 border border-accent/20'
|
||||
: 'bg-surface-raised border border-border'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="wol_enabled"
|
||||
checked={form.wol_enabled}
|
||||
onChange={e =>
|
||||
setForm({ ...form, wol_enabled: e.target.checked })
|
||||
}
|
||||
className="h-4 w-4 rounded border-border accent-accent"
|
||||
/>
|
||||
<Label htmlFor="wol_enabled" className="cursor-pointer mb-0">
|
||||
Enable Wake-on-LAN
|
||||
</Label>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setModalOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={loading}>
|
||||
{form.id ? 'Save Changes' : 'Add Machine'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<Modal open={deleteId !== null} onOpenChange={v => !v && setDeleteId(null)}>
|
||||
<ModalContent size="sm">
|
||||
<ModalHeader>
|
||||
<ModalTitle>Delete Machine</ModalTitle>
|
||||
<ModalDescription>
|
||||
Are you sure you want to delete this machine? This action cannot
|
||||
be undone.
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
<ModalFooter>
|
||||
<Button variant="secondary" onClick={() => setDeleteId(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger-solid" onClick={handleDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const variant =
|
||||
status === 'online'
|
||||
? 'success'
|
||||
: status === 'offline'
|
||||
? 'neutral'
|
||||
: 'info';
|
||||
return <Badge variant={variant} label={status} />;
|
||||
}
|
||||
|
||||
+289
-96
@@ -1,69 +1,130 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, SSHKey } from '../api/client';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Label } from '@/components/ui/Label';
|
||||
import { Textarea } from '@/components/ui/Textarea';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
ModalDescription,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
} from '@/components/ui/Modal';
|
||||
import { PageHeader } from '@/components/ui/PageHeader';
|
||||
import { Card, CardBody } from '@/components/ui/Card';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { CopyButton } from '@/components/ui/CopyButton';
|
||||
import { Spinner } from '@/components/ui/Spinner';
|
||||
import {
|
||||
Key,
|
||||
Plus,
|
||||
Upload,
|
||||
Download,
|
||||
Trash2,
|
||||
Fingerprint,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type ModalType = 'generate' | 'import' | 'delete' | null;
|
||||
|
||||
export default function SSHKeys() {
|
||||
const [keys, setKeys] = useState<SSHKey[]>([]);
|
||||
const [showGen, setShowGen] = useState(false);
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [modal, setModal] = useState<ModalType>(null);
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
const [genLabel, setGenLabel] = useState('');
|
||||
const [importLabel, setImportLabel] = useState('');
|
||||
const [importPubKey, setImportPubKey] = useState('');
|
||||
const [downloading, setDownloading] = useState<number | null>(null);
|
||||
const [copied, setCopied] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [downloading, setDownloading] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function load() {
|
||||
try { setKeys(await api<SSHKey[]>('/api/ssh-keys')); } catch {}
|
||||
try {
|
||||
setKeys(await api<SSHKey[]>('/api/ssh-keys'));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
if (!genLabel.trim()) { alert('Label is required'); return; }
|
||||
if (!genLabel.trim()) {
|
||||
toast.error('Label is required');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await api('/api/ssh-keys', {
|
||||
method: 'POST',
|
||||
body: { label: genLabel.trim(), generate: true },
|
||||
});
|
||||
setShowGen(false);
|
||||
setModal(null);
|
||||
setGenLabel('');
|
||||
toast.success('SSH key pair generated');
|
||||
load();
|
||||
} catch (e: unknown) { alert((e as Error).message); }
|
||||
finally { setLoading(false); }
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function importKey() {
|
||||
if (!importLabel.trim()) { alert('Label is required'); return; }
|
||||
if (!importPubKey.trim()) { alert('Public key is required'); return; }
|
||||
if (!importLabel.trim()) {
|
||||
toast.error('Label is required');
|
||||
return;
|
||||
}
|
||||
if (!importPubKey.trim()) {
|
||||
toast.error('Public key is required');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await api('/api/ssh-keys', {
|
||||
method: 'POST',
|
||||
body: { label: importLabel.trim(), generate: false, public_key: importPubKey.trim() },
|
||||
body: {
|
||||
label: importLabel.trim(),
|
||||
generate: false,
|
||||
public_key: importPubKey.trim(),
|
||||
},
|
||||
});
|
||||
setShowImport(false);
|
||||
setModal(null);
|
||||
setImportLabel('');
|
||||
setImportPubKey('');
|
||||
toast.success('SSH key imported');
|
||||
load();
|
||||
} catch (e: unknown) { alert((e as Error).message); }
|
||||
finally { setLoading(false); }
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
if (!confirm('Delete this SSH key? Machines using it will fall back to the server key.')) return;
|
||||
async function handleDelete() {
|
||||
if (deleteId === null) return;
|
||||
try {
|
||||
await api(`/api/ssh-keys/${id}`, { method: 'DELETE' });
|
||||
await api(`/api/ssh-keys/${deleteId}`, { method: 'DELETE' });
|
||||
toast.success('SSH key deleted');
|
||||
setDeleteId(null);
|
||||
load();
|
||||
} catch (e: unknown) { alert((e as Error).message); }
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadPrivate(id: number) {
|
||||
try {
|
||||
const res = await fetch(`/api/ssh-keys/${id}/private`, { credentials: 'include' });
|
||||
const res = await fetch(`/api/ssh-keys/${id}/private`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Failed' }));
|
||||
alert((err as { error: string }).error);
|
||||
toast.error((err as { error: string }).error);
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
@@ -76,88 +137,220 @@ export default function SSHKeys() {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
setDownloading(id);
|
||||
toast.success('Private key downloaded');
|
||||
setTimeout(() => setDownloading(null), 3000);
|
||||
} catch (e: unknown) { alert((e as Error).message); }
|
||||
}
|
||||
|
||||
async function copyPubKey(key: SSHKey) {
|
||||
await navigator.clipboard.writeText(key.public_key);
|
||||
setCopied(key.id);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">SSH Keys</h1>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setShowGen(true)} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm">
|
||||
Generate New
|
||||
</button>
|
||||
<button onClick={() => setShowImport(true)} className="bg-gray-600 hover:bg-gray-500 text-white px-4 py-2 rounded text-sm">
|
||||
Import Public Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(showGen || showImport) && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-gray-800 p-6 rounded-lg w-[500px] space-y-3">
|
||||
<h2 className="text-lg font-bold">{showGen ? 'Generate SSH Key Pair' : 'Import Public Key'}</h2>
|
||||
<input placeholder="Label (e.g. backup-nas)" value={genLabel} onChange={e => setGenLabel(e.target.value)}
|
||||
className="w-full bg-gray-700 rounded px-3 py-2 text-white" />
|
||||
{showImport && (
|
||||
<textarea placeholder="ssh-ed25519 AAAA..." value={importPubKey} onChange={e => setImportPubKey(e.target.value)}
|
||||
className="w-full bg-gray-700 rounded px-3 py-2 text-white font-mono text-xs h-32" />
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={showGen ? generate : importKey} disabled={loading}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded flex-1 disabled:opacity-50">
|
||||
{loading ? 'Working...' : showGen ? 'Generate' : 'Import'}
|
||||
</button>
|
||||
<button onClick={() => { setShowGen(false); setShowImport(false); }}
|
||||
className="bg-gray-600 hover:bg-gray-500 text-white px-4 py-2 rounded">Cancel</button>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="SSH Keys"
|
||||
description="Manage SSH key pairs for authenticating with remote machines"
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setModal('import')}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Import Public Key
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setModal('generate')}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Generate New
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{keys.length === 0 ? (
|
||||
<Card>
|
||||
<CardBody className="p-0">
|
||||
<EmptyState
|
||||
icon={<Key className="h-5 w-5" />}
|
||||
title="No SSH keys"
|
||||
description="Generate a key pair or import a public key to authenticate with remote machines"
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setModal('import')}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Import
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setModal('generate')}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Generate
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{keys.map(k => (
|
||||
<Card key={k.id}>
|
||||
<CardBody>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h3 className="text-sm font-semibold text-fg truncate">
|
||||
{k.label}
|
||||
</h3>
|
||||
{k.in_use && (
|
||||
<Badge variant="success" label="In Use" />
|
||||
)}
|
||||
{!k.has_private_key && (
|
||||
<Badge variant="neutral" label="Imported Only" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mb-3 text-xs text-fg-subtle">
|
||||
<Fingerprint className="h-3.5 w-3.5" />
|
||||
<span className="font-mono truncate">
|
||||
{k.fingerprint}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-canvas-raised rounded-card p-3 font-mono text-xs text-emerald-400/80 break-all max-w-2xl">
|
||||
{k.public_key}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<CopyButton
|
||||
text={k.public_key}
|
||||
displayText="Copy pub"
|
||||
/>
|
||||
{k.has_private_key && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => downloadPrivate(k.id)}
|
||||
loading={downloading === k.id}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{downloading === k.id ? 'Done' : 'Private'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="danger"
|
||||
size="icon-sm"
|
||||
onClick={() => setDeleteId(k.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{keys.map(k => (
|
||||
<div key={k.id} className="bg-gray-800 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-white">{k.label}</span>
|
||||
{k.in_use && <span className="text-xs bg-green-900 text-green-400 px-2 py-0.5 rounded">In Use</span>}
|
||||
{!k.has_private_key && <span className="text-xs bg-gray-700 text-gray-400 px-2 py-0.5 rounded">Imported Only</span>}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mb-2">Fingerprint: {k.fingerprint}</div>
|
||||
<div className="bg-gray-900 p-2 rounded font-mono text-xs text-green-400 break-all max-w-2xl">
|
||||
{k.public_key}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4">
|
||||
<button onClick={() => copyPubKey(k)}
|
||||
className="text-gray-400 hover:text-white text-xs px-3 py-1.5 rounded bg-gray-700 hover:bg-gray-600">
|
||||
{copied === k.id ? 'Copied!' : 'Copy Public'}
|
||||
</button>
|
||||
{k.has_private_key && (
|
||||
<button onClick={() => downloadPrivate(k.id)}
|
||||
className="text-yellow-400 hover:text-yellow-300 text-xs px-3 py-1.5 rounded bg-gray-700 hover:bg-gray-600">
|
||||
{downloading === k.id ? 'Downloaded!' : 'Download Private Key'}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => remove(k.id)}
|
||||
className="text-red-400 hover:text-red-300 text-xs px-3 py-1.5 rounded bg-gray-700 hover:bg-gray-600">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<Modal open={modal === 'generate'} onOpenChange={v => !v && setModal(null)}>
|
||||
<ModalContent size="md">
|
||||
<ModalHeader>
|
||||
<ModalTitle>Generate SSH Key Pair</ModalTitle>
|
||||
<ModalDescription>
|
||||
Generate a new Ed25519 key pair. The private key will be
|
||||
downloaded immediately and the public key stored on the server.
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
<ModalBody className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="gen-label" required>
|
||||
Label
|
||||
</Label>
|
||||
<Input
|
||||
id="gen-label"
|
||||
placeholder="backup-nas"
|
||||
value={genLabel}
|
||||
onChange={e => setGenLabel(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{keys.length === 0 && <div className="text-gray-500 text-center py-12">No SSH keys. Generate one or import a public key above.</div>}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="secondary" onClick={() => setModal(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={generate} loading={loading}>
|
||||
<Key className="h-4 w-4" />
|
||||
Generate
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<Modal open={modal === 'import'} onOpenChange={v => !v && setModal(null)}>
|
||||
<ModalContent size="md">
|
||||
<ModalHeader>
|
||||
<ModalTitle>Import Public Key</ModalTitle>
|
||||
<ModalDescription>
|
||||
Import an existing public key. Only the public key will be stored
|
||||
— you must have the corresponding private key on this server.
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
<ModalBody className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="import-label" required>
|
||||
Label
|
||||
</Label>
|
||||
<Input
|
||||
id="import-label"
|
||||
placeholder="work-server"
|
||||
value={importLabel}
|
||||
onChange={e => setImportLabel(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="import-pubkey" required>
|
||||
Public Key
|
||||
</Label>
|
||||
<Textarea
|
||||
id="import-pubkey"
|
||||
placeholder="ssh-ed25519 AAAA..."
|
||||
value={importPubKey}
|
||||
onChange={e => setImportPubKey(e.target.value)}
|
||||
className="font-mono text-xs h-24"
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="secondary" onClick={() => setModal(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={importKey} loading={loading}>
|
||||
<Upload className="h-4 w-4" />
|
||||
Import
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<Modal open={deleteId !== null} onOpenChange={v => !v && setDeleteId(null)}>
|
||||
<ModalContent size="sm">
|
||||
<ModalHeader>
|
||||
<ModalTitle>Delete SSH Key</ModalTitle>
|
||||
<ModalDescription>
|
||||
Are you sure you want to delete this SSH key? Machines using it
|
||||
will fall back to the server key.
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
<ModalFooter>
|
||||
<Button variant="secondary" onClick={() => setDeleteId(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger-solid" onClick={handleDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+94
-32
@@ -1,50 +1,112 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { CopyButton } from '@/components/ui/CopyButton';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { PageHeader } from '@/components/ui/PageHeader';
|
||||
import { Card, CardHeader, CardTitle, CardBody } from '@/components/ui/Card';
|
||||
import { Key, Download, Terminal } from 'lucide-react';
|
||||
|
||||
export default function Settings() {
|
||||
const [pubKey, setPubKey] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/settings/pubkey', { credentials: 'include' })
|
||||
.then(r => r.ok ? r.text() : '')
|
||||
.then(r => (r.ok ? r.text() : ''))
|
||||
.then(t => setPubKey(t))
|
||||
.catch(() => {});
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
function copyKey() {
|
||||
navigator.clipboard.writeText(pubKey).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
function downloadPubKey() {
|
||||
if (!pubKey) return;
|
||||
const blob = new Blob([pubKey], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'syncserver.pub';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-2xl">
|
||||
<h1 className="text-2xl font-bold mb-6">Settings</h1>
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<PageHeader
|
||||
title="Settings"
|
||||
description="Server configuration and SSH key management"
|
||||
/>
|
||||
|
||||
<div className="bg-gray-800 rounded-lg p-4 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-3">Server SSH Public Key</h2>
|
||||
<p className="text-gray-400 text-sm mb-3">
|
||||
Add this key to the <code className="bg-gray-700 px-1 rounded">~/.ssh/authorized_keys</code> file on your remote machines to allow SyncServer to connect.
|
||||
</p>
|
||||
<div className="bg-gray-900 p-3 rounded font-mono text-xs text-green-400 break-all mb-3">
|
||||
{pubKey || 'Loading...'}
|
||||
</div>
|
||||
<button onClick={copyKey} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm">
|
||||
{copied ? 'Copied!' : 'Copy to clipboard'}
|
||||
</button>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded-card bg-accent/10 p-1">
|
||||
<Key className="h-4 w-4 text-accent" />
|
||||
</div>
|
||||
<CardTitle>Server SSH Public Key</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="space-y-4">
|
||||
<p className="text-sm text-fg-muted">
|
||||
Add this key to the{' '}
|
||||
<code className="bg-surface-raised px-1.5 py-0.5 rounded text-xs text-fg font-mono">
|
||||
~/.ssh/authorized_keys
|
||||
</code>{' '}
|
||||
file on your remote machines to allow SyncServer to connect.
|
||||
</p>
|
||||
<div className="bg-canvas-raised rounded-card p-4 font-mono text-xs text-emerald-400/80 break-all min-h-[4rem]">
|
||||
{loading ? (
|
||||
<span className="text-fg-subtle">Loading...</span>
|
||||
) : pubKey ? (
|
||||
pubKey
|
||||
) : (
|
||||
<span className="text-fg-subtle">No public key available</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CopyButton text={pubKey} displayText="Copy public key" />
|
||||
{pubKey && (
|
||||
<Button variant="secondary" size="sm" onClick={downloadPubKey}>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Download .pub
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<div className="bg-gray-800 rounded-lg p-4">
|
||||
<h2 className="text-lg font-semibold mb-3">Quick Reference</h2>
|
||||
<div className="text-gray-400 text-sm space-y-2">
|
||||
<p><strong className="text-white">ssh-copy-id:</strong> Copy the public key above to a remote machine:</p>
|
||||
<code className="block bg-gray-900 p-2 rounded text-xs">
|
||||
cat ~/.ssh/id_ed25519.pub | ssh user@host 'cat >> ~/.ssh/authorized_keys'
|
||||
</code>
|
||||
<p className="mt-4"><strong className="text-white">Wake-on-LAN:</strong> Make sure your target machine BIOS/UEFI has WoL enabled and is connected to the same network layer (L2) as this server.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded-card bg-surface-raised p-1">
|
||||
<Terminal className="h-4 w-4 text-fg-muted" />
|
||||
</div>
|
||||
<CardTitle>Quick Reference</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-fg-muted mb-2">
|
||||
<span className="font-semibold text-fg">ssh-copy-id:</span> Copy
|
||||
the public key above to a remote machine:
|
||||
</p>
|
||||
<div className="bg-canvas-raised rounded-card p-3 font-mono text-xs text-fg-muted">
|
||||
cat ~/.ssh/id_ed25519.pub | ssh user@host 'cat >>
|
||||
~/.ssh/authorized_keys'
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-fg-muted">
|
||||
<span className="font-semibold text-fg">Wake-on-LAN:</span>{' '}
|
||||
Make sure your target machine BIOS/UEFI has WoL enabled and is
|
||||
connected to the same network layer (L2) as this server.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+423
-101
@@ -1,18 +1,73 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, SyncPair, Machine } from '../api/client';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Label } from '@/components/ui/Label';
|
||||
import { Textarea } from '@/components/ui/Textarea';
|
||||
import { Select, SelectValue, SelectTrigger, SelectContent, SelectItem } from '@/components/ui/Select';
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
ModalDescription,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
} from '@/components/ui/Modal';
|
||||
import { PageHeader } from '@/components/ui/PageHeader';
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from '@/components/ui/Table';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Play, Trash2, Plus, GitCompare, ArrowRight, ArrowLeft } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type SyncPairForm = {
|
||||
id: number | undefined;
|
||||
name: string;
|
||||
source_machine_id: number | null;
|
||||
source_path: string;
|
||||
dest_machine_id: number | null;
|
||||
dest_path: string;
|
||||
direction: 'push' | 'pull' | 'mirror';
|
||||
rsync_flags: string;
|
||||
exclude_patterns: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
const defaultForm: SyncPairForm = {
|
||||
id: undefined,
|
||||
name: '',
|
||||
source_machine_id: null,
|
||||
source_path: '',
|
||||
dest_machine_id: null,
|
||||
dest_path: '',
|
||||
direction: 'push',
|
||||
rsync_flags: '-aP',
|
||||
exclude_patterns: '',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
export default function SyncPairs() {
|
||||
const [pairs, setPairs] = useState<SyncPair[]>([]);
|
||||
const [machines, setMachines] = useState<Machine[]>([]);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
id: undefined as number | undefined, name: '', source_machine_id: null as number | null, source_path: '',
|
||||
dest_machine_id: null as number | null, dest_path: '',
|
||||
direction: 'push', rsync_flags: '-aP', exclude_patterns: '', enabled: true,
|
||||
});
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<SyncPairForm>(defaultForm);
|
||||
const [running, setRunning] = useState<Record<number, boolean>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
@@ -25,41 +80,86 @@ export default function SyncPairs() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
setForm(defaultForm);
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(p: SyncPair) {
|
||||
setForm({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
source_machine_id: p.source_machine_id,
|
||||
source_path: p.source_path,
|
||||
dest_machine_id: p.dest_machine_id,
|
||||
dest_path: p.dest_path,
|
||||
direction: p.direction as 'push' | 'pull' | 'mirror',
|
||||
rsync_flags: p.rsync_flags,
|
||||
exclude_patterns: p.exclude_patterns || '',
|
||||
enabled: p.enabled,
|
||||
});
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!form.name.trim()) {
|
||||
toast.error('Name is required');
|
||||
return;
|
||||
}
|
||||
if (!form.source_path.trim() || !form.dest_path.trim()) {
|
||||
toast.error('Source and destination paths are required');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name, source_machine_id: form.source_machine_id, source_path: form.source_path,
|
||||
dest_machine_id: form.dest_machine_id, dest_path: form.dest_path,
|
||||
direction: form.direction, rsync_flags: form.rsync_flags,
|
||||
exclude_patterns: form.exclude_patterns, enabled: form.enabled,
|
||||
};
|
||||
await api(form.id ? `/api/sync-pairs/${form.id}` : '/api/sync-pairs', {
|
||||
method: form.id ? 'PUT' : 'POST',
|
||||
body: payload,
|
||||
body: {
|
||||
name: form.name,
|
||||
source_machine_id: form.source_machine_id,
|
||||
source_path: form.source_path,
|
||||
dest_machine_id: form.dest_machine_id,
|
||||
dest_path: form.dest_path,
|
||||
direction: form.direction,
|
||||
rsync_flags: form.rsync_flags,
|
||||
exclude_patterns: form.exclude_patterns,
|
||||
enabled: form.enabled,
|
||||
},
|
||||
});
|
||||
setShowForm(false);
|
||||
resetForm();
|
||||
setModalOpen(false);
|
||||
toast.success(form.id ? 'Sync pair updated' : 'Sync pair created');
|
||||
load();
|
||||
} catch (e: unknown) { alert((e as Error).message); }
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function trigger(pairId: number) {
|
||||
setRunning(r => ({ ...r, [pairId]: true }));
|
||||
try {
|
||||
await api(`/api/sync-pairs/${pairId}/run`, { method: 'POST' });
|
||||
toast.success('Job triggered');
|
||||
load();
|
||||
} catch (e: unknown) { alert((e as Error).message); }
|
||||
setRunning(r => ({ ...r, [pairId]: false }));
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error).message);
|
||||
} finally {
|
||||
setRunning(r => ({ ...r, [pairId]: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
if (!confirm('Delete sync pair?')) return;
|
||||
try { await api(`/api/sync-pairs/${id}`, { method: 'DELETE' }); load(); } catch { alert('Delete failed'); }
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setForm({ id: undefined, name: '', source_machine_id: null, source_path: '', dest_machine_id: null, dest_path: '', direction: 'push', rsync_flags: '-aP', exclude_patterns: '', enabled: true });
|
||||
async function handleDelete() {
|
||||
if (deleteId === null) return;
|
||||
try {
|
||||
await api(`/api/sync-pairs/${deleteId}`, { method: 'DELETE' });
|
||||
toast.success('Sync pair deleted');
|
||||
setDeleteId(null);
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
function machineName(id: number | null) {
|
||||
@@ -68,85 +168,307 @@ export default function SyncPairs() {
|
||||
return m ? m.name : `Machine ${id}`;
|
||||
}
|
||||
|
||||
const directionIcon = (dir: string) => {
|
||||
if (dir === 'push') return <ArrowRight className="h-3 w-3" />
|
||||
if (dir === 'pull') return <ArrowLeft className="h-3 w-3" />
|
||||
return <GitCompare className="h-3 w-3" />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">Sync Pairs</h1>
|
||||
<button onClick={() => setShowForm(true)} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">
|
||||
Add Sync Pair
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Sync Pairs"
|
||||
description="Define source and destination for rsync operations"
|
||||
actions={
|
||||
<Button onClick={openCreate} size="sm">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Sync Pair
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<form onSubmit={handleSubmit} className="bg-gray-800 p-6 rounded-lg w-[500px] space-y-3 max-h-[90vh] overflow-y-auto">
|
||||
<h2 className="text-lg font-bold">Sync Pair</h2>
|
||||
<input placeholder="Name" value={form.name} onChange={e => setForm({...form, name: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" required />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-gray-400 text-xs">Source Machine</label>
|
||||
<select value={form.source_machine_id ?? ''} onChange={e => setForm({...form, source_machine_id: e.target.value ? +e.target.value : null })} className="w-full bg-gray-700 rounded px-3 py-2 text-white">
|
||||
<option value="">Local server</option>
|
||||
{machines.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-gray-400 text-xs">Dest Machine</label>
|
||||
<select value={form.dest_machine_id ?? ''} onChange={e => setForm({...form, dest_machine_id: e.target.value ? +e.target.value : null })} className="w-full bg-gray-700 rounded px-3 py-2 text-white">
|
||||
<option value="">Local server</option>
|
||||
{machines.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input placeholder="Source Path" value={form.source_path} onChange={e => setForm({...form, source_path: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" required />
|
||||
<input placeholder="Dest Path" value={form.dest_path} onChange={e => setForm({...form, dest_path: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" required />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<select value={form.direction} onChange={e => setForm({...form, direction: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white">
|
||||
<option value="push">Push</option>
|
||||
<option value="pull">Pull</option>
|
||||
<option value="mirror">Mirror</option>
|
||||
</select>
|
||||
<input placeholder="Rsync Flags" value={form.rsync_flags} onChange={e => setForm({...form, rsync_flags: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" />
|
||||
</div>
|
||||
<textarea placeholder="Exclude Patterns (one per line)" value={form.exclude_patterns} onChange={e => setForm({...form, exclude_patterns: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white font-mono text-sm" rows={3} />
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded flex-1">Save</button>
|
||||
<button type="button" onClick={() => { setShowForm(false); resetForm(); }} className="bg-gray-600 hover:bg-gray-500 text-white px-4 py-2 rounded">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
<Card>
|
||||
<div className="p-0">
|
||||
{pairs.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<GitCompare className="h-5 w-5" />}
|
||||
title="No sync pairs"
|
||||
description="Create a sync pair to define data transfer between machines"
|
||||
action={
|
||||
<Button onClick={openCreate} size="sm">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Sync Pair
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Source</TableHead>
|
||||
<TableHead>Destination</TableHead>
|
||||
<TableHead>Direction</TableHead>
|
||||
<TableHead>Enabled</TableHead>
|
||||
<TableHead className="w-28">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pairs.map(p => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="font-medium">{p.name}</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-mono text-xs text-fg-muted">
|
||||
{machineName(p.source_machine_id)}:{p.source_path}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-mono text-xs text-fg-muted">
|
||||
{machineName(p.dest_machine_id)}:{p.dest_path}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{directionIcon(p.direction)}
|
||||
<span className="text-xs capitalize">{p.direction}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{p.enabled ? (
|
||||
<Badge variant="success" label="Active" />
|
||||
) : (
|
||||
<Badge variant="neutral" label="Disabled" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => trigger(p.id)}
|
||||
disabled={running[p.id]}
|
||||
loading={running[p.id]}
|
||||
className="text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/10"
|
||||
title="Run now"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setDeleteId(p.id)}
|
||||
className="text-rose-400 hover:text-rose-300 hover:bg-rose-500/10"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<table className="w-full text-sm bg-gray-800 rounded-lg overflow-hidden">
|
||||
<thead className="bg-gray-700">
|
||||
<tr className="text-left text-gray-400">
|
||||
<th className="p-3">Name</th>
|
||||
<th className="p-3">Source</th>
|
||||
<th className="p-3">Dest</th>
|
||||
<th className="p-3">Direction</th>
|
||||
<th className="p-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pairs.map(p => (
|
||||
<tr key={p.id} className="border-t border-gray-700">
|
||||
<td className="p-3 font-medium">{p.name}</td>
|
||||
<td className="p-3 font-mono text-xs">{machineName(p.source_machine_id)}:{p.source_path}</td>
|
||||
<td className="p-3 font-mono text-xs">{machineName(p.dest_machine_id)}:{p.dest_path}</td>
|
||||
<td className="p-3">{p.direction}</td>
|
||||
<td className="p-3">
|
||||
<button onClick={() => trigger(p.id)} disabled={running[p.id]} className="text-green-400 hover:text-green-300 mr-3 disabled:opacity-50">
|
||||
{running[p.id] ? 'Running...' : 'Run'}
|
||||
</button>
|
||||
<button onClick={() => remove(p.id)} className="text-red-400 hover:text-red-300">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{pairs.length === 0 && <tr><td colSpan={5} className="p-4 text-center text-gray-500">No sync pairs</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
<Modal open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<ModalContent size="lg">
|
||||
<ModalHeader>
|
||||
<ModalTitle>{form.id ? 'Edit Sync Pair' : 'Add Sync Pair'}</ModalTitle>
|
||||
<ModalDescription>
|
||||
{form.id
|
||||
? 'Update the configuration for this sync pair'
|
||||
: 'Define a new source and destination for data syncing'}
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<ModalBody className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="sp-name" required>
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="sp-name"
|
||||
placeholder="backup-photos"
|
||||
value={form.name}
|
||||
onChange={e => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="sp-source-machine">Source Machine</Label>
|
||||
<Select
|
||||
value={form.source_machine_id?.toString() ?? ''}
|
||||
onValueChange={v =>
|
||||
setForm({ ...form, source_machine_id: v ? Number(v) : null })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="sp-source-machine">
|
||||
<SelectValue placeholder="Local server" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Local server</SelectItem>
|
||||
{machines.map(m => (
|
||||
<SelectItem key={m.id} value={m.id.toString()}>
|
||||
{m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="sp-dest-machine">Dest Machine</Label>
|
||||
<Select
|
||||
value={form.dest_machine_id?.toString() ?? ''}
|
||||
onValueChange={v =>
|
||||
setForm({ ...form, dest_machine_id: v ? Number(v) : null })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="sp-dest-machine">
|
||||
<SelectValue placeholder="Local server" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Local server</SelectItem>
|
||||
{machines.map(m => (
|
||||
<SelectItem key={m.id} value={m.id.toString()}>
|
||||
{m.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="sp-source-path" required>
|
||||
Source Path
|
||||
</Label>
|
||||
<Input
|
||||
id="sp-source-path"
|
||||
placeholder="/data/photos"
|
||||
value={form.source_path}
|
||||
onChange={e =>
|
||||
setForm({ ...form, source_path: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="sp-dest-path" required>
|
||||
Dest Path
|
||||
</Label>
|
||||
<Input
|
||||
id="sp-dest-path"
|
||||
placeholder="/backup/photos"
|
||||
value={form.dest_path}
|
||||
onChange={e =>
|
||||
setForm({ ...form, dest_path: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="sp-direction">Direction</Label>
|
||||
<Select
|
||||
value={form.direction}
|
||||
onValueChange={(v: 'push' | 'pull' | 'mirror') =>
|
||||
setForm({ ...form, direction: v })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="sp-direction">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="push">Push</SelectItem>
|
||||
<SelectItem value="pull">Pull</SelectItem>
|
||||
<SelectItem value="mirror">Mirror</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="sp-rsync-flags">Rsync Flags</Label>
|
||||
<Input
|
||||
id="sp-rsync-flags"
|
||||
placeholder="-aP"
|
||||
value={form.rsync_flags}
|
||||
onChange={e =>
|
||||
setForm({ ...form, rsync_flags: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="sp-exclude">Exclude Patterns</Label>
|
||||
<Textarea
|
||||
id="sp-exclude"
|
||||
placeholder="node_modules .git *.tmp"
|
||||
value={form.exclude_patterns}
|
||||
onChange={e =>
|
||||
setForm({ ...form, exclude_patterns: e.target.value })
|
||||
}
|
||||
className="font-mono text-xs"
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-xs text-fg-subtle">
|
||||
One pattern per line
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-card p-3 transition-colors',
|
||||
form.enabled
|
||||
? 'bg-accent/5 border border-accent/20'
|
||||
: 'bg-surface-raised border border-border'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="sp-enabled"
|
||||
checked={form.enabled}
|
||||
onChange={e => setForm({ ...form, enabled: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-border accent-accent"
|
||||
/>
|
||||
<Label htmlFor="sp-enabled" className="cursor-pointer mb-0">
|
||||
Enable this sync pair
|
||||
</Label>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setModalOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={loading}>
|
||||
{form.id ? 'Save Changes' : 'Add Sync Pair'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<Modal open={deleteId !== null} onOpenChange={v => !v && setDeleteId(null)}>
|
||||
<ModalContent size="sm">
|
||||
<ModalHeader>
|
||||
<ModalTitle>Delete Sync Pair</ModalTitle>
|
||||
<ModalDescription>
|
||||
Are you sure you want to delete this sync pair? All associated
|
||||
job history will remain.
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
<ModalFooter>
|
||||
<Button variant="secondary" onClick={() => setDeleteId(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger-solid" onClick={handleDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user