feat: add real-time log viewer to miniapp Config tab
Add WebSocket-based log streaming to the Telegram Mini App dashboard with three log sources (picoclaw, telego, dev-console), component filtering, and tar.gz snapshot export. Security hardening: - Add allow_from authorization to Mini App endpoints - Gate /dev/console endpoint on active dev proxy - Fix rate limiter TOCTOU race (atomic → mutex) - Restrict WebSocket CheckOrigin to Telegram/localhost origins - Add initData auth_date expiry check (24h) - Strip Caller field from all log output Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bd7799f031
commit
f06b21d427
11 changed files with 1026 additions and 79 deletions
19
CLAUDE.md
Normal file
19
CLAUDE.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# CLAUDE.md
|
||||
|
||||
## Project: picoclaw
|
||||
|
||||
Go-based AI agent with multi-channel messaging (Telegram, Discord, Slack, etc.) and a Telegram Mini App UI.
|
||||
|
||||
## Build & Test
|
||||
|
||||
```bash
|
||||
go build ./...
|
||||
go test ./...
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
Lint: `golangci-lint run`
|
||||
|
||||
## Security TODOs
|
||||
|
||||
- **Log Fields masking**: `LogEntry.Fields` (`map[string]any`) is exposed via WebSocket (`/miniapp/api/logs/ws`) and snapshots (`/miniapp/api/logs/snapshot`). If any code logs sensitive values (tokens, API keys, passwords) in Fields, they will be visible to Mini App users. Add a sanitizer in `RecentLogs()` and `wsLogs()` that masks values for keys matching patterns like `token`, `key`, `secret`, `password`, `authorization`. Track in: `pkg/logger/logger.go` (RecentLogs), `pkg/miniapp/miniapp.go` (wsLogs stream).
|
||||
|
|
@ -277,6 +277,11 @@ Telegram、Discord、QQ、DingTalk、LINE、WeCom で PicoClaw と会話でき
|
|||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
#### Mini App(ダッシュボード)
|
||||
|
||||
PicoClaw には Telegram Mini App が含まれており、チャット内から GUI ダッシュボードを利用できます。Config タブにはリアルタイムの **ログビューアー** があり、WebSocket で 3 種類のログストリーム(picoclaw / telego / dev-console)を表示します。コンポーネントフィルタ、スナップショット保存(tar.gz、14日後自動削除)にも対応しています。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -330,7 +330,7 @@ When Telegram is enabled, PicoClaw automatically registers a "Dashboard" menu bu
|
|||
| **Plan** | View plan phases/steps as a checklist, tap to mark done, start new plans |
|
||||
| **Skills** | Browse and invoke skills with a message input |
|
||||
| **Session** | View active sessions and token usage stats (requires `--stats` flag for stats) |
|
||||
| **Config** | Quick command buttons and custom command input |
|
||||
| **Config** | Quick command buttons, custom command input, and **Log Viewer** |
|
||||
|
||||
**Setup — Tailscale (recommended for self-hosting):**
|
||||
|
||||
|
|
@ -370,6 +370,16 @@ If you already have an HTTPS endpoint (e.g., reverse proxy, Cloudflare Tunnel),
|
|||
|
||||
When `web_app_url` is set, PicoClaw skips Tailscale auto-detection and serves the Mini App over HTTP (your reverse proxy handles TLS).
|
||||
|
||||
**Log Viewer (Config tab):**
|
||||
|
||||
The Config tab includes a real-time log viewer that streams logs via WebSocket. It supports three log streams:
|
||||
|
||||
- **picoclaw** — agent, tools, and channel activity logs
|
||||
- **telego** — Telegram library internal logs
|
||||
- **dev-console** — JavaScript console output from Dev Preview iframes
|
||||
|
||||
Features: component filter chips, auto-reconnect, and **Save Snapshot** to download a `.tar.gz` archive of the current log buffer (auto-deleted after 14 days). When `allow_from` is configured, the log viewer is restricted to allowed users only.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
|
|
|||
|
|
@ -298,6 +298,8 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
|
|||
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) |
|
||||
| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) |
|
||||
|
||||
**Mini App(仪表盘):** Telegram Mini App 的 Config 标签页内置实时**日志查看器**,通过 WebSocket 展示三种日志流(picoclaw / telego / dev-console)。支持组件筛选、快照保存(tar.gz,14天自动清理)。配置 `allow_from` 后,日志查看器仅限白名单用户使用。
|
||||
|
||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
|
||||
|
||||
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
|
||||
|
|
|
|||
|
|
@ -235,7 +235,7 @@ func gatewayCmd() {
|
|||
provider := &agentLoopDataProvider{loop: agentLoop, workspace: cfg.WorkspacePath()}
|
||||
sender := &telegramCommandSender{bus: msgBus}
|
||||
miniappNotifier = miniapp.NewStateNotifier()
|
||||
handler := miniapp.NewHandler(provider, sender, cfg.Channels.Telegram.Token, miniappNotifier)
|
||||
handler := miniapp.NewHandler(provider, sender, cfg.Channels.Telegram.Token, miniappNotifier, cfg.Channels.Telegram.AllowFrom, cfg.WorkspacePath())
|
||||
agentLoop.OnStateChange = miniappNotifier.Notify
|
||||
handler.RegisterRoutes(healthServer.Mux())
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,17 @@ func (c *thinkingCancel) Cancel() {
|
|||
}
|
||||
}
|
||||
|
||||
// telegoLogAdapter routes telego library logs through picoclaw's logger.
|
||||
type telegoLogAdapter struct{}
|
||||
|
||||
func (telegoLogAdapter) Debugf(format string, args ...any) {
|
||||
logger.DebugCF("telego", fmt.Sprintf(format, args...), nil)
|
||||
}
|
||||
|
||||
func (telegoLogAdapter) Errorf(format string, args ...any) {
|
||||
logger.ErrorCF("telego", fmt.Sprintf(format, args...), nil)
|
||||
}
|
||||
|
||||
const telegramMaxMessageChars = 3900
|
||||
const markdownTableMaxWidth = 42
|
||||
const markdownTableMinColWidth = 6
|
||||
|
|
@ -74,6 +85,8 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
|
|||
}))
|
||||
}
|
||||
|
||||
opts = append(opts, telego.WithLogger(telegoLogAdapter{}))
|
||||
|
||||
bot, err := telego.NewBot(telegramCfg.Token, opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create telegram bot: %w", err)
|
||||
|
|
|
|||
|
|
@ -30,12 +30,74 @@ var (
|
|||
FATAL: "FATAL",
|
||||
}
|
||||
|
||||
levelFromName = map[string]LogLevel{
|
||||
"DEBUG": DEBUG,
|
||||
"INFO": INFO,
|
||||
"WARN": WARN,
|
||||
"ERROR": ERROR,
|
||||
"FATAL": FATAL,
|
||||
}
|
||||
|
||||
currentLevel = INFO
|
||||
logger *Logger
|
||||
once sync.Once
|
||||
mu sync.RWMutex
|
||||
|
||||
ringBuf *logRingBuffer
|
||||
logSubs []*LogSubscriber
|
||||
logSubsMu sync.Mutex
|
||||
)
|
||||
|
||||
const ringBufSize = 300
|
||||
|
||||
// logRingBuffer is a fixed-size circular buffer for log entries.
|
||||
type logRingBuffer struct {
|
||||
entries []LogEntry
|
||||
head int
|
||||
count int
|
||||
seq uint64
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func newLogRingBuffer(size int) *logRingBuffer {
|
||||
return &logRingBuffer{
|
||||
entries: make([]LogEntry, size),
|
||||
}
|
||||
}
|
||||
|
||||
func (rb *logRingBuffer) push(entry LogEntry) {
|
||||
rb.mu.Lock()
|
||||
rb.entries[rb.head] = entry
|
||||
rb.head = (rb.head + 1) % len(rb.entries)
|
||||
if rb.count < len(rb.entries) {
|
||||
rb.count++
|
||||
}
|
||||
rb.seq++
|
||||
rb.mu.Unlock()
|
||||
}
|
||||
|
||||
func (rb *logRingBuffer) recent(limit int) []LogEntry {
|
||||
rb.mu.RLock()
|
||||
defer rb.mu.RUnlock()
|
||||
|
||||
n := rb.count
|
||||
if limit > 0 && limit < n {
|
||||
n = limit
|
||||
}
|
||||
result := make([]LogEntry, n)
|
||||
start := (rb.head - n + len(rb.entries)) % len(rb.entries)
|
||||
for i := 0; i < n; i++ {
|
||||
result[i] = rb.entries[(start+i)%len(rb.entries)]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// LogSubscriber receives log entries matching its filter.
|
||||
type LogSubscriber struct {
|
||||
Ch chan LogEntry
|
||||
filter func(LogEntry) bool
|
||||
}
|
||||
|
||||
type Logger struct {
|
||||
file *os.File
|
||||
}
|
||||
|
|
@ -52,6 +114,7 @@ type LogEntry struct {
|
|||
func init() {
|
||||
once.Do(func() {
|
||||
logger = &Logger{}
|
||||
ringBuf = newLogRingBuffer(ringBufSize)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -116,6 +179,10 @@ func logMessage(level LogLevel, component string, message string, fields map[str
|
|||
}
|
||||
}
|
||||
|
||||
// Push to ring buffer and broadcast to subscribers
|
||||
ringBuf.push(entry)
|
||||
broadcastToSubscribers(entry)
|
||||
|
||||
if logger.file != nil {
|
||||
jsonData, err := json.Marshal(entry)
|
||||
if err == nil {
|
||||
|
|
@ -237,3 +304,81 @@ func FatalF(message string, fields map[string]any) {
|
|||
func FatalCF(component string, message string, fields map[string]any) {
|
||||
logMessage(FATAL, component, message, fields)
|
||||
}
|
||||
|
||||
// broadcastToSubscribers sends an entry to all matching subscribers (non-blocking).
|
||||
func broadcastToSubscribers(entry LogEntry) {
|
||||
logSubsMu.Lock()
|
||||
subs := make([]*LogSubscriber, len(logSubs))
|
||||
copy(subs, logSubs)
|
||||
logSubsMu.Unlock()
|
||||
|
||||
for _, sub := range subs {
|
||||
if sub.filter != nil && !sub.filter(entry) {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case sub.Ch <- entry:
|
||||
default:
|
||||
// drop if subscriber channel is full
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RecentLogs returns recent log entries from the ring buffer, optionally filtered
|
||||
// by minimum level and component. The Caller field is stripped for security.
|
||||
func RecentLogs(minLevel LogLevel, component string, limit int) []LogEntry {
|
||||
all := ringBuf.recent(0) // get all
|
||||
result := make([]LogEntry, 0, limit)
|
||||
for i := len(all) - 1; i >= 0 && len(result) < limit; i-- {
|
||||
e := all[i]
|
||||
if lvl, ok := levelFromName[e.Level]; ok && lvl < minLevel {
|
||||
continue
|
||||
}
|
||||
if component != "" && e.Component != component {
|
||||
continue
|
||||
}
|
||||
e.Caller = "" // strip for security
|
||||
result = append(result, e)
|
||||
}
|
||||
// Reverse so oldest first
|
||||
for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
|
||||
result[i], result[j] = result[j], result[i]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Subscribe registers a new log subscriber with an optional filter.
|
||||
// The returned LogSubscriber's Ch channel has a buffer of 64 entries.
|
||||
func Subscribe(filter func(LogEntry) bool) *LogSubscriber {
|
||||
sub := &LogSubscriber{
|
||||
Ch: make(chan LogEntry, 64),
|
||||
filter: filter,
|
||||
}
|
||||
logSubsMu.Lock()
|
||||
logSubs = append(logSubs, sub)
|
||||
logSubsMu.Unlock()
|
||||
return sub
|
||||
}
|
||||
|
||||
// Unsubscribe removes a subscriber and closes its channel.
|
||||
func Unsubscribe(sub *LogSubscriber) {
|
||||
logSubsMu.Lock()
|
||||
for i, s := range logSubs {
|
||||
if s == sub {
|
||||
logSubs = append(logSubs[:i], logSubs[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
logSubsMu.Unlock()
|
||||
close(sub.Ch)
|
||||
}
|
||||
|
||||
// ParseLevel converts a level name string to a LogLevel.
|
||||
// Returns INFO if the string is not recognized.
|
||||
func ParseLevel(s string) LogLevel {
|
||||
s = strings.ToUpper(strings.TrimSpace(s))
|
||||
if lvl, ok := levelFromName[s]; ok {
|
||||
return lvl
|
||||
}
|
||||
return INFO
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package logger
|
|||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLogLevelFiltering(t *testing.T) {
|
||||
|
|
@ -137,3 +138,178 @@ func TestLoggerHelperFunctions(t *testing.T) {
|
|||
DebugC("test", "Debug with component")
|
||||
WarnF("Warning with fields", map[string]any{"key": "value"})
|
||||
}
|
||||
|
||||
// ── Ring buffer tests ──
|
||||
|
||||
func TestRingBuffer_PushAndRecent(t *testing.T) {
|
||||
rb := newLogRingBuffer(5)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
rb.push(LogEntry{Message: "msg" + string(rune('A'+i))})
|
||||
}
|
||||
|
||||
got := rb.recent(0)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("expected 3, got %d", len(got))
|
||||
}
|
||||
if got[0].Message != "msgA" || got[2].Message != "msgC" {
|
||||
t.Errorf("unexpected order: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRingBuffer_Wrap(t *testing.T) {
|
||||
rb := newLogRingBuffer(3)
|
||||
for i := 0; i < 5; i++ {
|
||||
rb.push(LogEntry{Message: string(rune('A' + i))})
|
||||
}
|
||||
|
||||
got := rb.recent(0)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("expected 3, got %d", len(got))
|
||||
}
|
||||
// Should have C, D, E (oldest two dropped)
|
||||
if got[0].Message != "C" || got[1].Message != "D" || got[2].Message != "E" {
|
||||
t.Errorf("expected [C,D,E], got [%s,%s,%s]", got[0].Message, got[1].Message, got[2].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRingBuffer_RecentLimit(t *testing.T) {
|
||||
rb := newLogRingBuffer(10)
|
||||
for i := 0; i < 8; i++ {
|
||||
rb.push(LogEntry{Message: string(rune('A' + i))})
|
||||
}
|
||||
|
||||
got := rb.recent(3)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("expected 3, got %d", len(got))
|
||||
}
|
||||
if got[0].Message != "F" || got[2].Message != "H" {
|
||||
t.Errorf("expected last 3 entries, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentLogs_FilterByLevel(t *testing.T) {
|
||||
initialLevel := GetLevel()
|
||||
defer SetLevel(initialLevel)
|
||||
SetLevel(DEBUG)
|
||||
|
||||
// Log messages at different levels
|
||||
DebugC("test", "debug msg")
|
||||
InfoC("test", "info msg")
|
||||
WarnC("test", "warn msg")
|
||||
ErrorC("test", "error msg")
|
||||
|
||||
got := RecentLogs(WARN, "", 100)
|
||||
for _, e := range got {
|
||||
if e.Level == "DEBUG" || e.Level == "INFO" {
|
||||
t.Errorf("unexpected level %s in result with minLevel=WARN", e.Level)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentLogs_FilterByComponent(t *testing.T) {
|
||||
initialLevel := GetLevel()
|
||||
defer SetLevel(initialLevel)
|
||||
SetLevel(DEBUG)
|
||||
|
||||
InfoC("alpha", "from alpha")
|
||||
InfoC("beta", "from beta")
|
||||
InfoC("alpha", "another from alpha")
|
||||
|
||||
got := RecentLogs(DEBUG, "alpha", 100)
|
||||
for _, e := range got {
|
||||
if e.Component != "alpha" {
|
||||
t.Errorf("unexpected component %s in result with component=alpha", e.Component)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentLogs_CallerStripped(t *testing.T) {
|
||||
initialLevel := GetLevel()
|
||||
defer SetLevel(initialLevel)
|
||||
SetLevel(DEBUG)
|
||||
|
||||
InfoC("test", "caller test")
|
||||
|
||||
got := RecentLogs(DEBUG, "", 100)
|
||||
for _, e := range got {
|
||||
if e.Caller != "" {
|
||||
t.Errorf("Caller should be stripped, got %q", e.Caller)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribe_ReceivesEntries(t *testing.T) {
|
||||
initialLevel := GetLevel()
|
||||
defer SetLevel(initialLevel)
|
||||
SetLevel(DEBUG)
|
||||
|
||||
sub := Subscribe(nil)
|
||||
defer Unsubscribe(sub)
|
||||
|
||||
InfoC("sub-test", "hello subscriber")
|
||||
|
||||
select {
|
||||
case entry := <-sub.Ch:
|
||||
if entry.Message != "hello subscriber" {
|
||||
t.Errorf("expected 'hello subscriber', got %q", entry.Message)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timed out waiting for log entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribe_FilterApplied(t *testing.T) {
|
||||
initialLevel := GetLevel()
|
||||
defer SetLevel(initialLevel)
|
||||
SetLevel(DEBUG)
|
||||
|
||||
sub := Subscribe(func(e LogEntry) bool {
|
||||
return e.Component == "target"
|
||||
})
|
||||
defer Unsubscribe(sub)
|
||||
|
||||
InfoC("other", "should be filtered out")
|
||||
InfoC("target", "should arrive")
|
||||
|
||||
select {
|
||||
case entry := <-sub.Ch:
|
||||
if entry.Component != "target" {
|
||||
t.Errorf("expected component=target, got %q", entry.Component)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Error("timed out waiting for filtered entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsubscribe_ClosesChannel(t *testing.T) {
|
||||
sub := Subscribe(nil)
|
||||
Unsubscribe(sub)
|
||||
|
||||
_, ok := <-sub.Ch
|
||||
if ok {
|
||||
t.Error("expected channel to be closed after Unsubscribe")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want LogLevel
|
||||
}{
|
||||
{"debug", DEBUG},
|
||||
{"DEBUG", DEBUG},
|
||||
{"info", INFO},
|
||||
{"WARN", WARN},
|
||||
{"error", ERROR},
|
||||
{"fatal", FATAL},
|
||||
{"unknown", INFO},
|
||||
{"", INFO},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := ParseLevel(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("ParseLevel(%q) = %d, want %d", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package miniapp
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
|
|
@ -13,12 +15,16 @@ import (
|
|||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/skills"
|
||||
"github.com/sipeed/picoclaw/pkg/stats"
|
||||
)
|
||||
|
|
@ -184,6 +190,8 @@ type Handler struct {
|
|||
sender CommandSender
|
||||
botToken string
|
||||
notifier *StateNotifier
|
||||
allowList []string
|
||||
workspace string
|
||||
|
||||
devMu sync.RWMutex
|
||||
devTarget *url.URL
|
||||
|
|
@ -191,15 +199,44 @@ type Handler struct {
|
|||
devTargets map[string]*DevTarget // registered targets (ID→DevTarget)
|
||||
devNextID int
|
||||
devActiveID string
|
||||
|
||||
wsClients []*wsClient
|
||||
wsClientsMu sync.Mutex
|
||||
|
||||
consoleMu sync.Mutex
|
||||
consoleReqCount int
|
||||
consoleReqSec int64
|
||||
}
|
||||
|
||||
const maxWSClients = 4
|
||||
|
||||
type wsClient struct {
|
||||
conn *websocket.Conn
|
||||
}
|
||||
|
||||
var wsUpgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
return true // non-browser clients (e.g. curl)
|
||||
}
|
||||
// Allow Telegram WebApp origins and localhost for dev
|
||||
return strings.HasSuffix(origin, ".telegram.org") ||
|
||||
strings.HasSuffix(origin, ".t.me") ||
|
||||
strings.HasPrefix(origin, "http://localhost") ||
|
||||
strings.HasPrefix(origin, "http://127.0.0.1")
|
||||
},
|
||||
}
|
||||
|
||||
// NewHandler creates a new Mini App handler.
|
||||
func NewHandler(provider DataProvider, sender CommandSender, botToken string, notifier *StateNotifier) *Handler {
|
||||
func NewHandler(provider DataProvider, sender CommandSender, botToken string, notifier *StateNotifier, allowList []string, workspace string) *Handler {
|
||||
return &Handler{
|
||||
provider: provider,
|
||||
sender: sender,
|
||||
botToken: botToken,
|
||||
notifier: notifier,
|
||||
allowList: allowList,
|
||||
workspace: workspace,
|
||||
devTargets: make(map[string]*DevTarget),
|
||||
}
|
||||
}
|
||||
|
|
@ -364,6 +401,7 @@ func (h *Handler) ListDevTargets() []DevTarget {
|
|||
// devProxyScript is the JavaScript injected into HTML responses from the dev proxy.
|
||||
// It rewrites fetch() and XMLHttpRequest.open() so that absolute paths like
|
||||
// "/api/items" are prefixed with "/miniapp/dev", matching the reverse proxy mount.
|
||||
// It also captures console.log/warn/error/info and forwards them to the server.
|
||||
const devProxyScript = `<script data-dev-proxy>
|
||||
(function(){
|
||||
var B='/miniapp/dev';
|
||||
|
|
@ -382,6 +420,30 @@ const devProxyScript = `<script data-dev-proxy>
|
|||
arguments[1]=rw(u);
|
||||
return _o.apply(this,arguments);
|
||||
};
|
||||
// Console capture: batch POST to /miniapp/dev/console
|
||||
var _cl=console.log,_cw=console.warn,_ce=console.error,_ci=console.info;
|
||||
var _buf=[],_timer=null;
|
||||
function _flush(){
|
||||
_timer=null;
|
||||
if(!_buf.length)return;
|
||||
var batch=_buf.splice(0,20);
|
||||
try{navigator.sendBeacon('/miniapp/dev/console',JSON.stringify(batch));}catch(e){}
|
||||
}
|
||||
function _cap(level,args){
|
||||
var msg=Array.prototype.map.call(args,function(a){
|
||||
try{return typeof a==='object'?JSON.stringify(a):String(a);}catch(e){return String(a);}
|
||||
}).join(' ');
|
||||
if(msg.length>1024)msg=msg.substring(0,1024);
|
||||
_buf.push({level:level,message:msg,timestamp:new Date().toISOString()});
|
||||
if(_buf.length>=20){if(_timer){clearTimeout(_timer);_timer=null;}_flush();}
|
||||
else if(!_timer){_timer=setTimeout(_flush,500);}
|
||||
}
|
||||
console.log=function(){_cap('log',arguments);_cl.apply(console,arguments);};
|
||||
console.warn=function(){_cap('warn',arguments);_cw.apply(console,arguments);};
|
||||
console.error=function(){_cap('error',arguments);_ce.apply(console,arguments);};
|
||||
console.info=function(){_cap('info',arguments);_ci.apply(console,arguments);};
|
||||
window.onerror=function(m,s,l,c,e){_cap('error',[m,'at',s+':'+l+':'+c]);};
|
||||
window.onunhandledrejection=function(e){_cap('error',['Unhandled rejection:',e.reason]);};
|
||||
})();
|
||||
</script>`
|
||||
|
||||
|
|
@ -441,6 +503,10 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
|||
mux.HandleFunc("/miniapp/api/git", h.requireAuth(h.apiGit))
|
||||
mux.HandleFunc("/miniapp/api/dev", h.requireAuth(h.apiDev))
|
||||
mux.HandleFunc("/miniapp/api/events", h.requireAuth(h.apiEvents))
|
||||
mux.HandleFunc("/miniapp/api/logs/ws", h.requireAuth(h.wsLogs))
|
||||
mux.HandleFunc("/miniapp/api/logs/snapshot", h.requireAuth(h.apiLogsSnapshot))
|
||||
mux.HandleFunc("/miniapp/api/logs/snapshot/", h.requireAuth(h.apiLogsSnapshotDownload))
|
||||
mux.HandleFunc("/miniapp/dev/console", h.apiDevConsole)
|
||||
mux.HandleFunc("/miniapp/dev/", h.serveDevProxy)
|
||||
}
|
||||
|
||||
|
|
@ -465,10 +531,36 @@ func (h *Handler) requireAuth(next http.HandlerFunc) http.HandlerFunc {
|
|||
http.Error(w, `{"error":"invalid initData"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if len(h.allowList) > 0 {
|
||||
userID, _ := extractUserFromInitData(initData)
|
||||
if userID == "" || !isAllowed(userID, h.allowList) {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// isAllowed checks whether userID matches any entry in the allow list.
|
||||
// Logic mirrors BaseChannel.IsAllowed without importing channels package.
|
||||
func isAllowed(userID string, allowList []string) bool {
|
||||
if len(allowList) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, allowed := range allowList {
|
||||
trimmed := strings.TrimPrefix(allowed, "@")
|
||||
allowedID := trimmed
|
||||
if idx := strings.Index(trimmed, "|"); idx > 0 {
|
||||
allowedID = trimmed[:idx]
|
||||
}
|
||||
if userID == allowed || userID == trimmed || userID == allowedID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *Handler) apiSkills(w http.ResponseWriter, r *http.Request) {
|
||||
skillsList := h.provider.ListSkills()
|
||||
writeJSON(w, skillsList)
|
||||
|
|
@ -716,7 +808,282 @@ func writeJSON(w http.ResponseWriter, v any) {
|
|||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// ValidateInitData verifies the Telegram WebApp initData HMAC-SHA256 signature.
|
||||
// apiDevConsole receives console output from dev preview iframes.
|
||||
func (h *Handler) apiDevConsole(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Only accept console posts when dev proxy is active
|
||||
if h.GetDevTarget() == "" {
|
||||
http.Error(w, `{"error":"not available"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Simple rate limit: max 10 requests per second
|
||||
now := time.Now().Unix()
|
||||
h.consoleMu.Lock()
|
||||
if h.consoleReqSec != now {
|
||||
h.consoleReqSec = now
|
||||
h.consoleReqCount = 0
|
||||
}
|
||||
h.consoleReqCount++
|
||||
over := h.consoleReqCount > 10
|
||||
h.consoleMu.Unlock()
|
||||
if over {
|
||||
http.Error(w, `{"error":"rate limit"}`, http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 32*1024))
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var entries []struct {
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &entries); err != nil {
|
||||
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Cap at 20 entries per batch
|
||||
if len(entries) > 20 {
|
||||
entries = entries[:20]
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
msg := e.Message
|
||||
if len(msg) > 1024 {
|
||||
msg = msg[:1024]
|
||||
}
|
||||
switch e.Level {
|
||||
case "warn":
|
||||
logger.WarnC("dev-console", msg)
|
||||
case "error":
|
||||
logger.ErrorC("dev-console", msg)
|
||||
default:
|
||||
logger.InfoC("dev-console", msg)
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// wsLogs serves a WebSocket endpoint that streams log entries in real time.
|
||||
func (h *Handler) wsLogs(w http.ResponseWriter, r *http.Request) {
|
||||
// Parse filter params
|
||||
component := r.URL.Query().Get("component")
|
||||
levelStr := r.URL.Query().Get("level")
|
||||
minLevel := logger.INFO
|
||||
if levelStr != "" {
|
||||
minLevel = logger.ParseLevel(levelStr)
|
||||
}
|
||||
|
||||
conn, err := wsUpgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
client := &wsClient{conn: conn}
|
||||
|
||||
// Enforce max WS clients: evict oldest if full
|
||||
h.wsClientsMu.Lock()
|
||||
if len(h.wsClients) >= maxWSClients {
|
||||
oldest := h.wsClients[0]
|
||||
h.wsClients = h.wsClients[1:]
|
||||
oldest.conn.Close()
|
||||
}
|
||||
h.wsClients = append(h.wsClients, client)
|
||||
h.wsClientsMu.Unlock()
|
||||
|
||||
defer func() {
|
||||
h.wsClientsMu.Lock()
|
||||
for i, c := range h.wsClients {
|
||||
if c == client {
|
||||
h.wsClients = append(h.wsClients[:i], h.wsClients[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
h.wsClientsMu.Unlock()
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
// Build filter function
|
||||
filter := func(e logger.LogEntry) bool {
|
||||
if lvl := logger.ParseLevel(e.Level); lvl < minLevel {
|
||||
return false
|
||||
}
|
||||
if component != "" && e.Component != component {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
sub := logger.Subscribe(filter)
|
||||
defer logger.Unsubscribe(sub)
|
||||
|
||||
// Send initial data
|
||||
initial := logger.RecentLogs(minLevel, component, 50)
|
||||
if err := conn.WriteJSON(map[string]any{"type": "init", "entries": initial}); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Close detection goroutine
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Stream loop
|
||||
for {
|
||||
select {
|
||||
case entry, ok := <-sub.Ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
entry.Caller = "" // strip for security
|
||||
if err := conn.WriteJSON(map[string]any{"type": "entry", "entry": entry}); err != nil {
|
||||
return
|
||||
}
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// apiLogsSnapshot creates a tar.gz snapshot of the current log buffer.
|
||||
func (h *Handler) apiLogsSnapshot(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
entries := logger.RecentLogs(logger.DEBUG, "", 300)
|
||||
|
||||
snapshotDir := filepath.Join(h.workspace, "logs", "snapshots")
|
||||
if err := os.MkdirAll(snapshotDir, 0o755); err != nil {
|
||||
http.Error(w, `{"error":"cannot create snapshot dir"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
id := time.Now().UTC().Format("20060102-150405")
|
||||
filename := fmt.Sprintf("picoclaw-logs-%s.tar.gz", id)
|
||||
snapshotPath := filepath.Join(snapshotDir, filename)
|
||||
|
||||
// Create tar.gz
|
||||
f, err := os.Create(snapshotPath)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"cannot create snapshot file"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
gw := gzip.NewWriter(f)
|
||||
tw := tar.NewWriter(gw)
|
||||
|
||||
prefix := fmt.Sprintf("picoclaw-logs-%s/", id)
|
||||
|
||||
// logs.json
|
||||
logsJSON, _ := json.MarshalIndent(entries, "", " ")
|
||||
_ = tw.WriteHeader(&tar.Header{
|
||||
Name: prefix + "logs.json",
|
||||
Size: int64(len(logsJSON)),
|
||||
Mode: 0o644,
|
||||
ModTime: time.Now(),
|
||||
})
|
||||
_, _ = tw.Write(logsJSON)
|
||||
|
||||
// metadata.json
|
||||
hostname, _ := os.Hostname()
|
||||
meta := map[string]any{
|
||||
"version": "1",
|
||||
"hostname": hostname,
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
"entry_count": len(entries),
|
||||
}
|
||||
metaJSON, _ := json.MarshalIndent(meta, "", " ")
|
||||
_ = tw.WriteHeader(&tar.Header{
|
||||
Name: prefix + "metadata.json",
|
||||
Size: int64(len(metaJSON)),
|
||||
Mode: 0o644,
|
||||
ModTime: time.Now(),
|
||||
})
|
||||
_, _ = tw.Write(metaJSON)
|
||||
|
||||
tw.Close()
|
||||
gw.Close()
|
||||
f.Close()
|
||||
|
||||
// Cleanup old snapshots (>14 days)
|
||||
go cleanOldSnapshots(snapshotDir, 14*24*time.Hour)
|
||||
|
||||
downloadURL := fmt.Sprintf("/miniapp/api/logs/snapshot/%s", id)
|
||||
writeJSON(w, map[string]string{"id": id, "download_url": downloadURL})
|
||||
}
|
||||
|
||||
// apiLogsSnapshotDownload serves a snapshot tar.gz file.
|
||||
func (h *Handler) apiLogsSnapshotDownload(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
id := strings.TrimPrefix(r.URL.Path, "/miniapp/api/logs/snapshot/")
|
||||
id = filepath.Base(id) // path traversal prevention
|
||||
|
||||
if id == "" || id == "." || id == ".." {
|
||||
http.Error(w, `{"error":"invalid id"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("picoclaw-logs-%s.tar.gz", id)
|
||||
snapshotPath := filepath.Join(h.workspace, "logs", "snapshots", filename)
|
||||
|
||||
if _, err := os.Stat(snapshotPath); os.IsNotExist(err) {
|
||||
http.Error(w, `{"error":"not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/gzip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
http.ServeFile(w, r, snapshotPath)
|
||||
}
|
||||
|
||||
// cleanOldSnapshots removes snapshot files older than maxAge.
|
||||
func cleanOldSnapshots(dir string, maxAge time.Duration) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().Add(-maxAge)
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.ModTime().Before(cutoff) {
|
||||
os.Remove(filepath.Join(dir, e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// initDataMaxAge is the maximum age of initData before it is considered expired.
|
||||
const initDataMaxAge = 24 * time.Hour
|
||||
|
||||
// ValidateInitData verifies the Telegram WebApp initData HMAC-SHA256 signature
|
||||
// and checks that auth_date is not older than initDataMaxAge.
|
||||
// See https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
|
||||
func ValidateInitData(initData, botToken string) bool {
|
||||
values, err := url.ParseQuery(initData)
|
||||
|
|
@ -729,6 +1096,17 @@ func ValidateInitData(initData, botToken string) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// Check auth_date freshness
|
||||
if authDateStr := values.Get("auth_date"); authDateStr != "" {
|
||||
authDate, err := strconv.ParseInt(authDateStr, 10, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if time.Since(time.Unix(authDate, 0)) > initDataMaxAge {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Build the data-check-string: sort all key=value pairs except "hash",
|
||||
// join with newlines.
|
||||
var pairs []string
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ func TestValidateInitData(t *testing.T) {
|
|||
params := map[string]string{
|
||||
"query_id": "AAHdF6IQAAAAAN0XohDhrOrc",
|
||||
"user": `{"id":279058397,"first_name":"Vlad"}`,
|
||||
"auth_date": "1234567890",
|
||||
"auth_date": freshAuthDate(),
|
||||
}
|
||||
initData := buildInitData(params, botToken)
|
||||
if !ValidateInitData(initData, botToken) {
|
||||
|
|
@ -71,7 +71,7 @@ func TestValidateInitData(t *testing.T) {
|
|||
params := map[string]string{
|
||||
"query_id": "AAHdF6IQAAAAAN0XohDhrOrc",
|
||||
"user": `{"id":279058397,"first_name":"Vlad"}`,
|
||||
"auth_date": "1234567890",
|
||||
"auth_date": freshAuthDate(),
|
||||
}
|
||||
initData := buildInitData(params, botToken)
|
||||
// Tamper with the data
|
||||
|
|
@ -83,7 +83,7 @@ func TestValidateInitData(t *testing.T) {
|
|||
|
||||
t.Run("wrong bot token", func(t *testing.T) {
|
||||
params := map[string]string{
|
||||
"auth_date": "1234567890",
|
||||
"auth_date": freshAuthDate(),
|
||||
}
|
||||
initData := buildInitData(params, botToken)
|
||||
if ValidateInitData(initData, "wrong-token") {
|
||||
|
|
@ -91,8 +91,19 @@ func TestValidateInitData(t *testing.T) {
|
|||
}
|
||||
})
|
||||
|
||||
t.Run("expired auth_date", func(t *testing.T) {
|
||||
params := map[string]string{
|
||||
"auth_date": "1234567890",
|
||||
}
|
||||
initData := buildInitData(params, botToken)
|
||||
if ValidateInitData(initData, botToken) {
|
||||
t.Error("ValidateInitData() returned true for expired auth_date")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing hash", func(t *testing.T) {
|
||||
if ValidateInitData("auth_date=1234567890", botToken) {
|
||||
now := freshAuthDate()
|
||||
if ValidateInitData("auth_date="+now, botToken) {
|
||||
t.Error("ValidateInitData() returned true for missing hash")
|
||||
}
|
||||
})
|
||||
|
|
@ -186,16 +197,20 @@ func (m *mockSender) SendCommand(senderID, chatID, command string) {}
|
|||
|
||||
const testBotToken = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
|
||||
|
||||
func freshAuthDate() string {
|
||||
return strconv.FormatInt(time.Now().Unix(), 10)
|
||||
}
|
||||
|
||||
func testInitData() string {
|
||||
return buildInitData(map[string]string{
|
||||
"user": `{"id":279058397,"first_name":"Test"}`,
|
||||
"auth_date": "1234567890",
|
||||
"auth_date": freshAuthDate(),
|
||||
}, testBotToken)
|
||||
}
|
||||
|
||||
func TestSSE_AuthRequired(t *testing.T) {
|
||||
notifier := NewStateNotifier()
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
|
|
@ -210,7 +225,7 @@ func TestSSE_AuthRequired(t *testing.T) {
|
|||
|
||||
func TestSSE_Headers(t *testing.T) {
|
||||
notifier := NewStateNotifier()
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
|
|
@ -233,7 +248,7 @@ func TestSSE_Headers(t *testing.T) {
|
|||
|
||||
func TestSSE_InitialEvents(t *testing.T) {
|
||||
notifier := NewStateNotifier()
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
|
|
@ -309,7 +324,7 @@ func TestStateNotifier_SubscribeCycleNoLeak(t *testing.T) {
|
|||
|
||||
func TestSSE_ClientDisconnectCleansUp(t *testing.T) {
|
||||
notifier := NewStateNotifier()
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
|
|
@ -362,7 +377,7 @@ func TestSSE_ClientDisconnectCleansUp(t *testing.T) {
|
|||
func TestSSE_NotifyDrivesSubsequentEvents(t *testing.T) {
|
||||
notifier := NewStateNotifier()
|
||||
provider := &mutatingDataProvider{}
|
||||
h := NewHandler(provider, &mockSender{}, testBotToken, notifier)
|
||||
h := NewHandler(provider, &mockSender{}, testBotToken, notifier, nil, "")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
|
|
@ -391,7 +406,7 @@ func TestSSE_NotifyDrivesSubsequentEvents(t *testing.T) {
|
|||
|
||||
func TestSSE_DiffDedupSuppressesDuplicate(t *testing.T) {
|
||||
notifier := NewStateNotifier()
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
|
|
@ -463,7 +478,7 @@ func (m *mutatingDataProvider) GetGitRepoDetail(name string) GitInfo {
|
|||
// ── Dev proxy tests ──
|
||||
|
||||
func TestDevProxy_RegisterAndActivate(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
|
||||
// Initially empty
|
||||
if got := h.GetDevTarget(); got != "" {
|
||||
|
|
@ -502,7 +517,7 @@ func TestDevProxy_RegisterAndActivate(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestDevProxy_UnregisterActive(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
|
||||
id, _ := h.RegisterDevTarget("api", "http://localhost:8080")
|
||||
h.ActivateDevTarget(id)
|
||||
|
|
@ -519,21 +534,21 @@ func TestDevProxy_UnregisterActive(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestDevProxy_UnregisterNotFound(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
if err := h.UnregisterDevTarget("999"); err == nil {
|
||||
t.Error("expected error for non-existent target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevProxy_ActivateNotFound(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
if err := h.ActivateDevTarget("999"); err == nil {
|
||||
t.Error("expected error for non-existent target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevProxy_LocalhostOnly(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
|
||||
// External host should be rejected at registration
|
||||
if _, err := h.RegisterDevTarget("ext", "http://example.com:3000"); err == nil {
|
||||
|
|
@ -552,7 +567,7 @@ func TestDevProxy_LocalhostOnly(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestDevProxy_IPv4Rewrite(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
|
||||
id, _ := h.RegisterDevTarget("front", "http://localhost:3000")
|
||||
h.ActivateDevTarget(id)
|
||||
|
|
@ -568,7 +583,7 @@ func TestDevProxy_IPv4Rewrite(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestDevProxy_ListDevTargets(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
|
||||
h.RegisterDevTarget("api", "http://localhost:8080")
|
||||
h.RegisterDevTarget("frontend", "http://localhost:3000")
|
||||
|
|
@ -594,7 +609,7 @@ func TestDevProxy_ReverseProxy(t *testing.T) {
|
|||
}))
|
||||
defer backend.Close()
|
||||
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
|
|
@ -621,7 +636,7 @@ func TestDevProxy_ReverseProxy(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestDevProxy_ErrorHandler(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
|
|
@ -643,7 +658,7 @@ func TestDevProxy_ErrorHandler(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestDevProxy_503WhenNotConfigured(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
|
|
@ -658,7 +673,7 @@ func TestDevProxy_503WhenNotConfigured(t *testing.T) {
|
|||
|
||||
func TestDevProxy_APIEndpoint(t *testing.T) {
|
||||
notifier := NewStateNotifier()
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
ts := httptest.NewServer(mux)
|
||||
|
|
@ -724,7 +739,7 @@ func TestDevProxy_APIEndpoint(t *testing.T) {
|
|||
// ── Registration edge cases ──
|
||||
|
||||
func TestDevProxy_RegisterUniqueIDs(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
|
||||
id1, _ := h.RegisterDevTarget("a", "http://localhost:3000")
|
||||
id2, _ := h.RegisterDevTarget("b", "http://localhost:3001")
|
||||
|
|
@ -736,7 +751,7 @@ func TestDevProxy_RegisterUniqueIDs(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestDevProxy_RegisterInvalidURL(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
|
||||
if _, err := h.RegisterDevTarget("bad", "://not-a-url"); err == nil {
|
||||
t.Error("expected error for malformed URL")
|
||||
|
|
@ -744,7 +759,7 @@ func TestDevProxy_RegisterInvalidURL(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestDevProxy_RegisterVariousLocalhost(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
|
|
@ -778,7 +793,7 @@ func TestDevProxy_RegisterVariousLocalhost(t *testing.T) {
|
|||
// ── Activation switching ──
|
||||
|
||||
func TestDevProxy_SwitchActiveTarget(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
|
||||
id1, _ := h.RegisterDevTarget("api", "http://localhost:8080")
|
||||
id2, _ := h.RegisterDevTarget("frontend", "http://localhost:3000")
|
||||
|
|
@ -801,7 +816,7 @@ func TestDevProxy_SwitchActiveTarget(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestDevProxy_ReactivateSameTarget(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
|
||||
id, _ := h.RegisterDevTarget("api", "http://localhost:8080")
|
||||
h.ActivateDevTarget(id)
|
||||
|
|
@ -816,7 +831,7 @@ func TestDevProxy_ReactivateSameTarget(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestDevProxy_ActivateAfterDeactivate(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
|
||||
id, _ := h.RegisterDevTarget("api", "http://localhost:8080")
|
||||
h.ActivateDevTarget(id)
|
||||
|
|
@ -834,7 +849,7 @@ func TestDevProxy_ActivateAfterDeactivate(t *testing.T) {
|
|||
// ── Unregister edge cases ──
|
||||
|
||||
func TestDevProxy_UnregisterInactiveTarget(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
|
||||
id1, _ := h.RegisterDevTarget("api", "http://localhost:8080")
|
||||
id2, _ := h.RegisterDevTarget("frontend", "http://localhost:3000")
|
||||
|
|
@ -853,7 +868,7 @@ func TestDevProxy_UnregisterInactiveTarget(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestDevProxy_UnregisterTwice(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
|
||||
id, _ := h.RegisterDevTarget("api", "http://localhost:8080")
|
||||
h.UnregisterDevTarget(id)
|
||||
|
|
@ -866,7 +881,7 @@ func TestDevProxy_UnregisterTwice(t *testing.T) {
|
|||
// ── IPv4 rewrite edge cases ──
|
||||
|
||||
func TestDevProxy_IPv4NoRewriteFor127(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
|
||||
id, _ := h.RegisterDevTarget("api", "http://127.0.0.1:8080")
|
||||
h.ActivateDevTarget(id)
|
||||
|
|
@ -878,7 +893,7 @@ func TestDevProxy_IPv4NoRewriteFor127(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestDevProxy_IPv4NoRewriteForIPv6(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
|
||||
id, _ := h.RegisterDevTarget("api", "http://[::1]:9000")
|
||||
h.ActivateDevTarget(id)
|
||||
|
|
@ -891,7 +906,7 @@ func TestDevProxy_IPv4NoRewriteForIPv6(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestDevProxy_IPv4RewriteLocalhostNoPort(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
|
||||
id, _ := h.RegisterDevTarget("api", "http://localhost")
|
||||
h.ActivateDevTarget(id)
|
||||
|
|
@ -908,7 +923,7 @@ func TestDevProxy_IPv4RewriteLocalhostNoPort(t *testing.T) {
|
|||
// ── devStatus ──
|
||||
|
||||
func TestDevProxy_DevStatusEmpty(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
|
||||
status := h.devStatus()
|
||||
if status["active"] != false {
|
||||
|
|
@ -927,7 +942,7 @@ func TestDevProxy_DevStatusEmpty(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestDevProxy_DevStatusTargetsButNoActive(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
|
||||
h.RegisterDevTarget("api", "http://localhost:8080")
|
||||
h.RegisterDevTarget("frontend", "http://localhost:3000")
|
||||
|
|
@ -943,7 +958,7 @@ func TestDevProxy_DevStatusTargetsButNoActive(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestDevProxy_DevStatusReturnsOriginalURL(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
|
||||
id, _ := h.RegisterDevTarget("front", "http://localhost:3000")
|
||||
h.ActivateDevTarget(id)
|
||||
|
|
@ -956,7 +971,7 @@ func TestDevProxy_DevStatusReturnsOriginalURL(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestDevProxy_DevStatusActiveID(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
|
||||
id1, _ := h.RegisterDevTarget("api", "http://localhost:8080")
|
||||
id2, _ := h.RegisterDevTarget("frontend", "http://localhost:3000")
|
||||
|
|
@ -986,7 +1001,7 @@ func TestDevProxy_DevStatusActiveID(t *testing.T) {
|
|||
// ── ListDevTargets ──
|
||||
|
||||
func TestDevProxy_ListDevTargetsEmpty(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
targets := h.ListDevTargets()
|
||||
if len(targets) != 0 {
|
||||
t.Errorf("expected 0, got %d", len(targets))
|
||||
|
|
@ -994,7 +1009,7 @@ func TestDevProxy_ListDevTargetsEmpty(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestDevProxy_ListDevTargetsStableOrder(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
|
||||
// Register in reverse order of expected sort
|
||||
h.RegisterDevTarget("c", "http://localhost:3003")
|
||||
|
|
@ -1010,7 +1025,7 @@ func TestDevProxy_ListDevTargetsStableOrder(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestDevProxy_ListDevTargetsAfterUnregister(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
|
||||
id1, _ := h.RegisterDevTarget("a", "http://localhost:3001")
|
||||
h.RegisterDevTarget("b", "http://localhost:3002")
|
||||
|
|
@ -1035,7 +1050,7 @@ func TestDevProxy_PathStripping(t *testing.T) {
|
|||
}))
|
||||
defer backend.Close()
|
||||
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
id, _ := h.RegisterDevTarget("back", backend.URL)
|
||||
|
|
@ -1062,7 +1077,7 @@ func TestDevProxy_PathStripping(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestDevProxy_RootPathStripRedirect(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
id, _ := h.RegisterDevTarget("back", "http://127.0.0.1:19999")
|
||||
|
|
@ -1080,7 +1095,7 @@ func TestDevProxy_RootPathStripRedirect(t *testing.T) {
|
|||
// ── ErrorHandler details ──
|
||||
|
||||
func TestDevProxy_ErrorHandlerHTMLContent(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
|
|
@ -1114,7 +1129,7 @@ func TestDevProxy_NotifierTriggeredOnRegister(t *testing.T) {
|
|||
notifier := NewStateNotifier()
|
||||
ch := notifier.Subscribe()
|
||||
defer notifier.Unsubscribe(ch)
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
||||
|
||||
h.RegisterDevTarget("api", "http://localhost:8080")
|
||||
|
||||
|
|
@ -1127,7 +1142,7 @@ func TestDevProxy_NotifierTriggeredOnRegister(t *testing.T) {
|
|||
|
||||
func TestDevProxy_NotifierTriggeredOnUnregister(t *testing.T) {
|
||||
notifier := NewStateNotifier()
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
||||
id, _ := h.RegisterDevTarget("api", "http://localhost:8080")
|
||||
|
||||
ch := notifier.Subscribe()
|
||||
|
|
@ -1144,7 +1159,7 @@ func TestDevProxy_NotifierTriggeredOnUnregister(t *testing.T) {
|
|||
|
||||
func TestDevProxy_NotifierTriggeredOnActivate(t *testing.T) {
|
||||
notifier := NewStateNotifier()
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
||||
id, _ := h.RegisterDevTarget("api", "http://localhost:8080")
|
||||
|
||||
ch := notifier.Subscribe()
|
||||
|
|
@ -1161,7 +1176,7 @@ func TestDevProxy_NotifierTriggeredOnActivate(t *testing.T) {
|
|||
|
||||
func TestDevProxy_NotifierTriggeredOnDeactivate(t *testing.T) {
|
||||
notifier := NewStateNotifier()
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
||||
id, _ := h.RegisterDevTarget("api", "http://localhost:8080")
|
||||
h.ActivateDevTarget(id)
|
||||
|
||||
|
|
@ -1180,7 +1195,7 @@ func TestDevProxy_NotifierTriggeredOnDeactivate(t *testing.T) {
|
|||
// ── API endpoint edge cases ──
|
||||
|
||||
func TestDevAPI_InvalidJSON(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
ts := httptest.NewServer(mux)
|
||||
|
|
@ -1199,7 +1214,7 @@ func TestDevAPI_InvalidJSON(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestDevAPI_UnknownAction(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
ts := httptest.NewServer(mux)
|
||||
|
|
@ -1220,7 +1235,7 @@ func TestDevAPI_UnknownAction(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestDevAPI_ActivateMissingID(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
ts := httptest.NewServer(mux)
|
||||
|
|
@ -1241,7 +1256,7 @@ func TestDevAPI_ActivateMissingID(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestDevAPI_ActivateNonExistentID(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
ts := httptest.NewServer(mux)
|
||||
|
|
@ -1262,7 +1277,7 @@ func TestDevAPI_ActivateNonExistentID(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestDevAPI_MethodNotAllowed(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
ts := httptest.NewServer(mux)
|
||||
|
|
@ -1281,7 +1296,7 @@ func TestDevAPI_MethodNotAllowed(t *testing.T) {
|
|||
|
||||
func TestDevAPI_GetReturnsTargetsArray(t *testing.T) {
|
||||
notifier := NewStateNotifier()
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
ts := httptest.NewServer(mux)
|
||||
|
|
@ -1314,7 +1329,7 @@ func TestDevAPI_GetReturnsTargetsArray(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestDevAPI_DeactivateWhenAlreadyInactive(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
ts := httptest.NewServer(mux)
|
||||
|
|
@ -1342,7 +1357,7 @@ func TestDevAPI_DeactivateWhenAlreadyInactive(t *testing.T) {
|
|||
// ── Concurrency ──
|
||||
|
||||
func TestDevProxy_ConcurrentRegisterActivate(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
|
||||
const n = 50
|
||||
done := make(chan struct{}, n)
|
||||
|
|
@ -1373,7 +1388,7 @@ func TestDevProxy_ConcurrentRegisterActivate(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestDevProxy_ConcurrentActivateDeactivate(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
|
||||
id1, _ := h.RegisterDevTarget("api", "http://localhost:8080")
|
||||
id2, _ := h.RegisterDevTarget("frontend", "http://localhost:3000")
|
||||
|
|
@ -1407,7 +1422,7 @@ func TestDevProxy_ConcurrentActivateDeactivate(t *testing.T) {
|
|||
|
||||
func TestSSE_DevEventContainsTargets(t *testing.T) {
|
||||
notifier := NewStateNotifier()
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
|
|
@ -1518,7 +1533,7 @@ func TestEscapeHTMLString(t *testing.T) {
|
|||
// ── Handler implements DevTargetManager ──
|
||||
|
||||
func TestHandler_ImplementsDevTargetManager(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
var _ DevTargetManager = h // compile-time check
|
||||
}
|
||||
|
||||
|
|
@ -1558,7 +1573,7 @@ func TestDevProxy_FullLifecycle(t *testing.T) {
|
|||
}))
|
||||
defer backend2.Close()
|
||||
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
|
|
@ -1648,7 +1663,7 @@ func TestDevProxy_FullLifecycle(t *testing.T) {
|
|||
|
||||
func TestSSE_DevEventUpdatesOnActivateDeactivate(t *testing.T) {
|
||||
notifier := NewStateNotifier()
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
ts := httptest.NewServer(mux)
|
||||
|
|
@ -1684,7 +1699,7 @@ func TestSSE_DevEventUpdatesOnActivateDeactivate(t *testing.T) {
|
|||
// ── API auth on /miniapp/api/dev ──
|
||||
|
||||
func TestDevAPI_AuthRequired(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
|
|
@ -1714,7 +1729,7 @@ func TestDevProxy_NoAuthRequired(t *testing.T) {
|
|||
}))
|
||||
defer backend.Close()
|
||||
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
id, _ := h.RegisterDevTarget("back", backend.URL)
|
||||
|
|
@ -1735,7 +1750,7 @@ func TestDevProxy_NoAuthRequired(t *testing.T) {
|
|||
// ── API response JSON structure ──
|
||||
|
||||
func TestDevAPI_ResponseTargetFields(t *testing.T) {
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
ts := httptest.NewServer(mux)
|
||||
|
|
@ -1798,7 +1813,7 @@ func TestDevProxy_HostHeaderForwarded(t *testing.T) {
|
|||
}))
|
||||
defer backend.Close()
|
||||
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
id, _ := h.RegisterDevTarget("back", backend.URL)
|
||||
|
|
@ -1920,7 +1935,7 @@ func TestDevProxy_ResponseRewriting(t *testing.T) {
|
|||
}))
|
||||
defer backend.Close()
|
||||
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
|
|
@ -1958,7 +1973,7 @@ func TestDevProxy_ResponseRewriting_NonHTML(t *testing.T) {
|
|||
}))
|
||||
defer backend.Close()
|
||||
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
|
|
@ -1989,7 +2004,7 @@ func TestDevProxy_ResponseRewriting_ContentLength(t *testing.T) {
|
|||
}))
|
||||
defer backend.Close()
|
||||
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||
mux := http.NewServeMux()
|
||||
h.RegisterRoutes(mux)
|
||||
|
||||
|
|
|
|||
|
|
@ -705,6 +705,68 @@
|
|||
background: rgba(255, 59, 48, 0.1);
|
||||
}
|
||||
|
||||
/* Log viewer */
|
||||
.log-filter-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.log-filter-chip {
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--glass-border-interactive);
|
||||
background: var(--glass-bg);
|
||||
color: var(--hint);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.log-filter-chip.active {
|
||||
background: var(--btn);
|
||||
color: var(--btn-text);
|
||||
border-color: var(--btn);
|
||||
}
|
||||
#logs-content {
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.log-entry {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
padding: 3px 0;
|
||||
font-size: 11px;
|
||||
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
|
||||
line-height: 1.4;
|
||||
border-bottom: 1px solid var(--glass-divider);
|
||||
}
|
||||
.log-ts { color: var(--hint); flex-shrink: 0; white-space: nowrap; }
|
||||
.log-badge {
|
||||
flex-shrink: 0;
|
||||
padding: 0 4px;
|
||||
border-radius: 4px;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
line-height: 16px;
|
||||
}
|
||||
.log-badge.error { background: rgba(255,59,48,0.15); color: #ff3b30; }
|
||||
.log-badge.warn { background: rgba(255,204,0,0.15); color: #cc9900; }
|
||||
.log-badge.info { background: rgba(52,199,89,0.12); color: #34c759; }
|
||||
.log-badge.debug { background: rgba(142,142,147,0.12); color: #8e8e93; }
|
||||
.log-comp { color: var(--link); flex-shrink: 0; font-size: 10px; }
|
||||
.log-msg { flex: 1; word-break: break-all; color: var(--text); }
|
||||
.log-actions { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
|
||||
.log-snap-btn {
|
||||
padding: 6px 12px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border-interactive);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -752,6 +814,21 @@
|
|||
<button class="send-btn" onclick="sendCustomCmd()">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card glass" id="logs-section">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||
<span class="card-title" style="margin:0">Logs</span>
|
||||
<span id="logs-status" class="dev-target-dot"></span>
|
||||
</div>
|
||||
<div class="log-filter-chips">
|
||||
<button class="log-filter-chip active" data-component="">All</button>
|
||||
<button class="log-filter-chip" data-component="telego">Telego</button>
|
||||
<button class="log-filter-chip" data-component="dev-console">Console</button>
|
||||
</div>
|
||||
<div id="logs-content"></div>
|
||||
<div class="log-actions">
|
||||
<button class="log-snap-btn" onclick="saveLogSnapshot()">Save Snapshot</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="git" class="panel">
|
||||
|
|
@ -813,6 +890,8 @@ tabs.forEach((tab, index) => {
|
|||
if (p === 'session' && !fresh) loadSession();
|
||||
if (p === 'git') loadGit();
|
||||
if (p === 'dev' && !fresh) loadDev();
|
||||
if (p === 'config') connectLogsWs();
|
||||
else disconnectLogsWs();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1452,6 +1531,111 @@ connectSSE();
|
|||
|
||||
// Initial load (fallback for tabs not covered by initial SSE burst)
|
||||
loadPlan();
|
||||
|
||||
// ── Logs WebSocket ──
|
||||
var logsWs = null;
|
||||
var logsComponent = '';
|
||||
var logsEntries = [];
|
||||
var logsReconnectTimer = null;
|
||||
|
||||
function connectLogsWs() {
|
||||
if (logsWs && logsWs.readyState <= 1) return;
|
||||
var wsProto = (location.protocol === 'https:') ? 'wss:' : 'ws:';
|
||||
var wsUrl = wsProto + '//' + location.host + '/miniapp/api/logs/ws?initData=' +
|
||||
encodeURIComponent(initData);
|
||||
if (logsComponent) wsUrl += '&component=' + encodeURIComponent(logsComponent);
|
||||
|
||||
logsWs = new WebSocket(wsUrl);
|
||||
var statusDot = document.getElementById('logs-status');
|
||||
|
||||
logsWs.onopen = function() {
|
||||
statusDot.classList.add('on');
|
||||
};
|
||||
|
||||
logsWs.onmessage = function(e) {
|
||||
var msg = JSON.parse(e.data);
|
||||
if (msg.type === 'init') {
|
||||
logsEntries = msg.entries || [];
|
||||
} else if (msg.type === 'entry') {
|
||||
logsEntries.push(msg.entry);
|
||||
if (logsEntries.length > 200) logsEntries.shift();
|
||||
}
|
||||
renderLogs();
|
||||
};
|
||||
|
||||
logsWs.onclose = function() {
|
||||
statusDot.classList.remove('on');
|
||||
logsWs = null;
|
||||
// Auto-reconnect if Config tab is active
|
||||
var activeTab = document.querySelector('.tab.active');
|
||||
if (activeTab && activeTab.dataset.panel === 'config') {
|
||||
logsReconnectTimer = setTimeout(connectLogsWs, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
logsWs.onerror = function() {
|
||||
// onclose will handle reconnect
|
||||
};
|
||||
}
|
||||
|
||||
function disconnectLogsWs() {
|
||||
if (logsReconnectTimer) { clearTimeout(logsReconnectTimer); logsReconnectTimer = null; }
|
||||
if (logsWs) { logsWs.close(); logsWs = null; }
|
||||
document.getElementById('logs-status').classList.remove('on');
|
||||
}
|
||||
|
||||
function renderLogs() {
|
||||
var container = document.getElementById('logs-content');
|
||||
var wasScrolledToBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 30;
|
||||
var html = '';
|
||||
for (var i = 0; i < logsEntries.length; i++) {
|
||||
var e = logsEntries[i];
|
||||
var lvl = (e.level || 'info').toLowerCase();
|
||||
var ts = e.timestamp ? e.timestamp.substring(11, 19) : '';
|
||||
var comp = e.component ? '<span class="log-comp">' + escapeHtml(e.component) + '</span>' : '';
|
||||
html += '<div class="log-entry">' +
|
||||
'<span class="log-ts">' + ts + '</span>' +
|
||||
'<span class="log-badge ' + lvl + '">' + lvl + '</span>' +
|
||||
comp +
|
||||
'<span class="log-msg">' + escapeHtml(e.message || '') + '</span>' +
|
||||
'</div>';
|
||||
}
|
||||
container.innerHTML = html;
|
||||
if (wasScrolledToBottom) container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
// Filter chip click
|
||||
document.querySelector('.log-filter-chips').addEventListener('click', function(e) {
|
||||
var chip = e.target.closest('.log-filter-chip');
|
||||
if (!chip) return;
|
||||
document.querySelectorAll('.log-filter-chip').forEach(function(c) { c.classList.remove('active'); });
|
||||
chip.classList.add('active');
|
||||
logsComponent = chip.dataset.component || '';
|
||||
logsEntries = [];
|
||||
renderLogs();
|
||||
disconnectLogsWs();
|
||||
connectLogsWs();
|
||||
});
|
||||
|
||||
async function saveLogSnapshot() {
|
||||
try {
|
||||
var res = await fetch(API_BASE + '/miniapp/api/logs/snapshot?initData=' + encodeURIComponent(initData), {
|
||||
method: 'POST'
|
||||
});
|
||||
if (!res.ok) throw new Error('API error: ' + res.status);
|
||||
var data = await res.json();
|
||||
if (data.download_url) {
|
||||
var a = document.createElement('a');
|
||||
a.href = API_BASE + data.download_url + '?initData=' + encodeURIComponent(initData);
|
||||
a.download = '';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
} catch(e) {
|
||||
// silent fail
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue