codex-autorunner 0.1.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.
- codex_autorunner/__init__.py +3 -0
- codex_autorunner/bootstrap.py +151 -0
- codex_autorunner/cli.py +886 -0
- codex_autorunner/codex_cli.py +79 -0
- codex_autorunner/codex_runner.py +17 -0
- codex_autorunner/core/__init__.py +1 -0
- codex_autorunner/core/about_car.py +125 -0
- codex_autorunner/core/codex_runner.py +100 -0
- codex_autorunner/core/config.py +1465 -0
- codex_autorunner/core/doc_chat.py +547 -0
- codex_autorunner/core/docs.py +37 -0
- codex_autorunner/core/engine.py +720 -0
- codex_autorunner/core/git_utils.py +206 -0
- codex_autorunner/core/hub.py +756 -0
- codex_autorunner/core/injected_context.py +9 -0
- codex_autorunner/core/locks.py +57 -0
- codex_autorunner/core/logging_utils.py +158 -0
- codex_autorunner/core/notifications.py +465 -0
- codex_autorunner/core/optional_dependencies.py +41 -0
- codex_autorunner/core/prompt.py +107 -0
- codex_autorunner/core/prompts.py +275 -0
- codex_autorunner/core/request_context.py +21 -0
- codex_autorunner/core/runner_controller.py +116 -0
- codex_autorunner/core/runner_process.py +29 -0
- codex_autorunner/core/snapshot.py +576 -0
- codex_autorunner/core/state.py +156 -0
- codex_autorunner/core/update.py +567 -0
- codex_autorunner/core/update_runner.py +44 -0
- codex_autorunner/core/usage.py +1221 -0
- codex_autorunner/core/utils.py +108 -0
- codex_autorunner/discovery.py +102 -0
- codex_autorunner/housekeeping.py +423 -0
- codex_autorunner/integrations/__init__.py +1 -0
- codex_autorunner/integrations/app_server/__init__.py +6 -0
- codex_autorunner/integrations/app_server/client.py +1386 -0
- codex_autorunner/integrations/app_server/supervisor.py +206 -0
- codex_autorunner/integrations/github/__init__.py +10 -0
- codex_autorunner/integrations/github/service.py +889 -0
- codex_autorunner/integrations/telegram/__init__.py +1 -0
- codex_autorunner/integrations/telegram/adapter.py +1401 -0
- codex_autorunner/integrations/telegram/commands_registry.py +104 -0
- codex_autorunner/integrations/telegram/config.py +450 -0
- codex_autorunner/integrations/telegram/constants.py +154 -0
- codex_autorunner/integrations/telegram/dispatch.py +162 -0
- codex_autorunner/integrations/telegram/handlers/__init__.py +0 -0
- codex_autorunner/integrations/telegram/handlers/approvals.py +241 -0
- codex_autorunner/integrations/telegram/handlers/callbacks.py +72 -0
- codex_autorunner/integrations/telegram/handlers/commands.py +160 -0
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +5262 -0
- codex_autorunner/integrations/telegram/handlers/messages.py +477 -0
- codex_autorunner/integrations/telegram/handlers/selections.py +545 -0
- codex_autorunner/integrations/telegram/helpers.py +2084 -0
- codex_autorunner/integrations/telegram/notifications.py +164 -0
- codex_autorunner/integrations/telegram/outbox.py +174 -0
- codex_autorunner/integrations/telegram/rendering.py +102 -0
- codex_autorunner/integrations/telegram/retry.py +37 -0
- codex_autorunner/integrations/telegram/runtime.py +270 -0
- codex_autorunner/integrations/telegram/service.py +921 -0
- codex_autorunner/integrations/telegram/state.py +1223 -0
- codex_autorunner/integrations/telegram/transport.py +318 -0
- codex_autorunner/integrations/telegram/types.py +57 -0
- codex_autorunner/integrations/telegram/voice.py +413 -0
- codex_autorunner/manifest.py +150 -0
- codex_autorunner/routes/__init__.py +53 -0
- codex_autorunner/routes/base.py +470 -0
- codex_autorunner/routes/docs.py +275 -0
- codex_autorunner/routes/github.py +197 -0
- codex_autorunner/routes/repos.py +121 -0
- codex_autorunner/routes/sessions.py +137 -0
- codex_autorunner/routes/shared.py +137 -0
- codex_autorunner/routes/system.py +175 -0
- codex_autorunner/routes/terminal_images.py +107 -0
- codex_autorunner/routes/voice.py +128 -0
- codex_autorunner/server.py +23 -0
- codex_autorunner/spec_ingest.py +113 -0
- codex_autorunner/static/app.js +95 -0
- codex_autorunner/static/autoRefresh.js +209 -0
- codex_autorunner/static/bootstrap.js +105 -0
- codex_autorunner/static/bus.js +23 -0
- codex_autorunner/static/cache.js +52 -0
- codex_autorunner/static/constants.js +48 -0
- codex_autorunner/static/dashboard.js +795 -0
- codex_autorunner/static/docs.js +1514 -0
- codex_autorunner/static/env.js +99 -0
- codex_autorunner/static/github.js +168 -0
- codex_autorunner/static/hub.js +1511 -0
- codex_autorunner/static/index.html +622 -0
- codex_autorunner/static/loader.js +28 -0
- codex_autorunner/static/logs.js +690 -0
- codex_autorunner/static/mobileCompact.js +300 -0
- codex_autorunner/static/snapshot.js +116 -0
- codex_autorunner/static/state.js +87 -0
- codex_autorunner/static/styles.css +4966 -0
- codex_autorunner/static/tabs.js +50 -0
- codex_autorunner/static/terminal.js +21 -0
- codex_autorunner/static/terminalManager.js +3535 -0
- codex_autorunner/static/todoPreview.js +25 -0
- codex_autorunner/static/types.d.ts +8 -0
- codex_autorunner/static/utils.js +597 -0
- codex_autorunner/static/vendor/LICENSE.xterm +24 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-cyrillic.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-greek.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-latin.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-400-vietnamese.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-cyrillic.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-greek.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-latin.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-500-vietnamese.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-cyrillic.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-greek.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin-ext.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-latin.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/JetBrainsMono-600-vietnamese.woff2 +0 -0
- codex_autorunner/static/vendor/fonts/jetbrains-mono/OFL.txt +93 -0
- codex_autorunner/static/vendor/xterm-addon-fit.js +2 -0
- codex_autorunner/static/vendor/xterm.css +209 -0
- codex_autorunner/static/vendor/xterm.js +2 -0
- codex_autorunner/static/voice.js +591 -0
- codex_autorunner/voice/__init__.py +39 -0
- codex_autorunner/voice/capture.py +349 -0
- codex_autorunner/voice/config.py +167 -0
- codex_autorunner/voice/provider.py +66 -0
- codex_autorunner/voice/providers/__init__.py +7 -0
- codex_autorunner/voice/providers/openai_whisper.py +345 -0
- codex_autorunner/voice/resolver.py +36 -0
- codex_autorunner/voice/service.py +210 -0
- codex_autorunner/web/__init__.py +1 -0
- codex_autorunner/web/app.py +1037 -0
- codex_autorunner/web/hub_jobs.py +181 -0
- codex_autorunner/web/middleware.py +552 -0
- codex_autorunner/web/pty_session.py +357 -0
- codex_autorunner/web/runner_manager.py +25 -0
- codex_autorunner/web/schemas.py +253 -0
- codex_autorunner/web/static_assets.py +430 -0
- codex_autorunner/web/terminal_sessions.py +78 -0
- codex_autorunner/workspace.py +16 -0
- codex_autorunner-0.1.0.dist-info/METADATA +240 -0
- codex_autorunner-0.1.0.dist-info/RECORD +147 -0
- codex_autorunner-0.1.0.dist-info/WHEEL +5 -0
- codex_autorunner-0.1.0.dist-info/entry_points.txt +3 -0
- codex_autorunner-0.1.0.dist-info/licenses/LICENSE +21 -0
- codex_autorunner-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1511 @@
|
|
|
1
|
+
import {
|
|
2
|
+
api,
|
|
3
|
+
flash,
|
|
4
|
+
statusPill,
|
|
5
|
+
resolvePath,
|
|
6
|
+
escapeHtml,
|
|
7
|
+
confirmModal,
|
|
8
|
+
inputModal,
|
|
9
|
+
openModal,
|
|
10
|
+
} from "./utils.js";
|
|
11
|
+
import { registerAutoRefresh } from "./autoRefresh.js";
|
|
12
|
+
import { CONSTANTS } from "./constants.js";
|
|
13
|
+
import { HUB_BASE } from "./env.js";
|
|
14
|
+
|
|
15
|
+
let hubData = { repos: [], last_scan_at: null };
|
|
16
|
+
const repoPrCache = new Map();
|
|
17
|
+
const repoPrFetches = new Set();
|
|
18
|
+
const prefetchedUrls = new Set();
|
|
19
|
+
|
|
20
|
+
const HUB_CACHE_TTL_MS = 30000;
|
|
21
|
+
const HUB_CACHE_KEY = `car:hub:${HUB_BASE || "/"}`;
|
|
22
|
+
const HUB_USAGE_CACHE_KEY = `car:hub-usage:${HUB_BASE || "/"}`;
|
|
23
|
+
const PR_CACHE_TTL_MS = 120000;
|
|
24
|
+
const PR_FAILURE_TTL_MS = 15000;
|
|
25
|
+
const PR_FETCH_CONCURRENCY = 3;
|
|
26
|
+
const PR_PREFETCH_MARGIN = "200px";
|
|
27
|
+
|
|
28
|
+
const repoListEl = document.getElementById("hub-repo-list");
|
|
29
|
+
const lastScanEl = document.getElementById("hub-last-scan");
|
|
30
|
+
const totalEl = document.getElementById("hub-count-total");
|
|
31
|
+
const runningEl = document.getElementById("hub-count-running");
|
|
32
|
+
const missingEl = document.getElementById("hub-count-missing");
|
|
33
|
+
const hubUsageList = document.getElementById("hub-usage-list");
|
|
34
|
+
const hubUsageMeta = document.getElementById("hub-usage-meta");
|
|
35
|
+
const hubUsageRefresh = document.getElementById("hub-usage-refresh");
|
|
36
|
+
const hubUsageChartCanvas = document.getElementById("hub-usage-chart-canvas");
|
|
37
|
+
const hubUsageChartRange = document.getElementById("hub-usage-chart-range");
|
|
38
|
+
const hubUsageChartSegment = document.getElementById("hub-usage-chart-segment");
|
|
39
|
+
const hubVersionEl = document.getElementById("hub-version");
|
|
40
|
+
const UPDATE_STATUS_SEEN_KEY = "car_update_status_seen";
|
|
41
|
+
const HUB_JOB_POLL_INTERVAL_MS = 1200;
|
|
42
|
+
const HUB_JOB_TIMEOUT_MS = 180000;
|
|
43
|
+
|
|
44
|
+
const hubUsageChartState = {
|
|
45
|
+
segment: "none",
|
|
46
|
+
bucket: "day",
|
|
47
|
+
windowDays: 30,
|
|
48
|
+
};
|
|
49
|
+
let hubUsageSeriesRetryTimer = null;
|
|
50
|
+
let hubUsageSummaryRetryTimer = null;
|
|
51
|
+
const repoPrPending = new Set();
|
|
52
|
+
let repoPrQueue = [];
|
|
53
|
+
let repoPrActive = 0;
|
|
54
|
+
let repoPrObserver = null;
|
|
55
|
+
|
|
56
|
+
function saveSessionCache(key, value) {
|
|
57
|
+
try {
|
|
58
|
+
const payload = { at: Date.now(), value };
|
|
59
|
+
sessionStorage.setItem(key, JSON.stringify(payload));
|
|
60
|
+
} catch (_err) {
|
|
61
|
+
// ignore session storage issues
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function loadSessionCache(key, maxAgeMs) {
|
|
66
|
+
try {
|
|
67
|
+
const raw = sessionStorage.getItem(key);
|
|
68
|
+
if (!raw) return null;
|
|
69
|
+
const payload = JSON.parse(raw);
|
|
70
|
+
if (!payload || typeof payload.at !== "number") return null;
|
|
71
|
+
if (maxAgeMs && Date.now() - payload.at > maxAgeMs) return null;
|
|
72
|
+
return payload.value;
|
|
73
|
+
} catch (_err) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function formatRunSummary(repo) {
|
|
79
|
+
if (!repo.initialized) return "Not initialized";
|
|
80
|
+
if (!repo.exists_on_disk) return "Missing on disk";
|
|
81
|
+
if (!repo.last_run_id) return "No runs yet";
|
|
82
|
+
const exit =
|
|
83
|
+
repo.last_exit_code === null || repo.last_exit_code === undefined
|
|
84
|
+
? ""
|
|
85
|
+
: ` exit:${repo.last_exit_code}`;
|
|
86
|
+
return `#${repo.last_run_id}${exit}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function formatLastActivity(repo) {
|
|
90
|
+
if (!repo.initialized) return "";
|
|
91
|
+
const time = repo.last_run_finished_at || repo.last_run_started_at;
|
|
92
|
+
if (!time) return "";
|
|
93
|
+
return formatTimeCompact(time);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function setButtonLoading(scanning) {
|
|
97
|
+
const buttons = [
|
|
98
|
+
document.getElementById("hub-scan"),
|
|
99
|
+
document.getElementById("hub-quick-scan"),
|
|
100
|
+
document.getElementById("hub-refresh"),
|
|
101
|
+
];
|
|
102
|
+
buttons.forEach((btn) => {
|
|
103
|
+
if (!btn) return;
|
|
104
|
+
btn.disabled = scanning;
|
|
105
|
+
if (scanning) {
|
|
106
|
+
btn.classList.add("loading");
|
|
107
|
+
} else {
|
|
108
|
+
btn.classList.remove("loading");
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function sleep(ms) {
|
|
114
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function pollHubJob(jobId, { timeoutMs = HUB_JOB_TIMEOUT_MS } = {}) {
|
|
118
|
+
const start = Date.now();
|
|
119
|
+
for (;;) {
|
|
120
|
+
const job = await api(`/hub/jobs/${jobId}`, { method: "GET" });
|
|
121
|
+
if (job.status === "succeeded") return job;
|
|
122
|
+
if (job.status === "failed") {
|
|
123
|
+
const err = job.error || "Hub job failed";
|
|
124
|
+
throw new Error(err);
|
|
125
|
+
}
|
|
126
|
+
if (Date.now() - start > timeoutMs) {
|
|
127
|
+
throw new Error("Hub job timed out");
|
|
128
|
+
}
|
|
129
|
+
await sleep(HUB_JOB_POLL_INTERVAL_MS);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function startHubJob(path, { body, startedMessage } = {}) {
|
|
134
|
+
const job = await api(path, { method: "POST", body });
|
|
135
|
+
if (startedMessage) {
|
|
136
|
+
flash(startedMessage);
|
|
137
|
+
}
|
|
138
|
+
return pollHubJob(job.job_id);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function formatTimeCompact(isoString) {
|
|
142
|
+
if (!isoString) return "–";
|
|
143
|
+
const date = new Date(isoString);
|
|
144
|
+
if (Number.isNaN(date.getTime())) return isoString;
|
|
145
|
+
const now = new Date();
|
|
146
|
+
const diff = now.getTime() - date.getTime();
|
|
147
|
+
const mins = Math.floor(diff / 60000);
|
|
148
|
+
if (mins < 1) return "just now";
|
|
149
|
+
if (mins < 60) return `${mins}m ago`;
|
|
150
|
+
const hours = Math.floor(mins / 60);
|
|
151
|
+
if (hours < 24) return `${hours}h ago`;
|
|
152
|
+
return date.toLocaleDateString();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function renderSummary(repos) {
|
|
156
|
+
const running = repos.filter((r) => r.status === "running").length;
|
|
157
|
+
const missing = repos.filter((r) => !r.exists_on_disk).length;
|
|
158
|
+
if (totalEl) totalEl.textContent = repos.length.toString();
|
|
159
|
+
if (runningEl) runningEl.textContent = running.toString();
|
|
160
|
+
if (missingEl) missingEl.textContent = missing.toString();
|
|
161
|
+
if (lastScanEl) {
|
|
162
|
+
lastScanEl.textContent = formatTimeCompact(hubData.last_scan_at);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function formatTokensCompact(val) {
|
|
167
|
+
if (val === null || val === undefined) return "0";
|
|
168
|
+
const num = Number(val);
|
|
169
|
+
if (Number.isNaN(num)) return val;
|
|
170
|
+
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
|
|
171
|
+
if (num >= 1000) return `${(num / 1000).toFixed(0)}k`;
|
|
172
|
+
return num.toLocaleString();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function formatTokensAxis(val) {
|
|
176
|
+
const num = Number(val);
|
|
177
|
+
if (Number.isNaN(num)) return "0";
|
|
178
|
+
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
|
|
179
|
+
if (num >= 1000) return `${(num / 1000).toFixed(1)}k`;
|
|
180
|
+
return Math.round(num).toString();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function renderHubUsage(data) {
|
|
184
|
+
if (!hubUsageList) return;
|
|
185
|
+
if (hubUsageMeta) {
|
|
186
|
+
hubUsageMeta.textContent = data?.codex_home || "–";
|
|
187
|
+
}
|
|
188
|
+
if (!data || !data.repos) {
|
|
189
|
+
hubUsageList.innerHTML =
|
|
190
|
+
'<span class="muted small">Usage unavailable</span>';
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (!data.repos.length && (!data.unmatched || !data.unmatched.events)) {
|
|
194
|
+
hubUsageList.innerHTML = '<span class="muted small">No token events</span>';
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
hubUsageList.innerHTML = "";
|
|
198
|
+
const entries = [...data.repos].sort(
|
|
199
|
+
(a, b) => (b.totals?.total_tokens || 0) - (a.totals?.total_tokens || 0)
|
|
200
|
+
);
|
|
201
|
+
entries.forEach((repo) => {
|
|
202
|
+
const div = document.createElement("div");
|
|
203
|
+
div.className = "hub-usage-chip";
|
|
204
|
+
const totals = repo.totals || {};
|
|
205
|
+
const cached = totals.cached_input_tokens || 0;
|
|
206
|
+
const cachePercent = totals.input_tokens
|
|
207
|
+
? Math.round((cached / totals.input_tokens) * 100)
|
|
208
|
+
: 0;
|
|
209
|
+
div.innerHTML = `
|
|
210
|
+
<span class="hub-usage-chip-name">${escapeHtml(repo.id)}</span>
|
|
211
|
+
<span class="hub-usage-chip-total">${escapeHtml(
|
|
212
|
+
formatTokensCompact(totals.total_tokens)
|
|
213
|
+
)}</span>
|
|
214
|
+
<span class="hub-usage-chip-meta">${escapeHtml(
|
|
215
|
+
`${repo.events ?? 0}ev · ${cachePercent}%↻`
|
|
216
|
+
)}</span>
|
|
217
|
+
`;
|
|
218
|
+
hubUsageList.appendChild(div);
|
|
219
|
+
});
|
|
220
|
+
if (data.unmatched && data.unmatched.events) {
|
|
221
|
+
const div = document.createElement("div");
|
|
222
|
+
div.className = "hub-usage-chip hub-usage-chip-unmatched";
|
|
223
|
+
const totals = data.unmatched.totals || {};
|
|
224
|
+
div.innerHTML = `
|
|
225
|
+
<span class="hub-usage-chip-name">other</span>
|
|
226
|
+
<span class="hub-usage-chip-total">${escapeHtml(
|
|
227
|
+
formatTokensCompact(totals.total_tokens)
|
|
228
|
+
)}</span>
|
|
229
|
+
<span class="hub-usage-chip-meta">${escapeHtml(
|
|
230
|
+
`${data.unmatched.events}ev`
|
|
231
|
+
)}</span>
|
|
232
|
+
`;
|
|
233
|
+
hubUsageList.appendChild(div);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function scheduleHubUsageSummaryRetry() {
|
|
238
|
+
clearHubUsageSummaryRetry();
|
|
239
|
+
hubUsageSummaryRetryTimer = setTimeout(() => {
|
|
240
|
+
loadHubUsage();
|
|
241
|
+
}, 1500);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function clearHubUsageSummaryRetry() {
|
|
245
|
+
if (hubUsageSummaryRetryTimer) {
|
|
246
|
+
clearTimeout(hubUsageSummaryRetryTimer);
|
|
247
|
+
hubUsageSummaryRetryTimer = null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function handleHubUsagePayload(data, { cachedUsage, allowRetry }) {
|
|
252
|
+
const hasSummary = data && Array.isArray(data.repos);
|
|
253
|
+
if (data?.status === "loading") {
|
|
254
|
+
if (hasSummary) {
|
|
255
|
+
renderHubUsage(data);
|
|
256
|
+
} else if (cachedUsage) {
|
|
257
|
+
renderHubUsage(cachedUsage);
|
|
258
|
+
} else {
|
|
259
|
+
renderHubUsage(data);
|
|
260
|
+
}
|
|
261
|
+
if (allowRetry) scheduleHubUsageSummaryRetry();
|
|
262
|
+
return hasSummary;
|
|
263
|
+
}
|
|
264
|
+
if (hasSummary) {
|
|
265
|
+
renderHubUsage(data);
|
|
266
|
+
clearHubUsageSummaryRetry();
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
if (cachedUsage) {
|
|
270
|
+
renderHubUsage(cachedUsage);
|
|
271
|
+
} else {
|
|
272
|
+
renderHubUsage(null);
|
|
273
|
+
}
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function loadHubUsage({ silent = false, allowRetry = true } = {}) {
|
|
278
|
+
if (!silent && hubUsageRefresh) hubUsageRefresh.disabled = true;
|
|
279
|
+
try {
|
|
280
|
+
const data = await api("/hub/usage");
|
|
281
|
+
const cachedUsage = loadSessionCache(HUB_USAGE_CACHE_KEY, HUB_CACHE_TTL_MS);
|
|
282
|
+
const shouldCache = handleHubUsagePayload(data, {
|
|
283
|
+
cachedUsage,
|
|
284
|
+
allowRetry,
|
|
285
|
+
});
|
|
286
|
+
loadHubUsageSeries();
|
|
287
|
+
if (shouldCache) {
|
|
288
|
+
saveSessionCache(HUB_USAGE_CACHE_KEY, data);
|
|
289
|
+
}
|
|
290
|
+
} catch (err) {
|
|
291
|
+
const cachedUsage = loadSessionCache(HUB_USAGE_CACHE_KEY, HUB_CACHE_TTL_MS);
|
|
292
|
+
if (cachedUsage) {
|
|
293
|
+
renderHubUsage(cachedUsage);
|
|
294
|
+
} else {
|
|
295
|
+
renderHubUsage(null);
|
|
296
|
+
}
|
|
297
|
+
if (!silent) {
|
|
298
|
+
flash(err.message || "Failed to load usage", "error");
|
|
299
|
+
}
|
|
300
|
+
clearHubUsageSummaryRetry();
|
|
301
|
+
} finally {
|
|
302
|
+
if (!silent && hubUsageRefresh) hubUsageRefresh.disabled = false;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function buildHubUsageSeriesQuery() {
|
|
307
|
+
const params = new URLSearchParams();
|
|
308
|
+
const now = new Date();
|
|
309
|
+
const since = new Date(now.getTime() - hubUsageChartState.windowDays * 86400000);
|
|
310
|
+
const bucket = hubUsageChartState.windowDays >= 180 ? "week" : "day";
|
|
311
|
+
params.set("since", since.toISOString());
|
|
312
|
+
params.set("until", now.toISOString());
|
|
313
|
+
params.set("bucket", bucket);
|
|
314
|
+
params.set("segment", hubUsageChartState.segment);
|
|
315
|
+
return params.toString();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function renderHubUsageChart(data) {
|
|
319
|
+
if (!hubUsageChartCanvas) return;
|
|
320
|
+
const buckets = data?.buckets || [];
|
|
321
|
+
const series = data?.series || [];
|
|
322
|
+
const isLoading = data?.status === "loading";
|
|
323
|
+
if (!buckets.length || !series.length) {
|
|
324
|
+
hubUsageChartCanvas.__usageChartBound = false;
|
|
325
|
+
hubUsageChartCanvas.innerHTML = isLoading
|
|
326
|
+
? '<div class="usage-chart-empty">Loading…</div>'
|
|
327
|
+
: '<div class="usage-chart-empty">No data</div>';
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const { width, height } = getChartSize(hubUsageChartCanvas, 560, 160);
|
|
332
|
+
const padding = 14;
|
|
333
|
+
const chartWidth = width - padding * 2;
|
|
334
|
+
const chartHeight = height - padding * 2;
|
|
335
|
+
const colors = [
|
|
336
|
+
"#6cf5d8",
|
|
337
|
+
"#6ca8ff",
|
|
338
|
+
"#f5b86c",
|
|
339
|
+
"#f56c8a",
|
|
340
|
+
"#84d1ff",
|
|
341
|
+
"#9be26f",
|
|
342
|
+
"#f2a0c5",
|
|
343
|
+
"#c18bff",
|
|
344
|
+
"#f5d36c",
|
|
345
|
+
];
|
|
346
|
+
|
|
347
|
+
const { series: displaySeries } = normalizeSeries(
|
|
348
|
+
limitSeries(series, 6, "rest").series,
|
|
349
|
+
buckets.length
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
const totals = new Array(buckets.length).fill(0);
|
|
353
|
+
displaySeries.forEach((entry) => {
|
|
354
|
+
(entry.values || []).forEach((value, i) => {
|
|
355
|
+
totals[i] += value;
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
let scaleMax = Math.max(...totals, 1);
|
|
359
|
+
|
|
360
|
+
let svg = `<svg viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMinYMin meet" role="img" aria-label="Hub usage trend">`;
|
|
361
|
+
svg += `
|
|
362
|
+
<defs></defs>
|
|
363
|
+
`;
|
|
364
|
+
|
|
365
|
+
const gridLines = 3;
|
|
366
|
+
for (let i = 1; i <= gridLines; i += 1) {
|
|
367
|
+
const y = padding + (chartHeight / (gridLines + 1)) * i;
|
|
368
|
+
svg += `<line x1="${padding}" y1="${y}" x2="${
|
|
369
|
+
padding + chartWidth
|
|
370
|
+
}" y2="${y}" stroke="rgba(108, 245, 216, 0.12)" stroke-width="1" />`;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const maxLabel = formatTokensAxis(scaleMax);
|
|
374
|
+
const midLabel = formatTokensAxis(scaleMax / 2);
|
|
375
|
+
svg += `<text x="${padding}" y="${padding + 12}" fill="rgba(203, 213, 225, 0.7)" font-size="9">${maxLabel}</text>`;
|
|
376
|
+
svg += `<text x="${padding}" y="${
|
|
377
|
+
padding + chartHeight / 2 + 4
|
|
378
|
+
}" fill="rgba(203, 213, 225, 0.6)" font-size="9">${midLabel}</text>`;
|
|
379
|
+
svg += `<text x="${padding}" y="${
|
|
380
|
+
padding + chartHeight + 2
|
|
381
|
+
}" fill="rgba(203, 213, 225, 0.5)" font-size="9">0</text>`;
|
|
382
|
+
|
|
383
|
+
const count = buckets.length;
|
|
384
|
+
const barWidth = count ? chartWidth / count : chartWidth;
|
|
385
|
+
const gap = Math.max(1, Math.round(barWidth * 0.2));
|
|
386
|
+
const usableWidth = Math.max(1, barWidth - gap);
|
|
387
|
+
if (hubUsageChartState.segment === "none") {
|
|
388
|
+
const values = displaySeries[0]?.values || [];
|
|
389
|
+
values.forEach((value, i) => {
|
|
390
|
+
const x = padding + i * barWidth + gap / 2;
|
|
391
|
+
const h = (value / scaleMax) * chartHeight;
|
|
392
|
+
const y = padding + chartHeight - h;
|
|
393
|
+
svg += `<rect x="${x}" y="${y}" width="${usableWidth}" height="${h}" fill="#6cf5d8" opacity="0.75" rx="2" />`;
|
|
394
|
+
});
|
|
395
|
+
} else {
|
|
396
|
+
const accum = new Array(count).fill(0);
|
|
397
|
+
displaySeries.forEach((entry, idx) => {
|
|
398
|
+
const color = colors[idx % colors.length];
|
|
399
|
+
const values = entry.values || [];
|
|
400
|
+
values.forEach((value, i) => {
|
|
401
|
+
if (!value) return;
|
|
402
|
+
const base = accum[i];
|
|
403
|
+
accum[i] += value;
|
|
404
|
+
const h = (value / scaleMax) * chartHeight;
|
|
405
|
+
const y = padding + chartHeight - (base / scaleMax) * chartHeight - h;
|
|
406
|
+
const x = padding + i * barWidth + gap / 2;
|
|
407
|
+
svg += `<rect x="${x}" y="${y}" width="${usableWidth}" height="${h}" fill="${color}" opacity="0.55" rx="2" />`;
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
svg += "</svg>";
|
|
413
|
+
hubUsageChartCanvas.__usageChartBound = false;
|
|
414
|
+
hubUsageChartCanvas.innerHTML = svg;
|
|
415
|
+
attachHubUsageChartInteraction(hubUsageChartCanvas, {
|
|
416
|
+
buckets,
|
|
417
|
+
series: displaySeries,
|
|
418
|
+
segment: hubUsageChartState.segment,
|
|
419
|
+
scaleMax,
|
|
420
|
+
width,
|
|
421
|
+
height,
|
|
422
|
+
padding,
|
|
423
|
+
chartWidth,
|
|
424
|
+
chartHeight,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function getChartSize(container, fallbackWidth, fallbackHeight) {
|
|
429
|
+
const rect = container.getBoundingClientRect();
|
|
430
|
+
const width = Math.max(1, Math.round(rect.width || fallbackWidth));
|
|
431
|
+
const height = Math.max(1, Math.round(rect.height || fallbackHeight));
|
|
432
|
+
return { width, height };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function limitSeries(series, maxSeries, restKey) {
|
|
436
|
+
if (series.length <= maxSeries) return { series };
|
|
437
|
+
const sorted = [...series].sort((a, b) => (b.total || 0) - (a.total || 0));
|
|
438
|
+
const top = sorted.slice(0, maxSeries).filter((entry) => (entry.total || 0) > 0);
|
|
439
|
+
const rest = sorted.slice(maxSeries);
|
|
440
|
+
if (!rest.length) return { series: top };
|
|
441
|
+
const values = new Array((top[0]?.values || []).length).fill(0);
|
|
442
|
+
rest.forEach((entry) => {
|
|
443
|
+
(entry.values || []).forEach((value, i) => {
|
|
444
|
+
values[i] += value;
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
const total = values.reduce((sum, value) => sum + value, 0);
|
|
448
|
+
if (total > 0) {
|
|
449
|
+
top.push({ key: restKey, repo: null, token_type: null, total, values });
|
|
450
|
+
}
|
|
451
|
+
return { series: top.length ? top : series };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function normalizeSeries(series, length) {
|
|
455
|
+
const normalized = series.map((entry) => {
|
|
456
|
+
const values = (entry.values || []).slice(0, length);
|
|
457
|
+
while (values.length < length) values.push(0);
|
|
458
|
+
return { ...entry, values, total: values.reduce((sum, v) => sum + v, 0) };
|
|
459
|
+
});
|
|
460
|
+
return { series: normalized };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function attachHubUsageChartInteraction(container, state) {
|
|
464
|
+
container.__usageChartState = state;
|
|
465
|
+
if (container.__usageChartBound) return;
|
|
466
|
+
container.__usageChartBound = true;
|
|
467
|
+
|
|
468
|
+
const focus = document.createElement("div");
|
|
469
|
+
focus.className = "usage-chart-focus";
|
|
470
|
+
const dot = document.createElement("div");
|
|
471
|
+
dot.className = "usage-chart-dot";
|
|
472
|
+
const tooltip = document.createElement("div");
|
|
473
|
+
tooltip.className = "usage-chart-tooltip";
|
|
474
|
+
container.appendChild(focus);
|
|
475
|
+
container.appendChild(dot);
|
|
476
|
+
container.appendChild(tooltip);
|
|
477
|
+
|
|
478
|
+
const updateTooltip = (event) => {
|
|
479
|
+
const chartState = container.__usageChartState;
|
|
480
|
+
if (!chartState) return;
|
|
481
|
+
const rect = container.getBoundingClientRect();
|
|
482
|
+
const x = event.clientX - rect.left;
|
|
483
|
+
const normalizedX = (x / rect.width) * chartState.width;
|
|
484
|
+
const count = chartState.buckets.length;
|
|
485
|
+
const usableWidth = chartState.chartWidth;
|
|
486
|
+
const localX = Math.min(
|
|
487
|
+
Math.max(normalizedX - chartState.padding, 0),
|
|
488
|
+
usableWidth
|
|
489
|
+
);
|
|
490
|
+
const barWidth = count ? usableWidth / count : usableWidth;
|
|
491
|
+
const index = Math.floor(localX / barWidth);
|
|
492
|
+
const clampedIndex = Math.max(
|
|
493
|
+
0,
|
|
494
|
+
Math.min(chartState.buckets.length - 1, index)
|
|
495
|
+
);
|
|
496
|
+
const xPos =
|
|
497
|
+
chartState.padding + clampedIndex * barWidth + barWidth / 2;
|
|
498
|
+
|
|
499
|
+
const totals = chartState.series.reduce((sum, entry) => {
|
|
500
|
+
return sum + (entry.values?.[clampedIndex] || 0);
|
|
501
|
+
}, 0);
|
|
502
|
+
const yPos =
|
|
503
|
+
chartState.padding +
|
|
504
|
+
chartState.chartHeight -
|
|
505
|
+
(totals / chartState.scaleMax) * chartState.chartHeight;
|
|
506
|
+
|
|
507
|
+
focus.style.opacity = "1";
|
|
508
|
+
dot.style.opacity = "1";
|
|
509
|
+
focus.style.left = `${(xPos / chartState.width) * 100}%`;
|
|
510
|
+
dot.style.left = `${(xPos / chartState.width) * 100}%`;
|
|
511
|
+
dot.style.top = `${(yPos / chartState.height) * 100}%`;
|
|
512
|
+
|
|
513
|
+
const bucketLabel = chartState.buckets[clampedIndex];
|
|
514
|
+
const rows = [];
|
|
515
|
+
rows.push(
|
|
516
|
+
`<div class="usage-chart-tooltip-row"><span>Total</span><span>${escapeHtml(
|
|
517
|
+
formatTokensCompact(totals)
|
|
518
|
+
)}</span></div>`
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
if (chartState.segment !== "none") {
|
|
522
|
+
const ranked = chartState.series
|
|
523
|
+
.map((entry) => ({
|
|
524
|
+
key: entry.key,
|
|
525
|
+
value: entry.values?.[clampedIndex] || 0,
|
|
526
|
+
}))
|
|
527
|
+
.filter((entry) => entry.value > 0)
|
|
528
|
+
.sort((a, b) => b.value - a.value)
|
|
529
|
+
.slice(0, 6);
|
|
530
|
+
ranked.forEach((entry) => {
|
|
531
|
+
rows.push(
|
|
532
|
+
`<div class="usage-chart-tooltip-row"><span>${escapeHtml(
|
|
533
|
+
entry.key
|
|
534
|
+
)}</span><span>${escapeHtml(
|
|
535
|
+
formatTokensCompact(entry.value)
|
|
536
|
+
)}</span></div>`
|
|
537
|
+
);
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
tooltip.innerHTML = `<div class="usage-chart-tooltip-title">${escapeHtml(
|
|
542
|
+
bucketLabel
|
|
543
|
+
)}</div>${rows.join("")}`;
|
|
544
|
+
|
|
545
|
+
const tooltipRect = tooltip.getBoundingClientRect();
|
|
546
|
+
let tooltipLeft = x + 12;
|
|
547
|
+
if (tooltipLeft + tooltipRect.width > rect.width) {
|
|
548
|
+
tooltipLeft = x - tooltipRect.width - 12;
|
|
549
|
+
}
|
|
550
|
+
tooltipLeft = Math.max(6, tooltipLeft);
|
|
551
|
+
const tooltipTop = 6;
|
|
552
|
+
tooltip.style.opacity = "1";
|
|
553
|
+
tooltip.style.transform = `translate(${tooltipLeft}px, ${tooltipTop}px)`;
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
container.addEventListener("pointermove", updateTooltip);
|
|
557
|
+
container.addEventListener("pointerleave", () => {
|
|
558
|
+
focus.style.opacity = "0";
|
|
559
|
+
dot.style.opacity = "0";
|
|
560
|
+
tooltip.style.opacity = "0";
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async function loadHubUsageSeries() {
|
|
565
|
+
if (!hubUsageChartCanvas) return;
|
|
566
|
+
try {
|
|
567
|
+
const data = await api(`/hub/usage/series?${buildHubUsageSeriesQuery()}`);
|
|
568
|
+
hubUsageChartCanvas.classList.toggle("loading", data?.status === "loading");
|
|
569
|
+
renderHubUsageChart(data);
|
|
570
|
+
if (data?.status === "loading") {
|
|
571
|
+
scheduleHubUsageSeriesRetry();
|
|
572
|
+
} else {
|
|
573
|
+
clearHubUsageSeriesRetry();
|
|
574
|
+
}
|
|
575
|
+
} catch (_err) {
|
|
576
|
+
hubUsageChartCanvas.classList.remove("loading");
|
|
577
|
+
renderHubUsageChart(null);
|
|
578
|
+
clearHubUsageSeriesRetry();
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function scheduleHubUsageSeriesRetry() {
|
|
583
|
+
clearHubUsageSeriesRetry();
|
|
584
|
+
hubUsageSeriesRetryTimer = setTimeout(() => {
|
|
585
|
+
loadHubUsageSeries();
|
|
586
|
+
}, 1500);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function clearHubUsageSeriesRetry() {
|
|
590
|
+
if (hubUsageSeriesRetryTimer) {
|
|
591
|
+
clearTimeout(hubUsageSeriesRetryTimer);
|
|
592
|
+
hubUsageSeriesRetryTimer = null;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function initHubUsageChartControls() {
|
|
597
|
+
if (hubUsageChartRange) {
|
|
598
|
+
hubUsageChartRange.value = String(hubUsageChartState.windowDays);
|
|
599
|
+
hubUsageChartRange.addEventListener("change", () => {
|
|
600
|
+
const value = Number(hubUsageChartRange.value);
|
|
601
|
+
hubUsageChartState.windowDays = Number.isNaN(value)
|
|
602
|
+
? hubUsageChartState.windowDays
|
|
603
|
+
: value;
|
|
604
|
+
loadHubUsageSeries();
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
if (hubUsageChartSegment) {
|
|
608
|
+
hubUsageChartSegment.value = hubUsageChartState.segment;
|
|
609
|
+
hubUsageChartSegment.addEventListener("change", () => {
|
|
610
|
+
hubUsageChartState.segment = hubUsageChartSegment.value;
|
|
611
|
+
loadHubUsageSeries();
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const UPDATE_TARGET_LABELS = {
|
|
617
|
+
both: "web + Telegram",
|
|
618
|
+
web: "web only",
|
|
619
|
+
telegram: "Telegram only",
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
function normalizeUpdateTarget(value) {
|
|
623
|
+
if (!value) return "both";
|
|
624
|
+
if (value === "both" || value === "web" || value === "telegram") return value;
|
|
625
|
+
return "both";
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function getUpdateTarget(selectId) {
|
|
629
|
+
const select = selectId ? document.getElementById(selectId) : null;
|
|
630
|
+
return normalizeUpdateTarget(select ? select.value : "both");
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function describeUpdateTarget(target) {
|
|
634
|
+
return UPDATE_TARGET_LABELS[target] || UPDATE_TARGET_LABELS.both;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async function handleSystemUpdate(btnId, targetSelectId) {
|
|
638
|
+
const btn = document.getElementById(btnId);
|
|
639
|
+
if (!btn) return;
|
|
640
|
+
|
|
641
|
+
const originalText = btn.textContent;
|
|
642
|
+
btn.disabled = true;
|
|
643
|
+
btn.textContent = "Checking...";
|
|
644
|
+
const updateTarget = getUpdateTarget(targetSelectId);
|
|
645
|
+
const targetLabel = describeUpdateTarget(updateTarget);
|
|
646
|
+
|
|
647
|
+
let check;
|
|
648
|
+
try {
|
|
649
|
+
check = await api("/system/update/check");
|
|
650
|
+
} catch (err) {
|
|
651
|
+
check = { update_available: true, message: err.message || "Unable to check for updates." };
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (!check?.update_available) {
|
|
655
|
+
flash(check?.message || "No update available.");
|
|
656
|
+
btn.disabled = false;
|
|
657
|
+
btn.textContent = originalText;
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const restartNotice =
|
|
662
|
+
updateTarget === "telegram"
|
|
663
|
+
? "The Telegram bot will restart."
|
|
664
|
+
: "The service will restart.";
|
|
665
|
+
const confirmed = await confirmModal(
|
|
666
|
+
`${check?.message || "Update available."} Update Codex Autorunner (${targetLabel})? ${restartNotice}`
|
|
667
|
+
);
|
|
668
|
+
if (!confirmed) {
|
|
669
|
+
btn.disabled = false;
|
|
670
|
+
btn.textContent = originalText;
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
btn.textContent = "Updating...";
|
|
675
|
+
|
|
676
|
+
try {
|
|
677
|
+
const res = await api("/system/update", {
|
|
678
|
+
method: "POST",
|
|
679
|
+
body: { target: updateTarget },
|
|
680
|
+
});
|
|
681
|
+
flash(res.message || `Update started (${targetLabel}).`, "success");
|
|
682
|
+
if (updateTarget === "telegram") {
|
|
683
|
+
btn.disabled = false;
|
|
684
|
+
btn.textContent = originalText;
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
// Disable interaction
|
|
688
|
+
document.body.style.pointerEvents = "none";
|
|
689
|
+
// Wait for restart (approx 5-10s) then reload
|
|
690
|
+
setTimeout(() => {
|
|
691
|
+
const url = new URL(window.location.href);
|
|
692
|
+
url.searchParams.set("v", String(Date.now()));
|
|
693
|
+
window.location.replace(url.toString());
|
|
694
|
+
}, 8000);
|
|
695
|
+
} catch (err) {
|
|
696
|
+
flash(err.message || "Update failed", "error");
|
|
697
|
+
btn.disabled = false;
|
|
698
|
+
btn.textContent = originalText;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function initHubSettings() {
|
|
703
|
+
const settingsBtn = document.getElementById("hub-settings");
|
|
704
|
+
const modal = document.getElementById("hub-settings-modal");
|
|
705
|
+
const closeBtn = document.getElementById("hub-settings-close");
|
|
706
|
+
const updateBtn = document.getElementById("hub-update-btn");
|
|
707
|
+
const updateTarget = document.getElementById("hub-update-target");
|
|
708
|
+
let closeModal = null;
|
|
709
|
+
|
|
710
|
+
const hideModal = () => {
|
|
711
|
+
if (closeModal) {
|
|
712
|
+
const close = closeModal;
|
|
713
|
+
closeModal = null;
|
|
714
|
+
close();
|
|
715
|
+
}
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
if (settingsBtn && modal) {
|
|
719
|
+
settingsBtn.addEventListener("click", () => {
|
|
720
|
+
const triggerEl = document.activeElement;
|
|
721
|
+
hideModal();
|
|
722
|
+
closeModal = openModal(modal, {
|
|
723
|
+
initialFocus: closeBtn || updateBtn || modal,
|
|
724
|
+
returnFocusTo: triggerEl,
|
|
725
|
+
onRequestClose: hideModal,
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (closeBtn && modal) {
|
|
731
|
+
closeBtn.addEventListener("click", () => {
|
|
732
|
+
hideModal();
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (updateBtn) {
|
|
737
|
+
updateBtn.addEventListener("click", () =>
|
|
738
|
+
handleSystemUpdate("hub-update-btn", updateTarget ? updateTarget.id : null)
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function buildActions(repo) {
|
|
744
|
+
const actions = [];
|
|
745
|
+
const missing = !repo.exists_on_disk;
|
|
746
|
+
const kind = repo.kind || "base";
|
|
747
|
+
if (!missing && repo.mount_error) {
|
|
748
|
+
actions.push({ key: "init", label: "Retry mount", kind: "primary" });
|
|
749
|
+
} else if (!missing && repo.init_error) {
|
|
750
|
+
actions.push({
|
|
751
|
+
key: "init",
|
|
752
|
+
label: repo.initialized ? "Re-init" : "Init",
|
|
753
|
+
kind: "primary",
|
|
754
|
+
});
|
|
755
|
+
} else if (!missing && !repo.initialized) {
|
|
756
|
+
actions.push({ key: "init", label: "Init", kind: "primary" });
|
|
757
|
+
}
|
|
758
|
+
if (!missing && kind === "base") {
|
|
759
|
+
actions.push({ key: "new_worktree", label: "New Worktree", kind: "ghost" });
|
|
760
|
+
}
|
|
761
|
+
if (!missing && kind === "worktree") {
|
|
762
|
+
actions.push({
|
|
763
|
+
key: "cleanup_worktree",
|
|
764
|
+
label: "Cleanup",
|
|
765
|
+
kind: "ghost",
|
|
766
|
+
title: "Remove worktree and delete branch",
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
if (kind === "base") {
|
|
770
|
+
actions.push({ key: "remove_repo", label: "Remove", kind: "danger" });
|
|
771
|
+
}
|
|
772
|
+
return actions;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function buildMountBadge(repo) {
|
|
776
|
+
if (!repo) return "";
|
|
777
|
+
const missing = !repo.exists_on_disk;
|
|
778
|
+
let label = "";
|
|
779
|
+
let className = "pill pill-small";
|
|
780
|
+
let title = "";
|
|
781
|
+
if (missing) {
|
|
782
|
+
label = "missing";
|
|
783
|
+
className += " pill-error";
|
|
784
|
+
title = "Repo path not found on disk";
|
|
785
|
+
} else if (repo.mount_error) {
|
|
786
|
+
label = "mount error";
|
|
787
|
+
className += " pill-error";
|
|
788
|
+
title = repo.mount_error;
|
|
789
|
+
} else if (repo.mounted === true) {
|
|
790
|
+
label = "mounted";
|
|
791
|
+
className += " pill-idle";
|
|
792
|
+
} else {
|
|
793
|
+
label = "not mounted";
|
|
794
|
+
className += " pill-warn";
|
|
795
|
+
}
|
|
796
|
+
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
|
|
797
|
+
return `<span class="${className} hub-mount-pill"${titleAttr}>${escapeHtml(
|
|
798
|
+
label
|
|
799
|
+
)}</span>`;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function inferBaseId(repo) {
|
|
803
|
+
if (!repo) return null;
|
|
804
|
+
if (repo.worktree_of) return repo.worktree_of;
|
|
805
|
+
if (typeof repo.id === "string" && repo.id.includes("--")) {
|
|
806
|
+
return repo.id.split("--", 1)[0];
|
|
807
|
+
}
|
|
808
|
+
return null;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function initRepoPrObserver() {
|
|
812
|
+
if (!("IntersectionObserver" in window)) return null;
|
|
813
|
+
if (repoPrObserver) return repoPrObserver;
|
|
814
|
+
repoPrObserver = new IntersectionObserver(
|
|
815
|
+
(entries) => {
|
|
816
|
+
entries.forEach((entry) => {
|
|
817
|
+
if (!entry.isIntersecting) return;
|
|
818
|
+
const target = entry.target;
|
|
819
|
+
const repoId = target?.dataset?.repoId;
|
|
820
|
+
if (repoId) {
|
|
821
|
+
const repo = (hubData.repos || []).find((item) => item.id === repoId);
|
|
822
|
+
if (repo) scheduleRepoPrFetch(repo);
|
|
823
|
+
}
|
|
824
|
+
if (target) repoPrObserver.unobserve(target);
|
|
825
|
+
});
|
|
826
|
+
},
|
|
827
|
+
{ rootMargin: PR_PREFETCH_MARGIN }
|
|
828
|
+
);
|
|
829
|
+
return repoPrObserver;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function scheduleRepoPrFetch(repo) {
|
|
833
|
+
if (!repo || repo.mounted !== true) return;
|
|
834
|
+
const cached = repoPrCache.get(repo.id);
|
|
835
|
+
if (
|
|
836
|
+
cached &&
|
|
837
|
+
typeof cached.fetchedAt === "number" &&
|
|
838
|
+
Date.now() - cached.fetchedAt <
|
|
839
|
+
(cached.failed ? PR_FAILURE_TTL_MS : PR_CACHE_TTL_MS)
|
|
840
|
+
) {
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
if (repoPrFetches.has(repo.id) || repoPrPending.has(repo.id)) return;
|
|
844
|
+
repoPrPending.add(repo.id);
|
|
845
|
+
repoPrQueue.push(repo);
|
|
846
|
+
pumpRepoPrQueue();
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function pumpRepoPrQueue() {
|
|
850
|
+
while (repoPrActive < PR_FETCH_CONCURRENCY && repoPrQueue.length) {
|
|
851
|
+
const repo = repoPrQueue.shift();
|
|
852
|
+
if (!repo || repoPrFetches.has(repo.id)) continue;
|
|
853
|
+
repoPrPending.delete(repo.id);
|
|
854
|
+
repoPrActive += 1;
|
|
855
|
+
repoPrFetches.add(repo.id);
|
|
856
|
+
api(`/repos/${repo.id}/api/github/pr`, { method: "GET" })
|
|
857
|
+
.then((pr) => {
|
|
858
|
+
repoPrCache.set(repo.id, { data: pr, fetchedAt: Date.now() });
|
|
859
|
+
})
|
|
860
|
+
.catch(() => {
|
|
861
|
+
// Best-effort: ignore GitHub errors so hub stays fast.
|
|
862
|
+
repoPrCache.set(repo.id, {
|
|
863
|
+
data: null,
|
|
864
|
+
fetchedAt: Date.now(),
|
|
865
|
+
failed: true,
|
|
866
|
+
});
|
|
867
|
+
})
|
|
868
|
+
.finally(() => {
|
|
869
|
+
repoPrFetches.delete(repo.id);
|
|
870
|
+
repoPrActive -= 1;
|
|
871
|
+
pumpRepoPrQueue();
|
|
872
|
+
renderRepos(hubData.repos || []);
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function renderRepos(repos) {
|
|
878
|
+
if (!repoListEl) return;
|
|
879
|
+
if (repoPrObserver) repoPrObserver.disconnect();
|
|
880
|
+
repoListEl.innerHTML = "";
|
|
881
|
+
if (!repos.length) {
|
|
882
|
+
repoListEl.innerHTML =
|
|
883
|
+
'<div class="hub-empty muted">No repos discovered yet. Run a scan or create a new repo.</div>';
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const bases = repos.filter((r) => (r.kind || "base") === "base");
|
|
888
|
+
const worktrees = repos.filter((r) => (r.kind || "base") === "worktree");
|
|
889
|
+
const byBase = new Map();
|
|
890
|
+
bases.forEach((b) => byBase.set(b.id, { base: b, worktrees: [] }));
|
|
891
|
+
const orphanWorktrees = [];
|
|
892
|
+
worktrees.forEach((w) => {
|
|
893
|
+
const baseId = inferBaseId(w);
|
|
894
|
+
if (baseId && byBase.has(baseId)) {
|
|
895
|
+
byBase.get(baseId).worktrees.push(w);
|
|
896
|
+
} else {
|
|
897
|
+
orphanWorktrees.push(w);
|
|
898
|
+
}
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
const orderedGroups = [...byBase.values()].sort((a, b) =>
|
|
902
|
+
String(a.base?.id || "").localeCompare(String(b.base?.id || ""))
|
|
903
|
+
);
|
|
904
|
+
|
|
905
|
+
const renderRepoCard = (repo, { isWorktreeRow = false } = {}) => {
|
|
906
|
+
const card = document.createElement("div");
|
|
907
|
+
card.className = isWorktreeRow
|
|
908
|
+
? "hub-repo-card hub-worktree-card"
|
|
909
|
+
: "hub-repo-card";
|
|
910
|
+
card.dataset.repoId = repo.id;
|
|
911
|
+
|
|
912
|
+
// Make card clickable only for repos that are actually mounted
|
|
913
|
+
const canNavigate = repo.mounted === true;
|
|
914
|
+
if (canNavigate) {
|
|
915
|
+
card.classList.add("hub-repo-clickable");
|
|
916
|
+
card.dataset.href = resolvePath(`/repos/${repo.id}/`);
|
|
917
|
+
card.setAttribute("role", "link");
|
|
918
|
+
card.setAttribute("tabindex", "0");
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const actions = buildActions(repo)
|
|
922
|
+
.map(
|
|
923
|
+
(action) =>
|
|
924
|
+
`<button class="${action.kind} sm" data-action="${
|
|
925
|
+
escapeHtml(action.key)
|
|
926
|
+
}" data-repo="${escapeHtml(repo.id)}"${
|
|
927
|
+
action.title ? ` title="${escapeHtml(action.title)}"` : ""
|
|
928
|
+
}>${escapeHtml(action.label)}</button>`
|
|
929
|
+
)
|
|
930
|
+
.join("");
|
|
931
|
+
|
|
932
|
+
const mountBadge = buildMountBadge(repo);
|
|
933
|
+
const lockBadge =
|
|
934
|
+
repo.lock_status && repo.lock_status !== "unlocked"
|
|
935
|
+
? `<span class="pill pill-small pill-warn">${escapeHtml(
|
|
936
|
+
repo.lock_status.replace("_", " ")
|
|
937
|
+
)}</span>`
|
|
938
|
+
: "";
|
|
939
|
+
const initBadge = !repo.initialized
|
|
940
|
+
? '<span class="pill pill-small pill-warn">uninit</span>'
|
|
941
|
+
: "";
|
|
942
|
+
|
|
943
|
+
// Build note for errors
|
|
944
|
+
let noteText = "";
|
|
945
|
+
if (!repo.exists_on_disk) {
|
|
946
|
+
noteText = "Missing on disk";
|
|
947
|
+
} else if (repo.init_error) {
|
|
948
|
+
noteText = repo.init_error;
|
|
949
|
+
} else if (repo.mount_error) {
|
|
950
|
+
noteText = `Cannot open: ${repo.mount_error}`;
|
|
951
|
+
}
|
|
952
|
+
const note = noteText
|
|
953
|
+
? `<div class="hub-repo-note">${escapeHtml(noteText)}</div>`
|
|
954
|
+
: "";
|
|
955
|
+
|
|
956
|
+
// Show open indicator only for navigable repos
|
|
957
|
+
const openIndicator = canNavigate
|
|
958
|
+
? '<span class="hub-repo-open-indicator">→</span>'
|
|
959
|
+
: "";
|
|
960
|
+
|
|
961
|
+
// Build compact info line
|
|
962
|
+
const runSummary = formatRunSummary(repo);
|
|
963
|
+
const lastActivity = formatLastActivity(repo);
|
|
964
|
+
const infoItems = [];
|
|
965
|
+
if (
|
|
966
|
+
runSummary &&
|
|
967
|
+
runSummary !== "No runs yet" &&
|
|
968
|
+
runSummary !== "Not initialized"
|
|
969
|
+
) {
|
|
970
|
+
infoItems.push(runSummary);
|
|
971
|
+
}
|
|
972
|
+
if (lastActivity) {
|
|
973
|
+
infoItems.push(lastActivity);
|
|
974
|
+
}
|
|
975
|
+
const infoLine =
|
|
976
|
+
infoItems.length > 0
|
|
977
|
+
? `<span class="hub-repo-info-line">${escapeHtml(
|
|
978
|
+
infoItems.join(" · ")
|
|
979
|
+
)}</span>`
|
|
980
|
+
: "";
|
|
981
|
+
|
|
982
|
+
// Best-effort PR pill for mounted repos (does not block rendering).
|
|
983
|
+
const prInfo = repoPrCache.get(repo.id)?.data;
|
|
984
|
+
const prPill = prInfo?.links?.files
|
|
985
|
+
? `<a class="pill pill-small hub-pr-pill" href="${escapeHtml(
|
|
986
|
+
prInfo.links.files
|
|
987
|
+
)}" target="_blank" rel="noopener noreferrer" title="${escapeHtml(
|
|
988
|
+
prInfo.pr?.title || "Open PR files"
|
|
989
|
+
)}">PR${
|
|
990
|
+
prInfo.pr?.number
|
|
991
|
+
? ` #${escapeHtml(prInfo.pr.number)}`
|
|
992
|
+
: ""
|
|
993
|
+
}</a>`
|
|
994
|
+
: "";
|
|
995
|
+
|
|
996
|
+
card.innerHTML = `
|
|
997
|
+
<div class="hub-repo-row">
|
|
998
|
+
<div class="hub-repo-left">
|
|
999
|
+
<span class="pill pill-small hub-status-pill">${escapeHtml(
|
|
1000
|
+
repo.status
|
|
1001
|
+
)}</span>
|
|
1002
|
+
${mountBadge}
|
|
1003
|
+
${lockBadge}
|
|
1004
|
+
${initBadge}
|
|
1005
|
+
</div>
|
|
1006
|
+
<div class="hub-repo-center">
|
|
1007
|
+
<span class="hub-repo-title">${escapeHtml(
|
|
1008
|
+
repo.display_name
|
|
1009
|
+
)}</span>
|
|
1010
|
+
<div class="hub-repo-subline">
|
|
1011
|
+
${infoLine}
|
|
1012
|
+
${prPill}
|
|
1013
|
+
</div>
|
|
1014
|
+
</div>
|
|
1015
|
+
<div class="hub-repo-right">
|
|
1016
|
+
${actions || ""}
|
|
1017
|
+
${openIndicator}
|
|
1018
|
+
</div>
|
|
1019
|
+
</div>
|
|
1020
|
+
${note}
|
|
1021
|
+
`;
|
|
1022
|
+
|
|
1023
|
+
const statusEl = card.querySelector(".hub-status-pill");
|
|
1024
|
+
if (statusEl) {
|
|
1025
|
+
statusPill(statusEl, repo.status);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
repoListEl.appendChild(card);
|
|
1029
|
+
|
|
1030
|
+
if (repo.mounted === true) {
|
|
1031
|
+
const observer = initRepoPrObserver();
|
|
1032
|
+
if (observer) {
|
|
1033
|
+
observer.observe(card);
|
|
1034
|
+
} else {
|
|
1035
|
+
scheduleRepoPrFetch(repo);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
};
|
|
1039
|
+
|
|
1040
|
+
orderedGroups.forEach((group) => {
|
|
1041
|
+
const repo = group.base;
|
|
1042
|
+
renderRepoCard(repo, { isWorktreeRow: false });
|
|
1043
|
+
if (group.worktrees && group.worktrees.length) {
|
|
1044
|
+
const list = document.createElement("div");
|
|
1045
|
+
list.className = "hub-worktree-list";
|
|
1046
|
+
group.worktrees
|
|
1047
|
+
.sort((a, b) => String(a.id).localeCompare(String(b.id)))
|
|
1048
|
+
.forEach((wt) => {
|
|
1049
|
+
const row = document.createElement("div");
|
|
1050
|
+
row.className = "hub-worktree-row";
|
|
1051
|
+
// render as mini-card via innerHTML generated by renderRepoCard logic:
|
|
1052
|
+
// easiest: reuse renderRepoCard but with separate container
|
|
1053
|
+
const tmp = document.createElement("div");
|
|
1054
|
+
tmp.className = "hub-worktree-row-inner";
|
|
1055
|
+
list.appendChild(tmp);
|
|
1056
|
+
// Temporarily render into tmp by calling renderRepoCard and moving the node.
|
|
1057
|
+
const beforeCount = repoListEl.children.length;
|
|
1058
|
+
renderRepoCard(wt, { isWorktreeRow: true });
|
|
1059
|
+
const newNode = repoListEl.children[beforeCount];
|
|
1060
|
+
if (newNode) {
|
|
1061
|
+
repoListEl.removeChild(newNode);
|
|
1062
|
+
tmp.appendChild(newNode);
|
|
1063
|
+
}
|
|
1064
|
+
});
|
|
1065
|
+
repoListEl.appendChild(list);
|
|
1066
|
+
}
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
if (orphanWorktrees.length) {
|
|
1070
|
+
const header = document.createElement("div");
|
|
1071
|
+
header.className = "hub-worktree-orphans muted small";
|
|
1072
|
+
header.textContent = "Orphan worktrees";
|
|
1073
|
+
repoListEl.appendChild(header);
|
|
1074
|
+
orphanWorktrees
|
|
1075
|
+
.sort((a, b) => String(a.id).localeCompare(String(b.id)))
|
|
1076
|
+
.forEach((wt) => renderRepoCard(wt, { isWorktreeRow: true }));
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
async function refreshRepoPrCache(repos) {
|
|
1081
|
+
const mounted = (repos || []).filter((r) => r && r.mounted === true);
|
|
1082
|
+
if (!mounted.length) return;
|
|
1083
|
+
const observer = initRepoPrObserver();
|
|
1084
|
+
if (observer && repoListEl) {
|
|
1085
|
+
mounted.forEach((repo) => {
|
|
1086
|
+
const card = repoListEl.querySelector(`[data-repo-id="${repo.id}"]`);
|
|
1087
|
+
if (card) {
|
|
1088
|
+
observer.observe(card);
|
|
1089
|
+
} else {
|
|
1090
|
+
scheduleRepoPrFetch(repo);
|
|
1091
|
+
}
|
|
1092
|
+
});
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
mounted.forEach((repo) => scheduleRepoPrFetch(repo));
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
async function refreshHub() {
|
|
1099
|
+
setButtonLoading(true);
|
|
1100
|
+
try {
|
|
1101
|
+
const data = await api("/hub/repos", { method: "GET" });
|
|
1102
|
+
hubData = data;
|
|
1103
|
+
saveSessionCache(HUB_CACHE_KEY, hubData);
|
|
1104
|
+
renderSummary(data.repos || []);
|
|
1105
|
+
renderRepos(data.repos || []);
|
|
1106
|
+
refreshRepoPrCache(data.repos || []).catch(() => {});
|
|
1107
|
+
loadHubUsage().catch(() => {});
|
|
1108
|
+
} catch (err) {
|
|
1109
|
+
flash(err.message || "Hub request failed", "error");
|
|
1110
|
+
} finally {
|
|
1111
|
+
setButtonLoading(false);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
async function triggerHubScan() {
|
|
1116
|
+
setButtonLoading(true);
|
|
1117
|
+
try {
|
|
1118
|
+
await startHubJob("/hub/jobs/scan", { startedMessage: "Hub scan queued" });
|
|
1119
|
+
await refreshHub();
|
|
1120
|
+
} catch (err) {
|
|
1121
|
+
flash(err.message || "Hub scan failed", "error");
|
|
1122
|
+
} finally {
|
|
1123
|
+
setButtonLoading(false);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
async function createRepo(repoId, repoPath, gitInit, gitUrl) {
|
|
1128
|
+
try {
|
|
1129
|
+
const payload = {};
|
|
1130
|
+
if (repoId) payload.id = repoId;
|
|
1131
|
+
if (repoPath) payload.path = repoPath;
|
|
1132
|
+
payload.git_init = gitInit;
|
|
1133
|
+
if (gitUrl) payload.git_url = gitUrl;
|
|
1134
|
+
const job = await startHubJob("/hub/jobs/repos", {
|
|
1135
|
+
body: payload,
|
|
1136
|
+
startedMessage: "Repo creation queued",
|
|
1137
|
+
});
|
|
1138
|
+
const label = repoId || repoPath || "repo";
|
|
1139
|
+
flash(`Created repo: ${label}`);
|
|
1140
|
+
await refreshHub();
|
|
1141
|
+
if (job?.result?.mounted && job?.result?.id) {
|
|
1142
|
+
window.location.href = resolvePath(`/repos/${job.result.id}/`);
|
|
1143
|
+
}
|
|
1144
|
+
return true;
|
|
1145
|
+
} catch (err) {
|
|
1146
|
+
flash(err.message || "Failed to create repo", "error");
|
|
1147
|
+
return false;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
let closeCreateRepoModal = null;
|
|
1152
|
+
|
|
1153
|
+
function hideCreateRepoModal() {
|
|
1154
|
+
if (closeCreateRepoModal) {
|
|
1155
|
+
const close = closeCreateRepoModal;
|
|
1156
|
+
closeCreateRepoModal = null;
|
|
1157
|
+
close();
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function showCreateRepoModal() {
|
|
1162
|
+
const modal = document.getElementById("create-repo-modal");
|
|
1163
|
+
if (!modal) return;
|
|
1164
|
+
const triggerEl = document.activeElement;
|
|
1165
|
+
hideCreateRepoModal();
|
|
1166
|
+
const input = document.getElementById("create-repo-id");
|
|
1167
|
+
closeCreateRepoModal = openModal(modal, {
|
|
1168
|
+
initialFocus: input || modal,
|
|
1169
|
+
returnFocusTo: triggerEl,
|
|
1170
|
+
onRequestClose: hideCreateRepoModal,
|
|
1171
|
+
});
|
|
1172
|
+
if (input) {
|
|
1173
|
+
input.value = "";
|
|
1174
|
+
input.focus();
|
|
1175
|
+
}
|
|
1176
|
+
const pathInput = document.getElementById("create-repo-path");
|
|
1177
|
+
if (pathInput) pathInput.value = "";
|
|
1178
|
+
const urlInput = document.getElementById("create-repo-url");
|
|
1179
|
+
if (urlInput) urlInput.value = "";
|
|
1180
|
+
const gitCheck = document.getElementById("create-repo-git");
|
|
1181
|
+
if (gitCheck) gitCheck.checked = true;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
async function handleCreateRepoSubmit() {
|
|
1185
|
+
const idInput = document.getElementById("create-repo-id");
|
|
1186
|
+
const pathInput = document.getElementById("create-repo-path");
|
|
1187
|
+
const urlInput = document.getElementById("create-repo-url");
|
|
1188
|
+
const gitCheck = document.getElementById("create-repo-git");
|
|
1189
|
+
|
|
1190
|
+
const repoId = idInput?.value?.trim();
|
|
1191
|
+
const repoPath = pathInput?.value?.trim() || null;
|
|
1192
|
+
const gitUrl = urlInput?.value?.trim() || null;
|
|
1193
|
+
const gitInit = gitCheck?.checked ?? true;
|
|
1194
|
+
|
|
1195
|
+
if (!repoId && !gitUrl) {
|
|
1196
|
+
flash("Repo ID or Git URL is required", "error");
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
const ok = await createRepo(repoId, repoPath, gitInit, gitUrl);
|
|
1201
|
+
if (ok) {
|
|
1202
|
+
hideCreateRepoModal();
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
async function handleRepoAction(repoId, action) {
|
|
1207
|
+
const buttons = repoListEl?.querySelectorAll(
|
|
1208
|
+
`button[data-repo="${repoId}"][data-action="${action}"]`
|
|
1209
|
+
);
|
|
1210
|
+
buttons?.forEach((btn) => (btn.disabled = true));
|
|
1211
|
+
try {
|
|
1212
|
+
const pathMap = {
|
|
1213
|
+
init: `/hub/repos/${repoId}/init`,
|
|
1214
|
+
};
|
|
1215
|
+
if (action === "new_worktree") {
|
|
1216
|
+
const branch = await inputModal("New worktree branch name:", {
|
|
1217
|
+
placeholder: "feature/my-branch",
|
|
1218
|
+
confirmText: "Create",
|
|
1219
|
+
});
|
|
1220
|
+
if (!branch) return;
|
|
1221
|
+
const job = await startHubJob("/hub/jobs/worktrees/create", {
|
|
1222
|
+
body: { base_repo_id: repoId, branch },
|
|
1223
|
+
startedMessage: "Worktree creation queued",
|
|
1224
|
+
});
|
|
1225
|
+
const created = job?.result;
|
|
1226
|
+
flash(`Created worktree: ${created?.id || branch}`);
|
|
1227
|
+
await refreshHub();
|
|
1228
|
+
if (created?.mounted) {
|
|
1229
|
+
window.location.href = resolvePath(`/repos/${created.id}/`);
|
|
1230
|
+
}
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
if (action === "cleanup_worktree") {
|
|
1234
|
+
// Extract display name for clearer messaging
|
|
1235
|
+
const displayName = repoId.includes("--")
|
|
1236
|
+
? repoId.split("--").pop()
|
|
1237
|
+
: repoId;
|
|
1238
|
+
const ok = await confirmModal(
|
|
1239
|
+
`Remove worktree "${displayName}"? This will delete the worktree directory and its branch.`,
|
|
1240
|
+
{ confirmText: "Remove", danger: true }
|
|
1241
|
+
);
|
|
1242
|
+
if (!ok) return;
|
|
1243
|
+
await startHubJob("/hub/jobs/worktrees/cleanup", {
|
|
1244
|
+
body: { worktree_repo_id: repoId },
|
|
1245
|
+
startedMessage: "Worktree cleanup queued",
|
|
1246
|
+
});
|
|
1247
|
+
flash(`Removed worktree: ${repoId}`);
|
|
1248
|
+
await refreshHub();
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
if (action === "remove_repo") {
|
|
1252
|
+
const check = await api(`/hub/repos/${repoId}/remove-check`, {
|
|
1253
|
+
method: "GET",
|
|
1254
|
+
});
|
|
1255
|
+
const warnings = [];
|
|
1256
|
+
const dirty = check?.is_clean === false;
|
|
1257
|
+
if (dirty) {
|
|
1258
|
+
warnings.push("Working tree has uncommitted changes.");
|
|
1259
|
+
}
|
|
1260
|
+
const hasUpstream = check?.upstream?.has_upstream;
|
|
1261
|
+
if (hasUpstream === false) {
|
|
1262
|
+
warnings.push("No upstream tracking branch is configured.");
|
|
1263
|
+
}
|
|
1264
|
+
const ahead = Number(check?.upstream?.ahead || 0);
|
|
1265
|
+
if (ahead > 0) {
|
|
1266
|
+
warnings.push(
|
|
1267
|
+
`Local branch is ahead of upstream by ${ahead} commit(s).`
|
|
1268
|
+
);
|
|
1269
|
+
}
|
|
1270
|
+
const behind = Number(check?.upstream?.behind || 0);
|
|
1271
|
+
if (behind > 0) {
|
|
1272
|
+
warnings.push(
|
|
1273
|
+
`Local branch is behind upstream by ${behind} commit(s).`
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
1276
|
+
const worktrees = Array.isArray(check?.worktrees) ? check.worktrees : [];
|
|
1277
|
+
if (worktrees.length) {
|
|
1278
|
+
warnings.push(`This repo has ${worktrees.length} worktree(s).`);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
const messageParts = [
|
|
1282
|
+
`Remove repo "${repoId}" and delete its local directory?`,
|
|
1283
|
+
];
|
|
1284
|
+
if (warnings.length) {
|
|
1285
|
+
messageParts.push("", "Warnings:", ...warnings.map((w) => `- ${w}`));
|
|
1286
|
+
}
|
|
1287
|
+
if (worktrees.length) {
|
|
1288
|
+
messageParts.push(
|
|
1289
|
+
"",
|
|
1290
|
+
"Worktrees to delete:",
|
|
1291
|
+
...worktrees.map((w) => `- ${w}`)
|
|
1292
|
+
);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
const ok = await confirmModal(messageParts.join("\n"), {
|
|
1296
|
+
confirmText: "Remove",
|
|
1297
|
+
danger: true,
|
|
1298
|
+
});
|
|
1299
|
+
if (!ok) return;
|
|
1300
|
+
const needsForce = dirty || ahead > 0;
|
|
1301
|
+
if (needsForce) {
|
|
1302
|
+
const forceOk = await confirmModal(
|
|
1303
|
+
"This repo has uncommitted or unpushed changes. Remove anyway?",
|
|
1304
|
+
{ confirmText: "Remove anyway", danger: true }
|
|
1305
|
+
);
|
|
1306
|
+
if (!forceOk) return;
|
|
1307
|
+
}
|
|
1308
|
+
await startHubJob(`/hub/jobs/repos/${repoId}/remove`, {
|
|
1309
|
+
body: {
|
|
1310
|
+
force: needsForce,
|
|
1311
|
+
delete_dir: true,
|
|
1312
|
+
delete_worktrees: worktrees.length > 0,
|
|
1313
|
+
},
|
|
1314
|
+
startedMessage: "Repo removal queued",
|
|
1315
|
+
});
|
|
1316
|
+
flash(`Removed repo: ${repoId}`);
|
|
1317
|
+
await refreshHub();
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
const path = pathMap[action];
|
|
1322
|
+
if (!path) return;
|
|
1323
|
+
await api(path, { method: "POST" });
|
|
1324
|
+
flash(`${action} sent to ${repoId}`);
|
|
1325
|
+
await refreshHub();
|
|
1326
|
+
} catch (err) {
|
|
1327
|
+
flash(err.message || "Hub action failed", "error");
|
|
1328
|
+
} finally {
|
|
1329
|
+
buttons?.forEach((btn) => (btn.disabled = false));
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
function attachHubHandlers() {
|
|
1334
|
+
initHubSettings();
|
|
1335
|
+
const scanBtn = document.getElementById("hub-scan");
|
|
1336
|
+
const refreshBtn = document.getElementById("hub-refresh");
|
|
1337
|
+
const quickScanBtn = document.getElementById("hub-quick-scan");
|
|
1338
|
+
const newRepoBtn = document.getElementById("hub-new-repo");
|
|
1339
|
+
const createCancelBtn = document.getElementById("create-repo-cancel");
|
|
1340
|
+
const createSubmitBtn = document.getElementById("create-repo-submit");
|
|
1341
|
+
const createRepoId = document.getElementById("create-repo-id");
|
|
1342
|
+
|
|
1343
|
+
scanBtn?.addEventListener("click", () => triggerHubScan());
|
|
1344
|
+
quickScanBtn?.addEventListener("click", () => triggerHubScan());
|
|
1345
|
+
refreshBtn?.addEventListener("click", () => refreshHub());
|
|
1346
|
+
hubUsageRefresh?.addEventListener("click", () => loadHubUsage());
|
|
1347
|
+
|
|
1348
|
+
newRepoBtn?.addEventListener("click", showCreateRepoModal);
|
|
1349
|
+
createCancelBtn?.addEventListener("click", hideCreateRepoModal);
|
|
1350
|
+
createSubmitBtn?.addEventListener("click", handleCreateRepoSubmit);
|
|
1351
|
+
|
|
1352
|
+
// Allow Enter key in the repo ID input to submit
|
|
1353
|
+
createRepoId?.addEventListener("keydown", (e) => {
|
|
1354
|
+
if (e.key === "Enter") {
|
|
1355
|
+
e.preventDefault();
|
|
1356
|
+
handleCreateRepoSubmit();
|
|
1357
|
+
}
|
|
1358
|
+
});
|
|
1359
|
+
|
|
1360
|
+
repoListEl?.addEventListener("click", (event) => {
|
|
1361
|
+
const target = event.target;
|
|
1362
|
+
if (!(target instanceof HTMLElement)) return;
|
|
1363
|
+
|
|
1364
|
+
// Allow PR pill navigation without triggering card navigation.
|
|
1365
|
+
const prLink = target.closest("a.hub-pr-pill");
|
|
1366
|
+
if (prLink) {
|
|
1367
|
+
event.stopPropagation();
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// Handle action buttons - stop propagation to prevent card navigation
|
|
1372
|
+
const btn = target.closest("button[data-action]");
|
|
1373
|
+
if (btn) {
|
|
1374
|
+
event.stopPropagation();
|
|
1375
|
+
const action = btn.dataset.action;
|
|
1376
|
+
const repoId = btn.dataset.repo;
|
|
1377
|
+
if (action && repoId) {
|
|
1378
|
+
handleRepoAction(repoId, action);
|
|
1379
|
+
}
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// Handle card click for navigation
|
|
1384
|
+
const card = target.closest(".hub-repo-clickable");
|
|
1385
|
+
if (card && card.dataset.href) {
|
|
1386
|
+
window.location.href = card.dataset.href;
|
|
1387
|
+
}
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
// Support keyboard navigation for cards
|
|
1391
|
+
repoListEl?.addEventListener("keydown", (event) => {
|
|
1392
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
1393
|
+
const target = event.target;
|
|
1394
|
+
if (
|
|
1395
|
+
target instanceof HTMLElement &&
|
|
1396
|
+
target.classList.contains("hub-repo-clickable")
|
|
1397
|
+
) {
|
|
1398
|
+
event.preventDefault();
|
|
1399
|
+
if (target.dataset.href) {
|
|
1400
|
+
window.location.href = target.dataset.href;
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
repoListEl?.addEventListener("mouseover", (event) => {
|
|
1407
|
+
const target = event.target;
|
|
1408
|
+
if (!(target instanceof HTMLElement)) return;
|
|
1409
|
+
const card = target.closest(".hub-repo-clickable");
|
|
1410
|
+
if (card && card.dataset.href) {
|
|
1411
|
+
prefetchRepo(card.dataset.href);
|
|
1412
|
+
}
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
repoListEl?.addEventListener("pointerdown", (event) => {
|
|
1416
|
+
const target = event.target;
|
|
1417
|
+
if (!(target instanceof HTMLElement)) return;
|
|
1418
|
+
const card = target.closest(".hub-repo-clickable");
|
|
1419
|
+
if (card && card.dataset.href) {
|
|
1420
|
+
prefetchRepo(card.dataset.href);
|
|
1421
|
+
}
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/**
|
|
1426
|
+
* Silent refresh for auto-refresh - doesn't show loading state on buttons.
|
|
1427
|
+
*/
|
|
1428
|
+
async function silentRefreshHub() {
|
|
1429
|
+
try {
|
|
1430
|
+
const data = await api("/hub/repos", { method: "GET" });
|
|
1431
|
+
hubData = data;
|
|
1432
|
+
saveSessionCache(HUB_CACHE_KEY, hubData);
|
|
1433
|
+
renderSummary(data.repos || []);
|
|
1434
|
+
renderRepos(data.repos || []);
|
|
1435
|
+
await loadHubUsage({ silent: true, allowRetry: false });
|
|
1436
|
+
} catch (err) {
|
|
1437
|
+
// Silently fail for background refresh
|
|
1438
|
+
console.error("Auto-refresh hub failed:", err);
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
async function loadHubVersion() {
|
|
1443
|
+
if (!hubVersionEl) return;
|
|
1444
|
+
try {
|
|
1445
|
+
const data = await api("/hub/version", { method: "GET" });
|
|
1446
|
+
const version = data?.asset_version || "";
|
|
1447
|
+
hubVersionEl.textContent = version ? `v${version}` : "v–";
|
|
1448
|
+
} catch (_err) {
|
|
1449
|
+
hubVersionEl.textContent = "v–";
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
async function checkUpdateStatus() {
|
|
1454
|
+
try {
|
|
1455
|
+
const data = await api("/system/update/status", { method: "GET" });
|
|
1456
|
+
if (!data || !data.status) return;
|
|
1457
|
+
const stamp = data.at ? String(data.at) : "";
|
|
1458
|
+
if (stamp && sessionStorage.getItem(UPDATE_STATUS_SEEN_KEY) === stamp) return;
|
|
1459
|
+
if (data.status === "rollback" || data.status === "error") {
|
|
1460
|
+
flash(data.message || "Update failed; rollback attempted.", "error");
|
|
1461
|
+
}
|
|
1462
|
+
if (stamp) sessionStorage.setItem(UPDATE_STATUS_SEEN_KEY, stamp);
|
|
1463
|
+
} catch (_err) {
|
|
1464
|
+
// ignore
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
function prefetchRepo(url) {
|
|
1469
|
+
if (!url || prefetchedUrls.has(url)) return;
|
|
1470
|
+
prefetchedUrls.add(url);
|
|
1471
|
+
fetch(url, { method: "GET", headers: { "x-prefetch": "1" } }).catch(() => {});
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
export function initHub() {
|
|
1475
|
+
if (!repoListEl) return;
|
|
1476
|
+
attachHubHandlers();
|
|
1477
|
+
initHubUsageChartControls();
|
|
1478
|
+
const cachedHub = loadSessionCache(HUB_CACHE_KEY, HUB_CACHE_TTL_MS);
|
|
1479
|
+
if (cachedHub) {
|
|
1480
|
+
hubData = cachedHub;
|
|
1481
|
+
renderSummary(cachedHub.repos || []);
|
|
1482
|
+
renderRepos(cachedHub.repos || []);
|
|
1483
|
+
}
|
|
1484
|
+
const cachedUsage = loadSessionCache(HUB_USAGE_CACHE_KEY, HUB_CACHE_TTL_MS);
|
|
1485
|
+
if (cachedUsage) {
|
|
1486
|
+
renderHubUsage(cachedUsage);
|
|
1487
|
+
}
|
|
1488
|
+
loadHubUsageSeries();
|
|
1489
|
+
refreshHub();
|
|
1490
|
+
loadHubVersion();
|
|
1491
|
+
checkUpdateStatus();
|
|
1492
|
+
|
|
1493
|
+
// Register auto-refresh for hub repo list
|
|
1494
|
+
// Hub is a top-level page so we use tabId: null (global)
|
|
1495
|
+
registerAutoRefresh("hub-repos", {
|
|
1496
|
+
callback: silentRefreshHub,
|
|
1497
|
+
tabId: null, // Hub is the main page, not a tab
|
|
1498
|
+
interval: CONSTANTS.UI.AUTO_REFRESH_INTERVAL,
|
|
1499
|
+
refreshOnActivation: true,
|
|
1500
|
+
immediate: false, // Already called refreshHub() above
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1505
|
+
// Test Exports
|
|
1506
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1507
|
+
|
|
1508
|
+
export const __hubTest = {
|
|
1509
|
+
renderRepos,
|
|
1510
|
+
renderHubUsage,
|
|
1511
|
+
};
|