Add system vitals gauges and unified log stream to dashboard
- Add /api/system endpoints for CPU, RAM, and disk metrics (SSE stream) - Add /api/system/logs for unified log stream from all apps (SSE stream) - Create Vitals component with three gauges: arc (CPU), bar (RAM), circular (Disk) - Create UnifiedLogs component with real-time scrolling logs and status highlighting - Update DashboardLanding with stats, vitals, and activity sections Design follows Dieter Rams / Teenage Engineering aesthetic with neutral palette. https://claude.ai/code/session_013L9HKHxMEoub76B1zuKive
This commit is contained in:
parent
543b5d08bc
commit
50e5c97beb
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useEffect, useState } from 'hono/jsx'
|
||||||
import { apps } from '../state'
|
import { apps } from '../state'
|
||||||
import {
|
import {
|
||||||
DashboardContainer,
|
DashboardContainer,
|
||||||
|
|
@ -9,18 +10,64 @@ import {
|
||||||
StatValue,
|
StatValue,
|
||||||
StatsGrid,
|
StatsGrid,
|
||||||
} from '../styles'
|
} from '../styles'
|
||||||
|
import { UnifiedLogs, type UnifiedLogLine } from './UnifiedLogs'
|
||||||
|
import { Vitals } from './Vitals'
|
||||||
|
|
||||||
|
interface SystemMetrics {
|
||||||
|
cpu: number
|
||||||
|
ram: { used: number, total: number, percent: number }
|
||||||
|
disk: { used: number, total: number, percent: number }
|
||||||
|
}
|
||||||
|
|
||||||
export function DashboardLanding() {
|
export function DashboardLanding() {
|
||||||
|
const [metrics, setMetrics] = useState<SystemMetrics>({
|
||||||
|
cpu: 0,
|
||||||
|
ram: { used: 0, total: 0, percent: 0 },
|
||||||
|
disk: { used: 0, total: 0, percent: 0 },
|
||||||
|
})
|
||||||
|
const [logs, setLogs] = useState<UnifiedLogLine[]>([])
|
||||||
|
|
||||||
const regularApps = apps.filter(app => !app.tool)
|
const regularApps = apps.filter(app => !app.tool)
|
||||||
const toolApps = apps.filter(app => app.tool)
|
const toolApps = apps.filter(app => app.tool)
|
||||||
const runningApps = apps.filter(app => app.state === 'running')
|
const runningApps = apps.filter(app => app.state === 'running')
|
||||||
|
|
||||||
|
// Subscribe to system metrics SSE
|
||||||
|
useEffect(() => {
|
||||||
|
const metricsSource = new EventSource('/api/system/metrics/stream')
|
||||||
|
metricsSource.onmessage = e => {
|
||||||
|
try {
|
||||||
|
setMetrics(JSON.parse(e.data))
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return () => metricsSource.close()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Subscribe to unified logs SSE
|
||||||
|
useEffect(() => {
|
||||||
|
const logsSource = new EventSource('/api/system/logs/stream')
|
||||||
|
logsSource.onmessage = e => {
|
||||||
|
try {
|
||||||
|
const line = JSON.parse(e.data) as UnifiedLogLine
|
||||||
|
setLogs((prev: UnifiedLogLine[]) => [...prev.slice(-199), line])
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return () => logsSource.close()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleClearLogs = async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/system/logs/clear', { method: 'POST' })
|
||||||
|
setLogs([])
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardContainer>
|
<DashboardContainer>
|
||||||
<DashboardHeader>
|
<DashboardHeader>
|
||||||
<DashboardTitle>🐾 Toes</DashboardTitle>
|
<DashboardTitle>Toes</DashboardTitle>
|
||||||
<DashboardSubtitle>Your personal web appliance</DashboardSubtitle>
|
<DashboardSubtitle>Your personal web appliance</DashboardSubtitle>
|
||||||
</DashboardHeader>
|
</DashboardHeader>
|
||||||
|
|
||||||
<StatsGrid>
|
<StatsGrid>
|
||||||
<StatCard>
|
<StatCard>
|
||||||
<StatValue>{regularApps.length}</StatValue>
|
<StatValue>{regularApps.length}</StatValue>
|
||||||
|
|
@ -35,6 +82,10 @@ export function DashboardLanding() {
|
||||||
<StatLabel>Running</StatLabel>
|
<StatLabel>Running</StatLabel>
|
||||||
</StatCard>
|
</StatCard>
|
||||||
</StatsGrid>
|
</StatsGrid>
|
||||||
|
|
||||||
|
<Vitals cpu={metrics.cpu} ram={metrics.ram} disk={metrics.disk} />
|
||||||
|
|
||||||
|
<UnifiedLogs logs={logs} onClear={handleClearLogs} />
|
||||||
</DashboardContainer>
|
</DashboardContainer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
97
src/client/components/UnifiedLogs.tsx
Normal file
97
src/client/components/UnifiedLogs.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { useEffect, useRef } from 'hono/jsx'
|
||||||
|
import {
|
||||||
|
LogApp,
|
||||||
|
LogEntry,
|
||||||
|
LogsBody,
|
||||||
|
LogsClearButton,
|
||||||
|
LogsHeader,
|
||||||
|
LogsSection,
|
||||||
|
LogsTitle,
|
||||||
|
LogText,
|
||||||
|
LogTimestamp,
|
||||||
|
} from '../styles'
|
||||||
|
|
||||||
|
export interface UnifiedLogLine {
|
||||||
|
time: number
|
||||||
|
app: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UnifiedLogsProps {
|
||||||
|
logs: UnifiedLogLine[]
|
||||||
|
onClear: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(timestamp: number): string {
|
||||||
|
const d = new Date(timestamp)
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLogText(text: string): { method?: string, path?: string, status?: number, rest: string } {
|
||||||
|
// Match patterns like "GET /api/time 200" or "200 GET http://... (0ms)"
|
||||||
|
const httpMatch = text.match(/^(\d{3})\s+(GET|POST|PUT|DELETE|PATCH)\s+\S+/)
|
||||||
|
|| text.match(/^(GET|POST|PUT|DELETE|PATCH)\s+(\S+)\s+(\d{3})/)
|
||||||
|
|
||||||
|
if (httpMatch) {
|
||||||
|
if (httpMatch[1]?.match(/^\d{3}$/)) {
|
||||||
|
return { method: httpMatch[2], status: parseInt(httpMatch[1], 10), rest: text }
|
||||||
|
} else {
|
||||||
|
return { method: httpMatch[1], path: httpMatch[2], status: parseInt(httpMatch[3]!, 10), rest: text }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rest: text }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusColor(status?: number): string | undefined {
|
||||||
|
if (!status) return undefined
|
||||||
|
if (status >= 200 && status < 300) return '#22c55e'
|
||||||
|
if (status >= 400 && status < 500) return '#f59e0b'
|
||||||
|
if (status >= 500) return '#ef4444'
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogLine({ log }: { log: UnifiedLogLine }) {
|
||||||
|
const parsed = parseLogText(log.text)
|
||||||
|
const statusColor = getStatusColor(parsed.status)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LogEntry>
|
||||||
|
<LogTimestamp>{formatTime(log.time)}</LogTimestamp>
|
||||||
|
<LogApp>{log.app}</LogApp>
|
||||||
|
<LogText style={statusColor ? { color: statusColor } : undefined}>
|
||||||
|
{log.text}
|
||||||
|
</LogText>
|
||||||
|
</LogEntry>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UnifiedLogs({ logs, onClear }: UnifiedLogsProps) {
|
||||||
|
const bodyRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Auto-scroll to bottom when new logs arrive
|
||||||
|
useEffect(() => {
|
||||||
|
if (bodyRef.current) {
|
||||||
|
bodyRef.current.scrollTop = bodyRef.current.scrollHeight
|
||||||
|
}
|
||||||
|
}, [logs.length])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LogsSection>
|
||||||
|
<LogsHeader>
|
||||||
|
<LogsTitle>Activity</LogsTitle>
|
||||||
|
<LogsClearButton onClick={onClear}>Clear</LogsClearButton>
|
||||||
|
</LogsHeader>
|
||||||
|
<LogsBody ref={bodyRef}>
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<LogEntry>
|
||||||
|
<LogText style={{ color: 'var(--colors-textFaint)' }}>No activity yet</LogText>
|
||||||
|
</LogEntry>
|
||||||
|
) : (
|
||||||
|
logs.map((log, i) => <LogLine key={i} log={log} />)
|
||||||
|
)}
|
||||||
|
</LogsBody>
|
||||||
|
</LogsSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
151
src/client/components/Vitals.tsx
Normal file
151
src/client/components/Vitals.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
import {
|
||||||
|
BarGaugeContainer,
|
||||||
|
BarGaugeFill,
|
||||||
|
BarGaugeLabel,
|
||||||
|
BarGaugeTrack,
|
||||||
|
CircleGaugeContainer,
|
||||||
|
CircleGaugeSvg,
|
||||||
|
CircleGaugeValue,
|
||||||
|
GaugeContainer,
|
||||||
|
GaugeSvg,
|
||||||
|
GaugeValue,
|
||||||
|
VitalCard,
|
||||||
|
VitalLabel,
|
||||||
|
VitalsSection,
|
||||||
|
} from '../styles'
|
||||||
|
|
||||||
|
interface VitalsProps {
|
||||||
|
cpu: number
|
||||||
|
ram: { percent: number }
|
||||||
|
disk: { percent: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArcGauge({ value }: { value: number }) {
|
||||||
|
// Arc from -135 to 135 degrees (270 degree sweep)
|
||||||
|
const radius = 50
|
||||||
|
const centerX = 60
|
||||||
|
const centerY = 60
|
||||||
|
const startAngle = -135
|
||||||
|
const endAngle = 135
|
||||||
|
const sweepAngle = endAngle - startAngle
|
||||||
|
|
||||||
|
// Calculate the value angle
|
||||||
|
const valueAngle = startAngle + (value / 100) * sweepAngle
|
||||||
|
|
||||||
|
// Convert angles to radians and calculate points
|
||||||
|
const toRad = (deg: number) => (deg * Math.PI) / 180
|
||||||
|
const startX = centerX + radius * Math.cos(toRad(startAngle))
|
||||||
|
const startY = centerY + radius * Math.sin(toRad(startAngle))
|
||||||
|
const endX = centerX + radius * Math.cos(toRad(endAngle))
|
||||||
|
const endY = centerY + radius * Math.sin(toRad(endAngle))
|
||||||
|
const valueX = centerX + radius * Math.cos(toRad(valueAngle))
|
||||||
|
const valueY = centerY + radius * Math.sin(toRad(valueAngle))
|
||||||
|
|
||||||
|
// Create arc paths
|
||||||
|
const trackPath = `M ${startX} ${startY} A ${radius} ${radius} 0 1 1 ${endX} ${endY}`
|
||||||
|
const valuePath = value > 0
|
||||||
|
? `M ${startX} ${startY} A ${radius} ${radius} 0 ${value > 50 ? 1 : 0} 1 ${valueX} ${valueY}`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GaugeContainer>
|
||||||
|
<GaugeSvg viewBox="0 0 120 70">
|
||||||
|
{/* Track */}
|
||||||
|
<path
|
||||||
|
d={trackPath}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--colors-border)"
|
||||||
|
stroke-width="6"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
{/* Value */}
|
||||||
|
{value > 0 && (
|
||||||
|
<path
|
||||||
|
d={valuePath}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--colors-textMuted)"
|
||||||
|
stroke-width="6"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Needle */}
|
||||||
|
<line
|
||||||
|
x1={centerX}
|
||||||
|
y1={centerY}
|
||||||
|
x2={valueX}
|
||||||
|
y2={valueY}
|
||||||
|
stroke="var(--colors-text)"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
<circle cx={centerX} cy={centerY} r="4" fill="var(--colors-text)" />
|
||||||
|
</GaugeSvg>
|
||||||
|
<GaugeValue>{value}%</GaugeValue>
|
||||||
|
</GaugeContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BarGauge({ value }: { value: number }) {
|
||||||
|
return (
|
||||||
|
<BarGaugeContainer>
|
||||||
|
<BarGaugeTrack>
|
||||||
|
<BarGaugeFill style={{ width: `${value}%` }} />
|
||||||
|
</BarGaugeTrack>
|
||||||
|
<BarGaugeLabel>{value}%</BarGaugeLabel>
|
||||||
|
</BarGaugeContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CircleGauge({ value }: { value: number }) {
|
||||||
|
const radius = 32
|
||||||
|
const circumference = 2 * Math.PI * radius
|
||||||
|
const offset = circumference - (value / 100) * circumference
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CircleGaugeContainer>
|
||||||
|
<CircleGaugeSvg viewBox="0 0 80 80">
|
||||||
|
{/* Track */}
|
||||||
|
<circle
|
||||||
|
cx="40"
|
||||||
|
cy="40"
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--colors-border)"
|
||||||
|
stroke-width="6"
|
||||||
|
/>
|
||||||
|
{/* Value */}
|
||||||
|
<circle
|
||||||
|
cx="40"
|
||||||
|
cy="40"
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--colors-textMuted)"
|
||||||
|
stroke-width="6"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-dasharray={circumference}
|
||||||
|
stroke-dashoffset={offset}
|
||||||
|
/>
|
||||||
|
</CircleGaugeSvg>
|
||||||
|
<CircleGaugeValue>{value}%</CircleGaugeValue>
|
||||||
|
</CircleGaugeContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Vitals({ cpu, ram, disk }: VitalsProps) {
|
||||||
|
return (
|
||||||
|
<VitalsSection>
|
||||||
|
<VitalCard>
|
||||||
|
<VitalLabel>CPU</VitalLabel>
|
||||||
|
<ArcGauge value={cpu} />
|
||||||
|
</VitalCard>
|
||||||
|
<VitalCard>
|
||||||
|
<VitalLabel>RAM</VitalLabel>
|
||||||
|
<BarGauge value={ram.percent} />
|
||||||
|
</VitalCard>
|
||||||
|
<VitalCard>
|
||||||
|
<VitalLabel>Disk</VitalLabel>
|
||||||
|
<CircleGauge value={disk.percent} />
|
||||||
|
</VitalCard>
|
||||||
|
</VitalsSection>
|
||||||
|
)
|
||||||
|
}
|
||||||
200
src/client/styles/dashboard.ts
Normal file
200
src/client/styles/dashboard.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
import { define } from '@because/forge'
|
||||||
|
import { theme } from '../themes'
|
||||||
|
|
||||||
|
// Vitals Section
|
||||||
|
export const VitalsSection = define('VitalsSection', {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||||
|
gap: 24,
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 800,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const VitalCard = define('VitalCard', {
|
||||||
|
background: theme('colors-bgElement'),
|
||||||
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
padding: 24,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 16,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const VitalLabel = define('VitalLabel', {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: theme('colors-textFaint'),
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Arc Gauge (for CPU)
|
||||||
|
export const GaugeContainer = define('GaugeContainer', {
|
||||||
|
position: 'relative',
|
||||||
|
width: 120,
|
||||||
|
height: 70,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const GaugeSvg = define('GaugeSvg', {
|
||||||
|
base: 'svg',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'visible',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const GaugeValue = define('GaugeValue', {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontFamily: theme('fonts-mono'),
|
||||||
|
color: theme('colors-text'),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Bar Gauge (for RAM)
|
||||||
|
export const BarGaugeContainer = define('BarGaugeContainer', {
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 120,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const BarGaugeTrack = define('BarGaugeTrack', {
|
||||||
|
width: '100%',
|
||||||
|
height: 12,
|
||||||
|
background: theme('colors-border'),
|
||||||
|
borderRadius: 6,
|
||||||
|
overflow: 'hidden',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const BarGaugeFill = define('BarGaugeFill', {
|
||||||
|
height: '100%',
|
||||||
|
background: theme('colors-textMuted'),
|
||||||
|
borderRadius: 6,
|
||||||
|
transition: 'width 0.3s ease',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const BarGaugeLabel = define('BarGaugeLabel', {
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontFamily: theme('fonts-mono'),
|
||||||
|
color: theme('colors-text'),
|
||||||
|
textAlign: 'center',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Circular Gauge (for Disk)
|
||||||
|
export const CircleGaugeContainer = define('CircleGaugeContainer', {
|
||||||
|
position: 'relative',
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const CircleGaugeSvg = define('CircleGaugeSvg', {
|
||||||
|
base: 'svg',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
transform: 'rotate(-90deg)',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const CircleGaugeValue = define('CircleGaugeValue', {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontFamily: theme('fonts-mono'),
|
||||||
|
color: theme('colors-text'),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Unified Logs Section
|
||||||
|
export const LogsSection = define('LogsSection', {
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 800,
|
||||||
|
background: theme('colors-bgElement'),
|
||||||
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
|
borderRadius: theme('radius-md'),
|
||||||
|
overflow: 'hidden',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const LogsHeader = define('LogsHeader', {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '12px 16px',
|
||||||
|
borderBottom: `1px solid ${theme('colors-border')}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const LogsTitle = define('LogsTitle', {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: theme('colors-textFaint'),
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.05em',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const LogsClearButton = define('LogsClearButton', {
|
||||||
|
base: 'button',
|
||||||
|
background: theme('colors-border'),
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: '4px 10px',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: theme('colors-textMuted'),
|
||||||
|
cursor: 'pointer',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.03em',
|
||||||
|
selectors: {
|
||||||
|
'&:hover': {
|
||||||
|
background: theme('colors-bgHover'),
|
||||||
|
color: theme('colors-text'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const LogsBody = define('LogsBody', {
|
||||||
|
height: 200,
|
||||||
|
overflow: 'auto',
|
||||||
|
fontFamily: theme('fonts-mono'),
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const LogEntry = define('LogEntry', {
|
||||||
|
display: 'flex',
|
||||||
|
gap: 8,
|
||||||
|
padding: '2px 16px',
|
||||||
|
selectors: {
|
||||||
|
'&:hover': {
|
||||||
|
background: theme('colors-bgHover'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const LogTimestamp = define('LogTimestamp', {
|
||||||
|
color: theme('colors-textFaint'),
|
||||||
|
flexShrink: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const LogApp = define('LogApp', {
|
||||||
|
color: theme('colors-textMuted'),
|
||||||
|
flexShrink: 0,
|
||||||
|
minWidth: 80,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const LogText = define('LogText', {
|
||||||
|
color: theme('colors-text'),
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const LogStatus = define('LogStatus', {
|
||||||
|
variants: {
|
||||||
|
success: { color: '#22c55e' },
|
||||||
|
error: { color: '#ef4444' },
|
||||||
|
warning: { color: '#f59e0b' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -1,4 +1,29 @@
|
||||||
export { ActionBar, Button, NewAppButton } from './buttons'
|
export { ActionBar, Button, NewAppButton } from './buttons'
|
||||||
|
export {
|
||||||
|
BarGaugeContainer,
|
||||||
|
BarGaugeFill,
|
||||||
|
BarGaugeLabel,
|
||||||
|
BarGaugeTrack,
|
||||||
|
CircleGaugeContainer,
|
||||||
|
CircleGaugeSvg,
|
||||||
|
CircleGaugeValue,
|
||||||
|
GaugeContainer,
|
||||||
|
GaugeSvg,
|
||||||
|
GaugeValue,
|
||||||
|
LogApp,
|
||||||
|
LogEntry,
|
||||||
|
LogsBody,
|
||||||
|
LogsClearButton,
|
||||||
|
LogsHeader,
|
||||||
|
LogsSection,
|
||||||
|
LogsTitle,
|
||||||
|
LogStatus,
|
||||||
|
LogText,
|
||||||
|
LogTimestamp,
|
||||||
|
VitalCard,
|
||||||
|
VitalLabel,
|
||||||
|
VitalsSection,
|
||||||
|
} from './dashboard'
|
||||||
export { Form, FormActions, FormCheckbox, FormCheckboxField, FormCheckboxLabel, FormError, FormField, FormInput, FormLabel, FormSelect } from './forms'
|
export { Form, FormActions, FormCheckbox, FormCheckboxField, FormCheckboxLabel, FormError, FormField, FormInput, FormLabel, FormSelect } from './forms'
|
||||||
export {
|
export {
|
||||||
AppItem,
|
AppItem,
|
||||||
|
|
|
||||||
175
src/server/api/system.ts
Normal file
175
src/server/api/system.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
import { allApps, onChange } from '$apps'
|
||||||
|
import { Hype } from '@because/hype'
|
||||||
|
import { cpus, freemem, totalmem } from 'os'
|
||||||
|
import { statfsSync } from 'fs'
|
||||||
|
|
||||||
|
export interface SystemMetrics {
|
||||||
|
cpu: number // 0-100
|
||||||
|
ram: { used: number, total: number, percent: number }
|
||||||
|
disk: { used: number, total: number, percent: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnifiedLogLine {
|
||||||
|
time: number
|
||||||
|
app: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = Hype.router()
|
||||||
|
|
||||||
|
// Unified log buffer
|
||||||
|
const unifiedLogs: UnifiedLogLine[] = []
|
||||||
|
const MAX_UNIFIED_LOGS = 200
|
||||||
|
const unifiedLogListeners = new Set<(line: UnifiedLogLine) => void>()
|
||||||
|
|
||||||
|
// Track last log counts per app for delta detection
|
||||||
|
const lastLogCounts = new Map<string, number>()
|
||||||
|
|
||||||
|
// CPU tracking
|
||||||
|
let lastCpuTimes: { idle: number, total: number } | null = null
|
||||||
|
|
||||||
|
function getCpuUsage(): number {
|
||||||
|
const cpuList = cpus()
|
||||||
|
let idle = 0
|
||||||
|
let total = 0
|
||||||
|
|
||||||
|
for (const cpu of cpuList) {
|
||||||
|
idle += cpu.times.idle
|
||||||
|
total += cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.idle + cpu.times.irq
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lastCpuTimes) {
|
||||||
|
lastCpuTimes = { idle, total }
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const idleDiff = idle - lastCpuTimes.idle
|
||||||
|
const totalDiff = total - lastCpuTimes.total
|
||||||
|
|
||||||
|
lastCpuTimes = { idle, total }
|
||||||
|
|
||||||
|
if (totalDiff === 0) return 0
|
||||||
|
return Math.round((1 - idleDiff / totalDiff) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMemoryUsage(): { used: number, total: number, percent: number } {
|
||||||
|
const total = totalmem()
|
||||||
|
const free = freemem()
|
||||||
|
const used = total - free
|
||||||
|
return {
|
||||||
|
used,
|
||||||
|
total,
|
||||||
|
percent: Math.round((used / total) * 100),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDiskUsage(): { used: number, total: number, percent: number } {
|
||||||
|
try {
|
||||||
|
const stats = statfsSync('/')
|
||||||
|
const total = stats.blocks * stats.bsize
|
||||||
|
const free = stats.bfree * stats.bsize
|
||||||
|
const used = total - free
|
||||||
|
return {
|
||||||
|
used,
|
||||||
|
total,
|
||||||
|
percent: Math.round((used / total) * 100),
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { used: 0, total: 0, percent: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current system metrics
|
||||||
|
router.get('/metrics', c => {
|
||||||
|
const metrics: SystemMetrics = {
|
||||||
|
cpu: getCpuUsage(),
|
||||||
|
ram: getMemoryUsage(),
|
||||||
|
disk: getDiskUsage(),
|
||||||
|
}
|
||||||
|
return c.json(metrics)
|
||||||
|
})
|
||||||
|
|
||||||
|
// SSE stream for real-time metrics (updates every 2s)
|
||||||
|
router.sse('/metrics/stream', (send) => {
|
||||||
|
const sendMetrics = () => {
|
||||||
|
const metrics: SystemMetrics = {
|
||||||
|
cpu: getCpuUsage(),
|
||||||
|
ram: getMemoryUsage(),
|
||||||
|
disk: getDiskUsage(),
|
||||||
|
}
|
||||||
|
send(metrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial send
|
||||||
|
sendMetrics()
|
||||||
|
|
||||||
|
// Update every 2 seconds
|
||||||
|
const interval = setInterval(sendMetrics, 2000)
|
||||||
|
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get recent unified logs
|
||||||
|
router.get('/logs', c => {
|
||||||
|
const tail = c.req.query('tail')
|
||||||
|
const count = tail ? parseInt(tail, 10) : MAX_UNIFIED_LOGS
|
||||||
|
return c.json(unifiedLogs.slice(-count))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clear unified logs
|
||||||
|
router.post('/logs/clear', c => {
|
||||||
|
unifiedLogs.length = 0
|
||||||
|
return c.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
// SSE stream for unified logs
|
||||||
|
router.sse('/logs/stream', (send) => {
|
||||||
|
// Send existing logs first
|
||||||
|
for (const line of unifiedLogs.slice(-50)) {
|
||||||
|
send(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to new logs
|
||||||
|
const listener = (line: UnifiedLogLine) => send(line)
|
||||||
|
unifiedLogListeners.add(listener)
|
||||||
|
|
||||||
|
return () => unifiedLogListeners.delete(listener)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Collect logs from all apps
|
||||||
|
function collectLogs() {
|
||||||
|
const apps = allApps()
|
||||||
|
|
||||||
|
for (const app of apps) {
|
||||||
|
const logs = app.logs ?? []
|
||||||
|
const lastCount = lastLogCounts.get(app.name) ?? 0
|
||||||
|
|
||||||
|
// Get new logs since last check
|
||||||
|
const newLogs = logs.slice(lastCount)
|
||||||
|
lastLogCounts.set(app.name, logs.length)
|
||||||
|
|
||||||
|
for (const log of newLogs) {
|
||||||
|
const line: UnifiedLogLine = {
|
||||||
|
time: log.time,
|
||||||
|
app: app.name,
|
||||||
|
text: log.text,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to buffer
|
||||||
|
unifiedLogs.push(line)
|
||||||
|
if (unifiedLogs.length > MAX_UNIFIED_LOGS) {
|
||||||
|
unifiedLogs.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify listeners
|
||||||
|
for (const listener of unifiedLogListeners) {
|
||||||
|
listener(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to app changes to collect logs
|
||||||
|
onChange(collectLogs)
|
||||||
|
|
||||||
|
export default router
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import { allApps, initApps, TOES_URL } from '$apps'
|
import { allApps, initApps, TOES_URL } from '$apps'
|
||||||
import appsRouter from './api/apps'
|
import appsRouter from './api/apps'
|
||||||
import syncRouter from './api/sync'
|
import syncRouter from './api/sync'
|
||||||
|
import systemRouter from './api/system'
|
||||||
import { Hype } from '@because/hype'
|
import { Hype } from '@because/hype'
|
||||||
|
|
||||||
const app = new Hype({ layout: false, logging: false })
|
const app = new Hype({ layout: false, logging: false })
|
||||||
|
|
||||||
app.route('/api/apps', appsRouter)
|
app.route('/api/apps', appsRouter)
|
||||||
app.route('/api/sync', syncRouter)
|
app.route('/api/sync', syncRouter)
|
||||||
|
app.route('/api/system', systemRouter)
|
||||||
|
|
||||||
// Tool URLs: /tool/code?app=todo&file=README.md -> redirect to tool port
|
// Tool URLs: /tool/code?app=todo&file=README.md -> redirect to tool port
|
||||||
app.get('/tool/:tool', c => {
|
app.get('/tool/:tool', c => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user