| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| <title>Web2API configuration</title> |
| <style> |
| :root { |
| --bg: #120f0d; |
| --bg-deep: #080706; |
| --panel: rgba(28, 23, 20, 0.88); |
| --panel-strong: rgba(38, 32, 27, 0.96); |
| --line: rgba(247, 239, 230, 0.12); |
| --line-strong: rgba(247, 239, 230, 0.18); |
| --text: #f7efe6; |
| --muted: #b7aa98; |
| --accent: #efd9bc; |
| --accent-strong: #ddb98a; |
| --success-bg: rgba(22, 101, 52, 0.26); |
| --success-text: #c8f2d6; |
| --danger-bg: rgba(127, 29, 29, 0.3); |
| --danger-text: #fecaca; |
| --warning-bg: rgba(124, 45, 18, 0.32); |
| --warning-text: #fed7aa; |
| --shadow: 0 32px 100px rgba(0, 0, 0, 0.42); |
| --radius: 28px; |
| } |
| |
| * { |
| box-sizing: border-box; |
| } |
| |
| body { |
| margin: 0; |
| color: var(--text); |
| font-family: "Avenir Next", "Segoe UI", ui-sans-serif, system-ui, sans-serif; |
| background: |
| radial-gradient(circle at top left, rgba(239, 217, 188, 0.14), transparent 28%), |
| radial-gradient(circle at 85% 8%, rgba(163, 120, 76, 0.16), transparent 20%), |
| linear-gradient(180deg, #171210 0%, var(--bg) 38%, var(--bg-deep) 100%); |
| } |
| |
| body::before { |
| content: ""; |
| position: fixed; |
| inset: 0; |
| pointer-events: none; |
| opacity: 0.12; |
| background-image: linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px); |
| background-size: 100% 3px; |
| } |
| |
| code, |
| textarea, |
| pre { |
| font-family: "SFMono-Regular", "JetBrains Mono", "Cascadia Code", ui-monospace, monospace; |
| } |
| |
| h1, |
| h2, |
| h3 { |
| font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif; |
| letter-spacing: -0.03em; |
| } |
| |
| .shell { |
| width: min(1280px, calc(100vw - 28px)); |
| margin: 0 auto; |
| padding: 22px 0 44px; |
| } |
| |
| .panel { |
| position: relative; |
| overflow: hidden; |
| border: 1px solid var(--line); |
| border-radius: var(--radius); |
| background: linear-gradient(180deg, rgba(41, 34, 30, 0.94), rgba(24, 20, 17, 0.9)); |
| box-shadow: var(--shadow); |
| backdrop-filter: blur(18px); |
| } |
| |
| .panel::after { |
| content: ""; |
| position: absolute; |
| inset: auto -10% 0; |
| height: 1px; |
| background: linear-gradient(90deg, transparent, rgba(247, 239, 230, 0.22), transparent); |
| } |
| |
| .hero { |
| display: flex; |
| flex-wrap: wrap; |
| align-items: flex-start; |
| justify-content: space-between; |
| gap: 18px; |
| padding: 28px; |
| } |
| |
| .hero-copy { |
| max-width: 720px; |
| } |
| |
| .eyebrow { |
| display: inline-flex; |
| align-items: center; |
| gap: 8px; |
| padding: 8px 14px; |
| border-radius: 999px; |
| border: 1px solid rgba(239, 217, 188, 0.18); |
| background: rgba(239, 217, 188, 0.08); |
| color: var(--accent-strong); |
| font-size: 11px; |
| font-weight: 700; |
| letter-spacing: 0.18em; |
| text-transform: uppercase; |
| } |
| |
| h1 { |
| margin: 18px 0 10px; |
| font-size: clamp(2.4rem, 5vw, 4.25rem); |
| line-height: 0.96; |
| } |
| |
| .hero-copy p { |
| margin: 0; |
| color: var(--muted); |
| line-height: 1.8; |
| } |
| |
| .toolbar { |
| display: flex; |
| flex-wrap: wrap; |
| gap: 12px; |
| align-items: center; |
| justify-content: flex-end; |
| } |
| |
| .btn { |
| display: inline-flex; |
| align-items: center; |
| justify-content: center; |
| min-height: 44px; |
| padding: 0 18px; |
| border-radius: 999px; |
| border: 1px solid var(--line); |
| background: rgba(255, 255, 255, 0.04); |
| color: var(--text); |
| font-size: 14px; |
| font-weight: 600; |
| cursor: pointer; |
| transition: |
| transform 0.16s ease, |
| border-color 0.16s ease, |
| background 0.16s ease; |
| } |
| |
| .btn:hover:not(:disabled) { |
| transform: translateY(-1px); |
| border-color: var(--line-strong); |
| } |
| |
| .btn:disabled { |
| opacity: 0.62; |
| cursor: wait; |
| } |
| |
| .btn-primary { |
| border-color: transparent; |
| background: linear-gradient(135deg, var(--accent), var(--accent-strong)); |
| color: #1a140f; |
| } |
| |
| .btn-danger { |
| background: rgba(239, 68, 68, 0.2); |
| color: #fecaca; |
| border-color: rgba(248, 113, 113, 0.22); |
| } |
| |
| .btn-sm { |
| min-height: 34px; |
| padding: 0 14px; |
| font-size: 12px; |
| } |
| |
| .stack { |
| display: grid; |
| gap: 18px; |
| margin-top: 18px; |
| } |
| |
| .top-grid { |
| display: grid; |
| grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr); |
| gap: 18px; |
| } |
| |
| .section { |
| padding: 24px; |
| } |
| |
| .section-head { |
| display: flex; |
| flex-wrap: wrap; |
| justify-content: space-between; |
| align-items: flex-start; |
| gap: 10px; |
| margin-bottom: 18px; |
| } |
| |
| .section-head h2 { |
| margin: 0; |
| font-size: 1.9rem; |
| } |
| |
| .section-head p, |
| .field-note, |
| .meta, |
| .stats, |
| .empty { |
| margin: 0; |
| color: var(--muted); |
| line-height: 1.7; |
| } |
| |
| .pill-row { |
| display: flex; |
| flex-wrap: wrap; |
| gap: 10px; |
| margin-bottom: 16px; |
| } |
| |
| .pill { |
| display: inline-flex; |
| align-items: center; |
| gap: 8px; |
| padding: 7px 12px; |
| border-radius: 999px; |
| border: 1px solid var(--line); |
| background: rgba(255, 255, 255, 0.04); |
| font-size: 12px; |
| color: var(--muted); |
| } |
| |
| .pill strong { |
| color: var(--text); |
| font-weight: 600; |
| } |
| |
| .field-grid { |
| display: grid; |
| grid-template-columns: repeat(2, minmax(0, 1fr)); |
| gap: 14px; |
| } |
| |
| .field.span-2 { |
| grid-column: span 2; |
| } |
| |
| label { |
| display: block; |
| margin-bottom: 8px; |
| color: var(--muted); |
| font-size: 12px; |
| letter-spacing: 0.08em; |
| text-transform: uppercase; |
| } |
| |
| input, |
| textarea, |
| select { |
| width: 100%; |
| padding: 12px 14px; |
| border-radius: 18px; |
| border: 1px solid var(--line); |
| background: rgba(9, 8, 7, 0.42); |
| color: var(--text); |
| font-size: 14px; |
| } |
| |
| textarea { |
| resize: vertical; |
| min-height: 112px; |
| } |
| |
| input:focus, |
| textarea:focus, |
| select:focus { |
| outline: none; |
| border-color: rgba(239, 217, 188, 0.32); |
| box-shadow: 0 0 0 4px rgba(239, 217, 188, 0.08); |
| } |
| |
| input[readonly], |
| textarea[readonly] { |
| color: var(--muted); |
| } |
| |
| .inline-state { |
| display: flex; |
| align-items: center; |
| min-height: 48px; |
| padding: 0 14px; |
| border-radius: 18px; |
| border: 1px solid var(--line); |
| background: rgba(255, 255, 255, 0.03); |
| color: var(--text); |
| } |
| |
| .sub-actions { |
| display: flex; |
| flex-wrap: wrap; |
| gap: 12px; |
| margin-top: 16px; |
| } |
| |
| .models-list { |
| display: grid; |
| gap: 12px; |
| } |
| |
| .model-card { |
| padding: 16px; |
| border-radius: 22px; |
| border: 1px solid var(--line); |
| background: rgba(255, 255, 255, 0.03); |
| } |
| |
| .model-card .kicker { |
| display: inline-flex; |
| margin-bottom: 10px; |
| padding: 5px 10px; |
| border-radius: 999px; |
| background: rgba(169, 215, 184, 0.12); |
| color: #cdebd6; |
| font-size: 11px; |
| font-weight: 700; |
| letter-spacing: 0.08em; |
| text-transform: uppercase; |
| } |
| |
| .model-card h3 { |
| margin: 0 0 10px; |
| font-size: 1.08rem; |
| } |
| |
| .search-grid { |
| display: grid; |
| grid-template-columns: minmax(0, 1fr) auto; |
| gap: 14px; |
| align-items: end; |
| } |
| |
| .stats { |
| padding-bottom: 2px; |
| white-space: nowrap; |
| } |
| |
| .hint, |
| .warn { |
| padding: 14px 16px; |
| border-radius: 20px; |
| border: 1px solid var(--line); |
| line-height: 1.75; |
| } |
| |
| .hint { |
| background: rgba(255, 255, 255, 0.03); |
| } |
| |
| .warn { |
| margin-bottom: 14px; |
| background: var(--warning-bg); |
| border-color: rgba(251, 146, 60, 0.22); |
| color: var(--warning-text); |
| } |
| |
| .group { |
| border: 1px solid var(--line); |
| border-radius: 24px; |
| background: linear-gradient(180deg, rgba(36, 30, 26, 0.96), rgba(21, 18, 15, 0.88)); |
| padding: 20px; |
| } |
| |
| .group + .group { |
| margin-top: 16px; |
| } |
| |
| .group-header { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| gap: 14px; |
| cursor: pointer; |
| user-select: none; |
| } |
| |
| .group-header h3 { |
| margin: 0; |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| font-size: 1.18rem; |
| } |
| |
| .group-header .meta { |
| margin-top: 4px; |
| } |
| |
| .chevron { |
| display: inline-flex; |
| transition: transform 0.18s ease; |
| } |
| |
| .group.collapsed .chevron { |
| transform: rotate(-90deg); |
| } |
| |
| .group.collapsed .group-body, |
| .group.collapsed .accounts { |
| display: none; |
| } |
| |
| .group-actions { |
| display: flex; |
| flex-wrap: wrap; |
| gap: 10px; |
| } |
| |
| .row { |
| display: grid; |
| grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); |
| gap: 12px; |
| margin-top: 16px; |
| } |
| |
| .row.proxy-one-row { |
| grid-template-columns: 1fr 2fr 4fr 2fr 1.3fr 2fr; |
| } |
| |
| .accounts { |
| margin-top: 18px; |
| padding-top: 18px; |
| border-top: 1px solid var(--line); |
| } |
| |
| .accounts-head { |
| display: flex; |
| flex-wrap: wrap; |
| justify-content: space-between; |
| gap: 10px; |
| align-items: center; |
| margin-bottom: 12px; |
| } |
| |
| .accounts-head h4 { |
| margin: 0; |
| font-size: 1rem; |
| } |
| |
| .account { |
| position: relative; |
| padding: 18px; |
| border-radius: 22px; |
| border: 1px solid var(--line); |
| background: rgba(10, 9, 8, 0.42); |
| } |
| |
| .account + .account { |
| margin-top: 12px; |
| } |
| |
| .account-status-badge { |
| position: absolute; |
| top: 14px; |
| right: 14px; |
| display: inline-flex; |
| align-items: center; |
| padding: 5px 10px; |
| border-radius: 999px; |
| border: 1px solid transparent; |
| font-size: 12px; |
| font-weight: 700; |
| } |
| |
| .account-status-badge.active { |
| background: rgba(20, 83, 45, 0.36); |
| color: #c8f2d6; |
| border-color: rgba(74, 222, 128, 0.16); |
| } |
| |
| .account-status-badge.frozen { |
| background: rgba(127, 29, 29, 0.36); |
| color: #fecaca; |
| border-color: rgba(248, 113, 113, 0.2); |
| } |
| |
| .account-status-badge.disabled { |
| background: rgba(63, 63, 70, 0.36); |
| color: #e4e4e7; |
| border-color: rgba(212, 212, 216, 0.12); |
| } |
| |
| .account-status-badge.idle { |
| background: rgba(30, 41, 59, 0.36); |
| color: #dbe7f5; |
| border-color: rgba(148, 163, 184, 0.16); |
| } |
| |
| .account-row1 { |
| display: flex; |
| flex-wrap: wrap; |
| align-items: flex-end; |
| gap: 12px; |
| margin-bottom: 12px; |
| padding-top: 26px; |
| } |
| |
| .account-row1 .f { |
| min-width: 120px; |
| } |
| |
| .account-row1 .f.name { |
| min-width: 120px; |
| max-width: 180px; |
| } |
| |
| .account-row1 .f.freeze-time { |
| min-width: 220px; |
| } |
| |
| .account-row2 .f { |
| width: 100%; |
| } |
| |
| .toggle { |
| display: inline-flex; |
| align-items: center; |
| gap: 8px; |
| min-height: 44px; |
| margin: 0; |
| color: var(--text); |
| } |
| |
| .toggle input { |
| width: auto; |
| margin: 0; |
| } |
| |
| .pagination { |
| display: flex; |
| flex-wrap: wrap; |
| align-items: center; |
| gap: 12px; |
| margin-top: 14px; |
| } |
| |
| .pagination .info { |
| color: var(--muted); |
| font-size: 13px; |
| } |
| |
| .msg { |
| position: fixed; |
| top: 16px; |
| left: 50%; |
| transform: translateX(-50%); |
| z-index: 1000; |
| display: none; |
| min-width: 260px; |
| max-width: min(560px, calc(100vw - 24px)); |
| padding: 13px 16px; |
| border-radius: 16px; |
| border: 1px solid transparent; |
| box-shadow: 0 18px 60px rgba(0, 0, 0, 0.35); |
| } |
| |
| .msg.success { |
| background: var(--success-bg); |
| color: var(--success-text); |
| border-color: rgba(74, 222, 128, 0.12); |
| } |
| |
| .msg.error { |
| background: var(--danger-bg); |
| color: var(--danger-text); |
| border-color: rgba(248, 113, 113, 0.18); |
| } |
| |
| .empty { |
| padding: 18px; |
| border-radius: 18px; |
| border: 1px dashed var(--line); |
| } |
| |
| @media (max-width: 1120px) { |
| .top-grid { |
| grid-template-columns: 1fr; |
| } |
| } |
| |
| @media (max-width: 920px) { |
| .row.proxy-one-row { |
| grid-template-columns: repeat(2, minmax(0, 1fr)); |
| } |
| } |
| |
| @media (max-width: 760px) { |
| .shell { |
| width: min(100vw - 18px, 1280px); |
| padding-top: 14px; |
| } |
| |
| .hero, |
| .section, |
| .group { |
| padding: 18px; |
| } |
| |
| .field-grid, |
| .search-grid { |
| grid-template-columns: 1fr; |
| } |
| |
| .field.span-2 { |
| grid-column: span 1; |
| } |
| |
| .toolbar { |
| width: 100%; |
| justify-content: flex-start; |
| } |
| |
| .btn { |
| width: 100%; |
| } |
| |
| .group-header { |
| flex-direction: column; |
| align-items: flex-start; |
| } |
| } |
| </style> |
| </head> |
| <body> |
| <div id="msg" class="msg"></div> |
|
|
| <main class="shell"> |
| <section class="panel hero"> |
| <div class="hero-copy"> |
| <div class="eyebrow">Admin dashboard</div> |
| <h1>Web2API configuration</h1> |
| <p> |
| Manage proxy groups, account auth JSON, global API keys, the admin password, and the |
| public model mapping used by this bridge. |
| </p> |
| </div> |
| <div class="toolbar"> |
| <button type="button" class="btn btn-primary" id="addGroup">Add proxy group</button> |
| <button type="button" class="btn" id="load">Reload</button> |
| <button type="button" class="btn btn-primary" id="save">Save config</button> |
| <button type="button" class="btn" id="logout">Logout</button> |
| </div> |
| </section> |
|
|
| <div class="stack"> |
| <section class="top-grid"> |
| <article class="panel section"> |
| <div class="section-head"> |
| <div> |
| <h2>Global auth settings</h2> |
| </div> |
| <p> |
| Database-backed values persist across restarts and take precedence once saved here. |
| Environment variables are used as the initial fallback when the database has no value. |
| </p> |
| </div> |
| <div class="pill-row"> |
| <div class="pill" id="apiKeySourceBadge">API key source</div> |
| <div class="pill" id="adminPasswordSourceBadge">Admin password source</div> |
| </div> |
| <div class="field-grid"> |
| <div class="field span-2"> |
| <label for="globalApiKey">API keys</label> |
| <textarea |
| id="globalApiKey" |
| placeholder="One key per line, or use comma-separated values" |
| ></textarea> |
| <p class="field-note" id="apiKeyHint"></p> |
| </div> |
| <div class="field"> |
| <label for="globalAdminPassword">New admin password</label> |
| <input |
| id="globalAdminPassword" |
| type="password" |
| autocomplete="new-password" |
| placeholder="Leave blank to keep the current password" |
| /> |
| <p class="field-note" id="adminPasswordHint"></p> |
| </div> |
| <div class="field"> |
| <label>Dashboard status</label> |
| <div class="inline-state" id="adminPasswordState">Loading…</div> |
| <p class="field-note">Saving a new password signs out the current dashboard session.</p> |
| </div> |
| </div> |
| <div class="sub-actions"> |
| <button type="button" class="btn btn-primary" id="saveAuthSettings"> |
| Save auth settings |
| </button> |
| <button type="button" class="btn btn-danger" id="clearAdminPassword"> |
| Disable dashboard password |
| </button> |
| </div> |
| </article> |
|
|
| <article class="panel section"> |
| <div class="section-head"> |
| <div> |
| <h2>Supported models</h2> |
| </div> |
| <p> |
| These public IDs are exposed to clients and resolved to the upstream Claude model IDs |
| shown below. |
| </p> |
| </div> |
| <div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;padding:0 4px;"> |
| <label style="margin:0;font-weight:500;font-size:0.92em;cursor:pointer;" for="proModelsToggle"> |
| Enable Pro models (Haiku, Opus) |
| </label> |
| <input type="checkbox" id="proModelsToggle" style="width:18px;height:18px;cursor:pointer;" /> |
| <span id="proModelsStatus" style="font-size:0.85em;color:#888;">Loading…</span> |
| </div> |
| <div id="modelsList" class="models-list"> |
| <div class="empty">Loading model metadata…</div> |
| </div> |
| </article> |
| </section> |
|
|
| <section class="panel section"> |
| <div class="search-grid"> |
| <div> |
| <label for="search">Search groups or accounts</label> |
| <input |
| type="text" |
| id="search" |
| placeholder="Filter by proxy host, proxy user, account name, or type" |
| /> |
| </div> |
| <div class="stats" id="stats"></div> |
| </div> |
| </section> |
|
|
| <section class="panel section"> |
| <div class="hint"> |
| <strong>Network mode:</strong> When <em>Use proxy</em> is enabled, the browser goes out |
| through the configured proxy. When disabled, the browser uses this machine’s own exit IP. |
| Avoid packing many accounts into one direct-connection group, or the upstream site may |
| link them together by IP. |
| </div> |
| <div id="list"></div> |
| </section> |
| </div> |
| </main> |
|
|
| <script> |
| const API = '/api/config' |
| const STATUS_API = '/api/config/status' |
| const TYPES_API = '/api/types' |
| const AUTH_SETTINGS_API = '/api/config/auth-settings' |
| const MODELS_API = '/api/config/models' |
| |
| const PAGE_SIZE_OPTIONS = [10, 20, 50, 100] |
| const DEFAULT_PAGE_SIZE = 20 |
| |
| let registeredTypes = ['claude'] |
| let runtimeStatus = {} |
| let runtimeNow = null |
| let authSettings = null |
| let modelMetadata = null |
| let config = [] |
| let groupCollapsed = {} |
| let groupPage = {} |
| let groupPageSize = {} |
| |
| const TIMEZONES = (() => { |
| if (typeof Intl !== 'undefined' && Intl.supportedValuesOf) { |
| try { |
| return Intl.supportedValuesOf('timeZone').sort() |
| } catch (_) {} |
| } |
| return [ |
| 'Africa/Cairo', |
| 'Africa/Johannesburg', |
| 'Africa/Lagos', |
| 'Africa/Nairobi', |
| 'Africa/Tunis', |
| 'America/Argentina/Buenos_Aires', |
| 'America/Bogota', |
| 'America/Chicago', |
| 'America/Denver', |
| 'America/Lima', |
| 'America/Los_Angeles', |
| 'America/Mexico_City', |
| 'America/New_York', |
| 'America/Santiago', |
| 'America/Sao_Paulo', |
| 'America/Toronto', |
| 'America/Vancouver', |
| 'Asia/Bangkok', |
| 'Asia/Colombo', |
| 'Asia/Dubai', |
| 'Asia/Ho_Chi_Minh', |
| 'Asia/Hong_Kong', |
| 'Asia/Jakarta', |
| 'Asia/Jerusalem', |
| 'Asia/Karachi', |
| 'Asia/Kolkata', |
| 'Asia/Manila', |
| 'Asia/Seoul', |
| 'Asia/Shanghai', |
| 'Asia/Singapore', |
| 'Asia/Taipei', |
| 'Asia/Tehran', |
| 'Asia/Tokyo', |
| 'Australia/Adelaide', |
| 'Australia/Melbourne', |
| 'Australia/Perth', |
| 'Australia/Sydney', |
| 'Europe/Amsterdam', |
| 'Europe/Athens', |
| 'Europe/Berlin', |
| 'Europe/Istanbul', |
| 'Europe/London', |
| 'Europe/Madrid', |
| 'Europe/Moscow', |
| 'Europe/Paris', |
| 'Europe/Rome', |
| 'Europe/Stockholm', |
| 'Pacific/Auckland', |
| 'Pacific/Fiji', |
| 'Pacific/Guam', |
| 'Pacific/Honolulu', |
| 'UTC', |
| ].sort() |
| })() |
| |
| function escapeAttr(value) { |
| if (value == null) return '' |
| return String(value) |
| .replace(/&/g, '&') |
| .replace(/"/g, '"') |
| .replace(/</g, '<') |
| .replace(/>/g, '>') |
| } |
| |
| function describeSource(source) { |
| if (source === 'env') return 'Environment' |
| if (source === 'db') return 'Database' |
| if (source === 'yaml') return 'YAML' |
| return 'Default' |
| } |
| |
| function setButtonBusy(button, busy, busyLabel, idleLabel) { |
| if (!button) return |
| button.disabled = !!busy |
| button.textContent = busy ? busyLabel : idleLabel |
| } |
| |
| async function parseResponse(res) { |
| const text = await res.text() |
| let data = null |
| try { |
| data = text ? JSON.parse(text) : null |
| } catch (_) {} |
| return { text, data } |
| } |
| |
| async function apiFetch(url, options = {}) { |
| const res = await fetch(url, options) |
| if (res.status === 401) { |
| window.location.href = '/login' |
| throw new Error('Your dashboard session has expired. Please sign in again.') |
| } |
| return res |
| } |
| |
| function showMsg(text, type = 'success') { |
| const el = document.getElementById('msg') |
| el.className = 'msg ' + type |
| el.textContent = text |
| el.style.display = 'block' |
| clearTimeout(showMsg._timer) |
| showMsg._timer = setTimeout(() => { |
| el.style.display = 'none' |
| }, 4200) |
| } |
| |
| async function loadTypes() { |
| try { |
| const res = await apiFetch(TYPES_API) |
| if (!res.ok) return |
| const list = await res.json() |
| if (Array.isArray(list) && list.length) registeredTypes = list |
| } catch (_) {} |
| } |
| |
| async function loadConfig() { |
| const res = await apiFetch(API) |
| const payload = await parseResponse(res) |
| if (!res.ok) throw new Error((payload.data && payload.data.detail) || payload.text || res.statusText) |
| return payload.data |
| } |
| |
| async function loadStatus() { |
| const res = await apiFetch(STATUS_API) |
| const payload = await parseResponse(res) |
| if (!res.ok) throw new Error((payload.data && payload.data.detail) || payload.text || res.statusText) |
| return payload.data |
| } |
| |
| async function loadAuthSettings() { |
| const res = await apiFetch(AUTH_SETTINGS_API) |
| const payload = await parseResponse(res) |
| if (!res.ok) throw new Error((payload.data && payload.data.detail) || payload.text || res.statusText) |
| return payload.data |
| } |
| |
| async function loadModelMetadata() { |
| const res = await apiFetch(MODELS_API) |
| const payload = await parseResponse(res) |
| if (!res.ok) throw new Error((payload.data && payload.data.detail) || payload.text || res.statusText) |
| return payload.data |
| } |
| |
| function authToStr(auth) { |
| if (auth == null || typeof auth !== 'object') return '{}' |
| try { |
| return JSON.stringify(auth, null, 2) |
| } catch { |
| return '{}' |
| } |
| } |
| |
| function parseAuth(text) { |
| if (typeof text !== 'string' || !text.trim()) return {} |
| try { |
| const parsed = JSON.parse(text) |
| return typeof parsed === 'object' && parsed !== null ? parsed : {} |
| } catch { |
| return {} |
| } |
| } |
| |
| function asUnix(value) { |
| if (typeof value === 'number' && Number.isFinite(value)) return Math.trunc(value) |
| if (typeof value === 'string' && value.trim()) { |
| const n = Number(value) |
| if (Number.isFinite(n)) return Math.trunc(n) |
| } |
| return null |
| } |
| |
| function formatDateTime(value) { |
| const ts = asUnix(value) |
| if (ts == null || ts <= 0) return '—' |
| try { |
| return new Date(ts * 1000).toLocaleString(undefined, { hour12: false }) |
| } catch (_) { |
| return String(ts) |
| } |
| } |
| |
| function applyRuntimeStatus(payload) { |
| runtimeStatus = (payload && payload.accounts) || {} |
| runtimeNow = |
| payload && typeof payload.now === 'number' ? payload.now : Math.trunc(Date.now() / 1000) |
| } |
| |
| function accountId(group, account) { |
| return `${group.fingerprint_id || ''}:${account.name || ''}` |
| } |
| |
| function getAccountBadge(group, account) { |
| const enabled = account.enabled !== false |
| const runtime = runtimeStatus[accountId(group, account)] || null |
| const isActive = !!(runtime && runtime.is_active) |
| const unfreezeAt = asUnix( |
| runtime && runtime.unfreeze_at != null ? runtime.unfreeze_at : account.unfreeze_at |
| ) |
| const now = runtimeNow || Math.trunc(Date.now() / 1000) |
| const isFrozen = unfreezeAt != null && unfreezeAt > now |
| if (!enabled) return { className: 'disabled', text: 'Disabled' } |
| if (isActive) return { className: 'active', text: 'Active' } |
| if (isFrozen) return { className: 'frozen', text: 'Frozen' } |
| return { className: 'idle', text: 'Idle' } |
| } |
| |
| function renderAuthSettings(data) { |
| authSettings = data || null |
| const apiKeyEl = document.getElementById('globalApiKey') |
| const apiKeyHintEl = document.getElementById('apiKeyHint') |
| const apiKeySourceBadge = document.getElementById('apiKeySourceBadge') |
| const adminPasswordEl = document.getElementById('globalAdminPassword') |
| const adminPasswordHintEl = document.getElementById('adminPasswordHint') |
| const adminPasswordSourceBadge = document.getElementById('adminPasswordSourceBadge') |
| const adminPasswordStateEl = document.getElementById('adminPasswordState') |
| const clearAdminPasswordBtn = document.getElementById('clearAdminPassword') |
| |
| if (!data) { |
| apiKeyEl.value = '' |
| apiKeyEl.disabled = true |
| adminPasswordEl.value = '' |
| adminPasswordEl.disabled = true |
| clearAdminPasswordBtn.disabled = true |
| apiKeyHintEl.textContent = 'Failed to load auth settings.' |
| adminPasswordHintEl.textContent = 'Failed to load auth settings.' |
| adminPasswordStateEl.textContent = 'Unavailable' |
| apiKeySourceBadge.innerHTML = '<strong>API keys</strong> Unavailable' |
| adminPasswordSourceBadge.innerHTML = '<strong>Admin password</strong> Unavailable' |
| return |
| } |
| |
| apiKeyEl.value = data.api_key || '' |
| apiKeyEl.disabled = false |
| apiKeySourceBadge.innerHTML = `<strong>API keys</strong> ${describeSource(data.api_key_source)}` |
| apiKeyHintEl.textContent = data.api_key_source === 'env' |
| ? 'Currently using WEB2API_AUTH_API_KEY as the fallback value. Saving here writes to the database and takes precedence from then on.' |
| : 'Accepts one key per line, or a comma-separated list.' |
| |
| adminPasswordEl.value = '' |
| adminPasswordEl.disabled = false |
| clearAdminPasswordBtn.disabled = false |
| adminPasswordSourceBadge.innerHTML = `<strong>Admin password</strong> ${describeSource( |
| data.admin_password_source |
| )}` |
| adminPasswordHintEl.textContent = data.admin_password_source === 'env' |
| ? 'Currently using WEB2API_AUTH_CONFIG_SECRET as the fallback value. Saving here writes to the database and takes precedence from then on.' |
| : 'Enter a new password only when you want to rotate it.' |
| adminPasswordStateEl.textContent = data.admin_password_configured |
| ? 'Dashboard password is enabled.' |
| : 'Dashboard password is disabled.' |
| } |
| |
| function renderModels(metadata) { |
| modelMetadata = metadata || null |
| const list = document.getElementById('modelsList') |
| const mapping = (metadata && metadata.model_mapping) || {} |
| const entries = Object.entries(mapping) |
| if (!entries.length) { |
| list.innerHTML = '<div class="empty">No model metadata available.</div>' |
| return |
| } |
| list.innerHTML = entries |
| .map( |
| ([publicModel, upstreamModel]) => ` |
| <article class="model-card"> |
| <div class="kicker">${ |
| publicModel === metadata.default_model ? 'Default model' : 'Available model' |
| }</div> |
| <h3><code>${escapeAttr(publicModel)}</code></h3> |
| <p class="meta">Public model ID accepted by client requests.</p> |
| <p class="meta"><strong>Upstream:</strong> <code>${escapeAttr(upstreamModel)}</code></p> |
| </article> |
| ` |
| ) |
| .join('') |
| } |
| |
| function renderAccount(account, accountIndex, group, onRemove, onChange) { |
| const currentType = account.type || 'claude' |
| const typeOptions = registeredTypes.includes(currentType) |
| ? registeredTypes |
| : [currentType].concat(registeredTypes) |
| const badge = getAccountBadge(group, account) |
| const div = document.createElement('div') |
| div.className = 'account' |
| div.dataset.accountIndex = String(accountIndex) |
| div.innerHTML = ` |
| <div class="account-status-badge ${badge.className}">${badge.text}</div> |
| <div class="account-row1"> |
| <div class="f name"> |
| <label>Account name</label> |
| <input type="text" data-k="name" value="${escapeAttr(account.name)}" placeholder="claude-01" /> |
| </div> |
| <div class="f type"> |
| <label>Provider type</label> |
| <select data-k="type"> |
| ${typeOptions |
| .map( |
| (typeName) => |
| `<option value="${escapeAttr(typeName)}" ${ |
| typeName === currentType ? 'selected' : '' |
| }>${escapeAttr(typeName)}</option>` |
| ) |
| .join('')} |
| </select> |
| </div> |
| <div class="f enabled"> |
| <label>Enabled</label> |
| <label class="toggle"> |
| <input type="checkbox" data-k="enabled" ${account.enabled !== false ? 'checked' : ''} /> |
| <span>Accept traffic</span> |
| </label> |
| </div> |
| <div class="f freeze-time"> |
| <label>Unfreeze time</label> |
| <input type="text" data-k="unfreeze_at_display" value="${escapeAttr( |
| formatDateTime(account.unfreeze_at) |
| )}" readonly /> |
| </div> |
| <div class="actions"> |
| <button type="button" class="btn btn-danger btn-sm">Delete account</button> |
| </div> |
| </div> |
| <div class="account-row2"> |
| <div class="f"> |
| <label>Auth JSON</label> |
| <textarea data-k="auth" rows="3" placeholder='{"sessionKey": "..."}'>${escapeAttr( |
| authToStr(account.auth) |
| )}</textarea> |
| </div> |
| </div> |
| ` |
| div.querySelector('[data-k="name"]').oninput = (event) => onChange(accountIndex, 'name', event.target.value) |
| div.querySelector('[data-k="type"]').onchange = (event) => onChange(accountIndex, 'type', event.target.value) |
| div.querySelector('[data-k="enabled"]').onchange = (event) => |
| onChange(accountIndex, 'enabled', event.target.checked, true) |
| div.querySelector('[data-k="auth"]').oninput = (event) => onChange(accountIndex, 'auth', event.target.value) |
| div.querySelector('.btn-danger').onclick = () => onRemove(accountIndex) |
| return div |
| } |
| |
| function groupKey(group, index) { |
| const host = (group.proxy_host || '').trim() |
| const user = (group.proxy_user || '').trim() |
| return host || user ? `${host}|${user}` : `idx-${index}` |
| } |
| |
| function filterConfig(cfg, search) { |
| const query = (search || '').trim().toLowerCase() |
| if (!query) return cfg.map((group, i) => ({ group, groupIndex: i, accountIndices: null })) |
| const result = [] |
| cfg.forEach((group, groupIndex) => { |
| const matchHost = (group.proxy_host || '').toLowerCase().includes(query) |
| const matchUser = (group.proxy_user || '').toLowerCase().includes(query) |
| const allAccounts = group.accounts || [] |
| const accountIndices = allAccounts |
| .map((_, i) => i) |
| .filter((i) => { |
| const account = allAccounts[i] |
| return ( |
| (account.name || '').toLowerCase().includes(query) || |
| (account.type || '').toLowerCase().includes(query) |
| ) |
| }) |
| if (matchHost || matchUser || accountIndices.length > 0) { |
| result.push({ |
| group, |
| groupIndex, |
| accountIndices: accountIndices.length ? accountIndices : null, |
| }) |
| } |
| }) |
| return result |
| } |
| |
| function renderGroup( |
| group, |
| groupIndex, |
| onRemoveGroup, |
| onAddAccount, |
| onRemoveAccount, |
| onAccountChange, |
| onGroupChange, |
| opts |
| ) { |
| const valueOf = (key, fallback = '') => (group[key] != null && group[key] !== '' ? group[key] : fallback) |
| const useProxy = group.use_proxy !== false |
| const gkey = groupKey(group, groupIndex) |
| const allAccounts = group.accounts || [] |
| const indices = opts.accountIndices || allAccounts.map((_, i) => i) |
| const totalCount = indices.length |
| const isCollapsed = groupCollapsed[gkey] === true |
| const pageSize = groupPageSize[gkey] || DEFAULT_PAGE_SIZE |
| const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)) |
| const currentPage = Math.min(groupPage[gkey] || 0, totalPages - 1) |
| const start = currentPage * pageSize |
| const pageIndices = indices.slice(start, start + pageSize) |
| const pageAccounts = pageIndices.map((i) => allAccounts[i]) |
| |
| const div = document.createElement('div') |
| div.className = 'group' + (isCollapsed ? ' collapsed' : '') |
| div.dataset.groupIndex = String(groupIndex) |
| div.innerHTML = ` |
| <div class="group-header" data-action="toggle"> |
| <div> |
| <h3> |
| <span class="chevron">▼</span> |
| Proxy group ${groupIndex + 1} |
| </h3> |
| <p class="meta">${escapeAttr( |
| useProxy ? group.proxy_host || 'Proxy not set' : 'Direct connection' |
| )} · ${allAccounts.length} accounts</p> |
| </div> |
| <div class="group-actions" onclick="event.stopPropagation()"> |
| <button type="button" class="btn btn-sm" data-action="add-account">Add account</button> |
| <button type="button" class="btn btn-danger btn-sm" data-action="remove-group">Delete group</button> |
| </div> |
| </div> |
| <div class="group-body"> |
| ${ |
| useProxy |
| ? '' |
| : '<div class="warn">This group is using a direct connection. The browser will use this machine\'s own exit IP. Avoid packing many accounts into one direct group.</div>' |
| } |
| <div class="row proxy-one-row"> |
| <div> |
| <label>Use proxy</label> |
| <label class="toggle"> |
| <input type="checkbox" data-k="use_proxy" ${useProxy ? 'checked' : ''} /> |
| <span>Use proxy</span> |
| </label> |
| </div> |
| <div> |
| <label>Proxy host</label> |
| <input type="text" data-k="proxy_host" value="${escapeAttr(valueOf('proxy_host'))}" placeholder="sg.arxlabs.io:3010" ${ |
| useProxy ? '' : 'disabled' |
| } /> |
| </div> |
| <div> |
| <label>Proxy user</label> |
| <input type="text" data-k="proxy_user" value="${escapeAttr(valueOf('proxy_user'))}" placeholder="Proxy username" ${ |
| useProxy ? '' : 'disabled' |
| } /> |
| </div> |
| <div> |
| <label>Proxy password</label> |
| <input type="text" data-k="proxy_pass" value="${escapeAttr(valueOf('proxy_pass'))}" placeholder="Proxy password" ${ |
| useProxy ? '' : 'disabled' |
| } /> |
| </div> |
| <div> |
| <label>Fingerprint ID</label> |
| <input type="text" data-k="fingerprint_id" value="${escapeAttr(valueOf('fingerprint_id'))}" placeholder="4567" /> |
| </div> |
| <div> |
| <label>Timezone</label> |
| <select data-k="timezone">${(() => { |
| const value = valueOf('timezone') || 'America/Chicago' |
| const options = TIMEZONES.includes(value) ? TIMEZONES : [value, ...TIMEZONES].sort() |
| return options |
| .map( |
| (tz) => |
| `<option value="${escapeAttr(tz)}" ${tz === value ? 'selected' : ''}>${escapeAttr(tz)}</option>` |
| ) |
| .join('') |
| })()}</select> |
| </div> |
| </div> |
| <div class="accounts"> |
| <div class="accounts-head"> |
| <h4>Accounts</h4> |
| <p class="meta">name / type / auth JSON</p> |
| </div> |
| <div class="accounts-list"></div> |
| ${ |
| totalPages > 1 |
| ? ` |
| <div class="pagination"> |
| <span class="info">Page ${currentPage + 1} of ${totalPages} · ${totalCount} accounts</span> |
| <select data-action="page-size"> |
| ${PAGE_SIZE_OPTIONS.map( |
| (n) => `<option value="${n}" ${n === pageSize ? 'selected' : ''}>${n} / page</option>` |
| ).join('')} |
| </select> |
| <button type="button" class="btn btn-sm" data-action="prev" ${ |
| currentPage <= 0 ? 'disabled' : '' |
| }>Previous</button> |
| <button type="button" class="btn btn-sm" data-action="next" ${ |
| currentPage >= totalPages - 1 ? 'disabled' : '' |
| }>Next</button> |
| </div> |
| ` |
| : '' |
| } |
| </div> |
| </div> |
| ` |
| |
| div.querySelector('[data-action="toggle"]').onclick = () => { |
| groupCollapsed[gkey] = !groupCollapsed[gkey] |
| opts.rerender() |
| } |
| |
| ;['proxy_host', 'proxy_user', 'proxy_pass', 'fingerprint_id'].forEach((key) => { |
| const element = div.querySelector(`[data-k="${key}"]`) |
| if (element) element.oninput = (event) => onGroupChange(groupIndex, key, event.target.value) |
| }) |
| |
| const useProxyEl = div.querySelector('[data-k="use_proxy"]') |
| if (useProxyEl) { |
| useProxyEl.onchange = (event) => onGroupChange(groupIndex, 'use_proxy', event.target.checked, true) |
| } |
| |
| const timezoneEl = div.querySelector('[data-k="timezone"]') |
| if (timezoneEl) timezoneEl.onchange = (event) => onGroupChange(groupIndex, 'timezone', event.target.value) |
| |
| div.querySelector('[data-action="add-account"]').onclick = () => onAddAccount(groupIndex) |
| div.querySelector('[data-action="remove-group"]').onclick = () => onRemoveGroup(groupIndex) |
| |
| const pageSizeEl = div.querySelector('[data-action="page-size"]') |
| if (pageSizeEl) { |
| pageSizeEl.onchange = (event) => { |
| groupPageSize[gkey] = Number(event.target.value) |
| groupPage[gkey] = 0 |
| opts.rerender() |
| } |
| } |
| |
| const prevEl = div.querySelector('[data-action="prev"]') |
| if (prevEl) { |
| prevEl.onclick = () => { |
| if (currentPage > 0) { |
| groupPage[gkey] = currentPage - 1 |
| opts.rerender() |
| } |
| } |
| } |
| |
| const nextEl = div.querySelector('[data-action="next"]') |
| if (nextEl) { |
| nextEl.onclick = () => { |
| if (currentPage < totalPages - 1) { |
| groupPage[gkey] = currentPage + 1 |
| opts.rerender() |
| } |
| } |
| } |
| |
| const list = div.querySelector('.accounts-list') |
| pageAccounts.forEach((account, localIndex) => { |
| const globalIndex = pageIndices[localIndex] |
| list.appendChild( |
| renderAccount( |
| account, |
| globalIndex, |
| group, |
| onRemoveAccount.bind(null, groupIndex), |
| onAccountChange.bind(null, groupIndex) |
| ) |
| ) |
| }) |
| |
| return div |
| } |
| |
| function getConfigFromForm() { |
| return (config || []) |
| .map((group) => ({ |
| use_proxy: group.use_proxy !== false, |
| proxy_host: (group.proxy_host || '').trim(), |
| proxy_user: (group.proxy_user || '').trim(), |
| proxy_pass: (group.proxy_pass || '').trim(), |
| fingerprint_id: (group.fingerprint_id || '').trim(), |
| timezone: (group.timezone || '').trim() || undefined, |
| accounts: (group.accounts || []) |
| .map((account) => { |
| const name = (account.name || '').trim() |
| if (!name) return null |
| const normalized = { |
| name, |
| type: (account.type || 'claude').trim() || 'claude', |
| auth: typeof account.auth === 'string' ? parseAuth(account.auth) : account.auth || {}, |
| enabled: account.enabled !== false, |
| } |
| const unfreezeAt = asUnix(account.unfreeze_at) |
| if (unfreezeAt != null) normalized.unfreeze_at = unfreezeAt |
| return normalized |
| }) |
| .filter(Boolean), |
| })) |
| .filter((group) => group.accounts.length) |
| } |
| |
| function updateAccountStatusBadges() { |
| document.querySelectorAll('.group').forEach((groupEl) => { |
| const groupIndex = Number(groupEl.dataset.groupIndex) |
| const group = config[groupIndex] |
| if (!group) return |
| groupEl.querySelectorAll('.account').forEach((accountEl) => { |
| const accountIndex = Number(accountEl.dataset.accountIndex) |
| const account = (group.accounts || [])[accountIndex] |
| if (!account) return |
| const badge = getAccountBadge(group, account) |
| const badgeEl = accountEl.querySelector('.account-status-badge') |
| if (badgeEl) { |
| badgeEl.className = `account-status-badge ${badge.className}` |
| badgeEl.textContent = badge.text |
| } |
| const unfreezeEl = accountEl.querySelector('[data-k="unfreeze_at_display"]') |
| if (unfreezeEl) { |
| const runtime = runtimeStatus[accountId(group, account)] || null |
| const value = runtime && runtime.unfreeze_at != null ? runtime.unfreeze_at : account.unfreeze_at |
| unfreezeEl.value = formatDateTime(value) |
| } |
| }) |
| }) |
| } |
| |
| function render(configData) { |
| config = JSON.parse(JSON.stringify(configData || [])) |
| const searchText = (document.getElementById('search') && document.getElementById('search').value) || '' |
| const filtered = filterConfig(config, searchText) |
| const list = document.getElementById('list') |
| list.innerHTML = '' |
| |
| const totalGroups = config.length |
| const totalAccounts = config.reduce((count, group) => count + (group.accounts || []).length, 0) |
| const statsEl = document.getElementById('stats') |
| statsEl.textContent = searchText.trim() |
| ? `${filtered.length} matching groups · ${totalGroups} total groups · ${totalAccounts} accounts` |
| : `${totalGroups} groups · ${totalAccounts} accounts` |
| |
| function rerender() { |
| render(config) |
| } |
| |
| function onGroupChange(groupIndex, key, value, shouldRerender = false) { |
| if (config[groupIndex]) config[groupIndex][key] = value |
| if (shouldRerender) rerender() |
| } |
| |
| function onAddAccount(groupIndex) { |
| if (!config[groupIndex].accounts) config[groupIndex].accounts = [] |
| config[groupIndex].accounts.push({ |
| name: '', |
| type: 'claude', |
| auth: {}, |
| enabled: true, |
| unfreeze_at: null, |
| }) |
| rerender() |
| } |
| |
| function onRemoveAccount(groupIndex, accountIndex) { |
| config[groupIndex].accounts.splice(accountIndex, 1) |
| rerender() |
| } |
| |
| function onAccountChange(groupIndex, accountIndex, key, value) { |
| if (!config[groupIndex].accounts[accountIndex]) return |
| if (key === 'auth') config[groupIndex].accounts[accountIndex].auth = parseAuth(value) |
| else config[groupIndex].accounts[accountIndex][key] = value |
| if (key === 'enabled') rerender() |
| } |
| |
| function onRemoveGroup(groupIndex) { |
| config.splice(groupIndex, 1) |
| rerender() |
| } |
| |
| if (!filtered.length) { |
| list.innerHTML = '<div class="empty">No groups match the current filter.</div>' |
| return |
| } |
| |
| filtered.forEach(({ group, groupIndex, accountIndices }) => { |
| list.appendChild( |
| renderGroup(group, groupIndex, onRemoveGroup, onAddAccount, onRemoveAccount, onAccountChange, onGroupChange, { |
| rerender, |
| accountIndices, |
| }) |
| ) |
| }) |
| updateAccountStatusBadges() |
| } |
| |
| async function refreshConfigAndStatus(showLoadedMessage = false) { |
| const [data, status] = await Promise.all([ |
| loadConfig(), |
| loadStatus().catch(() => ({ accounts: {}, now: Math.trunc(Date.now() / 1000) })), |
| ]) |
| applyRuntimeStatus(status) |
| render(data) |
| if (showLoadedMessage) showMsg('Configuration reloaded.') |
| } |
| |
| async function saveAuthSettings() { |
| const button = document.getElementById('saveAuthSettings') |
| if (!authSettings) { |
| showMsg('Auth settings are not loaded yet.', 'error') |
| return |
| } |
| const payload = {} |
| payload.api_key = document.getElementById('globalApiKey').value |
| const password = document.getElementById('globalAdminPassword').value.trim() |
| if (password) payload.admin_password = password |
| if (!Object.keys(payload).length) { |
| showMsg('Nothing to save.') |
| return |
| } |
| |
| setButtonBusy(button, true, 'Saving…', 'Save auth settings') |
| try { |
| const res = await apiFetch(AUTH_SETTINGS_API, { |
| method: 'PUT', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify(payload), |
| }) |
| const parsed = await parseResponse(res) |
| if (!res.ok) { |
| throw new Error((parsed.data && parsed.data.detail) || parsed.text || res.statusText) |
| } |
| renderAuthSettings((parsed.data && parsed.data.settings) || authSettings) |
| document.getElementById('globalAdminPassword').value = '' |
| if (payload.admin_password) { |
| showMsg('Admin password updated. Please sign in again.') |
| setTimeout(() => { |
| window.location.href = '/login' |
| }, 900) |
| return |
| } |
| showMsg('Auth settings saved.') |
| } catch (error) { |
| showMsg((error && error.message) || 'Failed to save auth settings.', 'error') |
| } finally { |
| setButtonBusy(button, false, 'Saving…', 'Save auth settings') |
| } |
| } |
| |
| async function disableDashboardPassword() { |
| const button = document.getElementById('clearAdminPassword') |
| if (!authSettings) return |
| const confirmed = window.confirm( |
| 'Disable the dashboard password? The config dashboard will no longer require sign-in until a new password is set.' |
| ) |
| if (!confirmed) return |
| |
| setButtonBusy(button, true, 'Disabling…', 'Disable dashboard password') |
| try { |
| const res = await apiFetch(AUTH_SETTINGS_API, { |
| method: 'PUT', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ admin_password: '' }), |
| }) |
| const parsed = await parseResponse(res) |
| if (!res.ok) { |
| throw new Error((parsed.data && parsed.data.detail) || parsed.text || res.statusText) |
| } |
| showMsg('Dashboard password disabled. Redirecting…') |
| setTimeout(() => { |
| window.location.href = '/' |
| }, 900) |
| } catch (error) { |
| showMsg((error && error.message) || 'Failed to disable dashboard password.', 'error') |
| } finally { |
| setButtonBusy(button, false, 'Disabling…', 'Disable dashboard password') |
| } |
| } |
| |
| document.getElementById('addGroup').onclick = () => { |
| config.push({ |
| use_proxy: true, |
| proxy_host: '', |
| proxy_user: '', |
| proxy_pass: '', |
| fingerprint_id: '', |
| timezone: 'America/Chicago', |
| accounts: [{ name: '', type: 'claude', auth: {}, enabled: true, unfreeze_at: null }], |
| }) |
| render(config) |
| } |
| |
| document.getElementById('load').onclick = async () => { |
| try { |
| await Promise.all([loadTypes(), refreshConfigAndStatus(false)]) |
| const [settings, metadata] = await Promise.all([ |
| loadAuthSettings().catch(() => null), |
| loadModelMetadata().catch(() => null), |
| ]) |
| renderAuthSettings(settings) |
| renderModels(metadata) |
| showMsg('Configuration reloaded.') |
| } catch (error) { |
| showMsg((error && error.message) || 'Failed to reload configuration.', 'error') |
| } |
| } |
| |
| document.getElementById('save').onclick = async () => { |
| const toSave = getConfigFromForm() |
| if (!toSave.length) { |
| showMsg('Keep at least one proxy group with at least one account.', 'error') |
| return |
| } |
| const button = document.getElementById('save') |
| setButtonBusy(button, true, 'Saving…', 'Save config') |
| try { |
| const res = await apiFetch(API, { |
| method: 'PUT', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify(toSave), |
| }) |
| const parsed = await parseResponse(res) |
| if (!res.ok) { |
| throw new Error((parsed.data && parsed.data.detail) || parsed.text || res.statusText) |
| } |
| showMsg((parsed.data && parsed.data.message) || 'Configuration saved and applied.') |
| await refreshConfigAndStatus(false) |
| } catch (error) { |
| showMsg((error && error.message) || 'Failed to save configuration.', 'error') |
| } finally { |
| setButtonBusy(button, false, 'Saving…', 'Save config') |
| } |
| } |
| |
| document.getElementById('saveAuthSettings').onclick = () => void saveAuthSettings() |
| document.getElementById('clearAdminPassword').onclick = () => void disableDashboardPassword() |
| |
| document.getElementById('logout').onclick = async () => { |
| try { |
| await fetch('/api/admin/logout', { method: 'POST' }) |
| } catch (_) {} |
| window.location.href = '/login' |
| } |
| |
| let searchDebounce |
| document.getElementById('search').oninput = () => { |
| clearTimeout(searchDebounce) |
| searchDebounce = setTimeout(() => render(config), 180) |
| } |
| |
| |
| async function loadProModels() { |
| try { |
| const res = await apiFetch('/api/config/pro-models') |
| const data = await res.json() |
| const toggle = document.getElementById('proModelsToggle') |
| const status = document.getElementById('proModelsStatus') |
| toggle.checked = !!data.enabled |
| status.textContent = data.enabled ? 'Enabled' : 'Disabled' |
| status.style.color = data.enabled ? '#22c55e' : '#888' |
| } catch (_) { |
| document.getElementById('proModelsStatus').textContent = 'Failed to load' |
| } |
| } |
| document.getElementById('proModelsToggle').onchange = async function () { |
| const enabled = this.checked |
| const status = document.getElementById('proModelsStatus') |
| status.textContent = 'Saving…' |
| try { |
| const res = await apiFetch('/api/config/pro-models', { |
| method: 'PUT', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ enabled }), |
| }) |
| const data = await res.json() |
| status.textContent = data.enabled ? 'Enabled' : 'Disabled' |
| status.style.color = data.enabled ? '#22c55e' : '#888' |
| showMsg(data.enabled ? 'Pro models enabled.' : 'Pro models disabled.') |
| } catch (e) { |
| this.checked = !enabled |
| status.textContent = 'Error' |
| showMsg('Failed to update Pro models setting.', 'error') |
| } |
| } |
| |
| ;(async () => { |
| await loadTypes() |
| try { |
| const [data, status, settings, metadata] = await Promise.all([ |
| loadConfig(), |
| loadStatus().catch(() => ({ accounts: {}, now: Math.trunc(Date.now() / 1000) })), |
| loadAuthSettings().catch(() => null), |
| loadModelMetadata().catch(() => null), |
| ]) |
| loadProModels().catch(() => null) |
| applyRuntimeStatus(status) |
| renderAuthSettings(settings) |
| renderModels(metadata) |
| render(data) |
| } catch (error) { |
| renderAuthSettings(null) |
| renderModels(null) |
| render([]) |
| showMsg((error && error.message) || 'No configuration was loaded yet.', 'error') |
| } |
| })() |
| |
| setInterval(async () => { |
| if (document.hidden) return |
| try { |
| applyRuntimeStatus(await loadStatus()) |
| updateAccountStatusBadges() |
| } catch (_) {} |
| }, 15000) |
| </script> |
| </body> |
| </html> |
|
|