Replace mock data on Glance and Infrastructure with real backend data
Adds an events table + logEvent helper for a genuine activity log, and a /api/integrations/resources aggregate endpoint backed by a new optional listResources adapter method (implemented for Docker via its containers API). StatusCards, MiddleRow, BottomRow, and Infrastructure now render real integration/resource/event data instead of hardcoded numbers, with empty states where no data source exists yet (AWS cost, historical trends). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
This commit is contained in:
parent
b49f8ac8f5
commit
3b920fcfb2
13 changed files with 430 additions and 336 deletions
|
|
@ -57,4 +57,16 @@ db.exec(`
|
|||
last_checked_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
source TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`)
|
||||
|
||||
export function logEvent(type: string, title: string, source?: string | null) {
|
||||
db.prepare('INSERT INTO events (type, title, source) VALUES (?, ?, ?)').run(type, title, source ?? null)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { IntegrationAdapter } from './types.js'
|
||||
import type { IntegrationAdapter, Resource } from './types.js'
|
||||
|
||||
export const docker: IntegrationAdapter = {
|
||||
async testConnection(config) {
|
||||
|
|
@ -12,4 +12,17 @@ export const docker: IntegrationAdapter = {
|
|||
return { ok: false, message: err instanceof Error ? err.message : 'Connection failed' }
|
||||
}
|
||||
},
|
||||
|
||||
async listResources(config): Promise<Resource[]> {
|
||||
const baseUrl = config.baseUrl?.replace(/\/$/, '')
|
||||
if (!baseUrl) return []
|
||||
const res = await fetch(`${baseUrl}/containers/json?all=true`)
|
||||
if (!res.ok) return []
|
||||
const containers = (await res.json()) as { Names: string[]; State: string }[]
|
||||
return containers.map((c) => ({
|
||||
name: c.Names[0]?.replace(/^\//, '') ?? 'unknown',
|
||||
status: c.State === 'running' ? 'healthy' : c.State === 'restarting' ? 'warning' : 'critical',
|
||||
detail: c.State,
|
||||
}))
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,13 @@ export interface TestResult {
|
|||
message: string
|
||||
}
|
||||
|
||||
export interface Resource {
|
||||
name: string
|
||||
status: 'healthy' | 'warning' | 'critical' | 'unknown'
|
||||
detail?: string
|
||||
}
|
||||
|
||||
export interface IntegrationAdapter {
|
||||
testConnection(config: IntegrationConfig, secrets: Record<string, string>): Promise<TestResult>
|
||||
listResources?(config: IntegrationConfig, secrets: Record<string, string>): Promise<Resource[]>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { FastifyInstance } from 'fastify'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
import { db } from '../db/index.js'
|
||||
import { db, logEvent } from '../db/index.js'
|
||||
|
||||
const credentialsSchema = z.object({
|
||||
username: z.string().min(3).max(64),
|
||||
|
|
@ -29,6 +29,7 @@ export async function authRoutes(app: FastifyInstance) {
|
|||
.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)')
|
||||
.run(username, passwordHash)
|
||||
const token = app.jwt.sign({ sub: Number(result.lastInsertRowid), username })
|
||||
logEvent('account_created', `Account created for ${username}`)
|
||||
return { token }
|
||||
})
|
||||
|
||||
|
|
@ -45,6 +46,7 @@ export async function authRoutes(app: FastifyInstance) {
|
|||
return reply.code(401).send({ error: 'Invalid username or password' })
|
||||
}
|
||||
const token = app.jwt.sign({ sub: user.id, username: user.username })
|
||||
logEvent('user_login', `${user.username} logged in`)
|
||||
return { token }
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import { db } from '../db/index.js'
|
||||
import { db, logEvent } from '../db/index.js'
|
||||
|
||||
const bookmarkSchema = z.object({
|
||||
categoryId: z.number().int().nullable().optional(),
|
||||
|
|
@ -48,6 +48,7 @@ export async function bookmarkRoutes(app: FastifyInstance) {
|
|||
'INSERT INTO bookmarks (category_id, title, url, icon, favorite) VALUES (?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run(categoryId ?? null, title, url, icon ?? null, favorite ? 1 : 0)
|
||||
logEvent('bookmark_created', `Bookmark added: ${title}`)
|
||||
return reply.code(201).send({ id: result.lastInsertRowid })
|
||||
})
|
||||
|
||||
|
|
@ -72,7 +73,9 @@ export async function bookmarkRoutes(app: FastifyInstance) {
|
|||
|
||||
app.delete('/api/bookmarks/:id', async (req, reply) => {
|
||||
const id = Number((req.params as { id: string }).id)
|
||||
const existing = db.prepare('SELECT title FROM bookmarks WHERE id = ?').get(id) as { title: string } | undefined
|
||||
db.prepare('DELETE FROM bookmarks WHERE id = ?').run(id)
|
||||
if (existing) logEvent('bookmark_deleted', `Bookmark removed: ${existing.title}`)
|
||||
return reply.code(204).send()
|
||||
})
|
||||
}
|
||||
|
|
|
|||
13
backend/src/routes/events.ts
Normal file
13
backend/src/routes/events.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import type { FastifyInstance } from 'fastify'
|
||||
import { db } from '../db/index.js'
|
||||
|
||||
export async function eventRoutes(app: FastifyInstance) {
|
||||
app.addHook('onRequest', app.authenticate)
|
||||
|
||||
app.get('/api/events', async (req) => {
|
||||
const query = req.query as { limit?: string }
|
||||
const limit = Math.min(Number(query.limit) || 20, 100)
|
||||
const events = db.prepare('SELECT * FROM events ORDER BY created_at DESC, id DESC LIMIT ?').all(limit)
|
||||
return { events }
|
||||
})
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import type { FastifyInstance } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
import { db } from '../db/index.js'
|
||||
import { db, logEvent } from '../db/index.js'
|
||||
import { encryptSecret, decryptSecret } from '../db/crypto.js'
|
||||
import { adapterRegistry } from '../integrations/registry.js'
|
||||
import type { IntegrationType } from '../integrations/types.js'
|
||||
import type { IntegrationType, Resource } from '../integrations/types.js'
|
||||
|
||||
const integrationTypes = [
|
||||
'proxmox',
|
||||
|
|
@ -80,6 +80,7 @@ export async function integrationRoutes(app: FastifyInstance) {
|
|||
insertSecret.run(integrationId, key, encryptSecret(value))
|
||||
}
|
||||
const row = db.prepare('SELECT * FROM integrations WHERE id = ?').get(integrationId) as IntegrationRow
|
||||
logEvent('integration_created', `${name} integration added`, type)
|
||||
return reply.code(201).send({ integration: serialize(row) })
|
||||
})
|
||||
|
||||
|
|
@ -116,7 +117,9 @@ export async function integrationRoutes(app: FastifyInstance) {
|
|||
|
||||
app.delete('/api/integrations/:id', async (req, reply) => {
|
||||
const id = Number((req.params as { id: string }).id)
|
||||
const existing = db.prepare('SELECT * FROM integrations WHERE id = ?').get(id) as IntegrationRow | undefined
|
||||
db.prepare('DELETE FROM integrations WHERE id = ?').run(id)
|
||||
if (existing) logEvent('integration_deleted', `${existing.name} integration removed`, existing.type)
|
||||
return reply.code(204).send()
|
||||
})
|
||||
|
||||
|
|
@ -136,6 +139,25 @@ export async function integrationRoutes(app: FastifyInstance) {
|
|||
status,
|
||||
id
|
||||
)
|
||||
logEvent('integration_tested', `${row.name} test ${result.ok ? 'succeeded' : 'failed'}`, row.type)
|
||||
return result
|
||||
})
|
||||
|
||||
app.get('/api/integrations/resources', async () => {
|
||||
const rows = db.prepare("SELECT * FROM integrations WHERE enabled = 1 AND status = 'connected'").all() as IntegrationRow[]
|
||||
const resources: (Resource & { integration: string })[] = []
|
||||
for (const row of rows) {
|
||||
const adapter = adapterRegistry[row.type as IntegrationType]
|
||||
if (!adapter.listResources) continue
|
||||
const config = JSON.parse(row.config_json)
|
||||
const secrets = loadSecrets(row.id)
|
||||
try {
|
||||
const found = await adapter.listResources(config, secrets)
|
||||
for (const r of found) resources.push({ ...r, integration: row.name })
|
||||
} catch {
|
||||
// adapter unreachable — skip, connection test already surfaces this
|
||||
}
|
||||
}
|
||||
return { resources }
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import jwt from '@fastify/jwt'
|
|||
import { authRoutes } from './routes/auth.js'
|
||||
import { integrationRoutes } from './routes/integrations.js'
|
||||
import { bookmarkRoutes } from './routes/bookmarks.js'
|
||||
import { eventRoutes } from './routes/events.js'
|
||||
|
||||
const JWT_SECRET = process.env.ARCHNEST_JWT_SECRET
|
||||
if (!JWT_SECRET) {
|
||||
|
|
@ -27,6 +28,7 @@ app.decorate('authenticate', async function (req, reply) {
|
|||
await app.register(authRoutes)
|
||||
await app.register(integrationRoutes)
|
||||
await app.register(bookmarkRoutes)
|
||||
await app.register(eventRoutes)
|
||||
|
||||
app.get('/api/health', async () => ({ ok: true }))
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,13 @@
|
|||
import { AreaChart, Area, ResponsiveContainer } from 'recharts'
|
||||
import { ServerCog, DatabaseBackup, Rocket, FileText } from 'lucide-react'
|
||||
|
||||
const trafficData = Array.from({ length: 48 }, (_, i) => ({
|
||||
time: i,
|
||||
incoming: 800 + Math.sin(i / 6) * 300 + Math.random() * 150,
|
||||
outgoing: 700 + Math.cos(i / 8) * 250 + Math.random() * 100,
|
||||
}))
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Plug, ServerCog, BookMarked, Settings as SettingsIcon } from 'lucide-react'
|
||||
import { api, type Integration } from '../lib/api'
|
||||
|
||||
const shortcuts = [
|
||||
{ icon: ServerCog, label: 'Add Server' },
|
||||
{ icon: DatabaseBackup, label: 'Create Backup' },
|
||||
{ icon: Rocket, label: 'Deploy App' },
|
||||
{ icon: FileText, label: 'View Logs' },
|
||||
{ icon: ServerCog, label: 'Add Integration', to: '/settings' },
|
||||
{ icon: BookMarked, label: 'Add Bookmark', to: '/booknest' },
|
||||
{ icon: Plug, label: 'Infrastructure', to: '/infrastructure' },
|
||||
{ icon: SettingsIcon, label: 'Settings', to: '/settings' },
|
||||
]
|
||||
|
||||
const cardBase: React.CSSProperties = {
|
||||
|
|
@ -25,56 +21,48 @@ const cardBase: React.CSSProperties = {
|
|||
overflow: 'hidden',
|
||||
}
|
||||
|
||||
const statusColor: Record<string, string> = {
|
||||
connected: '#2ECC71',
|
||||
error: '#E74C3C',
|
||||
unknown: '#7A7D85',
|
||||
}
|
||||
|
||||
export default function BottomRow() {
|
||||
const [integrations, setIntegrations] = useState<Integration[] | null>(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
api.listIntegrations().then(({ integrations }) => setIntegrations(integrations))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="grid w-full grid-cols-[1.8fr_1fr] gap-6">
|
||||
{/* Network Traffic */}
|
||||
{/* Connected Integrations */}
|
||||
<div style={cardBase} className="hover:!border-gold/15">
|
||||
{/* Background image at very low opacity */}
|
||||
<div style={{ position: 'absolute', inset: 0, opacity: 0.12, backgroundImage: 'url(/archnest-network-traffic-bg.png)', backgroundSize: 'cover', backgroundPosition: 'center', pointerEvents: 'none' }} />
|
||||
{/* Gold top edge */}
|
||||
<div style={{ position: 'absolute', top: 0, left: '5%', right: '5%', height: '1px', background: 'linear-gradient(90deg, transparent, rgba(200,164,52,0.15), transparent)', pointerEvents: 'none' }} />
|
||||
|
||||
<div className="relative z-10">
|
||||
<h3 style={{ fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500, marginBottom: '16px' }}>
|
||||
Network Traffic
|
||||
Connected Integrations
|
||||
</h3>
|
||||
<div className="flex items-end gap-6">
|
||||
<div style={{ flex: 1, height: '100px' }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={trafficData} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="trafficGold" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#C8A434" stopOpacity={0.25} />
|
||||
<stop offset="100%" stopColor="#C8A434" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
<linearGradient id="trafficAmber" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#E67E22" stopOpacity={0.15} />
|
||||
<stop offset="100%" stopColor="#E67E22" stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area type="monotone" dataKey="incoming" stroke="#C8A434" strokeWidth={1.5} fill="url(#trafficGold)" dot={false} isAnimationActive={true} animationDuration={1200} />
|
||||
<Area type="monotone" dataKey="outgoing" stroke="rgba(230,126,34,0.6)" strokeWidth={1} fill="url(#trafficAmber)" dot={false} isAnimationActive={true} animationDuration={1200} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
{integrations === null ? (
|
||||
<p style={{ fontSize: '12px', color: '#7A7D85' }}>Loading…</p>
|
||||
) : integrations.length === 0 ? (
|
||||
<p style={{ fontSize: '12px', color: '#7A7D85' }}>No integrations added yet — add one in Settings.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{integrations.map((i) => (
|
||||
<div key={i.id} className="flex items-center gap-2.5" style={{ padding: '8px 10px', borderRadius: '8px', backgroundColor: 'rgba(255,255,255,0.02)' }}>
|
||||
<span style={{ width: '7px', height: '7px', borderRadius: '50%', backgroundColor: statusColor[i.status] ?? '#7A7D85', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '13px', color: '#E8E6E0', flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{i.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 flex-shrink-0">
|
||||
<div>
|
||||
<p style={{ fontSize: '11px', color: '#7A7D85' }}>Incoming</p>
|
||||
<p style={{ fontSize: '18px', fontWeight: 700, color: '#E8E6E0' }}>1.23 Gbps</p>
|
||||
<p style={{ fontSize: '11px', color: '#E74C3C' }}>↓ 12.4%</p>
|
||||
</div>
|
||||
<div>
|
||||
<p style={{ fontSize: '11px', color: '#7A7D85' }}>Outgoing</p>
|
||||
<p style={{ fontSize: '18px', fontWeight: 700, color: '#E8E6E0' }}>1.08 Gbps</p>
|
||||
<p style={{ fontSize: '11px', color: '#2ECC71' }}>↑ 8.7%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shortcuts — miniature control panels */}
|
||||
{/* Shortcuts */}
|
||||
<div style={cardBase} className="hover:!border-gold/15">
|
||||
<div style={{ position: 'absolute', inset: 0, opacity: 0.02, backgroundImage: 'radial-gradient(circle, #C8A434 1px, transparent 1px)', backgroundSize: '24px 24px', pointerEvents: 'none' }} />
|
||||
|
||||
|
|
@ -88,6 +76,7 @@ export default function BottomRow() {
|
|||
return (
|
||||
<button
|
||||
key={item.label}
|
||||
onClick={() => navigate(item.to)}
|
||||
className="flex flex-col items-center gap-2 cursor-pointer bg-transparent transition-all duration-200 group/btn"
|
||||
style={{ padding: '16px 8px', borderRadius: '10px', border: '1px solid rgba(200,164,52,0.08)', boxShadow: '0 0 12px rgba(200,164,52,0.02)' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.borderColor = 'rgba(200,164,52,0.2)'; e.currentTarget.style.boxShadow = '0 0 16px rgba(200,164,52,0.06)' }}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,6 @@
|
|||
import { X, CircleCheck, Shield, Play, Settings, User } from 'lucide-react'
|
||||
|
||||
const resources = [
|
||||
{ label: 'Compute', current: 18, max: 24, unit: '' },
|
||||
{ label: 'Storage', current: 12.4, max: 20, unit: ' TB' },
|
||||
{ label: 'Database', current: 8, max: 12, unit: '' },
|
||||
{ label: 'Network', current: 98.7, max: 100, unit: '%' },
|
||||
{ label: 'Containers', current: 32, max: 40, unit: '' },
|
||||
]
|
||||
|
||||
const activities = [
|
||||
{ icon: CircleCheck, title: 'Backup completed', source: 'Database Cluster 01', time: '2m ago' },
|
||||
{ icon: Shield, title: 'Security scan completed', source: 'Web Frontend', time: '8m ago' },
|
||||
{ icon: Play, title: 'Instance launched', source: 'App Server 03', time: '15m ago' },
|
||||
{ icon: Settings, title: 'Configuration updated', source: 'Load Balancer', time: '22m ago' },
|
||||
{ icon: User, title: 'User login detected', source: 'admin@archnest.io', time: '35m ago' },
|
||||
]
|
||||
|
||||
const alerts = [
|
||||
{ severity: 'high', title: 'High CPU Usage', source: 'App Server 02', time: '2m ago' },
|
||||
{ severity: 'medium', title: 'Disk Space Low', source: 'Database Cluster 01', time: '15m ago' },
|
||||
{ severity: 'medium', title: 'Unauthorized Login Attempt', source: 'Web Frontend', time: '32m ago' },
|
||||
{ severity: 'medium', title: 'SSL Certificate Expiring', source: 'api.archnest.io', time: '1h ago' },
|
||||
]
|
||||
import { useEffect, useState } from 'react'
|
||||
import { CircleCheck, AlertTriangle, Plug, Bookmark as BookmarkIcon, LogIn } from 'lucide-react'
|
||||
import { api, type Event, type Resource, type Integration } from '../lib/api'
|
||||
|
||||
const cardBase: React.CSSProperties = {
|
||||
backgroundColor: 'rgba(10, 10, 12, 0.92)',
|
||||
|
|
@ -37,109 +16,143 @@ const cardBase: React.CSSProperties = {
|
|||
flexDirection: 'column',
|
||||
}
|
||||
|
||||
function getBarColor(percentage: number) {
|
||||
if (percentage >= 90) return '#E74C3C'
|
||||
if (percentage >= 70) return '#E67E22'
|
||||
return '#C8A434'
|
||||
const statusColor: Record<string, string> = {
|
||||
healthy: '#C8A434',
|
||||
warning: '#E67E22',
|
||||
critical: '#E74C3C',
|
||||
unknown: '#7A7D85',
|
||||
}
|
||||
|
||||
const eventIcons: Record<string, typeof CircleCheck> = {
|
||||
integration_created: Plug,
|
||||
integration_tested: CircleCheck,
|
||||
integration_deleted: Plug,
|
||||
bookmark_created: BookmarkIcon,
|
||||
bookmark_deleted: BookmarkIcon,
|
||||
user_login: LogIn,
|
||||
account_created: LogIn,
|
||||
}
|
||||
|
||||
function timeAgo(iso: string) {
|
||||
const diffMs = Date.now() - new Date(iso.replace(' ', 'T') + 'Z').getTime()
|
||||
const mins = Math.floor(diffMs / 60000)
|
||||
if (mins < 1) return 'just now'
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
const hours = Math.floor(mins / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
return `${Math.floor(hours / 24)}d ago`
|
||||
}
|
||||
|
||||
export default function MiddleRow() {
|
||||
const [resources, setResources] = useState<Resource[] | null>(null)
|
||||
const [events, setEvents] = useState<Event[] | null>(null)
|
||||
const [integrations, setIntegrations] = useState<Integration[] | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.listResources().then(({ resources }) => setResources(resources))
|
||||
api.listEvents(5).then(({ events }) => setEvents(events))
|
||||
api.listIntegrations().then(({ integrations }) => setIntegrations(integrations))
|
||||
}, [])
|
||||
|
||||
const erroredIntegrations = integrations?.filter((i) => i.status === 'error') ?? []
|
||||
const problemResources = resources?.filter((r) => r.status === 'warning' || r.status === 'critical') ?? []
|
||||
|
||||
return (
|
||||
<div className="grid h-full w-full grid-cols-[1fr_1.4fr_1fr] gap-6">
|
||||
{/* Resource Overview */}
|
||||
<div style={cardBase} className="hover:!border-gold/15 group">
|
||||
{/* Subtle hex pattern overlay */}
|
||||
<div style={{ position: 'absolute', inset: 0, opacity: 0.03, backgroundImage: 'radial-gradient(circle, #C8A434 1px, transparent 1px)', backgroundSize: '20px 20px', pointerEvents: 'none' }} />
|
||||
{/* Gold top edge lighting */}
|
||||
<div style={{ position: 'absolute', top: 0, left: '10%', right: '10%', height: '1px', background: 'linear-gradient(90deg, transparent, rgba(200,164,52,0.15), transparent)', pointerEvents: 'none' }} />
|
||||
|
||||
<div className="relative z-10 flex flex-1 flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 style={{ fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500 }}>
|
||||
Resource Overview
|
||||
</h3>
|
||||
<button className="bg-transparent border-none cursor-pointer p-1" style={{ color: '#7A7D85' }}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col justify-around gap-4">
|
||||
{resources.map((res) => {
|
||||
const percentage = res.unit === '%' ? res.current : (res.current / res.max) * 100
|
||||
const displayValue = res.unit === '%' ? `${res.current}%` : `${res.current} / ${res.max}${res.unit}`
|
||||
return (
|
||||
<div key={res.label} className="flex items-center gap-3">
|
||||
<span style={{ fontSize: '13px', color: '#E8E6E0', width: '90px' }}>{res.label}</span>
|
||||
<div style={{ flex: 1, height: '6px', backgroundColor: 'rgba(30,32,37,0.8)', borderRadius: '3px', overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', width: `${percentage}%`, backgroundColor: getBarColor(percentage), borderRadius: '3px', transition: 'width 0.8s ease' }} />
|
||||
</div>
|
||||
<span style={{ fontSize: '12px', color: '#7A7D85', width: '80px', textAlign: 'right' }}>{displayValue}</span>
|
||||
<h3 style={{ fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500, marginBottom: '16px' }}>
|
||||
Resource Overview
|
||||
</h3>
|
||||
{resources === null ? (
|
||||
<p style={{ fontSize: '12px', color: '#7A7D85' }}>Loading…</p>
|
||||
) : resources.length === 0 ? (
|
||||
<p style={{ fontSize: '12px', color: '#7A7D85' }}>Connect an integration in Settings to see live resources here.</p>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col justify-around gap-3" style={{ overflowY: 'auto' }}>
|
||||
{resources.slice(0, 6).map((r, i) => (
|
||||
<div key={i} className="flex items-center gap-2.5">
|
||||
<span style={{ width: '7px', height: '7px', borderRadius: '50%', backgroundColor: statusColor[r.status], flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '13px', color: '#E8E6E0', flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.name}</span>
|
||||
<span style={{ fontSize: '10px', color: '#7A7D85', flexShrink: 0 }}>{r.integration}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity — visually dominant */}
|
||||
{/* Recent Activity */}
|
||||
<div style={cardBase} className="hover:!border-gold/15 group">
|
||||
{/* City grid texture */}
|
||||
<div style={{ position: 'absolute', inset: 0, opacity: 0.02, backgroundImage: 'linear-gradient(rgba(200,164,52,0.3) 1px, transparent 1px), linear-gradient(90deg, rgba(200,164,52,0.3) 1px, transparent 1px)', backgroundSize: '40px 40px', pointerEvents: 'none' }} />
|
||||
{/* Gold top edge */}
|
||||
<div style={{ position: 'absolute', top: 0, left: '5%', right: '5%', height: '1px', background: 'linear-gradient(90deg, transparent, rgba(200,164,52,0.2), transparent)', pointerEvents: 'none' }} />
|
||||
|
||||
<div className="relative z-10 flex flex-1 flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 style={{ fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500 }}>
|
||||
Recent Activity
|
||||
</h3>
|
||||
<button className="bg-transparent border-none cursor-pointer p-1" style={{ color: '#7A7D85' }}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col justify-around gap-3">
|
||||
{activities.map((item, i) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<div key={i} className="flex items-start gap-3">
|
||||
<div style={{ width: '28px', height: '28px', borderRadius: '6px', backgroundColor: 'rgba(200,164,52,0.06)', border: '1px solid rgba(200,164,52,0.08)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, boxShadow: '0 0 8px rgba(200,164,52,0.04)' }}>
|
||||
<Icon size={13} style={{ color: '#C8A434' }} />
|
||||
<h3 style={{ fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500, marginBottom: '16px' }}>
|
||||
Recent Activity
|
||||
</h3>
|
||||
{events === null ? (
|
||||
<p style={{ fontSize: '12px', color: '#7A7D85' }}>Loading…</p>
|
||||
) : events.length === 0 ? (
|
||||
<p style={{ fontSize: '12px', color: '#7A7D85' }}>No activity yet.</p>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col justify-around gap-3">
|
||||
{events.map((item) => {
|
||||
const Icon = eventIcons[item.type] ?? CircleCheck
|
||||
return (
|
||||
<div key={item.id} className="flex items-start gap-3">
|
||||
<div style={{ width: '28px', height: '28px', borderRadius: '6px', backgroundColor: 'rgba(200,164,52,0.06)', border: '1px solid rgba(200,164,52,0.08)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, boxShadow: '0 0 8px rgba(200,164,52,0.04)' }}>
|
||||
<Icon size={13} style={{ color: '#C8A434' }} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.title}</p>
|
||||
{item.source && <p style={{ fontSize: '11px', color: '#7A7D85', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.source}</p>}
|
||||
</div>
|
||||
<span style={{ fontSize: '11px', color: '#7A7D85', flexShrink: 0 }}>{timeAgo(item.created_at)}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.title}</p>
|
||||
<p style={{ fontSize: '11px', color: '#7A7D85', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.source}</p>
|
||||
</div>
|
||||
<span style={{ fontSize: '11px', color: '#7A7D85', flexShrink: 0 }}>{item.time}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Alerts */}
|
||||
<div style={cardBase} className="hover:!border-gold/15 group">
|
||||
{/* Amber edge lighting */}
|
||||
<div style={{ position: 'absolute', top: 0, left: '10%', right: '10%', height: '1px', background: 'linear-gradient(90deg, transparent, rgba(231,126,34,0.15), transparent)', pointerEvents: 'none' }} />
|
||||
|
||||
<div className="relative z-10 flex flex-1 flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 style={{ fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500 }}>
|
||||
Top Alerts
|
||||
</h3>
|
||||
<a href="#" style={{ fontSize: '11px', color: '#C8A434', textDecoration: 'none' }}>View all</a>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col justify-around gap-3">
|
||||
{alerts.map((alert, i) => (
|
||||
<div key={i} className="flex items-start gap-3">
|
||||
<div style={{ width: '8px', height: '8px', borderRadius: '50%', flexShrink: 0, marginTop: '5px', backgroundColor: alert.severity === 'high' ? '#E74C3C' : '#E67E22', boxShadow: alert.severity === 'high' ? '0 0 6px rgba(231,76,60,0.3)' : '0 0 6px rgba(230,126,34,0.2)' }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{alert.title}</p>
|
||||
<p style={{ fontSize: '11px', color: '#7A7D85', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{alert.source}</p>
|
||||
<h3 style={{ fontSize: '11px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500, marginBottom: '16px' }}>
|
||||
Top Alerts
|
||||
</h3>
|
||||
{erroredIntegrations.length === 0 && problemResources.length === 0 ? (
|
||||
<p style={{ fontSize: '12px', color: '#7A7D85' }}>No alerts — everything connected is healthy.</p>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col justify-around gap-3">
|
||||
{erroredIntegrations.map((i) => (
|
||||
<div key={`int-${i.id}`} className="flex items-start gap-3">
|
||||
<AlertTriangle size={14} style={{ color: '#E74C3C', flexShrink: 0, marginTop: '2px' }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 500 }}>Connection failing</p>
|
||||
<p style={{ fontSize: '11px', color: '#7A7D85' }}>{i.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontSize: '11px', color: '#7A7D85', flexShrink: 0 }}>{alert.time}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{problemResources.map((r, idx) => (
|
||||
<div key={`res-${idx}`} className="flex items-start gap-3">
|
||||
<AlertTriangle size={14} style={{ color: r.status === 'critical' ? '#E74C3C' : '#E67E22', flexShrink: 0, marginTop: '2px' }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 500 }}>{r.name}</p>
|
||||
<p style={{ fontSize: '11px', color: '#7A7D85' }}>{r.integration} · {r.detail ?? r.status}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Server, Shield, Network } from 'lucide-react'
|
||||
import SparklineChart from './SparklineChart'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Server, Plug, BookMarked } from 'lucide-react'
|
||||
import ProgressRing from './ProgressRing'
|
||||
import { api, type Integration, type Resource, type Bookmark } from '../lib/api'
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
backgroundColor: 'rgba(10, 10, 12, 0.55)',
|
||||
|
|
@ -12,92 +13,96 @@ const cardStyle: React.CSSProperties = {
|
|||
transition: 'border-color 0.2s ease',
|
||||
}
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: '10px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '1.5px',
|
||||
color: '#7A7D85',
|
||||
fontWeight: 500,
|
||||
marginBottom: '8px',
|
||||
}
|
||||
|
||||
export default function StatusCards() {
|
||||
const [integrations, setIntegrations] = useState<Integration[] | null>(null)
|
||||
const [resources, setResources] = useState<Resource[] | null>(null)
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[] | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.listIntegrations().then(({ integrations }) => setIntegrations(integrations))
|
||||
api.listResources().then(({ resources }) => setResources(resources))
|
||||
api.listBookmarks().then(({ bookmarks }) => setBookmarks(bookmarks))
|
||||
}, [])
|
||||
|
||||
const connected = integrations?.filter((i) => i.status === 'connected').length ?? 0
|
||||
const errored = integrations?.filter((i) => i.status === 'error').length ?? 0
|
||||
const total = integrations?.length ?? 0
|
||||
|
||||
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 resourceTotal = resources?.length ?? 0
|
||||
|
||||
const favorites = bookmarks?.filter((b) => b.favorite).length ?? 0
|
||||
|
||||
const systemLabel = errored > 0 ? 'Issues Detected' : total === 0 ? 'Not Configured' : 'All Systems Operational'
|
||||
const systemPercent = total === 0 ? 0 : Math.round((connected / total) * 100)
|
||||
|
||||
return (
|
||||
<div className="grid w-full grid-cols-4 gap-5">
|
||||
{/* System Status */}
|
||||
<div style={cardStyle} className="hover:!border-gold/20">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 style={{ fontSize: '10px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500, marginBottom: '8px' }}>
|
||||
System Status
|
||||
</h3>
|
||||
<p style={{ fontSize: '18px', fontWeight: 700, color: '#E8E6E0', lineHeight: 1.3 }}>All Systems</p>
|
||||
<p style={{ fontSize: '18px', fontWeight: 700, color: '#2ECC71', lineHeight: 1.3 }}>Operational</p>
|
||||
<h3 style={labelStyle}>System Status</h3>
|
||||
<p style={{ fontSize: '16px', fontWeight: 700, color: errored > 0 ? '#E74C3C' : '#2ECC71', lineHeight: 1.3 }}>{systemLabel}</p>
|
||||
<p style={{ fontSize: '10px', color: '#7A7D85', marginTop: '4px' }}>{connected} of {total} integrations connected</p>
|
||||
</div>
|
||||
<ProgressRing percentage={100} size={44} strokeWidth={3} />
|
||||
</div>
|
||||
<div style={{ marginTop: '8px', paddingTop: '8px', borderTop: '1px solid rgba(200,164,52,0.06)' }}>
|
||||
<SparklineChart data={[100, 100, 99, 100, 100, 100, 98, 100, 100, 100, 100, 100]} color="#C8A434" height={20} />
|
||||
<p style={{ fontSize: '9px', color: '#7A7D85', marginTop: '4px' }}>Last checked: 2m ago</p>
|
||||
<ProgressRing percentage={systemPercent} size={44} strokeWidth={3} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Infrastructure */}
|
||||
<div style={cardStyle} className="hover:!border-gold/20">
|
||||
<h3 style={{ fontSize: '10px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500, marginBottom: '8px' }}>
|
||||
Infrastructure
|
||||
</h3>
|
||||
<h3 style={labelStyle}>Infrastructure</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server size={16} style={{ color: '#C8A434' }} />
|
||||
<span style={{ fontSize: '24px', fontWeight: 700, color: '#E8E6E0', lineHeight: 1 }}>24</span>
|
||||
<span style={{ fontSize: '24px', fontWeight: 700, color: '#E8E6E0', lineHeight: 1 }}>{resourceTotal}</span>
|
||||
</div>
|
||||
<p style={{ fontSize: '10px', color: '#7A7D85', marginTop: '4px' }}>Total Resources</p>
|
||||
<p style={{ fontSize: '10px', color: '#7A7D85', marginTop: '4px' }}>Resources from connected integrations</p>
|
||||
<div style={{ marginTop: '8px', paddingTop: '8px', borderTop: '1px solid rgba(200,164,52,0.06)', fontSize: '9px' }} className="flex items-center gap-2 flex-wrap">
|
||||
<span className="flex items-center gap-1">
|
||||
<span style={{ width: '6px', height: '6px', borderRadius: '50%', backgroundColor: '#2ECC71' }} />
|
||||
<span style={{ color: '#7A7D85' }}>24 Running</span>
|
||||
<span style={{ color: '#7A7D85' }}>{healthy} Healthy</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span style={{ width: '6px', height: '6px', borderRadius: '50%', backgroundColor: '#E67E22' }} />
|
||||
<span style={{ color: '#7A7D85' }}>0 Warning</span>
|
||||
<span style={{ color: '#7A7D85' }}>{warning} Warning</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span style={{ width: '6px', height: '6px', borderRadius: '50%', backgroundColor: '#E74C3C' }} />
|
||||
<span style={{ color: '#7A7D85' }}>0 Critical</span>
|
||||
<span style={{ color: '#7A7D85' }}>{critical} Critical</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security */}
|
||||
{/* Integrations */}
|
||||
<div style={cardStyle} className="hover:!border-gold/20">
|
||||
<h3 style={{ fontSize: '10px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500, marginBottom: '8px' }}>
|
||||
Security
|
||||
</h3>
|
||||
<h3 style={labelStyle}>Integrations</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield size={16} style={{ color: '#C8A434' }} />
|
||||
<span style={{ fontSize: '24px', fontWeight: 700, color: '#E8E6E0', lineHeight: 1 }}>2</span>
|
||||
</div>
|
||||
<p style={{ fontSize: '10px', color: '#7A7D85', marginTop: '4px' }}>Active Alerts</p>
|
||||
<div style={{ marginTop: '8px', paddingTop: '8px', borderTop: '1px solid rgba(200,164,52,0.06)', fontSize: '9px' }} className="flex items-center gap-2 flex-wrap">
|
||||
<span className="flex items-center gap-1">
|
||||
<span style={{ width: '6px', height: '6px', borderRadius: '50%', backgroundColor: '#2ECC71' }} />
|
||||
<span style={{ color: '#7A7D85' }}>2 Low</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span style={{ width: '6px', height: '6px', borderRadius: '50%', backgroundColor: '#E67E22' }} />
|
||||
<span style={{ color: '#7A7D85' }}>0 Medium</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span style={{ width: '6px', height: '6px', borderRadius: '50%', backgroundColor: '#E74C3C' }} />
|
||||
<span style={{ color: '#7A7D85' }}>0 High</span>
|
||||
</span>
|
||||
<Plug size={16} style={{ color: '#C8A434' }} />
|
||||
<span style={{ fontSize: '24px', fontWeight: 700, color: '#E8E6E0', lineHeight: 1 }}>{connected}/{total}</span>
|
||||
</div>
|
||||
<p style={{ fontSize: '10px', color: '#7A7D85', marginTop: '4px' }}>Connected services</p>
|
||||
</div>
|
||||
|
||||
{/* Network */}
|
||||
{/* Bookmarks */}
|
||||
<div style={cardStyle} className="hover:!border-gold/20">
|
||||
<h3 style={{ fontSize: '10px', textTransform: 'uppercase', letterSpacing: '1.5px', color: '#7A7D85', fontWeight: 500, marginBottom: '8px' }}>
|
||||
Network
|
||||
</h3>
|
||||
<h3 style={labelStyle}>Bookmarks</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Network size={16} style={{ color: '#C8A434' }} />
|
||||
<span style={{ fontSize: '24px', fontWeight: 700, color: '#E8E6E0', lineHeight: 1 }}>98.7%</span>
|
||||
</div>
|
||||
<p style={{ fontSize: '10px', color: '#7A7D85', marginTop: '4px' }}>Network Uptime</p>
|
||||
<div style={{ marginTop: '8px', paddingTop: '8px', borderTop: '1px solid rgba(200,164,52,0.06)' }}>
|
||||
<SparklineChart data={[99, 98, 99, 100, 99, 98, 99, 100, 99, 99, 98, 99, 100, 99, 98, 99, 100, 99, 99, 98, 99, 100, 99, 98]} color="#C8A434" height={24} filled />
|
||||
<BookMarked size={16} style={{ color: '#C8A434' }} />
|
||||
<span style={{ fontSize: '24px', fontWeight: 700, color: '#E8E6E0', lineHeight: 1 }}>{bookmarks?.length ?? 0}</span>
|
||||
</div>
|
||||
<p style={{ fontSize: '10px', color: '#7A7D85', marginTop: '4px' }}>{favorites} favorited</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -67,6 +67,9 @@ export const api = {
|
|||
updateBookmark: (id: number, data: Partial<{ categoryId: number | null; title: string; url: string; icon: string; favorite: boolean }>) =>
|
||||
apiFetch<{ ok: boolean }>(`/bookmarks/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
deleteBookmark: (id: number) => apiFetch<void>(`/bookmarks/${id}`, { method: 'DELETE' }),
|
||||
|
||||
listEvents: (limit = 20) => apiFetch<{ events: Event[] }>(`/events?limit=${limit}`),
|
||||
listResources: () => apiFetch<{ resources: Resource[] }>('/integrations/resources'),
|
||||
}
|
||||
|
||||
export interface Integration {
|
||||
|
|
@ -98,3 +101,18 @@ export interface BookmarkCategory {
|
|||
icon: string | null
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export interface Event {
|
||||
id: number
|
||||
type: string
|
||||
title: string
|
||||
source: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Resource {
|
||||
name: string
|
||||
status: 'healthy' | 'warning' | 'critical' | 'unknown'
|
||||
detail?: string
|
||||
integration: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,61 +1,12 @@
|
|||
import { useState } from 'react'
|
||||
import { PieChart, Pie, Cell, ResponsiveContainer, LineChart, Line, XAxis } from 'recharts'
|
||||
import { Plus, Server, Activity, AlertTriangle, DollarSign } from 'lucide-react'
|
||||
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 } from 'lucide-react'
|
||||
import { api, type Resource, type Integration, type Event } from '../lib/api'
|
||||
|
||||
const subTabs = ['Overview']
|
||||
const futureSubTabs = ['Network']
|
||||
|
||||
const statusCards = [
|
||||
{ label: 'Total Resources', value: '128', icon: Server, sub: '+4 this week' },
|
||||
{ label: 'Healthy', value: '124', icon: Activity, sub: '96.9%', color: '#2ECC71' },
|
||||
{ label: 'Warnings', value: '3', icon: AlertTriangle, sub: 'Needs attention', color: '#E67E22' },
|
||||
{ label: 'Critical', value: '1', icon: AlertTriangle, sub: 'Action required', color: '#E74C3C' },
|
||||
{ label: 'Monthly Cost', value: '$24,560.75', icon: DollarSign, sub: '↑ 3.2% MTD' },
|
||||
]
|
||||
|
||||
const costData = [
|
||||
{ name: 'Compute', value: 38, color: '#C8A434' },
|
||||
{ name: 'Storage', value: 22, color: '#E67E22' },
|
||||
{ name: 'Database', value: 25, color: '#2ECC71' },
|
||||
{ name: 'Network', value: 15, color: '#7A7D85' },
|
||||
]
|
||||
|
||||
const nodes = [
|
||||
{ name: 'web-frontend-lb', status: 'healthy' },
|
||||
{ name: 'app-server-01', status: 'healthy' },
|
||||
{ name: 'app-server-02', status: 'healthy' },
|
||||
{ name: 'app-server-03', status: 'warning' },
|
||||
{ name: 'db-primary-01', status: 'healthy' },
|
||||
{ name: 'db-replica-02', status: 'healthy' },
|
||||
{ name: 'cache-cluster-02', status: 'critical' },
|
||||
{ name: 'batch-worker-04', status: 'healthy' },
|
||||
{ name: 'storage-node-01', status: 'healthy' },
|
||||
{ name: 'storage-node-02', status: 'healthy' },
|
||||
{ name: 'monitoring-01', status: 'healthy' },
|
||||
{ name: 'vpn-gateway', status: 'warning' },
|
||||
]
|
||||
|
||||
const nodeStatusColor: Record<string, string> = {
|
||||
healthy: '#2ECC71',
|
||||
warning: '#E67E22',
|
||||
critical: '#E74C3C',
|
||||
}
|
||||
|
||||
const trendData = Array.from({ length: 14 }, (_, i) => ({
|
||||
day: i,
|
||||
compute: 60 + i * 0.6 + Math.sin(i / 2) * 3,
|
||||
storage: 35 + i * 0.4 + Math.cos(i / 3) * 2,
|
||||
database: 20 + i * 0.2 + Math.sin(i / 4) * 1.5,
|
||||
network: 45 + i * 0.3 + Math.cos(i / 2.5) * 2.5,
|
||||
}))
|
||||
|
||||
const activities = [
|
||||
{ title: 'Auto-scaling triggered', source: 'App Server Group', time: '4m ago' },
|
||||
{ title: 'Resource provisioned', source: 'db-replica-02', time: '19m ago' },
|
||||
{ title: 'Health check failed', source: 'cache-cluster-02', time: '27m ago' },
|
||||
{ title: 'Tag updated', source: '12 resources', time: '1h ago' },
|
||||
]
|
||||
|
||||
const cardBase: React.CSSProperties = {
|
||||
backgroundColor: 'rgba(10, 10, 12, 0.92)',
|
||||
border: '1px solid rgba(200, 164, 52, 0.08)',
|
||||
|
|
@ -108,26 +59,31 @@ const cardDim: React.CSSProperties = {
|
|||
backgroundColor: 'rgba(8, 8, 10, 0.45)',
|
||||
}
|
||||
|
||||
const distributionData = [
|
||||
{ name: 'Compute', value: 42, color: '#C8A434' },
|
||||
{ name: 'Storage', value: 28, color: '#E67E22' },
|
||||
{ name: 'Database', value: 18, color: '#2ECC71' },
|
||||
{ name: 'Network', value: 12, color: '#7A7D85' },
|
||||
]
|
||||
const nodeStatusColor: Record<string, string> = {
|
||||
healthy: '#2ECC71',
|
||||
warning: '#E67E22',
|
||||
critical: '#E74C3C',
|
||||
unknown: '#7A7D85',
|
||||
}
|
||||
|
||||
const integrationPalette = ['#C8A434', '#E67E22', '#2ECC71', '#7A7D85', '#3B82F6', '#8B5E3C']
|
||||
|
||||
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 }}>
|
||||
<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>
|
||||
{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>
|
||||
|
|
@ -138,8 +94,8 @@ function Donut({ data, centerLabel }: { data: { name: string; value: number; col
|
|||
{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: '70px' }}>{entry.name}</span>
|
||||
<span style={{ fontSize: '12px', color: '#7A7D85' }}>{entry.value}%</span>
|
||||
<span style={{ fontSize: '12px', color: '#E8E6E0', width: '90px' }}>{entry.name}</span>
|
||||
<span style={{ fontSize: '12px', color: '#7A7D85' }}>{entry.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -149,6 +105,35 @@ function Donut({ data, centerLabel }: { data: { name: string; value: number; col
|
|||
|
||||
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 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])
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -194,6 +179,7 @@ export default function Infrastructure() {
|
|||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/settings')}
|
||||
className="flex items-center gap-2 cursor-pointer transition-colors whitespace-nowrap"
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
|
|
@ -212,7 +198,7 @@ export default function Infrastructure() {
|
|||
</div>
|
||||
|
||||
{/* Status Cards */}
|
||||
<div className="grid w-full grid-cols-5 gap-5 shrink-0" style={{ marginTop: '8px', height: '110px' }}>
|
||||
<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 (
|
||||
|
|
@ -244,7 +230,11 @@ export default function Infrastructure() {
|
|||
<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">
|
||||
<Donut data={distributionData} />
|
||||
{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>
|
||||
|
|
@ -255,29 +245,35 @@ export default function Infrastructure() {
|
|||
<div style={cardVignette} />
|
||||
<div className="relative z-10 flex flex-1 flex-col">
|
||||
<h3 style={sectionTitle}>Node Status</h3>
|
||||
<div className="grid flex-1 grid-cols-4 gap-3 content-center">
|
||||
{nodes.map((node) => (
|
||||
<div
|
||||
key={node.name}
|
||||
title={`${node.name}: ${node.status}`}
|
||||
style={{
|
||||
backgroundColor: 'rgba(10, 10, 12, 0.55)',
|
||||
border: `1px solid ${nodeStatusColor[node.status]}33`,
|
||||
borderRadius: '8px',
|
||||
padding: '10px 12px',
|
||||
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]}` }} />
|
||||
<Server size={12} style={{ color: '#7A7D85' }} />
|
||||
{resources && resources.length > 0 ? (
|
||||
<div className="grid flex-1 grid-cols-4 gap-3 content-center">
|
||||
{resources.map((node, i) => (
|
||||
<div
|
||||
key={i}
|
||||
title={`${node.name}: ${node.detail ?? node.status}`}
|
||||
style={{
|
||||
backgroundColor: 'rgba(10, 10, 12, 0.55)',
|
||||
border: `1px solid ${nodeStatusColor[node.status]}33`,
|
||||
borderRadius: '8px',
|
||||
padding: '10px 12px',
|
||||
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]}` }} />
|
||||
<Server size={12} style={{ color: '#7A7D85' }} />
|
||||
</div>
|
||||
<span style={{ fontSize: '11px', color: '#E8E6E0', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{node.name}</span>
|
||||
</div>
|
||||
<span style={{ fontSize: '11px', color: '#E8E6E0', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{node.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</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>
|
||||
|
|
@ -285,30 +281,26 @@ export default function Infrastructure() {
|
|||
|
||||
{/* Bottom Row */}
|
||||
<div className="shrink-0">
|
||||
<div className="grid w-full grid-cols-[1.4fr_1fr_1fr] gap-6">
|
||||
{/* Resource Trend */}
|
||||
<div style={{ ...cardBase, height: 'auto', ...framedCard('/archnest-network-traffic-bg.png'), padding: '20px' }} className="hover:!border-gold/15">
|
||||
<div className="relative z-10">
|
||||
<h3 style={sectionTitle}>Resource Trend</h3>
|
||||
<div style={{ height: '100px' }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={trendData} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
|
||||
<XAxis dataKey="day" hide />
|
||||
<Line type="monotone" dataKey="compute" stroke="#3B82F6" strokeWidth={1.5} dot={false} isAnimationActive animationDuration={1000} />
|
||||
<Line type="monotone" dataKey="storage" stroke="#E67E22" strokeWidth={1.5} dot={false} isAnimationActive animationDuration={1000} />
|
||||
<Line type="monotone" dataKey="database" stroke="#2ECC71" strokeWidth={1.5} dot={false} isAnimationActive animationDuration={1000} />
|
||||
<Line type="monotone" dataKey="network" stroke="#8B5E3C" strokeWidth={1.5} dot={false} isAnimationActive animationDuration={1000} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cost Breakdown */}
|
||||
<div className="grid w-full grid-cols-2 gap-6">
|
||||
{/* Integration Health */}
|
||||
<div style={{ ...cardBase, height: 'auto' }} className="hover:!border-gold/15">
|
||||
<div className="relative z-10 flex flex-col">
|
||||
<h3 style={sectionTitle}>Cost Breakdown (MTD)</h3>
|
||||
<Donut data={costData} centerLabel="$24.5K" />
|
||||
<h3 style={sectionTitle}>Integration Health</h3>
|
||||
{integrations && integrations.length > 0 ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{integrations.map((i) => (
|
||||
<div key={i.id} className="flex items-center justify-between">
|
||||
<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>
|
||||
|
||||
|
|
@ -316,17 +308,19 @@ export default function Infrastructure() {
|
|||
<div style={{ ...cardBase, height: 'auto' }} className="hover:!border-gold/15">
|
||||
<div className="relative z-10">
|
||||
<h3 style={sectionTitle}>Recent Activity</h3>
|
||||
<div className="flex flex-col gap-3">
|
||||
{activities.map((item, i) => (
|
||||
<div key={i} 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>
|
||||
<p style={{ fontSize: '11px', color: '#7A7D85', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.source}</p>
|
||||
{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>
|
||||
<span style={{ fontSize: '11px', color: '#7A7D85', flexShrink: 0 }}>{item.time}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ fontSize: '12px', color: '#7A7D85' }}>No activity yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -342,13 +336,14 @@ export default function Infrastructure() {
|
|||
borderTop: '1px solid rgba(200,164,52,0.08)',
|
||||
}}
|
||||
>
|
||||
<span>AWS</span><span style={{ color: 'rgba(200,164,52,0.3)' }}>|</span>
|
||||
<span>6 Regions</span><span style={{ color: 'rgba(200,164,52,0.3)' }}>|</span>
|
||||
<span>18 AZs</span><span style={{ color: 'rgba(200,164,52,0.3)' }}>|</span>
|
||||
<span>128 Resources</span><span style={{ color: 'rgba(200,164,52,0.3)' }}>|</span>
|
||||
<span style={{ color: '#2ECC71' }}>98.7% Health</span><span style={{ color: 'rgba(200,164,52,0.3)' }}>|</span>
|
||||
<span>$24,560.75 MTD</span><span style={{ color: 'rgba(200,164,52,0.3)' }}>|</span>
|
||||
<span style={{ color: '#E67E22' }}>2 Alerts</span>
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue