smartcontext-proxy 0.1.0 → 0.2.1
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/PLAN-v2.md +390 -0
- package/dist/src/context/ab-test.d.ts +32 -0
- package/dist/src/context/ab-test.js +133 -0
- package/dist/src/index.js +99 -78
- package/dist/src/proxy/classifier.d.ts +14 -0
- package/dist/src/proxy/classifier.js +63 -0
- package/dist/src/proxy/connect-proxy.d.ts +37 -0
- package/dist/src/proxy/connect-proxy.js +234 -0
- package/dist/src/proxy/server.js +10 -1
- package/dist/src/proxy/tls-interceptor.d.ts +23 -0
- package/dist/src/proxy/tls-interceptor.js +211 -0
- package/dist/src/proxy/transparent-listener.d.ts +31 -0
- package/dist/src/proxy/transparent-listener.js +285 -0
- package/dist/src/proxy/tunnel.d.ts +7 -0
- package/dist/src/proxy/tunnel.js +33 -0
- package/dist/src/system/dns-redirect.d.ts +28 -0
- package/dist/src/system/dns-redirect.js +141 -0
- package/dist/src/system/installer.d.ts +25 -0
- package/dist/src/system/installer.js +180 -0
- package/dist/src/system/linux.d.ts +11 -0
- package/dist/src/system/linux.js +60 -0
- package/dist/src/system/macos.d.ts +24 -0
- package/dist/src/system/macos.js +98 -0
- package/dist/src/system/pf-redirect.d.ts +25 -0
- package/dist/src/system/pf-redirect.js +177 -0
- package/dist/src/system/watchdog.d.ts +7 -0
- package/dist/src/system/watchdog.js +115 -0
- package/dist/src/test/connect-proxy.test.d.ts +1 -0
- package/dist/src/test/connect-proxy.test.js +147 -0
- package/dist/src/test/dashboard.test.js +1 -0
- package/dist/src/tls/ca-manager.d.ts +9 -0
- package/dist/src/tls/ca-manager.js +117 -0
- package/dist/src/tls/trust-store.d.ts +11 -0
- package/dist/src/tls/trust-store.js +121 -0
- package/dist/src/tray/bridge.d.ts +8 -0
- package/dist/src/tray/bridge.js +66 -0
- package/dist/src/ui/dashboard.d.ts +10 -1
- package/dist/src/ui/dashboard.js +119 -34
- package/dist/src/ui/ws-feed.d.ts +8 -0
- package/dist/src/ui/ws-feed.js +30 -0
- package/native/macos/SmartContextTray/Package.swift +13 -0
- package/native/macos/SmartContextTray/Sources/main.swift +206 -0
- package/package.json +6 -2
- package/src/context/ab-test.ts +172 -0
- package/src/index.ts +104 -74
- package/src/proxy/classifier.ts +71 -0
- package/src/proxy/connect-proxy.ts +251 -0
- package/src/proxy/server.ts +11 -2
- package/src/proxy/tls-interceptor.ts +261 -0
- package/src/proxy/transparent-listener.ts +328 -0
- package/src/proxy/tunnel.ts +32 -0
- package/src/system/dns-redirect.ts +144 -0
- package/src/system/installer.ts +148 -0
- package/src/system/linux.ts +57 -0
- package/src/system/macos.ts +89 -0
- package/src/system/pf-redirect.ts +175 -0
- package/src/system/watchdog.ts +76 -0
- package/src/test/connect-proxy.test.ts +170 -0
- package/src/test/dashboard.test.ts +1 -0
- package/src/tls/ca-manager.ts +140 -0
- package/src/tls/trust-store.ts +123 -0
- package/src/tray/bridge.ts +61 -0
- package/src/ui/dashboard.ts +129 -35
- package/src/ui/ws-feed.ts +32 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import forge from 'node-forge';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const CA_DIR = path.join(process.env['HOME'] || '.', '.smartcontext', 'ca');
|
|
6
|
+
const HOSTS_DIR = path.join(CA_DIR, 'hosts');
|
|
7
|
+
const CA_CERT_PATH = path.join(CA_DIR, 'smartcontext-ca.crt');
|
|
8
|
+
const CA_KEY_PATH = path.join(CA_DIR, 'smartcontext-ca.key');
|
|
9
|
+
|
|
10
|
+
export interface CertPair {
|
|
11
|
+
cert: string; // PEM
|
|
12
|
+
key: string; // PEM
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let cachedCA: { cert: forge.pki.Certificate; key: forge.pki.rsa.PrivateKey } | null = null;
|
|
16
|
+
const hostCertCache = new Map<string, CertPair>();
|
|
17
|
+
|
|
18
|
+
/** Ensure root CA exists, generate if not */
|
|
19
|
+
export function ensureCA(): CertPair {
|
|
20
|
+
fs.mkdirSync(CA_DIR, { recursive: true });
|
|
21
|
+
fs.mkdirSync(HOSTS_DIR, { recursive: true });
|
|
22
|
+
|
|
23
|
+
if (fs.existsSync(CA_CERT_PATH) && fs.existsSync(CA_KEY_PATH)) {
|
|
24
|
+
const cert = fs.readFileSync(CA_CERT_PATH, 'utf-8');
|
|
25
|
+
const key = fs.readFileSync(CA_KEY_PATH, 'utf-8');
|
|
26
|
+
cachedCA = {
|
|
27
|
+
cert: forge.pki.certificateFromPem(cert),
|
|
28
|
+
key: forge.pki.privateKeyFromPem(key) as forge.pki.rsa.PrivateKey,
|
|
29
|
+
};
|
|
30
|
+
return { cert, key };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Generate new root CA
|
|
34
|
+
const keys = forge.pki.rsa.generateKeyPair(2048);
|
|
35
|
+
const cert = forge.pki.createCertificate();
|
|
36
|
+
|
|
37
|
+
cert.publicKey = keys.publicKey;
|
|
38
|
+
cert.serialNumber = generateSerial();
|
|
39
|
+
cert.validity.notBefore = new Date();
|
|
40
|
+
cert.validity.notAfter = new Date();
|
|
41
|
+
cert.validity.notAfter.setFullYear(cert.validity.notAfter.getFullYear() + 10);
|
|
42
|
+
|
|
43
|
+
const attrs = [
|
|
44
|
+
{ name: 'commonName', value: 'SmartContext Proxy CA' },
|
|
45
|
+
{ name: 'organizationName', value: 'SmartContext' },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
cert.setSubject(attrs);
|
|
49
|
+
cert.setIssuer(attrs);
|
|
50
|
+
|
|
51
|
+
cert.setExtensions([
|
|
52
|
+
{ name: 'basicConstraints', cA: true, critical: true },
|
|
53
|
+
{ name: 'keyUsage', keyCertSign: true, cRLSign: true, critical: true },
|
|
54
|
+
{ name: 'subjectKeyIdentifier' },
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
cert.sign(keys.privateKey, forge.md.sha256.create());
|
|
58
|
+
|
|
59
|
+
const certPem = forge.pki.certificateToPem(cert);
|
|
60
|
+
const keyPem = forge.pki.privateKeyToPem(keys.privateKey);
|
|
61
|
+
|
|
62
|
+
fs.writeFileSync(CA_CERT_PATH, certPem);
|
|
63
|
+
fs.writeFileSync(CA_KEY_PATH, keyPem, { mode: 0o600 });
|
|
64
|
+
|
|
65
|
+
cachedCA = { cert, key: keys.privateKey };
|
|
66
|
+
|
|
67
|
+
return { cert: certPem, key: keyPem };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Generate a TLS certificate for a specific hostname, signed by our CA */
|
|
71
|
+
export function getCertForHost(hostname: string): CertPair {
|
|
72
|
+
// Check memory cache
|
|
73
|
+
const cached = hostCertCache.get(hostname);
|
|
74
|
+
if (cached) return cached;
|
|
75
|
+
|
|
76
|
+
// Check disk cache
|
|
77
|
+
const certPath = path.join(HOSTS_DIR, `${hostname}.crt`);
|
|
78
|
+
const keyPath = path.join(HOSTS_DIR, `${hostname}.key`);
|
|
79
|
+
|
|
80
|
+
if (fs.existsSync(certPath) && fs.existsSync(keyPath)) {
|
|
81
|
+
const pair = {
|
|
82
|
+
cert: fs.readFileSync(certPath, 'utf-8'),
|
|
83
|
+
key: fs.readFileSync(keyPath, 'utf-8'),
|
|
84
|
+
};
|
|
85
|
+
hostCertCache.set(hostname, pair);
|
|
86
|
+
return pair;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Generate new cert
|
|
90
|
+
if (!cachedCA) ensureCA();
|
|
91
|
+
|
|
92
|
+
const keys = forge.pki.rsa.generateKeyPair(2048);
|
|
93
|
+
const cert = forge.pki.createCertificate();
|
|
94
|
+
|
|
95
|
+
cert.publicKey = keys.publicKey;
|
|
96
|
+
cert.serialNumber = generateSerial();
|
|
97
|
+
cert.validity.notBefore = new Date();
|
|
98
|
+
cert.validity.notAfter = new Date();
|
|
99
|
+
cert.validity.notAfter.setFullYear(cert.validity.notAfter.getFullYear() + 1);
|
|
100
|
+
|
|
101
|
+
cert.setSubject([{ name: 'commonName', value: hostname }]);
|
|
102
|
+
cert.setIssuer(cachedCA!.cert.subject.attributes);
|
|
103
|
+
|
|
104
|
+
cert.setExtensions([
|
|
105
|
+
{ name: 'basicConstraints', cA: false },
|
|
106
|
+
{ name: 'keyUsage', digitalSignature: true, keyEncipherment: true },
|
|
107
|
+
{ name: 'extKeyUsage', serverAuth: true },
|
|
108
|
+
{
|
|
109
|
+
name: 'subjectAltName',
|
|
110
|
+
altNames: [
|
|
111
|
+
{ type: 2, value: hostname }, // DNS
|
|
112
|
+
...(isIP(hostname) ? [{ type: 7, ip: hostname }] : []),
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
cert.sign(cachedCA!.key, forge.md.sha256.create());
|
|
118
|
+
|
|
119
|
+
const certPem = forge.pki.certificateToPem(cert);
|
|
120
|
+
const keyPem = forge.pki.privateKeyToPem(keys.privateKey);
|
|
121
|
+
|
|
122
|
+
fs.writeFileSync(certPath, certPem);
|
|
123
|
+
fs.writeFileSync(keyPath, keyPem, { mode: 0o600 });
|
|
124
|
+
|
|
125
|
+
const pair = { cert: certPem, key: keyPem };
|
|
126
|
+
hostCertCache.set(hostname, pair);
|
|
127
|
+
return pair;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function getCACertPath(): string {
|
|
131
|
+
return CA_CERT_PATH;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function generateSerial(): string {
|
|
135
|
+
return Date.now().toString(16) + Math.random().toString(16).slice(2, 10);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isIP(str: string): boolean {
|
|
139
|
+
return /^\d{1,3}(\.\d{1,3}){3}$/.test(str) || str.includes(':');
|
|
140
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { getCACertPath } from './ca-manager.js';
|
|
5
|
+
|
|
6
|
+
const CA_NAME = 'SmartContext Proxy CA';
|
|
7
|
+
|
|
8
|
+
export interface TrustStoreResult {
|
|
9
|
+
success: boolean;
|
|
10
|
+
message: string;
|
|
11
|
+
requiresSudo: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Install CA cert into system trust store */
|
|
15
|
+
export function installCA(): TrustStoreResult {
|
|
16
|
+
const certPath = getCACertPath();
|
|
17
|
+
if (!fs.existsSync(certPath)) {
|
|
18
|
+
return { success: false, message: 'CA cert not found. Run ensureCA() first.', requiresSudo: false };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (process.platform === 'darwin') {
|
|
22
|
+
return installMacOS(certPath);
|
|
23
|
+
} else if (process.platform === 'linux') {
|
|
24
|
+
return installLinux(certPath);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return { success: false, message: `Unsupported platform: ${process.platform}`, requiresSudo: false };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Remove CA cert from system trust store */
|
|
31
|
+
export function uninstallCA(): TrustStoreResult {
|
|
32
|
+
if (process.platform === 'darwin') {
|
|
33
|
+
return uninstallMacOS();
|
|
34
|
+
} else if (process.platform === 'linux') {
|
|
35
|
+
return uninstallLinux();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { success: false, message: `Unsupported platform: ${process.platform}`, requiresSudo: false };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Check if CA is installed in trust store */
|
|
42
|
+
export function isCAInstalled(): boolean {
|
|
43
|
+
if (process.platform === 'darwin') {
|
|
44
|
+
try {
|
|
45
|
+
const result = execFileSync('security', [
|
|
46
|
+
'find-certificate', '-c', CA_NAME, '-Z',
|
|
47
|
+
'/Library/Keychains/System.keychain',
|
|
48
|
+
], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
49
|
+
return result.includes(CA_NAME);
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
} else if (process.platform === 'linux') {
|
|
54
|
+
const dest = '/usr/local/share/ca-certificates/smartcontext-ca.crt';
|
|
55
|
+
return fs.existsSync(dest);
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function installMacOS(certPath: string): TrustStoreResult {
|
|
61
|
+
try {
|
|
62
|
+
// Add to system keychain (requires admin)
|
|
63
|
+
execFileSync('sudo', [
|
|
64
|
+
'security', 'add-trusted-cert',
|
|
65
|
+
'-d', '-r', 'trustRoot',
|
|
66
|
+
'-k', '/Library/Keychains/System.keychain',
|
|
67
|
+
certPath,
|
|
68
|
+
], { stdio: 'pipe' });
|
|
69
|
+
|
|
70
|
+
return { success: true, message: 'CA installed in macOS System Keychain', requiresSudo: true };
|
|
71
|
+
} catch (err) {
|
|
72
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
73
|
+
if (msg.includes('sudo') || msg.includes('password')) {
|
|
74
|
+
return { success: false, message: 'Requires sudo. Run: sudo smartcontext-proxy install', requiresSudo: true };
|
|
75
|
+
}
|
|
76
|
+
return { success: false, message: `Failed to install CA: ${msg}`, requiresSudo: true };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function uninstallMacOS(): TrustStoreResult {
|
|
81
|
+
try {
|
|
82
|
+
execFileSync('sudo', [
|
|
83
|
+
'security', 'remove-trusted-cert',
|
|
84
|
+
'-d', getCACertPath(),
|
|
85
|
+
], { stdio: 'pipe' });
|
|
86
|
+
|
|
87
|
+
return { success: true, message: 'CA removed from macOS System Keychain', requiresSudo: true };
|
|
88
|
+
} catch (err) {
|
|
89
|
+
// Try alternative: delete by name
|
|
90
|
+
try {
|
|
91
|
+
execFileSync('sudo', [
|
|
92
|
+
'security', 'delete-certificate',
|
|
93
|
+
'-c', CA_NAME,
|
|
94
|
+
'/Library/Keychains/System.keychain',
|
|
95
|
+
], { stdio: 'pipe' });
|
|
96
|
+
return { success: true, message: 'CA removed from macOS System Keychain', requiresSudo: true };
|
|
97
|
+
} catch {
|
|
98
|
+
return { success: false, message: `Failed to remove CA: ${err}`, requiresSudo: true };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function installLinux(certPath: string): TrustStoreResult {
|
|
104
|
+
const dest = '/usr/local/share/ca-certificates/smartcontext-ca.crt';
|
|
105
|
+
try {
|
|
106
|
+
execFileSync('sudo', ['cp', certPath, dest], { stdio: 'pipe' });
|
|
107
|
+
execFileSync('sudo', ['update-ca-certificates'], { stdio: 'pipe' });
|
|
108
|
+
return { success: true, message: 'CA installed in Linux trust store', requiresSudo: true };
|
|
109
|
+
} catch (err) {
|
|
110
|
+
return { success: false, message: `Failed: ${err}`, requiresSudo: true };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function uninstallLinux(): TrustStoreResult {
|
|
115
|
+
const dest = '/usr/local/share/ca-certificates/smartcontext-ca.crt';
|
|
116
|
+
try {
|
|
117
|
+
execFileSync('sudo', ['rm', '-f', dest], { stdio: 'pipe' });
|
|
118
|
+
execFileSync('sudo', ['update-ca-certificates', '--fresh'], { stdio: 'pipe' });
|
|
119
|
+
return { success: true, message: 'CA removed from Linux trust store', requiresSudo: true };
|
|
120
|
+
} catch (err) {
|
|
121
|
+
return { success: false, message: `Failed: ${err}`, requiresSudo: true };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { execFile, type ChildProcess } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
|
|
5
|
+
let trayProcess: ChildProcess | null = null;
|
|
6
|
+
|
|
7
|
+
const TRAY_BINARY_DIR = path.join(__dirname, '..', '..', 'native', 'macos', 'SmartContextTray');
|
|
8
|
+
const TRAY_BUILD_DIR = path.join(TRAY_BINARY_DIR, '.build', 'release');
|
|
9
|
+
const TRAY_BINARY = path.join(TRAY_BUILD_DIR, 'SmartContextTray');
|
|
10
|
+
|
|
11
|
+
/** Build the native tray app if not already built */
|
|
12
|
+
export function buildTray(): boolean {
|
|
13
|
+
if (process.platform !== 'darwin') return false;
|
|
14
|
+
|
|
15
|
+
if (fs.existsSync(TRAY_BINARY)) return true;
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const { execFileSync } = require('node:child_process');
|
|
19
|
+
execFileSync('swift', ['build', '-c', 'release'], {
|
|
20
|
+
cwd: TRAY_BINARY_DIR,
|
|
21
|
+
stdio: 'pipe',
|
|
22
|
+
timeout: 60000,
|
|
23
|
+
});
|
|
24
|
+
return fs.existsSync(TRAY_BINARY);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
console.log(`Tray build failed: ${err}`);
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Launch the tray app */
|
|
32
|
+
export function startTray(): boolean {
|
|
33
|
+
if (process.platform !== 'darwin') return false;
|
|
34
|
+
if (trayProcess) return true;
|
|
35
|
+
|
|
36
|
+
if (!buildTray()) {
|
|
37
|
+
console.log('Tray app not available (build failed or not macOS)');
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
trayProcess = execFile(TRAY_BINARY, [], {}, (err) => {
|
|
42
|
+
if (err) console.log(`Tray exited: ${err.message}`);
|
|
43
|
+
trayProcess = null;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
trayProcess.unref();
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Stop the tray app */
|
|
51
|
+
export function stopTray(): void {
|
|
52
|
+
if (trayProcess) {
|
|
53
|
+
trayProcess.kill('SIGTERM');
|
|
54
|
+
trayProcess = null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Check if tray is running */
|
|
59
|
+
export function isTrayRunning(): boolean {
|
|
60
|
+
return trayProcess !== null && !trayProcess.killed;
|
|
61
|
+
}
|
package/src/ui/dashboard.ts
CHANGED
|
@@ -1,17 +1,28 @@
|
|
|
1
1
|
import type { MetricsCollector } from '../metrics/collector.js';
|
|
2
|
-
import type { StorageAdapter } from '../storage/types.js';
|
|
3
2
|
|
|
4
|
-
export
|
|
3
|
+
export interface DashboardState {
|
|
4
|
+
paused: boolean;
|
|
5
|
+
mode: 'transparent' | 'optimizing';
|
|
6
|
+
proxyType: 'connect' | 'legacy';
|
|
7
|
+
abTestEnabled: boolean;
|
|
8
|
+
debugHeaders: boolean;
|
|
9
|
+
caInstalled: boolean;
|
|
10
|
+
systemProxyActive: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function renderDashboard(metrics: MetricsCollector, state: DashboardState): string {
|
|
5
14
|
const stats = metrics.getStats();
|
|
6
15
|
const recent = metrics.getRecent(20);
|
|
7
|
-
const
|
|
8
|
-
const
|
|
16
|
+
const uptimeStr = formatDuration(metrics.getUptime());
|
|
17
|
+
const savingsAmount = estimateCostSaved(stats.totalOriginalTokens - stats.totalOptimizedTokens);
|
|
9
18
|
|
|
10
|
-
const
|
|
19
|
+
const stateBadge = state.paused
|
|
11
20
|
? '<span class="badge paused">PAUSED</span>'
|
|
12
21
|
: '<span class="badge running">RUNNING</span>';
|
|
13
22
|
|
|
14
|
-
const
|
|
23
|
+
const modeBadge = state.mode === 'optimizing'
|
|
24
|
+
? '<span class="badge opt">OPTIMIZING</span>'
|
|
25
|
+
: '<span class="badge transparent">TRANSPARENT</span>';
|
|
15
26
|
|
|
16
27
|
return `<!DOCTYPE html>
|
|
17
28
|
<html lang="en">
|
|
@@ -24,17 +35,23 @@ export function renderDashboard(metrics: MetricsCollector, paused: boolean): str
|
|
|
24
35
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; background: #0f1117; color: #e1e4e8; }
|
|
25
36
|
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
|
26
37
|
header { display: flex; justify-content: space-between; align-items: center; padding: 16px 0; border-bottom: 1px solid #21262d; margin-bottom: 24px; }
|
|
27
|
-
h1 { font-size: 20px; font-weight: 600; }
|
|
38
|
+
h1 { font-size: 20px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
|
|
28
39
|
h2 { font-size: 16px; font-weight: 600; margin-bottom: 12px; color: #8b949e; }
|
|
29
|
-
.badge { padding: 4px 12px; border-radius: 12px; font-size:
|
|
40
|
+
.badge { padding: 4px 12px; border-radius: 12px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
30
41
|
.badge.running { background: #238636; color: #fff; }
|
|
31
42
|
.badge.paused { background: #d29922; color: #000; }
|
|
32
|
-
.
|
|
33
|
-
.
|
|
34
|
-
.
|
|
43
|
+
.badge.opt { background: #1f6feb; color: #fff; }
|
|
44
|
+
.badge.transparent { background: #30363d; color: #8b949e; }
|
|
45
|
+
.badge.on { background: #238636; color: #fff; }
|
|
46
|
+
.badge.off { background: #30363d; color: #8b949e; }
|
|
47
|
+
.controls { display: flex; gap: 8px; align-items: center; }
|
|
48
|
+
.btn { padding: 6px 16px; border-radius: 6px; border: 1px solid #30363d; background: #21262d; color: #e1e4e8; cursor: pointer; font-size: 13px; transition: all 0.15s; }
|
|
49
|
+
.btn:hover { background: #30363d; border-color: #484f58; }
|
|
35
50
|
.btn.primary { background: #238636; border-color: #238636; }
|
|
51
|
+
.btn.primary:hover { background: #2ea043; }
|
|
36
52
|
.btn.warn { background: #d29922; border-color: #d29922; color: #000; }
|
|
37
|
-
.
|
|
53
|
+
.btn.danger { background: #da3633; border-color: #da3633; }
|
|
54
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
|
38
55
|
.card { background: #161b22; border: 1px solid #21262d; border-radius: 8px; padding: 20px; }
|
|
39
56
|
.card .value { font-size: 32px; font-weight: 700; color: #58a6ff; }
|
|
40
57
|
.card .label { font-size: 13px; color: #8b949e; margin-top: 4px; }
|
|
@@ -50,20 +67,46 @@ export function renderDashboard(metrics: MetricsCollector, paused: boolean): str
|
|
|
50
67
|
.tab.active { color: #e1e4e8; border-bottom-color: #58a6ff; }
|
|
51
68
|
.tab-content { display: none; }
|
|
52
69
|
.tab-content.active { display: block; }
|
|
70
|
+
.settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
71
|
+
.setting { background: #161b22; border: 1px solid #21262d; border-radius: 8px; padding: 16px; display: flex; justify-content: space-between; align-items: center; }
|
|
72
|
+
.setting-info { flex: 1; }
|
|
73
|
+
.setting-name { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
|
|
74
|
+
.setting-desc { font-size: 12px; color: #8b949e; }
|
|
75
|
+
.toggle { position: relative; width: 44px; height: 24px; cursor: pointer; }
|
|
76
|
+
.toggle input { display: none; }
|
|
77
|
+
.toggle .slider { position: absolute; inset: 0; background: #30363d; border-radius: 12px; transition: 0.2s; }
|
|
78
|
+
.toggle .slider:before { content: ''; position: absolute; width: 18px; height: 18px; left: 3px; top: 3px; background: #8b949e; border-radius: 50%; transition: 0.2s; }
|
|
79
|
+
.toggle input:checked + .slider { background: #238636; }
|
|
80
|
+
.toggle input:checked + .slider:before { transform: translateX(20px); background: #fff; }
|
|
81
|
+
.status-row { display: flex; gap: 16px; margin-bottom: 16px; flex-wrap: wrap; }
|
|
82
|
+
.status-pill { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #8b949e; }
|
|
83
|
+
.dot { width: 8px; height: 8px; border-radius: 50%; }
|
|
84
|
+
.dot.green { background: #3fb950; }
|
|
85
|
+
.dot.yellow { background: #d29922; }
|
|
86
|
+
.dot.red { background: #f85149; }
|
|
87
|
+
.dot.gray { background: #484f58; }
|
|
53
88
|
.refresh-note { font-size: 11px; color: #484f58; text-align: right; margin-top: 8px; }
|
|
54
89
|
</style>
|
|
55
90
|
</head>
|
|
56
91
|
<body>
|
|
57
92
|
<div class="container">
|
|
58
93
|
<header>
|
|
59
|
-
<h1>SmartContext Proxy ${
|
|
94
|
+
<h1>SmartContext Proxy ${stateBadge} ${modeBadge}</h1>
|
|
60
95
|
<div class="controls">
|
|
61
|
-
${paused
|
|
96
|
+
${state.paused
|
|
62
97
|
? '<button class="btn primary" onclick="api(\'/_sc/resume\')">Resume</button>'
|
|
63
98
|
: '<button class="btn warn" onclick="api(\'/_sc/pause\')">Pause</button>'}
|
|
64
99
|
</div>
|
|
65
100
|
</header>
|
|
66
101
|
|
|
102
|
+
<div class="status-row">
|
|
103
|
+
<div class="status-pill"><div class="dot ${state.caInstalled ? 'green' : 'red'}"></div>CA Certificate</div>
|
|
104
|
+
<div class="status-pill"><div class="dot ${state.systemProxyActive ? 'green' : 'gray'}"></div>System Proxy</div>
|
|
105
|
+
<div class="status-pill"><div class="dot ${state.mode === 'optimizing' ? 'green' : 'gray'}"></div>Context Optimization</div>
|
|
106
|
+
<div class="status-pill"><div class="dot ${state.abTestEnabled ? 'yellow' : 'gray'}"></div>A/B Test</div>
|
|
107
|
+
<div class="status-pill"><div class="dot ${state.proxyType === 'connect' ? 'green' : 'gray'}"></div>${state.proxyType === 'connect' ? 'Transparent' : 'Legacy'} Mode</div>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
67
110
|
<div class="grid">
|
|
68
111
|
<div class="card savings">
|
|
69
112
|
<div class="value">$${savingsAmount}</div>
|
|
@@ -84,9 +127,10 @@ export function renderDashboard(metrics: MetricsCollector, paused: boolean): str
|
|
|
84
127
|
</div>
|
|
85
128
|
|
|
86
129
|
<div class="tab-bar">
|
|
87
|
-
<div class="tab active" onclick="switchTab('feed')">Live Feed</div>
|
|
88
|
-
<div class="tab" onclick="switchTab('providers')">By Provider</div>
|
|
89
|
-
<div class="tab" onclick="switchTab('models')">By Model</div>
|
|
130
|
+
<div class="tab active" onclick="switchTab('feed',this)">Live Feed</div>
|
|
131
|
+
<div class="tab" onclick="switchTab('providers',this)">By Provider</div>
|
|
132
|
+
<div class="tab" onclick="switchTab('models',this)">By Model</div>
|
|
133
|
+
<div class="tab" onclick="switchTab('settings',this)">Settings</div>
|
|
90
134
|
</div>
|
|
91
135
|
|
|
92
136
|
<div id="tab-feed" class="tab-content active">
|
|
@@ -116,12 +160,7 @@ export function renderDashboard(metrics: MetricsCollector, paused: boolean): str
|
|
|
116
160
|
<thead><tr><th>Provider</th><th>Requests</th><th>Tokens Saved</th><th>Savings %</th></tr></thead>
|
|
117
161
|
<tbody>
|
|
118
162
|
${Object.entries(stats.byProvider).map(([name, s]) => `
|
|
119
|
-
<tr>
|
|
120
|
-
<td>${name}</td>
|
|
121
|
-
<td class="mono">${s.requests}</td>
|
|
122
|
-
<td class="mono">${formatTokens(s.tokensSaved)}</td>
|
|
123
|
-
<td class="savings-pct">${s.savingsPercent}%</td>
|
|
124
|
-
</tr>
|
|
163
|
+
<tr><td>${name}</td><td class="mono">${s.requests}</td><td class="mono">${formatTokens(s.tokensSaved)}</td><td class="savings-pct">${s.savingsPercent}%</td></tr>
|
|
125
164
|
`).join('')}
|
|
126
165
|
</tbody>
|
|
127
166
|
</table>
|
|
@@ -133,31 +172,88 @@ export function renderDashboard(metrics: MetricsCollector, paused: boolean): str
|
|
|
133
172
|
<thead><tr><th>Model</th><th>Requests</th><th>Tokens Saved</th><th>Savings %</th></tr></thead>
|
|
134
173
|
<tbody>
|
|
135
174
|
${Object.entries(stats.byModel).map(([name, s]) => `
|
|
136
|
-
<tr>
|
|
137
|
-
<td>${name}</td>
|
|
138
|
-
<td class="mono">${s.requests}</td>
|
|
139
|
-
<td class="mono">${formatTokens(s.tokensSaved)}</td>
|
|
140
|
-
<td class="savings-pct">${s.savingsPercent}%</td>
|
|
141
|
-
</tr>
|
|
175
|
+
<tr><td>${name}</td><td class="mono">${s.requests}</td><td class="mono">${formatTokens(s.tokensSaved)}</td><td class="savings-pct">${s.savingsPercent}%</td></tr>
|
|
142
176
|
`).join('')}
|
|
143
177
|
</tbody>
|
|
144
178
|
</table>
|
|
145
179
|
</div>
|
|
146
180
|
|
|
147
|
-
<div
|
|
181
|
+
<div id="tab-settings" class="tab-content">
|
|
182
|
+
<h2>Controls</h2>
|
|
183
|
+
<div class="settings-grid">
|
|
184
|
+
<div class="setting">
|
|
185
|
+
<div class="setting-info">
|
|
186
|
+
<div class="setting-name">Context Optimization</div>
|
|
187
|
+
<div class="setting-desc">Optimize LLM context windows to reduce token usage</div>
|
|
188
|
+
</div>
|
|
189
|
+
<label class="toggle">
|
|
190
|
+
<input type="checkbox" ${!state.paused ? 'checked' : ''} onchange="api(this.checked ? '/_sc/resume' : '/_sc/pause')">
|
|
191
|
+
<span class="slider"></span>
|
|
192
|
+
</label>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<div class="setting">
|
|
196
|
+
<div class="setting-info">
|
|
197
|
+
<div class="setting-name">A/B Test Mode</div>
|
|
198
|
+
<div class="setting-desc">Send each request twice to compare quality</div>
|
|
199
|
+
</div>
|
|
200
|
+
<label class="toggle">
|
|
201
|
+
<input type="checkbox" ${state.abTestEnabled ? 'checked' : ''} onchange="api(this.checked ? '/_sc/ab-test/enable' : '/_sc/ab-test/disable')">
|
|
202
|
+
<span class="slider"></span>
|
|
203
|
+
</label>
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
<div class="setting">
|
|
207
|
+
<div class="setting-info">
|
|
208
|
+
<div class="setting-name">Debug Headers</div>
|
|
209
|
+
<div class="setting-desc">Add X-SmartContext-* headers to responses</div>
|
|
210
|
+
</div>
|
|
211
|
+
<label class="toggle">
|
|
212
|
+
<input type="checkbox" ${state.debugHeaders ? 'checked' : ''} onchange="api(this.checked ? '/_sc/debug-headers/enable' : '/_sc/debug-headers/disable')">
|
|
213
|
+
<span class="slider"></span>
|
|
214
|
+
</label>
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
<div class="setting">
|
|
218
|
+
<div class="setting-info">
|
|
219
|
+
<div class="setting-name">System Proxy</div>
|
|
220
|
+
<div class="setting-desc">Auto-intercept all LLM traffic system-wide</div>
|
|
221
|
+
</div>
|
|
222
|
+
<label class="toggle">
|
|
223
|
+
<input type="checkbox" ${state.systemProxyActive ? 'checked' : ''} onchange="api(this.checked ? '/_sc/system-proxy/enable' : '/_sc/system-proxy/disable')">
|
|
224
|
+
<span class="slider"></span>
|
|
225
|
+
</label>
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
<div class="refresh-note">v0.2.0 | Uptime: ${uptimeStr} | Auto-refresh: 5s</div>
|
|
148
231
|
</div>
|
|
149
232
|
<script>
|
|
150
|
-
function switchTab(name) {
|
|
233
|
+
function switchTab(name, el) {
|
|
151
234
|
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
|
|
152
235
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
153
236
|
document.getElementById('tab-' + name).classList.add('active');
|
|
154
|
-
|
|
237
|
+
if (el) el.classList.add('active');
|
|
238
|
+
location.hash = name;
|
|
155
239
|
}
|
|
156
240
|
async function api(path) {
|
|
157
241
|
await fetch(path, { method: 'POST' });
|
|
158
242
|
location.reload();
|
|
159
243
|
}
|
|
160
|
-
|
|
244
|
+
// Restore tab from URL hash
|
|
245
|
+
(function() {
|
|
246
|
+
var hash = location.hash.replace('#', '');
|
|
247
|
+
if (hash && document.getElementById('tab-' + hash)) {
|
|
248
|
+
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
|
|
249
|
+
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
250
|
+
document.getElementById('tab-' + hash).classList.add('active');
|
|
251
|
+
document.querySelectorAll('.tab').forEach(t => {
|
|
252
|
+
if (t.getAttribute('onclick') && t.getAttribute('onclick').indexOf("'" + hash + "'") !== -1) t.classList.add('active');
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
})();
|
|
256
|
+
setTimeout(() => { var h = location.hash; location.reload(); if (h) location.hash = h; }, 5000);
|
|
161
257
|
</script>
|
|
162
258
|
</body>
|
|
163
259
|
</html>`;
|
|
@@ -176,9 +272,7 @@ function formatDuration(ms: number): string {
|
|
|
176
272
|
return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
|
|
177
273
|
}
|
|
178
274
|
|
|
179
|
-
/** Rough cost estimate based on Anthropic/OpenAI pricing */
|
|
180
275
|
function estimateCostSaved(tokensSaved: number): string {
|
|
181
|
-
// Assume avg $15/1M input tokens (Opus pricing)
|
|
182
276
|
const cost = (tokensSaved / 1000000) * 15;
|
|
183
277
|
return cost.toFixed(2);
|
|
184
278
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
2
|
+
import type { Server } from 'node:http';
|
|
3
|
+
import type { RequestMetric } from '../metrics/collector.js';
|
|
4
|
+
|
|
5
|
+
let wss: WebSocketServer | null = null;
|
|
6
|
+
|
|
7
|
+
/** Attach WebSocket server to existing HTTP server */
|
|
8
|
+
export function attachWebSocketFeed(server: Server): void {
|
|
9
|
+
wss = new WebSocketServer({ server, path: '/_sc/ws' });
|
|
10
|
+
|
|
11
|
+
wss.on('connection', (ws) => {
|
|
12
|
+
ws.send(JSON.stringify({ type: 'connected', timestamp: Date.now() }));
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Broadcast a new request metric to all connected clients */
|
|
17
|
+
export function broadcastMetric(metric: RequestMetric): void {
|
|
18
|
+
if (!wss) return;
|
|
19
|
+
|
|
20
|
+
const data = JSON.stringify({ type: 'request', data: metric });
|
|
21
|
+
|
|
22
|
+
for (const client of wss.clients) {
|
|
23
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
24
|
+
client.send(data);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Get count of connected WebSocket clients */
|
|
30
|
+
export function getConnectedClients(): number {
|
|
31
|
+
return wss?.clients.size || 0;
|
|
32
|
+
}
|