@triflux/remote 10.51.0 → 10.53.0

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.
Files changed (62) hide show
  1. package/cto/brief.mjs +0 -8
  2. package/cto/collect.mjs +1 -127
  3. package/cto/current.schema.json +2 -19
  4. package/cto/hygiene.mjs +2 -61
  5. package/cto/lake-root.mjs +0 -56
  6. package/cto/status.mjs +5 -139
  7. package/hub/index.mjs +0 -9
  8. package/hub/team/agents-row-attach.py +120 -0
  9. package/hub/team/agents-row.mjs +200 -0
  10. package/hub/team/claude-daemon-control.mjs +1 -2
  11. package/hub/team/claude-session-projection.mjs +5 -73
  12. package/hub/team/claude-transcript.mjs +19 -0
  13. package/hub/team/cli/commands/debug.mjs +1 -2
  14. package/hub/team/cli/commands/start/index.mjs +2 -1
  15. package/hub/team/cli/commands/start/start-headless.mjs +2 -0
  16. package/hub/team/cli/commands/start/start-mux.mjs +2 -4
  17. package/hub/team/cli/services/state-store.mjs +5 -20
  18. package/hub/team/headless.mjs +132 -551
  19. package/hub/team/psmux.mjs +3 -33
  20. package/hub/team/results-index.mjs +92 -0
  21. package/hub/team/session-context.mjs +1 -1
  22. package/hub/team/tui-viewer.mjs +9 -56
  23. package/hub/team/tui.mjs +0 -37
  24. package/hub/team/uds-orchestrator.mjs +20 -599
  25. package/hub/workers/factory.mjs +6 -51
  26. package/hub/workers/interface.mjs +1 -7
  27. package/hub/workers/lib/jsonrpc-core.mjs +11 -0
  28. package/hub/workers/lib/jsonrpc-ws-uds.mjs +5 -6
  29. package/package.json +3 -8
  30. package/scripts/lib/agent-route-policy.mjs +0 -67
  31. package/scripts/lib/cli-claude.mjs +0 -12
  32. package/scripts/lib/env-probe.mjs +0 -70
  33. package/scripts/lib/legacy-mcp-cleanup.mjs +283 -0
  34. package/scripts/lib/mcp-guard-engine.mjs +42 -101
  35. package/hub/hub-lifecycle.mjs +0 -248
  36. package/hub/pipe.mjs +0 -825
  37. package/hub/promote-penalties.mjs +0 -138
  38. package/hub/public/dashboard.html +0 -416
  39. package/hub/schema.sql +0 -209
  40. package/hub/server.mjs +0 -3048
  41. package/hub/store-adapter.mjs +0 -182
  42. package/hub/store.mjs +0 -1736
  43. package/hub/team/cli/services/hub-client.mjs +0 -178
  44. package/hub/team/conductor-registry.mjs +0 -121
  45. package/hub/team/cto-auto-collect.mjs +0 -115
  46. package/hub/team/lead-control.mjs +0 -191
  47. package/hub/team/session-sync.mjs +0 -316
  48. package/hub/team/swarm-intent.mjs +0 -18
  49. package/hub/team/synapse-cli.mjs +0 -458
  50. package/hub/team/synapse-http.mjs +0 -311
  51. package/hub/team/synapse-registry.mjs +0 -618
  52. package/hub/team/tui-synapse.mjs +0 -223
  53. package/hub/team/worker-sandbox.mjs +0 -125
  54. package/hub/tools.mjs +0 -900
  55. package/hub/workers/codex-app-server-worker.mjs +0 -1067
  56. package/hub/workers/delegator-mcp.mjs +0 -1098
  57. package/hub/workers/lib/jsonrpc-stdio.mjs +0 -204
  58. package/scripts/lib/claudemd-scanner.mjs +0 -26
  59. package/scripts/lib/context.mjs +0 -51
  60. package/scripts/lib/handoff.mjs +0 -187
  61. package/scripts/lib/logger.mjs +0 -122
  62. package/scripts/lib/stealth-fetch.mjs +0 -176
@@ -1,138 +0,0 @@
1
- // hub/promote-penalties.mjs — pending-penalties.jsonl → store adaptive_rules 승격
2
- //
3
- // 흐름:
4
- // 1. safety-guard BLOCK → error-context.mjs → pending-penalties.jsonl 기록
5
- // 2. 이 스크립트가 penalties를 읽어 store.addAdaptiveRule()로 승격
6
- // 3. 승격 완료된 penalty는 파일에서 제거
7
- //
8
- // 호출 시점:
9
- // - Hub 시작 시 (server.mjs에서 import)
10
- // - 수동: node hub/promote-penalties.mjs
11
-
12
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
13
- import { join } from "node:path";
14
- import { adaptiveRuleFromError, promoteRule } from "@triflux/core/hub/reflexion.mjs";
15
-
16
- const HOME = process.env.HOME || process.env.USERPROFILE || "";
17
- const PENALTY_DIR = join(HOME, ".triflux", "reflexion");
18
- const PENALTY_FILE = join(PENALTY_DIR, "pending-penalties.jsonl");
19
-
20
- function loadPenalties() {
21
- if (!existsSync(PENALTY_FILE)) return [];
22
- try {
23
- return readFileSync(PENALTY_FILE, "utf8")
24
- .split("\n")
25
- .filter(Boolean)
26
- .map((line) => {
27
- try {
28
- return JSON.parse(line);
29
- } catch {
30
- return null;
31
- }
32
- })
33
- .filter(Boolean);
34
- } catch {
35
- return [];
36
- }
37
- }
38
-
39
- function clearPenalties() {
40
- try {
41
- writeFileSync(PENALTY_FILE, "", "utf8");
42
- } catch {
43
- /* ignore */
44
- }
45
- }
46
-
47
- /**
48
- * pending penalties를 adaptive rules로 승격한다.
49
- *
50
- * @param {object} store — store-adapter 인스턴스 (addAdaptiveRule 필요)
51
- * @param {object} [options]
52
- * @param {string} [options.projectSlug] — 프로젝트 식별자
53
- * @param {number} [options.sessionCount] — 현재 세션 번호
54
- * @returns {{ promoted: number, skipped: number, total: number }}
55
- */
56
- export function promotePenalties(store, options = {}) {
57
- const penalties = loadPenalties();
58
- if (penalties.length === 0) return { promoted: 0, skipped: 0, total: 0 };
59
-
60
- const projectSlug = options.projectSlug || "triflux";
61
- const sessionCount = options.sessionCount || 1;
62
- let promoted = 0;
63
- let skipped = 0;
64
-
65
- for (const penalty of penalties) {
66
- const errorContext = {
67
- errorText: penalty.error_pattern || "",
68
- errorMessage: `[${penalty.source}] ${penalty.error_pattern || "guard block"}`,
69
- command: penalty.command_preview || "",
70
- projectSlug,
71
- sessionCount,
72
- tool: penalty.tool || "Bash",
73
- };
74
-
75
- const rule = adaptiveRuleFromError(errorContext);
76
- if (!rule) {
77
- skipped++;
78
- continue;
79
- }
80
-
81
- try {
82
- if (store.addAdaptiveRule) {
83
- const existing = store.findAdaptiveRule?.(
84
- projectSlug,
85
- rule.error_pattern,
86
- );
87
- if (existing) {
88
- // 기존 규칙 → confidence 승격
89
- promoteRule(store, projectSlug, rule.error_pattern);
90
- } else {
91
- // 새 규칙 → 추가
92
- store.addAdaptiveRule({
93
- project_slug: projectSlug,
94
- pattern: rule.error_pattern,
95
- error_message: rule.error_message,
96
- solution: rule.solution,
97
- context:
98
- typeof rule.context === "string"
99
- ? rule.context
100
- : JSON.stringify(rule.context),
101
- confidence: rule.confidence,
102
- hit_count: rule.hit_count,
103
- last_seen_ms: Date.now(),
104
- });
105
- }
106
- promoted++;
107
- } else {
108
- skipped++;
109
- }
110
- } catch {
111
- skipped++;
112
- }
113
- }
114
-
115
- // 승격 완료된 penalties 제거
116
- if (promoted > 0) clearPenalties();
117
-
118
- return { promoted, skipped, total: penalties.length };
119
- }
120
-
121
- /**
122
- * store 없이 standalone 실행 — penalties 요약만 출력
123
- */
124
- export function dryRun() {
125
- const penalties = loadPenalties();
126
- console.log(`[promote-penalties] ${penalties.length} pending penalties`);
127
- for (const p of penalties) {
128
- console.log(
129
- ` ${p.ts?.slice(0, 19)} [${p.source}] ${p.error_pattern?.slice(0, 80)}`,
130
- );
131
- }
132
- return penalties;
133
- }
134
-
135
- // CLI 직접 실행
136
- if (process.argv[1]?.endsWith("promote-penalties.mjs")) {
137
- dryRun();
138
- }
@@ -1,416 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="ko">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>triflux QoS 대시보드</title>
7
- <style>
8
- *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
9
- body{background:#0d1117;color:#c9d1d9;font-family:system-ui,-apple-system,sans-serif;min-height:100vh;padding:16px}
10
- .header{display:flex;align-items:center;justify-content:space-between;padding:12px 0;margin-bottom:16px;border-bottom:1px solid #30363d;max-width:1200px;margin-left:auto;margin-right:auto}
11
- .header h1{font-size:1.4rem;font-weight:600}
12
- .status-bar{display:flex;align-items:center;gap:12px;font-size:.85rem;color:#8b949e}
13
- .status-dot{width:10px;height:10px;border-radius:50%;display:inline-block;transition:background .3s}
14
- .status-dot.ok{background:#3fb950}
15
- .status-dot.err{background:#f85149}
16
- .grid{display:grid;grid-template-columns:1fr 1fr;gap:16px;max-width:1200px;margin:0 auto}
17
- @media(max-width:768px){.grid{grid-template-columns:1fr}}
18
- .card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px}
19
- .card-title{font-size:.95rem;font-weight:600;margin-bottom:12px;color:#c9d1d9}
20
- .sub{color:#8b949e;font-size:.8rem}
21
-
22
- .gauge-wrap{display:flex;flex-direction:column;align-items:center;gap:8px}
23
- .gauge-value{font-size:2.2rem;font-weight:700;margin-top:-36px}
24
- .gauge-label{font-size:.85rem;color:#8b949e}
25
-
26
- .quota-row{margin-bottom:14px}
27
- .quota-row:last-child{margin-bottom:0}
28
- .quota-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;font-size:.85rem}
29
- .quota-header .cli-name{font-weight:600}
30
- .quota-header .meta{color:#8b949e;font-size:.78rem}
31
- .bar-row{display:flex;gap:6px;align-items:center;margin-bottom:4px}
32
- .bar-row:last-child{margin-bottom:0}
33
- .bar-row .bar-label{font-size:.72rem;color:#8b949e;width:56px;text-align:right;flex-shrink:0}
34
- .bar-track{height:18px;background:#21262d;border-radius:4px;overflow:hidden;position:relative;flex:1}
35
- .bar-fill{height:100%;border-radius:4px;transition:width .4s ease;display:flex;align-items:center;justify-content:flex-end;padding-right:6px;font-size:.72rem;font-weight:600;color:#0d1117;min-width:0}
36
- .bar-pct-outer{font-size:.72rem;color:#8b949e;margin-left:6px;width:32px;flex-shrink:0}
37
-
38
- .token-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}
39
- .token-item{background:#0d1117;border:1px solid #30363d;border-radius:6px;padding:12px;text-align:center}
40
- .token-item .label{font-size:.78rem;color:#8b949e;margin-bottom:4px}
41
- .token-item .value{font-size:1.5rem;font-weight:700}
42
- .token-item .calls{font-size:.78rem;color:#8b949e;margin-top:2px}
43
-
44
- .chart-wrap{position:relative;width:100%;height:160px}
45
- .chart-wrap canvas{width:100%;height:100%}
46
- .chart-legend{display:flex;gap:14px;margin-top:8px;font-size:.78rem;color:#8b949e}
47
- .legend-dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:4px;vertical-align:middle}
48
-
49
- .placeholder{text-align:center;padding:24px;color:#8b949e;font-size:.85rem}
50
- .btn{background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:4px 12px;border-radius:6px;font-size:.78rem;cursor:pointer;transition:background .2s}
51
- .btn:hover{background:#30363d}
52
- .btn:active{background:#3b414a}
53
- .btn.ok{border-color:#3fb950;color:#3fb950}
54
- .guide{display:none;max-width:1200px;margin:0 auto 16px;background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px;font-size:.82rem;line-height:1.7;color:#8b949e}
55
- .guide.open{display:block}
56
- .guide h3{color:#c9d1d9;font-size:.9rem;margin:12px 0 6px}
57
- .guide h3:first-child{margin-top:0}
58
- .guide code{background:#0d1117;padding:1px 5px;border-radius:3px;font-size:.78rem;color:#d19a66}
59
- .guide ul{padding-left:18px;margin:4px 0}
60
- .actions{display:flex;gap:8px;align-items:center}
61
- </style>
62
- </head>
63
- <body>
64
-
65
- <div class="header">
66
- <h1>&#9889; triflux QoS 대시보드</h1>
67
- <div class="status-bar">
68
- <span class="status-dot" id="statusDot"></span>
69
- <span id="statusText">연결 중...</span>
70
- <span id="lastUpdate" class="sub"></span>
71
- <div class="actions">
72
- <button class="btn" id="guideBtn" title="도움말">?</button>
73
- <button class="btn" id="reloadBtn" title="계정 브로커 리로드">Reload</button>
74
- <a href="/broker/dashboard" target="_blank" class="btn" style="text-decoration:none">Broker</a>
75
- </div>
76
- </div>
77
- </div>
78
-
79
- <div class="guide" id="guidePanel">
80
- <h3>AIMD 동시 워커</h3>
81
- <p>AIMD(Additive Increase / Multiplicative Decrease) 알고리즘으로 최적 동시 워커 수를 자동 조절합니다. 게이지가 <code>10/10</code>에 가까우면 시스템이 포화 상태입니다.</p>
82
- <h3>CLI 쿼터 사용률</h3>
83
- <ul>
84
- <li><strong>Claude</strong> — 5시간/주간 사용량. 80% 이상이면 빨간색 경고.</li>
85
- <li><strong>Codex</strong> — Primary/Secondary 버킷별 사용률. 리셋 시간이 표시됩니다.</li>
86
- <li><strong>Gemini</strong> — 남은 비율 기반 사용률. RPM/RPD 제한 포함.</li>
87
- </ul>
88
- <h3>호출 이력 타임라인</h3>
89
- <p>최근 CLI 호출의 성공/실패/타임아웃을 시간순으로 표시합니다. <span style="color:#3fb950">녹색</span>=성공, <span style="color:#f85149">빨강</span>=실패, <span style="color:#d29922">노랑</span>=타임아웃.</p>
90
- <h3>토큰 누적 현황</h3>
91
- <p>CLI별 누적 토큰 사용량과 호출 횟수입니다. 세션 시작 이후의 합계입니다.</p>
92
- <h3>조작</h3>
93
- <ul>
94
- <li><strong>Reload</strong> — <code>POST /broker/reload</code> 호출. accounts.json을 핫리로드합니다.</li>
95
- <li><strong>Broker</strong> — 계정 브로커 대시보드(별도 페이지)를 엽니다.</li>
96
- <li>데이터는 <strong>5초</strong>마다 자동 갱신됩니다.</li>
97
- </ul>
98
- </div>
99
-
100
- <div class="grid">
101
- <!-- AIMD 게이지 -->
102
- <div class="card">
103
- <div class="card-title">AIMD 동시 워커</div>
104
- <div class="gauge-wrap">
105
- <svg width="220" height="130" viewBox="0 0 220 130">
106
- <path d="M20 120 A90 90 0 0 1 200 120" fill="none" stroke="#21262d" stroke-width="18" stroke-linecap="round"/>
107
- <path id="gaugeArc" d="M20 120 A90 90 0 0 1 200 120" fill="none" stroke="#3fb950" stroke-width="18" stroke-linecap="round" stroke-dasharray="0 283"/>
108
- <!-- 눈금 -->
109
- <g id="gaugeTicks" fill="#8b949e" font-size="10" text-anchor="middle"></g>
110
- </svg>
111
- <div class="gauge-value" id="gaugeVal">-</div>
112
- <div class="gauge-label" id="gaugeLabel">- / 10</div>
113
- </div>
114
- </div>
115
-
116
- <!-- CLI 쿼터 바 -->
117
- <div class="card">
118
- <div class="card-title">CLI 쿼터 사용률</div>
119
- <div id="quotaBars">
120
- <div class="placeholder">데이터 대기 중...</div>
121
- </div>
122
- </div>
123
-
124
- <!-- 호출 이력 차트 -->
125
- <div class="card" style="grid-column:1/-1">
126
- <div class="card-title">호출 이력 타임라인</div>
127
- <div class="chart-wrap">
128
- <canvas id="eventsCanvas"></canvas>
129
- </div>
130
- <div class="chart-legend">
131
- <span><span class="legend-dot" style="background:#3fb950"></span>성공</span>
132
- <span><span class="legend-dot" style="background:#f85149"></span>실패</span>
133
- <span><span class="legend-dot" style="background:#d29922"></span>타임아웃</span>
134
- </div>
135
- </div>
136
-
137
- <!-- 토큰 누적 -->
138
- <div class="card" style="grid-column:1/-1">
139
- <div class="card-title">토큰 누적 현황</div>
140
- <div class="token-grid" id="tokenCards">
141
- <div class="placeholder" style="grid-column:1/-1">데이터 대기 중...</div>
142
- </div>
143
- </div>
144
- </div>
145
-
146
- <script>
147
- (function() {
148
- function esc(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
149
-
150
- var POLL = 5000;
151
- var MAX_W = 10;
152
- var ARC_TOTAL = 283;
153
-
154
- var $dot = document.getElementById('statusDot');
155
- var $stxt = document.getElementById('statusText');
156
- var $time = document.getElementById('lastUpdate');
157
- var $arc = document.getElementById('gaugeArc');
158
- var $gval = document.getElementById('gaugeVal');
159
- var $glab = document.getElementById('gaugeLabel');
160
- var $quota = document.getElementById('quotaBars');
161
- var $tokens = document.getElementById('tokenCards');
162
- var canvas = document.getElementById('eventsCanvas');
163
- var cx = canvas.getContext('2d');
164
- var cachedEvents = [];
165
-
166
- function status(ok) {
167
- $dot.className = 'status-dot ' + (ok ? 'ok' : 'err');
168
- $stxt.textContent = ok ? '연결됨' : '연결 실패';
169
- }
170
-
171
- function ts(d) {
172
- return d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
173
- }
174
-
175
- function fmtTok(n) {
176
- if (n == null) return '-';
177
- if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
178
- if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
179
- return String(n);
180
- }
181
-
182
- function barCol(p) {
183
- if (p >= 80) return '#f85149';
184
- if (p >= 50) return '#d29922';
185
- return '#3fb950';
186
- }
187
-
188
- // rt: ISO 문자열 또는 Unix 초(정수). Unix 초는 1e10 미만으로 구분
189
- function remain(rt) {
190
- if (!rt) return '';
191
- var ts = typeof rt === 'number' && rt < 1e10 ? rt * 1000 : new Date(rt).getTime();
192
- if (!Number.isFinite(ts)) return '';
193
- var ms = ts - Date.now();
194
- if (ms <= 0) return '만료됨';
195
- var h = Math.floor(ms / 3600000);
196
- var m = Math.floor((ms % 3600000) / 60000);
197
- return h > 0 ? h + '시간 ' + m + '분' : m + '분';
198
- }
199
-
200
- /* ── 게이지 ── */
201
- function gauge(val) {
202
- val = Math.max(0, Math.min(MAX_W, val || 0));
203
- var r = val / MAX_W;
204
- $arc.setAttribute('stroke-dasharray', (r * ARC_TOTAL) + ' ' + ARC_TOTAL);
205
- $arc.setAttribute('stroke', r > 0.8 ? '#f85149' : r > 0.5 ? '#d29922' : '#3fb950');
206
- $gval.textContent = val;
207
- $glab.textContent = val + ' / ' + MAX_W;
208
- }
209
-
210
- /* ── 쿼터 ── */
211
- function makeBar(label, pct) {
212
- pct = Math.min(100, Math.max(0, Math.round(pct)));
213
- var c = barCol(pct);
214
- var inner = pct > 10 ? pct + '%' : '';
215
- var outer = pct <= 10 ? pct + '%' : '';
216
- return '<div class="bar-row">' +
217
- '<span class="bar-label">' + esc(label) + '</span>' +
218
- '<div class="bar-track"><div class="bar-fill" style="width:' + pct + '%;background:' + c + '">' + inner + '</div></div>' +
219
- '<span class="bar-pct-outer">' + outer + '</span>' +
220
- '</div>';
221
- }
222
-
223
- function quotaBlock(name, color, bars, resetTime) {
224
- var rem = remain(resetTime);
225
- var expired = rem === '만료됨';
226
- var h = '<div class="quota-row"><div class="quota-header">' +
227
- '<span class="cli-name" style="color:' + color + '">' + esc(name) + '</span>';
228
- if (rem) h += '<span class="meta" style="' + (expired ? 'color:#f85149' : '') + '">리셋: ' + rem + '</span>';
229
- h += '</div>';
230
- for (var i = 0; i < bars.length; i++) h += makeBar(bars[i][0], bars[i][1]);
231
- h += '</div>';
232
- return h;
233
- }
234
-
235
- function renderQuota(d) {
236
- var s = '';
237
-
238
- // Claude
239
- var cl = d.claude?.data;
240
- if (cl) {
241
- s += quotaBlock('Claude (c)', '#d19a66', [
242
- ['5시간', cl.fiveHourPercent || 0],
243
- ['주간', cl.weeklyPercent || 0]
244
- ], cl.fiveHourResetsAt);
245
- }
246
-
247
- // Codex
248
- var cx2 = d.codex?.buckets?.codex;
249
- if (cx2) {
250
- s += quotaBlock('Codex (x)', '#ffffff', [
251
- ['Primary', cx2.primary ? cx2.primary.used_percent || 0 : 0],
252
- ['Secondary', cx2.secondary ? cx2.secondary.used_percent || 0 : 0]
253
- ], cx2.primary?.resets_at);
254
- }
255
-
256
- // Gemini
257
- var gm = d.gemini?.buckets;
258
- if (gm && gm.length > 0) {
259
- var frac = gm[0].remainingFraction;
260
- s += quotaBlock('Gemini (g)', '#61afef', [
261
- ['사용률', frac != null ? Math.round((1 - frac) * 100) : 0]
262
- ], gm[0].resetTime);
263
- }
264
-
265
- $quota.innerHTML = s || '<div class="placeholder">쿼터 데이터 없음</div>';
266
- }
267
-
268
- /* ── 토큰 ── */
269
- function renderTokens(acc) {
270
- if (!acc || Object.keys(acc).length === 0) {
271
- $tokens.innerHTML = '<div class="placeholder" style="grid-column:1/-1">누적 데이터 없음</div>';
272
- return;
273
- }
274
- var h = '';
275
- var keys = Object.keys(acc);
276
- for (var i = 0; i < keys.length; i++) {
277
- var k = keys[i], v = acc[k];
278
- if (!v) continue;
279
- h += '<div class="token-item">' +
280
- '<div class="label">' + esc(k.toUpperCase()) + '</div>' +
281
- '<div class="value">' + esc(fmtTok(v.tokens)) + '</div>' +
282
- '<div class="calls">' + (v.calls || 0) + '회 호출</div>' +
283
- '</div>';
284
- }
285
- $tokens.innerHTML = h || '<div class="placeholder" style="grid-column:1/-1">누적 데이터 없음</div>';
286
- }
287
-
288
- /* ── 이벤트 차트 ── */
289
- var colorMap = { success: '#3fb950', failed: '#f85149', fail: '#f85149', error: '#f85149', timeout: '#d29922' };
290
-
291
- function drawEvents(evts) {
292
- var dpr = window.devicePixelRatio || 1;
293
- var rect = canvas.parentElement.getBoundingClientRect();
294
- var w = rect.width, h = rect.height;
295
- canvas.width = w * dpr;
296
- canvas.height = h * dpr;
297
- cx.setTransform(dpr, 0, 0, dpr, 0, 0);
298
- cx.clearRect(0, 0, w, h);
299
-
300
- if (!evts || evts.length === 0) {
301
- cx.fillStyle = '#8b949e';
302
- cx.font = '13px system-ui';
303
- cx.textAlign = 'center';
304
- cx.fillText('이벤트 데이터 없음', w / 2, h / 2);
305
- return;
306
- }
307
-
308
- var pad = { l: 50, r: 16, t: 12, b: 28 };
309
- var cw = w - pad.l - pad.r;
310
- var ch = h - pad.t - pad.b;
311
-
312
- var times = [];
313
- for (var i = 0; i < evts.length; i++) {
314
- times.push(new Date(evts[i].timestamp || evts[i].time || evts[i].t).getTime());
315
- }
316
- var tMin = Math.min.apply(null, times);
317
- var tMax = Math.max.apply(null, times);
318
- if (tMax === tMin) tMax = tMin + 60000;
319
-
320
- // X축 라인
321
- cx.strokeStyle = '#30363d';
322
- cx.lineWidth = 1;
323
- cx.beginPath();
324
- cx.moveTo(pad.l, pad.t + ch);
325
- cx.lineTo(pad.l + cw, pad.t + ch);
326
- cx.stroke();
327
-
328
- // X축 라벨
329
- cx.fillStyle = '#8b949e';
330
- cx.font = '11px system-ui';
331
- cx.textAlign = 'center';
332
- var nL = Math.min(6, evts.length);
333
- for (var j = 0; j < nL; j++) {
334
- var tp = tMin + (tMax - tMin) * j / (nL - 1 || 1);
335
- var xp = pad.l + cw * (tp - tMin) / (tMax - tMin);
336
- var dd = new Date(tp);
337
- cx.fillText(dd.getHours() + ':' + String(dd.getMinutes()).padStart(2, '0'), xp, h - 6);
338
- }
339
-
340
- // 도트
341
- var dr = Math.max(3, Math.min(6, cw / evts.length * 0.4));
342
- for (var k = 0; k < evts.length; k++) {
343
- var ev = evts[k];
344
- var t = times[k];
345
- var ex = pad.l + cw * (t - tMin) / (tMax - tMin);
346
- // Y: 해시 분산 (겹침 방지)
347
- var ey = pad.t + ch * 0.15 + (ch * 0.65) * ((k * 7 + 3) % 13) / 13;
348
- var st = (ev.status || ev.result || 'success').toLowerCase();
349
- cx.beginPath();
350
- cx.arc(ex, ey, dr, 0, Math.PI * 2);
351
- cx.fillStyle = colorMap[st] || '#8b949e';
352
- cx.fill();
353
- }
354
- }
355
-
356
- /* ── 폴링 ── */
357
- function poll() {
358
- fetch('/api/qos-stats')
359
- .then(function(r) {
360
- if (!r.ok) throw new Error('HTTP ' + r.status);
361
- return r.json();
362
- })
363
- .then(function(data) {
364
- status(true);
365
- $time.textContent = ts(new Date());
366
-
367
- gauge(data.aimd ? data.aimd.batchSize : 0);
368
- renderQuota(data);
369
-
370
- cachedEvents = (data.aimd?.events) || [];
371
- drawEvents(cachedEvents);
372
-
373
- renderTokens(data.accumulator);
374
- })
375
- .catch(function(e) {
376
- status(false);
377
- $time.textContent = ts(new Date()) + ' (실패)';
378
- });
379
- }
380
-
381
- var rTimer;
382
- window.addEventListener('resize', function() {
383
- clearTimeout(rTimer);
384
- rTimer = setTimeout(function() { drawEvents(cachedEvents); }, 150);
385
- });
386
-
387
- /* ── 가이드 토글 ── */
388
- document.getElementById('guideBtn').onclick = function() {
389
- var p = document.getElementById('guidePanel');
390
- p.classList.toggle('open');
391
- this.classList.toggle('ok');
392
- };
393
-
394
- /* ── 브로커 리로드 ── */
395
- document.getElementById('reloadBtn').onclick = function() {
396
- var btn = this;
397
- btn.textContent = '...';
398
- fetch('/broker/reload', { method: 'POST' })
399
- .then(function(r) { return r.json(); })
400
- .then(function(d) {
401
- btn.textContent = d.ok ? 'OK (' + (d.accounts || 0) + ')' : 'Fail';
402
- btn.classList.toggle('ok', d.ok);
403
- setTimeout(function() { btn.textContent = 'Reload'; btn.classList.remove('ok'); }, 3000);
404
- })
405
- .catch(function() {
406
- btn.textContent = 'Error';
407
- setTimeout(function() { btn.textContent = 'Reload'; }, 3000);
408
- });
409
- };
410
-
411
- poll();
412
- setInterval(poll, POLL);
413
- })();
414
- </script>
415
- </body>
416
- </html>