Files
momentry_studio/src/views/AdminView.vue
Momentry Studio 1bc53ae584 fix: AdminView 保存配置前先加载现有数据
- 避免空对象覆盖数据库中的现有配置
- 保存前先 GET 当前配置
- 合并修改的字段后再 POST
- 确保所有字段都正确保存
2026-10-02 05:42:31 +08:00

1397 lines
67 KiB
Vue

<template>
<div id="ms-view-admin">
<div class="admin-header">
<h1>{{ t('admin.title') }}</h1>
<div class="admin-tabs">
<button class="admin-tab" :class="{ active: activeTab === 'dashboard' }" @click="activeTab = 'dashboard'">{{ t('admin.tab.dashboard') }}</button>
<button class="admin-tab" :class="{ active: activeTab === 'users' }" @click="activeTab = 'users'">{{ t('admin.tab.users') }}</button>
<button class="admin-tab" :class="{ active: activeTab === 'shares' }" @click="activeTab = 'shares'">{{ t('admin.tab.shares') }}</button>
<button class="admin-tab" :class="{ active: activeTab === 'config' }" @click="activeTab = 'config'">{{ t('admin.tab.config') }}</button>
<button class="admin-tab" :class="{ active: activeTab === 'settings' }" @click="activeTab = 'settings'">{{ t('admin.tab.settings') }}</button>
</div>
</div>
<div class="admin-content">
<div v-if="activeTab === 'dashboard'" class="dashboard-panel">
<div class="system-resources">
<h3>{{ t('admin.section.systemResources') }} <span class="source-badge">{{ systemStats.source }}</span></h3>
<div v-if="systemStats.chip_name" class="chip-info">{{ systemStats.chip_name }}</div>
<div class="resource-row">
<span class="resource-label">{{ t('admin.resource.cpu') }}</span>
<div class="resource-bar-container">
<div class="resource-bar" :class="getBarClass(systemStats.cpu_used_percent)" :style="{ width: systemStats.cpu_used_percent + '%' }"></div>
</div>
<span class="resource-value">{{ systemStats.cpu_used_percent.toFixed(1) }}%</span>
<span v-if="systemStats.cpu_power" class="resource-detail power">{{ systemStats.cpu_power.toFixed(1) }}W</span>
<span v-if="systemStats.cpu_temp" class="resource-detail">{{ systemStats.cpu_temp.toFixed(1) }}°C</span>
</div>
<div v-if="systemStats.pcpu_freq_mhz" class="resource-row sub">
<span class="resource-label">P-Core</span>
<span class="resource-value">{{ systemStats.pcpu_freq_mhz }} MHz</span>
<span v-if="systemStats.pcpu_cores" class="resource-detail">{{ systemStats.pcpu_cores }} cores</span>
</div>
<div v-if="systemStats.ecpu_freq_mhz" class="resource-row sub">
<span class="resource-label">E-Core</span>
<span class="resource-value">{{ systemStats.ecpu_freq_mhz }} MHz</span>
<span v-if="systemStats.ecpu_cores" class="resource-detail">{{ systemStats.ecpu_cores }} cores</span>
</div>
<div class="resource-row">
<span class="resource-label">{{ t('admin.resource.memory') }}</span>
<div class="resource-bar-container">
<div class="resource-bar" :class="getBarClass(systemStats.memory_used_percent)" :style="{ width: systemStats.memory_used_percent + '%' }"></div>
</div>
<span class="resource-value">{{ systemStats.memory_used_percent.toFixed(1) }}%</span>
<span class="resource-detail">{{ formatMemory(systemStats.memory_available_mb) }} / {{ formatMemory(systemStats.memory_total_mb) }}</span>
<span v-if="systemStats.ram_power" class="resource-detail power">{{ systemStats.ram_power.toFixed(1) }}W</span>
</div>
<div v-if="systemStats.swap_total_mb > 0" class="resource-row">
<span class="resource-label">{{ t('admin.resource.swap') }}</span>
<div class="resource-bar-container resource-bar-container--scaled" :style="{ '--swap-scale': swapScale() }">
<div class="resource-bar" :class="getBarClass(systemStats.swap_used_percent)" :style="{ width: systemStats.swap_used_percent + '%' }"></div>
</div>
<span class="resource-value">{{ systemStats.swap_used_percent.toFixed(1) }}%</span>
<span class="resource-detail">{{ formatMemory(systemStats.swap_available_mb) }} / {{ formatMemory(systemStats.swap_total_mb) }}</span>
<span class="resource-detail swap-scale-hint">({{ swapScaleHint() }})</span>
</div>
<div class="resource-row">
<span class="resource-label">{{ t('admin.resource.gpu') }}</span>
<div class="resource-bar-container">
<div class="resource-bar" :class="getBarClass(systemStats.gpu_utilization || 0)" :style="{ width: (systemStats.gpu_utilization || 0) + '%' }"></div>
</div>
<span class="resource-value">{{ (systemStats.gpu_utilization || 0).toFixed(1) }}%</span>
<span v-if="systemStats.gpu_power" class="resource-detail power">{{ systemStats.gpu_power.toFixed(1) }}W</span>
<span v-if="systemStats.gpu_temp" class="resource-detail">{{ systemStats.gpu_temp.toFixed(1) }}°C</span>
<span v-if="!systemStats.gpu_power && systemStats.gpu_memory_used_pct" class="resource-detail">VRAM {{ (systemStats.gpu_memory_used_pct || 0).toFixed(1) }}%</span>
</div>
<div v-if="systemStats.gpu_freq_mhz" class="resource-row sub">
<span class="resource-label">{{ t('admin.resource.gpuFreq') }}</span>
<span class="resource-value">{{ systemStats.gpu_freq_mhz }} MHz</span>
<span v-if="systemStats.gpu_cores" class="resource-detail">{{ systemStats.gpu_cores }} cores</span>
</div>
<div class="resource-row">
<span class="resource-label">ANE 🧠</span>
<div class="resource-bar-container">
<div class="resource-bar" :class="getBarClass(systemStats.ane_utilization || 0)" :style="{ width: (systemStats.ane_utilization || 0) + '%' }"></div>
</div>
<span class="resource-value">{{ (systemStats.ane_utilization || 0).toFixed(1) }}%</span>
<span v-if="systemStats.ane_power" class="resource-detail power">{{ systemStats.ane_power.toFixed(2) }}W</span>
<span v-if="!systemStats.ane_available && !systemStats.ane_power" class="resource-detail">N/A</span>
</div>
<div v-if="systemStats.sys_power" class="resource-row total">
<span class="resource-label">{{ t('admin.resource.systemPower') }}</span>
<span class="resource-value power-large">{{ systemStats.sys_power.toFixed(1) }}W</span>
</div>
</div>
<div class="refresh-setting">
<label>Refresh:</label>
<select v-model="refreshIntervalSec">
<option :value="2">2s</option>
<option :value="5">5s</option>
<option :value="10">10s</option>
<option :value="30">30s</option>
</select>
</div>
<div class="validation-section">
<div class="validation-header">
<h3>{{ t('admin.section.validation') }}</h3>
<button class="validation-btn" @click="runValidation" :disabled="validationLoading">
{{ validationLoading ? 'Running...' : '▶ Run Validation' }}
</button>
</div>
<div v-if="validationResult" class="validation-result">
<div class="validation-summary">
<span class="validation-summary-total">{{ validationResult.overall_summary.total }} metrics</span>
<span class="validation-summary-passed">{{ validationResult.overall_summary.passed }} passed</span>
<span class="validation-summary-failed">{{ validationResult.overall_summary.failed }} failed</span>
<span class="validation-summary-unverified">{{ validationResult.overall_summary.unverified }} unverified</span>
</div>
<div v-if="validationResult.layers.layer1" class="validation-layer">
<div class="validation-layer-title">Layer 1: Activity Monitor</div>
<div v-for="(metric, key) in validationResult.layers.layer1.metrics" :key="key" class="validation-metric">
<span class="validation-metric-name">{{ formatMetricName(String(key)) }}</span>
<span :class="['validation-metric-status', metric.status.toLowerCase()]">{{ metric.status }}</span>
<span class="validation-metric-value">{{ metric.macmon_value.toFixed(2) }}</span>
</div>
</div>
<div v-if="validationResult.layers.layer2" class="validation-layer">
<div class="validation-layer-title">Layer 2: System API</div>
<div v-for="(metric, key) in validationResult.layers.layer2.metrics" :key="key" class="validation-metric">
<span class="validation-metric-name">{{ formatMetricName(String(key)) }}</span>
<span :class="['validation-metric-status', metric.status.toLowerCase()]">{{ metric.status }}</span>
<span class="validation-metric-value">{{ metric.macmon_value.toFixed(2) }}</span>
</div>
</div>
</div>
</div>
<div class="stats-grid">
<div class="stat-card"><div class="stat-label">Users</div><div class="stat-value">{{ users.length }}</div></div>
<div class="stat-card"><div class="stat-label">Shares</div><div class="stat-value">{{ shares.length }}</div></div>
</div>
</div>
<div v-if="activeTab === 'users'" class="users-panel">
<table class="admin-table">
<thead><tr><th>{{ t('admin.table.username') }}</th><th>{{ t('admin.table.homeDir') }}</th><th>{{ t('admin.table.status') }}</th></tr></thead>
<tbody>
<tr v-for="u in users" :key="u.username">
<td>{{ u.username }}</td><td>{{ u.homeDir }}</td><td>{{ u.status === 1 ? 'Active' : 'Disabled' }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="activeTab === 'shares'" class="shares-panel">
<table class="admin-table">
<thead><tr><th>{{ t('admin.table.name') }}</th><th>{{ t('admin.table.port') }}</th><th>{{ t('admin.table.status') }}</th></tr></thead>
<tbody>
<tr v-for="s in shares" :key="s.name">
<td>{{ s.name }}</td><td>{{ s.port }}</td><td>{{ s.status }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="activeTab === 'config'" class="config-panel">
<div class="config-header">
<h2>{{ t('admin.section.configuration') }}</h2>
<div class="config-actions">
<button class="config-btn" @click="loadConfig" :disabled="configLoading">{{ configLoading ? 'Loading...' : '↻ Reload' }}</button>
<button class="config-btn config-btn-save" @click="saveConfig" :disabled="configSaving">{{ configSaving ? 'Saving...' : '💾 Save' }}</button>
</div>
</div>
<div v-if="configError" class="config-error">{{ configError }}</div>
<div v-if="configSaveMsg" :class="['config-msg', configSaveOk ? 'ok' : 'error']">{{ configSaveMsg }}</div>
<!-- VLM Prompts Section -->
<div class="config-section">
<h3 class="config-section-title" @click="toggleSection('vlm')">
<span class="config-section-arrow">{{ expandedSections.vlm ? '▼' : '▶' }}</span>
{{ t('admin.section.vlmPrompts') }} ({{ Object.keys(vlmPrompts).length }})
</h3>
<div v-if="expandedSections.vlm" class="config-section-content">
<div v-for="(prompt, key) in vlmPrompts" :key="key" class="config-prompt-card">
<div class="config-prompt-header">
<span class="config-prompt-key">{{ key }}</span>
<span class="config-prompt-desc">{{ prompt.description }}</span>
</div>
<div class="config-prompt-fields">
<div v-if="prompt.system_prompt !== undefined" class="config-field">
<label>System Prompt</label>
<textarea class="config-textarea" v-model="prompt.system_prompt" rows="3"></textarea>
</div>
<div v-if="prompt.prompt !== undefined" class="config-field">
<label>Prompt</label>
<textarea class="config-textarea" v-model="prompt.prompt" rows="3"></textarea>
</div>
<div v-if="prompt.user_prompt !== undefined" class="config-field">
<label>User Prompt</label>
<input class="config-input" v-model="prompt.user_prompt" />
</div>
<div class="config-field config-field-inline">
<label>Max Tokens</label>
<input class="config-input-small" type="number" min="1" :value="prompt.max_tokens" @input="prompt.max_tokens = Math.max(1, parseInt(($event.target as HTMLInputElement).value) || 1)" />
</div>
</div>
<!-- Language Pack Section -->
<div class="config-section">
<h3 class="config-section-title" @click="toggleSection('lang')">
<span class="config-section-arrow">{{ expandedSections.lang ? '▼' : '▶' }}</span>
{{ t('admin.section.languagePacks') }}
</h3>
<div v-if="expandedSections.lang" class="config-section-content">
<div class="config-field">
<label>{{ t('admin.config.availableLanguages') }}</label>
<div class="lang-list">
<div v-for="(lang, code) in availableLanguages" :key="code" class="lang-item">
<span class="lang-code">{{ code }}</span>
<span class="lang-name">{{ lang }}</span>
<button class="lang-export-btn" @click="exportLanguagePack(code as string)" title="Export">
📥 Export
</button>
</div>
</div>
</div>
<div class="config-field">
<label>{{ t('admin.config.importLanguagePack') }}</label>
<div class="lang-import">
<input type="file" accept=".json" @change="importLanguagePack" ref="langFileInput" style="display: none" />
<button class="lang-import-btn" @click="triggerImport">
📤 Import JSON
</button>
<span class="lang-import-hint">Upload a language pack JSON file (en.json, zh-TW.json, ja.json)</span>
</div>
</div>
<div v-if="langPackMsg" :class="['lang-pack-msg', langPackOk ? 'lang-pack-ok' : 'lang-pack-error']">
{{ langPackMsg }}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- LLM Config Section -->
<div class="config-section">
<h3 class="config-section-title" @click="toggleSection('llm')">
<span class="config-section-arrow">{{ expandedSections.llm ? '▼' : '▶' }}</span>
{{ t('admin.section.llmConfig') }}
</h3>
<div v-if="expandedSections.llm" class="config-section-content">
<div v-for="(cfg, key) in llmConfig" :key="key" class="config-llm-card">
<div class="config-llm-name">{{ key }}</div>
<div class="config-field">
<label>{{ t('admin.config.url') }}</label>
<input class="config-input" v-model="cfg.url" />
</div>
<div class="config-field config-field-inline">
<label>{{ t('admin.config.model') }}</label>
<input class="config-input" v-model="cfg.model" />
</div>
<div class="config-field config-field-inline">
<label>Timeout (s)</label>
<input class="config-input-small" type="number" min="0" :value="cfg.timeout" @input="cfg.timeout = Math.max(0, parseFloat(($event.target as HTMLInputElement).value) || 0)" />
</div>
</div>
</div>
</div>
<!-- Processor Config Section -->
<div class="config-section">
<h3 class="config-section-title" @click="toggleSection('proc')">
<span class="config-section-arrow">{{ expandedSections.proc ? '▼' : '▶' }}</span>
{{ t('admin.section.processorConfig') }}
</h3>
<div v-if="expandedSections.proc" class="config-section-content">
<div v-for="(cfg, key) in processorConfig" :key="key" class="config-processor-card">
<div class="config-processor-header">
<span class="config-processor-name">{{ key }}</span>
<label class="config-processor-enabled">
<input type="checkbox" v-model="cfg.enabled" />
Enabled
</label>
</div>
<div v-if="cfg.default_tool" class="config-processor-default">
Default Tool: <strong>{{ cfg.default_tool }}</strong>
</div>
<div v-if="cfg.timeout" class="config-processor-timeout">
Timeout: <strong>{{ cfg.timeout }}s</strong>
</div>
<div v-if="cfg.tools && cfg.tools.length" class="config-tools-section">
<div class="config-tools-title">{{ t('admin.config.tools') }}</div>
<div v-for="(tool, idx) in cfg.tools" :key="idx" class="config-tool-card">
<div class="config-tool-header">
<span class="config-tool-name">{{ tool.name }}</span>
<label class="config-tool-enabled">
<input type="checkbox" v-model="tool.enabled" />
Enabled
</label>
<span class="config-tool-priority">Priority: {{ tool.priority }}</span>
</div>
<div v-if="tool.parameters && Object.keys(tool.parameters).length" class="config-tool-params">
<div v-for="(val, paramKey) in tool.parameters" :key="paramKey" class="config-tool-param">
<label>{{ paramKey }}</label>
<input
class="config-input-small"
:type="typeof val === 'number' ? 'number' : 'text'"
:value="val"
@input="onToolParamChange(tool, String(paramKey), ($event.target as HTMLInputElement).value)"
:min="typeof val === 'number' ? 0 : undefined"
/>
</div>
</div>
</div>
</div>
<div v-if="hasTopLevelParams(cfg)" class="config-processor-params">
<div v-for="param in getProcParams(cfg)" :key="param.key" class="config-proc-param">
<label>{{ param.key }}</label>
<input
class="config-input-small"
:value="cfg[param.key]"
@input="onProcParamChange(cfg, param.key, ($event.target as HTMLInputElement).value)"
:type="param.isNumber ? 'number' : 'text'"
:min="param.isNumber ? 0 : undefined"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Filter Config Section -->
<div class="config-section">
<h3 class="config-section-title" @click="toggleSection('filter')">
<span class="config-section-arrow">{{ expandedSections.filter ? '▼' : '▶' }}</span>
{{ t('admin.section.filterConfig') }}
</h3>
<div v-if="expandedSections.filter" class="config-section-content">
<table class="config-proc-table">
<thead><tr><th>{{ t('admin.table.filter') }}</th><th>{{ t('admin.table.parameters') }}</th></tr></thead>
<tbody>
<tr v-for="(cfg, key) in filterConfig" :key="key">
<td class="config-proc-name">{{ key }}</td>
<td>
<div v-for="param in getProcParams(cfg)" :key="param.key" class="config-proc-param">
<label>{{ param.key }}</label>
<input
class="config-input-small"
:value="cfg[param.key]"
@input="onProcParamChange(cfg, param.key, ($event.target as HTMLInputElement).value)"
:type="param.isNumber ? 'number' : 'text'"
:min="param.isNumber ? 0 : undefined"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Search Config Section -->
<div class="config-section">
<h3 class="config-section-title" @click="toggleSection('search')">
<span class="config-section-arrow">{{ expandedSections.search ? '▼' : '▶' }}</span>
{{ t('admin.section.searchConfig') }}
</h3>
<div v-if="expandedSections.search" class="config-section-content">
<div class="config-field config-field-inline">
<label>{{ t('admin.vlm.minScore') }}</label>
<input
class="config-input"
type="number"
step="0.05"
min="0.0"
max="1.0"
v-model.number="searchConfig.vlm_min_score"
/>
<span class="config-hint">{{ t('admin.vlm.hint.minScore') }}</span>
</div>
<div class="config-field config-field-inline">
<label>{{ t('admin.vlm.maxResults') }}</label>
<input
class="config-input"
type="number"
step="1"
min="1"
max="100"
v-model.number="searchConfig.vlm_max_results"
/>
<span class="config-hint">{{ t('admin.vlm.hint.maxResults') }}</span>
</div>
<div class="config-field config-field-inline">
<label>{{ t('admin.vlm.outputLanguage') }}</label>
<select class="config-input" v-model="searchConfig.vlm_output_language">
<option value="en">English (Default)</option>
<option value="zh-TW">繁體中文</option>
<option value="ja">日本語</option>
</select>
<span class="config-hint">{{ t('admin.vlm.hint.outputLanguage') }}</span>
</div>
</div>
</div>
<!-- System Config Section -->
<div class="config-section">
<h3 class="config-section-title" @click="toggleSection('sys')">
<span class="config-section-arrow">{{ expandedSections.sys ? '▼' : '▶' }}</span>
{{ t('admin.section.systemConfig') }}
</h3>
<div v-if="expandedSections.sys" class="config-section-content">
<div v-for="(val, key) in systemConfig" :key="key" class="config-field config-field-inline">
<label>{{ key }}</label>
<input
class="config-input"
:type="typeof val === 'number' ? 'number' : 'text'"
:min="typeof val === 'number' ? 0 : undefined"
:value="val"
@input="systemConfig[key] = typeof val === 'number' ? Math.max(0, parseFloat(($event.target as HTMLInputElement).value) || 0) : ($event.target as HTMLInputElement).value"
/>
</div>
</div>
</div>
</div>
<div v-if="activeTab === 'settings'" class="settings-panel">
<div v-if="settingsError" class="settings-error">{{ settingsError }}</div>
<div class="settings-group">
<h3>{{ t('admin.section.uploadPath') }}</h3>
<p class="settings-desc">Base directory where uploaded files are stored (appended with /&lt;username&gt;).</p>
<div class="settings-row">
<input class="settings-input" v-model="uploadPath" placeholder="/Users/accusys/momentry/var/sftpgo/data" />
<button class="settings-save-btn" @click="saveUploadPath" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
<div v-if="saveMessage" :class="['settings-msg', saveError ? 'error' : 'ok']">{{ saveMessage }}</div>
</div>
<div class="settings-group">
<h3>{{ t('admin.section.serviceUrls') }}</h3>
<p class="settings-desc">Configure backend service endpoints.</p>
<div class="service-card">
<div class="service-header">
<span class="service-name">Core API</span>
<span :class="['service-status', coreStatus]">{{ coreStatus }}</span>
</div>
<div class="service-row">
<input class="settings-input" v-model="coreApiUrl" placeholder="http://localhost:3002" />
<button class="settings-test-btn" @click="testCoreApi" :disabled="testingCore">{{ testingCore ? 'Testing...' : 'Test' }}</button>
</div>
<div class="service-actions">
<button class="service-stop-btn" @click="stopService('core')" :disabled="coreActionLoading">Stop</button>
<button class="service-start-btn" @click="startService('core')" :disabled="coreActionLoading">Start</button>
<span v-if="coreActionLoading" class="action-loading">Processing...</span>
<span v-if="coreActionResult" :class="['action-result', coreActionResultOk ? 'ok' : 'error']">{{ coreActionResult }}</span>
</div>
<div v-if="coreInfo" class="service-info">
<span>Version: {{ coreInfo.version || 'N/A' }}</span>
<span>Uptime: {{ formatUptime(coreInfo.uptime) }}</span>
</div>
</div>
<div class="service-card">
<div class="service-header">
<span class="service-name">MarkBase</span>
<span :class="['service-status', mbStatus]">{{ mbStatus }}</span>
</div>
<div class="service-row">
<input class="settings-input" v-model="markbaseUrl" placeholder="http://localhost:11438" />
<button class="settings-test-btn" @click="testMarkbase" :disabled="testingMb">{{ testingMb ? 'Testing...' : 'Test' }}</button>
</div>
<div class="service-actions">
<button class="service-stop-btn" @click="stopService('markbase')" :disabled="mbActionLoading">Stop</button>
<button class="service-start-btn" @click="startService('markbase')" :disabled="mbActionLoading">Start</button>
<span v-if="mbActionLoading" class="action-loading">Processing...</span>
<span v-if="mbActionResult" :class="['action-result', mbActionResultOk ? 'ok' : 'error']">{{ mbActionResult }}</span>
</div>
<div v-if="mbInfo" class="service-info">
<span>Version: {{ mbInfo.version || 'N/A' }}</span>
<span>Uptime: {{ formatUptime(mbInfo.uptime) }}</span>
</div>
</div>
<div class="service-card">
<div class="service-header">
<span class="service-name">MarkBaseEngine</span>
<span :class="['service-status', mbeStatus]">{{ mbeStatus }}</span>
</div>
<div class="service-row">
<input class="settings-input" v-model="mbeUrl" placeholder="http://localhost:8080" />
<button class="settings-test-btn" @click="testMbe" :disabled="testingMbe">{{ testingMbe ? 'Testing...' : 'Test' }}</button>
</div>
<div class="service-actions">
<button class="service-stop-btn" @click="stopService('mbe')" :disabled="mbeActionLoading">Stop</button>
<button class="service-start-btn" @click="startService('mbe')" :disabled="mbeActionLoading">Start</button>
<span v-if="mbeActionLoading" class="action-loading">Processing...</span>
<span v-if="mbeActionResult" :class="['action-result', mbeActionResultOk ? 'ok' : 'error']">{{ mbeActionResult }}</span>
</div>
<div v-if="mbeInfo" class="service-info">
<span>Version: {{ mbeInfo.version || 'N/A' }}</span>
<span>Uptime: {{ formatUptime(mbeInfo.uptime) }}</span>
</div>
</div>
<div class="service-card">
<div class="service-header">
<span class="service-name">Proxy</span>
<span :class="['service-status', proxyStatus]">{{ proxyStatus }}</span>
</div>
<div class="service-row">
<input class="settings-input" v-model="proxyUrl" placeholder="http://localhost:8888" />
<button class="settings-test-btn" @click="testProxy" :disabled="testingProxy">{{ testingProxy ? 'Testing...' : 'Test' }}</button>
</div>
<div class="service-actions">
<button class="service-stop-btn" @click="stopService('proxy')" :disabled="proxyActionLoading">Stop</button>
<button class="service-start-btn" @click="startService('proxy')" :disabled="proxyActionLoading">Start</button>
<span v-if="proxyActionLoading" class="action-loading">Processing...</span>
<span v-if="proxyActionResult" :class="['action-result', proxyActionResultOk ? 'ok' : 'error']">{{ proxyActionResult }}</span>
</div>
</div>
<div class="service-card">
<div class="service-header">
<span class="service-name">Worker</span>
<span :class="['service-status', workerStatus]">{{ workerStatus }}</span>
</div>
<div class="service-row">
<input class="settings-input" v-model="workerUrl" placeholder="http://localhost:11438" />
<button class="settings-test-btn" @click="testWorker" :disabled="testingWorker">{{ testingWorker ? 'Testing...' : 'Test' }}</button>
</div>
<div class="service-actions">
<button class="service-stop-btn" @click="stopService('worker')" :disabled="workerActionLoading">Stop</button>
<button class="service-start-btn" @click="startService('worker')" :disabled="workerActionLoading">Start</button>
<span v-if="workerActionLoading" class="action-loading">Processing...</span>
<span v-if="workerActionResult" :class="['action-result', workerActionResultOk ? 'ok' : 'error']">{{ workerActionResult }}</span>
</div>
<div v-if="workerInfo" class="service-info">
<span>Version: {{ workerInfo.version || 'N/A' }}</span>
<span>Uptime: {{ formatUptime(workerInfo.uptime) }}</span>
</div>
</div>
<button class="settings-save-btn" @click="saveServiceUrls" style="margin-top: 12px;">Save URLs</button>
<div v-if="urlSaveMsg" :class="['settings-msg', urlSaveError ? 'error' : 'ok']">{{ urlSaveMsg }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import { apiCall } from '../api'
import { loadSystemStats, systemStatsCache, SystemStats } from '../store'
const activeTab = ref('dashboard')
const stats = ref({ cpu: 0, memory: 0, disk: 0 })
const users = ref<any[]>([])
const shares = ref<any[]>([])
const systemStats = ref<SystemStats>({
cpu_used_percent: 0,
memory_used_percent: 0,
memory_available_mb: 0,
memory_total_mb: 0,
swap_used_percent: 0,
swap_available_mb: 0,
swap_total_mb: 0,
gpu_available: false,
gpu_utilization: null,
gpu_memory_used_pct: null,
ane_available: false,
ane_utilization: null,
source: 'loading',
chip_name: undefined,
cpu_power: undefined,
gpu_power: undefined,
ane_power: undefined,
sys_power: undefined,
ram_power: undefined,
cpu_temp: undefined,
gpu_temp: undefined,
ecpu_freq_mhz: undefined,
pcpu_freq_mhz: undefined,
gpu_freq_mhz: undefined,
ecpu_cores: undefined,
pcpu_cores: undefined,
gpu_cores: undefined,
})
const refreshIntervalSec = ref(5)
let refreshTimer: ReturnType<typeof setInterval> | null = null
const validationLoading = ref(false)
const validationResult = ref<any>(null)
async function runValidation() {
validationLoading.value = true
validationResult.value = null
try {
const result = await apiCall('validate_system_stats', { layers: '1,2' })
validationResult.value = result
} catch (e: any) {
console.error('Validation error:', e)
} finally {
validationLoading.value = false
}
}
function formatMetricName(key: string): string {
const names: Record<string, string> = {
'cpu_usage': 'CPU Usage',
'memory_used_percent': 'Memory Used',
'memory_total_mb': 'Memory Total',
'swap_used_percent': 'Swap Used',
'cpu_temp': 'CPU Temp',
}
return names[key] || key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
function getBarClass(value: number): string {
if (value >= 80) return 'bar-red'
if (value >= 60) return 'bar-yellow'
return 'bar-green'
}
function formatMemory(mb: number): string {
if (mb >= 1024) return (mb / 1024).toFixed(1) + ' GB'
return mb + ' MB'
}
function swapScale(): number {
const total = systemStats.value.memory_total_mb
const swap = systemStats.value.swap_total_mb
if (total <= 0) return 1
return Math.min(1, swap / total)
}
function swapScaleHint(): string {
const scale = swapScale() * 100
if (scale < 1) return ''
return `${scale.toFixed(0)}% of RAM`
}
async function refreshSystemStats() {
const result = await loadSystemStats()
if (result) {
systemStats.value = result
}
}
function startAutoRefresh() {
if (refreshTimer) clearInterval(refreshTimer)
refreshTimer = setInterval(refreshSystemStats, refreshIntervalSec.value * 1000)
}
watch(refreshIntervalSec, () => {
startAutoRefresh()
})
onMounted(async () => {
await refreshSystemStats()
startAutoRefresh()
})
onUnmounted(() => {
if (refreshTimer) clearInterval(refreshTimer)
})
// Settings
const uploadPath = ref('')
const saving = ref(false)
const saveMessage = ref('')
const saveError = ref(false)
const settingsError = ref('')
const coreApiUrl = ref('http://localhost:3002')
const markbaseUrl = ref('http://localhost:11438')
const mbeUrl = ref('http://localhost:8080')
const proxyUrl = ref('http://localhost:8888')
const workerUrl = ref('http://localhost:11438')
const coreStatus = ref('unknown')
const mbStatus = ref('unknown')
const mbeStatus = ref('unknown')
const proxyStatus = ref('unknown')
const workerStatus = ref('unknown')
const coreInfo = ref<any>(null)
const mbInfo = ref<any>(null)
const mbeInfo = ref<any>(null)
const workerInfo = ref<any>(null)
const testingCore = ref(false)
const testingMb = ref(false)
const testingMbe = ref(false)
const testingProxy = ref(false)
const testingWorker = ref(false)
const urlSaveMsg = ref('')
const urlSaveError = ref(false)
// Service stop/start state
const coreActionLoading = ref(false)
const coreActionResult = ref('')
const coreActionResultOk = ref(false)
const mbActionLoading = ref(false)
const mbActionResult = ref('')
const mbActionResultOk = ref(false)
const mbeActionLoading = ref(false)
const mbeActionResult = ref('')
const mbeActionResultOk = ref(false)
const proxyActionLoading = ref(false)
const proxyActionResult = ref('')
const proxyActionResultOk = ref(false)
const workerActionLoading = ref(false)
const workerActionResult = ref('')
const workerActionResultOk = ref(false)
async function stopService(name: string) {
const s = serviceState(name)
s.loading.value = true
s.result.value = ''
try {
const result = await apiCall('stop_service', { name })
s.result.value = result === 'stopped' ? 'Service stopped' : result
s.ok.value = true
setTimeout(() => s.test(), 1000)
} catch (e: any) {
s.result.value = 'Error: ' + (e.message || e)
s.ok.value = false
} finally {
s.loading.value = false
}
}
async function startService(name: string) {
const s = serviceState(name)
s.loading.value = true
s.result.value = ''
try {
const result = await apiCall('start_service', { name })
const started = typeof result === 'string' ? result === 'started' : result?.result === 'started'
s.result.value = started ? (name === 'mbe' ? 'Loading model...' : 'Service started') : JSON.stringify(result)
s.ok.value = started
// Poll up to 30s for the service to pass health check
for (let i = 0; i < 15; i++) {
await new Promise(r => setTimeout(r, 2000))
await s.test()
if (s.status.value === 'online') {
s.result.value = 'Service started'
break
}
}
} catch (e: any) {
s.result.value = 'Error: ' + (e.message || e)
s.ok.value = false
} finally {
s.loading.value = false
}
}
function serviceState(name: string) {
switch (name) {
case 'core': return { loading: coreActionLoading, result: coreActionResult, ok: coreActionResultOk, status: coreStatus, test: testCoreApi }
case 'markbase': return { loading: mbActionLoading, result: mbActionResult, ok: mbActionResultOk, status: mbStatus, test: testMarkbase }
case 'proxy': return { loading: proxyActionLoading, result: proxyActionResult, ok: proxyActionResultOk, status: proxyStatus, test: testProxy }
case 'worker': return { loading: workerActionLoading, result: workerActionResult, ok: workerActionResultOk, status: workerStatus, test: testWorker }
default: return { loading: mbeActionLoading, result: mbeActionResult, ok: mbeActionResultOk, status: mbeStatus, test: testMbe }
}
}
function loadServiceUrls() {
const stored = localStorage.getItem('service_urls')
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
const currentOrigin = window.location.origin
if (stored) {
try {
const urls = JSON.parse(stored)
coreApiUrl.value = urls.core || (isLocalhost ? 'http://localhost:3002' : currentOrigin)
markbaseUrl.value = urls.markbase || (isLocalhost ? 'http://localhost:11438' : currentOrigin)
mbeUrl.value = urls.mbe || (isLocalhost ? 'http://localhost:8080' : currentOrigin)
proxyUrl.value = urls.proxy || (isLocalhost ? 'http://localhost:8888' : currentOrigin)
workerUrl.value = urls.worker || (isLocalhost ? 'http://localhost:11438' : currentOrigin)
} catch (e) {
coreApiUrl.value = isLocalhost ? 'http://localhost:3002' : currentOrigin
markbaseUrl.value = isLocalhost ? 'http://localhost:11438' : currentOrigin
mbeUrl.value = isLocalhost ? 'http://localhost:8080' : currentOrigin
proxyUrl.value = isLocalhost ? 'http://localhost:8888' : currentOrigin
workerUrl.value = isLocalhost ? 'http://localhost:11438' : currentOrigin
}
} else {
coreApiUrl.value = isLocalhost ? 'http://localhost:3002' : currentOrigin
markbaseUrl.value = isLocalhost ? 'http://localhost:11438' : currentOrigin
mbeUrl.value = isLocalhost ? 'http://localhost:8080' : currentOrigin
proxyUrl.value = isLocalhost ? 'http://localhost:8888' : currentOrigin
workerUrl.value = isLocalhost ? 'http://localhost:11438' : currentOrigin
}
}
function saveServiceUrls() {
urlSaveMsg.value = ''
urlSaveError.value = false
try {
localStorage.setItem('service_urls', JSON.stringify({
core: coreApiUrl.value,
markbase: markbaseUrl.value,
mbe: mbeUrl.value,
proxy: proxyUrl.value,
worker: workerUrl.value,
}))
;(window as any).__CORE_API_URL__ = coreApiUrl.value
;(window as any).__MARKBASE_URL__ = markbaseUrl.value
;(window as any).__MBE_URL__ = mbeUrl.value
;(window as any).__WORKER_URL__ = workerUrl.value
urlSaveMsg.value = 'Service URLs saved'
} catch (e: any) {
urlSaveError.value = true
urlSaveMsg.value = 'Failed to save: ' + (e.message || e)
}
}
async function testCoreApi() {
testingCore.value = true
coreInfo.value = null
try {
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
const healthUrl = isLocalhost ? `${coreApiUrl.value}/health` : `${coreApiUrl.value}/api/v1/health`
const resp = await fetch(healthUrl, { signal: AbortSignal.timeout(5000) })
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
coreStatus.value = 'online'
coreInfo.value = { version: data.version, uptime: data.uptime_ms }
} catch (e: any) {
coreStatus.value = 'offline'
coreInfo.value = null
} finally {
testingCore.value = false
}
}
async function testMarkbase() {
testingMb.value = true
mbInfo.value = null
try {
const resp = await fetch(`${markbaseUrl.value}/api/v2/health`, { signal: AbortSignal.timeout(5000) })
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
mbStatus.value = 'online'
mbInfo.value = { version: data.version, uptime: data.uptime }
} catch (e: any) {
mbStatus.value = 'offline'
mbInfo.value = null
} finally {
testingMb.value = false
}
}
async function testMbe() {
testingMbe.value = true
mbeInfo.value = null
try {
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
const healthUrl = isLocalhost ? `${mbeUrl.value}/health` : `${mbeUrl.value}/v1/health`
const resp = await fetch(healthUrl, { signal: AbortSignal.timeout(5000) })
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const text = await resp.text()
if (text === 'OK') {
mbeStatus.value = 'online'
mbeInfo.value = { version: 'MarkBaseEngine', uptime: 0 }
} else {
throw new Error('Unexpected response: ' + text)
}
} catch (e: any) {
mbeStatus.value = 'offline'
mbeInfo.value = null
} finally {
testingMbe.value = false
}
}
async function testProxy() {
testingProxy.value = true
try {
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
const healthUrl = isLocalhost ? `${proxyUrl.value}/health` : `${proxyUrl.value}/api/v1/health`
const resp = await fetch(healthUrl, { signal: AbortSignal.timeout(5000) })
proxyStatus.value = resp.ok ? 'online' : 'offline'
} catch {
proxyStatus.value = 'offline'
} finally {
testingProxy.value = false
}
}
async function testWorker() {
testingWorker.value = true
workerInfo.value = null
try {
const resp = await fetch(`${workerUrl.value}/api/v2/worker/status`, { signal: AbortSignal.timeout(5000) })
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
workerStatus.value = data.status === 'running' ? 'online' : 'offline'
workerInfo.value = { version: data.version, uptime: data.uptime }
} catch (e: any) {
workerStatus.value = 'offline'
workerInfo.value = null
} finally {
testingWorker.value = false
}
}
function formatUptime(seconds: number | undefined): string {
if (!seconds) return 'N/A'
const d = Math.floor(seconds / 86400)
const h = Math.floor((seconds % 86400) / 3600)
const m = Math.floor((seconds % 3600) / 60)
if (d > 0) return `${d}d ${h}h ${m}m`
if (h > 0) return `${h}h ${m}m`
return `${m}m`
}
async function loadDashboard() {
try { stats.value = await apiCall('get_system_stats', {}) || { cpu: 0, memory: 0, disk: 0 } } catch (e) { console.error(e) }
}
async function loadUsers() {
try { users.value = await apiCall('list_admin_users', {}) || [] } catch (e) { console.error(e) }
}
async function loadShares() {
try { shares.value = await apiCall('list_shares', {}) || [] } catch (e) { console.error(e) }
}
async function loadSettings() {
settingsError.value = ''
saveMessage.value = ''
try {
const resp = await fetch(`${markbaseUrl.value}/api/v2/config`)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const cfg = await resp.json()
uploadPath.value = cfg.server?.upload_path || ''
} catch (e: any) {
settingsError.value = 'Failed to load settings: ' + (e.message || e)
}
}
async function saveUploadPath() {
saving.value = true
saveMessage.value = ''
saveError.value = false
try {
const resp = await fetch(`${markbaseUrl.value}/api/v2/config/edit?key=server.upload_path&value=${encodeURIComponent(uploadPath.value)}`, { method: 'POST' })
const data = await resp.json()
if (data.ok) {
saveMessage.value = 'Upload path saved successfully'
} else {
saveError.value = true
saveMessage.value = data.error || 'Save failed'
}
} catch (e: any) {
saveError.value = true
saveMessage.value = 'Error: ' + (e.message || e)
} finally {
saving.value = false
}
}
// ── Language Pack Management ──
import { useI18n } from 'vue-i18n'
import type { MessageSchema } from '@/locales'
const { t, availableLocales, messages, locale } = useI18n()
const langFileInput = ref<HTMLInputElement | null>(null)
const langPackMsg = ref('')
const langPackOk = ref(false)
const availableLanguages = computed(() => {
const langs: Record<string, string> = {
'en': 'English',
'zh-TW': '繁體中文',
'ja': '日本語'
}
return langs
})
function exportLanguagePack(langCode: string) {
try {
const langData = messages.value[langCode] as MessageSchema
if (!langData) {
langPackOk.value = false
langPackMsg.value = `Language pack '${langCode}' not found`
return
}
const jsonStr = JSON.stringify(langData, null, 2)
const blob = new Blob([jsonStr], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${langCode}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
langPackOk.value = true
langPackMsg.value = `Language pack '${langCode}' exported successfully`
setTimeout(() => { langPackMsg.value = '' }, 3000)
} catch (e: any) {
langPackOk.value = false
langPackMsg.value = 'Export failed: ' + (e.message || e)
}
}
function triggerImport() {
langFileInput.value?.click()
}
async function importLanguagePack(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) return
try {
const text = await file.text()
const data = JSON.parse(text)
// Validate structure
if (typeof data !== 'object' || data === null) {
throw new Error('Invalid JSON structure')
}
// Extract language code from filename
const match = file.name.match(/^([a-z]{2}(?:-[A-Z]{2})?)\.json$/)
if (!match) {
throw new Error('Invalid filename format. Expected: en.json, zh-TW.json, ja.json')
}
const langCode = match[1]
// Validate that the language is supported
const supportedLanguages = ['en', 'zh-TW', 'ja']
if (!supportedLanguages.includes(langCode)) {
throw new Error(`Unsupported language: ${langCode}. Supported: ${supportedLanguages.join(', ')}`)
}
// Update messages
messages.value[langCode] = data
langPackOk.value = true
langPackMsg.value = `Language pack '${langCode}' imported successfully`
setTimeout(() => { langPackMsg.value = '' }, 3000)
// Reset file input
if (langFileInput.value) {
langFileInput.value.value = ''
}
} catch (e: any) {
langPackOk.value = false
langPackMsg.value = 'Import failed: ' + (e.message || e)
}
}
// ── Config Tab ──
const configLoading = ref(false)
const configSaving = ref(false)
const configError = ref('')
const configSaveMsg = ref('')
const configSaveOk = ref(false)
const expandedSections = ref({ vlm: true, llm: true, proc: true, filter: true, search: true, sys: false, lang: false })
const vlmPrompts = ref<Record<string, any>>({})
const llmConfig = ref<Record<string, any>>({})
const processorConfig = ref<Record<string, any>>({})
const filterConfig = ref<Record<string, any>>({})
const systemConfig = ref<Record<string, any>>({})
const searchConfig = ref<Record<string, any>>({
vlm_min_score: 0.0,
vlm_max_results: 5,
vlm_output_language: 'en'
})
function toggleSection(section: string) {
expandedSections.value[section] = !expandedSections.value[section]
}
function getProcParams(cfg: Record<string, any>) {
return Object.entries(cfg)
.filter(([k]) => !['enabled', 'default_tool', 'tools', 'timeout'].includes(k))
.map(([key, val]) => ({ key, val, isNumber: typeof val === 'number' }))
}
function hasTopLevelParams(cfg: Record<string, any>): boolean {
return Object.keys(cfg).some(k => !['enabled', 'default_tool', 'tools', 'timeout'].includes(k))
}
function onToolParamChange(tool: any, key: string, value: any) {
if (typeof tool.parameters[key] === 'number') {
const num = parseFloat(value)
tool.parameters[key] = isNaN(num) ? 0 : Math.max(0, num)
} else {
tool.parameters[key] = value
}
}
function onProcParamChange(cfg: Record<string, any>, key: string, value: any) {
if (typeof cfg[key] === 'number') {
const num = parseFloat(value)
cfg[key] = isNaN(num) ? 0 : Math.max(0, num)
} else {
cfg[key] = value
}
}
async function loadConfig() {
configLoading.value = true
configError.value = ''
try {
const res = await fetch('/api/v1/admin/config')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
vlmPrompts.value = data.vlm_prompts || {}
llmConfig.value = data.llm_config || {}
processorConfig.value = data.processor_config || {}
filterConfig.value = data.filter_config || {}
systemConfig.value = data.system_config || {}
searchConfig.value = data.search || { vlm_min_score: 0.0, vlm_max_results: 5, vlm_output_language: 'en' }
} catch (e: any) {
configError.value = 'Failed to load config: ' + (e.message || e)
} finally {
configLoading.value = false
}
}
async function saveConfig() {
configSaving.value = true
configSaveMsg.value = ''
try {
const res = await fetch('/api/v1/admin/config')
if (!res.ok) throw new Error(`Failed to load current config: HTTP ${res.status}`)
const currentConfig = await res.json()
const payload = {
version: '1.0.0',
vlm_prompts: Object.keys(vlmPrompts.value).length > 0 ? vlmPrompts.value : currentConfig.vlm_prompts || {},
llm_config: Object.keys(llmConfig.value).length > 0 ? llmConfig.value : currentConfig.llm_config || {},
processor_config: Object.keys(processorConfig.value).length > 0 ? processorConfig.value : currentConfig.processor_config || {},
filter_config: Object.keys(filterConfig.value).length > 0 ? filterConfig.value : currentConfig.filter_config || {},
system_config: Object.keys(systemConfig.value).length > 0 ? systemConfig.value : currentConfig.system_config || {},
search: {
vlm_min_score: searchConfig.value.vlm_min_score || 0.0,
vlm_max_results: searchConfig.value.vlm_max_results || 5,
vlm_output_language: searchConfig.value.vlm_output_language || 'en'
},
}
const saveRes = await fetch('/api/v1/admin/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!saveRes.ok) throw new Error(`HTTP ${saveRes.status}`)
const data = await saveRes.json()
configSaveOk.value = true
configSaveMsg.value = 'Config saved successfully'
} catch (e: any) {
configSaveOk.value = false
configSaveMsg.value = 'Save failed: ' + (e.message || e)
} finally {
configSaving.value = false
}
}
onMounted(async () => {
loadServiceUrls()
await Promise.all([loadDashboard(), loadUsers(), loadShares(), loadSettings()])
await Promise.all([testCoreApi(), testMarkbase(), testMbe(), testProxy(), testWorker()])
})
</script>
<style scoped>
#ms-view-admin { padding: 20px; color: var(--text-primary); }
.admin-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.admin-tabs { display: flex; gap: 10px; }
.admin-tab { padding: 8px 16px; border: none; background: var(--hover-background); color: var(--text-primary); cursor: pointer; border-radius: 4px; }
.admin-tab.active { background: var(--primary-color); color: white; }
.admin-content { background: var(--card-background); border-radius: 8px; padding: 20px; }
.stats-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 15px; }
.stat-card { background: var(--hover-background); padding: 15px; border-radius: 8px; text-align: center; }
.stat-label { font-size: 12px; color: var(--text-secondary); }
.stat-value { font-size: 24px; font-weight: bold; color: var(--text-primary); }
.system-resources { margin-bottom: 24px; background: var(--hover-background); padding: 16px; border-radius: 8px; }
.system-resources h3 { margin: 0 0 16px; font-size: 16px; color: var(--text-primary); }
.resource-row { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
.resource-row:last-child { margin-bottom: 0; }
.resource-label { min-width: 80px; font-weight: 600; font-size: 14px; }
.resource-bar-container { flex: 1; height: 20px; background: var(--border); border-radius: 4px; overflow: hidden; }
.resource-bar-container--scaled {
width: calc(var(--swap-scale, 1) * 100%);
flex: none;
background: color-mix(in srgb, var(--border) 60%, transparent);
border: 1px dashed color-mix(in srgb, var(--border) 80%, var(--text-secondary));
}
.swap-scale-hint { font-size: 11px; color: var(--text-secondary); opacity: 0.7; min-width: auto; }
.resource-bar { height: 100%; transition: width 0.3s ease; }
.resource-bar.bar-green { background: linear-gradient(90deg, #4caf50, #66bb6a); }
.resource-bar.bar-yellow { background: linear-gradient(90deg, #ff9800, #ffb74d); }
.resource-bar.bar-red { background: linear-gradient(90deg, #f44336, #ef5350); }
.resource-value { min-width: 60px; text-align: right; font-weight: 600; font-size: 14px; }
.resource-detail { font-size: 12px; color: var(--text-secondary); min-width: 80px; }
.resource-detail.power { color: #ff9800; font-weight: 600; min-width: 60px; }
.source-badge { font-size: 11px; background: var(--primary-color); color: white; padding: 2px 6px; border-radius: 4px; margin-left: 8px; vertical-align: middle; }
.chip-info { font-size: 12px; color: var(--text-secondary); margin-bottom: 12px; }
.resource-row.sub { padding-left: 20px; margin-bottom: 6px; }
.resource-row.sub .resource-label { min-width: 60px; font-weight: 500; }
.resource-row.sub .resource-value { min-width: 80px; }
.resource-row.total { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
.resource-value.power-large { font-size: 18px; font-weight: 700; color: var(--primary-color); min-width: 80px; }
.refresh-setting { display: flex; align-items: center; gap: 8px; margin-bottom: 20px; padding: 12px; background: var(--hover-background); border-radius: 6px; }
.refresh-setting label { font-size: 13px; color: var(--text-secondary); }
.refresh-setting select { padding: 4px 8px; border: 1px solid var(--border); border-radius: 4px; background: var(--card-background); color: var(--text-primary); font-size: 13px; }
.validation-section { background: var(--hover-background); border-radius: 8px; padding: 16px; margin-bottom: 20px; }
.validation-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.validation-header h3 { margin: 0; font-size: 16px; color: var(--text-primary); }
.validation-btn { padding: 6px 16px; background: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; }
.validation-btn:disabled { opacity: 0.6; cursor: default; }
.validation-result { margin-top: 12px; }
.validation-summary { display: flex; gap: 16px; font-size: 12px; margin-bottom: 12px; padding: 8px; background: var(--card-background); border-radius: 4px; }
.validation-summary-total { color: var(--text-secondary); }
.validation-summary-passed { color: var(--success-color); font-weight: 600; }
.validation-summary-failed { color: var(--danger-color); font-weight: 600; }
.validation-summary-unverified { color: var(--text-secondary); }
.validation-layer { margin-bottom: 12px; }
.validation-layer-title { font-size: 13px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; }
.validation-metric { display: flex; align-items: center; gap: 12px; padding: 6px 0; border-bottom: 1px solid var(--border); }
.validation-metric:last-child { border-bottom: none; }
.validation-metric-name { flex: 1; font-size: 13px; color: var(--text-secondary); }
.validation-metric-status { padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; text-transform: uppercase; }
.validation-metric-status.pass { background: rgba(76, 175, 80, 0.2); color: #4caf50; }
.validation-metric-status.warn { background: rgba(255, 152, 0, 0.2); color: #ff9800; }
.validation-metric-status.fail { background: rgba(244, 67, 54, 0.2); color: #f44336; }
.validation-metric-status.unverified { background: var(--hover-background); color: var(--text-secondary); }
.validation-metric-value { font-size: 13px; font-weight: 600; color: var(--text-primary); min-width: 60px; text-align: right; }
.admin-table { width: 100%; border-collapse: collapse; }
.admin-table th, .admin-table td { padding: 10px; border: 1px solid var(--border-color); text-align: left; color: var(--text-primary); }
.settings-panel { max-width: 600px; }
.settings-group { margin-bottom: 24px; }
.settings-group h3 { margin: 0 0 4px; font-size: 16px; color: var(--text-primary); }
.settings-desc { margin: 0 0 10px; font-size: 13px; color: var(--text-secondary); }
.settings-row { display: flex; gap: 8px; align-items: center; }
.settings-input { flex: 1; padding: 8px 12px; border: 1px solid var(--border-color); border-radius: 4px; font-size: 14px; background: var(--card-background); color: var(--text-primary); }
.settings-save-btn { padding: 8px 16px; background: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer; }
.settings-save-btn:disabled { opacity: 0.6; cursor: default; }
.settings-msg { margin-top: 8px; font-size: 13px; }
.settings-msg.ok { color: var(--success-color); }
.settings-msg.error { color: var(--danger-color); }
.settings-error { background: var(--danger-background); color: var(--danger-color); padding: 10px; border-radius: 4px; margin-bottom: 16px; }
.service-card { background: var(--hover-background); border-radius: 6px; padding: 12px; margin-top: 12px; }
.service-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.service-name { font-weight: 600; font-size: 14px; color: var(--text-primary); }
.service-status { font-size: 12px; padding: 2px 8px; border-radius: 4px; text-transform: uppercase; }
.service-status.online { background: rgba(var(--success-color), 0.2); color: var(--success-color); }
.service-status.offline { background: var(--danger-background); color: var(--danger-color); }
.service-status.unknown { background: var(--hover-background); color: var(--text-secondary); }
.service-row { display: flex; gap: 8px; align-items: center; }
.service-test-btn { padding: 6px 12px; background: var(--text-secondary); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; }
.service-test-btn:disabled { opacity: 0.6; cursor: default; }
.service-info { margin-top: 8px; font-size: 12px; color: var(--text-secondary); display: flex; gap: 16px; }
.service-actions { display: flex; gap: 6px; align-items: center; margin-top: 8px; }
.service-stop-btn { padding: 4px 12px; background: var(--danger-color); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; }
.service-stop-btn:disabled { opacity: 0.5; cursor: default; }
.service-start-btn { padding: 4px 12px; background: var(--success-color); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; }
.service-start-btn:disabled { opacity: 0.5; cursor: default; }
.action-loading { font-size: 12px; color: var(--text-secondary); }
.action-result { font-size: 12px; }
.action-result.ok { color: var(--success-color); }
.action-result.error { color: var(--danger-color); }
@media (max-width: 767px) {
.settings-panel { max-width: 100%; padding: 12px; }
table { display: block; overflow-x: auto; white-space: nowrap; }
.stats-grid { grid-template-columns: repeat(2, 1fr); gap: 8px; }
}
/* Config Panel */
.config-panel { position: relative; }
.config-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
margin-bottom: 16px;
position: sticky;
top: 0;
z-index: 10;
background: var(--card-background);
border-bottom: 1px solid var(--border);
}
.config-header h2 { margin: 0; font-size: 18px; }
.config-actions { display: flex; gap: 8px; }
.config-btn { padding: 8px 16px; border: 1px solid var(--border); background: var(--hover-background); color: var(--text-primary); cursor: pointer; border-radius: 4px; font-size: 13px; font-weight: 500; }
.config-btn:hover { background: var(--border-light); }
.config-btn-save { background: var(--primary-color); color: white; border-color: var(--primary-color); }
.config-btn-save:hover { background: var(--primary-color); opacity: 0.9; }
.config-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.config-error { background: #fee; color: #c00; padding: 10px; border-radius: 6px; margin-bottom: 12px; }
.config-msg { padding: 10px; border-radius: 6px; margin-bottom: 12px; }
.config-msg.ok { background: #efe; color: #0a0; }
.config-msg.error { background: #fee; color: #c00; }
.config-section { margin-bottom: 12px; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
.config-section-title { display: flex; align-items: center; gap: 8px; padding: 10px 14px; margin: 0; cursor: pointer; background: var(--hover-background); font-size: 14px; font-weight: 600; user-select: none; }
.config-section-title:hover { background: var(--border-light); }
.config-section-arrow { font-size: 10px; }
.config-section-content { padding: 14px; }
.config-prompt-card { margin-bottom: 14px; padding: 12px; background: var(--hover-background); border-radius: 6px; }
.config-prompt-header { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.config-prompt-key { font-weight: 700; font-size: 13px; color: var(--primary-color); font-family: monospace; }
.config-prompt-desc { font-size: 12px; color: var(--text-secondary); }
.config-prompt-fields { display: flex; flex-direction: column; gap: 8px; }
.config-llm-card { margin-bottom: 12px; padding: 12px; background: var(--hover-background); border-radius: 6px; }
.config-llm-name { font-weight: 700; font-size: 13px; color: var(--primary-color); margin-bottom: 6px; text-transform: uppercase; }
.config-field { display: flex; align-items: center; gap: 8px; }
.config-field label { font-size: 12px; font-weight: 600; color: var(--text-secondary); min-width: 80px; }
.config-input { flex: 1; padding: 6px 10px; border: 1px solid var(--border); border-radius: 4px; font-size: 13px; background: var(--card-background); color: var(--text-primary); }
.config-input-small { width: 80px; padding: 6px 10px; border: 1px solid var(--border); border-radius: 4px; font-size: 13px; background: var(--card-background); color: var(--text-primary); }
.config-textarea { width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 4px; font-size: 13px; background: var(--card-background); color: var(--text-primary); font-family: inherit; resize: vertical; }
.config-proc-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.config-proc-table th { text-align: left; padding: 8px; background: var(--hover-background); font-weight: 600; }
.config-proc-table td { padding: 8px; border-top: 1px solid var(--border); }
.config-proc-name { font-weight: 600; color: var(--primary-color); text-transform: uppercase; }
.config-proc-param { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.config-proc-param label { font-size: 11px; color: var(--text-secondary); min-width: 60px; }
@media (max-width: 768px) {
.config-header { flex-direction: column; align-items: flex-start; gap: 10px; }
.config-prompt-header { flex-direction: column; align-items: flex-start; }
.config-field { flex-direction: column; align-items: flex-start; }
.config-field label { min-width: auto; }
.config-input, .config-input-small { width: 100%; }
}
.lang-list { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
.lang-item { display: flex; align-items: center; gap: 12px; padding: 8px 12px; background: var(--hover-background); border-radius: 6px; }
.lang-code { font-weight: 700; font-size: 14px; color: var(--primary-color); min-width: 60px; font-family: monospace; }
.lang-name { flex: 1; font-size: 14px; color: var(--text-primary); }
.lang-export-btn { padding: 4px 12px; background: var(--hover-background); border: 1px solid var(--border); border-radius: 4px; cursor: pointer; font-size: 12px; color: var(--text-primary); transition: all 0.2s; }
.lang-export-btn:hover { background: var(--primary-color); color: white; border-color: var(--primary-color); }
.lang-import { display: flex; align-items: center; gap: 12px; margin-top: 8px; }
.lang-import-btn { padding: 8px 16px; background: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; transition: all 0.2s; }
.lang-import-btn:hover { opacity: 0.9; }
.lang-import-hint { font-size: 12px; color: var(--text-secondary); }
.lang-pack-msg { margin-top: 12px; padding: 8px 12px; border-radius: 4px; font-size: 13px; }
.lang-pack-ok { background: #4caf50; color: white; }
.lang-pack-error { background: #f44336; color: white; }
.config-hint { font-size: 11px; color: var(--text-secondary); margin-left: 8px; }
.config-processor-card { margin-bottom: 20px; padding: 16px; background: var(--hover-background); border-radius: 8px; border: 1px solid var(--border); }
.config-processor-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.config-processor-name { font-size: 16px; font-weight: 700; color: var(--primary-color); text-transform: uppercase; }
.config-processor-enabled { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-secondary); cursor: pointer; }
.config-processor-enabled input { cursor: pointer; }
.config-processor-default, .config-processor-timeout { font-size: 13px; color: var(--text-primary); margin-bottom: 8px; }
.config-processor-default strong, .config-processor-timeout strong { color: var(--primary-color); }
.config-tools-section { margin-top: 12px; }
.config-tools-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; }
.config-tool-card { padding: 12px; margin-bottom: 8px; background: var(--card-background); border-radius: 6px; border: 1px solid var(--border); }
.config-tool-header { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
.config-tool-name { font-size: 13px; font-weight: 600; color: var(--primary-color); min-width: 80px; }
.config-tool-enabled { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-secondary); cursor: pointer; }
.config-tool-enabled input { cursor: pointer; }
.config-tool-priority { font-size: 11px; color: var(--text-secondary); margin-left: auto; }
.config-tool-params { display: flex; flex-wrap: wrap; gap: 8px; }
.config-tool-param { display: flex; align-items: center; gap: 6px; }
.config-tool-param label { font-size: 11px; color: var(--text-secondary); min-width: 80px; }
.config-processor-params { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
</style>