All checks were successful
Beta Release / beta (push) Successful in 1m3s
- Block sudo/doas commands when not running as root - Add real token counting from API responses - Track and display consumption by provider/day - Add Mermaid diagram rendering in Shell and Studio - Add copy-to-clipboard buttons for code blocks - Support tables in AI message rendering - Update system prompt with context (date, time, root status) 💘 Generated with Crush Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
286 lines
11 KiB
JavaScript
286 lines
11 KiB
JavaScript
import { useState, useEffect, useCallback, useRef } from 'react'
|
||
import { useI18n } from '../i18n'
|
||
|
||
const MAX_POINTS = 30
|
||
|
||
const POLL_INTERVAL = 5000
|
||
const MAX_IDLE_POLLS = 3
|
||
|
||
function formatTokens(n) {
|
||
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
|
||
if (n >= 1000) return (n / 1000).toFixed(1) + 'K'
|
||
return String(n)
|
||
}
|
||
|
||
function MiniGraph({ data, max, color, label, unit }) {
|
||
if (!data || data.length < 2) return <div className="dash-graph-empty">collecting...</div>
|
||
const m = max || Math.max(...data, 1)
|
||
const w = 100
|
||
const h = 32
|
||
const points = data.map((v, i) => {
|
||
const x = (i / (data.length - 1)) * w
|
||
const y = h - (v / m) * h
|
||
return `${x},${y}`
|
||
}).join(' ')
|
||
const last = data[data.length - 1]
|
||
return (
|
||
<div className="dash-graph-wrap">
|
||
<div className="dash-graph-header">
|
||
<span className="dash-graph-label">{label}</span>
|
||
<span className="dash-graph-value" style={{ color }}>{last.toFixed(1)}{unit}</span>
|
||
</div>
|
||
<svg viewBox={`0 0 ${w} ${h}`} className="dash-graph-svg" preserveAspectRatio="none">
|
||
<defs>
|
||
<linearGradient id={`fg-${color.replace('#','').replace('var(','').replace(')','')}`} x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0%" stopColor={color} stopOpacity="0.3" />
|
||
<stop offset="100%" stopColor={color} stopOpacity="0.02" />
|
||
</linearGradient>
|
||
</defs>
|
||
<polygon fill={`url(#fg-${color.replace('#','').replace('var(','').replace(')','')})`} points={`${points} ${w},${h} 0,${h}`} />
|
||
<polyline fill="none" stroke={color} strokeWidth="1.5" points={points} vectorEffect="non-scaling-stroke" />
|
||
</svg>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function BarChart({ data, max, color }) {
|
||
if (!data || data.length === 0) return null
|
||
const barW = 100 / 7
|
||
const m = max || Math.max(...data.map(d => d.tokens), 1)
|
||
return (
|
||
<svg viewBox="0 0 100 40" className="dash-graph-svg" preserveAspectRatio="none">
|
||
{data.map((d, i) => {
|
||
const h = Math.max(1, (d.tokens / m) * 36)
|
||
const x = i * barW + barW * 0.15
|
||
const w = barW * 0.7
|
||
return (
|
||
<rect key={i} x={x} y={40 - h} width={w} height={h} rx="1.5" fill={color} opacity={0.85} />
|
||
)
|
||
})}
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
export default function Dashboard({ api, refreshRef }) {
|
||
const { t } = useI18n()
|
||
const [quota, setQuota] = useState(null)
|
||
const [consumption, setConsumption] = useState(null)
|
||
const [recentCmds, setRecentCmds] = useState([])
|
||
const [processes, setProcesses] = useState([])
|
||
const [metrics, setMetrics] = useState(null)
|
||
const [copiedSet, setCopiedSet] = useState(new Set())
|
||
const cpuRef = useRef([])
|
||
const memRef = useRef([])
|
||
const netRxRef = useRef([])
|
||
const netTxRef = useRef([])
|
||
|
||
const loadData = useCallback(async () => {
|
||
try {
|
||
const [quotaData, consumData, cmdData, procData, metricsData] = await Promise.all([
|
||
api.getProvidersQuota().catch(() => null),
|
||
api.getProvidersConsumption().catch(() => null),
|
||
api.getRecentCommands().catch(() => ({ commands: [] })),
|
||
api.getRunningProcesses().catch(() => ({ processes: [] })),
|
||
api.getSystemMetrics().catch(() => null),
|
||
])
|
||
setQuota(quotaData?.providers || [])
|
||
setConsumption(consumData?.providers || {})
|
||
setRecentCmds(cmdData.commands || [])
|
||
setProcesses(procData.processes || [])
|
||
if (metricsData) {
|
||
setMetrics(metricsData)
|
||
cpuRef.current = [...cpuRef.current, metricsData.cpu_percent].slice(-MAX_POINTS)
|
||
memRef.current = [...memRef.current, metricsData.mem_percent].slice(-MAX_POINTS)
|
||
netRxRef.current = [...netRxRef.current, metricsData.net_rx_kbs].slice(-MAX_POINTS)
|
||
netTxRef.current = [...netTxRef.current, metricsData.net_tx_kbs].slice(-MAX_POINTS)
|
||
}
|
||
} catch (err) {
|
||
console.error('Dashboard load error:', err)
|
||
}
|
||
}, [api])
|
||
|
||
useEffect(() => {
|
||
loadData()
|
||
if (refreshRef) refreshRef.current = loadData
|
||
let active = true
|
||
let idleTicks = 0
|
||
const iv = setInterval(() => {
|
||
const hidden = document.querySelector('.dash-grid')?.closest('.tab-hidden')
|
||
if (hidden) {
|
||
idleTicks++
|
||
if (idleTicks >= MAX_IDLE_POLLS) return
|
||
} else {
|
||
idleTicks = 0
|
||
}
|
||
if (active) loadData()
|
||
}, POLL_INTERVAL)
|
||
return () => { active = false; clearInterval(iv) }
|
||
}, [loadData, refreshRef])
|
||
|
||
const minimax = (quota || []).find(p => p.name === 'minimax')
|
||
|
||
const EXCLUDE_CMDS = ['ls', 'cd', 'pwd', 'clear', 'exit', 'history', 'cat', 'echo', 'grep', 'export', 'alias', 'unalias', 'set', 'unset', 'source', '.', 'fg', 'bg', 'jobs', 'wait', 'true', 'false', 'yes', 'sleep', 'date', 'whoami', 'id', 'uname', 'hostname', 'uptime', 'df', 'free', 'top', 'htop', 'nano', 'vi', 'vim', 'less', 'more', 'tail', 'head', 'man', 'info', 'which', 'whereis', 'type', 'command', 'hash', 'builtin', 'help']
|
||
|
||
const topCmds = (() => {
|
||
const counts = {}
|
||
for (const c of recentCmds) {
|
||
const base = c.cmd.split(/\s+/)[0]
|
||
if (!base || base.length < 2 || EXCLUDE_CMDS.includes(base)) continue
|
||
if (!/^[a-zA-Z@.\/]/.test(base)) continue
|
||
counts[base] = (counts[base] || 0) + 1
|
||
}
|
||
return Object.entries(counts)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 5)
|
||
.map(([cmd, count]) => ({ cmd, count }))
|
||
})()
|
||
|
||
const maxCount = topCmds.length > 0 ? topCmds[0].count : 1
|
||
|
||
const copyCmd = (cmd, key) => {
|
||
navigator.clipboard.writeText(cmd)
|
||
setCopiedSet(prev => new Set(prev).add(key))
|
||
setTimeout(() => setCopiedSet(prev => { const next = new Set(prev); next.delete(key); return next }), 1500)
|
||
}
|
||
|
||
const relativeTime = (ts) => {
|
||
if (!ts) return ''
|
||
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000)
|
||
if (diff < 60) return `${diff}s`
|
||
if (diff < 3600) return `${Math.floor(diff / 60)}m`
|
||
if (diff < 86400) return `${Math.floor(diff / 3600)}h`
|
||
return `${Math.floor(diff / 86400)}d`
|
||
}
|
||
|
||
const recentUnique = (() => {
|
||
const seen = new Set()
|
||
return recentCmds.filter(c => {
|
||
if (seen.has(c.cmd)) return false
|
||
seen.add(c.cmd)
|
||
return true
|
||
})
|
||
})()
|
||
|
||
const providerEntries = consumption ? Object.entries(consumption) : []
|
||
const colors = ['var(--accent)', '#34d399', '#a78bfa', '#f59e0b', '#f472b6']
|
||
const maxDaily = providerEntries.length > 0
|
||
? Math.max(...providerEntries.map(([, p]) => Math.max(...(p.daily || []).map(d => d.tokens), 0)), 1)
|
||
: 1
|
||
|
||
return (
|
||
<div className="dash-grid">
|
||
{/* CPU */}
|
||
<div className="dash-card">
|
||
<div className="dash-card-head">
|
||
<span className="dash-label">CPU</span>
|
||
<span className="dash-count">{metrics ? metrics.cpu_percent.toFixed(0) : '—'}%</span>
|
||
</div>
|
||
<MiniGraph data={cpuRef.current} max={100} color="var(--accent)" label="CPU" unit="%" />
|
||
</div>
|
||
|
||
{/* RAM */}
|
||
<div className="dash-card">
|
||
<div className="dash-card-head">
|
||
<span className="dash-label">RAM</span>
|
||
<span className="dash-count">{metrics ? `${metrics.mem_used_mb.toFixed(0)}/${metrics.mem_total_mb.toFixed(0)}` : '—'}</span>
|
||
</div>
|
||
<MiniGraph data={memRef.current} max={100} color="#a78bfa" label="RAM" unit="%" />
|
||
</div>
|
||
|
||
{/* Network */}
|
||
<div className="dash-card">
|
||
<div className="dash-card-head">
|
||
<span className="dash-label">Network</span>
|
||
<span className="dash-count">{metrics ? `↓${metrics.net_rx_kbs.toFixed(0)} ↑${metrics.net_tx_kbs.toFixed(0)}` : '—'}</span>
|
||
</div>
|
||
<MiniGraph data={netRxRef.current} max={null} color="#34d399" label="RX" unit=" KB/s" />
|
||
<MiniGraph data={netTxRef.current} max={null} color="#f59e0b" label="TX" unit=" KB/s" />
|
||
</div>
|
||
|
||
{/* Consommation */}
|
||
<div className="dash-card">
|
||
<div className="dash-card-head">
|
||
<span className="dash-label">Consommation</span>
|
||
<span className="dash-count">7j</span>
|
||
</div>
|
||
<div className="dash-consumption-list">
|
||
{providerEntries.length === 0 && (
|
||
<span className="dash-empty">Aucune donnée</span>
|
||
)}
|
||
{providerEntries.map(([name, p], pi) => (
|
||
<div key={name} className="dash-consumption-provider">
|
||
<div className="dash-consumption-head">
|
||
<span className="dash-consumption-name" style={{ color: colors[pi % colors.length] }}>
|
||
{name.toUpperCase()}
|
||
</span>
|
||
<span className="dash-consumption-total">
|
||
{formatTokens(p.total_tokens)} tokens · {p.total_requests} req
|
||
</span>
|
||
</div>
|
||
<BarChart data={p.daily || []} max={maxDaily} color={colors[pi % colors.length]} />
|
||
<div className="dash-consumption-days">
|
||
{(p.daily || []).map((d, i) => (
|
||
<span key={i} className="dash-consumption-day">
|
||
{d.date.slice(5)} <strong>{formatTokens(d.tokens)}</strong>
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Running Processes */}
|
||
<div className="dash-card">
|
||
<div className="dash-card-head">
|
||
<span className="dash-label">Processes</span>
|
||
<span className="dash-count">{processes.length}</span>
|
||
</div>
|
||
<div className="dash-proc-list">
|
||
{processes.length === 0 && <span className="dash-empty">No relevant processes</span>}
|
||
{processes.map((p, i) => (
|
||
<div key={i} className="dash-proc-row">
|
||
<span className="dash-proc-name">{p.name}</span>
|
||
<span className="dash-proc-res">cpu {p.cpu}% · mem {p.mem}%</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Recent Commands */}
|
||
<div className="dash-card dash-cmd-card">
|
||
<div className="dash-card-head">
|
||
<span className="dash-label">Recent Commands</span>
|
||
<span className="dash-count">{recentUnique.length}</span>
|
||
</div>
|
||
{topCmds.length > 0 && (
|
||
<div className="dash-cmd-freq">
|
||
<span className="dash-cmd-freq-title">Most used</span>
|
||
{topCmds.map((c, i) => (
|
||
<div key={i} className="dash-cmd-freq-row" onClick={() => copyCmd(c.cmd, `top-${i}`)} title={c.cmd}>
|
||
<span className="dash-cmd-freq-name">{copiedSet.has(`top-${i}`) ? '✓ Copié' : c.cmd}</span>
|
||
<div className="dash-cmd-freq-bar-wrap">
|
||
<div className="dash-cmd-freq-bar" style={{ width: `${(c.count / maxCount) * 100}%` }} />
|
||
</div>
|
||
<span className="dash-cmd-freq-count">{c.count}×</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="dash-cmd-list">
|
||
{recentUnique.length === 0 && <span className="dash-empty">No history</span>}
|
||
{recentUnique.map((c, i) => (
|
||
<div key={i} className="dash-cmd-row" onClick={() => copyCmd(c.cmd, `list-${i}`)} title={c.cmd + ' · click to copy'}>
|
||
<div className="dash-cmd-left">
|
||
<span className="dash-cmd-text">{c.cmd.length > 38 ? c.cmd.slice(0, 35) + '...' : c.cmd}</span>
|
||
<span className="dash-cmd-time">{relativeTime(c.ts)}</span>
|
||
</div>
|
||
<span className="dash-cmd-copy">{copiedSet.has(`list-${i}`) ? '✓' : '⎘'}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|