Compare commits
5 Commits
543b5d08bc
...
c10ebe3c98
| Author | SHA1 | Date | |
|---|---|---|---|
| c10ebe3c98 | |||
| 2f4d4f5c19 | |||
| 720c0e76fb | |||
|
|
8b31fa3f19 | ||
|
|
50e5c97beb |
|
|
@ -23,7 +23,8 @@
|
|||
"cli:link": "ln -sf $(pwd)/src/cli/index.ts ~/.bun/bin/toes",
|
||||
"cli:uninstall": "sudo rm /usr/local/bin",
|
||||
"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:install": "./scripts/remote-install.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>
|
||||
{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>
|
||||
|
|
|
|||
|
|
@ -1,40 +1,46 @@
|
|||
import { apps } from '../state'
|
||||
import { useEffect } from 'hono/jsx'
|
||||
import { apps, setSelectedApp } from '../state'
|
||||
import {
|
||||
DashboardContainer,
|
||||
DashboardHeader,
|
||||
DashboardSubtitle,
|
||||
DashboardTitle,
|
||||
StatCard,
|
||||
StatLabel,
|
||||
StatValue,
|
||||
StatsGrid,
|
||||
StatusDot,
|
||||
StatusDotLink,
|
||||
StatusDotsRow,
|
||||
} from '../styles'
|
||||
import { update } from '../update'
|
||||
import { UnifiedLogs, initUnifiedLogs } from './UnifiedLogs'
|
||||
import { Vitals, initVitals } from './Vitals'
|
||||
|
||||
export function DashboardLanding() {
|
||||
const regularApps = apps.filter(app => !app.tool)
|
||||
const toolApps = apps.filter(app => app.tool)
|
||||
const runningApps = apps.filter(app => app.state === 'running')
|
||||
useEffect(() => {
|
||||
initUnifiedLogs()
|
||||
initVitals()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<DashboardContainer>
|
||||
<DashboardHeader>
|
||||
<DashboardTitle>🐾 Toes</DashboardTitle>
|
||||
<DashboardSubtitle>Your personal web appliance</DashboardSubtitle>
|
||||
{/*<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>
|
||||
|
||||
<StatusDotsRow>
|
||||
{[...apps.filter(a => !a.tool), ...apps.filter(a => a.tool)].map(app => (
|
||||
<StatusDotLink key={app.name} data-tooltip={app.name} onClick={(e: Event) => {
|
||||
e.preventDefault()
|
||||
setSelectedApp(app.name)
|
||||
update()
|
||||
}}>
|
||||
<StatusDot state={app.state} data-app={app.name} />
|
||||
</StatusDotLink>
|
||||
))}
|
||||
</StatusDotsRow>
|
||||
|
||||
<Vitals />
|
||||
|
||||
<UnifiedLogs />
|
||||
</DashboardContainer>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { define } from '@because/forge'
|
||||
import type { App, LogLine as LogLineType } from '../../shared/types'
|
||||
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 { update } from '../update'
|
||||
|
||||
|
|
@ -32,14 +32,6 @@ const getState = (appName: string): LogsState => {
|
|||
return logsState.get(appName)!
|
||||
}
|
||||
|
||||
const LogsHeader = define('LogsHeader', {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
marginBottom: 12,
|
||||
})
|
||||
|
||||
const LogsControls = define('LogsControls', {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
|
|
|||
115
src/client/components/UnifiedLogs.tsx
Normal file
115
src/client/components/UnifiedLogs.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import {
|
||||
LogApp,
|
||||
LogEntry,
|
||||
LogsBody,
|
||||
LogsHeader,
|
||||
LogsSection,
|
||||
LogsTitle,
|
||||
LogText,
|
||||
LogTimestamp,
|
||||
} from '../styles'
|
||||
import { update } from '../update'
|
||||
|
||||
export interface UnifiedLogLine {
|
||||
time: number
|
||||
app: string
|
||||
text: string
|
||||
}
|
||||
|
||||
const MAX_LOGS = 200
|
||||
|
||||
let _logs: UnifiedLogLine[] = []
|
||||
let _source: EventSource | undefined
|
||||
|
||||
const 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())}`
|
||||
}
|
||||
|
||||
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 } {
|
||||
// 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 LogLineEntry({ 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>
|
||||
)
|
||||
}
|
||||
|
||||
function LogsBodyContent() {
|
||||
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} />)
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function renderLogs() {
|
||||
update('#unified-logs-body', <LogsBodyContent />)
|
||||
// Auto-scroll after render
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.getElementById('unified-logs-body')
|
||||
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 (
|
||||
<LogsSection>
|
||||
<LogsHeader>
|
||||
<LogsTitle>Logs</LogsTitle>
|
||||
</LogsHeader>
|
||||
<LogsBody id="unified-logs-body">
|
||||
<LogsBodyContent />
|
||||
</LogsBody>
|
||||
</LogsSection>
|
||||
)
|
||||
}
|
||||
165
src/client/components/Vitals.tsx
Normal file
165
src/client/components/Vitals.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import {
|
||||
GaugeContainer,
|
||||
GaugeSvg,
|
||||
GaugeValue,
|
||||
VitalCard,
|
||||
VitalLabel,
|
||||
VitalsSection,
|
||||
} from '../styles'
|
||||
import { update } from '../update'
|
||||
|
||||
interface AppMetrics {
|
||||
cpu: number
|
||||
mem: number
|
||||
disk: number
|
||||
}
|
||||
|
||||
interface SystemMetrics {
|
||||
cpu: number
|
||||
ram: { used: number, total: number, percent: number }
|
||||
disk: { used: number, total: number, percent: number }
|
||||
apps: Record<string, AppMetrics>
|
||||
}
|
||||
|
||||
const SEGMENTS = 19
|
||||
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
|
||||
|
||||
let _metrics: SystemMetrics = {
|
||||
cpu: 0,
|
||||
ram: { used: 0, total: 0, percent: 0 },
|
||||
disk: { used: 0, total: 0, percent: 0 },
|
||||
apps: {},
|
||||
}
|
||||
let _source: EventSource | undefined
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes}B`
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}K`
|
||||
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 (
|
||||
<GaugeContainer>
|
||||
<GaugeSvg viewBox="10 10 100 55">
|
||||
{segments}
|
||||
<polygon points={`${nx},${ny} ${bx1},${by1} ${bx2},${by2}`} fill="var(--colors-text)" />
|
||||
<circle cx={CX} cy={CY} r="4" fill="var(--colors-textMuted)" />
|
||||
</GaugeSvg>
|
||||
<GaugeValue>{value}%</GaugeValue>
|
||||
</GaugeContainer>
|
||||
)
|
||||
}
|
||||
|
||||
function VitalsContent() {
|
||||
return (
|
||||
<>
|
||||
<VitalCard>
|
||||
<VitalLabel>CPU</VitalLabel>
|
||||
<Gauge value={_metrics.cpu} />
|
||||
</VitalCard>
|
||||
<VitalCard>
|
||||
<VitalLabel>RAM</VitalLabel>
|
||||
<Gauge value={_metrics.ram.percent} />
|
||||
</VitalCard>
|
||||
<VitalCard>
|
||||
<VitalLabel>Disk</VitalLabel>
|
||||
<Gauge value={_metrics.disk.percent} />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -496,6 +496,21 @@ const EMOJI_KEYWORDS: Record<string, string[]> = {
|
|||
'🎮': ['video game', 'controller', 'gaming', 'play'],
|
||||
'🎰': ['slot machine', 'casino', 'gambling', 'jackpot'],
|
||||
'🧩': ['puzzle', 'piece', 'jigsaw', 'game'],
|
||||
'🎈': ['balloon', 'party', 'birthday'],
|
||||
'🎉': ['party', 'popper', 'celebrate', 'tada', 'congratulations'],
|
||||
'🎊': ['confetti', 'ball', 'celebrate'],
|
||||
'🎃': ['jack-o-lantern', 'pumpkin', 'halloween'],
|
||||
'🎄': ['christmas', 'tree', 'holiday', 'xmas'],
|
||||
'🎋': ['tanabata', 'tree', 'japanese', 'wish'],
|
||||
'🎍': ['pine', 'decoration', 'japanese', 'new year'],
|
||||
'🎎': ['japanese dolls', 'hinamatsuri'],
|
||||
'🎏': ['carp streamer', 'japanese', 'koinobori'],
|
||||
'🎐': ['wind chime', 'japanese'],
|
||||
'🎑': ['moon viewing', 'tsukimi', 'japanese'],
|
||||
'🧧': ['red envelope', 'lucky', 'chinese'],
|
||||
'🏮': ['lantern', 'red', 'japanese', 'festival'],
|
||||
'🎁': ['gift', 'present', 'birthday', 'christmas', 'wrapped'],
|
||||
'🎀': ['ribbon', 'bow', 'gift', 'decoration'],
|
||||
|
||||
// Travel
|
||||
'🚗': ['car', 'vehicle', 'drive', 'automobile'],
|
||||
|
|
@ -713,6 +728,126 @@ const EMOJI_KEYWORDS: Record<string, string[]> = {
|
|||
'🦠': ['microbe', 'germ', 'bacteria', 'virus'],
|
||||
'🧫': ['petri dish', 'bacteria', 'science'],
|
||||
'🧪': ['test tube', 'science', 'chemistry', 'experiment'],
|
||||
'📝': ['memo', 'note', 'pencil', 'write'],
|
||||
'✏️': ['pencil', 'write', 'edit'],
|
||||
'🖊️': ['pen', 'write', 'ballpoint'],
|
||||
'🖋️': ['fountain pen', 'write', 'ink'],
|
||||
'🖍️': ['crayon', 'draw', 'color'],
|
||||
'📖': ['book', 'open', 'read'],
|
||||
'📗': ['book', 'green', 'read'],
|
||||
'📘': ['book', 'blue', 'read'],
|
||||
'📙': ['book', 'orange', 'read'],
|
||||
'📕': ['book', 'red', 'closed', 'read'],
|
||||
'📓': ['notebook', 'write'],
|
||||
'📔': ['notebook', 'decorative'],
|
||||
'📒': ['ledger', 'notebook', 'yellow'],
|
||||
'📚': ['books', 'stack', 'read', 'library'],
|
||||
'📰': ['newspaper', 'news', 'paper', 'press'],
|
||||
'🗞️': ['newspaper', 'rolled', 'news'],
|
||||
'📜': ['scroll', 'document', 'ancient', 'paper'],
|
||||
'📃': ['page', 'curl', 'document'],
|
||||
'📄': ['page', 'document', 'paper'],
|
||||
'📑': ['bookmark', 'tabs', 'document'],
|
||||
'📊': ['bar chart', 'graph', 'statistics', 'data'],
|
||||
'📈': ['chart', 'increasing', 'trending up', 'stock'],
|
||||
'📉': ['chart', 'decreasing', 'trending down', 'stock'],
|
||||
'📆': ['calendar', 'date', 'schedule'],
|
||||
'📅': ['calendar', 'date'],
|
||||
'🗓️': ['spiral calendar', 'date', 'schedule'],
|
||||
'📇': ['card index', 'rolodex'],
|
||||
'🗃️': ['card file box', 'organize'],
|
||||
'🗳️': ['ballot box', 'vote', 'election'],
|
||||
'🗄️': ['file cabinet', 'storage', 'office'],
|
||||
'📋': ['clipboard', 'paste', 'list'],
|
||||
'📌': ['pushpin', 'pin', 'location'],
|
||||
'📍': ['round pushpin', 'pin', 'location'],
|
||||
'📎': ['paperclip', 'clip', 'attach'],
|
||||
'🖇️': ['linked paperclips', 'attach'],
|
||||
'📏': ['straight ruler', 'measure'],
|
||||
'📐': ['triangular ruler', 'measure', 'geometry'],
|
||||
'✂️': ['scissors', 'cut', 'snip'],
|
||||
'🗂️': ['card index dividers', 'organize', 'tabs'],
|
||||
'🗑️': ['wastebasket', 'trash', 'delete', 'bin', 'garbage'],
|
||||
'✉️': ['envelope', 'mail', 'letter', 'email'],
|
||||
'📧': ['email', 'mail', 'letter', 'at'],
|
||||
'📨': ['incoming envelope', 'mail', 'receive'],
|
||||
'📩': ['envelope', 'arrow', 'mail', 'send'],
|
||||
'📤': ['outbox', 'sent', 'mail'],
|
||||
'📥': ['inbox', 'receive', 'mail'],
|
||||
'📦': ['package', 'box', 'delivery', 'shipping'],
|
||||
'📫': ['mailbox', 'mail', 'closed', 'flag'],
|
||||
'📪': ['mailbox', 'empty', 'closed'],
|
||||
'📬': ['mailbox', 'mail', 'open', 'flag'],
|
||||
'📭': ['mailbox', 'open', 'empty'],
|
||||
'📮': ['postbox', 'mail', 'red'],
|
||||
'🔒': ['lock', 'locked', 'secure', 'closed'],
|
||||
'🔓': ['unlock', 'open', 'unlocked'],
|
||||
'🔏': ['lock', 'pen', 'privacy'],
|
||||
'🔐': ['lock', 'key', 'secure'],
|
||||
'🔑': ['key', 'lock', 'password', 'access'],
|
||||
'🗝️': ['old key', 'vintage', 'antique'],
|
||||
'🪞': ['mirror', 'reflection'],
|
||||
'🪟': ['window', 'glass', 'frame'],
|
||||
'🛋️': ['couch', 'sofa', 'furniture'],
|
||||
'🪑': ['chair', 'seat', 'furniture'],
|
||||
'🚪': ['door', 'entrance', 'exit'],
|
||||
'🛏️': ['bed', 'sleep', 'bedroom'],
|
||||
'🧸': ['teddy bear', 'toy', 'stuffed'],
|
||||
'🪆': ['nesting dolls', 'matryoshka', 'russian'],
|
||||
'🖼️': ['frame', 'picture', 'painting', 'art'],
|
||||
'🛒': ['shopping cart', 'grocery', 'buy'],
|
||||
'🛍️': ['shopping bags', 'buy', 'retail'],
|
||||
'🏷️': ['label', 'tag', 'price', 'sale'],
|
||||
'🧴': ['lotion', 'bottle', 'sunscreen'],
|
||||
'🧷': ['safety pin', 'diaper'],
|
||||
'🧹': ['broom', 'sweep', 'clean'],
|
||||
'🧺': ['basket', 'laundry'],
|
||||
'🧻': ['toilet paper', 'roll', 'bathroom'],
|
||||
'🪣': ['bucket', 'pail', 'water'],
|
||||
'🧼': ['soap', 'wash', 'clean'],
|
||||
'🧽': ['sponge', 'clean', 'wash'],
|
||||
'🪥': ['toothbrush', 'dental', 'brush'],
|
||||
'🪒': ['razor', 'shave'],
|
||||
'🪄': ['magic wand', 'wizard', 'spell'],
|
||||
'👑': ['crown', 'king', 'queen', 'royal'],
|
||||
'👒': ['hat', 'woman', 'sun'],
|
||||
'🎩': ['top hat', 'formal', 'gentleman', 'magic'],
|
||||
'🎓': ['graduation', 'cap', 'school', 'diploma'],
|
||||
'🧢': ['cap', 'hat', 'baseball'],
|
||||
'⛑️': ['rescue helmet', 'safety', 'hard hat'],
|
||||
'👓': ['glasses', 'eyeglasses', 'spectacles'],
|
||||
'🕶️': ['sunglasses', 'cool', 'dark'],
|
||||
'🥽': ['goggles', 'swim', 'lab', 'safety'],
|
||||
'💼': ['briefcase', 'business', 'work'],
|
||||
'👜': ['handbag', 'purse', 'bag'],
|
||||
'👛': ['purse', 'wallet', 'money'],
|
||||
'👝': ['clutch bag', 'pouch'],
|
||||
'🎒': ['backpack', 'school', 'bag'],
|
||||
'🧳': ['luggage', 'suitcase', 'travel'],
|
||||
'👞': ['shoe', 'man', 'dress'],
|
||||
'👟': ['sneaker', 'running', 'shoe', 'athletic'],
|
||||
'🥾': ['hiking boot', 'shoe', 'outdoor'],
|
||||
'🥿': ['flat shoe', 'ballet'],
|
||||
'👠': ['high heel', 'shoe', 'woman'],
|
||||
'👡': ['sandal', 'shoe', 'woman'],
|
||||
'🩰': ['ballet shoes', 'dance'],
|
||||
'👢': ['boot', 'woman', 'shoe'],
|
||||
'👗': ['dress', 'woman', 'fashion'],
|
||||
'👔': ['necktie', 'tie', 'formal', 'business'],
|
||||
'👕': ['t-shirt', 'shirt', 'clothes'],
|
||||
'👖': ['jeans', 'pants', 'denim'],
|
||||
'🧣': ['scarf', 'winter', 'warm'],
|
||||
'🧤': ['gloves', 'winter', 'hands'],
|
||||
'🧥': ['coat', 'jacket', 'winter'],
|
||||
'🧦': ['socks', 'feet', 'warm'],
|
||||
'👙': ['bikini', 'swim', 'beach'],
|
||||
'👘': ['kimono', 'japanese', 'clothes'],
|
||||
'🥻': ['sari', 'indian', 'clothes'],
|
||||
'🩱': ['swimsuit', 'one piece'],
|
||||
'🩲': ['swim briefs', 'underwear'],
|
||||
'🩳': ['shorts', 'pants'],
|
||||
'💄': ['lipstick', 'makeup', 'cosmetic'],
|
||||
'💍': ['ring', 'diamond', 'wedding', 'engagement'],
|
||||
|
||||
// Symbols
|
||||
'❤️': ['heart', 'love', 'red'],
|
||||
|
|
@ -1005,6 +1140,12 @@ const EMOJI_KEYWORDS: Record<string, string[]> = {
|
|||
'🕥': ['clock', '10:30', 'time'],
|
||||
'🕦': ['clock', '11:30', 'time'],
|
||||
'🕧': ['clock', '12:30', 'time'],
|
||||
'🏁': ['checkered flag', 'race', 'finish'],
|
||||
'🚩': ['red flag', 'triangular', 'warning'],
|
||||
'🎌': ['crossed flags', 'japanese', 'celebration'],
|
||||
'🏳️': ['white flag', 'surrender', 'peace'],
|
||||
'🏴': ['black flag', 'pirate'],
|
||||
'🏳️🌈': ['rainbow flag', 'pride', 'lgbtq'],
|
||||
|
||||
// Nature (excluding duplicates that exist in other categories)
|
||||
'🌸': ['cherry blossom', 'flower', 'spring', 'sakura'],
|
||||
|
|
@ -1083,10 +1224,10 @@ const EMOJI_CATEGORIES: Record<Category, string[]> = {
|
|||
'Gestures': ['👋', '🤚', '🖐️', '✋', '🖖', '👌', '🤌', '🤏', '✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉', '👆', '🖕', '👇', '☝️', '👍', '👎', '✊', '👊', '🤛', '🤜', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '✍️', '💅', '🤳', '💪', '🦾', '🦿', '🦵', '🦶', '👂', '🦻', '👃', '🧠', '🫀', '🫁', '🦷', '🦴', '👀', '👁️', '👅', '👄'],
|
||||
'Animals': ['🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼', '🐻❄️', '🐨', '🐯', '🦁', '🐮', '🐷', '🐸', '🐵', '🙈', '🙉', '🙊', '🐒', '🐔', '🐧', '🐦', '🐤', '🐣', '🐥', '🦆', '🦅', '🦉', '🦇', '🐺', '🐗', '🐴', '🦄', '🐝', '🪱', '🐛', '🦋', '🐌', '🐞', '🐜', '🪰', '🪲', '🪳', '🦟', '🦗', '🕷️', '🦂', '🐢', '🐍', '🦎', '🦖', '🦕', '🐙', '🦑', '🦐', '🦞', '🦀', '🐡', '🐠', '🐟', '🐬', '🐳', '🐋', '🦈', '🐊', '🐅', '🐆', '🦓', '🦍', '🦧', '🦣', '🐘', '🦛', '🦏', '🐪', '🐫', '🦒', '🦘', '🦬', '🐃', '🐂', '🐄', '🐎', '🐖', '🐏', '🐑', '🦙', '🐐', '🦌', '🐕', '🐩', '🦮', '🐕🦺', '🐈', '🐈⬛', '🪶', '🐓', '🦃', '🦤', '🦚', '🦜', '🦢', '🦩', '🕊️', '🐇', '🦝', '🦨', '🦡', '🦫', '🦦', '🦥', '🐁', '🐀', '🐿️', '🦔'],
|
||||
'Food': ['🍎', '🍐', '🍊', '🍋', '🍌', '🍉', '🍇', '🍓', '🫐', '🍈', '🍒', '🍑', '🥭', '🍍', '🥥', '🥝', '🍅', '🍆', '🥑', '🥦', '🥬', '🥒', '🌶️', '🫑', '🌽', '🥕', '🫒', '🧄', '🧅', '🥔', '🍠', '🥐', '🥯', '🍞', '🥖', '🥨', '🧀', '🥚', '🍳', '🧈', '🥞', '🧇', '🥓', '🥩', '🍗', '🍖', '🦴', '🌭', '🍔', '🍟', '🍕', '🫓', '🥪', '🥙', '🧆', '🌮', '🌯', '🫔', '🥗', '🥘', '🫕', '🥫', '🍝', '🍜', '🍲', '🍛', '🍣', '🍱', '🥟', '🦪', '🍤', '🍙', '🍚', '🍘', '🍥', '🥠', '🥮', '🍢', '🍡', '🍧', '🍨', '🍦', '🥧', '🧁', '🍰', '🎂', '🍮', '🍭', '🍬', '🍫', '🍿', '🍩', '🍪', '🌰', '🥜', '🍯', '🥛', '🍼', '🫖', '☕', '🍵', '🧃', '🥤', '🧋', '🍶', '🍺', '🍻', '🥂', '🍷', '🥃', '🍸', '🍹', '🧉', '🍾', '🧊'],
|
||||
'Activities': ['⚽', '🏀', '🏈', '⚾', '🥎', '🎾', '🏐', '🏉', '🥏', '🎱', '🪀', '🏓', '🏸', '🏒', '🏑', '🥍', '🏏', '🪃', '🥅', '⛳', '🪁', '🏹', '🎣', '🤿', '🥊', '🥋', '🎽', '🛹', '🛼', '🛷', '⛸️', '🥌', '🎿', '⛷️', '🏂', '🪂', '🏋️', '🤼', '🤸', '⛹️', '🤺', '🤾', '🏌️', '🏇', '🧘', '🏄', '🏊', '🤽', '🚣', '🧗', '🚵', '🚴', '🏆', '🥇', '🥈', '🥉', '🏅', '🎖️', '🏵️', '🎗️', '🎫', '🎟️', '🎪', '🎭', '🎨', '🎬', '🎤', '🎧', '🎼', '🎹', '🥁', '🪘', '🎷', '🎺', '🪗', '🎸', '🪕', '🎻', '🎲', '♟️', '🎯', '🎳', '🎮', '🎰', '🧩'],
|
||||
'Activities': ['⚽', '🏀', '🏈', '⚾', '🥎', '🎾', '🏐', '🏉', '🥏', '🎱', '🪀', '🏓', '🏸', '🏒', '🏑', '🥍', '🏏', '🪃', '🥅', '⛳', '🪁', '🏹', '🎣', '🤿', '🥊', '🥋', '🎽', '🛹', '🛼', '🛷', '⛸️', '🥌', '🎿', '⛷️', '🏂', '🪂', '🏋️', '🤼', '🤸', '⛹️', '🤺', '🤾', '🏌️', '🏇', '🧘', '🏄', '🏊', '🤽', '🚣', '🧗', '🚵', '🚴', '🏆', '🥇', '🥈', '🥉', '🏅', '🎖️', '🏵️', '🎗️', '🎫', '🎟️', '🎪', '🎭', '🎨', '🎬', '🎤', '🎧', '🎼', '🎹', '🥁', '🪘', '🎷', '🎺', '🪗', '🎸', '🪕', '🎻', '🎲', '♟️', '🎯', '🎳', '🎮', '🎰', '🧩', '🎈', '🎉', '🎊', '🎃', '🎄', '🎋', '🎍', '🎎', '🎏', '🎐', '🎑', '🧧', '🏮', '🎁', '🎀'],
|
||||
'Travel': ['🚗', '🚕', '🚙', '🚌', '🚎', '🏎️', '🚓', '🚑', '🚒', '🚐', '🛻', '🚚', '🚛', '🚜', '🦯', '🦽', '🦼', '🛴', '🚲', '🛵', '🏍️', '🛺', '🚨', '🚔', '🚍', '🚘', '🚖', '🚡', '🚠', '🚟', '🚃', '🚋', '🚞', '🚝', '🚄', '🚅', '🚈', '🚂', '🚆', '🚇', '🚊', '🚉', '✈️', '🛫', '🛬', '🛩️', '💺', '🛰️', '🚀', '🛸', '🚁', '🛶', '⛵', '🚤', '🛥️', '🛳️', '⛴️', '🚢', '⚓', '🪝', '⛽', '🚧', '🚦', '🚥', '🚏', '🗺️', '🗿', '🗽', '🗼', '🏰', '🏯', '🏟️', '🎡', '🎢', '🎠', '⛲', '⛱️', '🏖️', '🏝️', '🏜️', '🌋', '⛰️', '🏔️', '🗻', '🏕️', '⛺', '🛖', '🏠', '🏡', '🏘️', '🏚️', '🏗️', '🏭', '🏢', '🏬', '🏣', '🏤', '🏥', '🏦', '🏨', '🏪', '🏫', '🏩', '💒', '🏛️', '⛪', '🕌', '🕍', '🛕', '🕋', '⛩️'],
|
||||
'Objects': ['⌚', '📱', '📲', '💻', '⌨️', '🖥️', '🖨️', '🖱️', '🖲️', '🕹️', '🗜️', '💽', '💾', '💿', '📀', '📼', '📷', '📸', '📹', '🎥', '📽️', '🎞️', '📞', '☎️', '📟', '📠', '📺', '📻', '🎙️', '🎚️', '🎛️', '🧭', '⏱️', '⏲️', '⏰', '🕰️', '⌛', '⏳', '📡', '🔋', '🔌', '💡', '🔦', '🕯️', '🪔', '🧯', '🛢️', '💸', '💵', '💴', '💶', '💷', '🪙', '💰', '💳', '💎', '⚖️', '🪜', '🧰', '🪛', '🔧', '🔨', '⚒️', '🛠️', '⛏️', '🪚', '🔩', '⚙️', '🪤', '🧱', '⛓️', '🧲', '🔫', '💣', '🧨', '🪓', '🔪', '🗡️', '⚔️', '🛡️', '🚬', '⚰️', '🪦', '⚱️', '🏺', '🔮', '📿', '🧿', '💈', '⚗️', '🔭', '🔬', '🕳️', '🩹', '🩺', '💊', '💉', '🩸', '🧬', '🦠', '🧫', '🧪'],
|
||||
'Symbols': ['❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔', '❣️', '💕', '💞', '💓', '💗', '💖', '💘', '💝', '💟', '☮️', '✝️', '☪️', '🕉️', '☸️', '✡️', '🔯', '🕎', '☯️', '☦️', '🛐', '⛎', '♈', '♉', '♊', '♋', '♌', '♍', '♎', '♏', '♐', '♑', '♒', '♓', '🆔', '⚛️', '🉑', '☢️', '☣️', '📴', '📳', '🈶', '🈚', '🈸', '🈺', '🈷️', '✴️', '🆚', '💮', '🉐', '㊙️', '㊗️', '🈴', '🈵', '🈹', '🈲', '🅰️', '🅱️', '🆎', '🆑', '🅾️', '🆘', '❌', '⭕', '🛑', '⛔', '📛', '🚫', '💯', '💢', '♨️', '🚷', '🚯', '🚳', '🚱', '🔞', '📵', '🚭', '❗', '❕', '❓', '❔', '‼️', '⁉️', '🔅', '🔆', '〽️', '⚠️', '🚸', '🔱', '⚜️', '🔰', '♻️', '✅', '🈯', '💹', '❇️', '✳️', '❎', '🌐', '💠', 'Ⓜ️', '🌀', '💤', '🏧', '🚾', '♿', '🅿️', '🛗', '🈳', '🈂️', '🛂', '🛃', '🛄', '🛅', '🚹', '🚺', '🚼', '⚧️', '🚻', '🚮', '🎦', '📶', '🈁', '🔣', 'ℹ️', '🔤', '🔡', '🔠', '🆖', '🆗', '🆙', '🆒', '🆕', '🆓', '0️⃣', '1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣', '🔟', '🔢', '#️⃣', '*️⃣', '⏏️', '▶️', '⏸️', '⏯️', '⏹️', '⏺️', '⏭️', '⏮️', '⏩', '⏪', '⏫', '⏬', '◀️', '🔼', '🔽', '➡️', '⬅️', '⬆️', '⬇️', '↗️', '↘️', '↙️', '↖️', '↕️', '↔️', '↪️', '↩️', '⤴️', '⤵️', '🔀', '🔁', '🔂', '🔄', '🔃', '🎵', '🎶', '➕', '➖', '➗', '✖️', '♾️', '💲', '💱', '™️', '©️', '®️', '〰️', '➰', '➿', '🔚', '🔙', '🔛', '🔝', '🔜', '✔️', '☑️', '🔘', '🔴', '🟠', '🟡', '🟢', '🔵', '🟣', '⚫', '⚪', '🟤', '🔺', '🔻', '🔸', '🔹', '🔶', '🔷', '🔳', '🔲', '▪️', '▫️', '◾', '◽', '◼️', '◻️', '🟥', '🟧', '🟨', '🟩', '🟦', '🟪', '⬛', '⬜', '🟫', '🔈', '🔇', '🔉', '🔊', '🔔', '🔕', '📣', '📢', '👁️🗨️', '💬', '💭', '🗯️', '♠️', '♣️', '♥️', '♦️', '🃏', '🎴', '🀄', '🕐', '🕑', '🕒', '🕓', '🕔', '🕕', '🕖', '🕗', '🕘', '🕙', '🕚', '🕛', '🕜', '🕝', '🕞', '🕟', '🕠', '🕡', '🕢', '🕣', '🕤', '🕥', '🕦', '🕧'],
|
||||
'Objects': ['⌚', '📱', '📲', '💻', '⌨️', '🖥️', '🖨️', '🖱️', '🖲️', '🕹️', '🗜️', '💽', '💾', '💿', '📀', '📼', '📷', '📸', '📹', '🎥', '📽️', '🎞️', '📞', '☎️', '📟', '📠', '📺', '📻', '🎙️', '🎚️', '🎛️', '🧭', '⏱️', '⏲️', '⏰', '🕰️', '⌛', '⏳', '📡', '🔋', '🔌', '💡', '🔦', '🕯️', '🪔', '🧯', '🛢️', '💸', '💵', '💴', '💶', '💷', '🪙', '💰', '💳', '💎', '⚖️', '🪜', '🧰', '🪛', '🔧', '🔨', '⚒️', '🛠️', '⛏️', '🪚', '🔩', '⚙️', '🪤', '🧱', '⛓️', '🧲', '🔫', '💣', '🧨', '🪓', '🔪', '🗡️', '⚔️', '🛡️', '🚬', '⚰️', '🪦', '⚱️', '🏺', '🔮', '📿', '🧿', '💈', '⚗️', '🔭', '🔬', '🕳️', '🩹', '🩺', '💊', '💉', '🩸', '🧬', '🦠', '🧫', '🧪', '📝', '✏️', '🖊️', '🖋️', '🖍️', '📖', '📗', '📘', '📙', '📕', '📓', '📔', '📒', '📚', '📰', '🗞️', '📜', '📃', '📄', '📑', '📊', '📈', '📉', '📆', '📅', '🗓️', '📇', '🗃️', '🗳️', '🗄️', '📋', '📌', '📍', '📎', '🖇️', '📏', '📐', '✂️', '🗂️', '🗑️', '✉️', '📧', '📨', '📩', '📤', '📥', '📦', '📫', '📪', '📬', '📭', '📮', '🔒', '🔓', '🔏', '🔐', '🔑', '🗝️', '🪞', '🪟', '🛋️', '🪑', '🚪', '🛏️', '🧸', '🪆', '🖼️', '🛒', '🛍️', '🏷️', '🧴', '🧷', '🧹', '🧺', '🧻', '🪣', '🧼', '🧽', '🪥', '🪒', '🪄', '👑', '👒', '🎩', '🎓', '🧢', '⛑️', '👓', '🕶️', '🥽', '💼', '👜', '👛', '👝', '🎒', '🧳', '👞', '👟', '🥾', '🥿', '👠', '👡', '🩰', '👢', '👗', '👔', '👕', '👖', '🧣', '🧤', '🧥', '🧦', '👙', '👘', '🥻', '🩱', '🩲', '🩳', '💄', '💍'],
|
||||
'Symbols': ['❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔', '❣️', '💕', '💞', '💓', '💗', '💖', '💘', '💝', '💟', '☮️', '✝️', '☪️', '🕉️', '☸️', '✡️', '🔯', '🕎', '☯️', '☦️', '🛐', '⛎', '♈', '♉', '♊', '♋', '♌', '♍', '♎', '♏', '♐', '♑', '♒', '♓', '🆔', '⚛️', '🉑', '☢️', '☣️', '📴', '📳', '🈶', '🈚', '🈸', '🈺', '🈷️', '✴️', '🆚', '💮', '🉐', '㊙️', '㊗️', '🈴', '🈵', '🈹', '🈲', '🅰️', '🅱️', '🆎', '🆑', '🅾️', '🆘', '❌', '⭕', '🛑', '⛔', '📛', '🚫', '💯', '💢', '♨️', '🚷', '🚯', '🚳', '🚱', '🔞', '📵', '🚭', '❗', '❕', '❓', '❔', '‼️', '⁉️', '🔅', '🔆', '〽️', '⚠️', '🚸', '🔱', '⚜️', '🔰', '♻️', '✅', '🈯', '💹', '❇️', '✳️', '❎', '🌐', '💠', 'Ⓜ️', '🌀', '💤', '🏧', '🚾', '♿', '🅿️', '🛗', '🈳', '🈂️', '🛂', '🛃', '🛄', '🛅', '🚹', '🚺', '🚼', '⚧️', '🚻', '🚮', '🎦', '📶', '🈁', '🔣', 'ℹ️', '🔤', '🔡', '🔠', '🆖', '🆗', '🆙', '🆒', '🆕', '🆓', '0️⃣', '1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣', '🔟', '🔢', '#️⃣', '*️⃣', '⏏️', '▶️', '⏸️', '⏯️', '⏹️', '⏺️', '⏭️', '⏮️', '⏩', '⏪', '⏫', '⏬', '◀️', '🔼', '🔽', '➡️', '⬅️', '⬆️', '⬇️', '↗️', '↘️', '↙️', '↖️', '↕️', '↔️', '↪️', '↩️', '⤴️', '⤵️', '🔀', '🔁', '🔂', '🔄', '🔃', '🎵', '🎶', '➕', '➖', '➗', '✖️', '♾️', '💲', '💱', '™️', '©️', '®️', '〰️', '➰', '➿', '🔚', '🔙', '🔛', '🔝', '🔜', '✔️', '☑️', '🔘', '🔴', '🟠', '🟡', '🟢', '🔵', '🟣', '⚫', '⚪', '🟤', '🔺', '🔻', '🔸', '🔹', '🔶', '🔷', '🔳', '🔲', '▪️', '▫️', '◾', '◽', '◼️', '◻️', '🟥', '🟧', '🟨', '🟩', '🟦', '🟪', '⬛', '⬜', '🟫', '🔈', '🔇', '🔉', '🔊', '🔔', '🔕', '📣', '📢', '👁️🗨️', '💬', '💭', '🗯️', '♠️', '♣️', '♥️', '♦️', '🃏', '🎴', '🀄', '🕐', '🕑', '🕒', '🕓', '🕔', '🕕', '🕖', '🕗', '🕘', '🕙', '🕚', '🕛', '🕜', '🕝', '🕞', '🕟', '🕠', '🕡', '🕢', '🕣', '🕤', '🕥', '🕦', '🕧', '🏁', '🚩', '🎌', '🏳️', '🏴', '🏳️🌈'],
|
||||
'Nature': ['🌸', '💮', '🏵️', '🌹', '🥀', '🌺', '🌻', '🌼', '🌷', '🌱', '🪴', '🌲', '🌳', '🌴', '🌵', '🌾', '🌿', '☘️', '🍀', '🍁', '🍂', '🍃', '🍄', '🌰', '🦀', '🦞', '🦐', '🦑', '🌍', '🌎', '🌏', '🌐', '🪨', '🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘', '🌙', '🌚', '🌛', '🌜', '☀️', '🌝', '🌞', '🪐', '⭐', '🌟', '🌠', '🌌', '☁️', '⛅', '⛈️', '🌤️', '🌥️', '🌦️', '🌧️', '🌨️', '🌩️', '🌪️', '🌫️', '🌬️', '🌀', '🌈', '🌂', '☂️', '☔', '⛱️', '⚡', '❄️', '☃️', '⛄', '☄️', '🔥', '💧', '🌊'],
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,10 +44,26 @@ narrowQuery.addEventListener('change', e => {
|
|||
// SSE connection
|
||||
const events = new EventSource('/api/apps/stream')
|
||||
events.onmessage = e => {
|
||||
const prev = apps
|
||||
setApps(JSON.parse(e.data))
|
||||
// If selected app no longer exists, clear selection to show dashboard
|
||||
if (selectedApp && !apps.some(a => a.name === selectedApp)) {
|
||||
setSelectedApp(null)
|
||||
|
||||
// Full re-render if app list changed structurally or selected app disappeared
|
||||
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()
|
||||
}
|
||||
|
|
|
|||
142
src/client/styles/dashboard.ts
Normal file
142
src/client/styles/dashboard.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
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',
|
||||
})
|
||||
|
||||
export const GaugeContainer = define('GaugeContainer', {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
})
|
||||
|
||||
export const GaugeSvg = define('GaugeSvg', {
|
||||
base: 'svg',
|
||||
width: 120,
|
||||
height: 70,
|
||||
overflow: 'visible',
|
||||
})
|
||||
|
||||
export const GaugeValue = define('GaugeValue', {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
fontFamily: theme('fonts-mono'),
|
||||
color: theme('colors-text'),
|
||||
marginTop: 20,
|
||||
marginLeft: 5,
|
||||
})
|
||||
|
||||
// Unified Logs Section
|
||||
export const LogsSection = define('LogsSection', {
|
||||
width: '100%',
|
||||
maxWidth: 800,
|
||||
background: theme('colors-bgElement'),
|
||||
border: `1px solid ${theme('colors-border')}`,
|
||||
borderRadius: theme('radius-md'),
|
||||
overflow: 'hidden',
|
||||
})
|
||||
|
||||
export const LogsHeader = define('LogsHeader', {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '12px 16px',
|
||||
borderBottom: `1px solid ${theme('colors-border')}`,
|
||||
})
|
||||
|
||||
export const LogsTitle = define('LogsTitle', {
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: theme('colors-textFaint'),
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
})
|
||||
|
||||
export const LogsClearButton = define('LogsClearButton', {
|
||||
base: 'button',
|
||||
background: theme('colors-border'),
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
padding: '4px 10px',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: theme('colors-textMuted'),
|
||||
cursor: 'pointer',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.03em',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
background: theme('colors-bgHover'),
|
||||
color: theme('colors-text'),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const LogsBody = define('LogsBody', {
|
||||
height: 200,
|
||||
overflow: 'auto',
|
||||
fontFamily: theme('fonts-mono'),
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
})
|
||||
|
||||
export const LogEntry = define('LogEntry', {
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
padding: '2px 16px',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
background: theme('colors-bgHover'),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const LogTimestamp = define('LogTimestamp', {
|
||||
color: theme('colors-textFaint'),
|
||||
flexShrink: 0,
|
||||
})
|
||||
|
||||
export const LogApp = define('LogApp', {
|
||||
color: theme('colors-textMuted'),
|
||||
flexShrink: 0,
|
||||
minWidth: 80,
|
||||
})
|
||||
|
||||
export const LogText = define('LogText', {
|
||||
color: theme('colors-text'),
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
})
|
||||
|
||||
export const LogStatus = define('LogStatus', {
|
||||
variants: {
|
||||
success: { color: '#22c55e' },
|
||||
error: { color: '#ef4444' },
|
||||
warning: { color: '#f59e0b' },
|
||||
},
|
||||
})
|
||||
|
|
@ -1,4 +1,22 @@
|
|||
export { ActionBar, Button, NewAppButton } from './buttons'
|
||||
export {
|
||||
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,
|
||||
|
|
@ -40,6 +58,8 @@ export {
|
|||
SectionTitle,
|
||||
stateLabels,
|
||||
StatusDot,
|
||||
StatusDotLink,
|
||||
StatusDotsRow,
|
||||
Tab,
|
||||
TabBar,
|
||||
TabContent,
|
||||
|
|
|
|||
|
|
@ -203,6 +203,7 @@ export const DashboardContainer = define('DashboardContainer', {
|
|||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 40,
|
||||
paddingTop: 0,
|
||||
gap: 40,
|
||||
})
|
||||
|
||||
|
|
@ -214,7 +215,6 @@ export const DashboardTitle = define('DashboardTitle', {
|
|||
fontSize: 48,
|
||||
fontWeight: 'bold',
|
||||
margin: 0,
|
||||
marginBottom: 8,
|
||||
})
|
||||
|
||||
export const DashboardSubtitle = define('DashboardSubtitle', {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,44 @@ import { define } from '@because/forge'
|
|||
import { theme } from '../themes'
|
||||
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', {
|
||||
width: 8,
|
||||
height: 8,
|
||||
|
|
|
|||
264
src/server/api/system.ts
Normal file
264
src/server/api/system.ts
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
import { allApps, APPS_DIR, onChange } from '$apps'
|
||||
import { Hype } from '@because/hype'
|
||||
import { cpus, platform, totalmem } from 'os'
|
||||
import { join } from 'path'
|
||||
import { readFileSync, statfsSync } from 'fs'
|
||||
|
||||
export interface AppMetrics {
|
||||
cpu: number
|
||||
mem: number
|
||||
disk: number
|
||||
}
|
||||
|
||||
export interface SystemMetrics {
|
||||
cpu: number // 0-100
|
||||
ram: { used: number, total: number, percent: number }
|
||||
disk: { used: number, total: number, percent: number }
|
||||
apps: Record<string, AppMetrics>
|
||||
}
|
||||
|
||||
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 apps = allApps().filter(a => a.proc?.pid)
|
||||
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 {
|
||||
used,
|
||||
total,
|
||||
percent: used > 0 ? Math.max(1, Math.round((used / total) * 100)) : 0,
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
router.get('/metrics', c => {
|
||||
const metrics: SystemMetrics = {
|
||||
cpu: getCpuUsage(),
|
||||
ram: getMemoryUsage(),
|
||||
disk: getDiskUsage(),
|
||||
apps: getAppMetrics(),
|
||||
}
|
||||
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(),
|
||||
apps: getAppMetrics(),
|
||||
}
|
||||
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
|
||||
|
|
@ -96,7 +96,8 @@ export function readLogs(appName: string, date?: string, tail?: number): string[
|
|||
return lines
|
||||
}
|
||||
|
||||
export function initApps() {
|
||||
export async function initApps() {
|
||||
await killStaleProcesses()
|
||||
initPortPool()
|
||||
setupShutdownHandlers()
|
||||
rotateLogs()
|
||||
|
|
@ -466,6 +467,25 @@ function handleHealthCheckFailure(app: App) {
|
|||
}
|
||||
}
|
||||
|
||||
async function killStaleProcesses() {
|
||||
const result = Bun.spawnSync(['lsof', '-ti', `:${MIN_PORT - 1}-${MAX_PORT}`])
|
||||
const output = result.stdout.toString().trim()
|
||||
if (!output) return
|
||||
|
||||
const pids = output.split('\n').map(Number).filter(pid => pid && pid !== process.pid)
|
||||
if (pids.length === 0) return
|
||||
|
||||
hostLog(`Found ${pids.length} stale process(es) on ports ${MIN_PORT - 1}-${MAX_PORT}`)
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL')
|
||||
hostLog(`Killed stale process ${pid}`)
|
||||
} catch {
|
||||
// Process already gone
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function initPortPool() {
|
||||
_availablePorts.length = 0
|
||||
for (let port = MIN_PORT; port <= MAX_PORT; port++) {
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
const app = new Hype({ layout: false, logging: !!process.env.DEBUG })
|
||||
|
||||
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 => {
|
||||
|
|
@ -51,7 +53,7 @@ app.all('/api/tools/:tool/:path{.+}', async c => {
|
|||
})
|
||||
})
|
||||
|
||||
initApps()
|
||||
await initApps()
|
||||
|
||||
export default {
|
||||
...app.defaults,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { TOES_URL } from '$apps'
|
|||
const RENDER_DEBOUNCE = 50
|
||||
|
||||
let _apps: App[] = []
|
||||
let _enabled = process.stdout.isTTY ?? false
|
||||
let _enabled = (process.stdout.isTTY ?? false) && !process.env.DEBUG
|
||||
let _lastRender = 0
|
||||
let _renderTimer: Timer | undefined
|
||||
let _showEmoji = false
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user