owlservable 0.1.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/LICENSE +21 -0
- package/README.md +77 -0
- package/auto.js +2 -0
- package/dashboard.html +354 -0
- package/index.js +398 -0
- package/owl.png +0 -0
- package/package.json +29 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 A Harbs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# owlservable
|
|
2
|
+
|
|
3
|
+
One line. See every API call your app makes.
|
|
4
|
+
|
|
5
|
+
```js
|
|
6
|
+
import 'owlservable/auto'
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Open **http://localhost:4321** and watch requests arrive in real time.
|
|
10
|
+
|
|
11
|
+
No config. No dependencies. No build step. ~400 lines of vanilla Node.js.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install owlservable
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
Drop one import at the top of your entry file:
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
import 'owlservable/auto'
|
|
27
|
+
|
|
28
|
+
// ... rest of your app
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Every `fetch()`, `http.request()`, and `https.request()` your app makes will appear in the dashboard instantly.
|
|
32
|
+
|
|
33
|
+
## Manual init
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
import { init } from 'owlservable'
|
|
37
|
+
|
|
38
|
+
init({
|
|
39
|
+
port: 4321, // default: 4321
|
|
40
|
+
dashboard: true, // default: true
|
|
41
|
+
logging: false, // log to console, default: false
|
|
42
|
+
})
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Dashboard
|
|
46
|
+
|
|
47
|
+
- Live request log — method, status, latency, URL
|
|
48
|
+
- Color-coded status: green 2xx · yellow 3xx · red 4xx/5xx
|
|
49
|
+
- AI token counts for OpenAI, Anthropic, Gemini, Cohere — including streaming
|
|
50
|
+
- Optional log persistence (1h / 24h / 7d retention)
|
|
51
|
+
- Clear logs button wipes memory and the log file
|
|
52
|
+
|
|
53
|
+
## How it works
|
|
54
|
+
|
|
55
|
+
On import, `owlservable` monkey-patches `globalThis.fetch`, `node:http`, and `node:https`. Every outbound call passes through a thin wrapper that records timing and metadata, then forwards unchanged. A plain `http.createServer` serves one HTML file. Real-time updates arrive via Server-Sent Events — no WebSockets, no polling.
|
|
56
|
+
|
|
57
|
+
## Requirements
|
|
58
|
+
|
|
59
|
+
- Node.js 18+
|
|
60
|
+
- Zero runtime dependencies
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Security
|
|
65
|
+
|
|
66
|
+
**Development only.** `owlservable` exits immediately when `NODE_ENV=production`. Keep it in `devDependencies` and never bundle it into a production build.
|
|
67
|
+
|
|
68
|
+
- Dashboard binds to `127.0.0.1` only — not accessible over a network
|
|
69
|
+
- No authentication on the dashboard port
|
|
70
|
+
- Request bodies up to 32 KB are captured in memory — avoid secrets in request bodies
|
|
71
|
+
- `Authorization` headers are not captured
|
|
72
|
+
- API keys passed as query params will appear in the URL log
|
|
73
|
+
- When persistence is enabled, `.owlservable/log.ndjson` contains full request data — treat it as sensitive
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
MIT
|
package/auto.js
ADDED
package/dashboard.html
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<title>owlservable</title>
|
|
7
|
+
<link rel="icon" type="image/png" href="/owl.png">
|
|
8
|
+
<style>
|
|
9
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
10
|
+
:root{--bg:#0d1117;--surface:#161b22;--border:#444d56;--text:#e6edf3;--muted:#7d8590;--green:#3fb950;--yellow:#d29922;--red:#f85149;--mono:'Cascadia Code','Fira Code',ui-monospace,monospace}
|
|
11
|
+
body{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:12px;height:100vh;display:flex;flex-direction:column;overflow:hidden}
|
|
12
|
+
body.light{--bg:#fff;--surface:#f0f0f0;--border:#bbb;--text:#111;--muted:#666}
|
|
13
|
+
header{display:flex;align-items:center;gap:8px;padding:11px 18px;border-bottom:1px solid var(--border);flex-shrink:0;flex-wrap:wrap}
|
|
14
|
+
.logo{font-family:var(--mono);font-size:14px;font-weight:700;letter-spacing:-.5px;margin-right:4px}
|
|
15
|
+
.dot{width:7px;height:7px;border-radius:50%;background:var(--green);animation:pulse 2s ease-in-out infinite;flex-shrink:0}
|
|
16
|
+
.dot.off{background:var(--red);animation:none}.dot.paused{background:var(--yellow);animation:none}
|
|
17
|
+
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
|
|
18
|
+
.hstats{display:flex;align-items:center;gap:14px;border-left:1px solid var(--border);border-right:1px solid var(--border);padding:0 12px;margin:0 2px}
|
|
19
|
+
.hstats span{color:var(--muted);white-space:nowrap}
|
|
20
|
+
.hstats b{color:var(--text);font-family:var(--mono);font-weight:600}
|
|
21
|
+
#save-ctrl{display:flex;align-items:center;gap:5px}
|
|
22
|
+
button{padding:4px 10px;background:transparent;border:1px solid var(--border);border-radius:5px;color:var(--muted);cursor:pointer}
|
|
23
|
+
button:hover{border-color:var(--text);color:var(--text)}
|
|
24
|
+
button.paused{border-color:var(--yellow);color:var(--yellow)}
|
|
25
|
+
button.active{border-color:var(--green);color:var(--green)}
|
|
26
|
+
main{flex:1;overflow-y:auto;min-height:0}
|
|
27
|
+
table{width:100%;border-collapse:collapse;table-layout:fixed}
|
|
28
|
+
thead tr{position:sticky;top:0;background:var(--bg);border-bottom:1px solid var(--border);z-index:1}
|
|
29
|
+
th,.dl{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.07em;color:var(--muted)}
|
|
30
|
+
th{padding:7px 12px;text-align:left;white-space:nowrap;user-select:none}
|
|
31
|
+
td{padding:6px 12px;font-family:var(--mono);border-bottom:1px solid var(--border);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;vertical-align:middle}
|
|
32
|
+
.c-time{width:110px}.c-method{width:64px}.c-status{width:60px}.c-lat{width:76px}.c-tok{width:90px}
|
|
33
|
+
tr.req{cursor:pointer}
|
|
34
|
+
tr.req:hover td,tr.req.open td{background:var(--surface)}
|
|
35
|
+
.GET{color:var(--green)}.POST{color:var(--text)}.PUT,.PATCH{color:var(--yellow)}.DELETE{color:var(--red)}
|
|
36
|
+
.badge{display:inline-block;padding:1px 5px;border-radius:4px;font-weight:700;background:var(--surface)}
|
|
37
|
+
.s2{color:var(--green)}.s3{color:var(--yellow)}.s4,.s5{color:var(--red)}.s0{color:var(--muted)}
|
|
38
|
+
.muted{color:var(--muted)}
|
|
39
|
+
.tok{display:inline-flex;align-items:center;gap:3px;padding:1px 6px;border-radius:4px;font-weight:600;background:var(--surface);color:var(--green)}
|
|
40
|
+
.pending{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--border);animation:blink 1s ease-in-out infinite}
|
|
41
|
+
@keyframes blink{0%,100%{opacity:.25}50%{opacity:1}}
|
|
42
|
+
.new{animation:fi .3s}
|
|
43
|
+
@keyframes fi{from{background:var(--surface)}to{background:transparent}}
|
|
44
|
+
::-webkit-scrollbar{width:5px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
|
45
|
+
tr.detail td{padding:0;border-bottom:1px solid var(--border)}
|
|
46
|
+
.dp{padding:12px 18px 12px 44px;background:var(--bg);display:flex;flex-wrap:wrap;gap:16px;font-family:var(--mono)}
|
|
47
|
+
.ds{display:flex;flex-direction:column;gap:3px;min-width:120px}
|
|
48
|
+
.dw{flex:1 1 100%}
|
|
49
|
+
.dv{word-break:break-all;white-space:normal}
|
|
50
|
+
.dv.url{color:var(--muted)}
|
|
51
|
+
.tg{display:flex;gap:6px;margin-top:2px}
|
|
52
|
+
.tc{background:var(--surface);border:1px solid var(--border);border-radius:5px;padding:5px 10px;text-align:center;min-width:64px}
|
|
53
|
+
.tc .n{font-size:14px;font-weight:700}
|
|
54
|
+
.tc .l{font-size:10px;color:var(--muted);text-transform:uppercase}
|
|
55
|
+
.tc.in .n{color:var(--text)}.tc.out .n{color:var(--green)}.tc.tot .n{color:var(--yellow)}
|
|
56
|
+
.empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;height:200px;color:var(--muted)}
|
|
57
|
+
.empty h2{font-weight:500;color:var(--text)}
|
|
58
|
+
.empty code{font-family:var(--mono);background:var(--surface);border:1px solid var(--border);padding:5px 12px;border-radius:6px}
|
|
59
|
+
.msg-chain{display:flex;flex-direction:column;gap:3px;margin-top:6px}
|
|
60
|
+
.msg{display:flex;gap:10px;align-items:flex-start}
|
|
61
|
+
.msg-role{flex-shrink:0;font-size:10px;font-weight:700;text-transform:uppercase;padding:1px 5px;border-radius:3px;line-height:1.8;align-self:flex-start;background:var(--bg)}
|
|
62
|
+
.msg-role.user{color:var(--text)}.msg-role.assistant{color:var(--green)}
|
|
63
|
+
.msg-role.tool,.msg-role.tool_result{color:var(--yellow)}.msg-role.system{color:var(--muted)}
|
|
64
|
+
.msg-content{opacity:.8;word-break:break-all;white-space:normal;flex:1;line-height:1.5}
|
|
65
|
+
.tool-calls{display:flex;flex-direction:column;gap:6px;margin-top:6px}
|
|
66
|
+
.tool-call{padding:6px 10px;background:var(--surface);border:1px solid var(--border);border-radius:5px}
|
|
67
|
+
.tool-name{font-weight:700;color:var(--yellow);display:block;margin-bottom:3px}
|
|
68
|
+
.tool-args{color:var(--muted);word-break:break-all;white-space:pre-wrap;display:block}
|
|
69
|
+
.res-text{white-space:pre-wrap;word-break:break-word;line-height:1.6;opacity:.85}
|
|
70
|
+
details.raw-body{margin-top:2px}
|
|
71
|
+
details.raw-body>summary{cursor:pointer;user-select:none;list-style:none;display:flex;align-items:center;gap:4px}
|
|
72
|
+
details.raw-body>summary::before{content:'▶';font-size:8px;transition:transform .15s}
|
|
73
|
+
details.raw-body[open]>summary::before{transform:rotate(90deg)}
|
|
74
|
+
details.raw-body>summary:hover{color:var(--text)}
|
|
75
|
+
.raw-pre{color:var(--muted);white-space:pre-wrap;word-break:break-all;margin-top:6px;max-height:260px;overflow-y:auto;background:var(--surface);border:1px solid var(--border);border-radius:5px;padding:8px;line-height:1.5}
|
|
76
|
+
</style>
|
|
77
|
+
</head>
|
|
78
|
+
<body>
|
|
79
|
+
<header>
|
|
80
|
+
<img src="/owl.png" alt="" style="width:24px;height:24px;object-fit:contain;border-radius:4px;flex-shrink:0">
|
|
81
|
+
<span class="logo">owlservable</span>
|
|
82
|
+
<button id="btn-pause" title="Pause">⏸</button>
|
|
83
|
+
<span class="dot" id="dot"></span>
|
|
84
|
+
<div class="hstats">
|
|
85
|
+
<span>requests <b id="n-req">0</b></span>
|
|
86
|
+
<span>errors <b id="n-err">0</b></span>
|
|
87
|
+
<span>avg <b id="n-lat">—</b></span>
|
|
88
|
+
<span>tokens <b id="n-tok">0</b></span>
|
|
89
|
+
<span>mem <b id="n-store">0/500</b></span>
|
|
90
|
+
</div>
|
|
91
|
+
<div id="save-ctrl"></div>
|
|
92
|
+
<button id="btn-clear" style="margin-left:auto">Clear logs</button>
|
|
93
|
+
<button id="btn-theme" title="Toggle theme">☀</button>
|
|
94
|
+
</header>
|
|
95
|
+
<main>
|
|
96
|
+
<div id="log">
|
|
97
|
+
<div class="empty" id="empty">
|
|
98
|
+
<h2>Waiting for requests…</h2>
|
|
99
|
+
<code>import 'owlservable/auto'</code>
|
|
100
|
+
</div>
|
|
101
|
+
<table id="tbl" style="display:none">
|
|
102
|
+
<thead><tr>
|
|
103
|
+
<th class="c-time">Time</th>
|
|
104
|
+
<th class="c-method">Method</th>
|
|
105
|
+
<th class="c-status">Status</th>
|
|
106
|
+
<th class="c-lat">Latency</th>
|
|
107
|
+
<th class="c-tok">Tokens</th>
|
|
108
|
+
<th class="c-url">URL</th>
|
|
109
|
+
</tr></thead>
|
|
110
|
+
<tbody id="tbody"></tbody>
|
|
111
|
+
</table>
|
|
112
|
+
</div>
|
|
113
|
+
</main>
|
|
114
|
+
<script>
|
|
115
|
+
(function(){
|
|
116
|
+
var tbody=document.getElementById('tbody'),tbl=document.getElementById('tbl'),
|
|
117
|
+
empty=document.getElementById('empty'),dot=document.getElementById('dot'),
|
|
118
|
+
nReq=document.getElementById('n-req'),nErr=document.getElementById('n-err'),
|
|
119
|
+
nLat=document.getElementById('n-lat'),nTok=document.getElementById('n-tok'),
|
|
120
|
+
nStore=document.getElementById('n-store'),
|
|
121
|
+
btnPause=document.getElementById('btn-pause'),
|
|
122
|
+
saveCtrl=document.getElementById('save-ctrl');
|
|
123
|
+
|
|
124
|
+
var paused=false,pauseBuffer=[];
|
|
125
|
+
var total=0,errs=0,sumMs=0,totalTok=0,storeSize=0;
|
|
126
|
+
var byId={};
|
|
127
|
+
|
|
128
|
+
function sc(s){if(!s)return 's0';if(s>=500)return 's5';if(s>=400)return 's4';if(s>=300)return 's3';if(s>=200)return 's2';return 's0';}
|
|
129
|
+
function fms(ms){if(ms==null)return'—';return ms>=1000?(ms/1000).toFixed(2)+'s':ms+'ms';}
|
|
130
|
+
function fn(n){if(!n)return'0';if(n>=1e6)return(n/1e6).toFixed(1)+'M';if(n>=1000)return(n/1000).toFixed(1)+'k';return''+n;}
|
|
131
|
+
function ftime(ts){var d=new Date(ts);return d.getHours().toString().padStart(2,'0')+':'+d.getMinutes().toString().padStart(2,'0')+':'+d.getSeconds().toString().padStart(2,'0')+'.'+(d.getMilliseconds()+'').padStart(3,'0');}
|
|
132
|
+
function esc(s){return(''+s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
|
133
|
+
function splitUrl(u){try{var p=new URL(u);return{host:p.host,path:p.pathname+(p.search||'')};}catch(e){return{host:'',path:u};}}
|
|
134
|
+
|
|
135
|
+
function tokHtml(r){
|
|
136
|
+
if(r.tokens)return'<span class="tok">'+fn(r.tokens.total)+'</span>';
|
|
137
|
+
if(r.metaPending)return'<span class="pending" title="parsing response…"></span>';
|
|
138
|
+
return'<span class="muted">—</span>';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function msgContent(m){
|
|
142
|
+
if(typeof m.content==='string')return m.content;
|
|
143
|
+
if(Array.isArray(m.content))return m.content.map(function(c){
|
|
144
|
+
if(c.type==='text')return c.text||'';
|
|
145
|
+
if(c.type==='tool_use')return'[tool_use: '+c.name+'('+JSON.stringify(c.input||{}).slice(0,80)+')]';
|
|
146
|
+
if(c.type==='tool_result'){
|
|
147
|
+
var v=Array.isArray(c.content)?c.content.map(function(x){return x.text||'';}).join(''):String(c.content||'');
|
|
148
|
+
return'[tool_result: '+v.slice(0,80)+']';
|
|
149
|
+
}
|
|
150
|
+
return'['+(c.type||'?')+']';
|
|
151
|
+
}).join(' ');
|
|
152
|
+
if(m.tool_calls)return m.tool_calls.map(function(tc){
|
|
153
|
+
return'[call: '+(tc.function&&tc.function.name||'?')+'('+(tc.function&&tc.function.arguments||'').slice(0,80)+')]';
|
|
154
|
+
}).join(', ');
|
|
155
|
+
return String(m.content||'');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function msgChainHtml(messages){
|
|
159
|
+
if(!messages||!messages.length)return'';
|
|
160
|
+
var h='<div class="ds dw"><div class="dl">Messages ('+messages.length+')</div><div class="msg-chain">';
|
|
161
|
+
for(var i=0;i<messages.length;i++){
|
|
162
|
+
var m=messages[i],role=m.role||'unknown',text=msgContent(m);
|
|
163
|
+
var preview=text.slice(0,300)+(text.length>300?'…':'');
|
|
164
|
+
h+='<div class="msg"><span class="msg-role '+esc(role)+'">'+esc(role)+'</span><span class="msg-content">'+esc(preview)+'</span></div>';
|
|
165
|
+
}
|
|
166
|
+
return h+'</div></div>';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function toolCallsHtml(calls){
|
|
170
|
+
if(!calls||!calls.length)return'';
|
|
171
|
+
var h='<div class="ds dw"><div class="dl">Tool calls ('+calls.length+')</div><div class="tool-calls">';
|
|
172
|
+
for(var i=0;i<calls.length;i++){
|
|
173
|
+
var tc=calls[i];
|
|
174
|
+
var name=(tc.function&&tc.function.name)||tc.name||'?';
|
|
175
|
+
var args=(tc.function&&tc.function.arguments)||(tc.input?JSON.stringify(tc.input,null,2):'');
|
|
176
|
+
h+='<div class="tool-call"><span class="tool-name">'+esc(name)+'</span>';
|
|
177
|
+
if(args)h+='<span class="tool-args">'+esc(args.slice(0,400))+(args.length>400?'…':'')+'</span>';
|
|
178
|
+
h+='</div>';
|
|
179
|
+
}
|
|
180
|
+
return h+'</div></div>';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function rawBodyHtml(label,body){
|
|
184
|
+
if(!body)return'';
|
|
185
|
+
var pretty=body;try{pretty=JSON.stringify(JSON.parse(body),null,2);}catch(_){}
|
|
186
|
+
return'<details class="ds dw raw-body"><summary class="dl">'+esc(label)+'</summary><pre class="raw-pre">'+esc(pretty)+'</pre></details>';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function detailHtml(r){
|
|
190
|
+
var h='<div class="dp">';
|
|
191
|
+
h+='<div class="ds dw"><div class="dl">URL</div><div class="dv url">'+esc(r.url||'—')+'</div></div>';
|
|
192
|
+
if(r.error)h+='<div class="ds"><div class="dl">Error</div><div class="dv" style="color:var(--red)">'+esc(r.error)+'</div></div>';
|
|
193
|
+
if(r.model)h+='<div class="ds"><div class="dl">Model</div><div class="dv">'+esc(r.model)+'</div></div>';
|
|
194
|
+
var reqJson=null,resJson=null;
|
|
195
|
+
try{if(r.reqBody)reqJson=JSON.parse(r.reqBody);}catch(_){}
|
|
196
|
+
try{if(r.resBody)resJson=JSON.parse(r.resBody);}catch(_){}
|
|
197
|
+
if(reqJson&&reqJson.messages)h+=msgChainHtml(reqJson.messages);
|
|
198
|
+
var toolCalls=null;
|
|
199
|
+
if(resJson){
|
|
200
|
+
if(resJson.choices&&resJson.choices[0]&&resJson.choices[0].message)
|
|
201
|
+
toolCalls=resJson.choices[0].message.tool_calls||null;
|
|
202
|
+
if(!toolCalls&&Array.isArray(resJson.content))
|
|
203
|
+
toolCalls=resJson.content.filter(function(c){return c.type==='tool_use';});
|
|
204
|
+
}
|
|
205
|
+
if(toolCalls&&toolCalls.length)h+=toolCallsHtml(toolCalls);
|
|
206
|
+
var responseText=null;
|
|
207
|
+
if(resJson){
|
|
208
|
+
if(resJson.choices&&resJson.choices[0]&&resJson.choices[0].message)
|
|
209
|
+
responseText=resJson.choices[0].message.content;
|
|
210
|
+
if(!responseText&&Array.isArray(resJson.content)){
|
|
211
|
+
var tb=resJson.content.find(function(c){return c.type==='text';});
|
|
212
|
+
if(tb)responseText=tb.text;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if(responseText&&typeof responseText==='string'&&responseText.trim())
|
|
216
|
+
h+='<div class="ds dw"><div class="dl">Response</div><div class="dv res-text">'+esc(responseText.slice(0,800))+(responseText.length>800?'…':'')+'</div></div>';
|
|
217
|
+
if(r.tokens){
|
|
218
|
+
h+='<div class="ds dw"><div class="dl">Token usage</div><div class="tg">';
|
|
219
|
+
h+='<div class="tc in"><div class="n">'+fn(r.tokens.input)+'</div><div class="l">Input</div></div>';
|
|
220
|
+
h+='<div class="tc out"><div class="n">'+fn(r.tokens.output)+'</div><div class="l">Output</div></div>';
|
|
221
|
+
h+='<div class="tc tot"><div class="n">'+fn(r.tokens.total)+'</div><div class="l">Total</div></div>';
|
|
222
|
+
h+='</div></div>';
|
|
223
|
+
}
|
|
224
|
+
h+=rawBodyHtml('Request body',r.reqBody);
|
|
225
|
+
h+=rawBodyHtml('Response body',r.resBody);
|
|
226
|
+
return h+'</div>';
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function addRow(r,prepend){
|
|
230
|
+
total++;if(!r.status||r.error)errs++;sumMs+=r.latency||0;
|
|
231
|
+
if(r.tokens)totalTok+=r.tokens.total||0;
|
|
232
|
+
storeSize=Math.min(storeSize+1,500);
|
|
233
|
+
nReq.textContent=total;nErr.textContent=errs;
|
|
234
|
+
nLat.textContent=fms(Math.round(sumMs/total));
|
|
235
|
+
nTok.textContent=fn(totalTok);nStore.textContent=storeSize+'/500';
|
|
236
|
+
var method=r.method||'GET',parts=splitUrl(r.url||'');
|
|
237
|
+
var sTxt=r.status||(r.error?'ERR':'—');
|
|
238
|
+
var tr=document.createElement('tr');tr.className='req new';tr.dataset.id=r.id;
|
|
239
|
+
tr.innerHTML=
|
|
240
|
+
'<td class="c-time muted">'+ftime(r.timestamp)+'</td>'+
|
|
241
|
+
'<td class="c-method"><span class="'+esc(method)+'">'+esc(method)+'</span></td>'+
|
|
242
|
+
'<td class="c-status"><span class="badge '+sc(r.status)+'">'+esc(sTxt)+'</span></td>'+
|
|
243
|
+
'<td class="c-lat muted">'+fms(r.latency||0)+'</td>'+
|
|
244
|
+
'<td class="c-tok">'+tokHtml(r)+'</td>'+
|
|
245
|
+
'<td class="c-url" title="'+esc(r.url||'')+'"><span class="muted">'+esc(parts.host)+'</span>'+esc(parts.path)+'</td>';
|
|
246
|
+
var dtd=document.createElement('td');dtd.colSpan=6;dtd.innerHTML=detailHtml(r);
|
|
247
|
+
var dtr=document.createElement('tr');dtr.className='detail';dtr.style.display='none';dtr.appendChild(dtd);
|
|
248
|
+
tr.addEventListener('click',function(){
|
|
249
|
+
var open=dtr.style.display!=='none';
|
|
250
|
+
dtr.style.display=open?'none':'';tr.classList.toggle('open',!open);
|
|
251
|
+
});
|
|
252
|
+
byId[r.id]={tr:tr,dtr:dtr,data:r};
|
|
253
|
+
if(prepend){tbody.insertBefore(dtr,tbody.firstChild);tbody.insertBefore(tr,tbody.firstChild);}
|
|
254
|
+
else{tbody.appendChild(tr);tbody.appendChild(dtr);}
|
|
255
|
+
if(tbl.style.display==='none'){tbl.style.display='';empty.style.display='none';}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function applyUpdate(id,patch){
|
|
259
|
+
var e=byId[id];if(!e)return;
|
|
260
|
+
Object.assign(e.data,patch);
|
|
261
|
+
if(patch.tokens){
|
|
262
|
+
totalTok+=patch.tokens.total||0;
|
|
263
|
+
nTok.textContent=fn(totalTok);
|
|
264
|
+
e.tr.querySelector('.c-tok').innerHTML=tokHtml(e.data);
|
|
265
|
+
}else if('metaPending' in patch&&!patch.metaPending){
|
|
266
|
+
e.tr.querySelector('.c-tok').innerHTML=tokHtml(e.data);
|
|
267
|
+
}
|
|
268
|
+
e.dtr.querySelector('td').innerHTML=detailHtml(e.data);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function reset(){
|
|
272
|
+
tbody.innerHTML='';byId={};
|
|
273
|
+
total=errs=sumMs=totalTok=storeSize=0;
|
|
274
|
+
nReq.textContent=nErr.textContent=nTok.textContent='0';
|
|
275
|
+
nLat.textContent='—';nStore.textContent='0/500';
|
|
276
|
+
tbl.style.display='none';empty.style.display='';
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function renderSave(cfg){
|
|
280
|
+
if(!saveCtrl)return;
|
|
281
|
+
var active=cfg&&cfg.enabled?cfg.retention:null;
|
|
282
|
+
var h='<span class="muted">save</span>';
|
|
283
|
+
[{ms:3600000,label:'1h'},{ms:86400000,label:'24h'},{ms:604800000,label:'7d'}].forEach(function(o){
|
|
284
|
+
h+='<button data-ms="'+o.ms+'"'+(active===o.ms?' class="active"':'')+'>'+o.label+'</button>';
|
|
285
|
+
});
|
|
286
|
+
saveCtrl.innerHTML=h;
|
|
287
|
+
saveCtrl.querySelectorAll('button[data-ms]').forEach(function(b){
|
|
288
|
+
b.addEventListener('click',function(){
|
|
289
|
+
var ms=+b.dataset.ms,isActive=b.classList.contains('active');
|
|
290
|
+
b.disabled=true;b.textContent='…';
|
|
291
|
+
var body=isActive?{action:'disable'}:{action:'enable',retention:ms};
|
|
292
|
+
fetch('/api/save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})
|
|
293
|
+
.then(function(r){return r.json();}).then(function(d){renderSave(d.save);})
|
|
294
|
+
.catch(function(){renderSave(cfg);});
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
document.getElementById('btn-clear').addEventListener('click',function(){
|
|
301
|
+
fetch('/api/clear',{method:'POST'}).catch(function(){});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
function handleMsg(msg){
|
|
305
|
+
if(msg.type==='init'){
|
|
306
|
+
msg.requests.slice().reverse().forEach(function(r){addRow(r,false);});
|
|
307
|
+
renderSave(msg.save);
|
|
308
|
+
}else if(msg.type==='request'){
|
|
309
|
+
addRow(msg.record,true);
|
|
310
|
+
}else if(msg.type==='update'){
|
|
311
|
+
applyUpdate(msg.id,msg.patch);
|
|
312
|
+
}else if(msg.type==='saveConfig'){
|
|
313
|
+
renderSave(msg.config);
|
|
314
|
+
}else if(msg.type==='reload'){
|
|
315
|
+
reset();
|
|
316
|
+
if(msg.requests)msg.requests.slice().reverse().forEach(function(r){addRow(r,false);});
|
|
317
|
+
if(msg.save)renderSave(msg.save);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
btnPause.addEventListener('click',function(){
|
|
322
|
+
paused=!paused;
|
|
323
|
+
if(paused){
|
|
324
|
+
btnPause.textContent='⏵';btnPause.title='Resume';dot.classList.add('paused');
|
|
325
|
+
}else{
|
|
326
|
+
pauseBuffer.forEach(handleMsg);pauseBuffer=[];
|
|
327
|
+
btnPause.textContent='⏸';btnPause.title='Pause';dot.classList.remove('paused');
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
document.getElementById('btn-theme').addEventListener('click',function(){
|
|
332
|
+
var light=document.body.classList.toggle('light');
|
|
333
|
+
this.textContent=light?'☾':'☀';
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
function connect(){
|
|
337
|
+
var es=new EventSource('/events');
|
|
338
|
+
es.addEventListener('open',function(){dot.classList.remove('off');});
|
|
339
|
+
es.addEventListener('error',function(){dot.classList.add('off');es.close();setTimeout(connect,2000);});
|
|
340
|
+
es.addEventListener('message',function(e){
|
|
341
|
+
var msg;try{msg=JSON.parse(e.data);}catch(_){return;}
|
|
342
|
+
if(paused&&(msg.type==='request'||msg.type==='update')){
|
|
343
|
+
pauseBuffer.push(msg);
|
|
344
|
+
btnPause.title='Resume ('+pauseBuffer.length+' buffered)';
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
handleMsg(msg);
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
connect();
|
|
351
|
+
})();
|
|
352
|
+
</script>
|
|
353
|
+
</body>
|
|
354
|
+
</html>
|
package/index.js
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import * as http from 'node:http'
|
|
4
|
+
import * as https from 'node:https'
|
|
5
|
+
import * as fs from 'node:fs'
|
|
6
|
+
import * as path from 'node:path'
|
|
7
|
+
|
|
8
|
+
const __dir = path.dirname(fileURLToPath(import.meta.url))
|
|
9
|
+
|
|
10
|
+
const MAX_REQ_BODY = 32 * 1024
|
|
11
|
+
const MAX_RES_BODY = 64 * 1024
|
|
12
|
+
const MAX_PARSE = 512 * 1024
|
|
13
|
+
|
|
14
|
+
const emitter = new EventEmitter()
|
|
15
|
+
emitter.setMaxListeners(100)
|
|
16
|
+
|
|
17
|
+
const requests = []
|
|
18
|
+
let nextId = 1
|
|
19
|
+
|
|
20
|
+
function addRequest(entry) {
|
|
21
|
+
try {
|
|
22
|
+
const r = { id: nextId++, ...entry, timestamp: Date.now() }
|
|
23
|
+
requests.push(r)
|
|
24
|
+
if (requests.length > 500) requests.shift()
|
|
25
|
+
emitter.emit('request', r)
|
|
26
|
+
if (saveState.enabled) persistLine(r)
|
|
27
|
+
return r
|
|
28
|
+
} catch (_) {}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function updateRequest(id, patch) {
|
|
32
|
+
try {
|
|
33
|
+
const r = requests.find(r => r.id === id)
|
|
34
|
+
if (r) {
|
|
35
|
+
Object.assign(r, patch)
|
|
36
|
+
emitter.emit('update', { id, patch })
|
|
37
|
+
if (saveState.enabled && (patch.tokens || patch.resBody)) persistLine(r)
|
|
38
|
+
}
|
|
39
|
+
} catch (_) {}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getRequests() { try { return requests.slice() } catch (_) { return [] } }
|
|
43
|
+
|
|
44
|
+
function clearRequests() {
|
|
45
|
+
requests.splice(0)
|
|
46
|
+
if (saveState.enabled) try { fs.writeFileSync(saveState.filePath, '') } catch (_) {}
|
|
47
|
+
emitter.emit('reload', { requests: [], save: getSaveInfo() })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function extractAiMeta(body) {
|
|
51
|
+
try {
|
|
52
|
+
const j = JSON.parse(body)
|
|
53
|
+
if (j.usage?.total_tokens != null)
|
|
54
|
+
return { model: j.model || null, tokens: { input: j.usage.prompt_tokens || 0, output: j.usage.completion_tokens || 0, total: j.usage.total_tokens } }
|
|
55
|
+
if (j.usage?.input_tokens != null) {
|
|
56
|
+
const i = j.usage.input_tokens || 0, o = j.usage.output_tokens || 0
|
|
57
|
+
return { model: j.model || null, tokens: { input: i, output: o, total: i + o } }
|
|
58
|
+
}
|
|
59
|
+
if (j.usageMetadata?.totalTokenCount != null) {
|
|
60
|
+
const m = j.usageMetadata
|
|
61
|
+
return { model: j.modelVersion || null, tokens: { input: m.promptTokenCount || 0, output: m.candidatesTokenCount || 0, total: m.totalTokenCount } }
|
|
62
|
+
}
|
|
63
|
+
if (j.meta?.billed_units) {
|
|
64
|
+
const b = j.meta.billed_units, i = b.input_tokens || 0, o = b.output_tokens || 0
|
|
65
|
+
if (i || o) return { model: null, tokens: { input: i, output: o, total: i + o } }
|
|
66
|
+
}
|
|
67
|
+
} catch (_) {}
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function extractAiMetaFromSSE(raw) {
|
|
72
|
+
try {
|
|
73
|
+
let input = 0, output = 0, model = null
|
|
74
|
+
for (const line of raw.split('\n')) {
|
|
75
|
+
if (!line.startsWith('data: ') || line === 'data: [DONE]') continue
|
|
76
|
+
try {
|
|
77
|
+
const j = JSON.parse(line.slice(6))
|
|
78
|
+
if (j.usage?.total_tokens != null)
|
|
79
|
+
return { model: j.model || model, tokens: { input: j.usage.prompt_tokens || 0, output: j.usage.completion_tokens || 0, total: j.usage.total_tokens } }
|
|
80
|
+
if (j.type === 'message_start' && j.message) {
|
|
81
|
+
if (j.message.usage?.input_tokens) input = j.message.usage.input_tokens
|
|
82
|
+
if (j.message.model) model = j.message.model
|
|
83
|
+
}
|
|
84
|
+
if (j.type === 'message_delta' && j.usage?.output_tokens)
|
|
85
|
+
output = j.usage.output_tokens
|
|
86
|
+
} catch (_) {}
|
|
87
|
+
}
|
|
88
|
+
if (input || output) return { model, tokens: { input, output, total: input + output } }
|
|
89
|
+
} catch (_) {}
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function capStr(s, max) {
|
|
94
|
+
if (!s || typeof s !== 'string') return null
|
|
95
|
+
return s.length <= max ? s : s.slice(0, max) + '…'
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function bodyFromInit(b) {
|
|
99
|
+
try {
|
|
100
|
+
if (typeof b === 'string') return capStr(b, MAX_REQ_BODY)
|
|
101
|
+
if (b instanceof URLSearchParams) return capStr(b.toString(), MAX_REQ_BODY)
|
|
102
|
+
if (b instanceof ArrayBuffer) return capStr(Buffer.from(b).toString('utf8'), MAX_REQ_BODY)
|
|
103
|
+
if (ArrayBuffer.isView(b)) return capStr(Buffer.from(b.buffer, b.byteOffset, b.byteLength).toString('utf8'), MAX_REQ_BODY)
|
|
104
|
+
} catch (_) {}
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let patched = false
|
|
109
|
+
|
|
110
|
+
function handleJsonResponse(record, getText) {
|
|
111
|
+
getText()
|
|
112
|
+
.then(body => {
|
|
113
|
+
const meta = extractAiMeta(body)
|
|
114
|
+
const update = { metaPending: false, resBody: capStr(body, MAX_RES_BODY) }
|
|
115
|
+
if (meta) Object.assign(update, meta)
|
|
116
|
+
updateRequest(record.id, update)
|
|
117
|
+
})
|
|
118
|
+
.catch(() => updateRequest(record.id, { metaPending: false }))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function handleSseResponse(record, getText) {
|
|
122
|
+
getText()
|
|
123
|
+
.then(body => {
|
|
124
|
+
const meta = extractAiMetaFromSSE(body)
|
|
125
|
+
const update = { metaPending: false }
|
|
126
|
+
if (meta) Object.assign(update, meta)
|
|
127
|
+
updateRequest(record.id, update)
|
|
128
|
+
})
|
|
129
|
+
.catch(() => updateRequest(record.id, { metaPending: false }))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function patchFetch() {
|
|
133
|
+
const orig = globalThis.fetch
|
|
134
|
+
globalThis.fetch = async function interceptedFetch(input, init) {
|
|
135
|
+
let url = 'unknown', method = 'GET', reqBody = null
|
|
136
|
+
try {
|
|
137
|
+
url = input instanceof Request ? input.url : String(input)
|
|
138
|
+
method = (init?.method || (input instanceof Request ? input.method : null) || 'GET').toUpperCase()
|
|
139
|
+
const rawBody = init?.body ?? null
|
|
140
|
+
if (typeof rawBody === 'string' && rawBody.includes('"stream"')) {
|
|
141
|
+
try {
|
|
142
|
+
const parsed = JSON.parse(rawBody)
|
|
143
|
+
if (parsed.stream === true && !parsed.stream_options?.include_usage) {
|
|
144
|
+
parsed.stream_options = { ...parsed.stream_options, include_usage: true }
|
|
145
|
+
init = { ...init, body: JSON.stringify(parsed) }
|
|
146
|
+
}
|
|
147
|
+
} catch (_) {}
|
|
148
|
+
}
|
|
149
|
+
reqBody = bodyFromInit(init?.body ?? null)
|
|
150
|
+
} catch (_) {}
|
|
151
|
+
|
|
152
|
+
const start = Date.now()
|
|
153
|
+
try {
|
|
154
|
+
const res = await orig.call(this, input, init)
|
|
155
|
+
const ct = res.headers.get('content-type') || ''
|
|
156
|
+
const isJson = ct.includes('application/json')
|
|
157
|
+
const isSse = ct.includes('text/event-stream')
|
|
158
|
+
const r = addRequest({ url, method, status: res.status, latency: Date.now() - start, reqBody, metaPending: isJson || isSse })
|
|
159
|
+
if (r && isJson) handleJsonResponse(r, () => res.clone().text())
|
|
160
|
+
if (r && isSse) handleSseResponse(r, () => res.clone().text())
|
|
161
|
+
return res
|
|
162
|
+
} catch (err) {
|
|
163
|
+
try { addRequest({ url, method, status: 0, latency: Date.now() - start, reqBody, error: err.message }) } catch (_) {}
|
|
164
|
+
throw err
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function patchHttpModule(mod, protocol) {
|
|
170
|
+
const orig = mod.request
|
|
171
|
+
mod.request = function interceptedRequest(...args) {
|
|
172
|
+
const start = Date.now()
|
|
173
|
+
let url = 'unknown', method = 'GET'
|
|
174
|
+
try {
|
|
175
|
+
const first = args[0]
|
|
176
|
+
if (typeof first === 'string') { url = first; method = args[1]?.method || 'GET' }
|
|
177
|
+
else if (first instanceof URL) { url = first.toString(); method = args[1]?.method || 'GET' }
|
|
178
|
+
else if (first && typeof first === 'object') {
|
|
179
|
+
url = `${protocol}://${first.hostname || first.host || 'localhost'}${first.port ? ':' + first.port : ''}${first.path || '/'}`
|
|
180
|
+
method = first.method || 'GET'
|
|
181
|
+
}
|
|
182
|
+
} catch (_) {}
|
|
183
|
+
|
|
184
|
+
const req = orig.apply(mod, args)
|
|
185
|
+
const reqChunks = []; let reqSize = 0
|
|
186
|
+
const origWrite = req.write
|
|
187
|
+
req.write = function(chunk, encoding, cb) {
|
|
188
|
+
try {
|
|
189
|
+
if (reqSize < MAX_REQ_BODY) {
|
|
190
|
+
const s = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : typeof chunk === 'string' ? chunk : ''
|
|
191
|
+
if (s) { reqChunks.push(s); reqSize += s.length }
|
|
192
|
+
}
|
|
193
|
+
} catch (_) {}
|
|
194
|
+
return origWrite.call(this, chunk, encoding, cb)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
req.once('response', res => {
|
|
199
|
+
const reqBody = reqSize > 0 ? reqChunks.join('').slice(0, MAX_REQ_BODY) : null
|
|
200
|
+
const ct = res.headers['content-type'] || ''
|
|
201
|
+
const isJson = ct.includes('application/json')
|
|
202
|
+
const isSse = ct.includes('text/event-stream')
|
|
203
|
+
const r = addRequest({ url, method: method.toUpperCase(), status: res.statusCode, latency: Date.now() - start, reqBody, metaPending: isJson || isSse })
|
|
204
|
+
if (r && (isJson || isSse)) {
|
|
205
|
+
try {
|
|
206
|
+
const chunks = []; let size = 0
|
|
207
|
+
res.on('data', chunk => { try { if (size < MAX_PARSE) { chunks.push(chunk); size += chunk.length } } catch (_) {} })
|
|
208
|
+
const getText = () => new Promise((resolve, reject) => {
|
|
209
|
+
res.once('end', () => { try { resolve(Buffer.concat(chunks).toString('utf8')) } catch (e) { reject(e) } })
|
|
210
|
+
res.once('error', reject)
|
|
211
|
+
})
|
|
212
|
+
if (isJson) handleJsonResponse(r, getText)
|
|
213
|
+
if (isSse) handleSseResponse(r, getText)
|
|
214
|
+
} catch (_) { updateRequest(r.id, { metaPending: false }) }
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
req.once('error', err => {
|
|
218
|
+
try { addRequest({ url, method: method.toUpperCase(), status: 0, latency: Date.now() - start, error: err.message }) } catch (_) {}
|
|
219
|
+
})
|
|
220
|
+
} catch (_) {}
|
|
221
|
+
|
|
222
|
+
return req
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function patch() {
|
|
227
|
+
if (patched) return; patched = true
|
|
228
|
+
try { if (typeof globalThis.fetch === 'function') patchFetch() } catch (_) {}
|
|
229
|
+
try { patchHttpModule(http, 'http') } catch (_) {}
|
|
230
|
+
try { patchHttpModule(https, 'https') } catch (_) {}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const saveState = { enabled: false, filePath: null, retention: null }
|
|
234
|
+
|
|
235
|
+
function getSaveInfo() {
|
|
236
|
+
if (!saveState.enabled) return { enabled: false }
|
|
237
|
+
return { enabled: true, filePath: saveState.filePath, relPath: path.relative(process.cwd(), saveState.filePath).replace(/\\/g, '/'), retention: saveState.retention }
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function persistLine(r) {
|
|
241
|
+
try { fs.appendFileSync(saveState.filePath, JSON.stringify(r) + '\n') } catch (_) {}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function parseLog(raw, cutoff) {
|
|
245
|
+
const byId = new Map()
|
|
246
|
+
for (const line of raw.split('\n')) {
|
|
247
|
+
try { const r = JSON.parse(line); if (r.id && r.timestamp > cutoff) byId.set(r.id, r) } catch (_) {}
|
|
248
|
+
}
|
|
249
|
+
return [...byId.values()].sort((a, b) => a.id - b.id)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function pruneFile() {
|
|
253
|
+
if (!saveState.filePath) return
|
|
254
|
+
try {
|
|
255
|
+
const raw = fs.readFileSync(saveState.filePath, 'utf8').trim()
|
|
256
|
+
if (!raw) return
|
|
257
|
+
const entries = parseLog(raw, Date.now() - saveState.retention)
|
|
258
|
+
const out = entries.map(JSON.stringify).join('\n')
|
|
259
|
+
fs.writeFileSync(saveState.filePath, out + (out ? '\n' : ''))
|
|
260
|
+
} catch (_) {}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function enableSave(retentionMs) {
|
|
264
|
+
const filePath = path.join(process.cwd(), '.owlservable', 'log.ndjson')
|
|
265
|
+
const dir = path.dirname(filePath)
|
|
266
|
+
try { fs.mkdirSync(dir, { recursive: true }) } catch (_) {}
|
|
267
|
+
try { const gi = path.join(dir, '.gitignore'); if (!fs.existsSync(gi)) fs.writeFileSync(gi, '*\n') } catch (_) {}
|
|
268
|
+
try { fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ save: true, retention: retentionMs })) } catch (_) {}
|
|
269
|
+
saveState.enabled = true
|
|
270
|
+
saveState.filePath = filePath
|
|
271
|
+
saveState.retention = retentionMs
|
|
272
|
+
try {
|
|
273
|
+
const raw = fs.readFileSync(filePath, 'utf8').trim()
|
|
274
|
+
if (raw) {
|
|
275
|
+
const loaded = parseLog(raw, Date.now() - retentionMs)
|
|
276
|
+
for (const r of loaded) {
|
|
277
|
+
if (!requests.find(x => x.id === r.id)) requests.push(r)
|
|
278
|
+
if (r.id >= nextId) nextId = r.id + 1
|
|
279
|
+
}
|
|
280
|
+
if (requests.length > 500) requests.splice(0, requests.length - 500)
|
|
281
|
+
}
|
|
282
|
+
} catch (_) {}
|
|
283
|
+
pruneFile()
|
|
284
|
+
emitter.emit('reload', { requests: getRequests(), save: getSaveInfo() })
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function disableSave() {
|
|
288
|
+
saveState.enabled = false
|
|
289
|
+
try { fs.unlinkSync(path.join(process.cwd(), '.owlservable', 'config.json')) } catch (_) {}
|
|
290
|
+
emitter.emit('saveConfig', getSaveInfo())
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
setInterval(() => { if (saveState.enabled) pruneFile() }, 3_600_000).unref()
|
|
294
|
+
|
|
295
|
+
let server = null
|
|
296
|
+
let DASHBOARD_HTML = null
|
|
297
|
+
|
|
298
|
+
function getDashboard() {
|
|
299
|
+
if (!DASHBOARD_HTML) DASHBOARD_HTML = fs.readFileSync(path.join(__dir, 'dashboard.html'), 'utf8')
|
|
300
|
+
return DASHBOARD_HTML
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function readBody(req) {
|
|
304
|
+
return new Promise((resolve, reject) => {
|
|
305
|
+
let body = ''
|
|
306
|
+
req.on('data', c => { body += c })
|
|
307
|
+
req.on('end', () => resolve(body))
|
|
308
|
+
req.on('error', reject)
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function startDashboard(port) {
|
|
313
|
+
if (server) return
|
|
314
|
+
|
|
315
|
+
server = http.createServer((req, res) => {
|
|
316
|
+
try {
|
|
317
|
+
const urlPath = req.url?.split('?')[0] || '/'
|
|
318
|
+
|
|
319
|
+
if (urlPath === '/events') {
|
|
320
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' })
|
|
321
|
+
const send = d => { try { res.write('data: ' + JSON.stringify(d) + '\n\n') } catch (_) {} }
|
|
322
|
+
send({ type: 'init', requests: getRequests(), save: getSaveInfo() })
|
|
323
|
+
const onReq = r => send({ type: 'request', record: r })
|
|
324
|
+
const onUpdate = ({ id, patch }) => send({ type: 'update', id, patch })
|
|
325
|
+
const onSave = cfg => send({ type: 'saveConfig', config: cfg })
|
|
326
|
+
const onReload = data => send({ type: 'reload', ...data })
|
|
327
|
+
emitter.on('request', onReq)
|
|
328
|
+
emitter.on('update', onUpdate)
|
|
329
|
+
emitter.on('saveConfig', onSave)
|
|
330
|
+
emitter.on('reload', onReload)
|
|
331
|
+
req.on('close', () => {
|
|
332
|
+
emitter.off('request', onReq)
|
|
333
|
+
emitter.off('update', onUpdate)
|
|
334
|
+
emitter.off('saveConfig', onSave)
|
|
335
|
+
emitter.off('reload', onReload)
|
|
336
|
+
})
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (urlPath === '/api/save' && req.method === 'POST') {
|
|
341
|
+
readBody(req).then(body => {
|
|
342
|
+
const { action, retention } = JSON.parse(body)
|
|
343
|
+
if (action === 'enable' && retention > 0) enableSave(retention)
|
|
344
|
+
else if (action === 'disable') disableSave()
|
|
345
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
346
|
+
res.end(JSON.stringify({ ok: true, save: getSaveInfo() }))
|
|
347
|
+
}).catch(e => { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: e.message })) })
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (urlPath === '/api/clear' && req.method === 'POST') {
|
|
352
|
+
clearRequests()
|
|
353
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
354
|
+
res.end(JSON.stringify({ ok: true }))
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (urlPath === '/owl.png') {
|
|
359
|
+
try {
|
|
360
|
+
const data = fs.readFileSync(path.join(__dir, 'owl.png'))
|
|
361
|
+
res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=86400' })
|
|
362
|
+
res.end(data)
|
|
363
|
+
} catch (_) { res.writeHead(404); res.end() }
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
368
|
+
res.end(getDashboard())
|
|
369
|
+
} catch (_) { try { if (!res.headersSent) { res.writeHead(500); res.end() } } catch (_) {} }
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
server.on('error', e => { if (e.code === 'EADDRINUSE') console.warn('[owlservable] Port ' + port + ' in use — dashboard not started') })
|
|
373
|
+
server.listen(port, '127.0.0.1', () => console.log('[owlservable] Dashboard → http://localhost:' + port))
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
let initialized = false
|
|
377
|
+
|
|
378
|
+
export function init({ port = 4321, dashboard = true, logging = false, save = false, retention = 86_400_000 } = {}) {
|
|
379
|
+
if (initialized) return; initialized = true
|
|
380
|
+
if (process.env.NODE_ENV === 'production') {
|
|
381
|
+
console.warn('[owlservable] Refusing to start in NODE_ENV=production — remove owlservable from your production bundle')
|
|
382
|
+
return
|
|
383
|
+
}
|
|
384
|
+
try { patch() } catch (_) {}
|
|
385
|
+
if (!save) {
|
|
386
|
+
try {
|
|
387
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(process.cwd(), '.owlservable', 'config.json'), 'utf8'))
|
|
388
|
+
if (cfg.save) { save = true; retention = cfg.retention || retention }
|
|
389
|
+
} catch (_) {}
|
|
390
|
+
}
|
|
391
|
+
if (save) try { enableSave(retention) } catch (_) {}
|
|
392
|
+
if (dashboard) try { startDashboard(port) } catch (_) {}
|
|
393
|
+
if (logging) {
|
|
394
|
+
emitter.on('request', r => {
|
|
395
|
+
try { console.log('[owlservable]', r.method, r.url, '→', r.status || r.error || '?', '(' + (r.latency || 0) + 'ms)') } catch (_) {}
|
|
396
|
+
})
|
|
397
|
+
}
|
|
398
|
+
}
|
package/owl.png
ADDED
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "owlservable",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Minimalist Observability Platform. Zero config, zero dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./auto": "./auto.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"index.js",
|
|
13
|
+
"auto.js",
|
|
14
|
+
"dashboard.html",
|
|
15
|
+
"owl.png"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://github.com/aaharbaugh/Owlservable"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/aaharbaugh/Owlservable#readme",
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/aaharbaugh/Owlservable/issues"
|
|
27
|
+
},
|
|
28
|
+
"license": "MIT"
|
|
29
|
+
}
|