Spaces:
Running
Running
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>控制面板 - sharween/xiaoxiaxia</title> | |
| <style> | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| html { font-size: 15px; } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; | |
| background: #1a1a2e; | |
| color: #e0e0e0; | |
| min-height: 100vh; | |
| padding: 0; | |
| } | |
| .container { max-width: 1200px; margin: 0 auto; } | |
| /* Header */ | |
| .header { | |
| text-align: center; | |
| padding: 32px 20px 24px; | |
| margin-bottom: 28px; | |
| background: linear-gradient(135deg, #16213e 0%, #0f3460 100%); | |
| border-radius: 12px; | |
| border: 1px solid rgba(255,255,255,0.06); | |
| box-shadow: 0 4px 24px rgba(0,0,0,0.3); | |
| } | |
| .header h1 { font-size: 1.8rem; font-weight: 600; color: #fff; letter-spacing: 1px; } | |
| .header .subtitle { | |
| margin-top: 6px; | |
| font-size: 0.95rem; | |
| color: #8899b4; | |
| font-family: "SF Mono", "Fira Code", "Consolas", monospace; | |
| } | |
| /* Card base */ | |
| .card { | |
| background: #16213e; | |
| border-radius: 10px; | |
| border: 1px solid rgba(255,255,255,0.06); | |
| padding: 20px 24px; | |
| box-shadow: 0 2px 12px rgba(0,0,0,0.2); | |
| } | |
| .card-header, .card-title { | |
| font-size: 1.1rem; | |
| font-weight: 600; | |
| color: #c8d6e5; | |
| margin-bottom: 16px; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| /* Status Cards Row */ | |
| .status-row { | |
| display: grid; | |
| grid-template-columns: repeat(3, 1fr); | |
| gap: 16px; | |
| margin-bottom: 20px; | |
| } | |
| .status-card { | |
| background: #16213e; | |
| border-radius: 10px; | |
| border: 1px solid rgba(255,255,255,0.06); | |
| padding: 20px; | |
| box-shadow: 0 2px 12px rgba(0,0,0,0.2); | |
| transition: border-color 0.2s; | |
| } | |
| .status-card:hover { border-color: rgba(255,255,255,0.12); } | |
| .status-card .service-name { | |
| font-size: 1rem; | |
| font-weight: 600; | |
| color: #c8d6e5; | |
| margin-bottom: 10px; | |
| } | |
| .status-card .status-line { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| margin-bottom: 6px; | |
| } | |
| .status-dot { | |
| width: 10px; | |
| height: 10px; | |
| border-radius: 50%; | |
| flex-shrink: 0; | |
| transition: background 0.3s; | |
| } | |
| .status-dot.running { background: #4ecca3; box-shadow: 0 0 6px #4ecca3; } | |
| .status-dot.stopped { background: #e23e57; box-shadow: 0 0 6px #e23e57; } | |
| .status-dot.unknown { background: #666; } | |
| .status-dot.loading { background: #f0a500; animation: pulse 0.8s infinite; } | |
| @keyframes pulse { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.3; } | |
| } | |
| .status-text { | |
| font-size: 0.85rem; | |
| color: #8899b4; | |
| font-family: "SF Mono", "Fira Code", "Consolas", monospace; | |
| } | |
| .status-label { | |
| font-size: 0.82rem; | |
| color: #666; | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| } | |
| .status-info { margin-top: 4px; font-size: 0.82rem; color: #667; } | |
| /* Quick Actions */ | |
| .actions-row { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 10px; | |
| margin-bottom: 20px; | |
| } | |
| .btn { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 6px; | |
| padding: 10px 20px; | |
| border: none; | |
| border-radius: 8px; | |
| font-size: 0.88rem; | |
| font-family: inherit; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| white-space: nowrap; | |
| text-decoration: none; | |
| color: #e0e0e0; | |
| background: #0f3460; | |
| } | |
| .btn:hover { background: #1a4a7a; transform: translateY(-1px); } | |
| .btn:active { transform: translateY(0); } | |
| .btn:disabled { | |
| opacity: 0.5; | |
| cursor: not-allowed; | |
| transform: none; | |
| } | |
| .btn-primary { background: #0f3460; } | |
| .btn-primary:hover:not(:disabled) { background: #1a4a7a; } | |
| .btn-success { background: #1b6b4a; } | |
| .btn-success:hover:not(:disabled) { background: #248a5e; } | |
| .btn-warning { background: #a06a00; } | |
| .btn-warning:hover:not(:disabled) { background: #c88200; } | |
| .btn-danger { background: #8a1f2e; } | |
| .btn-danger:hover:not(:disabled) { background: #b0283c; } | |
| .btn-outline { | |
| background: transparent; | |
| border: 1px solid #334; | |
| } | |
| .btn-outline:hover:not(:disabled) { background: rgba(255,255,255,0.05); } | |
| .btn-sm { padding: 6px 14px; font-size: 0.82rem; } | |
| /* Backup Section */ | |
| .backup-section { margin-bottom: 20px; } | |
| .backup-section .card-body { display: flex; gap: 12px; flex-wrap: wrap; } | |
| .backup-section .full-backup-warning { | |
| width: 100%; | |
| font-size: 0.82rem; | |
| color: #f0a500; | |
| padding: 8px 12px; | |
| background: rgba(240,165,0,0.08); | |
| border-radius: 6px; | |
| border: 1px solid rgba(240,165,0,0.2); | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| } | |
| .feedback { | |
| width: 100%; | |
| padding: 8px 12px; | |
| border-radius: 6px; | |
| font-size: 0.85rem; | |
| display: none; | |
| } | |
| .feedback.show { display: block; } | |
| .feedback.success { background: rgba(78,204,163,0.12); color: #4ecca3; border: 1px solid rgba(78,204,163,0.25); } | |
| .feedback.error { background: rgba(226,62,87,0.12); color: #e23e57; border: 1px solid rgba(226,62,87,0.25); } | |
| .feedback.info { background: rgba(15,52,96,0.3); color: #6cb4ee; border: 1px solid rgba(15,52,96,0.4); } | |
| .spinner { | |
| display: inline-block; | |
| width: 14px; | |
| height: 14px; | |
| border: 2px solid rgba(255,255,255,0.2); | |
| border-top-color: #fff; | |
| border-radius: 50%; | |
| animation: spin 0.7s linear infinite; | |
| vertical-align: middle; | |
| } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| /* Log Section */ | |
| .log-section { margin-bottom: 20px; } | |
| .log-toolbar { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| margin-bottom: 12px; | |
| flex-wrap: wrap; | |
| } | |
| .log-toolbar .log-status { | |
| font-size: 0.82rem; | |
| color: #667; | |
| margin-left: auto; | |
| } | |
| .log-area { | |
| width: 100%; | |
| height: 500px; | |
| overflow-y: auto; | |
| background: #0a0a1a; | |
| border: 1px solid rgba(255,255,255,0.06); | |
| border-radius: 8px; | |
| padding: 14px 16px; | |
| font-family: "SF Mono", "Fira Code", "Consolas", "Courier New", monospace; | |
| font-size: 0.82rem; | |
| line-height: 1.5; | |
| color: #4ecca3; | |
| white-space: pre-wrap; | |
| word-break: break-all; | |
| } | |
| .log-line { display: block; padding: 2px 8px; font-family: "SF Mono", "Fira Code", "Consolas", "Courier New", monospace; white-space: pre-wrap; word-break: break-all; } | |
| .log-divider { display: block; padding: 4px 8px; font-family: "SF Mono", "Fira Code", "Consolas", "Courier New", monospace; color: #6cb4ee; font-weight: bold; border-top: 1px solid #333; margin-top: 4px; } | |
| .log-area .log-empty { | |
| color: #445; | |
| text-align: center; | |
| padding: 30px 0; | |
| } | |
| .log-area .log-timestamp { | |
| color: #6cb4ee; | |
| } | |
| /* Scrollbar */ | |
| .log-area::-webkit-scrollbar { width: 6px; } | |
| .log-area::-webkit-scrollbar-track { background: transparent; } | |
| .log-area::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; } | |
| .log-area::-webkit-scrollbar-thumb:hover { background: #555; } | |
| ::-webkit-scrollbar { width: 6px; } | |
| ::-webkit-scrollbar-track { background: transparent; } | |
| ::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; } | |
| /* Toggle switch */ | |
| .toggle-wrap { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| font-size: 0.82rem; | |
| color: #8899b4; | |
| cursor: pointer; | |
| user-select: none; | |
| } | |
| .toggle-track { | |
| width: 36px; | |
| height: 20px; | |
| background: #333; | |
| border-radius: 10px; | |
| position: relative; | |
| transition: background 0.2s; | |
| flex-shrink: 0; | |
| } | |
| .toggle-track.active { background: #0f3460; } | |
| .toggle-track::after { | |
| content: ''; | |
| position: absolute; | |
| top: 2px; | |
| left: 2px; | |
| width: 16px; | |
| height: 16px; | |
| border-radius: 50%; | |
| background: #ccc; | |
| transition: all 0.2s; | |
| } | |
| .toggle-track.active::after { | |
| left: 18px; | |
| background: #fff; | |
| } | |
| /* Footer */ | |
| .footer { | |
| text-align: center; | |
| padding: 20px; | |
| font-size: 0.8rem; | |
| color: #445; | |
| } | |
| .backup-grid { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 10px; | |
| margin-bottom: 12px; | |
| } | |
| /* Responsive */ | |
| @media (max-width: 768px) { | |
| body { padding: 12px; } | |
| .header h1 { font-size: 1.4rem; } | |
| .status-row { grid-template-columns: 1fr; } | |
| .actions-row { flex-direction: column; } | |
| .actions-row .btn { width: 100%; justify-content: center; } | |
| .backup-section .card-body { flex-direction: column; } | |
| .backup-grid { grid-template-columns: 1fr; } | |
| .backup-section .card-body .btn { width: 100%; justify-content: center; } | |
| .log-toolbar .log-status { margin-left: 0; width: 100%; } | |
| } | |
| @media (min-width: 769px) and (max-width: 1024px) { | |
| .status-row { grid-template-columns: repeat(3, 1fr); gap: 12px; } | |
| } | |
| /* Backup Confirm Modal */ | |
| .modal-overlay { | |
| display: none; | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: rgba(0,0,0,0.7); | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 1000; | |
| } | |
| .modal-overlay.show { display: flex; } | |
| .modal-box { | |
| background: #1a1a2e; | |
| border: 1px solid #0f3460; | |
| border-radius: 12px; | |
| padding: 28px 32px; | |
| max-width: 480px; | |
| width: 90%; | |
| box-shadow: 0 8px 40px rgba(0,0,0,0.5); | |
| } | |
| .modal-title { | |
| font-size: 1.15rem; | |
| font-weight: 600; | |
| color: #f0a500; | |
| margin-bottom: 16px; | |
| } | |
| .modal-body { | |
| font-size: 0.92rem; | |
| color: #c8d6e5; | |
| line-height: 1.6; | |
| margin-bottom: 24px; | |
| } | |
| .modal-body .last-time { | |
| color: #6cb4ee; | |
| font-weight: 500; | |
| } | |
| .modal-actions { | |
| display: flex; | |
| gap: 12px; | |
| justify-content: flex-end; | |
| } | |
| .modal-actions .btn { min-width: 100px; justify-content: center; } | |
| /* Sidebar */ | |
| .layout { display: flex; gap: 0; min-height: 100vh; max-width: 1400px; margin: 0 auto; } | |
| .sidebar { | |
| width: 220px; flex-shrink: 0; | |
| background: #111827; border-radius: 12px 0 0 12px; | |
| padding: 20px 0; border: 1px solid rgba(255,255,255,0.06); | |
| } | |
| .sidebar .sbtitle { padding: 0 20px 16px; font-size: 1.1rem; font-weight: 600; color: #fff; border-bottom: 1px solid #1f2937; margin-bottom: 8px; } | |
| .sidebar .sbsub { padding: 0 20px 16px; font-size: 0.75rem; color: #666; margin-top: -8px; border-bottom: 1px solid #1f2937; margin-bottom: 8px; } | |
| .sidebar .sbitem { | |
| display: flex; align-items: center; gap: 10px; | |
| padding: 12px 20px; cursor: pointer; color: #9ca3af; | |
| transition: all 0.2s; font-size: 0.9rem; | |
| } | |
| .sidebar .sbitem:hover { background: #1f2937; color: #e0e0e0; } | |
| .sidebar .sbitem.active { background: #1e3a5f; color: #4fc3f7; border-right: 3px solid #4fc3f7; } | |
| .main-content { flex: 1; padding: 24px; background: #1a1a2e; border-radius: 0 12px 12px 0; border: 1px solid rgba(255,255,255,0.06); border-left: none; overflow: auto; } | |
| .page-content { display: none; } | |
| .page-content.active { display: block; } | |
| @media (max-width: 768px) { | |
| .layout { flex-direction: column; } | |
| .sidebar { width: 100%; border-radius: 12px 12px 0 0; display: flex; flex-wrap: wrap; padding: 10px; } | |
| .sidebar .sbtitle, .sidebar .sbsub { display: none; } | |
| .sidebar .sbitem { padding: 8px 12px; } | |
| .main-content { border-radius: 0 0 12px 12px; border-left: 1px solid rgba(255,255,255,0.06); border-top: none; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="layout"> | |
| <div class="sidebar"> | |
| <div class="sbtitle">🔧 控制面板</div> | |
| <div class="sbsub" id="subtitleText">sharween/xiaoxiaxia</div> | |
| <div class="sbitem active" data-page="home">📊 主页</div> | |
| <div class="sbitem" data-page="vlm">🧠 OpenViking VLM</div> | |
| </div> | |
| <div class="main-content"> | |
| <div id="page-home" class="page-content active"> | |
| <!-- Status Cards --> | |
| <div class="status-row" id="statusRow"> | |
| <div class="status-card" data-service="openviking"> | |
| <div class="service-name">OpenViking</div> | |
| <div class="status-line"> | |
| <span class="status-dot unknown" id="dotOpenviking"></span> | |
| <span class="status-text" id="infoOpenviking">--</span> | |
| </div> | |
| <div class="status-info" id="metaOpenviking"></div> | |
| </div> | |
| <div class="status-card" data-service="openclaw"> | |
| <div class="service-name">OpenClaw Gateway</div> | |
| <div class="status-line"> | |
| <span class="status-dot unknown" id="dotOpenclaw"></span> | |
| <span class="status-text" id="infoOpenclaw">--</span> | |
| </div> | |
| <div class="status-info" id="metaOpenclaw"></div> | |
| </div> | |
| </div> | |
| <!-- Quick Actions --> | |
| <div class="actions-row"> | |
| <a class="btn btn-primary" href="/studio/" target="_blank" rel="noopener">🔗 OpenViking Studio</a> | |
| <a class="btn btn-primary" id="hfDatasetLink" href="#" target="_blank" rel="noopener">📁 HF 数据集</a> | |
| <a class="btn btn-primary" id="hfSettingsLink" href="#" target="_blank" rel="noopener">⚙️ HF Space 设置</a> | |
| <button class="btn btn-outline" data-action="loadStatus">🔄 刷新状态</button> | |
| <button class="btn btn-outline" data-action="scrollToLog">📋 查看日志</button> | |
| </div> | |
| <!-- Backup Section --> | |
| <div class="card backup-section"> | |
| <div class="card-title">📦 备份管理</div> | |
| <div class="card-body"> | |
| <!-- 单行备份按钮,每个按钮下方带说明 --> | |
| <div style="display:flex;gap:12px;flex-wrap:wrap;"> | |
| <div style="display:flex;flex-direction:column;align-items:center;"> | |
| <button class="btn btn-success backup-btn" data-service="webdav" data-type="quick">💳 WEBDAV 增量</button> | |
| <span style="font-size:0.72rem;color:#667;margin-top:4px;">仅上传变更文件</span> | |
| </div> | |
| <div style="display:flex;flex-direction:column;align-items:center;"> | |
| <button class="btn btn-warning backup-btn" data-service="webdav" data-type="full">🗝 WEBDAV 全量</button> | |
| <span style="font-size:0.72rem;color:#667;margin-top:4px;">完整打包+上传</span> | |
| </div> | |
| <div style="display:flex;flex-direction:column;align-items:center;"> | |
| <button class="btn btn-success backup-btn" data-service="dataset" data-type="quick">📂 DATASET 小</button> | |
| <span style="font-size:0.72rem;color:#667;margin-top:4px;">覆盖式快速备份</span> | |
| </div> | |
| <div style="display:flex;flex-direction:column;align-items:center;"> | |
| <button class="btn btn-warning backup-btn" data-service="dataset" data-type="full">🗝 DATASET 大</button> | |
| <span style="font-size:0.72rem;color:#667;margin-top:4px;">按日期版本保留</span> | |
| </div> | |
| </div> | |
| <div class="full-backup-warning"> | |
| ⚠ 完整备份将先停止 OpenViking 服务,备份完成后重新启动 | |
| </div> | |
| <div id="backupFeedback" class="feedback"></div> | |
| </div> | |
| </div> | |
| <!-- System Operations --> | |
| <div class="card" style="margin-bottom:20px;"> | |
| <div class="card-title">⚙ 系统操作</div> | |
| <div class="card-body" style="display:flex;gap:12px;flex-wrap:wrap;"> | |
| <button class="btn btn-warning" data-action="restartGateway">↻ 重启 Gateway</button> | |
| <button class="btn btn-danger" data-action="restartSystem">⚠ 重启系统</button> | |
| <button class="btn btn-danger" data-action="rebuildSystem">🔄 重构系统</button> | |
| <div style="width:100%;font-size:0.82rem;color:#667;padding:4px 0;"> | |
| 重启 Gateway:仅重启网关进程(约10秒)|重启系统:重启容器(约2分钟)|重构系统:重启并清理缓存(约5分钟) | |
| </div> | |
| <div id="systemFeedback" class="feedback"></div> | |
| </div> | |
| </div> | |
| <!-- Restore Section --> | |
| <div class="card backup-section"> | |
| <div class="card-title">📂 恢复管理</div> | |
| <div class="card-body"> | |
| <div style="width:100%;display:flex;gap:12px;flex-wrap:wrap;align-items:center;"> | |
| <select id="restoreSourceSelect" style="flex:1;min-width:150px;padding:8px 12px;background:#0a0a1a;color:#fff;border:1px solid #333;border-radius:6px;"> | |
| <option value="webdav">WebDAV</option> | |
| <option value="dataset">HF Dataset</option> | |
| </select> | |
| <select id="restoreFileSelect" style="flex:2;min-width:250px;padding:8px 12px;background:#0a0a1a;color:#fff;border:1px solid #333;border-radius:6px;"> | |
| <option value="">-- 请选择备份文件 --</option> | |
| </select> | |
| <button class="btn btn-warning" id="restoreBtn" data-action="restoreBackup">📂 恢复</button> | |
| <button class="btn btn-outline btn-sm" data-action="loadBackupList">🔄 刷新列表</button> | |
| </div> | |
| <div id="restoreFeedback" class="feedback"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="page-vlm" class="page-content"> | |
| <div class="card" style="margin-bottom:20px;"> | |
| <div class="card-header">OpenViking VLM 模型配置</div> | |
| <div class="card-body"> | |
| <div style="margin-bottom:12px;color:#aaa;font-size:0.85rem;"> | |
| 当前 VLM 模型:<span id="currentVlmModel">-</span> | |
| </div> | |
| <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px;"> | |
| <select id="vlmModelSelect" style="flex:1;min-width:200px;padding:8px 12px;background:#0a0a1a;color:#fff;border:1px solid #333;border-radius:6px;"> | |
| <option value="">-- 选择模型 --</option> | |
| </select> | |
| <button class="btn btn-primary" id="presetBtn" data-action="selectPresetModel" style="white-space:nowrap;">应用预设置</button> | |
| </div> | |
| <div style="border-top:1px solid #333;padding-top:12px;margin-top:8px;"> | |
| <div style="color:#888;font-size:0.85rem;margin-bottom:8px;">或自定义模型(填全部4个参数):</div> | |
| <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;"> | |
| <input type="text" id="vlmModelId" placeholder="model_id (必填)" style="padding:8px 12px;background:#0a0a1a;color:#fff;border:1px solid #333;border-radius:6px;"> | |
| <input type="text" id="vlmBaseUrl" placeholder="base_url (必填)" style="padding:8px 12px;background:#0a0a1a;color:#fff;border:1px solid #333;border-radius:6px;"> | |
| <input type="password" id="vlmApiKey" placeholder="api_key (必填)" style="padding:8px 12px;background:#0a0a1a;color:#fff;border:1px solid #333;border-radius:6px;"> | |
| <select id="vlmProvider" style="padding:8px 12px;background:#0a0a1a;color:#fff;border:1px solid #333;border-radius:6px;"> | |
| <option value="openai">openai</option> | |
| <option value="volcengine">volcengine</option> | |
| <option value="litellm">litellm</option> | |
| </select> | |
| </div> | |
| <div style="margin-top:8px;text-align:right;"> | |
| <button class="btn btn-primary" id="customVlmBtn" data-action="applyCustomVLM">应用自定义</button> | |
| </div> | |
| </div> | |
| <div id="vlmFeedback" class="feedback" style="margin-top:8px;font-size:0.85rem;"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="card log-section"> | |
| <div class="card-title">📝 备份日志</div> | |
| <div class="log-toolbar"> | |
| <button class="btn btn-sm btn-outline" data-action="clearLog">🗑 清除日志</button> | |
| <label class="toggle-wrap" id="autoScrollToggle"> | |
| <span class="toggle-track active" id="toggleTrack"></span> | |
| 自动滚动 | |
| </label> | |
| <span class="log-status" id="logStatus">就绪</span> | |
| </div> | |
| <div class="log-area" id="logArea"> | |
| <span class="log-empty">等待日志加载...</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Backup Confirm Modal --> | |
| <div class="modal-overlay" id="backupModal"> | |
| <div class="modal-box"> | |
| <div class="modal-title">⚠ 备份确认</div> | |
| <div class="modal-body"> | |
| 今天已有备份记录,确定再次备份?<br> | |
| 上次备份时间:<span class="last-time" id="lastBackupTime"></span> | |
| </div> | |
| <div class="modal-actions"> | |
| <button class="btn btn-outline" data-action="cancelBackup">取消</button> | |
| <button class="btn btn-warning" data-action="confirmBackup">确定备份</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| (function () { | |
| 'use strict'; | |
| // --- State --- | |
| var statusInterval = null; | |
| var logInterval = null; | |
| var isBackupRunning = false; | |
| var isRestoringRunning = false; | |
| var autoScroll = true; | |
| var prevLogHash = ''; | |
| var pendingBackup = null; | |
| // --- Constants --- | |
| var STATUS_POLL_INTERVAL = 5000; | |
| var LOG_POLL_INTERVAL = 3000; | |
| var BACKUP_TIMEOUT = 120000; | |
| var BACKUP_POLL_INTERVAL = 2000; | |
| var FEEDBACK_HIDE_DELAY = 6000; | |
| var backupCheckMap = { | |
| webdav_quick: 'webdav-incremental', | |
| webdav_full: 'webdav-full', | |
| dataset_quick: 'dataset-small', | |
| dataset_full: 'dataset-large' | |
| }; | |
| // --- DOM refs --- | |
| var logArea = document.getElementById('logArea'); | |
| var logStatus = document.getElementById('logStatus'); | |
| var toggleTrack = document.getElementById('toggleTrack'); | |
| var backupFeedback = document.getElementById('backupFeedback'); | |
| var restoreFeedback = document.getElementById('restoreFeedback'); | |
| var systemFeedback = document.getElementById('systemFeedback'); | |
| // --- Toggle auto-scroll --- | |
| document.getElementById('autoScrollToggle').addEventListener('click', function () { | |
| autoScroll = !autoScroll; | |
| toggleTrack.classList.toggle('active', autoScroll); | |
| }); | |
| // --- Helpers --- | |
| function fmtTime(ts) { | |
| if (!ts) return '--'; | |
| var d = new Date(ts); | |
| if (isNaN(d.getTime())) return ts; | |
| var pad = function (n) { return String(n).padStart(2, '0'); }; | |
| return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + | |
| ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()); | |
| } | |
| function showFeedback(msg, type) { | |
| if (window._fbTimer) clearTimeout(window._fbTimer); | |
| backupFeedback.textContent = msg; | |
| backupFeedback.className = 'feedback show ' + type; | |
| // 所有状态都不自动隐藏,由下一次 showFeedback 或操作替换 | |
| } | |
| function setButtonsDisabled(disabled) { | |
| document.querySelectorAll('.backup-btn').forEach(function(btn) { | |
| btn.disabled = disabled; | |
| }); | |
| } | |
| // --- Config: fetch HF Space ID for dynamic links --- | |
| function loadConfig() { | |
| fetch('/control/api/config') | |
| .then(function (r) { return r.json(); }) | |
| .then(function (data) { | |
| var spaceId = data.space_id || 'sharween/xiaoxiaxia'; | |
| var subtitleEl = document.getElementById('subtitleText'); | |
| if (subtitleEl) subtitleEl.textContent = spaceId; | |
| var footerEl = document.getElementById('footerSpaceId'); | |
| if (footerEl) footerEl.textContent = spaceId; | |
| var dsLink = document.getElementById('hfDatasetLink'); | |
| var stLink = document.getElementById('hfSettingsLink'); | |
| if (dsLink) dsLink.href = 'https://huggingface.co/datasets/' + spaceId + '_data/tree/main'; | |
| if (stLink) stLink.href = 'https://huggingface.co/spaces/' + spaceId + '/settings'; | |
| }) | |
| .catch(function () { | |
| console.error('loadConfig: fetch failed (network error)'); | |
| // fallback: keep default hrefs pointing to sharween/xiaoxiaxia | |
| var dsLink = document.getElementById('hfDatasetLink'); | |
| var stLink = document.getElementById('hfSettingsLink'); | |
| if (dsLink) dsLink.href = 'https://huggingface.co/datasets/sharween/xiaoxiaxia_data/tree/main'; | |
| if (stLink) stLink.href = 'https://huggingface.co/spaces/sharween/xiaoxiaxia/settings'; | |
| }); | |
| } | |
| function _renderStatusDisplay(data, dots, infos, metas) { | |
| var services = ['openviking', 'openclaw']; | |
| services.forEach(function (s) { | |
| var svc = data[s]; | |
| var status = 'unknown'; | |
| var dot = dots[s]; | |
| var info = infos[s]; | |
| var meta = metas[s]; | |
| if (typeof svc === 'object' && svc !== null) { | |
| status = (svc.status || 'unknown').toLowerCase(); | |
| } else if (typeof svc === 'string') { | |
| status = svc.toLowerCase(); | |
| } | |
| dot.className = 'status-dot'; | |
| if (status === 'running') { | |
| dot.classList.add('running'); | |
| info.textContent = '运行中'; | |
| } else if (status === 'stopped') { | |
| dot.classList.add('stopped'); | |
| info.textContent = '已停止'; | |
| } else { | |
| dot.classList.add('unknown'); | |
| info.textContent = svc && typeof svc === 'object' ? (svc.status || '未知') : (String(svc) || '未知'); | |
| } | |
| var extras = []; | |
| if (typeof svc === 'object' && svc !== null) { | |
| if (svc.pid) extras.push('PID: ' + svc.pid); | |
| if (svc.port) extras.push('Port: ' + svc.port); | |
| if (svc.memory) extras.push('Mem: ' + svc.memory); | |
| if (svc.uptime) extras.push('运行: ' + svc.uptime); | |
| } | |
| meta.textContent = extras.join(' | ') || ''; | |
| }); | |
| } | |
| // --- Status --- | |
| function loadStatus() { | |
| var dots = { | |
| openviking: document.getElementById('dotOpenviking'), | |
| openclaw: document.getElementById('dotOpenclaw') | |
| }; | |
| var infos = { | |
| openviking: document.getElementById('infoOpenviking'), | |
| openclaw: document.getElementById('infoOpenclaw') | |
| }; | |
| var metas = { | |
| openviking: document.getElementById('metaOpenviking'), | |
| openclaw: document.getElementById('metaOpenclaw') | |
| }; | |
| // Show loading | |
| Object.keys(dots).forEach(function (k) { | |
| dots[k].className = 'status-dot loading'; | |
| if (infos[k].textContent === '--') infos[k].textContent = '加载中...'; | |
| }); | |
| fetch('/control/api/status') | |
| .then(function (r) { | |
| if (!r.ok) throw new Error('HTTP ' + r.status); | |
| return r.json(); | |
| }) | |
| .then(function (data) { | |
| _renderStatusDisplay(data, dots, infos, metas); | |
| }) | |
| .catch(function (err) { | |
| Object.keys(dots).forEach(function (k) { | |
| dots[k].className = 'status-dot unknown'; | |
| infos[k].textContent = '连接失败'; | |
| }); | |
| console.error('Status fetch error:', err); | |
| }); | |
| } | |
| // Backup V2 (2x2 grid) — always prompt before backup | |
| function triggerBackupV2(service, type) { | |
| if (isBackupRunning) return; | |
| setButtonsDisabled(true); | |
| showFeedback('检查备份记录...', 'info'); | |
| pendingBackup = { service: service, type: type }; | |
| fetch('/control/api/backup/check') | |
| .then(function (r) { | |
| if (!r.ok) throw new Error('HTTP ' + r.status); | |
| return r.json(); | |
| }) | |
| .then(function (data) { | |
| var modalBody = document.querySelector('#backupModal .modal-body'); | |
| var msg = '确定执行备份?'; | |
| if (data.backup_today && data.last_backup) { | |
| msg = '今天已有备份记录(' + data.last_backup + '),确定再次备份?<br>此操作将覆盖当天备份。'; | |
| } else if (data.last_backup) { | |
| msg = '上次备份时间:' + data.last_backup + '<br>确定执行备份?'; | |
| } | |
| modalBody.innerHTML = msg; | |
| document.getElementById('backupModal').classList.add('show'); | |
| setButtonsDisabled(false); | |
| }) | |
| .catch(function () { | |
| var modalBody = document.querySelector('#backupModal .modal-body'); | |
| modalBody.innerHTML = '确定执行备份?'; | |
| document.getElementById('backupModal').classList.add('show'); | |
| setButtonsDisabled(false); | |
| }); | |
| } | |
| function _pollBackupCompletion(label, pollTimer, startTime) { | |
| // 超时检查放在最外层,无论 fetch 成功与否都会执行 | |
| if (Date.now() - startTime >= BACKUP_TIMEOUT) { | |
| clearInterval(pollTimer); | |
| showFeedback(label + ' 备份超时,请查看日志确认', 'error'); | |
| _resetBackupState(); | |
| return; | |
| } | |
| fetch('/control/api/log') | |
| .then(function (r) { return r.json(); }) | |
| .then(function (data) { | |
| var lines = data.lines || []; | |
| if (lines.length === 0) return; | |
| var lastLine = (lines[lines.length - 1] || '').toLowerCase(); | |
| var recentLines = lines.slice(-10).join('\n').toLowerCase(); | |
| // 1) 检查最近日志中是否有明确的完成关键词 | |
| var completed = | |
| recentLines.indexOf('completed successfully') >= 0 || | |
| recentLines.indexOf(' completed') >= 0 || | |
| recentLines.indexOf('备份完成') >= 0 || | |
| (recentLines.indexOf('complete') >= 0 && | |
| recentLines.indexOf('failed') < 0 && | |
| recentLines.indexOf('error') < 0); | |
| if (completed) { | |
| clearInterval(pollTimer); | |
| showFeedback(label + ' 备份完成', 'success'); | |
| _resetBackupState(); | |
| loadLog(); | |
| return; | |
| } | |
| // 2) 检查最近日志中是否有失败迹象 | |
| if (recentLines.indexOf('failed') >= 0 || | |
| recentLines.indexOf('error') >= 0) { | |
| clearInterval(pollTimer); | |
| showFeedback(label + ' 备份失败,请查看日志', 'error'); | |
| _resetBackupState(); | |
| loadLog(); | |
| return; | |
| } | |
| }) | |
| .catch(function () { | |
| // 网络错误不中断轮询,下次轮询会再试 | |
| }); | |
| } | |
| function executeBackup(service, type) { | |
| isBackupRunning = true; | |
| setButtonsDisabled(true); | |
| // 修改按钮文字为"备份中..." | |
| document.querySelectorAll('.backup-btn').forEach(function(btn) { | |
| btn.dataset.originalText = btn.textContent.trim(); | |
| btn.textContent = '⏳ 备份中...'; | |
| }); | |
| var specificLabels = { | |
| 'webdav_quick': 'WEBDAV 增量备份', | |
| 'webdav_full': 'WEBDAV 全量备份', | |
| 'dataset_quick': 'DATASET 小备份', | |
| 'dataset_full': 'DATASET 大备份' | |
| }; | |
| var key = service + '_' + type; | |
| var label = specificLabels[key] || (service.toUpperCase() + ' ' + type); | |
| showFeedback(label + ' 备份中...', 'info'); | |
| fetch('/control/api/backup/' + service + '/' + type, { method: 'POST' }) | |
| .then(function (r) { | |
| if (!r.ok) throw new Error('HTTP ' + r.status); | |
| return r.json(); | |
| }) | |
| .then(function () { | |
| var startTime = Date.now(); | |
| var pollTimer = setInterval(function () { | |
| _pollBackupCompletion(label, pollTimer, startTime); | |
| }, BACKUP_POLL_INTERVAL); | |
| }) | |
| .catch(function (err) { | |
| showFeedback(label + ' 备份失败: ' + err, 'error'); | |
| console.error('executeBackup error:', err); | |
| _resetBackupState(); | |
| }); | |
| } | |
| function _resetBackupState() { | |
| isBackupRunning = false; | |
| document.querySelectorAll('.backup-btn').forEach(function(btn) { | |
| btn.disabled = false; | |
| if (btn.dataset.originalText) { | |
| btn.textContent = btn.dataset.originalText; | |
| delete btn.dataset.originalText; | |
| } | |
| }); | |
| } | |
| function confirmBackup() { | |
| document.getElementById('backupModal').classList.remove('show'); | |
| if (pendingBackup) { | |
| var pb = pendingBackup; | |
| pendingBackup = null; | |
| executeBackup(pb.service, pb.type); | |
| } | |
| } | |
| function cancelBackup() { | |
| document.getElementById('backupModal').classList.remove('show'); | |
| pendingBackup = null; | |
| } | |
| // --- Log --- | |
| function loadLog() { | |
| fetch('/control/api/log') | |
| .then(function (r) { | |
| if (!r.ok) throw new Error('HTTP ' + r.status); | |
| return r.json(); | |
| }) | |
| .then(function (data) { | |
| var lines = data.lines || []; | |
| var text = lines.join('\n'); | |
| var hash = simpleHash(text); | |
| if (hash === prevLogHash && logArea.children.length > 0) { | |
| return; // no change | |
| } | |
| prevLogHash = hash; | |
| if (lines.length === 0) { | |
| logArea.innerHTML = '<span class="log-empty">暂无备份日志</span>'; | |
| return; | |
| } | |
| var html = lines.map(function(line) { | |
| if (line.startsWith('===')) { | |
| return '<div class="log-divider">' + escapeHtml(line) + '</div>'; | |
| } | |
| return '<div class="log-line">' + escapeHtml(line) + '</div>'; | |
| }).join(''); | |
| logArea.innerHTML = html; | |
| if (autoScroll) { | |
| logArea.scrollTop = logArea.scrollHeight; | |
| } | |
| logStatus.textContent = '更新于 ' + fmtTime(Date.now()); | |
| }) | |
| .catch(function (err) { | |
| logStatus.textContent = '日志加载失败'; | |
| console.error('Log fetch error:', err); | |
| }); | |
| } | |
| function simpleHash(str) { | |
| var h = 0; | |
| for (var i = 0; i < str.length; i++) { | |
| h = ((h << 5) - h) + str.charCodeAt(i); | |
| h |= 0; | |
| } | |
| return h; | |
| } | |
| function escapeHtml(str) { | |
| var div = document.createElement('div'); | |
| div.textContent = str; | |
| return div.innerHTML; | |
| } | |
| function clearLog() { | |
| logArea.innerHTML = '<span class="log-empty">日志已清除</span>'; | |
| prevLogHash = ''; | |
| logStatus.textContent = '已清除'; | |
| } | |
| function scrollToLog() { | |
| document.querySelector('.log-section').scrollIntoView({ behavior: 'smooth' }); | |
| } | |
| // --- VLM Models --- | |
| function loadModels() { | |
| fetch('/control/api/models') | |
| .then(function(r) { return r.json(); }) | |
| .then(function(data) { | |
| var select = document.getElementById('vlmModelSelect'); | |
| if (!select) return; | |
| select.innerHTML = '<option value="">-- 选择模型 --</option>'; | |
| data.models.forEach(function(m) { | |
| var opt = document.createElement('option'); | |
| opt.value = m.index; | |
| opt.textContent = m.name + ' (' + m.id + ')'; | |
| select.appendChild(opt); | |
| }); | |
| var curr = document.getElementById('currentVlmModel'); | |
| if (curr && data.current_vlm && data.current_vlm.model) { | |
| curr.textContent = data.current_vlm.model; | |
| } | |
| }) | |
| .catch(function(err) { | |
| console.error('Load models error:', err); | |
| }); | |
| } | |
| function selectPresetModel() { | |
| var select = document.getElementById('vlmModelSelect'); | |
| var feedback = document.getElementById('vlmFeedback'); | |
| var presetBtn = document.getElementById('presetBtn'); | |
| if (!feedback || !select || !select.value) { | |
| if (feedback) { | |
| feedback.className = 'feedback show error'; | |
| feedback.textContent = '请选择一个预设模型'; | |
| } | |
| return; | |
| } | |
| if (presetBtn) presetBtn.disabled = true; | |
| feedback.className = 'feedback show info'; | |
| feedback.textContent = '正在应用...'; | |
| fetch('/control/api/openviking/vlm', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({model_index: parseInt(select.value)}) | |
| }).then(function(r) { return r.json(); }) | |
| .then(function(d) { | |
| feedback.className = d.status === 'ok' ? 'feedback show success' : 'feedback show error'; | |
| feedback.textContent = d.message || 'OK'; | |
| if (presetBtn) presetBtn.disabled = false; | |
| if (d.status === 'ok') setTimeout(loadModels, 3000); | |
| }) | |
| .catch(function(e) { | |
| feedback.className = 'feedback show error'; | |
| feedback.textContent = 'Error: ' + e; | |
| console.error('selectPresetModel error:', e); | |
| if (presetBtn) presetBtn.disabled = false; | |
| }); | |
| } | |
| function applyCustomVLM() { | |
| var feedback = document.getElementById('vlmFeedback'); | |
| var customBtn = document.getElementById('customVlmBtn'); | |
| if (!feedback) return; | |
| feedback.className = ''; | |
| feedback.textContent = ''; | |
| var modelId = document.getElementById('vlmModelId').value.trim(); | |
| var baseUrl = document.getElementById('vlmBaseUrl').value.trim(); | |
| var apiKey = document.getElementById('vlmApiKey').value.trim(); | |
| var provider = document.getElementById('vlmProvider').value; | |
| if (!modelId || !baseUrl || !apiKey) { | |
| feedback.className = 'feedback show error'; | |
| feedback.textContent = '请填写完整的 4 个参数(model_id、base_url、api_key、provider)'; | |
| return; | |
| } | |
| if (customBtn) customBtn.disabled = true; | |
| feedback.className = 'feedback show info'; | |
| feedback.textContent = '正在应用...'; | |
| fetch('/control/api/openviking/vlm', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({model: modelId, base_url: baseUrl, api_key: apiKey, provider: provider}) | |
| }).then(function(r) { return r.json(); }) | |
| .then(function(d) { | |
| feedback.className = d.status === 'ok' ? 'feedback show success' : 'feedback show error'; | |
| feedback.textContent = d.message || 'OK'; | |
| if (customBtn) customBtn.disabled = false; | |
| if (d.status === 'ok') setTimeout(loadModels, 3000); | |
| }) | |
| .catch(function(e) { | |
| feedback.className = 'feedback show error'; | |
| feedback.textContent = 'Error: ' + e; | |
| console.error('applyCustomVLM error:', e); | |
| if (customBtn) customBtn.disabled = false; | |
| }); | |
| } | |
| // --- Restore --- | |
| function loadBackupList() { | |
| if (!restoreFeedback) return; | |
| restoreFeedback.textContent = '正在加载备份列表...'; | |
| restoreFeedback.className = 'feedback show info'; | |
| fetch('/control/api/backup/list') | |
| .then(function(r) { | |
| if (!r.ok) throw new Error('HTTP ' + r.status); | |
| return r.json(); | |
| }) | |
| .then(function(data) { | |
| var select = document.getElementById('restoreFileSelect'); | |
| if (!select) return; | |
| select.innerHTML = '<option value="">-- 请选择备份文件 --</option>'; | |
| var source = document.getElementById('restoreSourceSelect').value; | |
| var files = data[source] || []; | |
| files.forEach(function(f) { | |
| var opt = document.createElement('option'); | |
| opt.value = f; | |
| opt.textContent = f; | |
| select.appendChild(opt); | |
| }); | |
| restoreFeedback.textContent = '已加载 ' + files.length + ' 个备份文件'; | |
| restoreFeedback.className = 'feedback show success'; | |
| setTimeout(function() { | |
| restoreFeedback.className = 'feedback'; | |
| }, 3000); | |
| }) | |
| .catch(function(err) { | |
| restoreFeedback.textContent = '加载备份列表失败: ' + err; | |
| restoreFeedback.className = 'feedback show error'; | |
| console.error('loadBackupList error:', err); | |
| }); | |
| } | |
| // Reload file list when source select changes | |
| document.addEventListener('change', function(e) { | |
| if (e.target && e.target.id === 'restoreSourceSelect') { | |
| loadBackupList(); | |
| } | |
| }); | |
| function _resetRestoreState() { | |
| isRestoringRunning = false; | |
| var restoreBtn = document.getElementById('restoreBtn'); | |
| if (restoreBtn) restoreBtn.disabled = false; | |
| } | |
| function restoreBackup() { | |
| if (isRestoringRunning) return; | |
| if (!restoreFeedback) return; | |
| var source = document.getElementById('restoreSourceSelect').value; | |
| var filename = document.getElementById('restoreFileSelect').value; | |
| if (!filename) { | |
| restoreFeedback.textContent = '请先选择一个备份文件'; | |
| restoreFeedback.className = 'feedback show error'; | |
| return; | |
| } | |
| if (!confirm('确定要从 ' + source.toUpperCase() + ' 恢复备份文件 "' + filename + '" 吗?\n此操作将覆盖当前数据。')) { | |
| return; | |
| } | |
| isRestoringRunning = true; | |
| var restoreBtn = document.getElementById('restoreBtn'); | |
| if (restoreBtn) restoreBtn.disabled = true; | |
| restoreFeedback.textContent = '正在恢复...'; | |
| restoreFeedback.className = 'feedback show info'; | |
| function _resetRestoreState() { | |
| isRestoringRunning = false; | |
| if (restoreBtn) restoreBtn.disabled = false; | |
| } | |
| fetch('/control/api/restore', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({source: source, filename: filename}) | |
| }) | |
| .then(function(r) { | |
| if (!r.ok) throw new Error('HTTP ' + r.status); | |
| return r.json(); | |
| }) | |
| .then(function(data) { | |
| if (data.status === 'started') { | |
| restoreFeedback.textContent = '恢复任务已启动,请查看日志了解进度'; | |
| restoreFeedback.className = 'feedback show success'; | |
| } else { | |
| restoreFeedback.textContent = data.message || '恢复请求失败'; | |
| restoreFeedback.className = 'feedback show error'; | |
| } | |
| _resetRestoreState(); | |
| }) | |
| .catch(function(err) { | |
| restoreFeedback.textContent = '恢复请求失败: ' + err; | |
| restoreFeedback.className = 'feedback show error'; | |
| console.error('restoreBackup error:', err); | |
| _resetRestoreState(); | |
| }); | |
| } | |
| // --- Gateway / System Restart --- | |
| function restartGateway() { | |
| if (!confirm('确定要重启 Gateway 吗?\n服务将短暂中断约 30 秒。')) return; | |
| systemFeedback.textContent = 'Gateway 重启中(约30秒)...'; | |
| systemFeedback.className = 'feedback show info'; | |
| fetch('/control/api/gateway/restart', { method: 'POST' }) | |
| .then(function(r) { return r.json().catch(function(){ return {}; }); }) | |
| .then(function(data) { | |
| if (data.status === 'ok') { | |
| systemFeedback.textContent = data.message || 'Gateway 重启成功'; | |
| systemFeedback.className = 'feedback show success'; | |
| } else { | |
| systemFeedback.textContent = data.message || 'Gateway 重启失败,请查看日志'; | |
| systemFeedback.className = 'feedback show error'; | |
| } | |
| }) | |
| .catch(function(err) { | |
| systemFeedback.textContent = 'Gateway 重启失败,请查看日志'; | |
| systemFeedback.className = 'feedback show error'; | |
| }); | |
| } | |
| function restartSystem() { | |
| if (!confirm('⚠️ 确定要重启整个系统吗?\n所有服务将中断 1-2 分钟。')) return; | |
| if (!confirm('再次确认:系统重启将中断所有正在进行的操作!')) return; | |
| systemFeedback.textContent = '系统重启中,页面将断开连接...'; | |
| systemFeedback.className = 'feedback show info'; | |
| fetch('/control/api/system/restart', { method: 'POST' }).catch(function(){}); | |
| } | |
| function rebuildSystem() { | |
| if (!confirm('⚠️ 确定要重构整个系统吗?\n这将重启容器并清理缓存,约 5 分钟。')) return; | |
| if (!confirm('再次确认:重构将中断所有服务约 5 分钟!')) return; | |
| systemFeedback.textContent = '系统重构中,页面将断开连接...'; | |
| systemFeedback.className = 'feedback show info'; | |
| fetch('/control/api/system/rebuild', { method: 'POST' }).catch(function(){}); | |
| } | |
| function rebuildSystem() { | |
| if (!confirm('⚠️ 确定要重构整个系统吗?\n这将清空构建缓存、重新构建 Docker 镜像,约 5 分钟。')) return; | |
| if (!confirm('再次确认:重构将中断所有服务约 5 分钟!')) return; | |
| showFeedback('系统重构中,页面将断开连接...', 'info'); | |
| fetch('/control/api/system/rebuild', { method: 'POST' }).catch(function(){}); | |
| } | |
| // --- Polling --- | |
| function startPolling() { | |
| stopPolling(); | |
| // Poll status every 5s | |
| statusInterval = setInterval(loadStatus, STATUS_POLL_INTERVAL); | |
| // Poll log every 3s (always, as per requirement: "Auto-refresh every 3 seconds when backup is running" | |
| // we also poll in background to keep it fresh) | |
| loadLog(); | |
| logInterval = setInterval(loadLog, LOG_POLL_INTERVAL); | |
| } | |
| function stopPolling() { | |
| if (statusInterval) { clearInterval(statusInterval); statusInterval = null; } | |
| if (logInterval) { clearInterval(logInterval); logInterval = null; } | |
| } | |
| // Expose to window for inline onclick | |
| window.loadStatus = loadStatus; | |
| window.triggerBackupV2 = triggerBackupV2; | |
| window.executeBackup = executeBackup; | |
| window.confirmBackup = confirmBackup; | |
| window.cancelBackup = cancelBackup; | |
| window.loadLog = loadLog; | |
| window.clearLog = clearLog; | |
| window.scrollToLog = scrollToLog; | |
| window.startPolling = startPolling; | |
| window.stopPolling = stopPolling; | |
| window.loadConfig = loadConfig; | |
| window.loadModels = loadModels; | |
| window.selectPresetModel = selectPresetModel; | |
| window.applyCustomVLM = applyCustomVLM; | |
| window.loadBackupList = loadBackupList; | |
| window.restoreBackup = restoreBackup; | |
| window.showPage = showPage; | |
| window.restartGateway = restartGateway; | |
| window.restartSystem = restartSystem; | |
| window.rebuildSystem = rebuildSystem; | |
| // --- Bind data-action elements (replacing HTML onclick) --- | |
| var actionMap = { | |
| loadStatus: loadStatus, | |
| scrollToLog: scrollToLog, | |
| restoreBackup: restoreBackup, | |
| loadBackupList: loadBackupList, | |
| clearLog: clearLog, | |
| selectPresetModel: selectPresetModel, | |
| applyCustomVLM: applyCustomVLM, | |
| cancelBackup: cancelBackup, | |
| confirmBackup: confirmBackup, | |
| restartGateway: restartGateway, | |
| restartSystem: restartSystem, | |
| rebuildSystem: rebuildSystem, | |
| }; | |
| document.querySelectorAll('[data-action]').forEach(function (el) { | |
| var action = el.dataset.action; | |
| if (actionMap[action]) { | |
| el.addEventListener('click', actionMap[action]); | |
| } | |
| }); | |
| // --- Bind data-service/data-type backup buttons --- | |
| document.querySelectorAll('[data-service][data-type]').forEach(function (el) { | |
| el.addEventListener('click', function () { | |
| triggerBackupV2(this.dataset.service, this.dataset.type); | |
| }); | |
| }); | |
| // --- Bind data-page sidebar navigation --- | |
| document.querySelectorAll('[data-page]').forEach(function (el) { | |
| el.addEventListener('click', function () { | |
| showPage(this.dataset.page); | |
| }); | |
| }); | |
| // --- Init --- | |
| loadConfig(); | |
| loadStatus(); | |
| loadModels(); | |
| startPolling(); | |
| function showPage(name) { | |
| // Toggle pages using active class | |
| var pages = ['home', 'vlm']; | |
| pages.forEach(function(p) { | |
| var el = document.getElementById('page-' + p); | |
| if (el) el.classList.toggle('active', p === name); | |
| }); | |
| // Update sidebar active state | |
| document.querySelectorAll('.sbitem').forEach(function(el) { | |
| el.classList.toggle('active', | |
| el.textContent.includes(name === 'home' ? '主页' : 'VLM')); | |
| }); | |
| } | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |