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 {
|
||||
DashboardContainer,
|
||||
|
|
@ -9,18 +10,64 @@ import {
|
|||
StatValue,
|
||||
StatsGrid,
|
||||
} 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() {
|
||||
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 toolApps = apps.filter(app => app.tool)
|
||||
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 (
|
||||
<DashboardContainer>
|
||||
<DashboardHeader>
|
||||
<DashboardTitle>🐾 Toes</DashboardTitle>
|
||||
<DashboardTitle>Toes</DashboardTitle>
|
||||
<DashboardSubtitle>Your personal web appliance</DashboardSubtitle>
|
||||
</DashboardHeader>
|
||||
|
||||
<StatsGrid>
|
||||
<StatCard>
|
||||
<StatValue>{regularApps.length}</StatValue>
|
||||
|
|
@ -35,6 +82,10 @@ export function DashboardLanding() {
|
|||
<StatLabel>Running</StatLabel>
|
||||
</StatCard>
|
||||
</StatsGrid>
|
||||
|
||||
<Vitals cpu={metrics.cpu} ram={metrics.ram} disk={metrics.disk} />
|
||||
|
||||
<UnifiedLogs logs={logs} onClear={handleClearLogs} />
|
||||
</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 {
|
||||
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 {
|
||||
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 appsRouter from './api/apps'
|
||||
import syncRouter from './api/sync'
|
||||
import systemRouter from './api/system'
|
||||
import { Hype } from '@because/hype'
|
||||
|
||||
const app = new Hype({ layout: false, logging: false })
|
||||
|
||||
app.route('/api/apps', appsRouter)
|
||||
app.route('/api/sync', syncRouter)
|
||||
app.route('/api/system', systemRouter)
|
||||
|
||||
// Tool URLs: /tool/code?app=todo&file=README.md -> redirect to tool port
|
||||
app.get('/tool/:tool', c => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user