diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index f5df56f..0ef24ed 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -115,9 +115,10 @@ jobs: file: ./Dockerfile platforms: ${{ matrix.platform }} push: true - # 使用带架构后缀的临时标签 + # 同时推送到两个 Registry 的临时架构标签,确保层已就绪,避免合并时跨 Registry 复制层 tags: | ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:build-${{ github.run_id }}-${{ matrix.suffix }} + ${{ (github.event_name != 'pull_request' && secrets.DOCKERHUB_TOKEN != '') && format('{0}:build-{1}-{2}', env.DOCKERHUB_REPO, github.run_id, matrix.suffix) || '' }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha,scope=${{ matrix.platform }} cache-to: type=gha,mode=max,scope=${{ matrix.platform }} @@ -198,10 +199,30 @@ jobs: TAGS="${{ steps.meta.outputs.tags }}" echo "$TAGS" | while IFS= read -r TAG; do [ -z "$TAG" ] && continue - echo "Creating manifest for: $TAG" - docker buildx imagetools create -t "$TAG" \ - "${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}@${AMD64_DIGEST}" \ - "${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}@${ARM64_DIGEST}" + + # 确定当前标签所属的 Registry 以选择同源的源镜像进行合并(避免跨 Registry 复制 Blob 导致 400 错误) + if [[ "$TAG" == "${{ env.REGISTRY }}"* ]]; then + SRC_PREFIX="${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}" + else + SRC_PREFIX="${{ env.DOCKERHUB_REPO }}" + fi + + echo "Creating manifest for: $TAG using source: $SRC_PREFIX" + + # 增加重试机制,应对 Registry 极端情况下的抖动 + for i in {1..3}; do + docker buildx imagetools create -t "$TAG" \ + "${SRC_PREFIX}@${AMD64_DIGEST}" \ + "${SRC_PREFIX}@${ARM64_DIGEST}" && break || { + if [ $i -lt 3 ]; then + echo "Push failed, retrying in 10s ($i/3)..." + sleep 10 + else + echo "Failed to create manifest for $TAG after 3 attempts." + exit 1 + fi + } + done done - name: Image digest diff --git a/Dockerfile b/Dockerfile index 0b844ca..fd4c738 100644 --- a/Dockerfile +++ b/Dockerfile @@ -96,6 +96,7 @@ COPY --from=agent-builder --chown=nodejs:nodejs /app/agent-go/agent-windows-amd6 # 4. 复制后端源码 (不包含 node_modules) COPY --chown=nodejs:nodejs server.js ./ COPY --chown=nodejs:nodejs src ./src +COPY --chown=nodejs:nodejs modules ./modules ENV NODE_ENV=production \ PORT=3000 \ diff --git a/agent-go/collector.go b/agent-go/collector.go index 71bc7bc..0b1b02a 100644 --- a/agent-go/collector.go +++ b/agent-go/collector.go @@ -100,17 +100,29 @@ type Collector struct { lastGPUPower float64 lastGPUTime time.Time - // CPU 采集缓存 (保持上次有效值,避免返回 0) - lastCPUUsage float64 + // GPU 采集频率控制 + lastGPUMetadataTime time.Time + + // CPU 采集缓存 lastCPUTime time.Time + lastCPUUsage float64 + + // Windows Native (PDH) + pdhQuery uintptr + pdhCounter uintptr + + // NVIDIA Native (NVML) + nvmlLib any + nvmlInitialized bool } // NewCollector 创建采集器 func NewCollector() *Collector { return &Collector{ - lastNetTime: time.Now(), - lastGPUTime: time.Now().Add(-1 * time.Hour), // 确保第一次采集立即执行 - lastCPUTime: time.Now().Add(-1 * time.Hour), // 确保第一次采集立即执行 + lastNetTime: time.Now(), + lastGPUTime: time.Now().Add(-1 * time.Hour), // 确保第一次采集立即执行 + lastCPUTime: time.Now().Add(-1 * time.Hour), // 确保第一次采集立即执行 + lastGPUMetadataTime: time.Now().Add(-1 * time.Hour), // 确保第一次采集立即执行 } } @@ -191,6 +203,7 @@ func (c *Collector) CollectHostInfo() *HostInfo { gpuModels, gpuMemTotal := c.collectGPUMetadata() info.GPU = gpuModels info.GPUMemTotal = gpuMemTotal + c.lastGPUMetadataTime = time.Now() c.cachedHostInfo = info return info @@ -321,21 +334,27 @@ func (c *Collector) CollectState() *State { c.lastGPUTime = time.Now() } - // 补救措施:如果显存总量为 0,尝试重新获取静态信息 + // 补救措施:如果显存总量为 0,尝试重新获取静态信息 (增加冷却时间,防止频繁调用 PowerShell) if c.cachedHostInfo != nil && c.cachedHostInfo.GPUMemTotal == 0 { - go func() { - c.mu.Lock() - defer c.mu.Unlock() - // 再次检查,防止并发重复 - if c.cachedHostInfo.GPUMemTotal == 0 { + c.mu.Lock() + shouldRetry := time.Since(c.lastGPUMetadataTime) > 10*time.Minute + if shouldRetry { + c.lastGPUMetadataTime = time.Now() // 预设时间,防止下一秒再次触发 + } + c.mu.Unlock() + + if shouldRetry { + go func() { models, total := c.collectGPUMetadata() if total > 0 { + c.mu.Lock() c.cachedHostInfo.GPU = models c.cachedHostInfo.GPUMemTotal = total + c.mu.Unlock() fmt.Printf("[Collector] GPU metadata refreshed: %d MiB\n", total/1024/1024) } - } - }() + }() + } } state.GPU = c.lastGPUUsage state.GPUMemUsed = c.lastGPUMemUsed @@ -545,6 +564,14 @@ func (c *Collector) collectGPUState() (float64, uint64, float64) { // 2. 如果没有 NVIDIA,尝试其他方案 if runtime.GOOS == "windows" { + // 如果 meta 数据中已经确认没有 GPU 型号,则不再尝试采集使用率,避免频繁调用 PowerShell + c.mu.Lock() + hasGPU := c.cachedHostInfo != nil && len(c.cachedHostInfo.GPU) > 0 + c.mu.Unlock() + if !hasGPU { + return 0, 0, 0 + } + // Windows: 使用 Performance Counter 采集所有 GPU return c.collectGPUStateWindows() } else if runtime.GOOS == "linux" { @@ -555,8 +582,14 @@ func (c *Collector) collectGPUState() (float64, uint64, float64) { return 0, 0, 0 } -// collectNvidiaGPUState 使用 nvidia-smi 采集 NVIDIA GPU 状态 +// collectNvidiaGPUState 使用 NVML (优先) 或 nvidia-smi 采集 NVIDIA GPU 状态 func (c *Collector) collectNvidiaGPUState(nvidiaSmi string) (float64, uint64, float64) { + // 1. 尝试使用原生 NVML API (性能更高,不产生新进程) + if usage, usedMem, power, ok := c.collectNvidiaGPUStateNative(); ok { + return usage, usedMem, power + } + + // 2. 回退到 nvidia-smi 命令行工具 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -597,14 +630,19 @@ func (c *Collector) collectNvidiaGPUState(nvidiaSmi string) (float64, uint64, fl return totalUsage / float64(count), totalUsedMem, totalPower } -// collectGPUStateWindows Windows 下采集 AMD/Intel GPU 使用率 -// 使用 PowerShell 查询 GPU Engine 性能计数器 +// collectGPUStateWindows Windows 下采集 AMD/Intel/NVIDIA GPU 使用率 +// 优先使用 PDH 性能计数器 API,回退到 PowerShell func (c *Collector) collectGPUStateWindows() (float64, uint64, float64) { + // 1. 尝试使用原生 PDH API (性能极高,无额外进程) + if usage, ok := c.collectGPUUsagePDH(); ok { + return usage, 0, 0 + } + + // 2. 回退到 PowerShell (仅在 PDH 失败时使用) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() // 方案1: 使用 GPU Engine 性能计数器 (Windows 10 1709+) - // 查询所有 GPU 3D 引擎的使用率 psCmd := ` $counters = Get-Counter '\GPU Engine(*engtype_3D)\Utilization Percentage' -ErrorAction SilentlyContinue if ($counters) { @@ -624,9 +662,6 @@ if ($counters) { } usage, _ := strconv.ParseFloat(strings.TrimSpace(string(output)), 64) - if usage > 0 { - // fmt.Printf("[Collector] GPU (Win PerfCounter): %.1f%%\n", usage) - } return usage, 0, 0 } @@ -802,6 +837,7 @@ func (c *Collector) collectIntelGPULinux() float64 { return 0 } + func (c *Collector) getNvidiaSmiPath() string { if runtime.GOOS == "windows" { possiblePaths := []string{ diff --git a/agent-go/collector_unix.go b/agent-go/collector_unix.go new file mode 100644 index 0000000..dd3aeeb --- /dev/null +++ b/agent-go/collector_unix.go @@ -0,0 +1,14 @@ +//go:build !windows + +package main + +// collectGPUUsagePDH Windows-only stub +func (c *Collector) collectGPUUsagePDH() (float64, bool) { + return 0, false +} + +// collectNvidiaGPUStateNative Non-Windows stub +// (On Linux it currently falls back to nvidia-smi command line) +func (c *Collector) collectNvidiaGPUStateNative() (float64, uint64, float64, bool) { + return 0, 0, 0, false +} diff --git a/agent-go/collector_windows.go b/agent-go/collector_windows.go new file mode 100644 index 0000000..c6f2b97 --- /dev/null +++ b/agent-go/collector_windows.go @@ -0,0 +1,137 @@ +//go:build windows + +package main + +import ( + "runtime" + "syscall" + "unsafe" +) + +var ( + modPdh = syscall.NewLazyDLL("pdh.dll") + procPdhOpenQuery = modPdh.NewProc("PdhOpenQueryW") + procPdhAddEnglishCounter = modPdh.NewProc("PdhAddEnglishCounterW") + procPdhCollectQueryData = modPdh.NewProc("PdhCollectQueryData") + procPdhGetFormattedCounterValue = modPdh.NewProc("PdhGetFormattedCounterValue") + procPdhCloseQuery = modPdh.NewProc("PdhCloseQuery") +) + +type pdh_fmt_countervalue_double struct { + CStatus uint32 + DummyStruct [4]byte // padding for 64-bit alignment + DoubleValue float64 +} + +// collectGPUUsagePDH 使用原生 PDH API 获取所有 GPU 的 3D 引擎平均使用率 +func (c *Collector) collectGPUUsagePDH() (float64, bool) { + if runtime.GOOS != "windows" { + return 0, false + } + + c.mu.Lock() + defer c.mu.Unlock() + + // 初始化查询 + if c.pdhQuery == 0 { + var query uintptr + ret, _, _ := procPdhOpenQuery.Call(0, 0, uintptr(unsafe.Pointer(&query))) + if ret != 0 { + return 0, false + } + c.pdhQuery = query + + // 添加计数器 (使用通配符获取所有 GPU 的 3D 引擎使用率) + // 使用 English 名称确保兼容性 + counterPath := "\\GPU Engine(*engtype_3D)\\Utilization Percentage" + pathPtr, _ := syscall.UTF16PtrFromString(counterPath) + var counter uintptr + ret, _, _ = procPdhAddEnglishCounter.Call(c.pdhQuery, uintptr(unsafe.Pointer(pathPtr)), 0, uintptr(unsafe.Pointer(&counter))) + if ret != 0 { + procPdhCloseQuery.Call(c.pdhQuery) + c.pdhQuery = 0 + return 0, false + } + c.pdhCounter = counter + + // 第一次采集建立基准 + procPdhCollectQueryData.Call(c.pdhQuery) + return 0, true + } + + // 执行采集 + ret, _, _ := procPdhCollectQueryData.Call(c.pdhQuery) + if ret != 0 { + return 0, false + } + + // 获取格式化后的值 + var value pdh_fmt_countervalue_double + const PDH_FMT_DOUBLE = 0x00000200 + ret, _, _ = procPdhGetFormattedCounterValue.Call(c.pdhCounter, PDH_FMT_DOUBLE, 0, uintptr(unsafe.Pointer(&value))) + if ret != 0 { + return 0, false + } + + return value.DoubleValue, true +} + +// NVIDIA NVML 原生支持 (Windows 版) +func (c *Collector) collectNvidiaGPUStateNative() (float64, uint64, float64, bool) { + c.mu.Lock() + defer c.mu.Unlock() + + if !c.nvmlInitialized { + if c.nvmlLib == nil { + c.nvmlLib = syscall.NewLazyDLL("nvml.dll") + } + + lib := c.nvmlLib.(*syscall.LazyDLL) + // 尝试初始化 + initProc := lib.NewProc("nvmlInit_v2") + if err := initProc.Find(); err != nil { + return 0, 0, 0, false + } + ret, _, _ := initProc.Call() + if ret != 0 { + return 0, 0, 0, false + } + c.nvmlInitialized = true + } + + lib := c.nvmlLib.(*syscall.LazyDLL) + // 获取第一个设备的句柄 (简化处理) + getHandle := lib.NewProc("nvmlDeviceGetHandleByIndex_v2") + var device uintptr + ret, _, _ := getHandle.Call(0, uintptr(unsafe.Pointer(&device))) + if ret != 0 { + return 0, 0, 0, false + } + + // 获取利用率 + getUtil := lib.NewProc("nvmlDeviceGetUtilizationRates") + var util struct { + GPU uint32 + Memory uint32 + } + ret, _, _ = getUtil.Call(device, uintptr(unsafe.Pointer(&util))) + if ret != 0 { + return 0, 0, 0, false + } + + // 获取显存 + getMem := lib.NewProc("nvmlDeviceGetMemoryInfo") + var mem struct { + Total uint64 + Free uint64 + Used uint64 + } + ret, _, _ = getMem.Call(device, uintptr(unsafe.Pointer(&mem))) + + // 获取功耗 (单位通常是毫瓦) + getPower := lib.NewProc("nvmlDeviceGetPowerUsage") + var power uint32 + ret, _, _ = getPower.Call(device, uintptr(unsafe.Pointer(&power))) + + return float64(util.GPU), mem.Used, float64(power) / 1000.0, true +} diff --git a/modules/antigravity-api/storage.js b/modules/antigravity-api/storage.js index 00cc417..5e8e091 100644 --- a/modules/antigravity-api/storage.js +++ b/modules/antigravity-api/storage.js @@ -690,10 +690,24 @@ function getStats() { .get(); const accounts = getAccounts(); + + // 获取最近 14 天的趋势数据 + const dailyTrend = db.prepare(` + SELECT + strftime('%Y-%m-%d', datetime(created_at, 'localtime')) as date, + COUNT(*) as total, + SUM(CASE WHEN status_code = 200 THEN 1 ELSE 0 END) as success + FROM antigravity_logs + WHERE created_at >= datetime('now', '-14 days', 'localtime') + GROUP BY date + ORDER BY date ASC + `).all(); + return { total_calls: stats.total_calls || 0, success_calls: stats.success_calls || 0, fail_calls: stats.fail_calls || 0, + daily_trend: dailyTrend || [], accounts: { total: accounts.length, online: accounts.filter(a => a.status === 'online').length, diff --git a/modules/gemini-cli-api/gemini-client.js b/modules/gemini-cli-api/gemini-client.js index d716c0b..a2376e7 100644 --- a/modules/gemini-cli-api/gemini-client.js +++ b/modules/gemini-cli-api/gemini-client.js @@ -88,20 +88,25 @@ class GeminiCliClient { const generationConfig = { temperature: temperature ?? parseFloat(settings.DEFAULT_TEMPERATURE || 1), topP: top_p ?? parseFloat(settings.DEFAULT_TOP_P || 0.95), - topK: parseInt(settings.DEFAULT_TOP_K || 64), // 默认 topK (避免某些模型问题) - maxOutputTokens: Math.min(max_tokens ?? parseInt(settings.DEFAULT_MAX_TOKENS || 8192), 65536), // 限制在 64k (API 限制为 65537 exclusive) + topK: parseInt(settings.DEFAULT_TOP_K || 64), stopSequences: Array.isArray(stop) ? stop : stop ? [stop] : [], }; - // 处理 Thinking 配置 (参考 CatieCli 实现) - // 注意: gemini-3 模型**必须**包含 thinkingConfig,否则返回 404 + if (max_tokens !== undefined && max_tokens !== null) { + generationConfig.maxOutputTokens = Math.min(max_tokens, 65536); + } else if (settings.DEFAULT_MAX_TOKENS) { + generationConfig.maxOutputTokens = Math.min(parseInt(settings.DEFAULT_MAX_TOKENS), 65536); + } + const thinkingConfig = this._getThinkingConfig(model); if (thinkingConfig) { generationConfig.thinkingConfig = thinkingConfig; - // 关键校验:maxOutputTokens 必须大于等于 thinkingBudget (API 强制要求) - if (thinkingConfig.thinkingBudget && generationConfig.maxOutputTokens < thinkingConfig.thinkingBudget + 1024) { - generationConfig.maxOutputTokens = Math.min(thinkingConfig.thinkingBudget + 4096, 65536); - logger.info(`Adjusted maxOutputTokens for thinking budget (${thinkingConfig.thinkingBudget}): ${generationConfig.maxOutputTokens}`); + + if (generationConfig.maxOutputTokens && thinkingConfig.thinkingBudget) { + if (generationConfig.maxOutputTokens < thinkingConfig.thinkingBudget + 1024) { + generationConfig.maxOutputTokens = Math.min(thinkingConfig.thinkingBudget + 4096, 65536); + logger.info(`Adjusted maxOutputTokens for thinking budget (${thinkingConfig.thinkingBudget}): ${generationConfig.maxOutputTokens}`); + } } } @@ -273,9 +278,9 @@ class GeminiCliClient { // Gemini 3 系列必须包含 thinkingConfig (参考 CatieCli) if (model.includes('gemini-3')) { if (model.includes('flash')) { - return { thinkingBudget: 4096, includeThoughts: true }; + return { thinkingBudget: 2048, includeThoughts: true }; } - return { thinkingBudget: 16384, includeThoughts: true }; + return { thinkingBudget: 4096, includeThoughts: true }; } // 其他模型 (如 2.0/1.5) 不需要默认 thinkingConfig return null; diff --git a/modules/gemini-cli-api/router.js b/modules/gemini-cli-api/router.js index 53c5023..25f7469 100644 --- a/modules/gemini-cli-api/router.js +++ b/modules/gemini-cli-api/router.js @@ -130,7 +130,6 @@ const autoCheckService = { const testRequest = { model: modelId, messages: [{ role: 'user', content: 'Hi' }], - max_tokens: 5, stream: false, }; @@ -726,7 +725,6 @@ router.post('/accounts/check', async (req, res) => { const testRequest = { model: modelId, messages: [{ role: 'user', content: 'Hi' }], - max_tokens: 5, stream: false, }; diff --git a/modules/gemini-cli-api/storage.js b/modules/gemini-cli-api/storage.js index 619b5eb..e7ffd17 100644 --- a/modules/gemini-cli-api/storage.js +++ b/modules/gemini-cli-api/storage.js @@ -541,10 +541,24 @@ function getStats() { .get(); const accounts = getAccounts(); + + // 获取最近 14 天的趋势数据 + const dailyTrend = db.prepare(` + SELECT + strftime('%Y-%m-%d', datetime(created_at, 'localtime')) as date, + COUNT(*) as total, + SUM(CASE WHEN status_code = 200 THEN 1 ELSE 0 END) as success + FROM gemini_cli_logs + WHERE created_at >= datetime('now', '-14 days', 'localtime') + GROUP BY date + ORDER BY date ASC + `).all(); + return { total_calls: stats.total_calls || 0, success_calls: stats.success_calls || 0, fail_calls: stats.fail_calls || 0, + daily_trend: dailyTrend || [], accounts: { total: accounts.length, online: accounts.filter(a => a.status === 'online').length, diff --git a/modules/notification-api/channels/email.js b/modules/notification-api/channels/email.js index 32fbd6e..72b6133 100644 --- a/modules/notification-api/channels/email.js +++ b/modules/notification-api/channels/email.js @@ -26,7 +26,8 @@ class EmailChannel { const transporter = this.getTransporter(config); const mailOptions = { - from: config.auth.user, + // 支持自定义发送者名称: "Name" + from: config.sender_name ? `"${config.sender_name}" <${config.auth.user}>` : config.auth.user, to: config.to || config.auth.user, subject: title, text: message, diff --git a/modules/openai-api/openai-api.js b/modules/openai-api/openai-api.js index adc7f98..9b5c723 100644 --- a/modules/openai-api/openai-api.js +++ b/modules/openai-api/openai-api.js @@ -21,8 +21,8 @@ const HealthStatus = { }; // 默认配置 -const DEFAULT_HEALTH_CHECK_TIMEOUT = 15000; // 15 秒超时 -const DEGRADED_THRESHOLD = 6000; // 6 秒阈值 +const DEFAULT_HEALTH_CHECK_TIMEOUT = 60000; // 60 秒超时 (适应慢速思考模型) +const DEGRADED_THRESHOLD = 20000; // 20 秒阈值 /** * 发送 HTTP 请求 @@ -83,7 +83,7 @@ function apiRequest(baseUrl, apiKey, method, path, body = null) { 'Content-Type': 'application/json', 'User-Agent': 'API-Monitor/1.0', }, - timeout: 30000, + timeout: 60000, }; const req = httpModule.request(options, res => { @@ -268,7 +268,7 @@ async function testChatCompletion(baseUrl, apiKey, model = 'gpt-3.5-turbo') { * @param {string} baseUrl - API 基础 URL * @param {string} apiKey - API Key * @param {string} model - 模型名称 - * @param {number} timeout - 超时时间(毫秒),默认 15000 + * @param {number} timeout - 超时时间(毫秒),默认 60000 * @returns {Promise} 健康检查结果 */ async function healthCheckModel(baseUrl, apiKey, model, timeout = DEFAULT_HEALTH_CHECK_TIMEOUT) { @@ -301,7 +301,6 @@ async function healthCheckModel(baseUrl, apiKey, model, timeout = DEFAULT_HEALTH const requestBody = JSON.stringify({ model: model, messages: [{ role: 'user', content: 'hi' }], - max_tokens: 16384, // 增加到 16k 以兼容思考模型的需求 (max_tokens must be > budget) stream: true, // 使用流式 API }); diff --git a/modules/server-api/agent-service.js b/modules/server-api/agent-service.js index c28c6b8..0a95669 100644 --- a/modules/server-api/agent-service.js +++ b/modules/server-api/agent-service.js @@ -47,6 +47,9 @@ class AgentService extends EventEmitter { // 初始化加载或生成全局密钥 this.loadOrGenerateGlobalKey(); + + // 记录启动时间,用于抑制启动期间的通知风暴 (60秒静默期) + this.startupTime = Date.now(); } /** @@ -678,6 +681,12 @@ class AgentService extends EventEmitter { const notificationService = require('../notification-api/service'); const hostInfo = this.hostInfoCache.get(serverId); + // 检查启动静默期 (防止重启后通知风暴) + if (Date.now() - this.startupTime < 60000) { + this.log(`[主机通知] 静默期内跳过上线通知: ${server.name}`); + return; + } + notificationService.trigger('server', 'online', { serverId: serverId, serverName: server.name, diff --git a/modules/server-api/router.js b/modules/server-api/router.js index 406961d..0e1101d 100644 --- a/modules/server-api/router.js +++ b/modules/server-api/router.js @@ -271,6 +271,68 @@ router.post('/test-connection', async (req, res) => { // ==================== 服务器操作接口 ==================== +/** + * 服务器电源操作 (重启/关机) + * POST /action + * { serverId, action: 'reboot'|'shutdown' } + */ +router.post('/action', async (req, res) => { + try { + const { serverId, action } = req.body; + if (!serverId || !action) return res.status(400).json({ success: false, error: '缺少参数' }); + + const server = serverStorage.getById(serverId); + if (!server) return res.status(404).json({ success: false, error: '服务器不存在' }); + + // 智能识别操作系统 + let platform = 'linux'; + const hostInfo = agentService.getHostInfo ? agentService.getHostInfo(serverId) : null; + const metrics = agentService.getMetrics ? agentService.getMetrics(serverId) : null; + + if (hostInfo && hostInfo.platform) { + platform = hostInfo.platform.toLowerCase(); + } else if (metrics && metrics.platform) { + platform = metrics.platform.toLowerCase(); + } else if (server.os) { + platform = server.os.toLowerCase(); + } + + const isWindows = platform.includes('win'); + let command = ''; + + if (action === 'reboot') { + command = isWindows ? 'shutdown /r /t 0 /f' : 'sudo reboot'; + } else if (action === 'shutdown') { + command = isWindows ? 'shutdown /s /t 0 /f' : 'sudo shutdown -h now'; + } else { + return res.status(400).json({ success: false, error: '不支持的操作类型' }); + } + + // 优先使用 Agent 执行 + if (agentService.isOnline(serverId)) { + const { TaskTypes } = require('./protocol'); + const taskId = require('crypto').randomUUID(); + agentService.sendTask(serverId, { + id: taskId, + type: TaskTypes.COMMAND, + data: command, + timeout: 10, + }); + return res.json({ success: true, message: '电源管理命令已发送给 Agent' }); + } + + // 回退到 SSH 执行 + const result = await sshService.executeCommand(serverId, server, command); + if (result.success) { + res.json({ success: true, message: 'SSH 命令执行成功' }); + } else { + res.status(500).json({ success: false, message: 'SSH 执行失败: ' + (result.error || result.stderr) }); + } + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } +}); + /** * 手动触发探测所有服务器 */ diff --git a/modules/totp-api/router.js b/modules/totp-api/router.js index 46934b8..1da1192 100644 --- a/modules/totp-api/router.js +++ b/modules/totp-api/router.js @@ -43,6 +43,36 @@ router.get('/accounts', async (req, res) => { } }); +/** + * GET /accounts/:id + * 获取单个账号详情 + * 支持 ?showSecret=true 显示密钥 + */ +router.get('/accounts/:id', async (req, res) => { + try { + const { id } = req.params; + const account = storage.getAccount(id); + + if (!account) { + return res.status(404).json({ success: false, error: '账号不存在' }); + } + + const showSecret = req.query.showSecret === 'true'; + + res.json({ + success: true, + data: { + ...account, + secret: showSecret ? account.secret : undefined, + hasSecret: !!account.secret + } + }); + } catch (error) { + logger.error('获取账号详情失败', error.message); + res.status(500).json({ success: false, error: error.message }); + } +}); + /** * POST /accounts * 创建新账号 @@ -446,7 +476,7 @@ router.get('/extension/download', async (req, res) => { // 发送后删除临时文件 try { fs.unlinkSync(zipFile); - } catch (e) {} + } catch (e) { } }); }); } catch (error) { diff --git a/server.js b/server.js index dcf9102..241529f 100644 --- a/server.js +++ b/server.js @@ -20,7 +20,7 @@ $$ | $$ |$$ | _$$ |_ $$ \\__$$ |$$ \\__$$ | __ $$ | $$ |$$ | / $$ | $$ $$/ $$ $$/ / | $$/ $$/ $$/ $$$$$$/ $$$$$$/ $$$$$$/ $$/ \x1b[0m\x1b[33m ->>> Gravity Engineering System v0.1.2 <<<\x1b[0m +>>> Gravity Engineering System v0.1.3 <<<\x1b[0m `); // 导入中间件 diff --git a/src/css/dashboard.css b/src/css/dashboard.css index 0d356c6..b89cf79 100644 --- a/src/css/dashboard.css +++ b/src/css/dashboard.css @@ -61,7 +61,8 @@ .header-right { display: flex; align-items: center; - gap: 12px; + gap: 16px; + /* 增加间距 */ } .last-update { @@ -70,14 +71,25 @@ gap: 8px; padding: 0 16px; background: var(--bg-secondary); - border-radius: 20px; - /* 圆角增加 */ - font-size: 12px; - color: var(--text-tertiary); + border-radius: 12px; + font-size: 13px; + color: var(--text-secondary); font-family: var(--font-mono); border: 1px solid var(--border-color); - height: 38px; - /* 增加高度 */ + height: 40px; + backdrop-filter: blur(4px); + transition: all 0.3s ease; +} + +.last-update i { + color: var(--text-tertiary); + font-size: 14px; +} + +.last-update:hover { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.15); + color: var(--text-primary); } .btn-refresh-all { @@ -85,22 +97,54 @@ align-items: center; justify-content: center; gap: 8px; - height: 38px; + height: 40px; padding: 0 24px; - border-radius: 20px; + border-radius: 12px; font-size: 13px; font-weight: 600; cursor: pointer; - border: 1px solid transparent; - background: var(--primary-color); + border: 1px solid rgba(255, 255, 255, 0.1); + /* 渐变背景 */ + background: linear-gradient(135deg, var(--primary-color), #4f46e5); + /* 假设有 indigo 辅助色,如果没有则回退到 primary */ color: #fff; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - box-shadow: 0 4px 10px rgba(var(--primary-rgb), 0.2); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + /* box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.2); */ + position: relative; + overflow: hidden; +} + +/* 按钮光泽效果 */ +.btn-refresh-all::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); + transition: 0.5s; +} + +.btn-refresh-all:hover::before { + left: 100%; } .btn-refresh-all:hover:not(:disabled) { - /* transform: translateY(-2px); */ - box-shadow: 0 6px 16px rgba(var(--primary-rgb), 0.3); + transform: translateY(-1px); + box-shadow: 0 8px 20px rgba(var(--primary-rgb), 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.2); + filter: brightness(1.1); +} + +.btn-refresh-all:active:not(:disabled) { + transform: translateY(1px); + box-shadow: 0 2px 8px rgba(var(--primary-rgb), 0.2); +} + +.btn-refresh-all:disabled { + opacity: 0.7; + cursor: not-allowed; + filter: grayscale(0.5); } /* ========================================= @@ -510,6 +554,11 @@ transform: scale(1.1); } +.btn-control-circle:active { + transform: scale(0.95); + background: var(--bg-primary); +} + .btn-control-circle.play { width: 58px; height: 58px; @@ -709,7 +758,7 @@ .api-stat-group { background: var(--bg-secondary); border-radius: 12px; - padding: 20px; + padding: 10px; border: 1px solid var(--border-color); cursor: pointer; transition: all 0.2s ease; @@ -726,7 +775,6 @@ display: flex; justify-content: space-between; align-items: center; - margin-bottom: 12px; } .api-name { @@ -760,7 +808,7 @@ .paas-list { display: flex; flex-direction: column; - gap: 17px; + gap: 5px; } .paas-item { @@ -1051,7 +1099,7 @@ } .api-stat-group { - padding: 14px; + padding: 10px; } .music-content-layer { @@ -1555,7 +1603,7 @@ .paas-list { display: grid !important; grid-template-columns: repeat(2, 1fr) !important; - gap: 8px !important; + gap: 0px !important; } .paas-item { diff --git a/src/css/nav-grouped.css b/src/css/nav-grouped.css index 411f172..348216f 100644 --- a/src/css/nav-grouped.css +++ b/src/css/nav-grouped.css @@ -158,7 +158,7 @@ -webkit-backdrop-filter: blur(24px) saturate(200%); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 14px; - padding: 6px; + padding: 1px; box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25), 0 4px 10px rgba(0, 0, 0, 0.1), @@ -166,7 +166,7 @@ z-index: 1001; display: flex; flex-direction: column; - gap: 2px; + gap: 0; } /* 下拉菜单项 */ @@ -184,7 +184,8 @@ border-radius: 10px; transition: all 0.2s ease; text-align: left; - width: 100%; + margin: 3px; + width: calc(100% - 6px); } .nav-group-dropdown .dropdown-item i { @@ -297,6 +298,7 @@ top: auto; } + /* 底部导航模式下的动画方向修正 */ .nav-bottom-fixed .dropdown-fade-enter-from, .nav-bottom-fixed .dropdown-fade-leave-to { transform: translateX(-50%) translateY(8px); diff --git a/src/css/server.css b/src/css/server.css index dea54a4..b60792f 100644 --- a/src/css/server.css +++ b/src/css/server.css @@ -370,8 +370,10 @@ .server-detail-item { display: flex; justify-content: space-between; + align-items: flex-start; padding: 8px 0; border-bottom: 1px solid var(--border-color); + gap: 12px; } .server-detail-item:last-child { @@ -382,13 +384,26 @@ font-size: 12px; color: var(--text-tertiary); font-weight: 500; + white-space: nowrap; + flex-shrink: 0; + margin-top: 1px; + /* Align with first line of value */ } .server-detail-value { font-size: 13px; color: var(--server-primary, #00d4aa); font-weight: 700; - /* font-family: var(--font-mono, 'JetBrains Mono', monospace); */ + text-align: right; + word-break: break-word; + max-width: 70%; + + /* Limit to 2 lines */ + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.4; } /* 杩涘害鏉?*/ diff --git a/src/css/styles.css b/src/css/styles.css index c690776..f2cc8a4 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -44,6 +44,28 @@ textarea { resize: none; } + +/* button, +a, +input, +select, +textarea, +label, +summary, +[role="button"], +[role="link"], +[role="checkbox"], +[role="radio"], +[role="switch"], +[role="tab"], +[tabindex] { + will-change: transform, background-color, border-color, opacity; + transform-origin: center center; + backface-visibility: hidden; + -webkit-font-smoothing: antialiased; +} */ + + code, pre, .code-font, @@ -778,6 +800,135 @@ body.modal-open { overflow: hidden; } +/* ==================== Endpoint Action Buttons (OpenAI et al.) ==================== */ + +.endpoint-badges { + display: flex; + align-items: center; + gap: 8px; +} + +.endpoint-action-btn { + display: flex; + align-items: center; + justify-content: center; + background: rgba(16, 163, 127, 0.06); + border: 1px solid rgba(16, 163, 127, 0.15); + color: var(--openai-primary); + border-radius: 12px; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + overflow: hidden; + height: 42px; + /* 统一高度 */ + box-sizing: border-box; +} + +.endpoint-action-btn:hover { + background: rgba(16, 163, 127, 0.12); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(16, 163, 127, 0.15); + border-color: rgba(16, 163, 127, 0.3); +} + +.endpoint-action-btn:active { + transform: translateY(0); + background: rgba(16, 163, 127, 0.15); +} + +/* Icons */ +.endpoint-action-btn i { + font-size: 16px; + transition: transform 0.3s ease; +} + +.endpoint-action-btn:hover i { + transform: scale(1.1); +} + +/* Health Check Button (Square) */ +.endpoint-action-btn.btn-health { + width: 42px; + font-size: 18px; + /* 图标稍大 */ +} + +/* Models Count Button (Rect) */ +.endpoint-action-btn.btn-models { + padding: 0 14px; + gap: 10px; + min-width: 80px; +} + +.endpoint-action-btn.btn-models i { + font-size: 18px; + opacity: 0.9; +} + +/* Info Text Layout */ +.endpoint-action-btn .action-info { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + line-height: 1; +} + +.endpoint-action-btn .action-info .label { + font-size: 10px; + font-weight: 600; + color: var(--openai-dark); + /* 深一点的绿色 */ + opacity: 0.7; + margin-bottom: 2px; + text-transform: uppercase; +} + +.endpoint-action-btn .action-info .value { + font-size: 15px; + font-weight: 800; + font-family: var(--font-mono); + color: var(--openai-primary); +} + +/* Loading Spin */ +.endpoint-action-btn i.refreshing { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +/* Dark Mode Adaptation */ +@media (prefers-color-scheme: dark) { + .endpoint-action-btn { + background: rgba(16, 163, 127, 0.1); + border-color: rgba(16, 163, 127, 0.2); + } + + .endpoint-action-btn:hover { + background: rgba(16, 163, 127, 0.2); + border-color: rgba(16, 163, 127, 0.4); + } + + .endpoint-action-btn .action-info .label { + color: #a7f3d0; + /* 浅色文字 */ + } + + .endpoint-action-btn .action-info .value { + color: #34d399; + } +} + /* App Layout */ .app-wrapper { display: flex; @@ -1708,7 +1859,7 @@ body.modal-open { rgba(var(--zeabur-secondary-rgb), 0.05) 100%); border: 1px solid rgba(var(--zeabur-primary-rgb), 0.15); border-radius: 12px; - padding: 8px 6px; + padding: 3px 6px; margin: 0; backdrop-filter: blur(8px); transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); @@ -1725,7 +1876,7 @@ body.modal-open { display: flex; align-items: center; gap: 8px; - padding: 4px 8px; + padding: 4px; } .zeabur-balance-divider { diff --git a/src/js/main.js b/src/js/main.js index c694370..e5345d2 100644 --- a/src/js/main.js +++ b/src/js/main.js @@ -107,7 +107,7 @@ import { uptimeData, uptimeMethods, uptimeComputed } from './modules/uptime.js'; import { aliyunMethods } from './modules/aliyun.js'; import { tencentMethods } from './modules/tencent.js'; import { notificationData, notificationMethods } from './modules/notification.js'; -import { formatDateTime, formatFileSize, maskAddress, formatRegion } from './modules/utils.js'; +import { formatDateTime, formatFileSize, maskAddress, formatRegion, formatUptime } from './modules/utils.js'; // 导入全局状态 import { store, MODULE_CONFIG, MODULE_GROUPS } from './store.js'; @@ -1696,6 +1696,7 @@ const app = createApp({ formatDateTime, formatFileSize, formatRegion, + formatUptime, renderMarkdown, // 带缓存的日志内容渲染(避免重复解析 Base64 图片) diff --git a/src/js/modules/dashboard.js b/src/js/modules/dashboard.js index 4354c9f..5359739 100644 --- a/src/js/modules/dashboard.js +++ b/src/js/modules/dashboard.js @@ -190,6 +190,283 @@ export const dashboardMethods = { // Fire both, don't wait for all to finish before updating individual stats // But await the group to know when API section is fully done (for loading state) await Promise.allSettled([updateAntigravity(), updateGemini()]); + + // 渲染图表 + this.renderApiCharts(); + }, + + /** + * 渲染 API 趋势图表 + */ + renderApiCharts() { + // 确保 DOM 更新后执行 + setTimeout(() => { + if (store.dashboardStats.antigravity.daily_trend) { + this.drawTrendChart('agChart', store.dashboardStats.antigravity.daily_trend, '#f97316'); // Orange for AG + } + if (store.dashboardStats.geminiCli.daily_trend) { + this.drawTrendChart('geminiChart', store.dashboardStats.geminiCli.daily_trend, '#3b82f6'); // Blue for Gemini + } + }, 100); + }, + + /** + * 绘制 Canvas 趋势图 (Smooth Curve + Interaction) + */ + drawTrendChart(refName, data, color) { + const app = document.querySelector('#app')?.__vue_app__?._instance; + + let canvas = null; + let container = null; + if (refName === 'agChart') { + const groups = document.querySelectorAll('.api-stat-group'); + if (groups.length >= 1) { + canvas = groups[0].querySelector('canvas'); + container = groups[0].querySelector('.chart-container'); + } + } else if (refName === 'geminiChart') { + const groups = document.querySelectorAll('.api-stat-group'); + if (groups.length >= 2) { + canvas = groups[1].querySelector('canvas'); + container = groups[1].querySelector('.chart-container'); + } + } + + if (!canvas) return; + + // Check availability of data + if (!data || data.length === 0) { + const ctx = canvas.getContext('2d'); + const rect = canvas.getBoundingClientRect(); + canvas.width = rect.width * (window.devicePixelRatio || 1); + canvas.height = rect.height * (window.devicePixelRatio || 1); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = 'rgba(255, 255, 255, 0.1)'; + ctx.font = '10px sans-serif'; + ctx.fillText('No Data', 10, canvas.height / 2); + return; + } + + // Save state on canvas for interaction + canvas.chartState = { + data: data.map(d => d.total), + labels: data.map(d => d.date), // Assuming data has date + color: color, + paddingX: 10, + paddingTop: 8, // More space for tooltip + paddingBottom: 5 + }; + + // Attach event listeners once + if (!canvas.hasInteractionListeners) { + canvas.hasInteractionListeners = true; + + canvas.addEventListener('mousemove', (e) => { + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const state = canvas.chartState; + if (!state) return; + + const effectiveWidth = rect.width - (state.paddingX * 2); + const stepX = effectiveWidth / (state.data.length - 1 || 1); + + // Find closest index + let index = Math.round((x - state.paddingX) / stepX); + if (index < 0) index = 0; + if (index >= state.data.length) index = state.data.length - 1; + + this.renderChartFrame(canvas, index); + }); + + canvas.addEventListener('mouseleave', () => { + this.renderChartFrame(canvas, null); + }); + } + + // Initial Render + this.renderChartFrame(canvas, null); + }, + + /** + * Internal render function + */ + renderChartFrame(canvas, highlightIndex) { + const ctx = canvas.getContext('2d'); + const dpr = window.devicePixelRatio || 1; + + // Get theme colors + const styles = getComputedStyle(document.body); + const tooltipBgColor = styles.getPropertyValue('--card-bg').trim(); + const tooltipTextColor = styles.getPropertyValue('--text-primary').trim(); + const guideLineColor = styles.getPropertyValue('--border-color').trim(); + + const rect = canvas.getBoundingClientRect(); + const state = canvas.chartState; + + // Logic coords + const width = rect.width; + const height = rect.height; + + // Physical coords + canvas.width = width * dpr; + canvas.height = height * dpr; + ctx.scale(dpr, dpr); + + ctx.clearRect(0, 0, width, height); + + const { data, color, paddingX, paddingTop, paddingBottom } = state; + const maxVal = Math.max(...data, 10); + const minVal = 0; + const range = maxVal - minVal; + + const values = data; + const drawingHeight = height - paddingBottom - paddingTop; + const stepX = (width - paddingX * 2) / (values.length - 1 || 1); + + // Helper to get coords + const getPoint = (i) => { + const x = paddingX + i * stepX; + const y = paddingTop + drawingHeight - (values[i] / maxVal) * drawingHeight; + return { x, y }; + }; + + // 1. Fill Gradient Area + const gradient = ctx.createLinearGradient(0, 0, 0, height); + gradient.addColorStop(0, color + '66'); // 40% + gradient.addColorStop(1, color + '00'); // 0% + ctx.fillStyle = gradient; + + ctx.beginPath(); + ctx.moveTo(paddingX, height); + + // Smooth Curve for Fill + if (values.length > 1) { + const first = getPoint(0); + ctx.lineTo(first.x, first.y); + + for (let i = 0; i < values.length - 1; i++) { + const p0 = getPoint(i > 0 ? i - 1 : i); + const p1 = getPoint(i); + const p2 = getPoint(i + 1); + const p3 = getPoint(i + 2 < values.length ? i + 2 : i + 1); + + const cp1x = p1.x + (p2.x - p0.x) * 0.2; // Tension 0.2 + const cp1y = p1.y + (p2.y - p0.y) * 0.2; + const cp2x = p2.x - (p3.x - p1.x) * 0.2; + const cp2y = p2.y - (p3.y - p1.y) * 0.2; + + ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y); + } + } else { + const p = getPoint(0); + ctx.lineTo(p.x, p.y); + } + + ctx.lineTo(width - paddingX, height); + ctx.closePath(); + ctx.fill(); + + // 2. Stroke Line (Smooth) + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.beginPath(); + + if (values.length > 1) { + const first = getPoint(0); + ctx.moveTo(first.x, first.y); + + for (let i = 0; i < values.length - 1; i++) { + const p0 = getPoint(i > 0 ? i - 1 : i); + const p1 = getPoint(i); + const p2 = getPoint(i + 1); + const p3 = getPoint(i + 2 < values.length ? i + 2 : i + 1); + + const cp1x = p1.x + (p2.x - p0.x) * 0.2; + const cp1y = p1.y + (p2.y - p0.y) * 0.2; + const cp2x = p2.x - (p3.x - p1.x) * 0.2; + const cp2y = p2.y - (p3.y - p1.y) * 0.2; + + ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y); + } + } else { + const p = getPoint(0); + ctx.moveTo(p.x, p.y); + ctx.lineTo(p.x, p.y); + } + ctx.stroke(); + + // 3. Highlight Interaction + if (highlightIndex !== null && highlightIndex >= 0 && highlightIndex < values.length) { + const p = getPoint(highlightIndex); + + // Vertical Line + ctx.beginPath(); + ctx.moveTo(p.x, paddingTop); + ctx.lineTo(p.x, height); + ctx.strokeStyle = guideLineColor; + ctx.lineWidth = 1; + ctx.setLineDash([2, 2]); + ctx.stroke(); + ctx.setLineDash([]); // Reset + + // Outer Glow + ctx.beginPath(); + ctx.arc(p.x, p.y, 6, 0, Math.PI * 2); + ctx.fillStyle = color + '40'; // Transparent + ctx.fill(); + + // Inner Dot + ctx.beginPath(); + ctx.arc(p.x, p.y, 3, 0, Math.PI * 2); + ctx.fillStyle = '#fff'; + ctx.fill(); + + // Tooltip Text + const text = `${values[highlightIndex]}`; + ctx.font = 'bold 11px Inter, sans-serif'; + const textMetrics = ctx.measureText(text); + const padding = 4; + const boxWidth = textMetrics.width + padding * 2; + const boxHeight = 18; + + // Keep tooltip within bounds + // Add buffer to prevent stroke clipping (lineWidth=1) + const edgeBuffer = 2; + + let boxX = p.x - boxWidth / 2; + let boxY = p.y - 12 - boxHeight; + + if (boxX < edgeBuffer) boxX = edgeBuffer; + if (boxX + boxWidth > width - edgeBuffer) boxX = width - boxWidth - edgeBuffer; + if (boxY < 0) boxY = p.y + 12; // Flip to bottom if top clipped + + // Tooltip BG + ctx.fillStyle = tooltipBgColor; + ctx.strokeStyle = color; + ctx.lineWidth = 1; + + // Round Rect for Tooltip + ctx.beginPath(); + const r = 4; + ctx.roundRect(boxX, boxY, boxWidth, boxHeight, r); + ctx.fill(); + ctx.stroke(); + + // Text + ctx.fillStyle = tooltipTextColor; + ctx.fillText(text, boxX + padding, boxY + 12); + + } else { + // Draw last point if not hovering + const lastIdx = values.length - 1; + const p = getPoint(lastIdx); + ctx.beginPath(); + ctx.arc(p.x, p.y, 3, 0, Math.PI * 2); + ctx.fillStyle = '#fff'; + ctx.fill(); + } }, /** diff --git a/src/js/modules/host.js b/src/js/modules/host.js index a6e5fc9..2627a4a 100644 --- a/src/js/modules/host.js +++ b/src/js/modules/host.js @@ -2167,37 +2167,7 @@ export const hostMethods = { return platform.substring(0, 10); }, - formatUptime(uptimeStr) { - if (!uptimeStr || typeof uptimeStr !== 'string') return uptimeStr; - // 移除 "up " 前缀 - const str = uptimeStr.replace(/^up\s+/i, ''); - - // 提取各个时间部分 - const weekMatch = str.match(/(\d+)\s*(weeks?|w)/i); - const dayMatch = str.match(/(\d+)\s*(days?|d)/i); - const hourMatch = str.match(/(\d+)\s*(hours?|h)/i); - const minMatch = str.match(/(\d+)\s*(minutes?|m)/i); - - let days = dayMatch ? parseInt(dayMatch[1], 10) : 0; - const weeks = weekMatch ? parseInt(weekMatch[1], 10) : 0; - const hours = hourMatch ? parseInt(hourMatch[1], 10) : 0; - const minutes = minMatch ? parseInt(minMatch[1], 10) : 0; - - // 将周转换为天并累加 - days += weeks * 7; - - // 构建中文格式 - let result = ''; - if (days > 0) result += `${days}天`; - if (hours > 0) result += `${hours}时`; - if (minutes > 0) result += `${minutes}分`; - - // 如果都是0,显示 "0分" - if (result === '') result = '0分'; - - return result; - }, translateInfoKey(key) { const translations = { diff --git a/src/js/modules/notification.js b/src/js/modules/notification.js index 00ee116..a5c5d85 100644 --- a/src/js/modules/notification.js +++ b/src/js/modules/notification.js @@ -157,6 +157,7 @@ export const notificationMethods = { port: 465, secure: true, auth: { user: '', pass: '' }, + sender_name: '', to: '', bot_token: '', chat_id: '', @@ -175,6 +176,7 @@ export const notificationMethods = { port: 465, secure: true, auth: { user: '', pass: '' }, + sender_name: '', to: '', bot_token: '', chat_id: '', diff --git a/src/js/modules/openai.js b/src/js/modules/openai.js index 7d1b2ee..d5e421a 100644 --- a/src/js/modules/openai.js +++ b/src/js/modules/openai.js @@ -15,20 +15,20 @@ export const openaiMethods = { // 从内容中提取思考标签(支持各种变体如 , , 等) extractThinkingContent(content) { if (!content || typeof content !== 'string') return { thinking: '', cleaned: content || '' }; - + // 匹配各种思考标签变体: , , , etc. const thinkingPattern = /<(think(?:ing|_\w+)?)\s*>([\s\S]*?)<\/\1>/gi; let thinking = ''; let cleaned = content; - + let match; while ((match = thinkingPattern.exec(content)) !== null) { thinking += match[2].trim() + '\n'; } - + // 移除所有思考标签 cleaned = content.replace(thinkingPattern, '').trim(); - + return { thinking: thinking.trim(), cleaned }; }, @@ -51,13 +51,13 @@ export const openaiMethods = { // 对于 content 字段,先过滤思考标签 if (field === 'content' && typeof content === 'string') { const { thinking, cleaned } = this.extractThinkingContent(content); - + // 如果提取到了思考内容且 msg.reasoning 为空,自动填充 if (thinking && !msg.reasoning) { msg.reasoning = thinking; msg.showReasoning = false; // 默认折叠 } - + content = cleaned; } @@ -1793,7 +1793,7 @@ ${conversationText} }, // 创建新会话 - async createChatSession() { + async createChatSession(resetToDefault = false) { try { // 创建新会话时,强制使用全局默认设置(防止沿用上一个会话的“脏”状态) const globalSystemPrompt = localStorage.getItem('openai_system_prompt') || '你是一个有用的 AI 助手。'; @@ -1804,7 +1804,9 @@ ${conversationText} // 恢复当前会话状态为全局默认 store.openaiChatSystemPrompt = globalSystemPrompt; - if (store.openaiDefaultChatModel) { + + // 只有在明确要求重置或当前没有选定模型时,才使用默认模型 + if (store.openaiDefaultChatModel && (resetToDefault || !store.openaiChatModel)) { store.openaiChatModel = store.openaiDefaultChatModel; } @@ -2494,6 +2496,11 @@ ${conversationText} // 设置模型 store.openaiChatModel = modelName; + // 清空当前会话状态,确保开始新对话 + store.openaiChatCurrentSessionId = null; + store.openaiChatMessages = []; + store.openaiChatSelectedSessionIds = []; + // 切换到对话标签页 store.openaiCurrentTab = 'chat'; diff --git a/src/js/modules/server-status.js b/src/js/modules/server-status.js index 253cf6c..c6d5f4c 100644 --- a/src/js/modules/server-status.js +++ b/src/js/modules/server-status.js @@ -130,19 +130,7 @@ export const serverStatusMethods = { return '#22c55e'; }, - /** - * 格式化运行时间 - */ - formatUptime(seconds) { - if (!seconds) return '-'; - const days = Math.floor(seconds / 86400); - const hours = Math.floor((seconds % 86400) / 3600); - const mins = Math.floor((seconds % 3600) / 60); - - if (days > 0) return `${days}天 ${hours}时`; - if (hours > 0) return `${hours}时 ${mins}分`; - return `${mins}分`; - }, + /** * 获取容器状态颜色 diff --git a/src/js/modules/server.js b/src/js/modules/server.js index 101e055..7d9d444 100644 --- a/src/js/modules/server.js +++ b/src/js/modules/server.js @@ -4,6 +4,7 @@ import { store } from '../store.js'; import { toast } from './toast.js'; +import { formatUptime, escapeHtml } from './utils.js'; // 模块状态 const state = { @@ -503,41 +504,7 @@ function renderServerDetails(server, info) { `; } -/** - * 格式化运行时间为中文格式 - * 将 "up 6 days, 2 hours, 32 minutes" 转换为 "6天2时32分" - */ -function formatUptime(uptimeStr) { - if (!uptimeStr || typeof uptimeStr !== 'string') return uptimeStr; - - // 移除 "up " 前缀 - const str = uptimeStr.replace(/^up\s+/i, ''); - - // 提取各个时间部分 - const weekMatch = str.match(/(\d+)\s*weeks?/i); - const dayMatch = str.match(/(\d+)\s*days?/i); - const hourMatch = str.match(/(\d+)\s*hours?/i); - const minMatch = str.match(/(\d+)\s*minutes?/i); - - let days = dayMatch ? parseInt(dayMatch[1], 10) : 0; - const weeks = weekMatch ? parseInt(weekMatch[1], 10) : 0; - const hours = hourMatch ? parseInt(hourMatch[1], 10) : 0; - const minutes = minMatch ? parseInt(minMatch[1], 10) : 0; - - // 将周转换为天并累加 - days += weeks * 7; - - // 构建中文格式 - let result = ''; - if (days > 0) result += `${days}天`; - if (hours > 0) result += `${hours}时`; - if (minutes > 0) result += `${minutes}分`; - // 如果都是0,显示 "0分" - if (result === '') result = '0分'; - - return result; -} /** * 渲染详情项 @@ -867,15 +834,7 @@ function bindServerCardEvents() { // 事件已通过 onclick 属性绑定 } -/** - * HTML 转义 - */ -function escapeHtml(text) { - const div = document.createElement('div'); - if (text === null || text === undefined) return ''; - div.textContent = text; - return div.innerHTML; -} + /** * Vue 实例混入方法 - 用于解耦 main.js diff --git a/src/js/modules/totp.js b/src/js/modules/totp.js index 01e8874..c232d52 100644 --- a/src/js/modules/totp.js +++ b/src/js/modules/totp.js @@ -161,6 +161,25 @@ export const totpMethods = { this.showTotpModal = true; }, + async toggleSecretVisibility() { + this.totpShowSecret = !this.totpShowSecret; + + if (this.totpShowSecret && this.totpModalMode === 'edit' && this.totpForm.secret.includes('•••')) { + try { + const response = await fetch(`/api/totp/accounts/${this.totpEditingId}?showSecret=true`); + const data = await response.json(); + if (data.success && data.data.secret) { + this.totpForm.secret = data.data.secret; + } else { + this.showGlobalToast('获取密钥失败', 'error'); + } + } catch (error) { + console.error('[TOTP] 获取密钥失败:', error); + this.showGlobalToast('获取密钥失败', 'error'); + } + } + }, + switchTotpAddTab(tab) { this.totpAddTab = tab; if (tab !== 'scan') { diff --git a/src/js/modules/utils.js b/src/js/modules/utils.js index 4cda06f..0815858 100644 --- a/src/js/modules/utils.js +++ b/src/js/modules/utils.js @@ -11,9 +11,7 @@ import 'katex/dist/katex.min.css'; /** * 渲染 Markdown 为 HTML (安全模式) - * 支持多模态数组、JSON 对象及普通字符串 - * @param {any} text - 输入内容 - * @returns {string} 过滤后的 HTML + * ... (existing renderMarkdown implementation) */ export function renderMarkdown(text) { if (text === undefined || text === null) return ''; @@ -222,6 +220,73 @@ export function formatFileSize(bytes) { return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } +/** + * 格式化运行时间 (增强版) + * 支持 "up 1 day, 10:23", "12345" (秒), "2 days 3 hours" 等格式 + * @param {string|number} uptimeStr - 运行时间字符串或秒数 + * @returns {string} 中文格式时间 (e.g. "1天 10时 23分") + */ +export function formatUptime(uptimeStr) { + if (uptimeStr === undefined || uptimeStr === null) return '-'; + + // 处理数字输入 (视为秒) + if (typeof uptimeStr === 'number') { + const days = Math.floor(uptimeStr / 86400); + const hours = Math.floor((uptimeStr % 86400) / 3600); + const minutes = Math.floor((uptimeStr % 3600) / 60); + + let result = ''; + if (days > 0) result += `${days}天`; + if (hours > 0) result += `${hours}时`; + if (minutes > 0) result += `${minutes}分`; + return result || '0分'; + } + + if (typeof uptimeStr !== 'string') return uptimeStr; + + // 移除 "up " 前缀 + const str = uptimeStr.replace(/^up\s+/i, ''); + + let days = 0; + let hours = 0; + let minutes = 0; + + // 尝试匹配 "1 day, 10:23" 或 "10:23" 格式 (Linux uptime 常见) + const timeMatch = str.match(/(?:(\d+)\s*days?,\s*)?(\d{1,2}):(\d{2})/i); + + if (timeMatch) { + if (timeMatch[1]) days = parseInt(timeMatch[1], 10); + hours = parseInt(timeMatch[2], 10); + minutes = parseInt(timeMatch[3], 10); + } else { + // 尝试匹配 "1 week, 2 days" 或 "1w, 2d" 格式 + const weekMatch = str.match(/(\d+)\s*(weeks?|w)/i); + const dayMatch = str.match(/(\d+)\s*(days?|d)/i); + const hourMatch = str.match(/(\d+)\s*(hours?|h)/i); + const minMatch = str.match(/(\d+)\s*(minutes?|m)/i); + + if (dayMatch) days = parseInt(dayMatch[1], 10); + if (weekMatch) days += parseInt(weekMatch[1], 10) * 7; + if (hourMatch) hours = parseInt(hourMatch[1], 10); + if (minMatch) minutes = parseInt(minMatch[1], 10); + } + + // 构建中文格式 (紧凑) + let result = ''; + if (days > 0) result += `${days}天`; + if (hours > 0) result += `${hours}时`; + if (minutes > 0) result += `${minutes}分`; + + // 如果都是0,但有一个 parsing 发生,显示 "0分" + // 如果没有任何匹配,返回原字符串 (可能是其他格式) + if (result === '') { + if (str.includes('min') || str.includes('sec')) return '刚刚'; + return uptimeStr; // 原样返回,防止显示错误 + } + + return result; +} + /** * 防抖函数 * @param {Function} func - 要防抖的函数 @@ -392,3 +457,30 @@ export function formatRegion(region) { return regionStr; } + +/** + * 格式化网速为紧凑格式 + * 例如: "1.5 MB/s" -> "1.5M", "10 KB/s" -> "10K", "0 B/s" -> "0B" + */ +export function formatSpeedCompact(speed) { + if (!speed) return '0B'; + // 移除 "/s" 后缀,移除空格,保留数字和单位字母 + return speed + .replace(/\/s$/i, '') // 移除 /s + .replace(/\s+/g, '') // 移除空格 + .replace(/(\d+\.?\d*)([KMGT]?)B?/i, '$1$2'); // 简化单位 +} + +/** + * 解析网速为数字和单位分离的对象 + * 例如: "1.5 MB/s" -> { num: "1.5", unit: "M" } + */ +export function parseSpeed(speed) { + if (!speed) return { num: '0', unit: 'B' }; + const cleaned = speed.replace(/\/s$/i, '').replace(/\s+/g, ''); + const match = cleaned.match(/^(\d+\.?\d*)([KMGT]?)B?$/i); + if (match) { + return { num: match[1], unit: match[2] ? match[2].toUpperCase() : 'B' }; + } + return { num: '0', unit: 'B' }; +} diff --git a/src/js/store.js b/src/js/store.js index be4cdca..61b8c8d 100644 --- a/src/js/store.js +++ b/src/js/store.js @@ -148,7 +148,7 @@ export function getModuleIcon(moduleId) { export const store = reactive({ // 认证与基础状态 - isAuthenticated: false, + isAuthenticated: true, isCheckingAuth: true, showLoginModal: false, showSetPasswordModal: false, @@ -630,7 +630,7 @@ export const store = reactive({ openaiHealthCheckForm: { // 健康检测表单 useKey: 'single', // single: 单端点, all: 所有端点 concurrency: false, // 是否开启并发检测 - timeout: 15 // 超时时间(s) + timeout: 60 // 超时时间(s) }, // 自动标题生成配置 diff --git a/src/middleware/validation.js b/src/middleware/validation.js index 830d07f..e1935df 100644 --- a/src/middleware/validation.js +++ b/src/middleware/validation.js @@ -23,8 +23,12 @@ function validate(schemas) { if (schemas.body) { const result = schemas.body.safeParse(req.body); if (!result.success) { + const issues = result.error?.errors || []; + if (issues.length === 0) { + console.error('Validation failed but no errors found:', result.error); + } errors.push( - ...result.error.errors.map((e) => ({ + ...issues.map((e) => ({ field: `body.${e.path.join('.')}`, message: e.message, code: e.code, @@ -39,8 +43,9 @@ function validate(schemas) { if (schemas.query) { const result = schemas.query.safeParse(req.query); if (!result.success) { + const issues = result.error?.errors || []; errors.push( - ...result.error.errors.map((e) => ({ + ...issues.map((e) => ({ field: `query.${e.path.join('.')}`, message: e.message, code: e.code, @@ -55,8 +60,9 @@ function validate(schemas) { if (schemas.params) { const result = schemas.params.safeParse(req.params); if (!result.success) { + const issues = result.error?.errors || []; errors.push( - ...result.error.errors.map((e) => ({ + ...issues.map((e) => ({ field: `params.${e.path.join('.')}`, message: e.message, code: e.code, diff --git a/src/templates/dashboard.html b/src/templates/dashboard.html index bb3f61a..5de2d99 100644 --- a/src/templates/dashboard.html +++ b/src/templates/dashboard.html @@ -1,4 +1,4 @@ -
+
@@ -247,10 +247,9 @@

API 调用概览

总调用 {{ dashboardStats.antigravity.total_calls }} 次
-
-
-
+
+
@@ -261,10 +260,9 @@

API 调用概览

总调用 {{ dashboardStats.geminiCli.total_calls }} 次
-
-
-
+
+
@@ -273,12 +271,13 @@

API 调用概览

- +
-

云服务

+

服务 & 工具

+
@@ -294,6 +293,7 @@

Zeabur

{{ dashboardStats.paas.zeabur.running }}
+
@@ -307,6 +307,7 @@

Koyeb

{{ dashboardStats.paas.koyeb.running }}
+
@@ -322,15 +323,10 @@

Fly.io

{{ dashboardStats.paas.fly.running }}
-
-
- - -
-
-
+ +
-
+
diff --git a/src/templates/notification.html b/src/templates/notification.html index 5454847..4ee5212 100644 --- a/src/templates/notification.html +++ b/src/templates/notification.html @@ -238,6 +238,11 @@

+ + +

-
-
-
- -
-
+ +
+
-
-
-
- -
-
- 模型 - {{ endpoint.models.length }} -
+ + +
+ +
+ 模型 + {{ endpoint.models.length }}
@@ -292,7 +287,7 @@
- diff --git a/src/templates/server.html b/src/templates/server.html index cd989a2..2643679 100644 --- a/src/templates/server.html +++ b/src/templates/server.html @@ -905,7 +905,7 @@

网络速率

-

容器虚拟化

+

Docker 容器

diff --git a/src/templates/totp.html b/src/templates/totp.html index e672074..8558ef2 100644 --- a/src/templates/totp.html +++ b/src/templates/totp.html @@ -557,7 +557,7 @@
-