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>
This commit is contained in:
Augustin
2026-04-23 19:24:23 +02:00
parent 3948a4c656
commit 7682717093
6 changed files with 592 additions and 537 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"
@@ -415,3 +421,191 @@ 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})
}

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,9 @@ 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)
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {

View File

@@ -37,6 +37,9 @@ 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'),
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,438 +1,181 @@
import { useState, useEffect, useCallback } 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
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>
</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 }) {
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 [updates, setUpdates] = useState([])
const loadData = useCallback(async () => {
try {
const [toolsData, updatesData, systemData] = await Promise.all([
const [toolsData, systemData, dashData, quotaData, cmdData, procData, updatesData] = await Promise.all([
api.getTools().catch(() => ({ tools: [] })),
api.getUpdates().catch(() => ({ updates: [] })),
api.getSystem().catch(() => null),
api.getDashboardStatus().catch(() => null),
api.getProvidersQuota().catch(() => null),
api.getRecentCommands().catch(() => ({ commands: [] })),
api.getRunningProcesses().catch(() => ({ processes: [] })),
api.getUpdates().catch(() => ({ updates: [] })),
])
setTools(toolsData.tools || toolsData || [])
setSystemInfo(systemData?.system || systemData)
setDashboardStatus(dashData)
setQuota(quotaData?.providers || [])
setRecentCmds(cmdData.commands || [])
setProcesses(procData.processes || [])
setUpdates(updatesData.updates || updatesData || [])
setSystemInfo(systemData)
api.getDashboardStatus().then(d => setDashboardStatus(d)).catch(() => {})
} catch (err) {
console.error('Failed to load dashboard data:', err)
console.error('Dashboard load error:', err)
}
}, [api])
useEffect(() => {
loadData()
}, [loadData])
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)
}
}
useEffect(() => { loadData() }, [loadData])
const installedCount = tools.filter(t => t.installed || t.status === 'installed').length
const missingCount = tools.length - installedCount
const sys = systemInfo || {}
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">
{/* System */}
<div className="dash-card dash-span-2">
<div className="dash-card-head">
<span className="dash-label">{sys.os || sys.platform || 'System'} · {sys.arch || ''}</span>
<button className="sm ghost" onClick={() => api.runScan().then(loadData)}> Rescan</button>
</div>
<div className="dash-tools-row">
{tools.slice(0, 12).map((tool, i) => {
const ok = tool.installed || tool.status === 'installed'
return (
<span key={tool.name || i} className={`dash-tool-tag ${ok ? 'ok' : 'missing'}`}>
{ok ? '●' : '○'} {tool.name}
</span>
)
})}
{tools.length > 12 && <span className="dash-tool-tag">+{tools.length - 12}</span>}
</div>
</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>}
{/* 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>
{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}
/>
))}
))}
{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>
</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>
{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>
{/* 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>
{notifications.length === 0 ? (
<div className="empty-state">{t('dashboard.noActivity')}</div>
) : (
<div className="activity-log">
{notifications.map(entry => (
<ActivityItem key={entry.id} entry={entry} />
))}
</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>
{/* Status (MCP/LSP/Skills) */}
<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>
{/* Updates */}
{updates.length > 0 && (
<div className="dash-card dash-span-2">
<div className="dash-card-head">
<span className="dash-label">Updates Available</span>
<span className="dash-count warn">{updates.length}</span>
</div>
<div className="dash-updates-list">
{updates.slice(0, 5).map((u, i) => (
<div key={u.name || i} className="dash-update-row">
<span className="dash-update-name">{u.name}</span>
<span className="dash-update-ver">{u.current || '?'} {u.latest || '?'}</span>
</div>
))}
</div>
</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,84 @@ 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()))
}
const merged = [...new Set([...detected.map(n => n.toLowerCase()), ...BASE_EDITORS])]
setEditorList(merged)
setScanMessage('')
} catch (err) {
try {
setScanMessage('Fallback: scan local...')
const data = await api.getEditors()
const detected = (data.editors || []).map(e => e.name)
const merged = [...new Set([...detected, ...BASE_EDITORS])]
setEditorList(merged)
} 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 +160,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 +175,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 +219,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 +287,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 +298,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 +314,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,27 +324,19 @@ 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 ou tapez-en un autre ci-dessous.'}
</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"
@@ -288,7 +346,6 @@ export default function OnboardingWizard({ api, onComplete }) {
onChange={e => setAnswers(a => ({ ...a, editor: e.target.value }))}
autoFocus
/>
{error && <div className="onboarding-required">{error}</div>}
</div>
)}
@@ -394,6 +451,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

@@ -525,10 +525,122 @@ 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); }
/* 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 +652,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 +666,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 +673,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);