Compare commits
4 Commits
14d758ef42
...
8b31fa3f19
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b31fa3f19 | ||
|
|
50e5c97beb | ||
| 543b5d08bc | |||
|
|
a91f400100 |
|
|
@ -1,8 +1,9 @@
|
|||
import { Styles } from '@because/forge'
|
||||
import { Modal } from './modal'
|
||||
import { apps, isNarrow, selectedApp } from '../state'
|
||||
import { EmptyState, Layout } from '../styles'
|
||||
import { Layout } from '../styles'
|
||||
import { AppDetail } from './AppDetail'
|
||||
import { DashboardLanding } from './DashboardLanding'
|
||||
import { Modal } from './modal'
|
||||
import { Sidebar } from './Sidebar'
|
||||
|
||||
export function Dashboard({ render }: { render: () => void }) {
|
||||
|
|
@ -15,7 +16,7 @@ export function Dashboard({ render }: { render: () => void }) {
|
|||
{selected ? (
|
||||
<AppDetail app={selected} render={render} />
|
||||
) : (
|
||||
<EmptyState>Select an app to view details</EmptyState>
|
||||
<DashboardLanding />
|
||||
)}
|
||||
<Modal />
|
||||
</Layout>
|
||||
|
|
|
|||
91
src/client/components/DashboardLanding.tsx
Normal file
91
src/client/components/DashboardLanding.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { useEffect, useState } from 'hono/jsx'
|
||||
import { apps } from '../state'
|
||||
import {
|
||||
DashboardContainer,
|
||||
DashboardHeader,
|
||||
DashboardSubtitle,
|
||||
DashboardTitle,
|
||||
StatCard,
|
||||
StatLabel,
|
||||
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>
|
||||
<DashboardSubtitle>Your personal web appliance</DashboardSubtitle>
|
||||
</DashboardHeader>
|
||||
|
||||
<StatsGrid>
|
||||
<StatCard>
|
||||
<StatValue>{regularApps.length}</StatValue>
|
||||
<StatLabel>Apps</StatLabel>
|
||||
</StatCard>
|
||||
<StatCard>
|
||||
<StatValue>{toolApps.length}</StatValue>
|
||||
<StatLabel>Tools</StatLabel>
|
||||
</StatCard>
|
||||
<StatCard>
|
||||
<StatValue>{runningApps.length}</StatValue>
|
||||
<StatLabel>Running</StatLabel>
|
||||
</StatCard>
|
||||
</StatsGrid>
|
||||
|
||||
<Vitals cpu={metrics.cpu} ram={metrics.ram} disk={metrics.disk} />
|
||||
|
||||
<UnifiedLogs logs={logs} onClear={handleClearLogs} />
|
||||
</DashboardContainer>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { openNewAppModal } from '../modals'
|
||||
import {
|
||||
setSelectedApp,
|
||||
setSidebarCollapsed,
|
||||
sidebarCollapsed,
|
||||
} from '../state'
|
||||
|
|
@ -7,6 +8,7 @@ import {
|
|||
HamburgerButton,
|
||||
HamburgerLine,
|
||||
Logo,
|
||||
LogoLink,
|
||||
NewAppButton,
|
||||
Sidebar as SidebarContainer,
|
||||
SidebarFooter,
|
||||
|
|
@ -14,6 +16,11 @@ import {
|
|||
import { AppSelector } from './AppSelector'
|
||||
|
||||
export function Sidebar({ render }: { render: () => void }) {
|
||||
const goToDashboard = () => {
|
||||
setSelectedApp(null)
|
||||
render()
|
||||
}
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setSidebarCollapsed(!sidebarCollapsed)
|
||||
render()
|
||||
|
|
@ -22,7 +29,11 @@ export function Sidebar({ render }: { render: () => void }) {
|
|||
return (
|
||||
<SidebarContainer style={sidebarCollapsed ? { width: 'auto' } : undefined}>
|
||||
<Logo>
|
||||
{!sidebarCollapsed && <span>🐾 Toes</span>}
|
||||
{!sidebarCollapsed && (
|
||||
<LogoLink onClick={goToDashboard} title="Go to dashboard">
|
||||
🐾 Toes
|
||||
</LogoLink>
|
||||
)}
|
||||
<HamburgerButton onClick={toggleSidebar} title={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}>
|
||||
<HamburgerLine />
|
||||
<HamburgerLine />
|
||||
|
|
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -45,7 +45,9 @@ narrowQuery.addEventListener('change', e => {
|
|||
const events = new EventSource('/api/apps/stream')
|
||||
events.onmessage = e => {
|
||||
setApps(JSON.parse(e.data))
|
||||
const valid = selectedApp && apps.some(a => a.name === selectedApp)
|
||||
if (!valid && apps.length) setSelectedApp(apps[0]!.name)
|
||||
// If selected app no longer exists, clear selection to show dashboard
|
||||
if (selectedApp && !apps.some(a => a.name === selectedApp)) {
|
||||
setSelectedApp(null)
|
||||
}
|
||||
render()
|
||||
}
|
||||
|
|
|
|||
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,15 +1,45 @@
|
|||
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,
|
||||
AppList,
|
||||
AppSelectorChevron,
|
||||
ClickableAppName,
|
||||
DashboardContainer,
|
||||
DashboardHeader,
|
||||
DashboardSubtitle,
|
||||
DashboardTitle,
|
||||
HamburgerButton,
|
||||
HamburgerLine,
|
||||
HeaderActions,
|
||||
Layout,
|
||||
Logo,
|
||||
LogoLink,
|
||||
Main,
|
||||
MainContent,
|
||||
MainHeader,
|
||||
|
|
@ -19,6 +49,10 @@ export {
|
|||
SectionTab,
|
||||
Sidebar,
|
||||
SidebarFooter,
|
||||
StatCard,
|
||||
StatLabel,
|
||||
StatsGrid,
|
||||
StatValue,
|
||||
} from './layout'
|
||||
export { LogLine, LogsContainer, LogTime } from './logs.tsx'
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,18 @@ export const Logo = define('Logo', {
|
|||
borderBottom: `1px solid ${theme('colors-border')}`,
|
||||
})
|
||||
|
||||
export const LogoLink = define('LogoLink', {
|
||||
cursor: 'pointer',
|
||||
borderRadius: theme('radius-md'),
|
||||
padding: '4px 8px',
|
||||
margin: '-4px -8px',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
background: theme('colors-bgHover'),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const HamburgerButton = define('HamburgerButton', {
|
||||
base: 'button',
|
||||
background: 'none',
|
||||
|
|
@ -183,3 +195,58 @@ export const MainContent = define('MainContent', {
|
|||
padding: '10px 24px',
|
||||
overflow: 'auto',
|
||||
})
|
||||
|
||||
export const DashboardContainer = define('DashboardContainer', {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 40,
|
||||
gap: 40,
|
||||
})
|
||||
|
||||
export const DashboardHeader = define('DashboardHeader', {
|
||||
textAlign: 'center',
|
||||
})
|
||||
|
||||
export const DashboardTitle = define('DashboardTitle', {
|
||||
fontSize: 48,
|
||||
fontWeight: 'bold',
|
||||
margin: 0,
|
||||
marginBottom: 8,
|
||||
})
|
||||
|
||||
export const DashboardSubtitle = define('DashboardSubtitle', {
|
||||
fontSize: 16,
|
||||
color: theme('colors-textMuted'),
|
||||
margin: 0,
|
||||
})
|
||||
|
||||
export const StatsGrid = define('StatsGrid', {
|
||||
display: 'flex',
|
||||
gap: 24,
|
||||
})
|
||||
|
||||
export const StatCard = define('StatCard', {
|
||||
background: theme('colors-bgElement'),
|
||||
border: `1px solid ${theme('colors-border')}`,
|
||||
borderRadius: theme('radius-md'),
|
||||
padding: '24px 40px',
|
||||
textAlign: 'center',
|
||||
minWidth: 120,
|
||||
})
|
||||
|
||||
export const StatValue = define('StatValue', {
|
||||
fontSize: 36,
|
||||
fontWeight: 'bold',
|
||||
color: theme('colors-text'),
|
||||
marginBottom: 4,
|
||||
})
|
||||
|
||||
export const StatLabel = define('StatLabel', {
|
||||
fontSize: 14,
|
||||
color: theme('colors-textMuted'),
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
})
|
||||
|
|
|
|||
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 => {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ export const Shell = () => (
|
|||
<html>
|
||||
<head>
|
||||
<title>Toes</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🐾</text></svg>" />
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<style dangerouslySetInnerHTML={{ __html: `body{margin:0}` }} />
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user