dashboard
This commit is contained in:
parent
8b31fa3f19
commit
720c0e76fb
|
|
@ -23,7 +23,8 @@
|
||||||
"cli:link": "ln -sf $(pwd)/src/cli/index.ts ~/.bun/bin/toes",
|
"cli:link": "ln -sf $(pwd)/src/cli/index.ts ~/.bun/bin/toes",
|
||||||
"cli:uninstall": "sudo rm /usr/local/bin",
|
"cli:uninstall": "sudo rm /usr/local/bin",
|
||||||
"deploy": "./scripts/deploy.sh",
|
"deploy": "./scripts/deploy.sh",
|
||||||
"dev": "rm pub/client/index.js && bun run --hot src/server/index.tsx",
|
"debug": "DEBUG=1 bun run dev",
|
||||||
|
"dev": "rm -f pub/client/index.js && bun run --hot src/server/index.tsx",
|
||||||
"remote:deploy": "./scripts/deploy.sh",
|
"remote:deploy": "./scripts/deploy.sh",
|
||||||
"remote:install": "./scripts/remote-install.sh",
|
"remote:install": "./scripts/remote-install.sh",
|
||||||
"remote:logs": "./scripts/remote-logs.sh",
|
"remote:logs": "./scripts/remote-logs.sh",
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ export function AppSelector({ render, onSelect, collapsed, switcherStyle, listSt
|
||||||
<>
|
<>
|
||||||
<span style={{ fontSize: 14 }}>{app.icon}</span>
|
<span style={{ fontSize: 14 }}>{app.icon}</span>
|
||||||
{app.name}
|
{app.name}
|
||||||
<StatusDot state={app.state} style={{ marginLeft: 'auto' }} />
|
<StatusDot state={app.state} data-app={app.name} data-state={app.state} style={{ marginLeft: 'auto' }} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</AppItem>
|
</AppItem>
|
||||||
|
|
|
||||||
|
|
@ -1,91 +1,46 @@
|
||||||
import { useEffect, useState } from 'hono/jsx'
|
import { useEffect } from 'hono/jsx'
|
||||||
import { apps } from '../state'
|
import { apps, setSelectedApp } from '../state'
|
||||||
import {
|
import {
|
||||||
DashboardContainer,
|
DashboardContainer,
|
||||||
DashboardHeader,
|
DashboardHeader,
|
||||||
DashboardSubtitle,
|
DashboardSubtitle,
|
||||||
DashboardTitle,
|
DashboardTitle,
|
||||||
StatCard,
|
StatusDot,
|
||||||
StatLabel,
|
StatusDotLink,
|
||||||
StatValue,
|
StatusDotsRow,
|
||||||
StatsGrid,
|
|
||||||
} from '../styles'
|
} from '../styles'
|
||||||
import { UnifiedLogs, type UnifiedLogLine } from './UnifiedLogs'
|
import { update } from '../update'
|
||||||
import { Vitals } from './Vitals'
|
import { UnifiedLogs, initUnifiedLogs } from './UnifiedLogs'
|
||||||
|
import { Vitals, initVitals } 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 toolApps = apps.filter(app => app.tool)
|
|
||||||
const runningApps = apps.filter(app => app.state === 'running')
|
|
||||||
|
|
||||||
// Subscribe to system metrics SSE
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const metricsSource = new EventSource('/api/system/metrics/stream')
|
initUnifiedLogs()
|
||||||
metricsSource.onmessage = e => {
|
initVitals()
|
||||||
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>
|
<StatusDotsRow>
|
||||||
<StatCard>
|
{[...apps.filter(a => !a.tool), ...apps.filter(a => a.tool)].map(app => (
|
||||||
<StatValue>{regularApps.length}</StatValue>
|
<StatusDotLink key={app.name} data-tooltip={app.name} onClick={(e: Event) => {
|
||||||
<StatLabel>Apps</StatLabel>
|
e.preventDefault()
|
||||||
</StatCard>
|
setSelectedApp(app.name)
|
||||||
<StatCard>
|
update()
|
||||||
<StatValue>{toolApps.length}</StatValue>
|
}}>
|
||||||
<StatLabel>Tools</StatLabel>
|
<StatusDot state={app.state} data-app={app.name} />
|
||||||
</StatCard>
|
</StatusDotLink>
|
||||||
<StatCard>
|
))}
|
||||||
<StatValue>{runningApps.length}</StatValue>
|
</StatusDotsRow>
|
||||||
<StatLabel>Running</StatLabel>
|
|
||||||
</StatCard>
|
|
||||||
</StatsGrid>
|
|
||||||
|
|
||||||
<Vitals cpu={metrics.cpu} ram={metrics.ram} disk={metrics.disk} />
|
<Vitals />
|
||||||
|
|
||||||
<UnifiedLogs logs={logs} onClear={handleClearLogs} />
|
<UnifiedLogs />
|
||||||
</DashboardContainer>
|
</DashboardContainer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { define } from '@because/forge'
|
import { define } from '@because/forge'
|
||||||
import type { App, LogLine as LogLineType } from '../../shared/types'
|
import type { App, LogLine as LogLineType } from '../../shared/types'
|
||||||
import { getLogDates, getLogsForDate } from '../api'
|
import { getLogDates, getLogsForDate } from '../api'
|
||||||
import { LogLine, LogsContainer, LogTime, Section, SectionTitle } from '../styles'
|
import { LogLine, LogsContainer, LogsHeader, LogTime, Section, SectionTitle } from '../styles'
|
||||||
import { theme } from '../themes'
|
import { theme } from '../themes'
|
||||||
import { update } from '../update'
|
import { update } from '../update'
|
||||||
|
|
||||||
|
|
@ -32,14 +32,6 @@ const getState = (appName: string): LogsState => {
|
||||||
return logsState.get(appName)!
|
return logsState.get(appName)!
|
||||||
}
|
}
|
||||||
|
|
||||||
const LogsHeader = define('LogsHeader', {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: 12,
|
|
||||||
marginBottom: 12,
|
|
||||||
})
|
|
||||||
|
|
||||||
const LogsControls = define('LogsControls', {
|
const LogsControls = define('LogsControls', {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
import { useEffect, useRef } from 'hono/jsx'
|
|
||||||
import {
|
import {
|
||||||
LogApp,
|
LogApp,
|
||||||
LogEntry,
|
LogEntry,
|
||||||
LogsBody,
|
LogsBody,
|
||||||
LogsClearButton,
|
|
||||||
LogsHeader,
|
LogsHeader,
|
||||||
LogsSection,
|
LogsSection,
|
||||||
LogsTitle,
|
LogsTitle,
|
||||||
LogText,
|
LogText,
|
||||||
LogTimestamp,
|
LogTimestamp,
|
||||||
} from '../styles'
|
} from '../styles'
|
||||||
|
import { update } from '../update'
|
||||||
|
|
||||||
export interface UnifiedLogLine {
|
export interface UnifiedLogLine {
|
||||||
time: number
|
time: number
|
||||||
|
|
@ -17,17 +16,25 @@ export interface UnifiedLogLine {
|
||||||
text: string
|
text: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UnifiedLogsProps {
|
const MAX_LOGS = 200
|
||||||
logs: UnifiedLogLine[]
|
|
||||||
onClear: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(timestamp: number): string {
|
let _logs: UnifiedLogLine[] = []
|
||||||
|
let _source: EventSource | undefined
|
||||||
|
|
||||||
|
const formatTime = (timestamp: number): string => {
|
||||||
const d = new Date(timestamp)
|
const d = new Date(timestamp)
|
||||||
const pad = (n: number) => String(n).padStart(2, '0')
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const 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 parseLogText(text: string): { method?: string, path?: string, status?: number, rest: string } {
|
function parseLogText(text: string): { method?: string, path?: string, status?: number, rest: string } {
|
||||||
// Match patterns like "GET /api/time 200" or "200 GET http://... (0ms)"
|
// 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+/)
|
const httpMatch = text.match(/^(\d{3})\s+(GET|POST|PUT|DELETE|PATCH)\s+\S+/)
|
||||||
|
|
@ -44,15 +51,7 @@ function parseLogText(text: string): { method?: string, path?: string, status?:
|
||||||
return { rest: text }
|
return { rest: text }
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusColor(status?: number): string | undefined {
|
function LogLineEntry({ log }: { log: UnifiedLogLine }) {
|
||||||
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 parsed = parseLogText(log.text)
|
||||||
const statusColor = getStatusColor(parsed.status)
|
const statusColor = getStatusColor(parsed.status)
|
||||||
|
|
||||||
|
|
@ -67,30 +66,49 @@ function LogLine({ log }: { log: UnifiedLogLine }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UnifiedLogs({ logs, onClear }: UnifiedLogsProps) {
|
function LogsBodyContent() {
|
||||||
const bodyRef = useRef<HTMLDivElement>(null)
|
return (
|
||||||
|
<>
|
||||||
|
{_logs.length === 0 ? (
|
||||||
|
<LogEntry>
|
||||||
|
<LogText style={{ color: 'var(--colors-textFaint)' }}>No activity yet</LogText>
|
||||||
|
</LogEntry>
|
||||||
|
) : (
|
||||||
|
_logs.map((log, i) => <LogLineEntry key={i} log={log} />)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-scroll to bottom when new logs arrive
|
function renderLogs() {
|
||||||
useEffect(() => {
|
update('#unified-logs-body', <LogsBodyContent />)
|
||||||
if (bodyRef.current) {
|
// Auto-scroll after render
|
||||||
bodyRef.current.scrollTop = bodyRef.current.scrollHeight
|
requestAnimationFrame(() => {
|
||||||
}
|
const el = document.getElementById('unified-logs-body')
|
||||||
}, [logs.length])
|
if (el) el.scrollTop = el.scrollHeight
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initUnifiedLogs() {
|
||||||
|
if (_source) return
|
||||||
|
_source = new EventSource('/api/system/logs/stream')
|
||||||
|
_source.onmessage = e => {
|
||||||
|
try {
|
||||||
|
const line = JSON.parse(e.data) as UnifiedLogLine
|
||||||
|
_logs = [..._logs.slice(-(MAX_LOGS - 1)), line]
|
||||||
|
renderLogs()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UnifiedLogs() {
|
||||||
return (
|
return (
|
||||||
<LogsSection>
|
<LogsSection>
|
||||||
<LogsHeader>
|
<LogsHeader>
|
||||||
<LogsTitle>Activity</LogsTitle>
|
<LogsTitle>Logs</LogsTitle>
|
||||||
<LogsClearButton onClick={onClear}>Clear</LogsClearButton>
|
|
||||||
</LogsHeader>
|
</LogsHeader>
|
||||||
<LogsBody ref={bodyRef}>
|
<LogsBody id="unified-logs-body">
|
||||||
{logs.length === 0 ? (
|
<LogsBodyContent />
|
||||||
<LogEntry>
|
|
||||||
<LogText style={{ color: 'var(--colors-textFaint)' }}>No activity yet</LogText>
|
|
||||||
</LogEntry>
|
|
||||||
) : (
|
|
||||||
logs.map((log, i) => <LogLine key={i} log={log} />)
|
|
||||||
)}
|
|
||||||
</LogsBody>
|
</LogsBody>
|
||||||
</LogsSection>
|
</LogsSection>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,4 @@
|
||||||
import {
|
import {
|
||||||
BarGaugeContainer,
|
|
||||||
BarGaugeFill,
|
|
||||||
BarGaugeLabel,
|
|
||||||
BarGaugeTrack,
|
|
||||||
CircleGaugeContainer,
|
|
||||||
CircleGaugeSvg,
|
|
||||||
CircleGaugeValue,
|
|
||||||
GaugeContainer,
|
GaugeContainer,
|
||||||
GaugeSvg,
|
GaugeSvg,
|
||||||
GaugeValue,
|
GaugeValue,
|
||||||
|
|
@ -13,139 +6,160 @@ import {
|
||||||
VitalLabel,
|
VitalLabel,
|
||||||
VitalsSection,
|
VitalsSection,
|
||||||
} from '../styles'
|
} from '../styles'
|
||||||
|
import { update } from '../update'
|
||||||
|
|
||||||
interface VitalsProps {
|
interface AppMetrics {
|
||||||
cpu: number
|
cpu: number
|
||||||
ram: { percent: number }
|
mem: number
|
||||||
disk: { percent: number }
|
disk: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function ArcGauge({ value }: { value: number }) {
|
interface SystemMetrics {
|
||||||
// Arc from -135 to 135 degrees (270 degree sweep)
|
cpu: number
|
||||||
const radius = 50
|
ram: { used: number, total: number, percent: number }
|
||||||
const centerX = 60
|
disk: { used: number, total: number, percent: number }
|
||||||
const centerY = 60
|
apps: Record<string, AppMetrics>
|
||||||
const startAngle = -135
|
}
|
||||||
const endAngle = 135
|
|
||||||
const sweepAngle = endAngle - startAngle
|
|
||||||
|
|
||||||
// Calculate the value angle
|
const SEGMENTS = 19
|
||||||
const valueAngle = startAngle + (value / 100) * sweepAngle
|
const START_ANGLE = -225
|
||||||
|
const END_ANGLE = 45
|
||||||
|
const SWEEP = END_ANGLE - START_ANGLE
|
||||||
|
const CX = 60
|
||||||
|
const CY = 60
|
||||||
|
const RADIUS = 44
|
||||||
|
const SEGMENT_GAP = 3
|
||||||
|
const SEGMENT_WIDTH = 8
|
||||||
|
const NEEDLE_LENGTH = 38
|
||||||
|
|
||||||
// Convert angles to radians and calculate points
|
let _metrics: SystemMetrics = {
|
||||||
const toRad = (deg: number) => (deg * Math.PI) / 180
|
cpu: 0,
|
||||||
const startX = centerX + radius * Math.cos(toRad(startAngle))
|
ram: { used: 0, total: 0, percent: 0 },
|
||||||
const startY = centerY + radius * Math.sin(toRad(startAngle))
|
disk: { used: 0, total: 0, percent: 0 },
|
||||||
const endX = centerX + radius * Math.cos(toRad(endAngle))
|
apps: {},
|
||||||
const endY = centerY + radius * Math.sin(toRad(endAngle))
|
}
|
||||||
const valueX = centerX + radius * Math.cos(toRad(valueAngle))
|
let _source: EventSource | undefined
|
||||||
const valueY = centerY + radius * Math.sin(toRad(valueAngle))
|
|
||||||
|
|
||||||
// Create arc paths
|
const formatBytes = (bytes: number): string => {
|
||||||
const trackPath = `M ${startX} ${startY} A ${radius} ${radius} 0 1 1 ${endX} ${endY}`
|
if (bytes < 1024) return `${bytes}B`
|
||||||
const valuePath = value > 0
|
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}K`
|
||||||
? `M ${startX} ${startY} A ${radius} ${radius} 0 ${value > 50 ? 1 : 0} 1 ${valueX} ${valueY}`
|
if (bytes < 1024 * 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))}M`
|
||||||
: ''
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G`
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTooltips(apps: Record<string, AppMetrics>) {
|
||||||
|
for (const [name, m] of Object.entries(apps)) {
|
||||||
|
document.querySelectorAll(`[data-app="${name}"]`).forEach(dot => {
|
||||||
|
const parent = dot.parentElement
|
||||||
|
if (parent?.hasAttribute('data-tooltip')) {
|
||||||
|
parent.setAttribute('data-tooltip',
|
||||||
|
`${name}\n${'─'.repeat(name.length)}\nCPU ${m.cpu}%\nMEM ${formatBytes(m.mem)}\nDISK ${formatBytes(m.disk)}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toRad = (deg: number) => (deg * Math.PI) / 180
|
||||||
|
|
||||||
|
const segmentColor = (i: number, total: number): string => {
|
||||||
|
const t = i / (total - 1)
|
||||||
|
if (t < 0.4) return '#4caf50'
|
||||||
|
if (t < 0.6) return '#8bc34a'
|
||||||
|
if (t < 0.75) return '#ffc107'
|
||||||
|
if (t < 0.9) return '#ff9800'
|
||||||
|
return '#f44336'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Gauge({ value }: { value: number }) {
|
||||||
|
const segmentSweep = SWEEP / SEGMENTS
|
||||||
|
const activeSegments = Math.round((value / 100) * SEGMENTS)
|
||||||
|
|
||||||
|
const segments = []
|
||||||
|
for (let i = 0; i < SEGMENTS; i++) {
|
||||||
|
const startDeg = START_ANGLE + i * segmentSweep + SEGMENT_GAP / 2
|
||||||
|
const endDeg = START_ANGLE + (i + 1) * segmentSweep - SEGMENT_GAP / 2
|
||||||
|
const innerR = RADIUS - SEGMENT_WIDTH / 2
|
||||||
|
const outerR = RADIUS + SEGMENT_WIDTH / 2
|
||||||
|
|
||||||
|
const x1 = CX + outerR * Math.cos(toRad(startDeg))
|
||||||
|
const y1 = CY + outerR * Math.sin(toRad(startDeg))
|
||||||
|
const x2 = CX + outerR * Math.cos(toRad(endDeg))
|
||||||
|
const y2 = CY + outerR * Math.sin(toRad(endDeg))
|
||||||
|
const x3 = CX + innerR * Math.cos(toRad(endDeg))
|
||||||
|
const y3 = CY + innerR * Math.sin(toRad(endDeg))
|
||||||
|
const x4 = CX + innerR * Math.cos(toRad(startDeg))
|
||||||
|
const y4 = CY + innerR * Math.sin(toRad(startDeg))
|
||||||
|
|
||||||
|
const color = i < activeSegments ? segmentColor(i, SEGMENTS) : 'var(--colors-border)'
|
||||||
|
|
||||||
|
segments.push(
|
||||||
|
<path
|
||||||
|
key={i}
|
||||||
|
d={`M ${x1} ${y1} A ${outerR} ${outerR} 0 0 1 ${x2} ${y2} L ${x3} ${y3} A ${innerR} ${innerR} 0 0 0 ${x4} ${y4} Z`}
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Needle
|
||||||
|
const needleAngle = START_ANGLE + (value / 100) * SWEEP
|
||||||
|
const nx = CX + NEEDLE_LENGTH * Math.cos(toRad(needleAngle))
|
||||||
|
const ny = CY + NEEDLE_LENGTH * Math.sin(toRad(needleAngle))
|
||||||
|
// Needle base width
|
||||||
|
const perpAngle = needleAngle + 90
|
||||||
|
const bw = 3
|
||||||
|
const bx1 = CX + bw * Math.cos(toRad(perpAngle))
|
||||||
|
const by1 = CY + bw * Math.sin(toRad(perpAngle))
|
||||||
|
const bx2 = CX - bw * Math.cos(toRad(perpAngle))
|
||||||
|
const by2 = CY - bw * Math.sin(toRad(perpAngle))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GaugeContainer>
|
<GaugeContainer>
|
||||||
<GaugeSvg viewBox="0 0 120 70">
|
<GaugeSvg viewBox="10 10 100 55">
|
||||||
{/* Track */}
|
{segments}
|
||||||
<path
|
<polygon points={`${nx},${ny} ${bx1},${by1} ${bx2},${by2}`} fill="var(--colors-text)" />
|
||||||
d={trackPath}
|
<circle cx={CX} cy={CY} r="4" fill="var(--colors-textMuted)" />
|
||||||
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>
|
</GaugeSvg>
|
||||||
<GaugeValue>{value}%</GaugeValue>
|
<GaugeValue>{value}%</GaugeValue>
|
||||||
</GaugeContainer>
|
</GaugeContainer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function BarGauge({ value }: { value: number }) {
|
function VitalsContent() {
|
||||||
return (
|
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>
|
<VitalCard>
|
||||||
<VitalLabel>CPU</VitalLabel>
|
<VitalLabel>CPU</VitalLabel>
|
||||||
<ArcGauge value={cpu} />
|
<Gauge value={_metrics.cpu} />
|
||||||
</VitalCard>
|
</VitalCard>
|
||||||
<VitalCard>
|
<VitalCard>
|
||||||
<VitalLabel>RAM</VitalLabel>
|
<VitalLabel>RAM</VitalLabel>
|
||||||
<BarGauge value={ram.percent} />
|
<Gauge value={_metrics.ram.percent} />
|
||||||
</VitalCard>
|
</VitalCard>
|
||||||
<VitalCard>
|
<VitalCard>
|
||||||
<VitalLabel>Disk</VitalLabel>
|
<VitalLabel>Disk</VitalLabel>
|
||||||
<CircleGauge value={disk.percent} />
|
<Gauge value={_metrics.disk.percent} />
|
||||||
</VitalCard>
|
</VitalCard>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initVitals() {
|
||||||
|
if (_source) return
|
||||||
|
_source = new EventSource('/api/system/metrics/stream')
|
||||||
|
_source.onmessage = e => {
|
||||||
|
try {
|
||||||
|
_metrics = JSON.parse(e.data)
|
||||||
|
update('#vitals', <VitalsContent />)
|
||||||
|
updateTooltips(_metrics.apps)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Vitals() {
|
||||||
|
return (
|
||||||
|
<VitalsSection id="vitals">
|
||||||
|
<VitalsContent />
|
||||||
</VitalsSection>
|
</VitalsSection>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,10 +44,26 @@ narrowQuery.addEventListener('change', e => {
|
||||||
// SSE connection
|
// SSE connection
|
||||||
const events = new EventSource('/api/apps/stream')
|
const events = new EventSource('/api/apps/stream')
|
||||||
events.onmessage = e => {
|
events.onmessage = e => {
|
||||||
|
const prev = apps
|
||||||
setApps(JSON.parse(e.data))
|
setApps(JSON.parse(e.data))
|
||||||
// If selected app no longer exists, clear selection to show dashboard
|
|
||||||
if (selectedApp && !apps.some(a => a.name === selectedApp)) {
|
// Full re-render if app list changed structurally or selected app disappeared
|
||||||
setSelectedApp(null)
|
const added = apps.some(a => !prev.find(p => p.name === a.name))
|
||||||
|
const removed = prev.some(p => !apps.find(a => a.name === p.name))
|
||||||
|
if (added || removed || (selectedApp && !apps.some(a => a.name === selectedApp))) {
|
||||||
|
if (selectedApp && !apps.some(a => a.name === selectedApp)) {
|
||||||
|
setSelectedApp(null)
|
||||||
|
}
|
||||||
|
render()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Targeted DOM updates for state-only changes
|
||||||
|
const states = ['running', 'stopped', 'starting', 'stopping', 'invalid']
|
||||||
|
for (const app of apps) {
|
||||||
|
document.querySelectorAll(`[data-app="${app.name}"]`).forEach(dot => {
|
||||||
|
for (const s of states) dot.classList.remove(`state-${s}`)
|
||||||
|
dot.classList.add(`state-${app.state}`)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
render()
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,84 +29,26 @@ export const VitalLabel = define('VitalLabel', {
|
||||||
letterSpacing: '0.05em',
|
letterSpacing: '0.05em',
|
||||||
})
|
})
|
||||||
|
|
||||||
// Arc Gauge (for CPU)
|
|
||||||
export const GaugeContainer = define('GaugeContainer', {
|
export const GaugeContainer = define('GaugeContainer', {
|
||||||
position: 'relative',
|
display: 'flex',
|
||||||
width: 120,
|
flexDirection: 'column',
|
||||||
height: 70,
|
alignItems: 'center',
|
||||||
})
|
})
|
||||||
|
|
||||||
export const GaugeSvg = define('GaugeSvg', {
|
export const GaugeSvg = define('GaugeSvg', {
|
||||||
base: 'svg',
|
base: 'svg',
|
||||||
width: '100%',
|
width: 120,
|
||||||
height: '100%',
|
height: 70,
|
||||||
overflow: 'visible',
|
overflow: 'visible',
|
||||||
})
|
})
|
||||||
|
|
||||||
export const GaugeValue = define('GaugeValue', {
|
export const GaugeValue = define('GaugeValue', {
|
||||||
position: 'absolute',
|
fontSize: 20,
|
||||||
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',
|
fontWeight: 'bold',
|
||||||
fontFamily: theme('fonts-mono'),
|
fontFamily: theme('fonts-mono'),
|
||||||
color: theme('colors-text'),
|
color: theme('colors-text'),
|
||||||
|
marginTop: 20,
|
||||||
|
marginLeft: 5,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Unified Logs Section
|
// Unified Logs Section
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,5 @@
|
||||||
export { ActionBar, Button, NewAppButton } from './buttons'
|
export { ActionBar, Button, NewAppButton } from './buttons'
|
||||||
export {
|
export {
|
||||||
BarGaugeContainer,
|
|
||||||
BarGaugeFill,
|
|
||||||
BarGaugeLabel,
|
|
||||||
BarGaugeTrack,
|
|
||||||
CircleGaugeContainer,
|
|
||||||
CircleGaugeSvg,
|
|
||||||
CircleGaugeValue,
|
|
||||||
GaugeContainer,
|
GaugeContainer,
|
||||||
GaugeSvg,
|
GaugeSvg,
|
||||||
GaugeValue,
|
GaugeValue,
|
||||||
|
|
@ -65,6 +58,8 @@ export {
|
||||||
SectionTitle,
|
SectionTitle,
|
||||||
stateLabels,
|
stateLabels,
|
||||||
StatusDot,
|
StatusDot,
|
||||||
|
StatusDotLink,
|
||||||
|
StatusDotsRow,
|
||||||
Tab,
|
Tab,
|
||||||
TabBar,
|
TabBar,
|
||||||
TabContent,
|
TabContent,
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,7 @@ export const DashboardContainer = define('DashboardContainer', {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
padding: 40,
|
padding: 40,
|
||||||
|
paddingTop: 0,
|
||||||
gap: 40,
|
gap: 40,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -214,7 +215,6 @@ export const DashboardTitle = define('DashboardTitle', {
|
||||||
fontSize: 48,
|
fontSize: 48,
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
margin: 0,
|
margin: 0,
|
||||||
marginBottom: 8,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const DashboardSubtitle = define('DashboardSubtitle', {
|
export const DashboardSubtitle = define('DashboardSubtitle', {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,44 @@ import { define } from '@because/forge'
|
||||||
import { theme } from '../themes'
|
import { theme } from '../themes'
|
||||||
import type { AppState } from '../../shared/types'
|
import type { AppState } from '../../shared/types'
|
||||||
|
|
||||||
|
export const StatusDotLink = define('StatusDotLink', {
|
||||||
|
base: 'a',
|
||||||
|
position: 'relative',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textDecoration: 'none',
|
||||||
|
selectors: {
|
||||||
|
'&::after': {
|
||||||
|
content: 'attr(data-tooltip)',
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '100%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
marginBottom: 6,
|
||||||
|
padding: '2px 6px',
|
||||||
|
fontSize: 10,
|
||||||
|
fontFamily: theme('fonts-mono'),
|
||||||
|
color: theme('colors-text'),
|
||||||
|
background: theme('colors-bgElement'),
|
||||||
|
border: `1px solid ${theme('colors-border')}`,
|
||||||
|
borderRadius: 4,
|
||||||
|
whiteSpace: 'pre',
|
||||||
|
opacity: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
transition: 'opacity 0.15s',
|
||||||
|
},
|
||||||
|
'&:hover::after': {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const StatusDotsRow = define('StatusDotsRow', {
|
||||||
|
display: 'flex',
|
||||||
|
gap: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
})
|
||||||
|
|
||||||
export const StatusDot = define('StatusDot', {
|
export const StatusDot = define('StatusDot', {
|
||||||
width: 8,
|
width: 8,
|
||||||
height: 8,
|
height: 8,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,20 @@
|
||||||
import { allApps, onChange } from '$apps'
|
import { allApps, APPS_DIR, onChange } from '$apps'
|
||||||
import { Hype } from '@because/hype'
|
import { Hype } from '@because/hype'
|
||||||
import { cpus, freemem, totalmem } from 'os'
|
import { cpus, platform, totalmem } from 'os'
|
||||||
import { statfsSync } from 'fs'
|
import { join } from 'path'
|
||||||
|
import { readFileSync, statfsSync } from 'fs'
|
||||||
|
|
||||||
|
export interface AppMetrics {
|
||||||
|
cpu: number
|
||||||
|
mem: number
|
||||||
|
disk: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface SystemMetrics {
|
export interface SystemMetrics {
|
||||||
cpu: number // 0-100
|
cpu: number // 0-100
|
||||||
ram: { used: number, total: number, percent: number }
|
ram: { used: number, total: number, percent: number }
|
||||||
disk: { used: number, total: number, percent: number }
|
disk: { used: number, total: number, percent: number }
|
||||||
|
apps: Record<string, AppMetrics>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UnifiedLogLine {
|
export interface UnifiedLogLine {
|
||||||
|
|
@ -54,12 +62,36 @@ function getCpuUsage(): number {
|
||||||
|
|
||||||
function getMemoryUsage(): { used: number, total: number, percent: number } {
|
function getMemoryUsage(): { used: number, total: number, percent: number } {
|
||||||
const total = totalmem()
|
const total = totalmem()
|
||||||
const free = freemem()
|
const apps = allApps().filter(a => a.proc?.pid)
|
||||||
const used = total - free
|
let used = 0
|
||||||
|
|
||||||
|
if (platform() === 'linux') {
|
||||||
|
for (const app of apps) {
|
||||||
|
try {
|
||||||
|
const status = readFileSync(`/proc/${app.proc!.pid}/status`, 'utf-8')
|
||||||
|
const match = status.match(/VmRSS:\s+(\d+)/)
|
||||||
|
if (match) used += parseInt(match[1]!, 10) * 1024
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// macOS: batch ps call for all pids
|
||||||
|
const pids = apps.map(a => a.proc!.pid).join(',')
|
||||||
|
if (pids) {
|
||||||
|
try {
|
||||||
|
const result = Bun.spawnSync(['ps', '-o', 'rss=', '-p', pids])
|
||||||
|
const output = result.stdout.toString()
|
||||||
|
for (const line of output.split('\n')) {
|
||||||
|
const kb = parseInt(line.trim(), 10)
|
||||||
|
if (kb) used += kb * 1024
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
used,
|
used,
|
||||||
total,
|
total,
|
||||||
percent: Math.round((used / total) * 100),
|
percent: used > 0 ? Math.max(1, Math.round((used / total) * 100)) : 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -79,12 +111,68 @@ function getDiskUsage(): { used: number, total: number, percent: number } {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-app disk cache (updated every 30s)
|
||||||
|
let _appDiskCache: Record<string, number> = {}
|
||||||
|
let _appDiskLastUpdate = 0
|
||||||
|
const DISK_CACHE_TTL = 30000
|
||||||
|
|
||||||
|
function getAppMetrics(): Record<string, AppMetrics> {
|
||||||
|
const apps = allApps()
|
||||||
|
const running = apps.filter(a => a.proc?.pid)
|
||||||
|
const result: Record<string, AppMetrics> = {}
|
||||||
|
|
||||||
|
// CPU + MEM via ps (works on both macOS and Linux)
|
||||||
|
const pidToName = new Map<number, string>()
|
||||||
|
for (const app of running) {
|
||||||
|
pidToName.set(app.proc!.pid, app.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pidToName.size > 0) {
|
||||||
|
try {
|
||||||
|
const pids = [...pidToName.keys()].join(',')
|
||||||
|
const ps = Bun.spawnSync(['ps', '-o', 'pid=,%cpu=,rss=', '-p', pids])
|
||||||
|
for (const line of ps.stdout.toString().split('\n')) {
|
||||||
|
const parts = line.trim().split(/\s+/)
|
||||||
|
if (parts.length < 3) continue
|
||||||
|
const pid = parseInt(parts[0]!, 10)
|
||||||
|
const cpu = parseFloat(parts[1]!) || 0
|
||||||
|
const mem = (parseInt(parts[2]!, 10) || 0) * 1024
|
||||||
|
const name = pidToName.get(pid)
|
||||||
|
if (name) result[name] = { cpu: Math.round(cpu), mem, disk: 0 }
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disk usage per app (cached)
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - _appDiskLastUpdate > DISK_CACHE_TTL) {
|
||||||
|
_appDiskLastUpdate = now
|
||||||
|
_appDiskCache = {}
|
||||||
|
for (const app of apps) {
|
||||||
|
try {
|
||||||
|
const du = Bun.spawnSync(['du', '-sk', join(APPS_DIR, app.name)])
|
||||||
|
const kb = parseInt(du.stdout.toString().trim().split('\t')[0]!, 10)
|
||||||
|
if (kb) _appDiskCache[app.name] = kb * 1024
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge disk into results, fill in stopped apps
|
||||||
|
for (const app of apps) {
|
||||||
|
if (!result[app.name]) result[app.name] = { cpu: 0, mem: 0, disk: 0 }
|
||||||
|
result[app.name]!.disk = _appDiskCache[app.name] ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// Get current system metrics
|
// Get current system metrics
|
||||||
router.get('/metrics', c => {
|
router.get('/metrics', c => {
|
||||||
const metrics: SystemMetrics = {
|
const metrics: SystemMetrics = {
|
||||||
cpu: getCpuUsage(),
|
cpu: getCpuUsage(),
|
||||||
ram: getMemoryUsage(),
|
ram: getMemoryUsage(),
|
||||||
disk: getDiskUsage(),
|
disk: getDiskUsage(),
|
||||||
|
apps: getAppMetrics(),
|
||||||
}
|
}
|
||||||
return c.json(metrics)
|
return c.json(metrics)
|
||||||
})
|
})
|
||||||
|
|
@ -96,6 +184,7 @@ router.sse('/metrics/stream', (send) => {
|
||||||
cpu: getCpuUsage(),
|
cpu: getCpuUsage(),
|
||||||
ram: getMemoryUsage(),
|
ram: getMemoryUsage(),
|
||||||
disk: getDiskUsage(),
|
disk: getDiskUsage(),
|
||||||
|
apps: getAppMetrics(),
|
||||||
}
|
}
|
||||||
send(metrics)
|
send(metrics)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import syncRouter from './api/sync'
|
||||||
import systemRouter from './api/system'
|
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: !!process.env.DEBUG })
|
||||||
|
|
||||||
app.route('/api/apps', appsRouter)
|
app.route('/api/apps', appsRouter)
|
||||||
app.route('/api/sync', syncRouter)
|
app.route('/api/sync', syncRouter)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { TOES_URL } from '$apps'
|
||||||
const RENDER_DEBOUNCE = 50
|
const RENDER_DEBOUNCE = 50
|
||||||
|
|
||||||
let _apps: App[] = []
|
let _apps: App[] = []
|
||||||
let _enabled = process.stdout.isTTY ?? false
|
let _enabled = (process.stdout.isTTY ?? false) && !process.env.DEBUG
|
||||||
let _lastRender = 0
|
let _lastRender = 0
|
||||||
let _renderTimer: Timer | undefined
|
let _renderTimer: Timer | undefined
|
||||||
let _showEmoji = false
|
let _showEmoji = false
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user