566 lines
24 KiB
TypeScript
566 lines
24 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts'
|
|
import { Plus, Server, Activity, AlertTriangle, CircleCheck, Box, MonitorSmartphone, Waypoints, AppWindow, type LucideIcon } from 'lucide-react'
|
|
import { api, type Resource, type Integration, type Event } from '../lib/api'
|
|
|
|
const subTabs = ['Overview']
|
|
const futureSubTabs = ['Network']
|
|
|
|
const cardBase: React.CSSProperties = {
|
|
backgroundColor: 'rgba(10, 10, 12, 0.92)',
|
|
border: '1px solid rgba(200, 164, 52, 0.08)',
|
|
borderRadius: '12px',
|
|
padding: '20px',
|
|
boxShadow: '0 0 20px rgba(200, 164, 52, 0.03)',
|
|
transition: 'border-color 0.2s ease',
|
|
position: 'relative',
|
|
overflow: 'hidden',
|
|
height: '100%',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
}
|
|
|
|
const sectionTitle: React.CSSProperties = {
|
|
fontSize: '11px',
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '1.5px',
|
|
color: '#7A7D85',
|
|
fontWeight: 500,
|
|
marginBottom: '16px',
|
|
}
|
|
|
|
function framedCard(bgUrl: string): React.CSSProperties {
|
|
return {
|
|
backgroundImage: `url(${bgUrl})`,
|
|
backgroundSize: '100% 100%',
|
|
backgroundPosition: 'center',
|
|
backgroundRepeat: 'no-repeat',
|
|
position: 'relative',
|
|
overflow: 'hidden',
|
|
height: '100%',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
padding: '20px 20px 64px 20px',
|
|
}
|
|
}
|
|
|
|
const cardVignette: React.CSSProperties = {
|
|
position: 'absolute',
|
|
inset: 0,
|
|
pointerEvents: 'none',
|
|
background: 'radial-gradient(ellipse closest-side at center, transparent 70%, var(--color-page) 100%)',
|
|
}
|
|
|
|
const cardDim: React.CSSProperties = {
|
|
position: 'absolute',
|
|
inset: 0,
|
|
pointerEvents: 'none',
|
|
backgroundColor: 'rgba(8, 8, 10, 0.45)',
|
|
}
|
|
|
|
const nodeStatusColor: Record<string, string> = {
|
|
healthy: '#2ECC71',
|
|
warning: '#E67E22',
|
|
critical: '#E74C3C',
|
|
unknown: '#7A7D85',
|
|
}
|
|
|
|
const integrationPalette = ['#C8A434', '#E67E22', '#2ECC71', '#7A7D85', '#3B82F6', '#8B5E3C']
|
|
|
|
// Every integration except Proxmox collapses its resources into one tile per
|
|
// integration on Node Status (e.g. 30 EC2 instances under one "AWS" tile, all of
|
|
// Uptime Kuma's monitors under one "Uptime" tile) — the underlying items are listed
|
|
// in Node Detail once selected. Proxmox stays ungrouped since its VMs/LXCs are
|
|
// managed individually elsewhere in the app. See ROADMAP.md for the planned paid-tier
|
|
// per-integration tabs that will show every node, not just the grouped tile.
|
|
const ungroupedIntegrationTypes = new Set(['proxmox'])
|
|
|
|
// Icon lookup per integration type, ordered by preference. A user-supplied
|
|
// icon (Settings → Integrations → "Icon" — URL or upload, stored as
|
|
// config.iconUrl) always wins; these are the built-in fallback chain when
|
|
// no custom icon is set. Each entry is tried in order — on a failed image
|
|
// load (404/network) the next URL in the list is tried — and if every
|
|
// candidate fails, the per-kind Lucide icon (kindIcon) renders instead.
|
|
const cdnIconCandidatesByIntegrationType: Record<string, string[]> = {
|
|
uptime_kuma: ['https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/uptime-kuma.png'],
|
|
aws: [
|
|
'https://samuelsjames.github.io/assets-public/logos/aws-logo.svg',
|
|
'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/aws.png',
|
|
],
|
|
docker: ['https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/docker.png'],
|
|
netbird: ['https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/netbird.png'],
|
|
cloudflare: ['https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/cloudflare.png'],
|
|
ssh: ['https://samuelsjames.github.io/assets-public/logos/linux-logo.svg'],
|
|
proxmox: [
|
|
'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/proxmox.png',
|
|
'https://api.iconify.design/simple-icons/proxmox.svg',
|
|
],
|
|
weather: [
|
|
'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/openweathermap.png',
|
|
'https://api.iconify.design/mdi/weather-partly-cloudy.svg',
|
|
],
|
|
remote_desktop: [
|
|
'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/guacamole.png',
|
|
'https://api.iconify.design/simple-icons/apacheguacamole.svg',
|
|
'https://api.iconify.design/mdi/remote-desktop.svg',
|
|
],
|
|
}
|
|
|
|
// SSH connections are named by the user after whatever host/service they point at
|
|
// (e.g. "Linode", "Portainer") — match known keywords in the connection name to a
|
|
// more specific icon before falling back to the generic Linux icon above.
|
|
const sshNameIconCandidates: { keyword: string; candidates: string[] }[] = [
|
|
{ keyword: 'linode', candidates: [
|
|
'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/linode.png',
|
|
'https://api.iconify.design/simple-icons/linode.svg',
|
|
] },
|
|
{ keyword: 'portainer', candidates: [
|
|
'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/portainer.png',
|
|
'https://api.iconify.design/simple-icons/portainer.svg',
|
|
] },
|
|
]
|
|
|
|
function iconCandidatesForNode(node: NodeTile): string[] {
|
|
if (node.integrationType === 'ssh') {
|
|
const name = ('isGroup' in node ? node.integration : node.name).toLowerCase()
|
|
const match = sshNameIconCandidates.find((m) => name.includes(m.keyword))
|
|
if (match) return [...match.candidates, ...cdnIconCandidatesByIntegrationType.ssh]
|
|
}
|
|
return cdnIconCandidatesByIntegrationType[node.integrationType] ?? []
|
|
}
|
|
|
|
interface NodeGroup {
|
|
isGroup: true
|
|
integration: string
|
|
integrationType: string
|
|
kind: string
|
|
status: Resource['status']
|
|
iconUrl?: string
|
|
members: Resource[]
|
|
}
|
|
|
|
type NodeTile = Resource | NodeGroup
|
|
|
|
const kindIcon: Record<string, LucideIcon> = {
|
|
vm: MonitorSmartphone,
|
|
container: Box,
|
|
app: AppWindow,
|
|
host: Server,
|
|
network: Waypoints,
|
|
}
|
|
|
|
// Tries the user's custom icon first, then each built-in candidate URL in
|
|
// order, falling back to the per-kind Lucide icon if every image 404s.
|
|
function TileIcon({ customUrl, candidates, fallback: Fallback }: { customUrl?: string; candidates: string[]; fallback: LucideIcon }) {
|
|
const urls = useMemo(() => [...(customUrl ? [customUrl] : []), ...candidates], [customUrl, candidates])
|
|
const [failedCount, setFailedCount] = useState(0)
|
|
useEffect(() => setFailedCount(0), [urls])
|
|
if (failedCount >= urls.length) return <Fallback size={12} style={{ color: '#7A7D85' }} />
|
|
return (
|
|
<img
|
|
key={urls[failedCount]}
|
|
src={urls[failedCount]}
|
|
width={12}
|
|
height={12}
|
|
style={{ borderRadius: '2px' }}
|
|
alt=""
|
|
onError={() => setFailedCount((n) => n + 1)}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function Donut({ data, centerLabel }: { data: { name: string; value: number; color: string }[]; centerLabel?: string }) {
|
|
const hasData = data.some((d) => d.value > 0)
|
|
return (
|
|
<div className="flex flex-1 items-center gap-4">
|
|
<div className="relative" style={{ width: '120px', height: '120px', flexShrink: 0 }}>
|
|
{hasData && (
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<PieChart>
|
|
<Pie data={data} dataKey="value" innerRadius={38} outerRadius={56} paddingAngle={2} isAnimationActive animationDuration={1000}>
|
|
{data.map((entry) => (
|
|
<Cell key={entry.name} fill={entry.color} stroke="none" />
|
|
))}
|
|
</Pie>
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
)}
|
|
{centerLabel && (
|
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
|
<span style={{ fontSize: '18px', fontWeight: 700, color: '#E8E6E0' }}>{centerLabel}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
{data.map((entry) => (
|
|
<div key={entry.name} className="flex items-center gap-2">
|
|
<span style={{ width: '8px', height: '8px', borderRadius: '50%', backgroundColor: entry.color, flexShrink: 0 }} />
|
|
<span style={{ fontSize: '12px', color: '#E8E6E0', width: '90px' }}>{entry.name}</span>
|
|
<span style={{ fontSize: '12px', color: '#7A7D85' }}>{entry.value}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function Infrastructure() {
|
|
const [activeTab, setActiveTab] = useState('Overview')
|
|
const [resources, setResources] = useState<Resource[] | null>(null)
|
|
const [integrations, setIntegrations] = useState<Integration[] | null>(null)
|
|
const [events, setEvents] = useState<Event[] | null>(null)
|
|
const [selectedNode, setSelectedNode] = useState<NodeTile | null>(null)
|
|
const navigate = useNavigate()
|
|
|
|
useEffect(() => {
|
|
api.listResources().then(({ resources }) => setResources(resources))
|
|
api.listIntegrations().then(({ integrations }) => setIntegrations(integrations))
|
|
api.listEvents(4).then(({ events }) => setEvents(events))
|
|
}, [])
|
|
|
|
const healthy = resources?.filter((r) => r.status === 'healthy').length ?? 0
|
|
const warning = resources?.filter((r) => r.status === 'warning').length ?? 0
|
|
const critical = resources?.filter((r) => r.status === 'critical').length ?? 0
|
|
const total = resources?.length ?? 0
|
|
|
|
const statusCards = [
|
|
{ label: 'Total Resources', value: String(total), icon: Server, sub: `${integrations?.filter((i) => i.status === 'connected').length ?? 0} integrations connected` },
|
|
{ label: 'Healthy', value: String(healthy), icon: Activity, sub: total ? `${Math.round((healthy / total) * 100)}%` : '—', color: '#2ECC71' },
|
|
{ label: 'Warnings', value: String(warning), icon: AlertTriangle, sub: warning ? 'Needs attention' : 'None', color: '#E67E22' },
|
|
{ label: 'Critical', value: String(critical), icon: AlertTriangle, sub: critical ? 'Action required' : 'None', color: '#E74C3C' },
|
|
]
|
|
|
|
const distributionData = useMemo(() => {
|
|
if (!resources) return []
|
|
const byIntegration = new Map<string, number>()
|
|
for (const r of resources) byIntegration.set(r.integration, (byIntegration.get(r.integration) ?? 0) + 1)
|
|
return Array.from(byIntegration.entries()).map(([name, value], i) => ({ name, value, color: integrationPalette[i % integrationPalette.length] }))
|
|
}, [resources])
|
|
|
|
// An integration can have dozens of resources (e.g. 30 EC2 instances) — collapse
|
|
// everything except Proxmox into one tile per integration on Node Status, with
|
|
// members listed in Node Detail.
|
|
const nodeTiles = useMemo<NodeTile[]>(() => {
|
|
if (!resources) return []
|
|
const groups = new Map<string, Resource[]>()
|
|
const singles: Resource[] = []
|
|
for (const r of resources) {
|
|
if (!ungroupedIntegrationTypes.has(r.integrationType)) {
|
|
const arr = groups.get(r.integration) ?? []
|
|
arr.push(r)
|
|
groups.set(r.integration, arr)
|
|
} else {
|
|
singles.push(r)
|
|
}
|
|
}
|
|
const groupTiles: NodeGroup[] = Array.from(groups.entries()).map(([integration, members]) => ({
|
|
isGroup: true,
|
|
integration,
|
|
integrationType: members[0]?.integrationType ?? '',
|
|
kind: members[0]?.kind ?? '',
|
|
iconUrl: members[0]?.iconUrl,
|
|
status: members.some((m) => m.status === 'critical')
|
|
? 'critical'
|
|
: members.some((m) => m.status === 'warning')
|
|
? 'warning'
|
|
: members.every((m) => m.status === 'healthy')
|
|
? 'healthy'
|
|
: 'unknown',
|
|
members,
|
|
}))
|
|
return [...singles, ...groupTiles]
|
|
}, [resources])
|
|
|
|
return (
|
|
<>
|
|
{/* Sub-tabs + Add Resource — hero banner is rendered at the layout level behind this */}
|
|
<div className="flex items-center justify-between shrink-0">
|
|
<div className="flex items-center gap-1 overflow-x-auto" style={{ scrollbarWidth: 'none' }}>
|
|
{subTabs.map((tab) => {
|
|
const active = tab === activeTab
|
|
return (
|
|
<button
|
|
key={tab}
|
|
onClick={() => setActiveTab(tab)}
|
|
className="cursor-pointer bg-transparent border-none transition-colors whitespace-nowrap"
|
|
style={{
|
|
fontSize: '12px',
|
|
fontWeight: 500,
|
|
padding: '8px 14px',
|
|
borderRadius: '8px',
|
|
color: active ? '#C8A434' : '#7A7D85',
|
|
backgroundColor: active ? 'rgba(200,164,52,0.1)' : 'transparent',
|
|
}}
|
|
>
|
|
{tab}
|
|
</button>
|
|
)
|
|
})}
|
|
{futureSubTabs.map((tab) => (
|
|
<button
|
|
key={tab}
|
|
disabled
|
|
title="Coming soon"
|
|
className="cursor-not-allowed bg-transparent border-none whitespace-nowrap"
|
|
style={{
|
|
fontSize: '12px',
|
|
fontWeight: 500,
|
|
padding: '8px 14px',
|
|
borderRadius: '8px',
|
|
color: '#4A4D55',
|
|
}}
|
|
>
|
|
{tab}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<button
|
|
onClick={() => navigate('/settings')}
|
|
className="flex items-center gap-2 cursor-pointer transition-colors whitespace-nowrap"
|
|
style={{
|
|
fontSize: '12px',
|
|
fontWeight: 600,
|
|
color: '#0A0B0D',
|
|
backgroundColor: '#C8A434',
|
|
border: 'none',
|
|
borderRadius: '8px',
|
|
padding: '9px 16px',
|
|
boxShadow: '0 0 14px rgba(200,164,52,0.2)',
|
|
}}
|
|
>
|
|
<Plus size={14} />
|
|
Add Resource
|
|
</button>
|
|
</div>
|
|
|
|
{/* Status Cards */}
|
|
<div className="grid w-full grid-cols-4 gap-5 shrink-0" style={{ marginTop: '8px', height: '110px' }}>
|
|
{statusCards.map((card) => {
|
|
const Icon = card.icon
|
|
return (
|
|
<div
|
|
key={card.label}
|
|
style={{ ...cardBase, backgroundColor: 'rgba(10, 10, 12, 0.5)', padding: '18px', justifyContent: 'center', alignItems: 'center', gap: '8px' }}
|
|
className="hover:!border-gold/20"
|
|
>
|
|
<h3 style={{ fontSize: '10px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500, textAlign: 'center' }}>
|
|
{card.label}
|
|
</h3>
|
|
<div className="flex items-center gap-2">
|
|
<Icon size={18} style={{ color: card.color ?? '#C8A434' }} />
|
|
<span style={{ fontSize: '26px', fontWeight: 700, color: '#E8E6E0', lineHeight: 1 }}>{card.value}</span>
|
|
</div>
|
|
<p style={{ fontSize: '10px', color: card.color ?? '#7A7D85', textAlign: 'center' }}>{card.sub}</p>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Middle Row */}
|
|
<div className="min-h-0 flex-1">
|
|
<div className="grid h-full w-full grid-cols-[1fr_1.6fr] gap-6">
|
|
{/* Resource Distribution */}
|
|
<div style={framedCard('/blank-kpi-bg.png')}>
|
|
<div style={cardDim} />
|
|
<div style={cardVignette} />
|
|
<div className="relative z-10 flex flex-1 flex-col">
|
|
<h3 style={sectionTitle}>Resource Distribution</h3>
|
|
<div className="flex flex-1 flex-col items-center justify-center">
|
|
{distributionData.length > 0 ? (
|
|
<Donut data={distributionData} />
|
|
) : (
|
|
<p style={{ fontSize: '12px', color: '#7A7D85', textAlign: 'center' }}>Connect an integration in Settings to see resource distribution.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Node Status — expanded */}
|
|
<div style={framedCard('/blank-kpi-bg.png')}>
|
|
<div style={cardDim} />
|
|
<div style={cardVignette} />
|
|
<div className="relative z-10 flex min-h-0 flex-1 flex-col">
|
|
<h3 style={sectionTitle}>Node Status</h3>
|
|
{nodeTiles.length > 0 ? (
|
|
<div className="scrollbar-ghost grid min-h-0 flex-1 grid-cols-5 content-start gap-2" style={{ overflowY: 'auto' }}>
|
|
{nodeTiles.map((node, i) => {
|
|
const NodeIcon = kindIcon[node.kind ?? ''] ?? Server
|
|
const label = 'isGroup' in node ? node.integration : node.name
|
|
const tooltip = 'isGroup' in node ? `${node.integration}: ${node.members.length} monitored` : `${node.name}: ${node.detail ?? node.status}`
|
|
return (
|
|
<div
|
|
key={i}
|
|
title={tooltip}
|
|
onClick={() => setSelectedNode(node)}
|
|
className="cursor-pointer transition-colors"
|
|
style={{
|
|
backgroundColor: selectedNode === node ? 'rgba(200,164,52,0.1)' : 'rgba(10, 10, 12, 0.55)',
|
|
border: selectedNode === node ? '1px solid rgba(200,164,52,0.5)' : `1px solid ${nodeStatusColor[node.status]}33`,
|
|
borderRadius: '8px',
|
|
padding: '8px 10px',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '6px',
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-1.5">
|
|
<span style={{ width: '7px', height: '7px', borderRadius: '50%', backgroundColor: nodeStatusColor[node.status], boxShadow: `0 0 6px ${nodeStatusColor[node.status]}` }} />
|
|
<TileIcon
|
|
customUrl={node.iconUrl}
|
|
candidates={iconCandidatesForNode(node)}
|
|
fallback={NodeIcon}
|
|
/>
|
|
</div>
|
|
<span style={{ fontSize: '11px', color: '#E8E6E0', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-1 items-center justify-center">
|
|
<p style={{ fontSize: '12px', color: '#7A7D85', textAlign: 'center' }}>No resources reported yet. Connect Docker (or another supported integration) in Settings to populate this view.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottom Row */}
|
|
<div className="shrink-0">
|
|
<div className="grid w-full grid-cols-[1fr_1.5fr_0.7fr] gap-6">
|
|
{/* Integration Health */}
|
|
<div style={{ ...framedCard('/archnest-network-traffic-bg.png'), height: 'auto', padding: '20px' }} className="hover:!border-gold/15">
|
|
<div style={cardDim} />
|
|
<div style={cardVignette} />
|
|
<div className="relative z-10 flex flex-col">
|
|
<h3 style={sectionTitle}>Integration Health</h3>
|
|
{integrations && integrations.length > 0 ? (
|
|
<div className="scrollbar-ghost flex flex-col gap-2" style={{ maxHeight: '140px', overflowY: 'auto' }}>
|
|
{integrations.map((i) => (
|
|
<div key={i.id} className="flex items-center gap-3">
|
|
<span className="flex items-center gap-2" style={{ fontSize: '12px', color: '#E8E6E0' }}>
|
|
<CircleCheck size={13} style={{ color: i.status === 'connected' ? '#2ECC71' : i.status === 'error' ? '#E74C3C' : '#7A7D85' }} />
|
|
{i.name}
|
|
</span>
|
|
<span style={{ fontSize: '11px', color: '#7A7D85' }}>{i.status}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p style={{ fontSize: '12px', color: '#7A7D85' }}>No integrations added yet.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Node Detail — shows the node selected up in Node Status */}
|
|
<div style={{ ...cardBase, height: 'auto' }} className="hover:!border-gold/15">
|
|
<div className="relative z-10 flex flex-col" style={{ maxHeight: '220px' }}>
|
|
<h3 style={sectionTitle}>Node Detail</h3>
|
|
{selectedNode && 'isGroup' in selectedNode ? (
|
|
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
style={{
|
|
width: '8px',
|
|
height: '8px',
|
|
borderRadius: '50%',
|
|
backgroundColor: nodeStatusColor[selectedNode.status],
|
|
boxShadow: `0 0 6px ${nodeStatusColor[selectedNode.status]}`,
|
|
}}
|
|
/>
|
|
<span style={{ fontSize: '14px', fontWeight: 600, color: '#E8E6E0' }}>{selectedNode.integration}</span>
|
|
<span style={{ fontSize: '11px', color: '#7A7D85' }}>({selectedNode.members.length} monitored)</span>
|
|
</div>
|
|
<div className="scrollbar-ghost flex flex-col gap-1.5" style={{ overflowY: 'auto' }}>
|
|
{selectedNode.members.map((m, i) => (
|
|
<div key={i} className="flex items-center gap-2" style={{ fontSize: '11.5px' }}>
|
|
<span
|
|
style={{
|
|
width: '6px',
|
|
height: '6px',
|
|
borderRadius: '50%',
|
|
backgroundColor: nodeStatusColor[m.status],
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
<span style={{ color: '#E8E6E0', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{m.name}</span>
|
|
<span style={{ color: '#7A7D85', marginLeft: 'auto', flexShrink: 0 }}>{m.detail ?? m.status}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : selectedNode ? (
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
style={{
|
|
width: '8px',
|
|
height: '8px',
|
|
borderRadius: '50%',
|
|
backgroundColor: nodeStatusColor[selectedNode.status],
|
|
boxShadow: `0 0 6px ${nodeStatusColor[selectedNode.status]}`,
|
|
}}
|
|
/>
|
|
<span style={{ fontSize: '14px', fontWeight: 600, color: '#E8E6E0' }}>{selectedNode.name}</span>
|
|
</div>
|
|
<div className="flex items-center gap-3" style={{ fontSize: '11px', color: '#7A7D85' }}>
|
|
<span>{selectedNode.integration}</span>
|
|
<span style={{ color: 'rgba(200,164,52,0.3)' }}>|</span>
|
|
<span style={{ textTransform: 'capitalize', color: nodeStatusColor[selectedNode.status] }}>{selectedNode.status}</span>
|
|
</div>
|
|
{selectedNode.detail && (
|
|
<p style={{ fontSize: '12px', color: '#A8A6A0', lineHeight: 1.5 }}>{selectedNode.detail}</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<p style={{ fontSize: '12px', color: '#7A7D85' }}>Select a node above to view its details.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recent Activity */}
|
|
<div style={{ ...cardBase, height: 'auto' }} className="hover:!border-gold/15">
|
|
<div className="relative z-10">
|
|
<h3 style={sectionTitle}>Recent Activity</h3>
|
|
{events && events.length > 0 ? (
|
|
<div className="flex flex-col gap-3">
|
|
{events.map((item) => (
|
|
<div key={item.id} className="flex items-start justify-between gap-3">
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<p style={{ fontSize: '12px', color: '#E8E6E0', fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.title}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p style={{ fontSize: '12px', color: '#7A7D85' }}>No activity yet.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer stats bar */}
|
|
<div
|
|
className="shrink-0 flex items-center justify-center gap-3"
|
|
style={{
|
|
fontSize: '11px',
|
|
color: '#7A7D85',
|
|
padding: '8px 0',
|
|
borderTop: '1px solid rgba(200,164,52,0.08)',
|
|
}}
|
|
>
|
|
<span>{total} Resources</span><span style={{ color: 'rgba(200,164,52,0.3)' }}>|</span>
|
|
<span>{integrations?.filter((i) => i.status === 'connected').length ?? 0} Integrations Connected</span>
|
|
{critical > 0 && (
|
|
<>
|
|
<span style={{ color: 'rgba(200,164,52,0.3)' }}>|</span>
|
|
<span style={{ color: '#E74C3C' }}>{critical} Critical</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</>
|
|
)
|
|
}
|