@gravito/zenith 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +88 -0
- package/BATCH_OPERATIONS_IMPLEMENTATION.md +159 -0
- package/DEMO.md +156 -0
- package/DEPLOYMENT.md +157 -0
- package/DOCS_INTERNAL.md +73 -0
- package/Dockerfile +46 -0
- package/Dockerfile.demo-worker +29 -0
- package/EVOLUTION_BLUEPRINT.md +112 -0
- package/JOBINSPECTOR_SCROLL_FIX.md +152 -0
- package/PULSE_IMPLEMENTATION_PLAN.md +111 -0
- package/QUICK_TEST_GUIDE.md +72 -0
- package/README.md +33 -0
- package/ROADMAP.md +85 -0
- package/TESTING_BATCH_OPERATIONS.md +252 -0
- package/bin/flux-console.ts +2 -0
- package/dist/bin.js +108196 -0
- package/dist/client/assets/index-DGYEwTDL.css +1 -0
- package/dist/client/assets/index-oyTdySX0.js +421 -0
- package/dist/client/index.html +13 -0
- package/dist/server/index.js +108191 -0
- package/docker-compose.yml +40 -0
- package/docs/integrations/LARAVEL.md +207 -0
- package/package.json +50 -0
- package/postcss.config.js +6 -0
- package/scripts/flood-logs.ts +21 -0
- package/scripts/seed.ts +213 -0
- package/scripts/verify-throttle.ts +45 -0
- package/scripts/worker.ts +123 -0
- package/src/bin.ts +6 -0
- package/src/client/App.tsx +70 -0
- package/src/client/Layout.tsx +644 -0
- package/src/client/Sidebar.tsx +102 -0
- package/src/client/ThroughputChart.tsx +135 -0
- package/src/client/WorkerStatus.tsx +170 -0
- package/src/client/components/ConfirmDialog.tsx +103 -0
- package/src/client/components/JobInspector.tsx +524 -0
- package/src/client/components/LogArchiveModal.tsx +383 -0
- package/src/client/components/NotificationBell.tsx +203 -0
- package/src/client/components/Toaster.tsx +80 -0
- package/src/client/components/UserProfileDropdown.tsx +177 -0
- package/src/client/contexts/AuthContext.tsx +93 -0
- package/src/client/contexts/NotificationContext.tsx +103 -0
- package/src/client/index.css +174 -0
- package/src/client/index.html +12 -0
- package/src/client/main.tsx +15 -0
- package/src/client/pages/LoginPage.tsx +153 -0
- package/src/client/pages/MetricsPage.tsx +408 -0
- package/src/client/pages/OverviewPage.tsx +511 -0
- package/src/client/pages/QueuesPage.tsx +372 -0
- package/src/client/pages/SchedulesPage.tsx +531 -0
- package/src/client/pages/SettingsPage.tsx +449 -0
- package/src/client/pages/WorkersPage.tsx +316 -0
- package/src/client/pages/index.ts +7 -0
- package/src/client/utils.ts +6 -0
- package/src/server/index.ts +556 -0
- package/src/server/middleware/auth.ts +127 -0
- package/src/server/services/AlertService.ts +160 -0
- package/src/server/services/QueueService.ts +828 -0
- package/tailwind.config.js +73 -0
- package/tests/placeholder.test.ts +7 -0
- package/tsconfig.json +38 -0
- package/tsconfig.node.json +12 -0
- package/vite.config.ts +27 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { motion } from 'framer-motion'
|
|
2
|
+
import {
|
|
3
|
+
Activity,
|
|
4
|
+
ChevronLeft,
|
|
5
|
+
ChevronRight,
|
|
6
|
+
Clock,
|
|
7
|
+
HardDrive,
|
|
8
|
+
LayoutDashboard,
|
|
9
|
+
ListTree,
|
|
10
|
+
Settings,
|
|
11
|
+
} from 'lucide-react'
|
|
12
|
+
import { NavLink, useLocation } from 'react-router-dom'
|
|
13
|
+
import { cn } from './utils'
|
|
14
|
+
|
|
15
|
+
interface SidebarProps {
|
|
16
|
+
className?: string
|
|
17
|
+
collapsed: boolean
|
|
18
|
+
toggleCollapse: () => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function Sidebar({ className, collapsed, toggleCollapse }: SidebarProps) {
|
|
22
|
+
const location = useLocation()
|
|
23
|
+
|
|
24
|
+
const navItems = [
|
|
25
|
+
{ icon: LayoutDashboard, label: 'Overview', path: '/' },
|
|
26
|
+
{ icon: ListTree, label: 'Queues', path: '/queues' },
|
|
27
|
+
{ icon: Clock, label: 'Schedules', path: '/schedules' },
|
|
28
|
+
{ icon: HardDrive, label: 'Workers', path: '/workers' },
|
|
29
|
+
{ icon: Activity, label: 'Metrics', path: '/metrics' },
|
|
30
|
+
{ icon: Settings, label: 'Settings', path: '/settings' },
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div
|
|
35
|
+
className={cn(
|
|
36
|
+
'flex-1 flex flex-col justify-between transition-all duration-300 ease-in-out relative group z-20 overflow-hidden',
|
|
37
|
+
className
|
|
38
|
+
)}
|
|
39
|
+
>
|
|
40
|
+
{/* Nav Items */}
|
|
41
|
+
<nav className="flex-1 px-4 py-6 space-y-2">
|
|
42
|
+
{navItems.map((item, i) => {
|
|
43
|
+
const isActive = location.pathname === item.path
|
|
44
|
+
return (
|
|
45
|
+
<NavLink
|
|
46
|
+
key={i}
|
|
47
|
+
to={item.path}
|
|
48
|
+
className={cn(
|
|
49
|
+
'w-full flex items-center gap-4 px-4 py-3 rounded-xl transition-all text-muted-foreground group/item relative overflow-hidden',
|
|
50
|
+
isActive
|
|
51
|
+
? 'bg-primary text-primary-foreground shadow-lg shadow-primary/20 hover:scale-[1.02]'
|
|
52
|
+
: 'hover:bg-muted font-medium hover:text-foreground active:scale-95'
|
|
53
|
+
)}
|
|
54
|
+
>
|
|
55
|
+
<item.icon
|
|
56
|
+
size={22}
|
|
57
|
+
className={cn(
|
|
58
|
+
'transition-all shrink-0',
|
|
59
|
+
isActive ? 'scale-110' : 'group-hover/item:scale-110'
|
|
60
|
+
)}
|
|
61
|
+
/>
|
|
62
|
+
<motion.span
|
|
63
|
+
initial={false}
|
|
64
|
+
animate={{
|
|
65
|
+
opacity: collapsed ? 0 : 1,
|
|
66
|
+
display: collapsed ? 'none' : 'block',
|
|
67
|
+
}}
|
|
68
|
+
className="font-semibold whitespace-nowrap tracking-tight"
|
|
69
|
+
>
|
|
70
|
+
{item.label}
|
|
71
|
+
</motion.span>
|
|
72
|
+
{isActive && (
|
|
73
|
+
<motion.div
|
|
74
|
+
layoutId="active-pill"
|
|
75
|
+
className="absolute left-0 w-1 h-6 bg-primary-foreground rounded-r-full"
|
|
76
|
+
/>
|
|
77
|
+
)}
|
|
78
|
+
</NavLink>
|
|
79
|
+
)
|
|
80
|
+
})}
|
|
81
|
+
</nav>
|
|
82
|
+
|
|
83
|
+
{/* Footer / Toggle */}
|
|
84
|
+
<div className="p-4 border-t border-border/50">
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
onClick={toggleCollapse}
|
|
88
|
+
className="w-full flex items-center justify-center h-10 rounded-xl hover:bg-muted text-muted-foreground hover:text-foreground transition-all active:scale-90"
|
|
89
|
+
>
|
|
90
|
+
{collapsed ? (
|
|
91
|
+
<ChevronRight size={20} />
|
|
92
|
+
) : (
|
|
93
|
+
<div className="flex items-center gap-2 px-2">
|
|
94
|
+
<ChevronLeft size={18} />{' '}
|
|
95
|
+
<span className="text-xs font-black uppercase tracking-widest">Collapse Sidebar</span>
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
98
|
+
</button>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { useQuery } from '@tanstack/react-query'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
import {
|
|
4
|
+
Area,
|
|
5
|
+
AreaChart,
|
|
6
|
+
CartesianGrid,
|
|
7
|
+
ResponsiveContainer,
|
|
8
|
+
Tooltip,
|
|
9
|
+
XAxis,
|
|
10
|
+
YAxis,
|
|
11
|
+
} from 'recharts'
|
|
12
|
+
|
|
13
|
+
interface ThroughputPoint {
|
|
14
|
+
timestamp: string
|
|
15
|
+
count: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function ThroughputChart() {
|
|
19
|
+
// Initial fetch via React Query
|
|
20
|
+
const { data: initialData } = useQuery({
|
|
21
|
+
queryKey: ['throughput'],
|
|
22
|
+
queryFn: async () => {
|
|
23
|
+
const res = await fetch('/api/throughput')
|
|
24
|
+
const json = await res.json()
|
|
25
|
+
return json.data || []
|
|
26
|
+
},
|
|
27
|
+
staleTime: Infinity, // Don't refetch automatically
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const [throughputData, setThroughputData] = React.useState<ThroughputPoint[]>([])
|
|
31
|
+
|
|
32
|
+
// Sync with initial data
|
|
33
|
+
React.useEffect(() => {
|
|
34
|
+
if (initialData) {
|
|
35
|
+
setThroughputData(initialData)
|
|
36
|
+
}
|
|
37
|
+
}, [initialData])
|
|
38
|
+
|
|
39
|
+
// Listen for live updates
|
|
40
|
+
React.useEffect(() => {
|
|
41
|
+
const handler = (e: Event) => {
|
|
42
|
+
const customEvent = e as CustomEvent
|
|
43
|
+
if (customEvent.detail?.throughput) {
|
|
44
|
+
setThroughputData(customEvent.detail.throughput)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
window.addEventListener('flux-stats-update', handler)
|
|
48
|
+
return () => window.removeEventListener('flux-stats-update', handler)
|
|
49
|
+
}, [])
|
|
50
|
+
|
|
51
|
+
const chartData =
|
|
52
|
+
throughputData?.map((d: ThroughputPoint) => ({
|
|
53
|
+
time: d.timestamp,
|
|
54
|
+
value: d.count,
|
|
55
|
+
})) || []
|
|
56
|
+
return (
|
|
57
|
+
<div className="card-premium h-[350px] w-full p-6 flex flex-col relative overflow-hidden group">
|
|
58
|
+
{/* Background Accent */}
|
|
59
|
+
<div className="absolute top-0 right-0 w-64 h-64 bg-primary/5 rounded-full -translate-y-1/2 translate-x-1/2 blur-3xl" />
|
|
60
|
+
|
|
61
|
+
<div className="flex justify-between items-start mb-6 z-10">
|
|
62
|
+
<div>
|
|
63
|
+
<div className="flex items-center gap-2">
|
|
64
|
+
<h3 className="text-xl font-bold tracking-tight">System Throughput</h3>
|
|
65
|
+
<div className="flex items-center gap-1.5 px-2 py-0.5 bg-green-500/10 text-green-500 text-[8px] font-black uppercase tracking-widest rounded-full border border-green-500/20">
|
|
66
|
+
<span className="w-1 h-1 bg-green-500 rounded-full animate-ping"></span>
|
|
67
|
+
Live
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
<p className="text-[10px] text-muted-foreground uppercase tracking-[0.2em] font-bold mt-1">
|
|
71
|
+
Jobs processed per minute
|
|
72
|
+
</p>
|
|
73
|
+
</div>
|
|
74
|
+
<div className="text-right">
|
|
75
|
+
<p className="text-2xl font-black text-foreground">
|
|
76
|
+
{chartData[chartData.length - 1]?.value || 0}
|
|
77
|
+
</p>
|
|
78
|
+
<p className="text-[8px] text-muted-foreground uppercase font-bold">Current Rate</p>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<div className="flex-1 w-full min-h-0">
|
|
83
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
84
|
+
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
|
|
85
|
+
<defs>
|
|
86
|
+
<linearGradient id="colorValue" x1="0" y1="0" x2="0" y2="1">
|
|
87
|
+
<stop offset="5%" stopColor="hsl(var(--primary))" stopOpacity={0.4} />
|
|
88
|
+
<stop offset="50%" stopColor="hsl(var(--primary))" stopOpacity={0.1} />
|
|
89
|
+
<stop offset="95%" stopColor="hsl(var(--primary))" stopOpacity={0} />
|
|
90
|
+
</linearGradient>
|
|
91
|
+
</defs>
|
|
92
|
+
<CartesianGrid
|
|
93
|
+
strokeDasharray="3 3"
|
|
94
|
+
vertical={false}
|
|
95
|
+
stroke="hsl(var(--border))"
|
|
96
|
+
opacity={0.5}
|
|
97
|
+
/>
|
|
98
|
+
<XAxis
|
|
99
|
+
dataKey="time"
|
|
100
|
+
axisLine={false}
|
|
101
|
+
tickLine={false}
|
|
102
|
+
tick={{ fontSize: 9, fill: 'hsl(var(--muted-foreground))', fontWeight: 600 }}
|
|
103
|
+
dy={10}
|
|
104
|
+
/>
|
|
105
|
+
<YAxis
|
|
106
|
+
axisLine={false}
|
|
107
|
+
tickLine={false}
|
|
108
|
+
tick={{ fontSize: 9, fill: 'hsl(var(--muted-foreground))', fontWeight: 600 }}
|
|
109
|
+
/>
|
|
110
|
+
<Tooltip
|
|
111
|
+
cursor={{ stroke: 'hsl(var(--primary))', strokeWidth: 1, strokeDasharray: '4 4' }}
|
|
112
|
+
contentStyle={{
|
|
113
|
+
backgroundColor: 'hsl(var(--card))',
|
|
114
|
+
border: '1px solid hsl(var(--border))',
|
|
115
|
+
borderRadius: '16px',
|
|
116
|
+
fontSize: '12px',
|
|
117
|
+
boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)',
|
|
118
|
+
}}
|
|
119
|
+
itemStyle={{ fontWeight: 'bold', color: 'hsl(var(--primary))' }}
|
|
120
|
+
/>
|
|
121
|
+
<Area
|
|
122
|
+
type="stepAfter"
|
|
123
|
+
dataKey="value"
|
|
124
|
+
stroke="hsl(var(--primary))"
|
|
125
|
+
fillOpacity={1}
|
|
126
|
+
fill="url(#colorValue)"
|
|
127
|
+
strokeWidth={2.5}
|
|
128
|
+
animationDuration={1500}
|
|
129
|
+
/>
|
|
130
|
+
</AreaChart>
|
|
131
|
+
</ResponsiveContainer>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { type ClassValue, clsx } from 'clsx'
|
|
2
|
+
import { Activity, Cpu } from 'lucide-react'
|
|
3
|
+
import { twMerge } from 'tailwind-merge'
|
|
4
|
+
|
|
5
|
+
function cn(...inputs: ClassValue[]) {
|
|
6
|
+
return twMerge(clsx(inputs))
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface WorkerInfo {
|
|
10
|
+
id: string
|
|
11
|
+
status: 'online' | 'offline'
|
|
12
|
+
pid: number
|
|
13
|
+
uptime: number
|
|
14
|
+
metrics?: {
|
|
15
|
+
cpu: number
|
|
16
|
+
cores: number
|
|
17
|
+
ram: {
|
|
18
|
+
rss: number
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function WorkerStatus({
|
|
24
|
+
highlightedWorkerId,
|
|
25
|
+
workers = [],
|
|
26
|
+
}: {
|
|
27
|
+
highlightedWorkerId?: string | null
|
|
28
|
+
workers?: WorkerInfo[]
|
|
29
|
+
}) {
|
|
30
|
+
// Legacy polling removed, now using passed props
|
|
31
|
+
// const { data: workerData } = useQuery<{ workers: any[] }>({ ... })
|
|
32
|
+
|
|
33
|
+
// Fallback if not passed (though it should be)
|
|
34
|
+
// const workers = workerData?.workers || []
|
|
35
|
+
|
|
36
|
+
const onlineCount = workers.filter((w) => w.status === 'online').length
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="card-premium p-6 h-full">
|
|
40
|
+
<div className="flex justify-between items-center mb-8">
|
|
41
|
+
<div>
|
|
42
|
+
<h3 className="text-lg font-black flex items-center gap-2 tracking-tight">
|
|
43
|
+
<Cpu size={20} className="text-primary" />
|
|
44
|
+
Cluster Nodes
|
|
45
|
+
</h3>
|
|
46
|
+
<p className="text-[10px] text-muted-foreground uppercase font-black tracking-widest opacity-60">
|
|
47
|
+
Real-time load
|
|
48
|
+
</p>
|
|
49
|
+
</div>
|
|
50
|
+
<span className="text-[10px] font-black text-green-500 bg-green-500/10 px-3 py-1 rounded-full uppercase tracking-widest border border-green-500/20">
|
|
51
|
+
{onlineCount} ACTIVE
|
|
52
|
+
</span>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<div className="space-y-3">
|
|
56
|
+
{workers.length === 0 && (
|
|
57
|
+
<div className="py-12 text-center text-muted-foreground/30 flex flex-col items-center gap-2">
|
|
58
|
+
<Activity size={24} className="opacity-20 animate-pulse" />
|
|
59
|
+
<p className="text-[10px] font-black uppercase tracking-widest">No nodes connected</p>
|
|
60
|
+
</div>
|
|
61
|
+
)}
|
|
62
|
+
|
|
63
|
+
{workers.map((worker) => (
|
|
64
|
+
<div
|
|
65
|
+
key={worker.id}
|
|
66
|
+
className={cn(
|
|
67
|
+
'flex items-center justify-between p-4 rounded-2xl bg-muted/10 border transition-all group overflow-hidden relative',
|
|
68
|
+
worker.id === highlightedWorkerId
|
|
69
|
+
? 'border-primary/50 bg-primary/5 shadow-[0_0_20px_rgba(var(--primary-rgb),0.1)] -translate-y-1 scale-[1.02] z-10'
|
|
70
|
+
: 'border-transparent hover:border-primary/20 hover:bg-muted/20'
|
|
71
|
+
)}
|
|
72
|
+
>
|
|
73
|
+
{/* Status bar */}
|
|
74
|
+
<div
|
|
75
|
+
className={cn(
|
|
76
|
+
'absolute left-0 top-0 bottom-0 w-1 transition-all',
|
|
77
|
+
worker.status === 'online' ? 'bg-green-500' : 'bg-muted-foreground/30'
|
|
78
|
+
)}
|
|
79
|
+
/>
|
|
80
|
+
|
|
81
|
+
<div className="flex items-center gap-4">
|
|
82
|
+
<div className="relative">
|
|
83
|
+
<div
|
|
84
|
+
className={cn(
|
|
85
|
+
'w-3 h-3 rounded-full',
|
|
86
|
+
worker.status === 'online'
|
|
87
|
+
? 'bg-green-500 animate-pulse shadow-[0_0_12px_rgba(34,197,94,0.6)]'
|
|
88
|
+
: 'bg-muted-foreground/40'
|
|
89
|
+
)}
|
|
90
|
+
></div>
|
|
91
|
+
</div>
|
|
92
|
+
<div>
|
|
93
|
+
<p className="text-sm font-black tracking-tight group-hover:text-primary transition-colors">
|
|
94
|
+
{worker.id}
|
|
95
|
+
</p>
|
|
96
|
+
<div className="flex items-center gap-2">
|
|
97
|
+
<span className="text-[9px] font-black uppercase tracking-tighter opacity-50">
|
|
98
|
+
{worker.status}
|
|
99
|
+
</span>
|
|
100
|
+
<span className="text-[9px] font-black text-primary/60">PID {worker.pid}</span>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<div className="flex items-center gap-6">
|
|
106
|
+
{worker.metrics && (
|
|
107
|
+
<div className="flex gap-4">
|
|
108
|
+
<div className="space-y-1.5 w-20">
|
|
109
|
+
<div className="flex justify-between text-[8px] font-black text-muted-foreground uppercase tracking-tighter">
|
|
110
|
+
<span>LOAD ({worker.metrics.cores || '-'})</span>
|
|
111
|
+
<span
|
|
112
|
+
className={cn(
|
|
113
|
+
worker.metrics.cpu > (worker.metrics.cores || 4) && 'text-red-500'
|
|
114
|
+
)}
|
|
115
|
+
>
|
|
116
|
+
{worker.metrics.cpu.toFixed(2)}
|
|
117
|
+
</span>
|
|
118
|
+
</div>
|
|
119
|
+
<div className="h-1 w-full bg-muted rounded-full overflow-hidden">
|
|
120
|
+
<div
|
|
121
|
+
className={cn(
|
|
122
|
+
'h-full transition-all duration-700',
|
|
123
|
+
worker.metrics.cpu > (worker.metrics.cores || 4)
|
|
124
|
+
? 'bg-red-500'
|
|
125
|
+
: worker.metrics.cpu > (worker.metrics.cores || 4) * 0.7
|
|
126
|
+
? 'bg-amber-500'
|
|
127
|
+
: 'bg-green-500'
|
|
128
|
+
)}
|
|
129
|
+
style={{
|
|
130
|
+
width: `${Math.min(100, (worker.metrics.cpu / (worker.metrics.cores || 1)) * 100)}%`,
|
|
131
|
+
}}
|
|
132
|
+
/>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
<div className="space-y-1.5 w-16">
|
|
136
|
+
<div className="flex justify-between text-[8px] font-black text-muted-foreground uppercase tracking-tighter">
|
|
137
|
+
<span>RAM</span>
|
|
138
|
+
<span>{Math.round(worker.metrics.ram.rss / 1024)}G</span>
|
|
139
|
+
</div>
|
|
140
|
+
<div className="h-1 w-full bg-muted rounded-full overflow-hidden">
|
|
141
|
+
<div
|
|
142
|
+
className="h-full bg-indigo-500 transition-all duration-700"
|
|
143
|
+
style={{
|
|
144
|
+
width: `${Math.min(100, (worker.metrics.ram.rss / 2048) * 100)}%`,
|
|
145
|
+
}}
|
|
146
|
+
/>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
)}
|
|
151
|
+
<div className="text-right whitespace-nowrap hidden sm:block">
|
|
152
|
+
<p className="text-sm font-black tracking-tighter">{worker.uptime}s</p>
|
|
153
|
+
<p className="text-[8px] text-muted-foreground uppercase font-black tracking-widest opacity-50">
|
|
154
|
+
UPTIME
|
|
155
|
+
</p>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
))}
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<button
|
|
163
|
+
type="button"
|
|
164
|
+
className="w-full mt-8 py-3 bg-muted text-[10px] font-black rounded-xl hover:bg-primary hover:text-primary-foreground transition-all uppercase tracking-[0.2em] opacity-60 hover:opacity-100 active:scale-95 shadow-lg shadow-transparent hover:shadow-primary/20"
|
|
165
|
+
>
|
|
166
|
+
Manage Nodes
|
|
167
|
+
</button>
|
|
168
|
+
</div>
|
|
169
|
+
)
|
|
170
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { AnimatePresence, motion } from 'framer-motion'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import { cn } from '../utils'
|
|
4
|
+
|
|
5
|
+
export interface ConfirmDialogProps {
|
|
6
|
+
open: boolean
|
|
7
|
+
title: string
|
|
8
|
+
message: string
|
|
9
|
+
confirmText?: string
|
|
10
|
+
cancelText?: string
|
|
11
|
+
onConfirm: () => void
|
|
12
|
+
onCancel: () => void
|
|
13
|
+
variant?: 'danger' | 'warning' | 'info'
|
|
14
|
+
isProcessing?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function ConfirmDialog({
|
|
18
|
+
open,
|
|
19
|
+
title,
|
|
20
|
+
message,
|
|
21
|
+
confirmText = 'Confirm',
|
|
22
|
+
cancelText = 'Cancel',
|
|
23
|
+
onConfirm,
|
|
24
|
+
onCancel,
|
|
25
|
+
variant = 'danger',
|
|
26
|
+
isProcessing = false,
|
|
27
|
+
}: ConfirmDialogProps) {
|
|
28
|
+
return createPortal(
|
|
29
|
+
<AnimatePresence>
|
|
30
|
+
{open && (
|
|
31
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: Backdrop needs click handler to stop propagation
|
|
32
|
+
<div
|
|
33
|
+
role="presentation"
|
|
34
|
+
className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-[5000] pointer-events-auto"
|
|
35
|
+
onClick={(e) => e.stopPropagation()}
|
|
36
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
37
|
+
>
|
|
38
|
+
{/* biome-ignore lint/a11y/noStaticElementInteractions: Modal content needs to stop propagation */}
|
|
39
|
+
<motion.div
|
|
40
|
+
initial={{ scale: 0.9, opacity: 0 }}
|
|
41
|
+
animate={{ scale: 1, opacity: 1 }}
|
|
42
|
+
exit={{ scale: 0.9, opacity: 0 }}
|
|
43
|
+
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
|
44
|
+
className="bg-card border rounded-2xl p-6 max-w-md shadow-2xl"
|
|
45
|
+
onClick={(e) => e.stopPropagation()}
|
|
46
|
+
>
|
|
47
|
+
<h3 className="text-xl font-black mb-2">{title}</h3>
|
|
48
|
+
<p className="text-sm text-muted-foreground mb-6 whitespace-pre-line">{message}</p>
|
|
49
|
+
<div className="flex gap-3 justify-end">
|
|
50
|
+
<button
|
|
51
|
+
type="button"
|
|
52
|
+
onClick={(e) => {
|
|
53
|
+
e.stopPropagation()
|
|
54
|
+
onCancel()
|
|
55
|
+
}}
|
|
56
|
+
disabled={isProcessing}
|
|
57
|
+
className="px-4 py-2 bg-muted text-foreground rounded-lg hover:bg-muted/80 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
|
58
|
+
>
|
|
59
|
+
{cancelText}
|
|
60
|
+
</button>
|
|
61
|
+
<button
|
|
62
|
+
type="button"
|
|
63
|
+
onClick={(e) => {
|
|
64
|
+
e.stopPropagation()
|
|
65
|
+
onConfirm()
|
|
66
|
+
}}
|
|
67
|
+
disabled={isProcessing}
|
|
68
|
+
className={cn(
|
|
69
|
+
'px-4 py-2 rounded-lg text-white transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2',
|
|
70
|
+
variant === 'danger' && 'bg-red-500 hover:bg-red-600',
|
|
71
|
+
variant === 'warning' && 'bg-amber-500 hover:bg-amber-600',
|
|
72
|
+
variant === 'info' && 'bg-blue-500 hover:bg-blue-600'
|
|
73
|
+
)}
|
|
74
|
+
>
|
|
75
|
+
{isProcessing && (
|
|
76
|
+
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" aria-label="Loading">
|
|
77
|
+
<title>Loading</title>
|
|
78
|
+
<circle
|
|
79
|
+
className="opacity-25"
|
|
80
|
+
cx="12"
|
|
81
|
+
cy="12"
|
|
82
|
+
r="10"
|
|
83
|
+
stroke="currentColor"
|
|
84
|
+
strokeWidth="4"
|
|
85
|
+
fill="none"
|
|
86
|
+
/>
|
|
87
|
+
<path
|
|
88
|
+
className="opacity-75"
|
|
89
|
+
fill="currentColor"
|
|
90
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
91
|
+
/>
|
|
92
|
+
</svg>
|
|
93
|
+
)}
|
|
94
|
+
{isProcessing ? 'Processing...' : confirmText}
|
|
95
|
+
</button>
|
|
96
|
+
</div>
|
|
97
|
+
</motion.div>
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
</AnimatePresence>,
|
|
101
|
+
document.body
|
|
102
|
+
)
|
|
103
|
+
}
|