Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a5102117c | |||
| 9000eeeaa7 | |||
| df433a9179 | |||
| 8c43a96817 | |||
| 6802a432d7 | |||
| e95b3315b0 | |||
| 034738fa3f | |||
| d1abd2b15d | |||
| cc502c7173 | |||
| c04c9e6cf2 | |||
| 1e33bfa4bc | |||
| 1e7efd9c3b |
@@ -10,7 +10,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GO_VERSION: "1.22.2"
|
||||
GO_VERSION: "1.25.0"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
||||
@@ -50,12 +50,13 @@ title_template = "{agent} — {context}" # context = 工作目录名
|
||||
body_stop = "等待输入"
|
||||
|
||||
[inbox]
|
||||
enabled = false
|
||||
|
||||
[remote]
|
||||
enabled = true
|
||||
socket = "/run/user/1000/agent-notify.sock"
|
||||
remote_socket = "/tmp/agent-notify-longbin.sock"
|
||||
addr = "127.0.0.1:17777"
|
||||
fallback_local = true
|
||||
timeout_ms = 500
|
||||
url = "http://your-server:8080"
|
||||
token = "shared-secret"
|
||||
timeout_ms = 2000
|
||||
```
|
||||
|
||||
## 命令
|
||||
@@ -64,44 +65,38 @@ timeout_ms = 500
|
||||
agent-notify send --event stop
|
||||
agent-notify hook cursor stop # Cursor CLI stop hook
|
||||
agent-notify hook claude stop # Claude Stop hook(输出 terminalSequence JSON)
|
||||
agent-notify inbox serve # 本地接收远程通知记录
|
||||
agent-notify inbox list # 列出未处理通知
|
||||
agent-notify inbox show <id>
|
||||
agent-notify inbox done <id>
|
||||
agent-notify inbox tui # Bubble Tea TUI
|
||||
agent-notify inbox ssh-config install # 自动写 ~/.ssh/config RemoteForward
|
||||
agent-notify server --listen :8080 # 远程汇总服务(见下方 Remote Dashboard)
|
||||
agent-notify inbox serve # 已弃用:见下方 Inbox(deprecated)
|
||||
agent-notify test cursor [-v]
|
||||
agent-notify test claude [--apply]
|
||||
agent-notify doctor
|
||||
agent-notify install --all [--force]
|
||||
```
|
||||
|
||||
## 本地汇总 Inbox
|
||||
## Remote Dashboard
|
||||
|
||||
在本地 Ghostty 所在机器启动接收服务:
|
||||
在可访问的机器上启动汇总服务(需设置与客户端相同的 token):
|
||||
|
||||
```bash
|
||||
agent-notify inbox serve
|
||||
AGENT_NOTIFY_TOKEN=secret agent-notify server --listen :8080
|
||||
```
|
||||
|
||||
让命令自动写 SSH `RemoteForward` 配置:
|
||||
在 `~/.config/agent-notify/config.toml` 中启用远程上报(`agent-notify install` 会生成带 `[remote]` 的默认配置):
|
||||
|
||||
```bash
|
||||
agent-notify inbox ssh-config install
|
||||
```toml
|
||||
[remote]
|
||||
enabled = true
|
||||
url = "http://your-server:8080"
|
||||
token = "shared-secret"
|
||||
```
|
||||
|
||||
命令会在 `~/.ssh/config` 写入一个托管块,并把写入内容打印出来。已有 SSH 连接需要重连后才会生效。
|
||||
远程 Agent hook 在 `stop` 等事件时会向该服务 POST 状态;浏览器打开服务根路径即可查看 Web UI(输入 token 后加载会话列表)。
|
||||
|
||||
默认写入的转发形式是:远程创建 `/tmp/agent-notify-$USER.sock`,转发到本地 `$XDG_RUNTIME_DIR/agent-notify.sock`。远程 hook 会尝试通过这个 SSH 反向转发把记录写回本地 inbox;如果本地接收服务不可用,会 fallback 写到远程机器自己的 `~/.local/state/agent-notify/inbox.jsonl`,避免丢记录。
|
||||
## 本地汇总 Inbox(已弃用)
|
||||
|
||||
查看和处理:
|
||||
> **Deprecated:** 请改用上方的 Remote Dashboard(`agent-notify server` + `[remote]`)。`agent-notify inbox` 子命令仍会打印弃用警告并继续工作,便于过渡。
|
||||
|
||||
```bash
|
||||
agent-notify inbox list
|
||||
agent-notify inbox show <id>
|
||||
agent-notify inbox done <id>
|
||||
agent-notify inbox tui
|
||||
```
|
||||
旧流程:在本地 Ghostty 机器运行 `agent-notify inbox serve`,用 `agent-notify inbox ssh-config install` 配置 SSH `RemoteForward`,通过 Unix socket 把远程通知写回本地 `inbox.jsonl`,并用 `inbox list` / `tui` 等处理。
|
||||
|
||||
## Hook 配置位置
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
)
|
||||
|
||||
func cmdInbox(args []string, stdout, stderr io.Writer) error {
|
||||
fmt.Fprintln(stderr, "warning: inbox is deprecated; use agent-notify server and [remote] config")
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("usage: agent-notify inbox <list|show|done|rm|clear|serve|ssh-config>")
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ func run(cmd string, args []string) error {
|
||||
return cmdLogs(args)
|
||||
case "inbox":
|
||||
return cmdInbox(args, os.Stdout, os.Stderr)
|
||||
case "server":
|
||||
return cmdServer(args)
|
||||
case "version", "-V":
|
||||
return cmdVersion()
|
||||
case "help", "-h", "--help":
|
||||
@@ -190,6 +192,20 @@ func cmdDoctor() error {
|
||||
}
|
||||
cfgPath := config.DefaultPath()
|
||||
fmt.Printf(" config=%s\n", cfgPath)
|
||||
cfg, err := config.LoadDefault()
|
||||
if err != nil {
|
||||
fmt.Printf("✗ config load failed: %v\n", err)
|
||||
} else if cfg.Remote.Enabled {
|
||||
if cfg.Remote.URL == "" {
|
||||
fmt.Println("✗ remote.enabled but remote.url is empty — set url to your agent-notify server (e.g. http://host:8080)")
|
||||
}
|
||||
if cfg.Remote.Token == "" {
|
||||
fmt.Println("✗ remote.enabled but remote.token is empty — must match AGENT_NOTIFY_TOKEN on agent-notify server")
|
||||
}
|
||||
if cfg.Remote.URL != "" && cfg.Remote.Token != "" {
|
||||
fmt.Printf("✓ remote dashboard configured (%s)\n", cfg.Remote.URL)
|
||||
}
|
||||
}
|
||||
fmt.Printf(" hook_log=%s\n", logx.Path())
|
||||
return nil
|
||||
}
|
||||
@@ -205,6 +221,7 @@ Commands:
|
||||
test claude [--apply]
|
||||
logs [--tail 30]
|
||||
inbox <list|show|done|rm|clear|serve|ssh-config>
|
||||
server [--listen ADDR] [--db PATH] [--token TOKEN]
|
||||
version
|
||||
doctor
|
||||
`)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/longbin/agent-notify/internal/server"
|
||||
)
|
||||
|
||||
func cmdServer(args []string) error {
|
||||
fs := flag.NewFlagSet("server", flag.ExitOnError)
|
||||
listen := fs.String("listen", ":8080", "listen address")
|
||||
db := fs.String("db", "./agent-notify.db", "SQLite database path")
|
||||
token := fs.String("token", "", "API bearer token")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
tok := *token
|
||||
if tok == "" {
|
||||
tok = os.Getenv("AGENT_NOTIFY_TOKEN")
|
||||
}
|
||||
if tok == "" {
|
||||
return fmt.Errorf("--token or AGENT_NOTIFY_TOKEN is required")
|
||||
}
|
||||
|
||||
store, err := server.Open(*db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
handler := server.NewHandler(store, tok)
|
||||
fmt.Fprintf(os.Stderr, "agent-notify server listening on %s\n", *listen)
|
||||
return http.ListenAndServe(*listen, handler)
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
# Agent Notify Remote Dashboard Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace local inbox aggregation with a self-hosted HTTP server and Web dashboard that shows real-time Agent status across all machines, keyed by hostname+IP × cwd × agent type, with last user/agent message details from transcripts (≤2000 chars each).
|
||||
|
||||
**Architecture:** Add `internal/remote`, `internal/transcript`, `internal/hostmeta`, and `internal/server` (SQLite + REST + Bearer auth). Extend CLI with `agent-notify server` and embed `web/`. Hooks call `reportRemote` instead of `recordInbox`. Deprecate inbox commands with stderr warnings; default `[inbox] enabled = false`, new `[remote]` config section.
|
||||
|
||||
**Tech Stack:** Go stdlib, `modernc.org/sqlite` (pure Go, no CGO), `embed` for static web, existing `config`, `hook`, `logx`.
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
| Path | Responsibility |
|
||||
|------|----------------|
|
||||
| `internal/config/config.go` | `[remote]` struct; inbox default `enabled=false` |
|
||||
| `internal/hostmeta/hostmeta.go` | hostname + IP collection |
|
||||
| `internal/transcript/transcript.go` | parse jsonl tail, last user/agent, truncate |
|
||||
| `internal/remote/report.go` | `StatusReport`, `SessionKey`, HTTP client |
|
||||
| `internal/server/store.go` | SQLite upsert/list/meta, offline |
|
||||
| `internal/server/http.go` | handlers + Bearer middleware |
|
||||
| `internal/hook/remote.go` | `reportRemote` from cursor/claude hooks |
|
||||
| `internal/hook/cursor.go` | extend payload; call reportRemote |
|
||||
| `cmd/agent-notify/server.go` | `server` subcommand |
|
||||
| `web/index.html`, `web/app.js`, `web/style.css` | dashboard |
|
||||
| `README.md` | remote server + migration from inbox |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Remote config and host metadata
|
||||
|
||||
**Files:**
|
||||
- Modify: `internal/config/config.go`
|
||||
- Modify: `internal/config/config_test.go`
|
||||
- Create: `internal/hostmeta/hostmeta.go`
|
||||
- Create: `internal/hostmeta/hostmeta_test.go`
|
||||
|
||||
- [ ] **Step 1: Add failing config tests**
|
||||
|
||||
```go
|
||||
func TestDefaultRemoteDisabledInbox(t *testing.T) {
|
||||
cfg := Default()
|
||||
if cfg.Inbox.Enabled {
|
||||
t.Fatal("inbox should default disabled")
|
||||
}
|
||||
if cfg.Remote.TimeoutMS != 2000 {
|
||||
t.Fatalf("remote timeout: got %d", cfg.Remote.TimeoutMS)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test — expect FAIL**
|
||||
|
||||
Run: `go test ./internal/config -run TestDefaultRemote -v`
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
Add to `config.go`:
|
||||
|
||||
```go
|
||||
type Remote struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
URL string `toml:"url"`
|
||||
Token string `toml:"token"`
|
||||
TimeoutMS int `toml:"timeout_ms"`
|
||||
}
|
||||
```
|
||||
|
||||
`Default()`: `Inbox.Enabled: false`, `Remote: {Enabled: false, TimeoutMS: 2000}`.
|
||||
|
||||
- [ ] **Step 4: hostmeta tests**
|
||||
|
||||
```go
|
||||
func TestHostnameNonEmpty(t *testing.T) {
|
||||
h, err := Hostname()
|
||||
if err != nil || h == "" {
|
||||
t.Fatalf("hostname: %q err=%v", h, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPsSkipsLoopback(t *testing.T) {
|
||||
ips := IPs()
|
||||
for _, ip := range ips {
|
||||
if ip == "127.0.0.1" || ip == "::1" {
|
||||
t.Fatalf("loopback in ips: %v", ips)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implement `Hostname()` via `os.Hostname()`, `IPs()` via `net.Interfaces()` collecting non-loopback addresses.
|
||||
|
||||
- [ ] **Step 5: Run tests**
|
||||
|
||||
Run: `go test ./internal/config ./internal/hostmeta -v`
|
||||
Expected: PASS
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Transcript parser
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/transcript/transcript.go`
|
||||
- Create: `internal/transcript/transcript_test.go`
|
||||
|
||||
- [ ] **Step 1: Failing tests with fixture jsonl**
|
||||
|
||||
Create `internal/transcript/testdata/sample.jsonl`:
|
||||
|
||||
```jsonl
|
||||
{"role":"user","content":"hello"}
|
||||
{"role":"assistant","content":"world"}
|
||||
{"role":"user","content":"second question"}
|
||||
{"role":"assistant","content":"final answer"}
|
||||
```
|
||||
|
||||
```go
|
||||
func TestLastMessages(t *testing.T) {
|
||||
u, a, err := LastMessages("testdata/sample.jsonl", 2000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u != "second question" || a != "final answer" {
|
||||
t.Fatalf("got user=%q agent=%q", u, a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate2000(t *testing.T) {
|
||||
long := strings.Repeat("x", 3000)
|
||||
// fixture with one long assistant line
|
||||
u, a, err := LastMessages("testdata/long.jsonl", 2000)
|
||||
if err != nil || len(a) != 2000 {
|
||||
t.Fatalf("len=%d err=%v", len(a), err)
|
||||
}
|
||||
_ = u
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — expect FAIL**
|
||||
|
||||
Run: `go test ./internal/transcript -v`
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
- `LastMessages(path string, maxLen int) (lastUser, lastAgent string, err error)`
|
||||
- Read file; if size > 256*1024, seek to tail only
|
||||
- Decode line-by-line JSON; track last `role==user` and `role==assistant` (also accept `type` field aliases if present in samples)
|
||||
- `truncate(s, maxLen)` with rune-safe cut
|
||||
|
||||
- [ ] **Step 4: Run — PASS**
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Remote report model and client
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/remote/report.go`
|
||||
- Create: `internal/remote/client.go`
|
||||
- Create: `internal/remote/report_test.go`
|
||||
|
||||
- [ ] **Step 1: Failing session key test**
|
||||
|
||||
```go
|
||||
func TestSessionKeyStable(t *testing.T) {
|
||||
k1 := SessionKey("host", "10.0.0.1", "/proj", "Cursor")
|
||||
k2 := SessionKey("host", "10.0.0.1", "/proj", "Cursor")
|
||||
if k1 != k2 || len(k1) != 32 {
|
||||
t.Fatalf("key=%q", k1)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Failing client test with httptest**
|
||||
|
||||
```go
|
||||
func TestClientPostStatus(t *testing.T) {
|
||||
var got remote.StatusReport
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer secret" {
|
||||
http.Error(w, "auth", 401)
|
||||
return
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&got)
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := remote.NewClient(remote.ClientConfig{URL: srv.URL, Token: "secret", Timeout: time.Second})
|
||||
err := c.Report(context.Background(), remote.StatusReport{Hostname: "h", Agent: "Cursor", CWD: "/p", Status: "waiting"})
|
||||
if err != nil || got.Hostname != "h" {
|
||||
t.Fatalf("err=%v got=%+v", err, got)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
```go
|
||||
type StatusReport struct {
|
||||
Hostname string `json:"hostname"`
|
||||
IPs []string `json:"ips,omitempty"`
|
||||
Agent string `json:"agent"`
|
||||
CWD string `json:"cwd"`
|
||||
Status string `json:"status"`
|
||||
Event string `json:"event,omitempty"`
|
||||
ConversationID string `json:"conversation_id,omitempty"`
|
||||
LastUser string `json:"last_user,omitempty"`
|
||||
LastAgent string `json:"last_agent,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func SessionKey(hostname, primaryIP, cwd, agent string) string { ... sha256 hex first 32 ... }
|
||||
```
|
||||
|
||||
`Client.Report` POST `{url}/api/v1/status` with Bearer.
|
||||
|
||||
- [ ] **Step 4: PASS**
|
||||
|
||||
Run: `go test ./internal/remote -v`
|
||||
|
||||
---
|
||||
|
||||
### Task 4: SQLite server store
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/server/store.go`
|
||||
- Create: `internal/server/store_test.go`
|
||||
|
||||
- [ ] **Step 1: Add `modernc.org/sqlite` to go.mod**
|
||||
|
||||
Run: `go get modernc.org/sqlite`
|
||||
|
||||
- [ ] **Step 2: Failing store tests**
|
||||
|
||||
```go
|
||||
func TestUpsertAndList(t *testing.T) {
|
||||
db := filepath.Join(t.TempDir(), "test.db")
|
||||
s, err := Open(db)
|
||||
// upsert twice same key, different status
|
||||
// list returns 1 row with latest status
|
||||
}
|
||||
|
||||
func TestOfflineAfter5Min(t *testing.T) {
|
||||
// insert row with updated_at 10 minutes ago
|
||||
// List applies offline when now-updated > 5min
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Implement Open, Upsert(report), List(filters), Meta()**
|
||||
|
||||
Merge rule for `last_user`/`last_agent`: empty incoming does not overwrite stored non-empty.
|
||||
|
||||
`List` computes `offline` when `time.Since(updated_at) > 5*time.Minute`.
|
||||
|
||||
- [ ] **Step 4: PASS**
|
||||
|
||||
Run: `go test ./internal/server -run Store -v`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: HTTP API and auth
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/server/http.go`
|
||||
- Create: `internal/server/http_test.go`
|
||||
|
||||
- [ ] **Step 1: Failing handler tests**
|
||||
|
||||
- POST without token → 401
|
||||
- POST valid → 200, row in DB
|
||||
- GET with `?agent=Cursor` filters
|
||||
- GET `/healthz` without token → 200
|
||||
|
||||
- [ ] **Step 2: Implement**
|
||||
|
||||
```go
|
||||
func NewHandler(store *Store, token string) http.Handler
|
||||
```
|
||||
|
||||
Middleware: skip auth for `/healthz` and static `/` assets; require `Authorization: Bearer ` + constant-time compare.
|
||||
|
||||
Handlers: `handleStatusPost`, `handleStatusList`, `handleMeta`.
|
||||
|
||||
- [ ] **Step 3: PASS**
|
||||
|
||||
Run: `go test ./internal/server -v`
|
||||
|
||||
---
|
||||
|
||||
### Task 6: `agent-notify server` command
|
||||
|
||||
**Files:**
|
||||
- Create: `cmd/agent-notify/server.go`
|
||||
- Modify: `cmd/agent-notify/main.go`
|
||||
- Modify: `cmd/agent-notify/version.go` usage string if needed
|
||||
|
||||
- [ ] **Step 1: Implement cmdServer**
|
||||
|
||||
Flags: `--listen` (default `:8080`), `--db` (default `./agent-notify.db`), `--token` (fallback env `AGENT_NOTIFY_TOKEN`). Error if token empty.
|
||||
|
||||
Wire `server.Open`, `server.NewHandler`, `http.ListenAndServe`.
|
||||
|
||||
- [ ] **Step 2: Manual smoke**
|
||||
|
||||
```bash
|
||||
AGENT_NOTIFY_TOKEN=test go run ./cmd/agent-notify server --listen 127.0.0.1:18080 &
|
||||
curl -s http://127.0.0.1:18080/healthz
|
||||
curl -s -H "Authorization: Bearer test" -H "Content-Type: application/json" \
|
||||
-d '{"hostname":"h","ips":["1.2.3.4"],"agent":"Cursor","cwd":"/x","status":"waiting","updated_at":"2026-06-02T00:00:00Z"}' \
|
||||
http://127.0.0.1:18080/api/v1/status
|
||||
```
|
||||
|
||||
Expected: `{"ok":true}`
|
||||
|
||||
- [ ] **Step 3: Update printUsage** — add `server` line
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Hook integration
|
||||
|
||||
**Files:**
|
||||
- Create: `internal/hook/remote.go`
|
||||
- Modify: `internal/hook/cursor.go`
|
||||
- Modify: `internal/hook/claude.go`
|
||||
- Modify: `internal/hook/inbox.go` (leave but unused path)
|
||||
- Modify: `internal/hook/hook_test.go`
|
||||
|
||||
- [ ] **Step 1: Extend cursorPayload**
|
||||
|
||||
```go
|
||||
type cursorPayload struct {
|
||||
WorkspaceRoots []string `json:"workspace_roots"`
|
||||
HookEventName string `json:"hook_event_name"`
|
||||
Status string `json:"status"`
|
||||
ConversationID string `json:"conversation_id"`
|
||||
TranscriptPath string `json:"transcript_path"`
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Implement reportRemote**
|
||||
|
||||
```go
|
||||
func reportRemote(cfg config.Config, agent, event, cwd string, payload transcriptInput) {
|
||||
if !cfg.Remote.Enabled || cfg.Remote.URL == "" {
|
||||
return
|
||||
}
|
||||
// map event -> status
|
||||
// hostmeta Hostname, IPs
|
||||
// if transcript path: LastMessages
|
||||
// client.Report with timeout
|
||||
}
|
||||
```
|
||||
|
||||
Replace `recordInbox(...)` calls in `RunCursor` / `RunClaude` with `reportRemote`.
|
||||
|
||||
- [ ] **Step 3: Hook tests**
|
||||
|
||||
Inject mock client; assert Report called on stop when remote enabled; assert NOT called when disabled; assert hook still returns nil when Report fails.
|
||||
|
||||
Run: `go test ./internal/hook -v`
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Embedded Web UI
|
||||
|
||||
**Files:**
|
||||
- Create: `web/index.html`
|
||||
- Create: `web/app.js`
|
||||
- Create: `web/style.css`
|
||||
- Create: `internal/server/web.go` (embed + FileServer)
|
||||
- Modify: `internal/server/http.go` — serve `/` from embed
|
||||
|
||||
- [ ] **Step 1: Minimal dashboard**
|
||||
|
||||
- Token prompt → sessionStorage
|
||||
- Fetch `/api/v1/meta` and `/api/v1/status` every 3s with Bearer
|
||||
- Sidebar filters (host, agent, status); table columns per spec §7
|
||||
- Expand row for `last_user` / `last_agent` pre blocks
|
||||
|
||||
- [ ] **Step 2: Manual check**
|
||||
|
||||
Open `http://127.0.0.1:18080/`, enter token, see seeded row.
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Deprecate inbox + docs
|
||||
|
||||
**Files:**
|
||||
- Modify: `cmd/agent-notify/inbox.go` — print `warning: inbox is deprecated; use remote server` to stderr on any subcommand
|
||||
- Modify: `internal/install/install.go` — default config includes `[remote]` commented template
|
||||
- Modify: `internal/config/config.go` — `WriteDefault` includes remote section
|
||||
- Modify: `README.md` — new Remote Dashboard section; inbox marked deprecated
|
||||
- Modify: `internal/hook/inbox.go` — add comment deprecated
|
||||
|
||||
- [ ] **Step 1: doctor checks remote**
|
||||
|
||||
When `remote.enabled`, warn if `url` or `token` empty.
|
||||
|
||||
- [ ] **Step 2: Full test suite**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: all PASS (existing inbox tests still pass; inbox code remains)
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Final verification
|
||||
|
||||
- [ ] **Step 1: Cross-build**
|
||||
|
||||
Run: `make test && make cross VERSION=v0.3.0`
|
||||
|
||||
- [ ] **Step 2: README example end-to-end**
|
||||
|
||||
Document server start + client config + hook stop updates dashboard.
|
||||
|
||||
---
|
||||
|
||||
## Plan self-review (spec coverage)
|
||||
|
||||
| Spec § | Task |
|
||||
|--------|------|
|
||||
| §3 architecture | Tasks 4–8 |
|
||||
| §4 session_key + merge | Task 3, 4 |
|
||||
| §5 API + Bearer | Task 5, 6 |
|
||||
| §6 hook + transcript | Task 2, 7 |
|
||||
| §7 Web UI | Task 8 |
|
||||
| §8 config + deprecate inbox | Task 1, 9 |
|
||||
| §9 errors/tests | Tasks 2–7, 10 |
|
||||
|
||||
No TBD placeholders in task steps.
|
||||
@@ -0,0 +1,238 @@
|
||||
# agent-notify 第二期:远端状态仪表盘 设计规格
|
||||
|
||||
**日期:** 2026-06-02
|
||||
**状态:** 已批准(brainstorming)
|
||||
**目标:** 各开发机 Hook 将 Agent 实时状态上报到自托管 HTTP Server,Web 页面按机器、工作目录、Agent 类型分类查看;详情含最后一次用户与 Agent 消息(各 ≤2000 字符)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
第一期(v0.2 inbox)在本地聚合通知记录(`inbox serve` + SSH `RemoteForward` + CLI/TUI)。第二期改为**中心化远端 Server + Web 仪表盘**,满足:
|
||||
|
||||
- **实时状态**(非纯事件流):每台机器、每个工作目录、每种 Agent 类型一行当前状态
|
||||
- 上报包含 **hostname + IP**、**工作目录**
|
||||
- 详情:从 hook 提供的 **transcript** 解析最后 user/assistant 各一条,**单条最多 2000 字符**
|
||||
- Web 支持按机器 / 目录 / Agent / 状态 **分类筛选**
|
||||
- **单机自托管**,共享 **Bearer Token**;网络暴露由用户自行配置
|
||||
- **弃用本地 inbox**:Hook 默认只报远端;`inbox` 子命令标记 deprecated,代码下个大版本再删
|
||||
|
||||
### 成功标准
|
||||
|
||||
- `agent-notify server` 可启动 HTTP 服务(API + 内嵌 Web)
|
||||
- 各机器配置 `[remote]` 后,Cursor/Claude hook 在发通知后上报状态,失败不影响 hook 退出码
|
||||
- Web 展示所有机器的合并视图,默认按 `updated_at` 降序;`offline` 由 server 根据 5 分钟无更新判定
|
||||
- 同一 `hostname + ip + cwd + agent` 仅一条记录,多会话取最近活跃
|
||||
|
||||
### 非目标(第二期)
|
||||
|
||||
- 历史事件流、告警推送
|
||||
- 多用户 RBAC、每机器独立 token
|
||||
- `beforeSubmitPrompt` 实时更新 `last_user`
|
||||
- 删除 inbox 源码(仅 deprecated)
|
||||
- macOS/Windows server 部署文档(实现保持可交叉编译,优先 Linux 验证)
|
||||
|
||||
---
|
||||
|
||||
## 2. 方案选择
|
||||
|
||||
| 方案 | 描述 | 结论 |
|
||||
|------|------|------|
|
||||
| A | 同仓库 `agent-notify server` + SQLite + embed Web | **选用** |
|
||||
| B | 独立 server 二进制 | 版本对齐成本高,YAGNI |
|
||||
| C | 内存态 + JSON 快照 | 查询与持久化弱 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 架构
|
||||
|
||||
```
|
||||
┌─────────────────┐ hook ┌──────────────────┐
|
||||
│ Cursor / Claude │ ────────────► │ agent-notify hook │
|
||||
└─────────────────┘ └────────┬─────────┘
|
||||
│ OSC 777(不变)
|
||||
│ POST /api/v1/status
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ agent-notify server │
|
||||
│ SQLite + HTTP API │
|
||||
│ embed Web (轮询 3s) │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
### 组件
|
||||
|
||||
1. **`internal/remote`** — 状态模型、`session_key`、HTTP 上报客户端
|
||||
2. **`internal/transcript`** — 从 `transcript_path` jsonl 解析最后 user/assistant,截断 2000
|
||||
3. **`internal/hostmeta`** — `hostname` + 非 loopback IP 列表
|
||||
4. **`internal/server`** — SQLite store、Bearer 中间件、REST handlers、`offline` 计算
|
||||
5. **`web/`** — 静态仪表盘(embed)
|
||||
6. **Hook 改动** — `recordInbox` → `reportRemote`;`[inbox]` 默认关闭并 deprecated
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据模型
|
||||
|
||||
### Session 键(合并规则 C)
|
||||
|
||||
```
|
||||
session_key = SHA256(hostname + "\0" + primary_ip + "\0" + cwd + "\0" + agent)[:32] hex
|
||||
```
|
||||
|
||||
- `primary_ip`:上报 `ips` 中第一个 IPv4,无则空字符串
|
||||
- 同一键 **upsert** 覆盖,不保留历史行
|
||||
- `conversation_id` 仅存字段,不拆行
|
||||
|
||||
### 状态枚举
|
||||
|
||||
| status | 含义 | 典型 hook |
|
||||
|--------|------|-----------|
|
||||
| `running` | Agent 刚产出回复 | `response` |
|
||||
| `waiting` | 等待用户输入 | `stop` |
|
||||
| `tool` | 工具执行中 | `tool` |
|
||||
| `idle` | 长时间空闲 | `idle` |
|
||||
| `offline` | 5 分钟无更新 | server 计算 |
|
||||
|
||||
### 上报 JSON(`POST /api/v1/status`)
|
||||
|
||||
```json
|
||||
{
|
||||
"hostname": "dev-box",
|
||||
"ips": ["192.168.1.10"],
|
||||
"agent": "Cursor",
|
||||
"cwd": "/home/user/proj",
|
||||
"status": "waiting",
|
||||
"event": "stop",
|
||||
"conversation_id": "uuid",
|
||||
"last_user": "…",
|
||||
"last_agent": "…",
|
||||
"updated_at": "2026-06-02T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
- `last_user` / `last_agent`:可选;有 transcript 时更新,截断 2000;读失败时不覆盖已有字段(server merge:空字符串不覆盖非空列)
|
||||
- `updated_at`:客户端 UTC;server 亦可写入 `received_at`
|
||||
|
||||
### SQLite 表 `sessions`
|
||||
|
||||
| 列 | 类型 | 说明 |
|
||||
|----|------|------|
|
||||
| session_key | TEXT PK | |
|
||||
| hostname | TEXT | |
|
||||
| ips | TEXT | JSON 数组 |
|
||||
| agent | TEXT | |
|
||||
| cwd | TEXT | |
|
||||
| status | TEXT | |
|
||||
| event | TEXT | 最近触发事件名 |
|
||||
| conversation_id | TEXT | |
|
||||
| last_user | TEXT | |
|
||||
| last_agent | TEXT | |
|
||||
| updated_at | TEXT | ISO8601 |
|
||||
| received_at | TEXT | server 写入 |
|
||||
|
||||
索引:`hostname`, `cwd`, `agent`, `updated_at`。
|
||||
|
||||
---
|
||||
|
||||
## 5. API
|
||||
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
|------|------|------|------|
|
||||
| POST | `/api/v1/status` | Bearer | Upsert |
|
||||
| GET | `/api/v1/status` | Bearer | 列表;query: `host`, `cwd`, `agent`, `status` |
|
||||
| GET | `/api/v1/meta` | Bearer | distinct hosts / cwds / agents |
|
||||
| GET | `/healthz` | 无 | 健康检查 |
|
||||
| GET | `/` | 无 | Web UI |
|
||||
|
||||
- Token:server `--token` 或 `AGENT_NOTIFY_TOKEN`(启动必填)
|
||||
- 401:缺失或错误 token
|
||||
|
||||
---
|
||||
|
||||
## 6. Hook 与 Transcript
|
||||
|
||||
### 流程
|
||||
|
||||
```
|
||||
hook → 通知(不变)→ build report → 解析 transcript(可选)→ POST remote
|
||||
```
|
||||
|
||||
- 失败仅 `logx.Append`,返回码仍为 0
|
||||
- 替换 `recordInbox`;`cfg.Remote.Enabled` 为 false 时跳过上报
|
||||
|
||||
### 事件 → status
|
||||
|
||||
| event | status |
|
||||
|-------|--------|
|
||||
| stop | waiting |
|
||||
| response | running |
|
||||
| tool | tool |
|
||||
| idle | idle |
|
||||
|
||||
### Transcript
|
||||
|
||||
- Cursor stop payload:`transcript_path` → 读 jsonl 尾部窗口(最大 256KB)→ 最后 user/assistant
|
||||
- Claude:有 path 则同逻辑;无则跳过文本
|
||||
- 字段映射:支持 `role`+`content` 或 Cursor transcript 常见行格式(实现时以实测样本为准)
|
||||
|
||||
### 机器标识
|
||||
|
||||
- `hostname`:`os.Hostname()`
|
||||
- `ips`:网卡非 loopback IPv4(可含 IPv6),去重排序
|
||||
|
||||
---
|
||||
|
||||
## 7. Web UI
|
||||
|
||||
- 内嵌静态页,`GET /api/v1/status` 每 3s 轮询
|
||||
- 侧栏筛选:机器(hostname + ip)、Agent、状态;工作目录可侧栏或下拉
|
||||
- 主表列:机器 | 目录(basename,完整路径 title)| Agent | 状态 | 更新时间 | 详情展开(last_user / last_agent)
|
||||
- Token:首次输入存 `sessionStorage`,请求带 `Authorization`
|
||||
- `offline` 行样式变灰(由 API 返回 `status=offline`)
|
||||
|
||||
---
|
||||
|
||||
## 8. 配置
|
||||
|
||||
```toml
|
||||
[remote]
|
||||
enabled = true
|
||||
url = "http://your-server:8080"
|
||||
token = "shared-secret"
|
||||
timeout_ms = 2000
|
||||
|
||||
[inbox]
|
||||
enabled = false # deprecated
|
||||
```
|
||||
|
||||
- `install` 默认写入 `[remote]` 占位与 `[inbox] enabled = false`
|
||||
- `doctor` 检查 `remote.url`、`remote.token` 非空(enabled 时)
|
||||
|
||||
### 命令
|
||||
|
||||
```bash
|
||||
agent-notify server --listen :8080 --db ./agent-notify.db --token "$AGENT_NOTIFY_TOKEN"
|
||||
```
|
||||
|
||||
`inbox` 子命令保留,执行时打印 deprecation 警告。
|
||||
|
||||
---
|
||||
|
||||
## 9. 错误处理与测试
|
||||
|
||||
| 场景 | 行为 |
|
||||
|------|------|
|
||||
| 远端不可达 | log,通知照常 |
|
||||
| Token 错误 | 401 |
|
||||
| Transcript 过大 | 只读尾部 256KB |
|
||||
| 同键多次上报 | upsert |
|
||||
|
||||
测试:`session_key` 稳定、截断 2000、Bearer、offline 5min、hook 失败不影响 RunCursor/RunClaude。
|
||||
|
||||
---
|
||||
|
||||
## 10. 迁移说明
|
||||
|
||||
1. 在自托管机启动 `agent-notify server`
|
||||
2. 各开发机 `config.toml` 配置 `[remote]`
|
||||
3. 停止依赖 `inbox serve` / SSH RemoteForward(可选保留至下版本删除)
|
||||
@@ -1,10 +1,11 @@
|
||||
module github.com/longbin/agent-notify
|
||||
|
||||
go 1.22.2
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.6.0
|
||||
github.com/charmbracelet/bubbletea v0.26.6
|
||||
modernc.org/sqlite v1.34.5
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -12,13 +13,21 @@ require (
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
golang.org/x/sync v0.8.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.3.8 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
@@ -10,8 +10,18 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
||||
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
@@ -20,12 +30,49 @@ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
|
||||
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
@@ -12,6 +12,7 @@ type Config struct {
|
||||
Events Events `toml:"events"`
|
||||
Notify Notify `toml:"notify"`
|
||||
Inbox Inbox `toml:"inbox"`
|
||||
Remote Remote `toml:"remote"`
|
||||
}
|
||||
|
||||
type Events struct {
|
||||
@@ -38,6 +39,13 @@ type Inbox struct {
|
||||
TimeoutMS int `toml:"timeout_ms"`
|
||||
}
|
||||
|
||||
type Remote struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
URL string `toml:"url"`
|
||||
Token string `toml:"token"`
|
||||
TimeoutMS int `toml:"timeout_ms"`
|
||||
}
|
||||
|
||||
func Default() Config {
|
||||
return Config{
|
||||
Events: Events{Stop: true, Response: true, Idle: false, Tool: false},
|
||||
@@ -49,13 +57,19 @@ func Default() Config {
|
||||
BodyTool: "工具执行完成",
|
||||
},
|
||||
Inbox: Inbox{
|
||||
Enabled: true,
|
||||
Enabled: false,
|
||||
Socket: DefaultInboxSocket(),
|
||||
RemoteSocket: DefaultInboxRemoteSocket(),
|
||||
Addr: "127.0.0.1:17777",
|
||||
FallbackLocal: true,
|
||||
TimeoutMS: 500,
|
||||
},
|
||||
Remote: Remote{
|
||||
Enabled: false,
|
||||
URL: "",
|
||||
Token: "",
|
||||
TimeoutMS: 2000,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@ func TestDefaultConfig(t *testing.T) {
|
||||
if cfg.Notify.Protocol != "osc777" {
|
||||
t.Fatalf("expected osc777, got %q", cfg.Notify.Protocol)
|
||||
}
|
||||
if !cfg.Inbox.Enabled {
|
||||
t.Fatal("expected inbox enabled by default")
|
||||
if cfg.Inbox.Enabled {
|
||||
t.Fatal("expected inbox disabled by default")
|
||||
}
|
||||
if cfg.Inbox.Addr != "127.0.0.1:17777" {
|
||||
t.Fatalf("expected default inbox addr, got %q", cfg.Inbox.Addr)
|
||||
@@ -104,6 +104,16 @@ func TestDefaultInboxSocketPrefersXDGRuntimeDir(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultRemoteDisabledInbox(t *testing.T) {
|
||||
cfg := Default()
|
||||
if cfg.Inbox.Enabled {
|
||||
t.Fatal("inbox should default disabled")
|
||||
}
|
||||
if cfg.Remote.TimeoutMS != 2000 {
|
||||
t.Fatalf("remote timeout: got %d", cfg.Remote.TimeoutMS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventEnabled(t *testing.T) {
|
||||
cfg := Default()
|
||||
if !cfg.EventEnabled("stop") {
|
||||
|
||||
@@ -53,6 +53,10 @@ func RunClaude(r io.Reader, cfg config.Config, event string, w io.Writer) error
|
||||
return err
|
||||
}
|
||||
logx.Append("hook claude event=%s terminalSequence OK title=%q", event, title)
|
||||
recordInbox(cfg, "Claude", event, meta.CWD, title, body)
|
||||
if cfg.Remote.Enabled {
|
||||
reportRemoteHook(cfg, "Claude", event, meta.CWD, "", "")
|
||||
} else if cfg.Inbox.Enabled {
|
||||
recordInbox(cfg, "Claude", event, meta.CWD, title, body)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+10
-4
@@ -12,9 +12,11 @@ import (
|
||||
)
|
||||
|
||||
type cursorPayload struct {
|
||||
WorkspaceRoots []string `json:"workspace_roots"`
|
||||
HookEventName string `json:"hook_event_name"`
|
||||
Status string `json:"status"`
|
||||
WorkspaceRoots []string `json:"workspace_roots"`
|
||||
HookEventName string `json:"hook_event_name"`
|
||||
Status string `json:"status"`
|
||||
ConversationID string `json:"conversation_id"`
|
||||
TranscriptPath string `json:"transcript_path"`
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -64,7 +66,11 @@ func RunCursor(r io.Reader, cfg config.Config, event string, _ io.Writer) error
|
||||
return err
|
||||
}
|
||||
logx.Append("hook cursor event=%s send OK via %s title=%q", event, result.Method, title)
|
||||
recordInbox(cfg, "Cursor", event, meta.CWD, title, body)
|
||||
if cfg.Remote.Enabled {
|
||||
reportRemoteHook(cfg, "Cursor", event, meta.CWD, payload.TranscriptPath, payload.ConversationID)
|
||||
} else if cfg.Inbox.Enabled {
|
||||
recordInbox(cfg, "Cursor", event, meta.CWD, title, body)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,14 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/longbin/agent-notify/internal/config"
|
||||
"github.com/longbin/agent-notify/internal/inbox"
|
||||
"github.com/longbin/agent-notify/internal/remote"
|
||||
)
|
||||
|
||||
func TestCursorStopHookDisabled(t *testing.T) {
|
||||
@@ -64,6 +67,7 @@ func TestCursorHookUploadsInboxRecord(t *testing.T) {
|
||||
)
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.Inbox.Enabled = true
|
||||
err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"]}`)), cfg, "stop", &bytes.Buffer{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -87,6 +91,7 @@ func TestCursorHookFallbacksWhenInboxUploadFails(t *testing.T) {
|
||||
)
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.Inbox.Enabled = true
|
||||
err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"]}`)), cfg, "stop", &bytes.Buffer{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -130,6 +135,7 @@ func TestClaudeHookUploadsInboxRecord(t *testing.T) {
|
||||
)
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.Inbox.Enabled = true
|
||||
var out bytes.Buffer
|
||||
if err := RunClaude(strings.NewReader(`{"stop_hook_active":false}`), cfg, "stop", &out); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -150,3 +156,69 @@ func stubInbox(t *testing.T, upload func(inbox.Record, config.Config) error, app
|
||||
appendInboxRecord = prevAppend
|
||||
})
|
||||
}
|
||||
|
||||
func stubReportRemote(t *testing.T, fn func(config.Config, string, string, string, string, string)) {
|
||||
t.Helper()
|
||||
prev := reportRemoteHook
|
||||
reportRemoteHook = fn
|
||||
t.Cleanup(func() { reportRemoteHook = prev })
|
||||
}
|
||||
|
||||
func TestCursorHookReportsRemoteOnStop(t *testing.T) {
|
||||
stubCursorSend(t)
|
||||
var got remote.StatusReport
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v1/status" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||
http.Error(w, "decode", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.Remote.Enabled = true
|
||||
cfg.Remote.URL = srv.URL
|
||||
err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"],"conversation_id":"conv-1","transcript_path":"/tmp/t.jsonl"}`)), cfg, "stop", &bytes.Buffer{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Agent != "Cursor" || got.Event != "stop" || got.Status != "waiting" || got.CWD != "/tmp/proj" {
|
||||
t.Fatalf("unexpected report: %+v", got)
|
||||
}
|
||||
if got.ConversationID != "conv-1" {
|
||||
t.Fatalf("conversation_id: got %q", got.ConversationID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorHookSkipsRemoteWhenDisabled(t *testing.T) {
|
||||
stubCursorSend(t)
|
||||
stubReportRemote(t, func(config.Config, string, string, string, string, string) {
|
||||
t.Fatal("reportRemote should not run when remote disabled")
|
||||
})
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.Remote.Enabled = false
|
||||
if err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"]}`)), cfg, "stop", &bytes.Buffer{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorHookRemoteReportFailureStillOK(t *testing.T) {
|
||||
stubCursorSend(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "fail", http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.Remote.Enabled = true
|
||||
cfg.Remote.URL = srv.URL
|
||||
if err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"]}`)), cfg, "stop", &bytes.Buffer{}); err != nil {
|
||||
t.Fatalf("hook should return nil on remote failure, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/longbin/agent-notify/internal/config"
|
||||
"github.com/longbin/agent-notify/internal/hostmeta"
|
||||
"github.com/longbin/agent-notify/internal/logx"
|
||||
"github.com/longbin/agent-notify/internal/remote"
|
||||
"github.com/longbin/agent-notify/internal/transcript"
|
||||
)
|
||||
|
||||
var reportRemoteHook = reportRemote
|
||||
|
||||
func reportRemote(cfg config.Config, agent, event, cwd string, transcriptPath, conversationID string) {
|
||||
if !cfg.Remote.Enabled || cfg.Remote.URL == "" {
|
||||
return
|
||||
}
|
||||
|
||||
hostname, err := hostmeta.Hostname()
|
||||
if err != nil {
|
||||
logx.Append("remote report hostname: %v", err)
|
||||
hostname = ""
|
||||
}
|
||||
ips := hostmeta.IPs()
|
||||
|
||||
var lastUser, lastAgent string
|
||||
if transcriptPath != "" {
|
||||
lastUser, lastAgent, err = transcript.LastMessages(transcriptPath, 2000)
|
||||
if err != nil {
|
||||
logx.Append("remote report transcript: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
timeout := time.Duration(cfg.Remote.TimeoutMS) * time.Millisecond
|
||||
if timeout <= 0 {
|
||||
timeout = 2 * time.Second
|
||||
}
|
||||
report := remote.StatusReport{
|
||||
Hostname: hostname,
|
||||
IPs: ips,
|
||||
Agent: agent,
|
||||
CWD: cwd,
|
||||
Status: eventToStatus(event),
|
||||
Event: event,
|
||||
ConversationID: conversationID,
|
||||
LastUser: lastUser,
|
||||
LastAgent: lastAgent,
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
client := remote.NewClient(remote.ClientConfig{
|
||||
URL: cfg.Remote.URL,
|
||||
Token: cfg.Remote.Token,
|
||||
Timeout: timeout,
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
if err := client.Report(ctx, report); err != nil {
|
||||
logx.Append("remote report failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func eventToStatus(event string) string {
|
||||
switch strings.ToLower(event) {
|
||||
case "stop":
|
||||
return "waiting"
|
||||
case "response":
|
||||
return "running"
|
||||
case "tool":
|
||||
return "tool"
|
||||
case "idle":
|
||||
return "idle"
|
||||
default:
|
||||
return "waiting"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package hostmeta
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"sort"
|
||||
)
|
||||
|
||||
func Hostname() (string, error) {
|
||||
return os.Hostname()
|
||||
}
|
||||
|
||||
func IPs() []string {
|
||||
seen := make(map[string]struct{})
|
||||
var ips []string
|
||||
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for _, iface := range ifaces {
|
||||
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
var ip net.IP
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ip = v.IP
|
||||
case *net.IPAddr:
|
||||
ip = v.IP
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if ip == nil || ip.IsLoopback() {
|
||||
continue
|
||||
}
|
||||
if ip4 := ip.To4(); ip4 != nil {
|
||||
ip = ip4
|
||||
}
|
||||
s := ip.String()
|
||||
if _, ok := seen[s]; ok {
|
||||
continue
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
ips = append(ips, s)
|
||||
}
|
||||
}
|
||||
sort.Strings(ips)
|
||||
return ips
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package hostmeta
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHostnameNonEmpty(t *testing.T) {
|
||||
h, err := Hostname()
|
||||
if err != nil || h == "" {
|
||||
t.Fatalf("hostname: %q err=%v", h, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIPsSkipsLoopback(t *testing.T) {
|
||||
ips := IPs()
|
||||
for _, ip := range ips {
|
||||
if ip == "127.0.0.1" || ip == "::1" {
|
||||
t.Fatalf("loopback in ips: %v", ips)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package remote
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ClientConfig struct {
|
||||
URL string
|
||||
Token string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
cfg ClientConfig
|
||||
}
|
||||
|
||||
func NewClient(cfg ClientConfig) Client {
|
||||
if cfg.Timeout <= 0 {
|
||||
cfg.Timeout = 5 * time.Second
|
||||
}
|
||||
return Client{cfg: cfg}
|
||||
}
|
||||
|
||||
func (c Client) Report(ctx context.Context, report StatusReport) error {
|
||||
body, err := json.Marshal(report)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
url := strings.TrimRight(c.cfg.URL, "/") + "/api/v1/status"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if c.cfg.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.cfg.Token)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: c.cfg.Timeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return fmt.Errorf("remote report failed: %s", resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package remote
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
type StatusReport struct {
|
||||
Hostname string `json:"hostname"`
|
||||
IPs []string `json:"ips,omitempty"`
|
||||
Agent string `json:"agent"`
|
||||
CWD string `json:"cwd"`
|
||||
Status string `json:"status"`
|
||||
Event string `json:"event,omitempty"`
|
||||
ConversationID string `json:"conversation_id,omitempty"`
|
||||
LastUser string `json:"last_user,omitempty"`
|
||||
LastAgent string `json:"last_agent,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func SessionKey(hostname, primaryIP, cwd, agent string) string {
|
||||
sum := sha256.Sum256([]byte(hostname + "\x00" + primaryIP + "\x00" + cwd + "\x00" + agent))
|
||||
return hex.EncodeToString(sum[:])[:32]
|
||||
}
|
||||
|
||||
func PrimaryIP(ips []string) string {
|
||||
for _, s := range ips {
|
||||
ip := net.ParseIP(s)
|
||||
if ip != nil && ip.To4() != nil {
|
||||
return ip.To4().String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package remote
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSessionKeyStable(t *testing.T) {
|
||||
k1 := SessionKey("host", "10.0.0.1", "/proj", "Cursor")
|
||||
k2 := SessionKey("host", "10.0.0.1", "/proj", "Cursor")
|
||||
if k1 != k2 || len(k1) != 32 {
|
||||
t.Fatalf("key=%q len=%d", k1, len(k1))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientPostStatus(t *testing.T) {
|
||||
var got StatusReport
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if r.URL.Path != "/api/v1/status" {
|
||||
http.Error(w, "path", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer secret" {
|
||||
http.Error(w, "auth", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||
http.Error(w, "decode", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(ClientConfig{URL: srv.URL, Token: "secret", Timeout: time.Second})
|
||||
err := c.Report(context.Background(), StatusReport{Hostname: "h", Agent: "Cursor", CWD: "/p", Status: "waiting"})
|
||||
if err != nil || got.Hostname != "h" {
|
||||
t.Fatalf("err=%v got=%+v", err, got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/longbin/agent-notify/internal/remote"
|
||||
)
|
||||
|
||||
func NewHandler(store *Store, token string) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", handleHealthz)
|
||||
mux.Handle("/api/v1/status", bearerAuth(token, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
handleStatusPost(store, w, r)
|
||||
case http.MethodGet:
|
||||
handleStatusList(store, w, r)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
})))
|
||||
mux.Handle("/api/v1/meta", bearerAuth(token, http.HandlerFunc(handleMeta(store))))
|
||||
mux.Handle("/", webHandler())
|
||||
return mux
|
||||
}
|
||||
|
||||
func handleHealthz(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func bearerAuth(token string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !checkBearer(r, token) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func checkBearer(r *http.Request, token string) bool {
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
h := r.Header.Get("Authorization")
|
||||
const prefix = "Bearer "
|
||||
if len(h) < len(prefix) || !strings.HasPrefix(h, prefix) {
|
||||
return false
|
||||
}
|
||||
got := h[len(prefix):]
|
||||
return subtle.ConstantTimeCompare([]byte(got), []byte(token)) == 1
|
||||
}
|
||||
|
||||
func handleStatusPost(store *Store, w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
var report remote.StatusReport
|
||||
if err := json.NewDecoder(r.Body).Decode(&report); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := store.Upsert(report); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func handleStatusList(store *Store, w http.ResponseWriter, r *http.Request) {
|
||||
filters := ListFilters{
|
||||
Host: r.URL.Query().Get("host"),
|
||||
CWD: r.URL.Query().Get("cwd"),
|
||||
Agent: r.URL.Query().Get("agent"),
|
||||
Status: r.URL.Query().Get("status"),
|
||||
}
|
||||
rows, err := store.List(filters)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if rows == nil {
|
||||
rows = []SessionRow{}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(rows)
|
||||
}
|
||||
|
||||
func handleMeta(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
meta, err := store.Meta()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(meta)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/longbin/agent-notify/internal/remote"
|
||||
)
|
||||
|
||||
func openTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
db := filepath.Join(t.TempDir(), "test.db")
|
||||
s, err := Open(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func TestHTTPPostWithoutToken401(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
srv := httptest.NewServer(NewHandler(store, "secret"))
|
||||
defer srv.Close()
|
||||
|
||||
body := `{"hostname":"h","ips":["1.2.3.4"],"agent":"Cursor","cwd":"/x","status":"waiting","updated_at":"2026-06-02T00:00:00Z"}`
|
||||
resp, err := http.Post(srv.URL+"/api/v1/status", "application/json", strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("status: got %d want %d", resp.StatusCode, http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPPostValidToken200(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
srv := httptest.NewServer(NewHandler(store, "secret"))
|
||||
defer srv.Close()
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
body := `{"hostname":"h","ips":["1.2.3.4"],"agent":"Cursor","cwd":"/x","status":"waiting","updated_at":"` + now + `"}`
|
||||
req, err := http.NewRequest(http.MethodPost, srv.URL+"/api/v1/status", strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer secret")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status: got %d want %d", resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
var okResp map[string]bool
|
||||
if err := json.NewDecoder(resp.Body).Decode(&okResp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !okResp["ok"] {
|
||||
t.Fatalf("response: %+v", okResp)
|
||||
}
|
||||
|
||||
rows, err := store.List(ListFilters{Agent: "Cursor"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 row in store, got %d", len(rows))
|
||||
}
|
||||
if rows[0].Hostname != "h" || rows[0].Status != "waiting" {
|
||||
t.Fatalf("row: %+v", rows[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPGetAgentFilter(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
now := time.Now().UTC()
|
||||
for _, agent := range []string{"Cursor", "Claude"} {
|
||||
if err := store.Upsert(remote.StatusReport{
|
||||
Hostname: "host-filter",
|
||||
IPs: []string{"10.0.0.1"},
|
||||
Agent: agent,
|
||||
CWD: "/proj",
|
||||
Status: "waiting",
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(NewHandler(store, "secret"))
|
||||
defer srv.Close()
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api/v1/status?agent=Cursor", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer secret")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status: got %d want %d", resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rows []SessionRow
|
||||
if err := json.Unmarshal(data, &rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 row, got %d: %s", len(rows), data)
|
||||
}
|
||||
if rows[0].Agent != "Cursor" {
|
||||
t.Fatalf("agent: got %q want Cursor", rows[0].Agent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPGetRootNoAuth200(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
srv := httptest.NewServer(NewHandler(store, "secret"))
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Get(srv.URL + "/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status: got %d want %d", resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(body), "agent-notify") {
|
||||
t.Fatalf("expected dashboard HTML, got: %q", string(body[:min(80, len(body))]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPHealthz200(t *testing.T) {
|
||||
store := openTestStore(t)
|
||||
srv := httptest.NewServer(NewHandler(store, "secret"))
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := http.Get(srv.URL + "/healthz")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status: got %d want %d", resp.StatusCode, http.StatusOK)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/longbin/agent-notify/internal/remote"
|
||||
)
|
||||
|
||||
const offlineThreshold = 5 * time.Minute
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
type SessionRow struct {
|
||||
SessionKey string `json:"session_key"`
|
||||
Hostname string `json:"hostname"`
|
||||
IPs []string `json:"ips"`
|
||||
Agent string `json:"agent"`
|
||||
CWD string `json:"cwd"`
|
||||
Status string `json:"status"`
|
||||
Event string `json:"event,omitempty"`
|
||||
ConversationID string `json:"conversation_id,omitempty"`
|
||||
LastUser string `json:"last_user,omitempty"`
|
||||
LastAgent string `json:"last_agent,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ReceivedAt time.Time `json:"received_at"`
|
||||
}
|
||||
|
||||
type ListFilters struct {
|
||||
Host string
|
||||
CWD string
|
||||
Agent string
|
||||
Status string
|
||||
}
|
||||
|
||||
type MetaResult struct {
|
||||
Hosts []string `json:"hosts"`
|
||||
CWDs []string `json:"cwds"`
|
||||
Agents []string `json:"agents"`
|
||||
}
|
||||
|
||||
func Open(path string) (*Store, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Store{db: db}
|
||||
if err := s.initSchema(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func (s *Store) initSchema() error {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_key TEXT PRIMARY KEY,
|
||||
hostname TEXT NOT NULL,
|
||||
ips TEXT NOT NULL,
|
||||
agent TEXT NOT NULL,
|
||||
cwd TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
event TEXT NOT NULL DEFAULT '',
|
||||
conversation_id TEXT NOT NULL DEFAULT '',
|
||||
last_user TEXT NOT NULL DEFAULT '',
|
||||
last_agent TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL,
|
||||
received_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_sessions_hostname ON sessions(hostname)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_sessions_agent ON sessions(agent)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_sessions_updated_at ON sessions(updated_at)`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if _, err := s.db.Exec(stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Upsert(report remote.StatusReport) error {
|
||||
key := remote.SessionKey(report.Hostname, remote.PrimaryIP(report.IPs), report.CWD, report.Agent)
|
||||
ipsJSON, err := json.Marshal(report.IPs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
receivedAt := time.Now().UTC()
|
||||
updatedAt := report.UpdatedAt.UTC()
|
||||
if updatedAt.IsZero() {
|
||||
updatedAt = receivedAt
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(`
|
||||
INSERT INTO sessions (
|
||||
session_key, hostname, ips, agent, cwd, status, event,
|
||||
conversation_id, last_user, last_agent, updated_at, received_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(session_key) DO UPDATE SET
|
||||
hostname = excluded.hostname,
|
||||
ips = excluded.ips,
|
||||
agent = excluded.agent,
|
||||
cwd = excluded.cwd,
|
||||
status = excluded.status,
|
||||
event = excluded.event,
|
||||
conversation_id = excluded.conversation_id,
|
||||
last_user = CASE WHEN excluded.last_user = '' THEN sessions.last_user ELSE excluded.last_user END,
|
||||
last_agent = CASE WHEN excluded.last_agent = '' THEN sessions.last_agent ELSE excluded.last_agent END,
|
||||
updated_at = excluded.updated_at,
|
||||
received_at = excluded.received_at
|
||||
`, key, report.Hostname, string(ipsJSON), report.Agent, report.CWD, report.Status,
|
||||
report.Event, report.ConversationID, report.LastUser, report.LastAgent,
|
||||
updatedAt.Format(time.RFC3339Nano), receivedAt.Format(time.RFC3339Nano))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) List(filters ListFilters) ([]SessionRow, error) {
|
||||
query := `SELECT session_key, hostname, ips, agent, cwd, status, event,
|
||||
conversation_id, last_user, last_agent, updated_at, received_at
|
||||
FROM sessions WHERE 1=1`
|
||||
var args []any
|
||||
|
||||
if filters.Host != "" {
|
||||
query += ` AND hostname = ?`
|
||||
args = append(args, filters.Host)
|
||||
}
|
||||
if filters.CWD != "" {
|
||||
query += ` AND cwd = ?`
|
||||
args = append(args, filters.CWD)
|
||||
}
|
||||
if filters.Agent != "" {
|
||||
query += ` AND agent = ?`
|
||||
args = append(args, filters.Agent)
|
||||
}
|
||||
query += ` ORDER BY updated_at DESC`
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []SessionRow
|
||||
for rows.Next() {
|
||||
row, err := scanSessionRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row.Status = displayStatus(row.Status, row.UpdatedAt)
|
||||
if filters.Status != "" && row.Status != filters.Status {
|
||||
continue
|
||||
}
|
||||
result = append(result, row)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func displayStatus(stored string, updatedAt time.Time) string {
|
||||
if time.Since(updatedAt) > offlineThreshold {
|
||||
return "offline"
|
||||
}
|
||||
return stored
|
||||
}
|
||||
|
||||
func (s *Store) Meta() (MetaResult, error) {
|
||||
var meta MetaResult
|
||||
for _, q := range []struct {
|
||||
sql string
|
||||
dest *[]string
|
||||
}{
|
||||
{`SELECT DISTINCT hostname FROM sessions ORDER BY hostname`, &meta.Hosts},
|
||||
{`SELECT DISTINCT cwd FROM sessions ORDER BY cwd`, &meta.CWDs},
|
||||
{`SELECT DISTINCT agent FROM sessions ORDER BY agent`, &meta.Agents},
|
||||
} {
|
||||
rows, err := s.db.Query(q.sql)
|
||||
if err != nil {
|
||||
return MetaResult{}, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var v string
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
rows.Close()
|
||||
return MetaResult{}, err
|
||||
}
|
||||
*q.dest = append(*q.dest, v)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return MetaResult{}, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return MetaResult{}, err
|
||||
}
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanSessionRow(rows rowScanner) (SessionRow, error) {
|
||||
var row SessionRow
|
||||
var ipsJSON, updatedAtStr, receivedAtStr string
|
||||
if err := rows.Scan(
|
||||
&row.SessionKey, &row.Hostname, &ipsJSON, &row.Agent, &row.CWD, &row.Status,
|
||||
&row.Event, &row.ConversationID, &row.LastUser, &row.LastAgent,
|
||||
&updatedAtStr, &receivedAtStr,
|
||||
); err != nil {
|
||||
return SessionRow{}, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(ipsJSON), &row.IPs); err != nil {
|
||||
return SessionRow{}, fmt.Errorf("decode ips: %w", err)
|
||||
}
|
||||
if row.IPs == nil {
|
||||
row.IPs = []string{}
|
||||
}
|
||||
var err error
|
||||
row.UpdatedAt, err = parseTime(updatedAtStr)
|
||||
if err != nil {
|
||||
return SessionRow{}, fmt.Errorf("updated_at: %w", err)
|
||||
}
|
||||
row.ReceivedAt, err = parseTime(receivedAtStr)
|
||||
if err != nil {
|
||||
return SessionRow{}, fmt.Errorf("received_at: %w", err)
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func parseTime(s string) (time.Time, error) {
|
||||
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t.UTC(), nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("invalid time %q", s)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/longbin/agent-notify/internal/remote"
|
||||
)
|
||||
|
||||
func TestStoreUpsertAndList(t *testing.T) {
|
||||
db := filepath.Join(t.TempDir(), "test.db")
|
||||
s, err := Open(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
now := time.Now().UTC()
|
||||
base := remote.StatusReport{
|
||||
Hostname: "host-a",
|
||||
IPs: []string{"10.0.0.1"},
|
||||
Agent: "Cursor",
|
||||
CWD: "/proj",
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
r1 := base
|
||||
r1.Status = "waiting"
|
||||
if err := s.Upsert(r1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r2 := base
|
||||
r2.Status = "running"
|
||||
if err := s.Upsert(r2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rows, err := s.List(ListFilters{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 row, got %d", len(rows))
|
||||
}
|
||||
if rows[0].Status != "running" {
|
||||
t.Fatalf("expected latest status running, got %q", rows[0].Status)
|
||||
}
|
||||
key := remote.SessionKey("host-a", "10.0.0.1", "/proj", "Cursor")
|
||||
if rows[0].SessionKey != key {
|
||||
t.Fatalf("session_key: got %q want %q", rows[0].SessionKey, key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreOfflineAfter5Min(t *testing.T) {
|
||||
db := filepath.Join(t.TempDir(), "test.db")
|
||||
s, err := Open(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
stale := time.Now().UTC().Add(-10 * time.Minute)
|
||||
report := remote.StatusReport{
|
||||
Hostname: "host-b",
|
||||
IPs: []string{"192.168.1.5"},
|
||||
Agent: "Claude",
|
||||
CWD: "/work",
|
||||
Status: "waiting",
|
||||
UpdatedAt: stale,
|
||||
}
|
||||
if err := s.Upsert(report); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rows, err := s.List(ListFilters{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 row, got %d", len(rows))
|
||||
}
|
||||
if rows[0].Status != "offline" {
|
||||
t.Fatalf("expected offline status, got %q", rows[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreMergePreservesLastUser(t *testing.T) {
|
||||
db := filepath.Join(t.TempDir(), "test.db")
|
||||
s, err := Open(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
now := time.Now().UTC()
|
||||
base := remote.StatusReport{
|
||||
Hostname: "host-c",
|
||||
IPs: []string{"10.0.0.2"},
|
||||
Agent: "Cursor",
|
||||
CWD: "/proj",
|
||||
Status: "waiting",
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
r1 := base
|
||||
r1.LastUser = "keep me"
|
||||
if err := s.Upsert(r1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r2 := base
|
||||
r2.LastUser = ""
|
||||
r2.Status = "running"
|
||||
if err := s.Upsert(r2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rows, err := s.List(ListFilters{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("expected 1 row, got %d", len(rows))
|
||||
}
|
||||
if rows[0].LastUser != "keep me" {
|
||||
t.Fatalf("last_user: got %q want %q", rows[0].LastUser, "keep me")
|
||||
}
|
||||
if rows[0].Status != "running" {
|
||||
t.Fatalf("expected status running, got %q", rows[0].Status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/longbin/agent-notify/web"
|
||||
)
|
||||
|
||||
func webHandler() http.Handler {
|
||||
return web.Handler()
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
{"role": "user", "content": "q"}
|
||||
{"role": "assistant", "content": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{"role":"user","content":"hello"}
|
||||
{"role":"assistant","content":"world"}
|
||||
{"role":"user","content":"second question"}
|
||||
{"role":"assistant","content":"final answer"}
|
||||
@@ -0,0 +1,93 @@
|
||||
package transcript
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const tailWindow = 256 * 1024
|
||||
|
||||
// LastMessages reads a JSONL transcript and returns the last user and assistant
|
||||
// messages, each truncated to maxLen runes.
|
||||
func LastMessages(path string, maxLen int) (lastUser, lastAgent string, err error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", "", err
|
||||
}
|
||||
return "", "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
size := info.Size()
|
||||
|
||||
var r io.Reader = f
|
||||
if size > tailWindow {
|
||||
start := size - tailWindow
|
||||
if _, err := f.Seek(start, io.SeekStart); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
tail := make([]byte, tailWindow)
|
||||
n, err := io.ReadFull(f, tail)
|
||||
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
return "", "", err
|
||||
}
|
||||
tail = tail[:n]
|
||||
if i := bytes.IndexByte(tail, '\n'); i >= 0 {
|
||||
tail = tail[i+1:]
|
||||
}
|
||||
r = bytes.NewReader(tail)
|
||||
}
|
||||
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var row struct {
|
||||
Role string `json:"role"`
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &row); err != nil {
|
||||
continue
|
||||
}
|
||||
role := row.Role
|
||||
if role == "" {
|
||||
role = row.Type
|
||||
}
|
||||
switch role {
|
||||
case "user":
|
||||
lastUser = row.Content
|
||||
case "assistant":
|
||||
lastAgent = row.Content
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
lastUser = truncateRunes(lastUser, maxLen)
|
||||
lastAgent = truncateRunes(lastAgent, maxLen)
|
||||
return lastUser, lastAgent, nil
|
||||
}
|
||||
|
||||
func truncateRunes(s string, maxLen int) string {
|
||||
if maxLen <= 0 || utf8.RuneCountInString(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
runes := []rune(s)
|
||||
return string(runes[:maxLen])
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package transcript
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLastMessages(t *testing.T) {
|
||||
u, a, err := LastMessages("testdata/sample.jsonl", 2000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u != "second question" || a != "final answer" {
|
||||
t.Fatalf("got user=%q agent=%q", u, a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate2000(t *testing.T) {
|
||||
u, a, err := LastMessages("testdata/long.jsonl", 2000)
|
||||
if err != nil || len(a) != 2000 {
|
||||
t.Fatalf("len=%d err=%v", len(a), err)
|
||||
}
|
||||
_ = u
|
||||
}
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
(function () {
|
||||
const STORAGE_KEY = 'agent_notify_token';
|
||||
const POLL_MS = 3000;
|
||||
|
||||
const elHost = document.getElementById('filter-host');
|
||||
const elAgent = document.getElementById('filter-agent');
|
||||
const elStatus = document.getElementById('filter-status');
|
||||
const elCwd = document.getElementById('filter-cwd');
|
||||
const elSessions = document.getElementById('sessions');
|
||||
const elError = document.getElementById('error');
|
||||
const btnToken = document.getElementById('btn-token');
|
||||
|
||||
let expanded = new Set();
|
||||
let pollTimer = null;
|
||||
|
||||
function getToken() {
|
||||
return sessionStorage.getItem(STORAGE_KEY) || '';
|
||||
}
|
||||
|
||||
function promptToken() {
|
||||
const current = getToken();
|
||||
const t = window.prompt('API token (Bearer):', current);
|
||||
if (t === null) return false;
|
||||
const trimmed = t.trim();
|
||||
if (!trimmed) {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
return false;
|
||||
}
|
||||
sessionStorage.setItem(STORAGE_KEY, trimmed);
|
||||
return true;
|
||||
}
|
||||
|
||||
function ensureToken() {
|
||||
if (getToken()) return true;
|
||||
return promptToken();
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
if (!msg) {
|
||||
elError.textContent = '';
|
||||
elError.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
elError.textContent = msg;
|
||||
elError.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function basename(path) {
|
||||
if (!path) return '';
|
||||
const i = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
|
||||
return i >= 0 ? path.slice(i + 1) : path;
|
||||
}
|
||||
|
||||
function formatMachine(row) {
|
||||
const ips = (row.ips || []).join(', ');
|
||||
return row.hostname + (ips ? ' (' + ips + ')' : '');
|
||||
}
|
||||
|
||||
function formatTime(iso) {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return isNaN(d.getTime()) ? iso : d.toLocaleString();
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = s;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
async function apiGet(path) {
|
||||
const token = getToken();
|
||||
if (!token) throw new Error('No token');
|
||||
const res = await fetch(path, {
|
||||
headers: { Authorization: 'Bearer ' + token },
|
||||
});
|
||||
if (res.status === 401) {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
throw new Error('Unauthorized — check token');
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(res.status + ' ' + res.statusText);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function queryParams() {
|
||||
const q = new URLSearchParams();
|
||||
if (elHost.value) q.set('host', elHost.value);
|
||||
if (elAgent.value) q.set('agent', elAgent.value);
|
||||
if (elStatus.value) q.set('status', elStatus.value);
|
||||
if (elCwd.value) q.set('cwd', elCwd.value);
|
||||
const s = q.toString();
|
||||
return s ? '?' + s : '';
|
||||
}
|
||||
|
||||
function fillSelect(select, values, keepValue) {
|
||||
const current = keepValue ? select.value : '';
|
||||
while (select.options.length > 1) select.remove(1);
|
||||
for (const v of values || []) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = v;
|
||||
opt.textContent = v;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
if (current && [...select.options].some((o) => o.value === current)) {
|
||||
select.value = current;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMeta() {
|
||||
const meta = await apiGet('/api/v1/meta');
|
||||
fillSelect(elHost, meta.hosts, true);
|
||||
fillSelect(elAgent, meta.agents, true);
|
||||
fillSelect(elCwd, meta.cwds, true);
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
const rows = await apiGet('/api/v1/status' + queryParams());
|
||||
renderTable(Array.isArray(rows) ? rows : []);
|
||||
}
|
||||
|
||||
function renderTable(rows) {
|
||||
if (rows.length === 0) {
|
||||
elSessions.innerHTML =
|
||||
'<tr><td colspan="6" class="empty">No sessions</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
for (const row of rows) {
|
||||
const key = row.session_key || row.hostname + row.cwd + row.agent;
|
||||
const offline = row.status === 'offline';
|
||||
const cls = offline ? ' class="offline"' : '';
|
||||
const open = expanded.has(key);
|
||||
|
||||
parts.push(
|
||||
'<tr' +
|
||||
cls +
|
||||
' data-key="' +
|
||||
escapeHtml(key) +
|
||||
'">' +
|
||||
'<td>' +
|
||||
escapeHtml(formatMachine(row)) +
|
||||
'</td>' +
|
||||
'<td title="' +
|
||||
escapeHtml(row.cwd || '') +
|
||||
'">' +
|
||||
escapeHtml(basename(row.cwd)) +
|
||||
'</td>' +
|
||||
'<td>' +
|
||||
escapeHtml(row.agent || '') +
|
||||
'</td>' +
|
||||
'<td><span class="status">' +
|
||||
escapeHtml(row.status || '') +
|
||||
'</span></td>' +
|
||||
'<td>' +
|
||||
escapeHtml(formatTime(row.updated_at)) +
|
||||
'</td>' +
|
||||
'<td><button type="button" class="btn-expand" data-key="' +
|
||||
escapeHtml(key) +
|
||||
'">' +
|
||||
(open ? 'Hide' : 'Details') +
|
||||
'</button></td>' +
|
||||
'</tr>'
|
||||
);
|
||||
|
||||
if (open) {
|
||||
parts.push(
|
||||
'<tr class="details-row"><td colspan="6">' +
|
||||
'<div class="label">last_user</div>' +
|
||||
'<pre>' +
|
||||
escapeHtml(row.last_user || '(empty)') +
|
||||
'</pre>' +
|
||||
'<div class="label">last_agent</div>' +
|
||||
'<pre>' +
|
||||
escapeHtml(row.last_agent || '(empty)') +
|
||||
'</pre>' +
|
||||
'</td></tr>'
|
||||
);
|
||||
}
|
||||
}
|
||||
elSessions.innerHTML = parts.join('');
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!ensureToken()) {
|
||||
showError('Token required');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
showError('');
|
||||
await Promise.all([loadMeta(), loadStatus()]);
|
||||
} catch (e) {
|
||||
showError(e.message || String(e));
|
||||
if (String(e.message || '').includes('Unauthorized')) {
|
||||
if (promptToken()) refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startPoll() {
|
||||
stopPoll();
|
||||
pollTimer = setInterval(refresh, POLL_MS);
|
||||
}
|
||||
|
||||
function stopPoll() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
[elHost, elAgent, elStatus, elCwd].forEach((el) => {
|
||||
el.addEventListener('change', refresh);
|
||||
});
|
||||
|
||||
btnToken.addEventListener('click', () => {
|
||||
promptToken();
|
||||
refresh();
|
||||
});
|
||||
|
||||
elSessions.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.btn-expand');
|
||||
if (!btn) return;
|
||||
const key = btn.getAttribute('data-key');
|
||||
if (expanded.has(key)) expanded.delete(key);
|
||||
else expanded.add(key);
|
||||
refresh();
|
||||
});
|
||||
|
||||
if (!getToken()) promptToken();
|
||||
refresh();
|
||||
startPoll();
|
||||
})();
|
||||
@@ -0,0 +1,13 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
//go:embed index.html app.js style.css
|
||||
var content embed.FS
|
||||
|
||||
func Handler() http.Handler {
|
||||
return http.FileServer(http.FS(content))
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>agent-notify</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>agent-notify</h1>
|
||||
<button type="button" id="btn-token" title="Change API token">Token</button>
|
||||
</header>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<label>Host
|
||||
<select id="filter-host"><option value="">All</option></select>
|
||||
</label>
|
||||
<label>Agent
|
||||
<select id="filter-agent"><option value="">All</option></select>
|
||||
</label>
|
||||
<label>Status
|
||||
<select id="filter-status">
|
||||
<option value="">All</option>
|
||||
<option value="waiting">waiting</option>
|
||||
<option value="running">running</option>
|
||||
<option value="tool">tool</option>
|
||||
<option value="idle">idle</option>
|
||||
<option value="offline">offline</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>CWD
|
||||
<select id="filter-cwd"><option value="">All</option></select>
|
||||
</label>
|
||||
</aside>
|
||||
<main>
|
||||
<p id="error" class="error hidden"></p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Machine</th>
|
||||
<th>Directory</th>
|
||||
<th>Agent</th>
|
||||
<th>Status</th>
|
||||
<th>Updated</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sessions"></tbody>
|
||||
</table>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
:root {
|
||||
--bg: #0f1114;
|
||||
--surface: #161a20;
|
||||
--border: #2a3038;
|
||||
--text: #e4e6eb;
|
||||
--muted: #8b929a;
|
||||
--accent: #5b9fd4;
|
||||
--error: #e06c75;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-size: 14px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#btn-token {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
#btn-token:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--muted);
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
min-height: calc(100vh - 49px);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
padding: 16px;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.sidebar label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.sidebar select {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
padding: 16px 20px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--error);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.error.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
tr.offline td {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
tr.offline .status {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-expand {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.btn-expand:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.details-row td {
|
||||
padding: 0 12px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.details-row pre {
|
||||
margin: 8px 0 0;
|
||||
padding: 10px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.details-row .label {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.details-row .label:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}
|
||||
Reference in New Issue
Block a user