Compare commits
No commits in common. "8b31fa3f1981981d289530bc21ad87cf17013a5b" and "14d758ef42c4d179b228c246028feffa68c243e8" have entirely different histories.
8b31fa3f19
...
14d758ef42
|
|
@ -1,9 +1,8 @@
|
||||||
import { Styles } from '@because/forge'
|
import { Styles } from '@because/forge'
|
||||||
import { apps, isNarrow, selectedApp } from '../state'
|
|
||||||
import { Layout } from '../styles'
|
|
||||||
import { AppDetail } from './AppDetail'
|
|
||||||
import { DashboardLanding } from './DashboardLanding'
|
|
||||||
import { Modal } from './modal'
|
import { Modal } from './modal'
|
||||||
|
import { apps, isNarrow, selectedApp } from '../state'
|
||||||
|
import { EmptyState, Layout } from '../styles'
|
||||||
|
import { AppDetail } from './AppDetail'
|
||||||
import { Sidebar } from './Sidebar'
|
import { Sidebar } from './Sidebar'
|
||||||
|
|
||||||
export function Dashboard({ render }: { render: () => void }) {
|
export function Dashboard({ render }: { render: () => void }) {
|
||||||
|
|
@ -16,7 +15,7 @@ export function Dashboard({ render }: { render: () => void }) {
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<AppDetail app={selected} render={render} />
|
<AppDetail app={selected} render={render} />
|
||||||
) : (
|
) : (
|
||||||
<DashboardLanding />
|
<EmptyState>Select an app to view details</EmptyState>
|
||||||
)}
|
)}
|
||||||
<Modal />
|
<Modal />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
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,6 +1,5 @@
|
||||||
import { openNewAppModal } from '../modals'
|
import { openNewAppModal } from '../modals'
|
||||||
import {
|
import {
|
||||||
setSelectedApp,
|
|
||||||
setSidebarCollapsed,
|
setSidebarCollapsed,
|
||||||
sidebarCollapsed,
|
sidebarCollapsed,
|
||||||
} from '../state'
|
} from '../state'
|
||||||
|
|
@ -8,7 +7,6 @@ import {
|
||||||
HamburgerButton,
|
HamburgerButton,
|
||||||
HamburgerLine,
|
HamburgerLine,
|
||||||
Logo,
|
Logo,
|
||||||
LogoLink,
|
|
||||||
NewAppButton,
|
NewAppButton,
|
||||||
Sidebar as SidebarContainer,
|
Sidebar as SidebarContainer,
|
||||||
SidebarFooter,
|
SidebarFooter,
|
||||||
|
|
@ -16,11 +14,6 @@ import {
|
||||||
import { AppSelector } from './AppSelector'
|
import { AppSelector } from './AppSelector'
|
||||||
|
|
||||||
export function Sidebar({ render }: { render: () => void }) {
|
export function Sidebar({ render }: { render: () => void }) {
|
||||||
const goToDashboard = () => {
|
|
||||||
setSelectedApp(null)
|
|
||||||
render()
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleSidebar = () => {
|
const toggleSidebar = () => {
|
||||||
setSidebarCollapsed(!sidebarCollapsed)
|
setSidebarCollapsed(!sidebarCollapsed)
|
||||||
render()
|
render()
|
||||||
|
|
@ -29,11 +22,7 @@ export function Sidebar({ render }: { render: () => void }) {
|
||||||
return (
|
return (
|
||||||
<SidebarContainer style={sidebarCollapsed ? { width: 'auto' } : undefined}>
|
<SidebarContainer style={sidebarCollapsed ? { width: 'auto' } : undefined}>
|
||||||
<Logo>
|
<Logo>
|
||||||
{!sidebarCollapsed && (
|
{!sidebarCollapsed && <span>🐾 Toes</span>}
|
||||||
<LogoLink onClick={goToDashboard} title="Go to dashboard">
|
|
||||||
🐾 Toes
|
|
||||||
</LogoLink>
|
|
||||||
)}
|
|
||||||
<HamburgerButton onClick={toggleSidebar} title={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}>
|
<HamburgerButton onClick={toggleSidebar} title={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}>
|
||||||
<HamburgerLine />
|
<HamburgerLine />
|
||||||
<HamburgerLine />
|
<HamburgerLine />
|
||||||
|
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,151 +0,0 @@
|
||||||
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,9 +45,7 @@ narrowQuery.addEventListener('change', e => {
|
||||||
const events = new EventSource('/api/apps/stream')
|
const events = new EventSource('/api/apps/stream')
|
||||||
events.onmessage = e => {
|
events.onmessage = e => {
|
||||||
setApps(JSON.parse(e.data))
|
setApps(JSON.parse(e.data))
|
||||||
// If selected app no longer exists, clear selection to show dashboard
|
const valid = selectedApp && apps.some(a => a.name === selectedApp)
|
||||||
if (selectedApp && !apps.some(a => a.name === selectedApp)) {
|
if (!valid && apps.length) setSelectedApp(apps[0]!.name)
|
||||||
setSelectedApp(null)
|
|
||||||
}
|
|
||||||
render()
|
render()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,200 +0,0 @@
|
||||||
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,45 +1,15 @@
|
||||||
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,
|
||||||
AppList,
|
AppList,
|
||||||
AppSelectorChevron,
|
AppSelectorChevron,
|
||||||
ClickableAppName,
|
ClickableAppName,
|
||||||
DashboardContainer,
|
|
||||||
DashboardHeader,
|
|
||||||
DashboardSubtitle,
|
|
||||||
DashboardTitle,
|
|
||||||
HamburgerButton,
|
HamburgerButton,
|
||||||
HamburgerLine,
|
HamburgerLine,
|
||||||
HeaderActions,
|
HeaderActions,
|
||||||
Layout,
|
Layout,
|
||||||
Logo,
|
Logo,
|
||||||
LogoLink,
|
|
||||||
Main,
|
Main,
|
||||||
MainContent,
|
MainContent,
|
||||||
MainHeader,
|
MainHeader,
|
||||||
|
|
@ -49,10 +19,6 @@ export {
|
||||||
SectionTab,
|
SectionTab,
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarFooter,
|
SidebarFooter,
|
||||||
StatCard,
|
|
||||||
StatLabel,
|
|
||||||
StatsGrid,
|
|
||||||
StatValue,
|
|
||||||
} from './layout'
|
} from './layout'
|
||||||
export { LogLine, LogsContainer, LogTime } from './logs.tsx'
|
export { LogLine, LogsContainer, LogTime } from './logs.tsx'
|
||||||
export {
|
export {
|
||||||
|
|
|
||||||
|
|
@ -28,18 +28,6 @@ export const Logo = define('Logo', {
|
||||||
borderBottom: `1px solid ${theme('colors-border')}`,
|
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', {
|
export const HamburgerButton = define('HamburgerButton', {
|
||||||
base: 'button',
|
base: 'button',
|
||||||
background: 'none',
|
background: 'none',
|
||||||
|
|
@ -195,58 +183,3 @@ export const MainContent = define('MainContent', {
|
||||||
padding: '10px 24px',
|
padding: '10px 24px',
|
||||||
overflow: 'auto',
|
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',
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
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,14 +1,12 @@
|
||||||
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 => {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ export const Shell = () => (
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Toes</title>
|
<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 charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width" />
|
<meta name="viewport" content="width=device-width" />
|
||||||
<style dangerouslySetInnerHTML={{ __html: `body{margin:0}` }} />
|
<style dangerouslySetInnerHTML={{ __html: `body{margin:0}` }} />
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user