feat(monitor): integração Beszel/Stack Observer, tickets do observer e novos sites no uptime

This commit is contained in:
2026-10-28 16:00:59 +00:00
parent 2bbc12e8b4
commit ca9a07b1d9
12 changed files with 1110 additions and 7 deletions
+1
View File
@@ -37,6 +37,7 @@ const NAV_ITEMS: NavItem[] = [
{ to: '/ai', label: 'IA / Claude', icon: Brain },
{ to: '/operations', label: 'Operações', icon: ClipboardList },
{ to: '/sessions', label: 'Espelho', icon: Eye },
{ to: '/stack-observer', label: 'Stack Observer', icon: Zap },
]
function useIsMobile() {
+2 -2
View File
@@ -13,9 +13,8 @@ import AiOverview from './pages/AiOverview.tsx'
import Operations from './pages/Operations.tsx'
import Sessions from './pages/Sessions.tsx'
import SessionDetail from './pages/SessionDetail.tsx'
import StackObserver from './pages/StackObserver.tsx'
import Layout from './components/Layout.tsx'
import { oidcConfig } from './auth/config.ts'
import { AuthWrapper } from './auth/AuthWrapper.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
@@ -35,6 +34,7 @@ createRoot(document.getElementById('root')!).render(
<Route path="/sessions" element={<Sessions />} />
<Route path="/sessions/:id" element={<SessionDetail />} />
<Route path="/callback" element={<App />} />
<Route path="/stack-observer" element={<StackObserver />} />
</Route>
</Routes>
</AuthWrapper>
+87 -4
View File
@@ -18,6 +18,7 @@ import {
Cpu,
MemoryStick,
MonitorDot,
Laptop2,
} from 'lucide-react'
// Types
@@ -60,6 +61,24 @@ interface VMConfig {
accentBg: string
accentBorder: string
}
interface FleetSystem {
name: string
host: string
status: string
cpu: number
mem: number
disk: number
containers: number
load: number
uptime: number
}
interface FleetData {
total: number
up: number
down: number
systems: FleetSystem[]
}
// VM definitions matching cluster architecture
const VM_CONFIG: Record<string, VMConfig> = {
@@ -331,16 +350,25 @@ const VMCard = ({ item, config }: { item?: MonitorItem; config: VMConfig }) => {
export default function Monitor() {
const [data, setData] = useState<MonitorData | null>(null)
const [fleet, setFleet] = useState<FleetData | null>(null)
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const fetchData = useCallback(async () => {
setRefreshing(true)
try {
const response = await fetch('/api/monitor')
if (!response.ok) throw new Error('Failed')
const json = await response.json()
setData(json)
const [monitorRes, fleetRes] = await Promise.all([
fetch('/api/monitor'),
fetch('/api/beszel'),
])
if (monitorRes.ok) {
const json = await monitorRes.json()
setData(json)
}
if (fleetRes.ok) {
const json = await fleetRes.json()
setFleet(json)
}
} catch {
if (import.meta.env.DEV) {
setData(getMockData())
@@ -421,6 +449,61 @@ export default function Monitor() {
<VMCard key={key} item={getVMItem(key)} config={config} />
))}
</div>
{/* Section 2.5: Fleet Beszel */}
{fleet && fleet.systems.length > 0 && (
<motion.div variants={itemVariants} className="mt-8">
<div className="glass-card overflow-hidden">
<div className="px-5 py-4 border-b border-white/5 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white flex items-center gap-2 uppercase tracking-wide">
<Laptop2 className="w-4 h-4 text-cyan-400" />
Fleet — {fleet.up}/{fleet.total} activas
</h3>
<div className="flex items-center gap-2">
{fleet.down > 0 && (
<span className="text-xs bg-red-500/20 text-red-400 px-2 py-1 rounded-full">{fleet.down} down</span>
)}
<a
href="https://descomplicar-beszel.i7zsqo.easypanel.host/"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
>
Abrir Beszel →
</a>
</div>
</div>
<div className="p-4">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
{fleet.systems.sort((a, b) => a.name.localeCompare(b.name)).map((sys) => (
<div
key={sys.name}
className={`p-3 rounded-xl border transition-colors ${
sys.status === 'up'
? 'bg-white/[0.02] border-white/5 hover:bg-white/[0.04]'
: 'bg-red-500/5 border-red-500/10'
}`}
>
<div className="flex items-center gap-2 mb-2">
<StatusDot status={sys.status} />
<span className="text-sm font-medium text-white truncate">{sys.name}</span>
</div>
<div className="space-y-1.5">
<ProgressBar percent={sys.cpu} size="sm" />
<div className="flex justify-between text-[10px] text-zinc-500">
<span>RAM {sys.mem}%</span>
<span>DK {sys.disk}%</span>
</div>
{sys.containers > 0 && (
<div className="text-[10px] text-zinc-600">{sys.containers} containers</div>
)}
</div>
</div>
))}
</div>
</div>
</div>
</motion.div>
)}
{/* Section 3: Detail Categories */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 mt-8">
+309
View File
@@ -0,0 +1,309 @@
import { useState, useEffect } from 'react'
import { motion } from 'framer-motion'
import {
AlertTriangle,
CheckCircle2,
Clock,
TrendingUp,
Zap,
RefreshCw,
AlertCircle,
} from 'lucide-react'
interface Evaluation {
id: number
source: string
entity_id: string
result_real: string
result_nominal: string
confidence: number
evaluated_at: number
category: string
judge_method: string
}
interface DivergenceStats {
total_divergences: number
by_category: Record<string, number>
by_judge: Record<string, number>
recent_24h: number
high_confidence: number
}
interface DivergenceData {
divergences: Evaluation[]
stats: DivergenceStats
generated_at: string
}
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.1 },
},
}
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.4 },
},
}
const StatusBadge = ({ status }: { status: string }) => {
const styles: Record<string, { bg: string; text: string; icon: React.ReactNode }> = {
'automation-liveness': {
bg: 'bg-red-500/10',
text: 'text-red-400',
icon: <AlertTriangle className="w-4 h-4" />,
},
'automation-failure': {
bg: 'bg-orange-500/10',
text: 'text-orange-400',
icon: <AlertCircle className="w-4 h-4" />,
},
}
const style = styles[status] || styles['automation-failure']
return (
<div className={`flex items-center gap-2 px-3 py-1 rounded-full ${style.bg} text-sm ${style.text}`}>
{style.icon}
{status.replace('-', ' ')}
</div>
)
}
const StatCard = ({
label,
value,
icon: Icon,
color = 'text-blue-400',
}: {
label: string
value: number | string
icon: React.ElementType
color?: string
}) => (
<motion.div
variants={itemVariants}
className="glass-card p-4 rounded-xl flex items-center gap-4"
>
<div className={`${color} p-3 rounded-lg bg-white/5`}>
<Icon className="w-6 h-6" />
</div>
<div>
<p className="text-sm text-white/60">{label}</p>
<p className="text-2xl font-bold text-white">{value}</p>
</div>
</motion.div>
)
const DivergenceRow = ({ div }: { div: Evaluation }) => {
const timestamp = new Date(div.evaluated_at * 1000).toLocaleString('pt-PT')
const confidenceColor = div.confidence >= 0.95 ? 'bg-green-500' : div.confidence >= 0.9 ? 'bg-yellow-500' : 'bg-orange-500'
return (
<motion.tr
variants={itemVariants}
className="border-b border-white/10 hover:bg-white/[0.03] transition-colors"
>
<td className="px-4 py-3 text-sm text-white/80 font-mono">{div.id}</td>
<td className="px-4 py-3 text-sm">
<code className="bg-white/5 px-2 py-1 rounded text-white/90">{div.entity_id}</code>
</td>
<td className="px-4 py-3 text-sm">
<StatusBadge status={div.category} />
</td>
<td className="px-4 py-3 text-sm">
<span className="text-red-400">{div.result_real}</span>
</td>
<td className="px-4 py-3 text-sm text-white/60">{div.result_nominal}</td>
<td className="px-4 py-3 text-sm">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${confidenceColor}`} />
<span className="text-white/80">{(div.confidence * 100).toFixed(0)}%</span>
</div>
</td>
<td className="px-4 py-3 text-xs text-white/40">{timestamp}</td>
</motion.tr>
)
}
export default function StackObserver() {
const [data, setData] = useState<DivergenceData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [lastUpdate, setLastUpdate] = useState<Date | null>(null)
const fetchData = async () => {
try {
setLoading(true)
setError(null)
const response = await fetch('/api/stack-observer')
if (!response.ok) throw new Error(`API error: ${response.status}`)
const result = await response.json() as DivergenceData
setData(result)
setLastUpdate(new Date())
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to fetch divergences'
setError(message)
console.error('Stack Observer error:', err)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchData()
// Refresh every 30 seconds
const interval = setInterval(fetchData, 30000)
return () => clearInterval(interval)
}, [])
const refreshTime = lastUpdate
? lastUpdate.toLocaleTimeString('pt-PT', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
: 'nunca'
return (
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 p-6"
>
{/* Header */}
<motion.div variants={itemVariants} className="mb-8">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-3 rounded-lg bg-red-500/10">
<Zap className="w-6 h-6 text-red-400" />
</div>
<div>
<h1 className="text-3xl font-bold text-white">Stack Observer</h1>
<p className="text-white/60">Monitorização de divergências de automações</p>
</div>
</div>
<button
onClick={fetchData}
disabled={loading}
className="flex items-center gap-2 px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Actualizar
</button>
</div>
<p className="text-sm text-white/40">
Última actualização: {refreshTime} • Auto-refresh a cada 30s
</p>
</motion.div>
{error && (
<motion.div
variants={itemVariants}
className="mb-6 p-4 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 flex items-center gap-3"
>
<AlertCircle className="w-5 h-5 flex-shrink-0" />
{error}
</motion.div>
)}
{loading && !data ? (
<motion.div variants={itemVariants} className="text-center py-12">
<div className="inline-block">
<div className="animate-spin">
<Zap className="w-8 h-8 text-blue-400" />
</div>
</div>
<p className="mt-4 text-white/60">Carregando divergências...</p>
</motion.div>
) : data ? (
<>
{/* Stats Grid */}
<motion.div variants={containerVariants} className="grid grid-cols-1 md:grid-cols-5 gap-4 mb-8">
<StatCard
label="Total Divergências"
value={data.stats.total_divergences}
icon={AlertTriangle}
color="text-red-400"
/>
<StatCard
label="Últimas 24h"
value={data.stats.recent_24h}
icon={TrendingUp}
color="text-orange-400"
/>
<StatCard
label="Alta Confiança (>90%)"
value={data.stats.high_confidence}
icon={CheckCircle2}
color="text-yellow-400"
/>
<StatCard
label="Por Método"
value={Object.keys(data.stats.by_judge).length}
icon={Zap}
color="text-blue-400"
/>
<StatCard
label="Por Categoria"
value={Object.keys(data.stats.by_category).length}
icon={Clock}
color="text-purple-400"
/>
</motion.div>
{/* Divergences Table */}
<motion.div variants={itemVariants} className="glass-card rounded-lg overflow-hidden">
<div className="px-6 py-4 border-b border-white/10">
<h2 className="text-xl font-bold text-white">Divergências Detectadas</h2>
</div>
{data.divergences.length === 0 ? (
<div className="px-6 py-12 text-center">
<CheckCircle2 className="w-12 h-12 text-green-400 mx-auto mb-4 opacity-60" />
<p className="text-white/60">Nenhuma divergência detectada</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-white/5 border-b border-white/10">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-white/60">ID</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-white/60">Entidade</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-white/60">Categoria</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-white/60">Resultado Real</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-white/60">Resultado Nominal</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-white/60">Confiança</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-white/60">Timestamp</th>
</tr>
</thead>
<tbody>
<motion.td
variants={containerVariants}
initial="hidden"
animate="visible"
>
{data.divergences.slice(0, 20).map((div) => (
<DivergenceRow key={div.id} div={div} />
))}
</motion.td>
</tbody>
</table>
</div>
)}
{data.divergences.length > 20 && (
<div className="px-6 py-3 bg-white/5 border-t border-white/10 text-xs text-white/40">
Mostrando 20 de {data.divergences.length} divergências
</div>
)}
</motion.div>
</>
) : null}
</motion.div>
)
}