Compare commits

...

2 Commits

Author SHA1 Message Date
Augustin
79d082180c feat(dashboard): single-view grid with live CPU/RAM/Net graphs, API quota, processes, and sudo indicator
All checks were successful
Beta Release / beta (push) Successful in 46s
- Rewrite dashboard from 4 tabs to single grid view with 5s auto-refresh
- Add live CPU/RAM/Network SVG graphs with rolling 30-point history
- Add backend /api/system/metrics reading /proc/stat, /proc/meminfo, /proc/net/dev
- Add backend /api/providers/quota for MiniMax and Z.AI quota monitoring
- Add backend /api/recent-commands reading bash/zsh history
- Add backend /api/running-processes filtering editors/IDEs/languages
- Add sudo/root indicator ( ROOT) in footer when running as root
- Remove duplicate Ctrl+1-4 shortcut from page-specific footer (keep only right side)
- Add Ctrl+R shortcut on dashboard for metrics-only refresh
- Make API key mandatory in onboarding, auto-scan editors via AI chat
- Remove manual editor input, only show AI-detected editors
- Bump version to 0.3.3

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 19:46:16 +02:00
Augustin
7682717093 feat(dashboard): add quota monitoring, process list, and command history
All checks were successful
Beta Release / beta (push) Successful in 44s
- New API endpoints: /providers/quota, /recent-commands, /running-processes
- New grid-based dashboard layout with cards for tools, quota, processes, commands
- Improved OnboardingWizard with required API key validation and scanning feedback
- Auto-initialize config on first run

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-23 19:24:23 +02:00
8 changed files with 759 additions and 560 deletions

View File

@@ -2,8 +2,14 @@ package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/muyue/muyue/internal/lsp"
"github.com/muyue/muyue/internal/mcp"
@@ -17,6 +23,7 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
"name": version.Name,
"version": version.Version,
"author": version.Author,
"sudo": os.Geteuid() == 0,
})
}
@@ -415,3 +422,299 @@ func (s *Server) handleEditors(w http.ResponseWriter, r *http.Request) {
editors := scanner.ScanEditors()
writeJSON(w, map[string]interface{}{"editors": editors})
}
func (s *Server) handleProvidersQuota(w http.ResponseWriter, r *http.Request) {
type providerQuota struct {
Name string `json:"name"`
Active bool `json:"active"`
Healthy bool `json:"healthy"`
Data map[string]interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
var results []providerQuota
client := &http.Client{Timeout: 8 * time.Second}
for _, p := range s.config.AI.Providers {
q := providerQuota{Name: p.Name, Active: p.Active}
switch p.Name {
case "minimax":
if p.APIKey == "" {
q.Error = "no API key"
results = append(results, q)
continue
}
req, _ := http.NewRequest("GET", "https://api.minimax.io/v1/token_plan/remains", nil)
req.Header.Set("Authorization", "Bearer "+p.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
q.Error = err.Error()
} else {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var data map[string]interface{}
if json.Unmarshal(body, &data) == nil {
if models, ok := data["model_remains"].([]interface{}); ok {
filtered := make([]map[string]interface{}, 0)
for _, m := range models {
if mm, ok := m.(map[string]interface{}); ok {
usage, _ := mm["current_interval_usage_count"].(float64)
total, _ := mm["current_interval_total_count"].(float64)
if total > 0 {
filtered = append(filtered, map[string]interface{}{
"model": mm["model_name"],
"used": usage,
"total": total,
"remaining": total - usage,
"weekly_used": mm["current_weekly_usage_count"],
"weekly_total": mm["current_weekly_total_count"],
})
}
}
}
q.Data = map[string]interface{}{"models": filtered}
q.Healthy = true
}
}
}
case "zai":
if p.APIKey == "" {
q.Error = "no API key"
results = append(results, q)
continue
}
req, _ := http.NewRequest("GET", "https://api.z.ai/api/monitor/usage/quota/limit", nil)
req.Header.Set("Authorization", "Bearer "+p.APIKey)
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
q.Error = err.Error()
} else {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var data map[string]interface{}
if json.Unmarshal(body, &data) == nil {
q.Data = data
q.Healthy = true
}
}
default:
q.Error = "quota not supported"
}
results = append(results, q)
}
writeJSON(w, map[string]interface{}{"providers": results})
}
func (s *Server) handleRecentCommands(w http.ResponseWriter, r *http.Request) {
home, _ := os.UserHomeDir()
type cmdEntry struct {
Cmd string `json:"cmd"`
Shell string `json:"shell"`
}
var entries []cmdEntry
for _, histFile := range []string{".bash_history", ".zsh_history"} {
path := filepath.Join(home, histFile)
data, err := os.ReadFile(path)
if err != nil {
continue
}
shell := "bash"
if strings.Contains(histFile, "zsh") {
shell = "zsh"
}
lines := strings.Split(string(data), "\n")
start := len(lines) - 25
if start < 0 {
start = 0
}
for i := len(lines) - 1; i >= start; i-- {
line := strings.TrimSpace(lines[i])
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, ": ") {
parts := strings.SplitN(line, ";", 2)
if len(parts) == 2 {
line = strings.TrimSpace(parts[1])
} else {
continue
}
}
if line == "" {
continue
}
entries = append(entries, cmdEntry{Cmd: line, Shell: shell})
}
}
max := 20
if len(entries) > max {
entries = entries[:max]
}
writeJSON(w, map[string]interface{}{"commands": entries})
}
func (s *Server) handleRunningProcesses(w http.ResponseWriter, r *http.Request) {
type proc struct {
PID int `json:"pid"`
Name string `json:"name"`
Command string `json:"command"`
CPU string `json:"cpu"`
Mem string `json:"mem"`
}
var procs []proc
editors := []string{"code", "nvim", "vim", "emacs", "hx", "subl", "zed", "cursor"}
langs := []string{"node", "python", "java", "go", "rustc", "cargo", "ruby", "php"}
interesting := append(editors, langs...)
interesting = append(interesting, "muyue")
cmd := exec.Command("ps", "aux")
out, err := cmd.Output()
if err != nil {
writeJSON(w, map[string]interface{}{"processes": procs})
return
}
lines := strings.Split(string(out), "\n")
for _, line := range lines[1:] {
fields := strings.Fields(line)
if len(fields) < 11 {
continue
}
fullCmd := strings.Join(fields[10:], " ")
name := filepath.Base(fields[10])
matched := false
for _, pattern := range interesting {
if strings.Contains(name, pattern) || strings.Contains(strings.ToLower(fullCmd), pattern) {
matched = true
break
}
}
if !matched {
continue
}
var pid int
fmt.Sscanf(fields[1], "%d", &pid)
procs = append(procs, proc{
PID: pid,
Name: name,
Command: fullCmd,
CPU: fields[2],
Mem: fields[3],
})
}
writeJSON(w, map[string]interface{}{"processes": procs})
}
type sysMetrics struct {
CPUPercent float64 `json:"cpu_percent"`
MemPercent float64 `json:"mem_percent"`
MemUsedMB float64 `json:"mem_used_mb"`
MemTotalMB float64 `json:"mem_total_mb"`
NetRxKBs float64 `json:"net_rx_kbs"`
NetTxKBs float64 `json:"net_tx_kbs"`
}
var (
lastCPU [2]float64
lastNet [2]float64
lastNetTs time.Time
lastCPUSet bool
)
func (s *Server) handleSystemMetrics(w http.ResponseWriter, r *http.Request) {
m := sysMetrics{}
// CPU from /proc/stat
if data, err := os.ReadFile("/proc/stat"); err == nil {
line := strings.Split(string(data), "\n")[0]
fields := strings.Fields(line)
if len(fields) >= 5 {
var idle, total float64
for i := 1; i < len(fields) && i <= 4; i++ {
var v float64
fmt.Sscanf(fields[i], "%f", &v)
total += v
if i == 4 {
idle = v
}
}
if lastCPUSet {
dIdle := idle - lastCPU[0]
dTotal := total - lastCPU[1]
if dTotal > 0 {
m.CPUPercent = (1 - dIdle/dTotal) * 100
}
}
lastCPU = [2]float64{idle, total}
lastCPUSet = true
}
}
// Memory from /proc/meminfo
if data, err := os.ReadFile("/proc/meminfo"); err == nil {
var memTotal, memAvailable float64
for _, line := range strings.Split(string(data), "\n") {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
var v float64
fmt.Sscanf(fields[1], "%f", &v)
switch fields[0] {
case "MemTotal:":
memTotal = v
case "MemAvailable:":
memAvailable = v
}
}
if memTotal > 0 {
m.MemTotalMB = memTotal / 1024
m.MemUsedMB = (memTotal - memAvailable) / 1024
m.MemPercent = (memTotal - memAvailable) / memTotal * 100
}
}
// Network from /proc/net/dev
if data, err := os.ReadFile("/proc/net/dev"); err == nil {
var rxBytes, txBytes float64
for _, line := range strings.Split(string(data), "\n")[2:] {
fields := strings.Fields(line)
if len(fields) < 10 {
continue
}
iface := strings.TrimSuffix(fields[0], ":")
if iface == "lo" {
continue
}
var rx, tx float64
fmt.Sscanf(fields[1], "%f", &rx)
fmt.Sscanf(fields[9], "%f", &tx)
rxBytes += rx
txBytes += tx
}
now := time.Now()
if !lastNetTs.IsZero() {
elapsed := now.Sub(lastNetTs).Seconds()
if elapsed > 0 {
m.NetRxKBs = (rxBytes - lastNet[0]) / 1024 / elapsed
m.NetTxKBs = (txBytes - lastNet[1]) / 1024 / elapsed
if m.NetRxKBs < 0 {
m.NetRxKBs = 0
}
if m.NetTxKBs < 0 {
m.NetTxKBs = 0
}
}
}
lastNet = [2]float64{rxBytes, txBytes}
lastNetTs = now
}
writeJSON(w, m)
}

View File

@@ -2,6 +2,7 @@ package api
import (
"encoding/json"
"log"
"net/http"
"strings"
@@ -23,9 +24,26 @@ type Server struct {
func NewServer(cfg *config.MuyueConfig) *Server {
s := &Server{
config: cfg,
mux: http.NewServeMux(),
mux: http.NewServeMux(),
}
// Auto-initialize config if nil or if no config file exists on disk
if cfg == nil || !config.Exists() {
defaultCfg := config.Default()
if cfg != nil {
// Preserve any user-provided settings from cfg
defaultCfg.Profile = cfg.Profile
defaultCfg.AI = cfg.AI
defaultCfg.Tools = cfg.Tools
defaultCfg.BMAD = cfg.BMAD
defaultCfg.Terminal = cfg.Terminal
}
// Save initial config to establish the file for first-time usage
if err := config.Save(defaultCfg); err != nil {
log.Printf("config: initial save failed: %v", err)
}
cfg = defaultCfg
}
s.config = cfg
s.scanResult = scanner.ScanSystem()
s.convStore = NewConversationStore()
s.agentRegistry = agent.DefaultRegistry()
@@ -95,6 +113,10 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/skills/export", s.handleSkillExport)
s.mux.HandleFunc("/api/skills/import", s.handleSkillImport)
s.mux.HandleFunc("/api/dashboard/status", s.handleDashboardStatus)
s.mux.HandleFunc("/api/providers/quota", s.handleProvidersQuota)
s.mux.HandleFunc("/api/recent-commands", s.handleRecentCommands)
s.mux.HandleFunc("/api/running-processes", s.handleRunningProcesses)
s.mux.HandleFunc("/api/system/metrics", s.handleSystemMetrics)
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {

View File

@@ -7,7 +7,7 @@ import (
const (
Name = "muyue"
Version = "0.3.2"
Version = "0.3.3"
Author = "La Légion de Muyue"
)

View File

@@ -37,6 +37,10 @@ const api = {
exportSkill: (name) => request('/skills/export', { method: 'POST', body: JSON.stringify({ name }) }),
importSkill: (path) => request('/skills/import', { method: 'POST', body: JSON.stringify({ import_path: path }) }),
getDashboardStatus: () => request('/dashboard/status'),
getProvidersQuota: () => request('/providers/quota'),
getRecentCommands: () => request('/recent-commands'),
getRunningProcesses: () => request('/running-processes'),
getSystemMetrics: () => request('/system/metrics'),
savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }),
saveProfile: (profile) => request('/config/profile', { method: 'PUT', body: JSON.stringify(profile) }),
saveProvider: (provider) => request('/config/provider', { method: 'PUT', body: JSON.stringify(provider) }),

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { LayoutDashboard, Sparkles, Terminal, Settings } from 'lucide-react'
import api from '../api/client'
import { getTheme, applyTheme } from '../themes'
@@ -13,6 +13,9 @@ export default function App() {
const [activeTab, setActiveTab] = useState('dash')
const [info, setInfo] = useState({})
const [clock, setClock] = useState(new Date())
const [isSudo, setIsSudo] = useState(false)
const [dashRefreshKey, setDashRefreshKey] = useState(0)
const dashRefreshRef = useRef(null)
const [updates, setUpdates] = useState([])
const [tools, setTools] = useState([])
const [config, setConfig] = useState(null)
@@ -27,7 +30,7 @@ export default function App() {
], [t])
useEffect(() => {
api.getInfo().then(setInfo).catch(() => {})
api.getInfo().then(d => { setInfo(d); setIsSudo(!!d.sudo) }).catch(() => {})
api.getTools().then(d => setTools(d.tools || [])).catch(() => {})
api.getUpdates().then(d => setUpdates(d.updates || [])).catch(() => {})
api.getConfig().then(d => {
@@ -60,6 +63,11 @@ export default function App() {
if (map[e.code]) {
e.preventDefault()
setActiveTab(map[e.code])
return
}
if (e.ctrlKey && e.code === 'KeyR') {
e.preventDefault()
if (dashRefreshRef.current) dashRefreshRef.current()
}
}
window.addEventListener('keydown', onKey)
@@ -72,27 +80,21 @@ export default function App() {
const installed = tools.filter(tool => tool.installed).length
const WINDOW_SHORTCUTS = useMemo(() => ({
dash: [
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
],
dash: [],
studio: [
{ keys: layout.keys.enter, desc: t('statusbar.sendMessage') },
{ keys: `${layout.keys.shift}+${layout.keys.enter}`, desc: t('statusbar.newLine') },
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
],
shell: [
{ keys: layout.keys.enter, desc: t('statusbar.runCommand') },
{ keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') },
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
],
config: [
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
],
config: [],
}), [layout, t])
const renderContent = () => {
switch (activeTab) {
case 'dash': return <Dashboard api={api} />
case 'dash': return <Dashboard api={api} refreshRef={dashRefreshRef} />
case 'studio': return <Studio api={api} />
case 'shell': return <Shell api={api} />
case 'config': return <Config api={api} />
@@ -147,6 +149,12 @@ export default function App() {
<footer className="statusbar">
<div className="statusbar-left">
{isSudo && <span className="statusbar-sudo"> ROOT</span>}
{activeTab === 'dash' && (
<span className="statusbar-shortcut">
<kbd>{layout.keys.ctrl}+R</kbd> refresh
</span>
)}
<FooterShortcuts shortcuts={WINDOW_SHORTCUTS[activeTab] || []} />
</div>
<div className="statusbar-right">

View File

@@ -1,438 +1,202 @@
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useI18n } from '../i18n'
const TOOL_ICONS = {
crush: '⚡',
claude: '🤖',
go: '🔷',
node: '🟢',
python: '🐍',
docker: '🐳',
git: '📚',
ssh: '🌐',
starship: '🚀',
rust: '🦀',
}
function ToolCard({ tool, onInstall, installing }) {
const { t } = useI18n()
const [showInstall, setShowInstall] = useState(false)
const icon = TOOL_ICONS[tool.name?.toLowerCase()] || '🔧'
const isInstalled = tool.installed || tool.status === 'installed'
const version = tool.version || ''
const hasUpdate = tool.hasUpdate || tool.updateAvailable
const MAX_POINTS = 30
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={`tool-card ${isInstalled ? 'installed' : 'missing'}`}>
<div className="tool-card-icon">{icon}</div>
<div className="tool-card-info">
<div className="tool-card-name">{tool.name || 'Unknown'}</div>
<div className="tool-card-version">
{isInstalled ? (
<span className="status-ok">{t('dashboard.installed')}</span>
) : (
<span className="status-missing">{t('dashboard.missing')}</span>
)}
{version && <span className="tool-version-text">{version}</span>}
</div>
</div>
<div className="tool-card-actions">
{isInstalled && hasUpdate && (
<span className="tool-update-badge" title={`Update to ${tool.latestVersion || 'latest'}`}>
{tool.latestVersion || 'new'}
</span>
)}
{!isInstalled && (
<button
className="sm primary"
onClick={() => onInstall(tool.name)}
disabled={installing}
>
{installing ? '...' : t('dashboard.install')}
</button>
)}
<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">
<polyline fill="none" stroke={color} strokeWidth="1.5" points={points} vectorEffect="non-scaling-stroke" />
</svg>
</div>
)
}
function ActivityItem({ entry }) {
const time = entry.time
? new Date(entry.time).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' })
: ''
const type = entry.type || entry.level || 'info'
const text = entry.message || entry.text || entry.content || ''
const typeClass = {
ok: 'notif-ok',
success: 'notif-ok',
install: 'notif-ok',
update: 'notif-info',
info: 'notif-info',
warn: 'notif-warn',
warning: 'notif-warn',
error: 'notif-error',
fail: 'notif-error',
}[type] || 'notif-info'
const icon = {
ok: '✓', success: '✓', install: '✓', update: '→',
info: '', warn: '⚠', warning: '⚠', error: '✗', fail: '✗',
}[type] || '•'
return (
<div className={`notif-row ${typeClass}`}>
<span className="notif-time">{time}</span>
<span className="notif-icon">{icon}</span>
<span className="notif-text">{text}</span>
</div>
)
}
function QuickActionButton({ icon, label, onClick, loading, disabled }) {
return (
<button
className="quick-action-btn"
onClick={onClick}
disabled={disabled || loading}
>
{loading ? <span className="spinner" style={{ width: 14, height: 14 }} /> : <span className="quick-action-icon">{icon}</span>}
<span className="quick-action-label">{label}</span>
</button>
)
}
export default function Dashboard({ api }) {
export default function Dashboard({ api, refreshRef }) {
const { t } = useI18n()
const [activeTab, setActiveTab] = useState('tools')
const [tools, setTools] = useState([])
const [updates, setUpdates] = useState([])
const [systemInfo, setSystemInfo] = useState(null)
const [notifications, setNotifications] = useState([])
const [loading, setLoading] = useState(false)
const [installing, setInstalling] = useState(false)
const [scanLoading, setScanLoading] = useState(false)
const [mcpLoading, setMcpLoading] = useState(false)
const [dashboardStatus, setDashboardStatus] = useState(null)
const [quota, setQuota] = useState(null)
const [recentCmds, setRecentCmds] = useState([])
const [processes, setProcesses] = useState([])
const [metrics, setMetrics] = useState(null)
const cpuRef = useRef([])
const memRef = useRef([])
const netRxRef = useRef([])
const netTxRef = useRef([])
const loadData = useCallback(async () => {
try {
const [toolsData, updatesData, systemData] = await Promise.all([
api.getTools().catch(() => ({ tools: [] })),
api.getUpdates().catch(() => ({ updates: [] })),
api.getSystem().catch(() => null),
const [dashData, quotaData, cmdData, procData, metricsData] = await Promise.all([
api.getDashboardStatus().catch(() => null),
api.getProvidersQuota().catch(() => null),
api.getRecentCommands().catch(() => ({ commands: [] })),
api.getRunningProcesses().catch(() => ({ processes: [] })),
api.getSystemMetrics().catch(() => null),
])
setTools(toolsData.tools || toolsData || [])
setUpdates(updatesData.updates || updatesData || [])
setSystemInfo(systemData)
api.getDashboardStatus().then(d => setDashboardStatus(d)).catch(() => {})
setDashboardStatus(dashData)
setQuota(quotaData?.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('Failed to load dashboard data:', err)
console.error('Dashboard load error:', err)
}
}, [api])
useEffect(() => {
loadData()
}, [loadData])
if (refreshRef) refreshRef.current = loadData
const iv = setInterval(loadData, 5000)
return () => clearInterval(iv)
}, [loadData, refreshRef])
const addNotification = (message, type = 'info') => {
const entry = { id: Date.now(), time: new Date().toISOString(), message, type }
setNotifications(prev => [entry, ...prev].slice(0, 100))
}
const handleRescan = async () => {
setScanLoading(true)
addNotification(t('dashboard.rescanning'), 'info')
try {
await api.runScan()
await loadData()
addNotification(t('dashboard.scanComplete'), 'ok')
} catch (err) {
addNotification(`${t('dashboard.scanFailed')}: ${err.message}`, 'error')
} finally {
setScanLoading(false)
}
}
const handleInstallMissing = async () => {
const missing = tools.filter(t => !t.installed && t.status !== 'installed')
if (missing.length === 0) return
setInstalling(true)
addNotification(t('dashboard.installing', { count: missing.length }), 'info')
try {
await api.installTools(missing.map(t => t.name))
addNotification(t('dashboard.installStarted'), 'ok')
setTimeout(() => handleRescan(), 2000)
} catch (err) {
addNotification(`${t('dashboard.installFailed')}: ${err.message}`, 'error')
} finally {
setInstalling(false)
}
}
const handleCheckUpdates = async () => {
setLoading(true)
addNotification(t('config.checking'), 'info')
try {
const data = await api.getUpdates()
setUpdates(data.updates || data || [])
const count = (data.updates || data || []).length
if (count > 0) {
addNotification(t('dashboard.updatesCount', { count }), 'warn')
} else {
addNotification(t('dashboard.allUpToDate'), 'ok')
}
} catch (err) {
addNotification(`${t('dashboard.checkUpdatesFailed')}: ${err.message}`, 'error')
} finally {
setLoading(false)
}
}
const handleConfigureMCP = async () => {
setMcpLoading(true)
addNotification(t('dashboard.configuringMCP'), 'info')
try {
await api.configureMCP()
addNotification(t('dashboard.mcpConfigured'), 'ok')
} catch (err) {
addNotification(`${t('dashboard.mcpConfigFailed')}: ${err.message}`, 'error')
} finally {
setMcpLoading(false)
}
}
const handleInstallTool = async (name) => {
setInstalling(true)
addNotification(`${t('dashboard.installing')} ${name}...`, 'info')
try {
await api.installTools([name])
addNotification(`${name} ${t('dashboard.installed')}`, 'ok')
setTimeout(() => loadData(), 2000)
} catch (err) {
addNotification(`${t('dashboard.installFailed')}: ${err.message}`, 'error')
} finally {
setInstalling(false)
}
}
const installedCount = tools.filter(t => t.installed || t.status === 'installed').length
const missingCount = tools.length - installedCount
const minimax = (quota || []).find(p => p.name === 'minimax')
const zai = (quota || []).find(p => p.name === 'zai')
return (
<div className="dashboard-layout">
<div className="dashboard-tabs">
<button
className={`dashboard-tab ${activeTab === 'tools' ? 'active' : ''}`}
onClick={() => setActiveTab('tools')}
>
<span className="tab-icon">🔧</span>
{t('dashboard.tools')}
<span className="tab-count">{installedCount}</span>
</button>
<button
className={`dashboard-tab ${activeTab === 'activity' ? 'active' : ''}`}
onClick={() => setActiveTab('activity')}
>
<span className="tab-icon">📋</span>
{t('dashboard.activity')}
{notifications.length > 0 && <span className="tab-count warn">{notifications.length}</span>}
</button>
<button
className={`dashboard-tab ${activeTab === 'actions' ? 'active' : ''}`}
onClick={() => setActiveTab('actions')}
>
<span className="tab-icon"></span>
{t('dashboard.quickActions')}
</button>
<button
className={`dashboard-tab ${activeTab === 'status' ? 'active' : ''}`}
onClick={() => setActiveTab('status')}
>
<span className="tab-icon">📡</span>
{t('dashboard.status') || 'Status'}
</button>
<div className="dash-grid">
{/* CPU / RAM / Network Graphs */}
<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>
<div className="dashboard-content">
{activeTab === 'tools' && (
<div className="dashboard-tools-panel">
<div className="dashboard-section-header">
<div className="dashboard-section-title">{t('dashboard.systemOverview')}</div>
<div className="dashboard-tools-stats">
<span className="stat-ok">{installedCount} {t('dashboard.installed')}</span>
{missingCount > 0 && <span className="stat-missing">{missingCount} {t('dashboard.missing')}</span>}
</div>
</div>
{systemInfo && (
<div className="dashboard-system-info">
<span className="sys-info-item">{systemInfo.os || systemInfo.platform || 'Unknown'}</span>
<span className="sys-info-sep">·</span>
<span className="sys-info-item">{systemInfo.arch || 'Unknown'}</span>
{systemInfo.shell && <><span className="sys-info-sep">·</span><span className="sys-info-item">{systemInfo.shell}</span></>}
</div>
)}
<div className="tools-grid">
{tools.length === 0 && (
<div className="empty-state">{t('dashboard.noTools')}</div>
)}
{tools.map((tool, i) => (
<ToolCard
key={tool.name || i}
tool={tool}
onInstall={handleInstallTool}
installing={installing}
/>
))}
</div>
</div>
)}
<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)} MB` : '—'}</span>
</div>
<MiniGraph data={memRef.current} max={100} color="#a78bfa" label="RAM" unit="%" />
</div>
{activeTab === 'activity' && (
<div className="dashboard-activity-panel">
<div className="dashboard-section-header">
<div className="dashboard-section-title">{t('dashboard.activityLog')}</div>
<button className="sm ghost" onClick={() => setNotifications([])} disabled={notifications.length === 0}>
{t('dashboard.clearLog')}
</button>
<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)} KB/s` : '—'}</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>
{/* API Quota */}
<div className="dash-card">
<div className="dash-card-head">
<span className="dash-label">API Quota</span>
</div>
<div className="dash-quota-list">
{minimax && minimax.data?.models?.map((m, i) => (
<div key={i} className="dash-quota-row">
<span className="dash-quota-name">{String(m.model).replace('MiniMax-', '')}</span>
<div className="dash-bar">
<div className="dash-bar-fill" style={{ width: `${Math.min(100, (m.used / m.total) * 100)}%` }} />
</div>
<span className="dash-quota-val">{m.remaining}/{m.total}</span>
</div>
{notifications.length === 0 ? (
<div className="empty-state">{t('dashboard.noActivity')}</div>
) : (
<div className="activity-log">
{notifications.map(entry => (
<ActivityItem key={entry.id} entry={entry} />
))}
{minimax && minimax.data?.models?.length === 0 && (
<div className="dash-quota-row">
<span className="dash-quota-name">MiniMax</span>
<span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{minimax.error || 'no data'}</span>
</div>
)}
{zai && (
<div className="dash-quota-row">
<span className="dash-quota-name">Z.AI</span>
<span className="dash-quota-val">{zai.healthy ? '✓ active' : zai.error || '—'}</span>
</div>
)}
{!minimax && !zai && <span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>No providers</span>}
</div>
</div>
{/* Running Processes */}
<div className="dash-card">
<div className="dash-card-head">
<span className="dash-label">Running 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.slice(0, 8).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">
<div className="dash-card-head">
<span className="dash-label">Recent Commands</span>
</div>
<div className="dash-cmd-list">
{recentCmds.length === 0 && <span className="dash-empty">No history</span>}
{recentCmds.slice(0, 8).map((c, i) => (
<div key={i} className="dash-cmd-row" title={c.cmd}>
<span className="dash-cmd-shell">{c.shell}</span>
<span className="dash-cmd-text">{c.cmd.length > 45 ? c.cmd.slice(0, 42) + '...' : c.cmd}</span>
</div>
))}
</div>
</div>
{/* Services */}
<div className="dash-card">
<div className="dash-card-head">
<span className="dash-label">Services</span>
</div>
{dashboardStatus ? (
<div className="dash-services">
<div className="dash-svc-row">
<span className="dash-svc-name">MCP</span>
<span className="dash-svc-val">{dashboardStatus.mcp?.healthy || 0}/{dashboardStatus.mcp?.total || 0} healthy</span>
</div>
<div className="dash-svc-row">
<span className="dash-svc-name">LSP</span>
<span className="dash-svc-val">{dashboardStatus.lsp?.installed || 0}/{dashboardStatus.lsp?.total || 0} installed</span>
</div>
<div className="dash-svc-row">
<span className="dash-svc-name">Skills</span>
<span className="dash-svc-val">{dashboardStatus.skills?.total || 0} deployed</span>
</div>
{(dashboardStatus.skills?.issues || []).length > 0 && (
<div className="dash-svc-issues">
{(dashboardStatus.skills.issues || []).slice(0, 3).map((issue, i) => (
<div key={i} className="dash-svc-issue"> {issue}</div>
))}
</div>
)}
</div>
)}
{activeTab === 'actions' && (
<div className="dashboard-actions-panel">
<div className="dashboard-section-header">
<div className="dashboard-section-title">{t('dashboard.quickActions')}</div>
</div>
<div className="quick-actions-grid">
<QuickActionButton
icon="🔍"
label={t('dashboard.rescanSystem')}
onClick={handleRescan}
loading={scanLoading}
/>
<QuickActionButton
icon="📦"
label={t('dashboard.installMissing')}
onClick={handleInstallMissing}
loading={installing}
disabled={missingCount === 0}
/>
<QuickActionButton
icon="🔄"
label={t('dashboard.checkUpdates')}
onClick={handleCheckUpdates}
loading={loading}
/>
<QuickActionButton
icon="⚙"
label={t('dashboard.configureMCP')}
onClick={handleConfigureMCP}
loading={mcpLoading}
/>
</div>
{updates.length > 0 && (
<div className="dashboard-updates-section">
<div className="dashboard-section-header">
<div className="dashboard-section-title">{t('dashboard.updates')}</div>
<span className="badge warn">{updates.length}</span>
</div>
<div className="updates-list">
{updates.map((update, i) => (
<div key={update.name || i} className="update-row">
<div className="update-info">
<span className="update-name">{update.name || 'Unknown'}</span>
<span className="update-versions">
{update.current || update.version || '?'} {update.latest || update.target || '?'}
</span>
</div>
<button
className="sm"
onClick={() => api.runUpdate(update.name)}
disabled={loading}
>
{t('dashboard.update')}
</button>
</div>
))}
</div>
</div>
)}
</div>
)}
{activeTab === 'status' && (
<div className="dashboard-status-panel">
{dashboardStatus ? (
<>
<div className="dashboard-section-header">
<div className="dashboard-section-title">MCP Servers</div>
<span className="badge">{dashboardStatus.mcp?.healthy || 0}/{dashboardStatus.mcp?.total || 0} healthy</span>
</div>
<div className="tools-grid" style={{ marginBottom: 16 }}>
{(dashboardStatus.mcp?.servers || []).map((s, i) => (
<div key={i} className={`tool-card ${s.healthy ? 'installed' : s.installed ? '' : 'missing'}`}>
<div className="tool-card-info">
<div className="tool-card-name">{s.name}</div>
<div className="tool-card-version">
{s.healthy ? <span className="status-ok">healthy</span> :
s.installed ? <span className="status-missing">installed</span> :
<span className="status-missing">not found</span>}
</div>
</div>
</div>
))}
</div>
<div className="dashboard-section-header">
<div className="dashboard-section-title">LSP Servers</div>
<span className="badge">{dashboardStatus.lsp?.installed || 0}/{dashboardStatus.lsp?.total || 0} installed</span>
</div>
<div className="tools-grid" style={{ marginBottom: 16 }}>
{(dashboardStatus.lsp?.servers || []).filter(s => s.installed).map((s, i) => (
<div key={i} className="tool-card installed">
<div className="tool-card-info">
<div className="tool-card-name">{s.name}</div>
<div className="tool-card-version">
<span className="status-ok">{s.language}</span>
</div>
</div>
</div>
))}
</div>
<div className="dashboard-section-header">
<div className="dashboard-section-title">Skills</div>
<span className="badge">{dashboardStatus.skills?.total || 0} deployed</span>
{(dashboardStatus.skills?.issues || []).length > 0 && (
<span className="badge warn">{(dashboardStatus.skills.issues || []).length} issues</span>
)}
</div>
{(dashboardStatus.skills?.issues || []).length > 0 && (
<div style={{ fontSize: 12, color: 'var(--warning)', marginBottom: 8 }}>
{(dashboardStatus.skills.issues || []).map((issue, i) => (
<div key={i}>{issue}</div>
))}
</div>
)}
</>
) : (
<div className="empty-state">Loading status...</div>
)}
</div>
) : (
<span className="dash-empty">Loading...</span>
)}
</div>
</div>
)
}
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { Sparkles, ArrowRight, ArrowLeft, Search, Loader } from 'lucide-react'
import { useState, useEffect, useRef } from 'react'
import { Sparkles, ArrowRight, ArrowLeft, Loader } from 'lucide-react'
import { useI18n, LANGUAGES } from '../i18n'
import { getLayoutList } from '../i18n/keyboards'
@@ -32,6 +32,8 @@ export default function OnboardingWizard({ api, onComplete }) {
const [validating, setValidating] = useState(false)
const [keyValid, setKeyValid] = useState(false)
const [scanning, setScanning] = useState(false)
const [scanMessage, setScanMessage] = useState('')
const scanAbortRef = useRef(null)
const current = STEPS[step]
const layouts = getLayoutList()
@@ -50,7 +52,7 @@ export default function OnboardingWizard({ api, onComplete }) {
case 'name': return answers.name.trim().length > 0
case 'language': return !!answers.language
case 'keyboard': return !!answers.keyboard
case 'apikey': return true
case 'apikey': return keyValid && !scanning
case 'editor': return true
case 'done': return true
default: return true
@@ -61,14 +63,82 @@ export default function OnboardingWizard({ api, onComplete }) {
if (step > 0) setStep(step - 1)
}
const cycleOption = (key, list, dir) => {
const idx = list.findIndex(item => item.id === answers[key])
const next = (idx + dir + list.length) % list.length
setAnswers(a => ({ ...a, [key]: list[next].id }))
}
const cycleOptionEditor = (dir) => {
const idx = editorList.findIndex(ed => ed === answers.editor)
const next = (idx + dir + editorList.length) % editorList.length
setAnswers(a => ({ ...a, editor: editorList[next] }))
}
const handleScanViaChat = async (apikey) => {
setScanning(true)
setScanMessage('Recherche des éditeurs sur votre système...')
setError(null)
try {
const detected = []
const fallback = async () => {
setScanMessage('Utilisation du scan local...')
const data = await api.getEditors()
return (data.editors || []).map(e => e.name)
}
const prompt = 'Liste tous les éditeurs de texte et IDE installés sur ce système. Exécute les commandes nécessaires pour les détecter (which, command -v, etc.). Réponds UNIQUEMENT avec les noms séparés par des virgules, sans aucune autre explication. Exemples: vim, nvim, code, emacs, nano, helix, subl, zed'
const ctrl = new AbortController()
scanAbortRef.current = ctrl
const full = await api.sendChat(prompt, true, (text, data) => {
if (data.tool_call) setScanMessage('Exécution: ' + (data.tool_call.name || '...'))
else if (data.tool_result) setScanMessage('Analyse des résultats...')
else if (data.content) setScanMessage('Réception: ' + text.slice(0, 60) + (text.length > 60 ? '...' : ''))
}, ctrl.signal)
const names = full.split(/[,\n]/).map(s => s.replace(/[^a-zA-Z0-9._-]/g, '')).filter(Boolean)
if (names.length > 0) {
detected.push(...names)
} else {
detected.push(...(await fallback()))
}
setEditorList([...new Set(detected.map(n => n.toLowerCase()))])
setScanMessage('')
} catch (err) {
try {
setScanMessage('Fallback: scan local...')
const data = await api.getEditors()
const detected = (data.editors || []).map(e => e.name)
setEditorList([...new Set(detected)])
} catch {}
setScanMessage('')
}
setScanning(false)
}
useEffect(() => {
const handler = (e) => {
if (e.key === 'Escape') { goPrev(); return }
if (current.key === 'language') {
if (e.key === 'Tab' || e.key === 'ArrowRight') { e.preventDefault(); cycleOption('language', LANGUAGES, 1); return }
if (e.key === 'ArrowLeft') { e.preventDefault(); cycleOption('language', LANGUAGES, -1); return }
}
if (current.key === 'keyboard') {
if (e.key === 'Tab' || e.key === 'ArrowRight') { e.preventDefault(); cycleOption('keyboard', layouts, 1); return }
if (e.key === 'ArrowLeft') { e.preventDefault(); cycleOption('keyboard', layouts, -1); return }
}
if (current.key === 'editor') {
if (e.key === 'Tab' || e.key === 'ArrowRight') { e.preventDefault(); cycleOptionEditor(1); return }
if (e.key === 'ArrowLeft') { e.preventDefault(); cycleOptionEditor(-1); return }
}
if (e.key === 'Tab') { e.preventDefault(); const input = document.querySelector('.onboarding-input'); if (input) input.focus(); return }
if (e.key === 'Enter' && current.key !== 'done' && current.key !== 'editor') { e.preventDefault(); goNext() }
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [step, current])
}, [step, current, answers, editorList])
useEffect(() => {
return () => { if (scanAbortRef.current) scanAbortRef.current.abort() }
}, [])
useEffect(() => {
if (current.key === 'done' && !saving) {
@@ -88,6 +158,14 @@ export default function OnboardingWizard({ api, onComplete }) {
base_url: 'https://api.minimax.io/v1',
})
setKeyValid(true)
await api.saveProvider({
name: 'minimax',
api_key: answers.apikey,
model: 'MiniMax-M2.7',
base_url: 'https://api.minimax.io/v1',
active: true,
})
handleScanViaChat(answers.apikey)
} catch (err) {
setError(err.message || 'Clé invalide')
setKeyValid(false)
@@ -95,22 +173,7 @@ export default function OnboardingWizard({ api, onComplete }) {
setValidating(false)
}
const handleScanEditors = async () => {
setScanning(true)
setError(null)
try {
const data = await api.getEditors()
const detected = (data.editors || []).map(e => e.name)
const merged = [...new Set([...detected, ...BASE_EDITORS])]
setEditorList(merged)
if (detected.length === 0) {
setError('Aucun éditeur détecté')
}
} catch (err) {
setError(err.message || 'Erreur lors du scan')
}
setScanning(false)
}
const handleSave = async () => {
setSaving(true)
@@ -154,9 +217,10 @@ export default function OnboardingWizard({ api, onComplete }) {
</div>
<div className="onboarding-progress">
{STEPS.map((_, i) => (
<div key={i} className={`onboarding-dot ${i === step ? 'active' : ''} ${i < step ? 'done' : ''}`} />
))}
{STEPS.filter(s => s.key !== 'done').map(s => {
const i = STEPS.indexOf(s)
return <div key={s.key} className={`onboarding-dot ${i === step ? 'active' : ''} ${i < step ? 'done' : ''}`} />
})}
</div>
<div className="onboarding-body">
@@ -221,7 +285,7 @@ export default function OnboardingWizard({ api, onComplete }) {
<div className="onboarding-step">
<div className="onboarding-title">Clé API MiniMax</div>
<div className="onboarding-desc">
Entrez votre clé API MiniMax pour activer l'assistant IA. Vous pouvez passer cette étape et la configurer plus tard.
Entrez votre clé API MiniMax pour activer l'assistant IA. La clé est obligatoire pour continuer.
</div>
<input
className="onboarding-input"
@@ -232,7 +296,14 @@ export default function OnboardingWizard({ api, onComplete }) {
autoFocus
/>
{error && !keyValid && <div className="onboarding-required">{error}</div>}
{keyValid && <div className="onboarding-valid">Clé valide ✓</div>}
{keyValid && !scanning && <div className="onboarding-valid">Clé valide ✓ — Appuyez sur Entrée pour continuer</div>}
{scanning && (
<div className="onboarding-scanning">
<Loader size={14} className="spin-icon" />
<span>{scanMessage}</span>
</div>
)}
{requiredError && <div className="onboarding-required">Veuillez valider votre clé API pour continuer</div>}
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<button
className="sm primary"
@@ -241,16 +312,9 @@ export default function OnboardingWizard({ api, onComplete }) {
>
{validating ? 'Validation...' : 'Valider la clé'}
</button>
<button
className="sm ghost"
onClick={goNext}
disabled={!answers.apikey.trim()}
>
Passer
</button>
</div>
{answers.apikey.trim() && !keyValid && !error && (
<div className="onboarding-hint">Cliquez "Valider la clé" ou "Passer"</div>
{!keyValid && !error && answers.apikey.trim() && (
<div className="onboarding-hint">Entrez votre clé puis cliquez "Valider la clé"</div>
)}
</div>
)}
@@ -258,37 +322,20 @@ export default function OnboardingWizard({ api, onComplete }) {
{current.key === 'editor' && (
<div className="onboarding-step">
<div className="onboarding-title">Quel éditeur utilisez-vous ?</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className="onboarding-chips" style={{ flex: 1 }}>
{editorList.map(ed => (
<div
key={ed}
className={`chip ${answers.editor === ed ? 'active' : ''}`}
onClick={() => setAnswers(a => ({ ...a, editor: ed }))}
>
{ed}
</div>
))}
</div>
<button
className="sm ghost"
onClick={handleScanEditors}
disabled={scanning}
title="Détecter les éditeurs installés"
style={{ marginLeft: 8, flexShrink: 0 }}
>
{scanning ? <Loader size={14} className="spin-icon" /> : <Search size={14} />}
</button>
<div className="onboarding-desc">
{scanning ? 'Détection en cours...' : 'Sélectionnez votre éditeur.'}
</div>
<div className="onboarding-chips">
{editorList.map(ed => (
<div
key={ed}
className={`chip ${answers.editor === ed ? 'active' : ''}`}
onClick={() => setAnswers(a => ({ ...a, editor: ed }))}
>
{ed}
</div>
))}
</div>
<input
className="onboarding-input"
style={{ marginTop: 12 }}
placeholder="Autre éditeur..."
value={answers.editor}
onChange={e => setAnswers(a => ({ ...a, editor: e.target.value }))}
autoFocus
/>
{error && <div className="onboarding-required">{error}</div>}
</div>
)}
@@ -394,6 +441,10 @@ export default function OnboardingWizard({ api, onComplete }) {
.onboarding-hint {
font-size: 12px; color: var(--text-tertiary); margin-top: 4px;
}
.onboarding-scanning {
display: flex; align-items: center; gap: 8px;
font-size: 13px; color: var(--accent); margin-top: 4px;
}
.spin-icon {
animation: spin 1s linear infinite;
}

View File

@@ -169,6 +169,12 @@ input::placeholder { color: var(--text-disabled); }
color: var(--text-disabled);
}
.statusbar-left, .statusbar-right { display: flex; align-items: center; gap: 12px; }
.statusbar-sudo {
font-size: 10px; font-weight: 700; font-family: var(--font-mono);
padding: 1px 6px; border-radius: 3px;
background: rgba(239, 68, 68, 0.15); color: #ef4444; border: 1px solid rgba(239, 68, 68, 0.3);
text-transform: uppercase; letter-spacing: 0.5px;
}
.statusbar-shortcut { display: inline-flex; align-items: center; gap: 4px; }
.statusbar-shortcut kbd {
display: inline-block; padding: 1px 5px; border-radius: 3px;
@@ -525,10 +531,130 @@ input::placeholder { color: var(--text-disabled); }
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; color: var(--text-disabled); font-size: 13px; text-align: center; gap: 8px; }
/* ── Dashboard Grid ── */
.dash-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
padding: 16px;
height: 100%;
overflow: hidden;
}
.dash-card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 14px 16px;
display: flex; flex-direction: column; gap: 8px;
overflow: hidden;
}
.dash-span-2 { grid-column: span 2; }
.dash-card-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 4px;
}
.dash-label {
font-size: 11px; font-weight: 700; color: var(--accent);
text-transform: uppercase; letter-spacing: 0.5px;
}
.dash-count {
font-size: 10px; font-family: var(--font-mono);
background: var(--bg-input); padding: 1px 6px; border-radius: 10px;
}
.dash-count.warn { background: var(--accent-bg); color: var(--accent); }
/* Tools row */
.dash-tools-row {
display: flex; flex-wrap: wrap; gap: 6px;
}
.dash-tool-tag {
font-size: 11px; font-family: var(--font-mono);
padding: 3px 8px; border-radius: var(--radius);
background: var(--bg-surface);
}
.dash-tool-tag.ok { color: var(--success); }
.dash-tool-tag.missing { color: var(--error); }
/* Quota */
.dash-quota-list { display: flex; flex-direction: column; gap: 6px; }
.dash-quota-row { display: flex; align-items: center; gap: 8px; }
.dash-quota-name {
font-size: 11px; font-weight: 600; color: var(--text-primary);
min-width: 80px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.dash-bar {
flex: 1; height: 4px; background: var(--bg-input); border-radius: 2px; overflow: hidden;
}
.dash-bar-fill {
height: 100%; background: var(--accent); border-radius: 2px;
transition: width 0.3s;
}
.dash-quota-val {
font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary);
white-space: nowrap;
}
/* Processes */
.dash-proc-list { display: flex; flex-direction: column; gap: 4px; }
.dash-proc-row {
display: flex; justify-content: space-between; align-items: center;
padding: 4px 0;
}
.dash-proc-name {
font-size: 11px; font-weight: 600; color: var(--text-primary);
font-family: var(--font-mono);
}
.dash-proc-res {
font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary);
}
/* Commands */
.dash-cmd-list { display: flex; flex-direction: column; gap: 3px; }
.dash-cmd-row {
display: flex; align-items: center; gap: 6px;
padding: 3px 0; overflow: hidden;
}
.dash-cmd-shell {
font-size: 9px; font-family: var(--font-mono); color: var(--text-disabled);
background: var(--bg-input); padding: 1px 4px; border-radius: 3px;
text-transform: uppercase; flex-shrink: 0;
}
.dash-cmd-text {
font-size: 11px; font-family: var(--font-mono); color: var(--text-secondary);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* Services */
.dash-services { display: flex; flex-direction: column; gap: 6px; }
.dash-svc-row {
display: flex; justify-content: space-between; align-items: center;
padding: 4px 0;
}
.dash-svc-name { font-size: 12px; font-weight: 600; color: var(--text-primary); }
.dash-svc-val { font-size: 11px; font-family: var(--font-mono); color: var(--text-tertiary); }
.dash-svc-issues { margin-top: 4px; }
.dash-svc-issue { font-size: 10px; color: var(--warning); padding: 2px 0; }
/* Updates */
.dash-updates-list { display: flex; flex-direction: column; gap: 4px; }
.dash-update-row {
display: flex; justify-content: space-between; align-items: center;
}
.dash-update-name { font-size: 12px; font-weight: 600; color: var(--text-primary); }
.dash-update-ver { font-size: 11px; font-family: var(--font-mono); color: var(--text-tertiary); }
.dash-empty { font-size: 11px; color: var(--text-disabled); }
/* Graph */
.dash-graph-wrap { display: flex; flex-direction: column; gap: 2px; }
.dash-graph-header { display: flex; justify-content: space-between; align-items: center; }
.dash-graph-label { font-size: 9px; color: var(--text-disabled); text-transform: uppercase; }
.dash-graph-value { font-size: 10px; font-family: var(--font-mono); font-weight: 600; }
.dash-graph-svg { width: 100%; height: 32px; }
.dash-graph-empty { font-size: 10px; color: var(--text-disabled); text-align: center; padding: 8px 0; }
/* Legacy dashboard kept for reference */
.dashboard-layout { display: flex; flex-direction: column; height: 100%; }
.dashboard-content { flex: 1; overflow-y: auto; }
.dashboard-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; padding: 24px; }
.dashboard-section {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 20px; transition: border-color 0.2s;
@@ -540,11 +666,8 @@ input::placeholder { color: var(--text-disabled); }
font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase;
letter-spacing: 0.5px;
}
.dashboard-workflows-inline { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.dashboard-notifications-inline { display: flex; flex-direction: column; gap: 2px; }
.dashboard-notifications { padding: 0; }
.notif-row {
display: flex; align-items: flex-start; gap: 12px;
@@ -557,7 +680,6 @@ input::placeholder { color: var(--text-disabled); }
.notif-ok .notif-text { color: var(--success); }
.notif-warn .notif-text { color: var(--warning); }
.notif-error .notif-text { color: var(--error); }
.dashboard-workflows { padding: 0; display: flex; flex-direction: column; gap: 24px; }
.workflow-section { }
.section-label {
@@ -565,81 +687,6 @@ input::placeholder { color: var(--text-disabled); }
letter-spacing: 1px; margin-bottom: 12px; padding-bottom: 6px; border-bottom: 1px solid var(--border);
}
/* ── Dashboard Tabs ── */
.dashboard-tabs {
display: flex; gap: 4px; padding: 12px 20px 0;
border-bottom: 1px solid var(--border); background: var(--bg-surface); flex-shrink: 0;
}
.dashboard-tab {
padding: 8px 16px; border-radius: var(--radius) var(--radius) 0 0;
border: 1px solid transparent; border-bottom: none; background: transparent;
color: var(--text-tertiary); font-size: 12px; font-weight: 600; cursor: pointer;
display: flex; align-items: center; gap: 6px; transition: all 0.15s;
}
.dashboard-tab:hover { color: var(--text-primary); background: var(--bg-hover); }
.dashboard-tab.active { background: var(--bg-card); color: var(--accent); border-color: var(--border); }
.dashboard-tab .tab-icon { font-size: 14px; }
.dashboard-tab .tab-count {
background: var(--bg-input); padding: 1px 6px; border-radius: 10px; font-size: 10px; font-family: var(--font-mono);
}
.dashboard-tab .tab-count.warn { background: var(--accent-bg); color: var(--accent); }
.dashboard-tools-panel { padding: 20px 24px; }
.dashboard-tools-stats { display: flex; gap: 12px; font-size: 12px; }
.stat-ok { color: var(--success); font-family: var(--font-mono); }
.stat-missing { color: var(--error); font-family: var(--font-mono); }
.dashboard-system-info { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; font-size: 12px; color: var(--text-tertiary); }
.sys-info-item { font-family: var(--font-mono); }
.sys-info-sep { color: var(--text-disabled); }
.tools-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; margin-top: 8px; }
.tool-card {
background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg);
padding: 14px 16px; display: flex; align-items: center; gap: 12px; transition: border-color 0.2s;
}
.tool-card:hover { border-color: var(--accent-dim); }
.tool-card.installed { border-left: 3px solid var(--success); }
.tool-card.missing { border-left: 3px solid var(--error); }
.tool-card-icon { font-size: 20px; flex-shrink: 0; }
.tool-card-info { flex: 1; min-width: 0; }
.tool-card-name { font-weight: 600; font-size: 13px; color: var(--text-primary); margin-bottom: 2px; }
.tool-card-version { font-size: 11px; color: var(--text-tertiary); display: flex; align-items: center; gap: 6px; }
.tool-version-text { font-family: var(--font-mono); font-size: 10px; color: var(--text-disabled); }
.status-ok { color: var(--success); }
.status-missing { color: var(--error); }
.tool-card-actions { flex-shrink: 0; display: flex; align-items: center; gap: 6px; }
.tool-update-badge { background: var(--accent-bg); color: var(--accent); font-size: 10px; font-family: var(--font-mono); padding: 2px 6px; border-radius: 4px; cursor: pointer; }
.tool-update-badge:hover { background: var(--accent-dim); }
.dashboard-activity-panel { padding: 20px 24px; }
.activity-log { display: flex; flex-direction: column; gap: 2px; }
.notif-icon { font-size: 12px; width: 16px; text-align: center; }
.dashboard-actions-panel { padding: 20px 24px; }
.quick-actions-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; margin-bottom: 24px; }
.quick-action-btn {
background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg);
padding: 16px 20px; display: flex; align-items: center; gap: 12px; cursor: pointer;
transition: all 0.2s; font-size: 13px; color: var(--text-secondary);
}
.quick-action-btn:hover:not(:disabled) { border-color: var(--accent-dim); background: var(--bg-hover); color: var(--text-primary); }
.quick-action-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.quick-action-icon { font-size: 18px; }
.quick-action-label { font-weight: 600; }
.dashboard-updates-section { margin-top: 16px; }
.updates-list { display: flex; flex-direction: column; gap: 6px; }
.update-row {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px; border-radius: var(--radius); background: var(--bg-card);
border: 1px solid var(--border);
}
.update-row:hover { border-color: var(--accent-dim); }
.update-info { display: flex; align-items: center; gap: 16px; }
.update-name { font-weight: 600; color: var(--text-primary); font-size: 13px; min-width: 100px; }
.update-versions { color: var(--text-tertiary); font-size: 12px; font-family: var(--font-mono); }
.panel-header {
display: flex; align-items: center; justify-content: space-between; padding: 10px 16px;
border-bottom: 1px solid var(--border); background: var(--bg-surface);