omnigate-ai 1.0.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.
- package/Dockerfile +37 -0
- package/Omnigate AI.txt +72 -0
- package/bin/omnigate.js +2 -0
- package/docker-compose.yml +16 -0
- package/package.json +30 -0
- package/payload-theme.json +1 -0
- package/proxy-helper.js +60 -0
- package/public/app.js +432 -0
- package/public/index.html +367 -0
- package/public/styles.css +1060 -0
- package/server.js +586 -0
- package/test-payload.json +1 -0
- package/test-payload2.json +1 -0
- package/test-real-api.js +228 -0
package/Dockerfile
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# ========================================================
|
|
2
|
+
# Stage 1: Dependency builder
|
|
3
|
+
# ========================================================
|
|
4
|
+
FROM node:20-alpine AS builder
|
|
5
|
+
|
|
6
|
+
WORKDIR /usr/src/app
|
|
7
|
+
|
|
8
|
+
# Copy package descriptors
|
|
9
|
+
COPY package*.json ./
|
|
10
|
+
|
|
11
|
+
# Install only production dependencies cleanly
|
|
12
|
+
RUN npm ci --only=production
|
|
13
|
+
|
|
14
|
+
# ========================================================
|
|
15
|
+
# Stage 2: Production runner
|
|
16
|
+
# ========================================================
|
|
17
|
+
FROM node:20-alpine AS runner
|
|
18
|
+
|
|
19
|
+
# Set production environment variables
|
|
20
|
+
ENV NODE_ENV=production
|
|
21
|
+
ENV PORT=8080
|
|
22
|
+
|
|
23
|
+
WORKDIR /usr/src/app
|
|
24
|
+
|
|
25
|
+
# Copy production artifacts from builder and source code
|
|
26
|
+
COPY package*.json ./
|
|
27
|
+
COPY --from=builder /usr/src/app/node_modules ./node_modules
|
|
28
|
+
COPY server.js ./
|
|
29
|
+
|
|
30
|
+
# Run container as a non-root node user for container hardening
|
|
31
|
+
USER node
|
|
32
|
+
|
|
33
|
+
# Expose port
|
|
34
|
+
EXPOSE 8080
|
|
35
|
+
|
|
36
|
+
# Run the gateway server
|
|
37
|
+
CMD ["node", "server.js"]
|
package/Omnigate AI.txt
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Product Requirement Document (PRD)
|
|
2
|
+
Project: OmniGate AI (Enterprise AI Cost Gateway & Proxy)
|
|
3
|
+
Document Version: 1.0
|
|
4
|
+
Target Audience: Engineering Teams, AI Agencies, and B2B Development Shops
|
|
5
|
+
1. Executive Summary & Product Goal
|
|
6
|
+
1.1 Core Objective
|
|
7
|
+
OmniGate AI is a self-hosted, privacy-first API gateway and proxy designed for software development agencies and enterprise teams building with Large Language Models (LLMs). The product solves the critical problem of "API Bill Shock"—unpredictable, skyrocketing AI costs caused by developer terminal tools (e.g., Claude Code), IDE extensions (e.g., Cursor, VS Code), and custom frontends that recursively send massive conversation histories and consume invisible "thinking/reasoning" tokens.
|
|
8
|
+
1.2 Key Value Propositions
|
|
9
|
+
Privacy-First Architecture: Self-hosted deployments mean sensitive codebases and client data never pass through a third-party SaaS infrastructure.
|
|
10
|
+
Absolute Cost Transparency: Intercepts and logs precise, true token consumption (including hidden reasoning tokens) in real-time.
|
|
11
|
+
Granular Governance: Allows agency administrators to set hard spending limits, track costs by project or developer, and prevent runaway automated agent loops.
|
|
12
|
+
2. Problem Statement & Critical Roadblocks
|
|
13
|
+
Through rigorous technical analysis, the following structural challenges have been identified and directly addressed in this specification:
|
|
14
|
+
2.1 The Privacy Hurdle
|
|
15
|
+
Agencies handling proprietary enterprise code or regulated data (e.g., healthcare, financial applications) will refuse to route API traffic through an external vendor's proxy.
|
|
16
|
+
Solution: 100% decoupling of the data plane and the control plane. The proxy runs entirely within the client's network. Only metadata (token counts, timestamps, user IDs) is synchronized outwards.
|
|
17
|
+
2.2 The "Invisible" Token Problem (Reasoning/Thinking)
|
|
18
|
+
Modern tier-1 models (like OpenAI's o1/o3 or Anthropic's extended thinking models) do not expose thinking tokens inside the visible body of streamed text. Calculating costs by running a local tokenizer (like tiktoken) over raw text output will result in massive calculation deficits, causing the agency to underreport costs.
|
|
19
|
+
Solution: Abandon localized text tokenization for streaming data. The gateway must modify incoming requests to force provider-side usage monitoring and extract the definitive billing token metadata directly from the final Server-Sent Events (SSE) payload chunks.
|
|
20
|
+
3. Product Architecture & Technical Specifications
|
|
21
|
+
[ Developer Tool ]
|
|
22
|
+
(VS Code, Claude Code, Terminal)
|
|
23
|
+
│
|
|
24
|
+
▼ (Modified API Base URL)
|
|
25
|
+
┌────────────────────────────────────────────────────────┐
|
|
26
|
+
│ CLIENT CLOUD INFRASTRUCTURE (Self-Hosted Docker) │
|
|
27
|
+
│ │
|
|
28
|
+
│ [ OmniGate Proxy Container ] │
|
|
29
|
+
│ │ │
|
|
30
|
+
│ ├─► 1. Modify Request & Forward Streaming ────┼───► [ AI Provider API ]
|
|
31
|
+
│ │ │ (OpenAI, Anthropic)
|
|
32
|
+
│ └─► 2. Intercept Final SSE "Usage" Chunk │
|
|
33
|
+
│ │
|
|
34
|
+
└─────────────────────────┬──────────────────────────────┘
|
|
35
|
+
│
|
|
36
|
+
▼ (Anonymized Telemetry Metadata Only)
|
|
37
|
+
[ Cloud Analytics Dashboard ]
|
|
38
|
+
3.1 Core Proxy Service (Data Plane)
|
|
39
|
+
Deployment Vector: Packaged exclusively as a lightweight Docker container (node:20-alpine) deployable via Docker Compose or Kubernetes on client infra.
|
|
40
|
+
Multi-Provider Translation: Must support unified routing to major LLM providers:
|
|
41
|
+
OpenAI (/v1/chat/completions)
|
|
42
|
+
Anthropic (/v1/messages)
|
|
43
|
+
Token Interception Protocol:
|
|
44
|
+
For OpenAI Streams: The proxy must inspect incoming payloads and append/override "stream_options": {"include_usage": true}. The engine must actively listen for the final, non-text chunk containing the unified usage block.
|
|
45
|
+
For Anthropic Streams: The engine must parse the message_start event for input tracking and compile the final message_delta/message_stop events to ensure output_tokens (inclusive of thinking blocks) are fully captured.
|
|
46
|
+
3.2 Cloud Control Plane (SaaS Dashboard)
|
|
47
|
+
Central Analytics Interface: A multi-tenant web platform where agency administrators log in to view charts, manage developer identities, and set budget thresholds.
|
|
48
|
+
Telemetry Synchronization: The proxy container transmits single, lightweight, non-blocking telemetry payloads upon the completion of every successful API execution block.
|
|
49
|
+
4. Functional Requirements
|
|
50
|
+
4.1 Proxy & Security Configuration
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
4.2 Tracking & Analytics
|
|
59
|
+
|
|
60
|
+
4.3 Governance & Controls (B2B Features)
|
|
61
|
+
|
|
62
|
+
5. Non-Functional Requirements (NFRs)
|
|
63
|
+
Performance / Latency Overhead: The proxy stream evaluation loop must introduce less than 15ms of latency to the time-to-first-token (TTFT) and maintain zero perceptible drag during continuous token streaming.
|
|
64
|
+
Resiliency: If connection drops occur between the self-hosted container and the centralized SaaS dashboard, the proxy must cache telemetry logs locally using an internal, ephemeral SQLite fallback mechanism and flush them once connectivity is restored. API traffic flowing to developers must never be blocked by external telemetry synchronization issues.
|
|
65
|
+
6. Success Metrics & Future Milestones
|
|
66
|
+
6.1 Phase 1 Success Metrics (MVP)
|
|
67
|
+
Data Integrity: Zero discrepancy between the cost tracked on OmniGate and the end-of-month official bills generated by OpenAI/Anthropic.
|
|
68
|
+
Infrastructure Churn: Less than 1% container crash frequency across diverse agency deployment environments.
|
|
69
|
+
6.2 Future Enhancements (Phase 2 Roadmap)
|
|
70
|
+
The Prompt Compressor: An intelligent token-reduction layer that scans prompt vectors within the proxy context and safely minifies data payloads to reduce active enterprise spend by 20–30% automatically.
|
|
71
|
+
Semantic De-duplication: Internal caching of static context layers (like massive code files or dense system prompts) to prevent redundant upstream transmissions across adjacent development teams.
|
|
72
|
+
Does this PRD provide the structural depth you need to begin technical execution, or would you like to expand the specifications on how the circuit breaker architecture detects runaway developer loops?
|
package/bin/omnigate.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
version: '3.8'
|
|
2
|
+
|
|
3
|
+
services:
|
|
4
|
+
gateway:
|
|
5
|
+
build:
|
|
6
|
+
context: .
|
|
7
|
+
dockerfile: Dockerfile
|
|
8
|
+
container_name: omnigate-gateway
|
|
9
|
+
ports:
|
|
10
|
+
- "8080:8080"
|
|
11
|
+
environment:
|
|
12
|
+
- PORT=8080
|
|
13
|
+
- AGENCY_GATEWAY_KEY=stub-agency-key-for-local-testing
|
|
14
|
+
- OPENAI_API_KEY=your-openai-api-key-stub
|
|
15
|
+
- ANTHROPIC_API_KEY=your-anthropic-api-key-stub
|
|
16
|
+
restart: unless-stopped
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "omnigate-ai",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Privacy-first, self-hosted LLM API Gateway and Proxy for tracking token usage.",
|
|
5
|
+
"main": "server.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"omnigate": "./bin/omnigate.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node server.js",
|
|
12
|
+
"dev": "node --watch server.js"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"openai",
|
|
16
|
+
"anthropic",
|
|
17
|
+
"proxy",
|
|
18
|
+
"gateway",
|
|
19
|
+
"telemetry",
|
|
20
|
+
"token-counting",
|
|
21
|
+
"privacy-first"
|
|
22
|
+
],
|
|
23
|
+
"author": "OmniGate AI Team",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"cors": "^2.8.5",
|
|
27
|
+
"dotenv": "^16.4.5",
|
|
28
|
+
"express": "^4.19.2"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"model":"deepseek-chat","messages":[{"role":"user","content":"Generate CSS for a dark/light theme toggle with a glassmorphism aesthetic. The dark theme should use deep purple-blue gradient background with glass cards. The light theme should use a soft off-white/light blue gradient with frosted glass cards. Both should feel elegant and polished. Include CSS variables for both themes."}],"stream":false}
|
package/proxy-helper.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This helper configures OmniGate proxy to work with DeepSeek/OpenAI
|
|
3
|
+
* and displays token usage information.
|
|
4
|
+
*
|
|
5
|
+
* The proxy is already running on port 8080.
|
|
6
|
+
* Configure your API key via the dashboard at http://localhost:8080/dashboard/
|
|
7
|
+
* Gateway Key: stub-agency-key-for-local-testing
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const PROXY_URL = 'http://localhost:8080';
|
|
11
|
+
|
|
12
|
+
// Pricing reference (from the dashboard)
|
|
13
|
+
const PRICING = {
|
|
14
|
+
'gpt-4o': { in: 5.00, out: 15.00 },
|
|
15
|
+
'gpt-4o-mini': { in: 0.15, out: 0.60 },
|
|
16
|
+
'deepseek-chat': { in: 0.27, out: 1.10 },
|
|
17
|
+
'claude-3-5-sonnet-20241022': { in: 3.00, out: 15.00 },
|
|
18
|
+
'default': { in: 2.00, out: 10.00 }
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function calculateCost(model, inTokens, outTokens) {
|
|
22
|
+
const rates = PRICING[model] || PRICING['default'];
|
|
23
|
+
return (inTokens / 1000000) * rates.in + (outTokens / 1000000) * rates.out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function getTelemetry() {
|
|
27
|
+
const res = await fetch(`${PROXY_URL}/api/telemetry`);
|
|
28
|
+
return res.json();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function getConfig() {
|
|
32
|
+
const res = await fetch(`${PROXY_URL}/api/config`, {
|
|
33
|
+
headers: { 'X-Gateway-Key': 'stub-agency-key-for-local-testing' }
|
|
34
|
+
});
|
|
35
|
+
return res.json();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
console.log('\n╔══════════════════════════════════════════════════════════╗');
|
|
39
|
+
console.log('║ 🚪 OmniGate Proxy Helper ║');
|
|
40
|
+
console.log('║ ║');
|
|
41
|
+
console.log('║ 1. Open http://localhost:8080/dashboard/ in browser ║');
|
|
42
|
+
console.log('║ 2. Enter Gateway Key: stub-agency-key-for-local-testing ║');
|
|
43
|
+
console.log('║ 3. Paste your API key and Save ║');
|
|
44
|
+
console.log('║ 4. Come back here and I will send test requests ║');
|
|
45
|
+
console.log('║ through the proxy so you see them on dashboard ║');
|
|
46
|
+
console.log('╚══════════════════════════════════════════════════════════╝\n');
|
|
47
|
+
|
|
48
|
+
// Check current config status
|
|
49
|
+
const config = await getConfig();
|
|
50
|
+
console.log('Current proxy config:');
|
|
51
|
+
console.log(` OpenAI URL: ${config.openaiApiUrl}`);
|
|
52
|
+
console.log(` API Key set: ${config.openaiApiKey ? '✅ Yes' : '❌ No'}`);
|
|
53
|
+
console.log(` Gateway Key: ${config.agencyGatewayKey}\n`);
|
|
54
|
+
|
|
55
|
+
if (!config.openaiApiKey) {
|
|
56
|
+
console.log('⚠️ No API key configured yet.');
|
|
57
|
+
console.log('➡️ Go to http://localhost:8080/dashboard/ and enter your key.\n');
|
|
58
|
+
} else {
|
|
59
|
+
console.log('✅ API key is configured! Ready to route requests through proxy.\n');
|
|
60
|
+
}
|
package/public/app.js
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
2
|
+
// Constants for generic pricing estimates (per 1M tokens)
|
|
3
|
+
const PRICING = {
|
|
4
|
+
'gpt-4o': { in: 5.00, out: 15.00 },
|
|
5
|
+
'claude-3-5-sonnet-20241022': { in: 3.00, out: 15.00 },
|
|
6
|
+
'default': { in: 2.00, out: 10.00 } // fallback
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
// State
|
|
10
|
+
let totalTokens = 0;
|
|
11
|
+
let totalInputTokens = 0;
|
|
12
|
+
let totalOutputTokens = 0;
|
|
13
|
+
let totalCost = 0.0;
|
|
14
|
+
let totalRequests = 0;
|
|
15
|
+
|
|
16
|
+
// DOM Elements
|
|
17
|
+
const elTotalTokens = document.getElementById('total-tokens');
|
|
18
|
+
const elInputTokens = document.getElementById('input-tokens');
|
|
19
|
+
const elOutputTokens = document.getElementById('output-tokens');
|
|
20
|
+
|
|
21
|
+
const elTotalCost = document.getElementById('total-cost');
|
|
22
|
+
const elAvgCost = document.getElementById('avg-cost');
|
|
23
|
+
|
|
24
|
+
const elRpmValue = document.getElementById('rpm-value');
|
|
25
|
+
const elTotalRequests = document.getElementById('total-requests');
|
|
26
|
+
|
|
27
|
+
const tokensGauge = document.getElementById('tokens-gauge');
|
|
28
|
+
const costGauge = document.getElementById('cost-gauge');
|
|
29
|
+
const rpmGauge = document.getElementById('rpm-gauge');
|
|
30
|
+
const tbody = document.getElementById('telemetry-tbody');
|
|
31
|
+
|
|
32
|
+
// Track timestamps for RPM calculation
|
|
33
|
+
const requestTimestamps = [];
|
|
34
|
+
|
|
35
|
+
function calculateCost(model, inTokens, outTokens) {
|
|
36
|
+
const rates = PRICING[model] || PRICING['default'];
|
|
37
|
+
return (inTokens / 1000000) * rates.in + (outTokens / 1000000) * rates.out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function formatNumber(num) {
|
|
41
|
+
return new Intl.NumberFormat('en-US').format(num);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function updateGauges() {
|
|
45
|
+
// Max tokens scale (e.g., 50k max for the gauge visual to be more reactive to test data)
|
|
46
|
+
const MAX_TOKENS = 50000;
|
|
47
|
+
let tokenPct = Math.min(totalTokens / MAX_TOKENS, 1);
|
|
48
|
+
tokensGauge.style.strokeDashoffset = 283 - (283 * tokenPct);
|
|
49
|
+
|
|
50
|
+
// Max cost scale (e.g. $0.5 max for reactivity)
|
|
51
|
+
const MAX_COST = 0.5;
|
|
52
|
+
let costPct = Math.min(totalCost / MAX_COST, 1);
|
|
53
|
+
costGauge.style.strokeDashoffset = 283 - (283 * costPct);
|
|
54
|
+
|
|
55
|
+
// RPM gauge (max 60 RPM)
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
// Keep requests from last 60 seconds
|
|
58
|
+
while (requestTimestamps.length > 0 && now - requestTimestamps[0] > 60000) {
|
|
59
|
+
requestTimestamps.shift();
|
|
60
|
+
}
|
|
61
|
+
const rpm = requestTimestamps.length;
|
|
62
|
+
let rpmPct = Math.min(rpm / 60, 1);
|
|
63
|
+
rpmGauge.style.strokeDashoffset = 283 - (283 * rpmPct);
|
|
64
|
+
|
|
65
|
+
elRpmValue.textContent = rpm;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Periodic RPM update
|
|
69
|
+
setInterval(updateGauges, 2000);
|
|
70
|
+
|
|
71
|
+
function renderRow(tel, isNew = false) {
|
|
72
|
+
const cost = calculateCost(tel.model, tel.inputTokens, tel.outputTokens);
|
|
73
|
+
|
|
74
|
+
// Update State
|
|
75
|
+
totalTokens += (tel.inputTokens + tel.outputTokens);
|
|
76
|
+
totalInputTokens += tel.inputTokens;
|
|
77
|
+
totalOutputTokens += tel.outputTokens;
|
|
78
|
+
totalCost += cost;
|
|
79
|
+
totalRequests += 1;
|
|
80
|
+
requestTimestamps.push(new Date(tel.timestamp).getTime());
|
|
81
|
+
|
|
82
|
+
// Update DOM Text
|
|
83
|
+
elTotalTokens.textContent = formatNumber(totalTokens);
|
|
84
|
+
elInputTokens.textContent = formatNumber(totalInputTokens);
|
|
85
|
+
elOutputTokens.textContent = formatNumber(totalOutputTokens);
|
|
86
|
+
|
|
87
|
+
elTotalCost.textContent = `$${totalCost.toFixed(3)}`;
|
|
88
|
+
elAvgCost.textContent = `$${(totalCost / totalRequests).toFixed(4)}`;
|
|
89
|
+
elTotalRequests.textContent = formatNumber(totalRequests);
|
|
90
|
+
|
|
91
|
+
// Create Row
|
|
92
|
+
const tr = document.createElement('tr');
|
|
93
|
+
if (isNew) tr.className = 'new-row';
|
|
94
|
+
|
|
95
|
+
const time = new Date(tel.timestamp).toLocaleTimeString();
|
|
96
|
+
|
|
97
|
+
tr.innerHTML = `
|
|
98
|
+
<td>${time}</td>
|
|
99
|
+
<td><strong>${tel.provider.toUpperCase()}</strong><br><span style="color:#94a3b8;font-size:0.75rem">${tel.model}</span></td>
|
|
100
|
+
<td>${tel.projectId}</td>
|
|
101
|
+
<td>${tel.userId}</td>
|
|
102
|
+
<td class="text-right">${formatNumber(tel.inputTokens)}</td>
|
|
103
|
+
<td class="text-right">${formatNumber(tel.outputTokens)}</td>
|
|
104
|
+
<td class="text-right" style="color:var(--neon-cyan)">$${cost.toFixed(4)}</td>
|
|
105
|
+
`;
|
|
106
|
+
|
|
107
|
+
tbody.insertBefore(tr, tbody.firstChild);
|
|
108
|
+
|
|
109
|
+
// Keep table from growing infinitely
|
|
110
|
+
if (tbody.children.length > 50) {
|
|
111
|
+
tbody.removeChild(tbody.lastChild);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
updateGauges();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let dashboardInitialized = false;
|
|
118
|
+
let evtSource = null;
|
|
119
|
+
|
|
120
|
+
function initializeDashboard() {
|
|
121
|
+
if (dashboardInitialized) return;
|
|
122
|
+
dashboardInitialized = true;
|
|
123
|
+
|
|
124
|
+
// 1. Fetch initial history
|
|
125
|
+
tbody.innerHTML = ''; // Clear rows
|
|
126
|
+
fetch('/api/telemetry')
|
|
127
|
+
.then(res => res.json())
|
|
128
|
+
.then(data => {
|
|
129
|
+
// Clear states before reloading
|
|
130
|
+
totalTokens = 0;
|
|
131
|
+
totalInputTokens = 0;
|
|
132
|
+
totalOutputTokens = 0;
|
|
133
|
+
totalCost = 0.0;
|
|
134
|
+
totalRequests = 0;
|
|
135
|
+
requestTimestamps.length = 0;
|
|
136
|
+
|
|
137
|
+
data.reverse().forEach(tel => renderRow(tel, false));
|
|
138
|
+
})
|
|
139
|
+
.catch(err => console.error("Error fetching telemetry:", err));
|
|
140
|
+
|
|
141
|
+
// 2. Connect to live SSE stream
|
|
142
|
+
if (evtSource) {
|
|
143
|
+
evtSource.close();
|
|
144
|
+
}
|
|
145
|
+
evtSource = new EventSource('/api/telemetry/stream');
|
|
146
|
+
evtSource.onmessage = (e) => {
|
|
147
|
+
try {
|
|
148
|
+
const tel = JSON.parse(e.data);
|
|
149
|
+
renderRow(tel, true);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
console.error("Stream parse error:", err);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ==========================================
|
|
157
|
+
// Configuration & Security Lock Logic
|
|
158
|
+
// ==========================================
|
|
159
|
+
const configForm = document.getElementById('config-form');
|
|
160
|
+
const openaiPreset = document.getElementById('openaiPreset');
|
|
161
|
+
const openaiApiUrl = document.getElementById('openaiApiUrl');
|
|
162
|
+
const configStatus = document.getElementById('config-status');
|
|
163
|
+
|
|
164
|
+
const gaugeWrapper = document.getElementById('gauge-cluster-wrapper');
|
|
165
|
+
const dataWrapper = document.getElementById('data-panel-wrapper');
|
|
166
|
+
|
|
167
|
+
function getGatewayKey() {
|
|
168
|
+
return localStorage.getItem('gatewayKey') || '';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function setLockedState(isLocked) {
|
|
172
|
+
if (isLocked) {
|
|
173
|
+
gaugeWrapper.classList.add('locked-section');
|
|
174
|
+
dataWrapper.classList.add('locked-section');
|
|
175
|
+
} else {
|
|
176
|
+
gaugeWrapper.classList.remove('locked-section');
|
|
177
|
+
dataWrapper.classList.remove('locked-section');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function showOnboardingModal() {
|
|
182
|
+
const modal = document.getElementById('onboarding-modal');
|
|
183
|
+
if (modal) modal.classList.remove('hidden');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function checkAuthAndLoad() {
|
|
187
|
+
const key = getGatewayKey();
|
|
188
|
+
if (!key) {
|
|
189
|
+
setLockedState(true);
|
|
190
|
+
showOnboardingModal();
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Validate key and fetch configs
|
|
195
|
+
fetch('/api/config', {
|
|
196
|
+
headers: { 'x-gateway-key': key }
|
|
197
|
+
})
|
|
198
|
+
.then(res => {
|
|
199
|
+
if (res.status === 401) {
|
|
200
|
+
localStorage.removeItem('gatewayKey');
|
|
201
|
+
setLockedState(true);
|
|
202
|
+
showOnboardingModal();
|
|
203
|
+
alert("Unauthorized. Incorrect Gateway Secret Key.");
|
|
204
|
+
throw new Error("Unauthorized. Incorrect Gateway Secret Key.");
|
|
205
|
+
}
|
|
206
|
+
return res.json();
|
|
207
|
+
})
|
|
208
|
+
.then(config => {
|
|
209
|
+
// Success: Unlock dashboard!
|
|
210
|
+
setLockedState(false);
|
|
211
|
+
initializeDashboard();
|
|
212
|
+
|
|
213
|
+
document.getElementById('agencyGatewayKey').value = config.agencyGatewayKey || '';
|
|
214
|
+
document.getElementById('openaiApiUrl').value = config.openaiApiUrl || '';
|
|
215
|
+
document.getElementById('openaiApiKey').placeholder = config.openaiApiKey || 'Enter API key (leave blank to keep current)';
|
|
216
|
+
document.getElementById('anthropicApiUrl').value = config.anthropicApiUrl || '';
|
|
217
|
+
document.getElementById('anthropicApiKey').placeholder = config.anthropicApiKey || 'Enter API key (leave blank to keep current)';
|
|
218
|
+
|
|
219
|
+
// Select preset if matched
|
|
220
|
+
let foundPreset = false;
|
|
221
|
+
for (const option of openaiPreset.options) {
|
|
222
|
+
if (option.value === config.openaiApiUrl) {
|
|
223
|
+
openaiPreset.value = config.openaiApiUrl;
|
|
224
|
+
foundPreset = true;
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (!foundPreset && config.openaiApiUrl) {
|
|
229
|
+
openaiPreset.value = 'custom';
|
|
230
|
+
}
|
|
231
|
+
})
|
|
232
|
+
.catch(err => {
|
|
233
|
+
console.error("Config load error:", err);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Run initial check
|
|
238
|
+
checkAuthAndLoad();
|
|
239
|
+
|
|
240
|
+
// Handle preset selection
|
|
241
|
+
openaiPreset.addEventListener('change', (e) => {
|
|
242
|
+
if (e.target.value !== 'custom') {
|
|
243
|
+
openaiApiUrl.value = e.target.value;
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// Handle save and unlock
|
|
248
|
+
configForm.addEventListener('submit', (e) => {
|
|
249
|
+
e.preventDefault();
|
|
250
|
+
|
|
251
|
+
configStatus.textContent = "Saving and validating...";
|
|
252
|
+
configStatus.className = "config-status";
|
|
253
|
+
|
|
254
|
+
const enteredKey = document.getElementById('agencyGatewayKey').value;
|
|
255
|
+
localStorage.setItem('gatewayKey', enteredKey);
|
|
256
|
+
|
|
257
|
+
const payload = {
|
|
258
|
+
agencyGatewayKey: enteredKey,
|
|
259
|
+
openaiApiUrl: document.getElementById('openaiApiUrl').value,
|
|
260
|
+
anthropicApiUrl: document.getElementById('anthropicApiUrl').value,
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const openaiApiKey = document.getElementById('openaiApiKey').value;
|
|
264
|
+
if (openaiApiKey) payload.openaiApiKey = openaiApiKey;
|
|
265
|
+
|
|
266
|
+
const anthropicApiKey = document.getElementById('anthropicApiKey').value;
|
|
267
|
+
if (anthropicApiKey) payload.anthropicApiKey = anthropicApiKey;
|
|
268
|
+
|
|
269
|
+
fetch('/api/config', {
|
|
270
|
+
method: 'POST',
|
|
271
|
+
headers: {
|
|
272
|
+
'Content-Type': 'application/json',
|
|
273
|
+
'x-gateway-key': enteredKey
|
|
274
|
+
},
|
|
275
|
+
body: JSON.stringify(payload)
|
|
276
|
+
})
|
|
277
|
+
.then(res => {
|
|
278
|
+
if (res.status === 401) {
|
|
279
|
+
localStorage.removeItem('gatewayKey');
|
|
280
|
+
setLockedState(true);
|
|
281
|
+
throw new Error("Invalid Gateway Secret Key. Access Denied.");
|
|
282
|
+
}
|
|
283
|
+
return res.json();
|
|
284
|
+
})
|
|
285
|
+
.then(data => {
|
|
286
|
+
if (data.status === 'success') {
|
|
287
|
+
configStatus.textContent = "Dashboard unlocked and configurations saved!";
|
|
288
|
+
configStatus.classList.add('success');
|
|
289
|
+
|
|
290
|
+
setLockedState(false);
|
|
291
|
+
initializeDashboard();
|
|
292
|
+
|
|
293
|
+
// Clear password fields so placeholders remain
|
|
294
|
+
document.getElementById('openaiApiKey').value = '';
|
|
295
|
+
document.getElementById('anthropicApiKey').value = '';
|
|
296
|
+
} else {
|
|
297
|
+
configStatus.textContent = data.error || "Failed to save.";
|
|
298
|
+
configStatus.classList.add('error');
|
|
299
|
+
}
|
|
300
|
+
setTimeout(() => configStatus.textContent = "", 4000);
|
|
301
|
+
})
|
|
302
|
+
.catch(err => {
|
|
303
|
+
configStatus.textContent = err.message || "Network error. Failed to save.";
|
|
304
|
+
configStatus.classList.add('error');
|
|
305
|
+
setTimeout(() => configStatus.textContent = "", 4000);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// ==========================================
|
|
310
|
+
// Tab Switching Logic
|
|
311
|
+
// ==========================================
|
|
312
|
+
const tabButtons = document.querySelectorAll('.tab-btn');
|
|
313
|
+
const tabContents = document.querySelectorAll('.tab-content');
|
|
314
|
+
|
|
315
|
+
tabButtons.forEach(btn => {
|
|
316
|
+
btn.addEventListener('click', () => {
|
|
317
|
+
const targetTab = btn.getAttribute('data-tab');
|
|
318
|
+
|
|
319
|
+
tabButtons.forEach(b => b.classList.remove('active'));
|
|
320
|
+
tabContents.forEach(c => c.classList.remove('active'));
|
|
321
|
+
|
|
322
|
+
btn.classList.add('active');
|
|
323
|
+
const targetElement = document.getElementById(`tab-${targetTab}`);
|
|
324
|
+
if (targetElement) {
|
|
325
|
+
targetElement.classList.add('active');
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// ==========================================
|
|
331
|
+
// Onboarding Modal Logic
|
|
332
|
+
// ==========================================
|
|
333
|
+
const modal = document.getElementById('onboarding-modal');
|
|
334
|
+
const btnNext = document.getElementById('btn-next');
|
|
335
|
+
const btnPrev = document.getElementById('btn-prev');
|
|
336
|
+
const btnFinish = document.getElementById('btn-finish');
|
|
337
|
+
const steps = document.querySelectorAll('.wizard-step');
|
|
338
|
+
const dots = document.querySelectorAll('.step-dot');
|
|
339
|
+
|
|
340
|
+
let currentStep = 1;
|
|
341
|
+
const totalSteps = steps.length;
|
|
342
|
+
|
|
343
|
+
function updateModalUI() {
|
|
344
|
+
steps.forEach(step => {
|
|
345
|
+
step.classList.remove('active', 'exit-left');
|
|
346
|
+
const stepNum = parseInt(step.id.split('-')[1]);
|
|
347
|
+
if (stepNum < currentStep) {
|
|
348
|
+
step.classList.add('exit-left');
|
|
349
|
+
} else if (stepNum === currentStep) {
|
|
350
|
+
step.classList.add('active');
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
dots.forEach(dot => {
|
|
355
|
+
const dotStep = parseInt(dot.getAttribute('data-step'));
|
|
356
|
+
dot.classList.toggle('active', dotStep === currentStep);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
if (btnPrev) btnPrev.style.visibility = currentStep === 1 ? 'hidden' : 'visible';
|
|
360
|
+
|
|
361
|
+
if (currentStep === totalSteps) {
|
|
362
|
+
if (btnNext) btnNext.classList.add('hidden');
|
|
363
|
+
if (btnFinish) btnFinish.classList.remove('hidden');
|
|
364
|
+
|
|
365
|
+
const enteredKey = document.getElementById('onboardingGatewayKey').value;
|
|
366
|
+
const previewKeyEl = document.getElementById('preview-key');
|
|
367
|
+
if (previewKeyEl) {
|
|
368
|
+
previewKeyEl.textContent = enteredKey || 'your-key';
|
|
369
|
+
}
|
|
370
|
+
} else {
|
|
371
|
+
if (btnNext) btnNext.classList.remove('hidden');
|
|
372
|
+
if (btnFinish) btnFinish.classList.add('hidden');
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (btnNext) {
|
|
377
|
+
btnNext.addEventListener('click', () => {
|
|
378
|
+
if (currentStep < totalSteps) {
|
|
379
|
+
currentStep++;
|
|
380
|
+
updateModalUI();
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (btnPrev) {
|
|
386
|
+
btnPrev.addEventListener('click', () => {
|
|
387
|
+
if (currentStep > 1) {
|
|
388
|
+
currentStep--;
|
|
389
|
+
updateModalUI();
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (btnFinish) {
|
|
395
|
+
btnFinish.addEventListener('click', () => {
|
|
396
|
+
const enteredKey = document.getElementById('onboardingGatewayKey').value;
|
|
397
|
+
if (!enteredKey) {
|
|
398
|
+
alert("Please enter your Gateway Secret Key to continue.");
|
|
399
|
+
currentStep = 2;
|
|
400
|
+
updateModalUI();
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
localStorage.setItem('gatewayKey', enteredKey);
|
|
405
|
+
if (modal) modal.classList.add('hidden');
|
|
406
|
+
checkAuthAndLoad();
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// ==========================================
|
|
412
|
+
// Copy Code Snippet Helper
|
|
413
|
+
// ==========================================
|
|
414
|
+
window.copySnippet = function(id, btn) {
|
|
415
|
+
const codeElement = document.getElementById(id);
|
|
416
|
+
if (!codeElement) return;
|
|
417
|
+
const code = codeElement.textContent;
|
|
418
|
+
navigator.clipboard.writeText(code).then(() => {
|
|
419
|
+
const originalText = btn.textContent;
|
|
420
|
+
btn.textContent = "Copied!";
|
|
421
|
+
btn.style.borderColor = "var(--accent-green)";
|
|
422
|
+
btn.style.color = "var(--accent-green)";
|
|
423
|
+
setTimeout(() => {
|
|
424
|
+
btn.textContent = originalText;
|
|
425
|
+
btn.style.borderColor = "";
|
|
426
|
+
btn.style.color = "";
|
|
427
|
+
}, 2000);
|
|
428
|
+
}).catch(err => {
|
|
429
|
+
console.error("Failed to copy code snippet:", err);
|
|
430
|
+
});
|
|
431
|
+
};
|
|
432
|
+
|