openvidia 2.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openvidia/__init__.py +30 -0
- openvidia/__main__.py +827 -0
- openvidia/_upstream_utils.py +57 -0
- openvidia/anthropic_shim.py +714 -0
- openvidia/compaction.py +851 -0
- openvidia/config.py +513 -0
- openvidia/embedding_cache.py +69 -0
- openvidia/graph_engine.py +466 -0
- openvidia/provider_fallback.py +98 -0
- openvidia/proxy_app.py +863 -0
- openvidia/proxy_state.py +1010 -0
- openvidia/redis_sync.py +93 -0
- openvidia/responses_shim.py +1374 -0
- openvidia/safe_file.py +100 -0
- openvidia/server_manager.py +106 -0
- openvidia/upstream_router.py +50 -0
- openvidia/web/assets/32x32.ico +0 -0
- openvidia/web/assets/64x64.ico +0 -0
- openvidia/web/assets/dashboard.gif +0 -0
- openvidia/web/assets/favicon.ico +0 -0
- openvidia/web/assets/logo.png +0 -0
- openvidia/web/favicon.ico +0 -0
- openvidia/web/index.html +166 -0
- openvidia/web/logo.png +0 -0
- openvidia/web/main.js +732 -0
- openvidia/web/styles.css +425 -0
- openvidia/webui.py +532 -0
- openvidia-2.2.0.dist-info/METADATA +814 -0
- openvidia-2.2.0.dist-info/RECORD +33 -0
- openvidia-2.2.0.dist-info/WHEEL +5 -0
- openvidia-2.2.0.dist-info/entry_points.txt +2 -0
- openvidia-2.2.0.dist-info/licenses/LICENSE +21 -0
- openvidia-2.2.0.dist-info/top_level.txt +1 -0
openvidia/safe_file.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Safe file operations with automatic backups."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_backup(
|
|
12
|
+
file_path: Path,
|
|
13
|
+
backup_dir: Path | None = None,
|
|
14
|
+
max_backups: int = 5,
|
|
15
|
+
) -> Path | None:
|
|
16
|
+
"""Create a timestamped backup of a file.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
file_path: Path to the file to backup
|
|
20
|
+
backup_dir: Directory for backups (default: same dir as file)
|
|
21
|
+
max_backups: Maximum number of backups to keep
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Path to the backup file, or None if backup failed
|
|
25
|
+
"""
|
|
26
|
+
if not file_path.exists():
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
backup_dir = backup_dir or file_path.parent
|
|
30
|
+
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
|
|
32
|
+
# Microseconds, not seconds. At second granularity two backups taken in the
|
|
33
|
+
# same second resolved to the same filename, so the second silently
|
|
34
|
+
# overwrote the first — the rotation kept one file where it claimed five,
|
|
35
|
+
# and the test that was supposed to catch it passed for the same reason.
|
|
36
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
|
37
|
+
backup_name = f"{file_path.stem}_backup_{timestamp}{file_path.suffix}"
|
|
38
|
+
backup_path = backup_dir / backup_name
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
shutil.copy2(file_path, backup_path)
|
|
42
|
+
# copy2 preserves the source mode, which is right for a key file — but
|
|
43
|
+
# a backup taken from a file an older version wrote 0644 would inherit
|
|
44
|
+
# 0644. Pin it: a backup of a secret is still a secret.
|
|
45
|
+
if os.name != "nt":
|
|
46
|
+
os.chmod(backup_path, 0o600)
|
|
47
|
+
|
|
48
|
+
# Cleanup old backups
|
|
49
|
+
cleanup_old_backups(file_path, backup_dir, max_backups)
|
|
50
|
+
|
|
51
|
+
return backup_path
|
|
52
|
+
except OSError as e:
|
|
53
|
+
print(f"Warning: Could not create backup: {e}")
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def cleanup_old_backups(
|
|
58
|
+
original_file: Path,
|
|
59
|
+
backup_dir: Path | None = None,
|
|
60
|
+
max_backups: int = 5,
|
|
61
|
+
) -> int:
|
|
62
|
+
"""Remove old backups, keeping only the most recent ones.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Number of backups removed
|
|
66
|
+
"""
|
|
67
|
+
backup_dir = backup_dir or original_file.parent
|
|
68
|
+
|
|
69
|
+
# Find all backups for this file
|
|
70
|
+
prefix = f"{original_file.stem}_backup_"
|
|
71
|
+
suffix = original_file.suffix
|
|
72
|
+
|
|
73
|
+
backups = []
|
|
74
|
+
for f in backup_dir.iterdir():
|
|
75
|
+
if f.is_file() and f.name.startswith(prefix) and f.name.endswith(suffix):
|
|
76
|
+
backups.append(f)
|
|
77
|
+
|
|
78
|
+
# Sort by modification time (newest first)
|
|
79
|
+
backups.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
|
80
|
+
|
|
81
|
+
# Remove old backups
|
|
82
|
+
removed = 0
|
|
83
|
+
for old_backup in backups[max_backups:]:
|
|
84
|
+
try:
|
|
85
|
+
old_backup.unlink()
|
|
86
|
+
removed += 1
|
|
87
|
+
except OSError:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
return removed
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# Removed with this commit: safe_write_with_backup, restore_from_backup and
|
|
94
|
+
# list_backups. None had a production caller — the test suite was their only
|
|
95
|
+
# consumer, which made a dead API look maintained. safe_write_with_backup also
|
|
96
|
+
# duplicated config.atomic_write with a *different* temp-file convention
|
|
97
|
+
# (keys.json.tmp vs keys.tmp) and without the 0600 mode or the fsync, so the
|
|
98
|
+
# two would have raced each other had anything used both. restore_from_backup
|
|
99
|
+
# built its path from an unvalidated timestamp, so a caller that ever exposed
|
|
100
|
+
# it over HTTP would have handed out arbitrary file overwrite.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Owns the actual uvicorn server instance(s) for one proxy run.
|
|
3
|
+
|
|
4
|
+
Binds loopback on BOTH IP stacks so a client reaching us as "localhost"
|
|
5
|
+
works whether its resolver picks IPv4 (127.0.0.1) or IPv6 (::1) — the
|
|
6
|
+
latter is what Node/Bun try first. Loopback-only, never all-interfaces:
|
|
7
|
+
the proxy serves unauthenticated API keys. IPv6 loopback is best-effort —
|
|
8
|
+
don't fail proxy startup on hosts with IPv6 off.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import socket as socket_mod
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import uvicorn
|
|
17
|
+
|
|
18
|
+
from .proxy_app import create_app
|
|
19
|
+
from .proxy_state import ProxyState, ProxyStats
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ProxyServer:
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
servers: list[uvicorn.Server],
|
|
26
|
+
tasks: list[asyncio.Task],
|
|
27
|
+
state: ProxyState,
|
|
28
|
+
):
|
|
29
|
+
self._servers = servers
|
|
30
|
+
self._tasks = tasks
|
|
31
|
+
self.state = state
|
|
32
|
+
|
|
33
|
+
async def shutdown(self):
|
|
34
|
+
for s in self._servers:
|
|
35
|
+
s.should_exit = True
|
|
36
|
+
for t in self._tasks:
|
|
37
|
+
try:
|
|
38
|
+
await asyncio.wait_for(t, timeout=2.0)
|
|
39
|
+
except (TimeoutError, asyncio.CancelledError):
|
|
40
|
+
t.cancel()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _bind(family: int, host: str, port: int) -> socket_mod.socket | None:
|
|
44
|
+
try:
|
|
45
|
+
sock = socket_mod.socket(family, socket_mod.SOCK_STREAM)
|
|
46
|
+
except OSError:
|
|
47
|
+
return None
|
|
48
|
+
sock.setsockopt(socket_mod.SOL_SOCKET, socket_mod.SO_REUSEADDR, 1)
|
|
49
|
+
try:
|
|
50
|
+
sock.bind((host, port))
|
|
51
|
+
sock.listen(128)
|
|
52
|
+
sock.setblocking(False)
|
|
53
|
+
return sock
|
|
54
|
+
except OSError:
|
|
55
|
+
sock.close()
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def start(
|
|
60
|
+
port: int,
|
|
61
|
+
keys: list[str],
|
|
62
|
+
log_cb: Callable[[str], None],
|
|
63
|
+
stats: ProxyStats,
|
|
64
|
+
web_dir: Path | None = None,
|
|
65
|
+
initial_model: str = "",
|
|
66
|
+
) -> ProxyServer:
|
|
67
|
+
state = ProxyState(keys=keys, stats=stats, log_cb=log_cb, port=port)
|
|
68
|
+
if initial_model:
|
|
69
|
+
state.active_model = initial_model
|
|
70
|
+
app = create_app(state, web_dir=web_dir)
|
|
71
|
+
|
|
72
|
+
# Bind explicitly first (like Rust's TcpListener::bind) so a host with
|
|
73
|
+
# IPv6 off just skips that stack instead of taking the whole proxy down.
|
|
74
|
+
v4_sock = _bind(socket_mod.AF_INET, "127.0.0.1", port)
|
|
75
|
+
if v4_sock is None:
|
|
76
|
+
raise RuntimeError(f"proxy failed to bind :{port} (IPv4 loopback)")
|
|
77
|
+
|
|
78
|
+
v6_sock = _bind(socket_mod.AF_INET6, "::1", port)
|
|
79
|
+
|
|
80
|
+
servers: list[uvicorn.Server] = []
|
|
81
|
+
tasks: list[asyncio.Task] = []
|
|
82
|
+
socks = [v4_sock]
|
|
83
|
+
if v6_sock is not None:
|
|
84
|
+
socks.append(v6_sock)
|
|
85
|
+
|
|
86
|
+
v4_config = uvicorn.Config(app, log_level="warning", lifespan="on")
|
|
87
|
+
v4_server = uvicorn.Server(v4_config)
|
|
88
|
+
servers.append(v4_server)
|
|
89
|
+
tasks.append(asyncio.create_task(v4_server.serve(sockets=[v4_sock])))
|
|
90
|
+
|
|
91
|
+
if v6_sock is not None:
|
|
92
|
+
v6_config = uvicorn.Config(app, log_level="warning", lifespan="off")
|
|
93
|
+
v6_server = uvicorn.Server(v6_config)
|
|
94
|
+
servers.append(v6_server)
|
|
95
|
+
tasks.append(asyncio.create_task(v6_server.serve(sockets=[v6_sock])))
|
|
96
|
+
|
|
97
|
+
for _ in range(100):
|
|
98
|
+
if v4_server.started:
|
|
99
|
+
break
|
|
100
|
+
await asyncio.sleep(0.02)
|
|
101
|
+
else:
|
|
102
|
+
for s in socks:
|
|
103
|
+
s.close()
|
|
104
|
+
raise RuntimeError(f"proxy failed to start on :{port}")
|
|
105
|
+
|
|
106
|
+
return ProxyServer(servers, tasks, state)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Round-robin router over multiple NVIDIA-compatible upstream endpoints.
|
|
2
|
+
|
|
3
|
+
A single integrate.api.nvidia.com can 429 the whole account: keeping a
|
|
4
|
+
secondary endpoint (same key family, different host) and blacklisting a host
|
|
5
|
+
for a short window after a 5xx/timeout spreads load and survives partial
|
|
6
|
+
outages.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
ENDPOINT_RETRY_AFTER = 60.0
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EndpointRouter:
|
|
17
|
+
"""Tracks per-endpoint health and hands out the healthy ones in order."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, endpoints: list[str]) -> None:
|
|
20
|
+
self.endpoints = [e.rstrip("/") + "/" for e in endpoints]
|
|
21
|
+
self._blacklisted_until: dict[str, float] = {}
|
|
22
|
+
|
|
23
|
+
def healthy_endpoints(self) -> list[str]:
|
|
24
|
+
now = time.monotonic()
|
|
25
|
+
return [e for e in self.endpoints if self._blacklisted_until.get(e, 0.0) <= now]
|
|
26
|
+
|
|
27
|
+
def mark_failure(self, url: str) -> None:
|
|
28
|
+
for endpoint in self.endpoints:
|
|
29
|
+
if url.startswith(endpoint):
|
|
30
|
+
self._blacklisted_until[endpoint] = time.monotonic() + ENDPOINT_RETRY_AFTER
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
def mark_success(self, url: str) -> None:
|
|
34
|
+
for endpoint in self.endpoints:
|
|
35
|
+
if url.startswith(endpoint):
|
|
36
|
+
self._blacklisted_until.pop(endpoint, None)
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
def as_dict(self) -> dict[str, object]:
|
|
40
|
+
now = time.monotonic()
|
|
41
|
+
return {
|
|
42
|
+
"endpoints": [
|
|
43
|
+
{
|
|
44
|
+
"url": e,
|
|
45
|
+
"healthy": self._blacklisted_until.get(e, 0.0) <= now,
|
|
46
|
+
"retry_in_s": max(0.0, self._blacklisted_until.get(e, 0.0) - now),
|
|
47
|
+
}
|
|
48
|
+
for e in self.endpoints
|
|
49
|
+
]
|
|
50
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
openvidia/web/index.html
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" data-theme="dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
|
6
|
+
<meta name="description" content="OpenVidia — local multi-key proxy control panel for NVIDIA-hosted models." />
|
|
7
|
+
<title>OpenVidia</title>
|
|
8
|
+
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
|
9
|
+
<link rel="stylesheet" href="styles.css" />
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div id="bg-canvas"></div>
|
|
13
|
+
<div id="toast-container"></div>
|
|
14
|
+
|
|
15
|
+
<div id="app">
|
|
16
|
+
<header>
|
|
17
|
+
<div class="logo">
|
|
18
|
+
<img src="logo.png" alt="OpenVidia" class="logo-img">
|
|
19
|
+
<span class="logo-text">OpenVidia</span>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="header-right">
|
|
22
|
+
<span class="header-stat upstream-badge" id="hUpstream" title="NVIDIA upstream health — probed every 30s">
|
|
23
|
+
<span class="up-dot" id="upDot"></span> NVIDIA <span id="upText">…</span>
|
|
24
|
+
</span>
|
|
25
|
+
<span class="header-stat" id="hKeyCount">0 keys</span>
|
|
26
|
+
<span class="header-stat" id="hRpm">0 RPM</span>
|
|
27
|
+
<button class="theme-btn" id="themeToggle" title="Toggle theme">🌙</button>
|
|
28
|
+
</div>
|
|
29
|
+
</header>
|
|
30
|
+
|
|
31
|
+
<div class="bento">
|
|
32
|
+
<!-- ── STATUS (cell-status) ── -->
|
|
33
|
+
<div class="bento-cell cell-status">
|
|
34
|
+
<span class="indicator-dot" id="statusDot"><span class="pulse-dot running"></span></span>
|
|
35
|
+
<div class="status-info">
|
|
36
|
+
<span class="status-label" id="statusText">Running</span>
|
|
37
|
+
<span class="status-meta">Port <strong id="portDisplay">1919</strong> · <strong id="activeModelDisplay">none</strong></span>
|
|
38
|
+
<!-- Reasoning toggle: a hybrid model emits nothing until it stops
|
|
39
|
+
thinking, which is the difference between a 2s and a 160s
|
|
40
|
+
first token. Applies to the active model. -->
|
|
41
|
+
<span class="status-meta think-row">
|
|
42
|
+
Thinking
|
|
43
|
+
<button class="btn btn-xs think-btn" data-think="auto" title="Leave it to the model">auto</button>
|
|
44
|
+
<button class="btn btn-xs think-btn" data-think="on" title="Force reasoning on">on</button>
|
|
45
|
+
<button class="btn btn-xs think-btn" data-think="off" title="Force reasoning off — much faster first token">off</button>
|
|
46
|
+
</span>
|
|
47
|
+
<!-- Reasoning effort: finer granularity than on/off.
|
|
48
|
+
low = fast/zero thinking, medium = balanced, high = deep/focused -->
|
|
49
|
+
<span class="status-meta effort-row">
|
|
50
|
+
Effort
|
|
51
|
+
<button class="btn btn-xs effort-btn" data-effort="auto" title="Defer to thinking toggle">auto</button>
|
|
52
|
+
<button class="btn btn-xs effort-btn" data-effort="low" title="Fast — no reasoning, temp 0.7">low</button>
|
|
53
|
+
<button class="btn btn-xs effort-btn" data-effort="medium" title="Balanced — thinking on, temp 0.5">med</button>
|
|
54
|
+
<button class="btn btn-xs effort-btn" data-effort="high" title="Deep — thinking on, temp 0.2">high</button>
|
|
55
|
+
</span>
|
|
56
|
+
<span class="status-meta fallback-row">
|
|
57
|
+
Fallback
|
|
58
|
+
<button class="btn btn-xs fallback-btn" data-fallback="off" title="Never failover — return 503 if model is down">off</button>
|
|
59
|
+
<button class="btn btn-xs fallback-btn" data-fallback="on" title="Always failover to next healthy preset">on</button>
|
|
60
|
+
<button class="btn btn-xs fallback-btn" data-fallback="auto" title="Failover only when circuit is open">auto</button>
|
|
61
|
+
</span>
|
|
62
|
+
</div>
|
|
63
|
+
<div class="status-acts">
|
|
64
|
+
<button class="btn btn-sm" id="stopBtn" title="Stop">⏹</button>
|
|
65
|
+
<button class="btn btn-sm" id="startBtn" title="Start" style="display:none">▶</button>
|
|
66
|
+
<button class="btn btn-sm" id="restartBtn" title="Restart">⟳</button>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<!-- ── STATS (cell-stats, col-span-3) ── -->
|
|
71
|
+
<div class="bento-cell cell-stats col-span-3">
|
|
72
|
+
<div class="stat-mini"><span class="stat-num" id="statReqs">0</span><span class="stat-label">req</span></div>
|
|
73
|
+
<div class="stat-mini"><span class="stat-num" id="statOk">0</span><span class="stat-label">ok</span></div>
|
|
74
|
+
<div class="stat-mini"><span class="stat-num" id="statRots">0</span><span class="stat-label">rot</span></div>
|
|
75
|
+
<div class="stat-mini" id="cdMini"><span class="stat-num" id="statCooldowns">0</span><span class="stat-label">⏳</span></div>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<!-- ── MODELS (single list; ★ Starred = quick-pick shortlist) ── -->
|
|
79
|
+
<div class="bento-cell cell-models col-span-2" data-dropdown>
|
|
80
|
+
<div class="bento-hd" data-toggle data-idx="01">
|
|
81
|
+
<span class="bento-ttl">Models</span>
|
|
82
|
+
<span class="bento-badge" id="modelCount">0</span>
|
|
83
|
+
<span class="chevron">▸</span>
|
|
84
|
+
</div>
|
|
85
|
+
<div class="bento-body" data-body>
|
|
86
|
+
<div class="bento-body-inner">
|
|
87
|
+
<div class="browser-filters" id="modelFilters"></div>
|
|
88
|
+
<input type="text" class="browser-input" id="modelSearch" placeholder="Search models…" spellcheck="false" />
|
|
89
|
+
<div class="browser-list" id="modelList"><div class="browser-empty">Loading…</div></div>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<!-- ── KEY MANAGEMENT (right after status/stats, full width, open by default) ── -->
|
|
95
|
+
<div class="bento-cell cell-keys col-span-4 open" data-dropdown>
|
|
96
|
+
<div class="bento-hd" data-toggle data-idx="02">
|
|
97
|
+
<span class="bento-ttl">Keys</span>
|
|
98
|
+
<span class="bento-badge" id="keyCount">0</span>
|
|
99
|
+
<span class="chevron">▸</span>
|
|
100
|
+
</div>
|
|
101
|
+
<div class="bento-body" data-body>
|
|
102
|
+
<div class="bento-body-inner">
|
|
103
|
+
<div class="keys-toolbar">
|
|
104
|
+
<div class="key-filters" id="keyFilters">
|
|
105
|
+
<button class="key-filter" data-filter="all">All <span class="filter-count" id="fAll">0</span></button>
|
|
106
|
+
<button class="key-filter active" data-filter="active">Active <span class="filter-count" id="fActive">0</span></button>
|
|
107
|
+
<button class="key-filter" data-filter="cooldown">⏳ <span class="filter-count" id="fCooldown">0</span></button>
|
|
108
|
+
<button class="key-filter" data-filter="fresh">● <span class="filter-count" id="fFresh">0</span></button>
|
|
109
|
+
<button class="key-filter" data-filter="idle">Idle <span class="filter-count" id="fIdle">0</span></button>
|
|
110
|
+
</div>
|
|
111
|
+
<button class="btn btn-sm btn-primary" id="addKeyBtn">+ Add Key</button>
|
|
112
|
+
</div>
|
|
113
|
+
<div class="slide-form hidden" id="keyAddForm">
|
|
114
|
+
<input type="text" id="newKeyInput" spellcheck="false" placeholder="nvapi-…" autocomplete="off" />
|
|
115
|
+
<div class="slide-form-actions">
|
|
116
|
+
<button class="btn btn-sm btn-primary" id="confirmAddKeyBtn">Add</button>
|
|
117
|
+
<button class="btn btn-sm" id="cancelAddKeyBtn">Cancel</button>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
<div class="empty-state hidden" id="keysEmpty">
|
|
121
|
+
<div class="empty-ttl">No keys loaded</div>
|
|
122
|
+
<div class="empty-desc">Add keys with the button above</div>
|
|
123
|
+
</div>
|
|
124
|
+
<div id="keysList" class="keys-list"></div>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<!-- ── ACTIVITY LOG (cell-log, col-span-2) ── -->
|
|
130
|
+
<div class="bento-cell cell-log col-span-2" data-dropdown>
|
|
131
|
+
<div class="bento-hd" data-toggle data-idx="04">
|
|
132
|
+
<span class="bento-ttl"><span class="log-dot" id="logDot"></span>Activity</span>
|
|
133
|
+
<span class="bento-badge" id="logCount">0</span>
|
|
134
|
+
<span class="chevron">▸</span>
|
|
135
|
+
</div>
|
|
136
|
+
<div class="bento-body" data-body>
|
|
137
|
+
<div class="bento-body-inner">
|
|
138
|
+
<div class="log-toolbar">
|
|
139
|
+
<button class="btn btn-sm" id="clearLogBtn">Clear</button>
|
|
140
|
+
</div>
|
|
141
|
+
<div class="log-box" id="logArea">
|
|
142
|
+
<div class="log-empty">Waiting for events…</div>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
<!-- ── CLI SETUP (cell-cli, full width) ── -->
|
|
149
|
+
<div class="bento-cell cell-cli col-span-4" data-dropdown>
|
|
150
|
+
<div class="bento-hd" data-toggle data-idx="05">
|
|
151
|
+
<span class="bento-ttl">CLI Setup</span>
|
|
152
|
+
<span class="chevron">▸</span>
|
|
153
|
+
</div>
|
|
154
|
+
<div class="bento-body" data-body>
|
|
155
|
+
<div class="bento-body-inner">
|
|
156
|
+
<div class="cli-tabs" id="cliTabs"></div>
|
|
157
|
+
<div class="cli-setup" id="cliSetup"></div>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
<script type="module" src="main.js"></script>
|
|
165
|
+
</body>
|
|
166
|
+
</html>
|
openvidia/web/logo.png
ADDED
|
Binary file
|