feat: add telescope
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
package telescope
|
||||
|
||||
// pageHTML یک صفحهی مستقل (بدونِ وابستگیِ خارجی) که هر ۲ ثانیه داده را میگیرد.
|
||||
const pageHTML = `<!doctype html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>تلسکوپ — بازرسِ درخواستها</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; font-family: -apple-system, "Segoe UI", Tahoma, sans-serif;
|
||||
background:#0E2347; color:#E8EEF7; }
|
||||
header { padding:14px 20px; background:#17345C; border-bottom:1px solid #1E5FA8;
|
||||
display:flex; align-items:center; gap:14px; position:sticky; top:0; }
|
||||
header h1 { font-size:17px; margin:0; font-weight:700; }
|
||||
header .dot { width:9px; height:9px; border-radius:50%; background:#4ade80; }
|
||||
header .meta { margin-inline-start:auto; font-size:12px; opacity:.7; }
|
||||
header label { font-size:12px; opacity:.85; display:flex; align-items:center; gap:6px; }
|
||||
.filters { padding:10px 20px; display:flex; gap:8px; flex-wrap:wrap; }
|
||||
.filters input, .filters select {
|
||||
background:#0b1c39; color:#E8EEF7; border:1px solid #1E5FA8;
|
||||
border-radius:8px; padding:7px 10px; font-size:13px; }
|
||||
.filters input { flex:1; min-width:160px; }
|
||||
table { width:100%; border-collapse:collapse; font-size:13px; }
|
||||
th, td { padding:9px 12px; text-align:right; border-bottom:1px solid #16305a; white-space:nowrap; }
|
||||
th { position:sticky; top:52px; background:#122c52; font-weight:600; z-index:1; }
|
||||
tr:hover { background:#132b50; }
|
||||
.m { font-weight:700; font-size:11px; padding:2px 7px; border-radius:6px; }
|
||||
.GET{background:#134e4a;color:#5eead4} .POST{background:#1e3a8a;color:#93c5fd}
|
||||
.PUT{background:#713f12;color:#fcd34d} .DELETE{background:#7f1d1d;color:#fca5a5}
|
||||
.st { font-weight:700; }
|
||||
.s2{color:#4ade80} .s3{color:#60a5fa} .s4{color:#fbbf24} .s5{color:#f87171}
|
||||
.path { font-family: ui-monospace, monospace; }
|
||||
.body { display:none; }
|
||||
tr.open .body { display:table-row; }
|
||||
.body td { background:#0b1c39; white-space:pre-wrap; font-family: ui-monospace, monospace;
|
||||
font-size:12px; color:#fca5a5; direction:ltr; text-align:left; }
|
||||
.slow { color:#fbbf24; }
|
||||
.dim { opacity:.55; }
|
||||
.empty { padding:40px; text-align:center; opacity:.6; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<span class="dot"></span>
|
||||
<h1>تلسکوپ — بازرسِ درخواستها</h1>
|
||||
<label><input type="checkbox" id="live" checked> زنده</label>
|
||||
<span class="meta" id="meta">…</span>
|
||||
</header>
|
||||
<div class="filters">
|
||||
<input id="q" placeholder="جستجو در مسیر…">
|
||||
<select id="mf">
|
||||
<option value="">همه متدها</option>
|
||||
<option>GET</option><option>POST</option><option>PUT</option><option>DELETE</option>
|
||||
</select>
|
||||
<select id="sf">
|
||||
<option value="">همه وضعیتها</option>
|
||||
<option value="2">2xx</option><option value="3">3xx</option>
|
||||
<option value="4">4xx</option><option value="5">5xx</option>
|
||||
</select>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>زمان</th><th>متد</th><th>مسیر</th><th>وضعیت</th><th>مدت</th><th>حجم</th><th>IP</th>
|
||||
</tr></thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
<div class="empty" id="empty" style="display:none">درخواستی ثبت نشده است</div>
|
||||
<script>
|
||||
let data = [];
|
||||
const $ = s => document.querySelector(s);
|
||||
function fmtTime(t){ const d = new Date(t); return d.toLocaleTimeString('fa-IR'); }
|
||||
function stClass(s){ return 's' + Math.floor(s/100); }
|
||||
function render(){
|
||||
const q = $('#q').value.trim().toLowerCase();
|
||||
const mf = $('#mf').value, sf = $('#sf').value;
|
||||
const rows = data.filter(e =>
|
||||
(!q || (e.path||'').toLowerCase().includes(q)) &&
|
||||
(!mf || e.method === mf) &&
|
||||
(!sf || Math.floor((e.status||0)/100) == sf));
|
||||
const tb = $('#rows'); tb.innerHTML = '';
|
||||
$('#empty').style.display = rows.length ? 'none' : 'block';
|
||||
for(const e of rows){
|
||||
const tr = document.createElement('tr');
|
||||
const slow = e.duration_ms > 500 ? ' slow' : '';
|
||||
tr.innerHTML =
|
||||
'<td class="dim">'+fmtTime(e.time)+'</td>'+
|
||||
'<td><span class="m '+e.method+'">'+e.method+'</span></td>'+
|
||||
'<td class="path">'+e.path+'</td>'+
|
||||
'<td class="st '+stClass(e.status)+'">'+(e.status||'-')+'</td>'+
|
||||
'<td class="'+slow.trim()+'">'+e.duration_ms+'ms</td>'+
|
||||
'<td class="dim">'+e.bytes+'</td>'+
|
||||
'<td class="dim">'+(e.ip||'')+'</td>';
|
||||
if(e.body){
|
||||
tr.style.cursor='pointer';
|
||||
tr.onclick = () => { const b = tr.nextSibling; b.style.display = b.style.display==='table-row'?'none':'table-row'; };
|
||||
tb.appendChild(tr);
|
||||
const b = document.createElement('tr'); b.className='body';
|
||||
b.innerHTML = '<td colspan="7">'+e.body.replace(/</g,'<')+'</td>';
|
||||
b.style.display='none';
|
||||
tb.appendChild(b);
|
||||
} else {
|
||||
tb.appendChild(tr);
|
||||
}
|
||||
}
|
||||
$('#meta').textContent = rows.length + ' / ' + data.length + ' درخواست';
|
||||
}
|
||||
async function load(){
|
||||
try{
|
||||
const r = await fetch('/admin/telescope/data');
|
||||
data = await r.json();
|
||||
render();
|
||||
}catch(e){ $('#meta').textContent = 'خطا در دریافت'; }
|
||||
}
|
||||
['q','mf','sf'].forEach(id => $('#'+id).addEventListener('input', render));
|
||||
load();
|
||||
setInterval(() => { if($('#live').checked) load(); }, 2000);
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
@@ -0,0 +1,188 @@
|
||||
// Package telescope یک بازرسِ سبکِ درخواستها است (شبیهِ Laravel Telescope).
|
||||
// آخرین N درخواست را در یک رینگبافرِ حافظه نگه میدارد: متد، مسیر، وضعیت،
|
||||
// مدتزمان، IP و در صورتِ خطا (>=۴۰۰) بدنهی درخواست. بدونِ هیچ وابستگیِ خارجی
|
||||
// و با سرباری ناچیز؛ برای پایشِ سریع روی سرورِ کممنابع مناسب است.
|
||||
package telescope
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Entry یک درخواستِ ثبتشده را نگه میدارد.
|
||||
type Entry struct {
|
||||
ID int64 `json:"id"`
|
||||
Time time.Time `json:"time"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Status int `json:"status"`
|
||||
Duration int64 `json:"duration_ms"`
|
||||
IP string `json:"ip"`
|
||||
Bytes int `json:"bytes"`
|
||||
Body string `json:"body,omitempty"` // فقط برای خطاها (>=۴۰۰) و POST/PUT
|
||||
}
|
||||
|
||||
// Telescope رینگبافرِ همزمانامنِ درخواستها.
|
||||
type Telescope struct {
|
||||
mu sync.Mutex
|
||||
buf []Entry
|
||||
size int
|
||||
next int64 // شناسهی افزایشی
|
||||
full bool
|
||||
head int // اندیسِ نوشتنِ بعدی
|
||||
}
|
||||
|
||||
// New یک بازرس با ظرفیتِ size میسازد (حداقل ۱).
|
||||
func New(size int) *Telescope {
|
||||
if size < 1 {
|
||||
size = 200
|
||||
}
|
||||
return &Telescope{buf: make([]Entry, size), size: size}
|
||||
}
|
||||
|
||||
func (t *Telescope) add(e Entry) {
|
||||
t.mu.Lock()
|
||||
t.next++
|
||||
e.ID = t.next
|
||||
t.buf[t.head] = e
|
||||
t.head = (t.head + 1) % t.size
|
||||
if t.head == 0 {
|
||||
t.full = true
|
||||
}
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
// Entries آخرین درخواستها را بهترتیبِ جدید-به-قدیم برمیگرداند.
|
||||
func (t *Telescope) Entries() []Entry {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
n := t.head
|
||||
if t.full {
|
||||
n = t.size
|
||||
}
|
||||
out := make([]Entry, 0, n)
|
||||
// از جدیدترین (درست قبلِ head) عقب میرویم.
|
||||
for i := 0; i < n; i++ {
|
||||
idx := (t.head - 1 - i + t.size*2) % t.size
|
||||
out = append(out, t.buf[idx])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// statusRecorder کدِ وضعیت و حجمِ پاسخ را میگیرد.
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
bytes int
|
||||
}
|
||||
|
||||
func (r *statusRecorder) WriteHeader(code int) {
|
||||
r.status = code
|
||||
r.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (r *statusRecorder) Write(b []byte) (int, error) {
|
||||
if r.status == 0 {
|
||||
r.status = http.StatusOK
|
||||
}
|
||||
n, err := r.ResponseWriter.Write(b)
|
||||
r.bytes += n
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Hijack برای WebSocket لازم است تا میدلور مانعِ ارتقاء نشود.
|
||||
func (r *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
if h, ok := r.ResponseWriter.(http.Hijacker); ok {
|
||||
return h.Hijack()
|
||||
}
|
||||
return nil, nil, http.ErrNotSupported
|
||||
}
|
||||
|
||||
// Middleware هر درخواستِ /api و /admin را ثبت میکند (بهجز خودِ صفحهی تلسکوپ).
|
||||
func (t *Telescope) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
p := req.URL.Path
|
||||
if p == "/ws" || strings.HasPrefix(p, "/admin/telescope") ||
|
||||
strings.HasPrefix(p, "/cards/") || strings.HasPrefix(p, "/carpets/") {
|
||||
next.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
var body string
|
||||
if (req.Method == http.MethodPost || req.Method == http.MethodPut) && req.Body != nil {
|
||||
b, _ := io.ReadAll(io.LimitReader(req.Body, 4096))
|
||||
req.Body = io.NopCloser(bytes.NewReader(b))
|
||||
body = maskSecrets(string(b))
|
||||
}
|
||||
|
||||
rec := &statusRecorder{ResponseWriter: w}
|
||||
start := time.Now()
|
||||
next.ServeHTTP(rec, req)
|
||||
dur := time.Since(start)
|
||||
|
||||
e := Entry{
|
||||
Time: start,
|
||||
Method: req.Method,
|
||||
Path: p,
|
||||
Status: rec.status,
|
||||
Duration: dur.Milliseconds(),
|
||||
IP: req.RemoteAddr,
|
||||
Bytes: rec.bytes,
|
||||
}
|
||||
// بدنه را فقط برای خطاها نگه میداریم تا حافظه/حریمِ خصوصی حفظ شود.
|
||||
if rec.status >= 400 {
|
||||
e.Body = body
|
||||
}
|
||||
t.add(e)
|
||||
})
|
||||
}
|
||||
|
||||
// maskSecrets مقادیرِ حساس (توکن، کد، پسورد) را در بدنهی JSON پنهان میکند.
|
||||
func maskSecrets(s string) string {
|
||||
for _, k := range []string{"password", "token", "code", "otp", "signature"} {
|
||||
s = maskField(s, k)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func maskField(s, key string) string {
|
||||
needle := `"` + key + `"`
|
||||
i := strings.Index(s, needle)
|
||||
if i < 0 {
|
||||
return s
|
||||
}
|
||||
// دنبالِ ": " و سپس مقدار میگردیم.
|
||||
j := strings.Index(s[i:], ":")
|
||||
if j < 0 {
|
||||
return s
|
||||
}
|
||||
rest := s[i+j+1:]
|
||||
rest = strings.TrimLeft(rest, " ")
|
||||
if strings.HasPrefix(rest, `"`) {
|
||||
end := strings.Index(rest[1:], `"`)
|
||||
if end >= 0 {
|
||||
masked := s[:i+j+1] + `"***"` + rest[end+2:]
|
||||
return masked
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Data آخرین درخواستها را بهصورتِ JSON برمیگرداند.
|
||||
func (t *Telescope) Data(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(t.Entries())
|
||||
}
|
||||
|
||||
// Page یک صفحهی HTML سبک برای مرورِ درخواستها میدهد.
|
||||
func (t *Telescope) Page(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = io.WriteString(w, pageHTML)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package telescope
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRingOrderAndOverflow(t *testing.T) {
|
||||
tel := New(3)
|
||||
for i := 0; i < 5; i++ {
|
||||
tel.add(Entry{Path: string(rune('a' + i))})
|
||||
}
|
||||
got := tel.Entries()
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("want 3 entries, got %d", len(got))
|
||||
}
|
||||
// جدید-به-قدیم: e, d, c (a و b بازنویسی شدهاند).
|
||||
want := []string{"e", "d", "c"}
|
||||
for i, w := range want {
|
||||
if got[i].Path != w {
|
||||
t.Errorf("entry %d: want %q got %q", i, w, got[i].Path)
|
||||
}
|
||||
}
|
||||
// شناسهها باید افزایشی و یکتا باشند.
|
||||
if got[0].ID != 5 || got[2].ID != 3 {
|
||||
t.Errorf("ids wrong: %d..%d", got[2].ID, got[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareCapturesErrorBodyMasked(t *testing.T) {
|
||||
tel := New(10)
|
||||
h := tel.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/x",
|
||||
strings.NewReader(`{"password":"secret","kind":"coin"}`))
|
||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
e := tel.Entries()
|
||||
if len(e) != 1 || e[0].Status != 400 {
|
||||
t.Fatalf("expected one 400 entry, got %+v", e)
|
||||
}
|
||||
if !strings.Contains(e[0].Body, `"password":"***"`) {
|
||||
t.Errorf("password not masked: %s", e[0].Body)
|
||||
}
|
||||
if !strings.Contains(e[0].Body, `"kind":"coin"`) {
|
||||
t.Errorf("non-secret field lost: %s", e[0].Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareOKHasNoBody(t *testing.T) {
|
||||
tel := New(10)
|
||||
h := tel.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/y",
|
||||
strings.NewReader(`{"token":"abc"}`))
|
||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||
e := tel.Entries()
|
||||
if len(e) != 1 || e[0].Body != "" {
|
||||
t.Errorf("2xx should not store body, got %+v", e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareSkipsWS(t *testing.T) {
|
||||
tel := New(10)
|
||||
h := tel.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/ws", nil))
|
||||
if len(tel.Entries()) != 0 {
|
||||
t.Error("ws request should be skipped")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user