Compare commits
10 Commits
1e33bfa4bc
...
3a5102117c
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a5102117c | |||
| 9000eeeaa7 | |||
| df433a9179 | |||
| 8c43a96817 | |||
| 6802a432d7 | |||
| e95b3315b0 | |||
| 034738fa3f | |||
| d1abd2b15d | |||
| cc502c7173 | |||
| c04c9e6cf2 |
@@ -10,7 +10,7 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GO_VERSION: "1.22.2"
|
GO_VERSION: "1.25.0"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
|||||||
@@ -50,12 +50,13 @@ title_template = "{agent} — {context}" # context = 工作目录名
|
|||||||
body_stop = "等待输入"
|
body_stop = "等待输入"
|
||||||
|
|
||||||
[inbox]
|
[inbox]
|
||||||
|
enabled = false
|
||||||
|
|
||||||
|
[remote]
|
||||||
enabled = true
|
enabled = true
|
||||||
socket = "/run/user/1000/agent-notify.sock"
|
url = "http://your-server:8080"
|
||||||
remote_socket = "/tmp/agent-notify-longbin.sock"
|
token = "shared-secret"
|
||||||
addr = "127.0.0.1:17777"
|
timeout_ms = 2000
|
||||||
fallback_local = true
|
|
||||||
timeout_ms = 500
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 命令
|
## 命令
|
||||||
@@ -64,44 +65,38 @@ timeout_ms = 500
|
|||||||
agent-notify send --event stop
|
agent-notify send --event stop
|
||||||
agent-notify hook cursor stop # Cursor CLI stop hook
|
agent-notify hook cursor stop # Cursor CLI stop hook
|
||||||
agent-notify hook claude stop # Claude Stop hook(输出 terminalSequence JSON)
|
agent-notify hook claude stop # Claude Stop hook(输出 terminalSequence JSON)
|
||||||
agent-notify inbox serve # 本地接收远程通知记录
|
agent-notify server --listen :8080 # 远程汇总服务(见下方 Remote Dashboard)
|
||||||
agent-notify inbox list # 列出未处理通知
|
agent-notify inbox serve # 已弃用:见下方 Inbox(deprecated)
|
||||||
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 test cursor [-v]
|
agent-notify test cursor [-v]
|
||||||
agent-notify test claude [--apply]
|
agent-notify test claude [--apply]
|
||||||
agent-notify doctor
|
agent-notify doctor
|
||||||
agent-notify install --all [--force]
|
agent-notify install --all [--force]
|
||||||
```
|
```
|
||||||
|
|
||||||
## 本地汇总 Inbox
|
## Remote Dashboard
|
||||||
|
|
||||||
在本地 Ghostty 所在机器启动接收服务:
|
在可访问的机器上启动汇总服务(需设置与客户端相同的 token):
|
||||||
|
|
||||||
```bash
|
```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
|
```toml
|
||||||
agent-notify inbox ssh-config install
|
[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
|
旧流程:在本地 Ghostty 机器运行 `agent-notify inbox serve`,用 `agent-notify inbox ssh-config install` 配置 SSH `RemoteForward`,通过 Unix socket 把远程通知写回本地 `inbox.jsonl`,并用 `inbox list` / `tui` 等处理。
|
||||||
agent-notify inbox list
|
|
||||||
agent-notify inbox show <id>
|
|
||||||
agent-notify inbox done <id>
|
|
||||||
agent-notify inbox tui
|
|
||||||
```
|
|
||||||
|
|
||||||
## Hook 配置位置
|
## Hook 配置位置
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func cmdInbox(args []string, stdout, stderr io.Writer) error {
|
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 {
|
if len(args) == 0 {
|
||||||
return fmt.Errorf("usage: agent-notify inbox <list|show|done|rm|clear|serve|ssh-config>")
|
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)
|
return cmdLogs(args)
|
||||||
case "inbox":
|
case "inbox":
|
||||||
return cmdInbox(args, os.Stdout, os.Stderr)
|
return cmdInbox(args, os.Stdout, os.Stderr)
|
||||||
|
case "server":
|
||||||
|
return cmdServer(args)
|
||||||
case "version", "-V":
|
case "version", "-V":
|
||||||
return cmdVersion()
|
return cmdVersion()
|
||||||
case "help", "-h", "--help":
|
case "help", "-h", "--help":
|
||||||
@@ -190,6 +192,20 @@ func cmdDoctor() error {
|
|||||||
}
|
}
|
||||||
cfgPath := config.DefaultPath()
|
cfgPath := config.DefaultPath()
|
||||||
fmt.Printf(" config=%s\n", cfgPath)
|
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())
|
fmt.Printf(" hook_log=%s\n", logx.Path())
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -205,6 +221,7 @@ Commands:
|
|||||||
test claude [--apply]
|
test claude [--apply]
|
||||||
logs [--tail 30]
|
logs [--tail 30]
|
||||||
inbox <list|show|done|rm|clear|serve|ssh-config>
|
inbox <list|show|done|rm|clear|serve|ssh-config>
|
||||||
|
server [--listen ADDR] [--db PATH] [--token TOKEN]
|
||||||
version
|
version
|
||||||
doctor
|
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)
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
module github.com/longbin/agent-notify
|
module github.com/longbin/agent-notify
|
||||||
|
|
||||||
go 1.22.2
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/BurntSushi/toml v1.6.0
|
github.com/BurntSushi/toml v1.6.0
|
||||||
github.com/charmbracelet/bubbletea v0.26.6
|
github.com/charmbracelet/bubbletea v0.26.6
|
||||||
|
modernc.org/sqlite v1.34.5
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -12,13 +13,21 @@ require (
|
|||||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.3.0 // 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/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-localereader v0.0.1 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // 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
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
golang.org/x/sync v0.8.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.28.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/text v0.3.8 // 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/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 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
||||||
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
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 h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
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 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
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/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 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
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 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
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.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
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 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
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=
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ func Default() Config {
|
|||||||
},
|
},
|
||||||
Remote: Remote{
|
Remote: Remote{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
|
URL: "",
|
||||||
|
Token: "",
|
||||||
TimeoutMS: 2000,
|
TimeoutMS: 2000,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ func RunClaude(r io.Reader, cfg config.Config, event string, w io.Writer) error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
logx.Append("hook claude event=%s terminalSequence OK title=%q", event, title)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-4
@@ -12,9 +12,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type cursorPayload struct {
|
type cursorPayload struct {
|
||||||
WorkspaceRoots []string `json:"workspace_roots"`
|
WorkspaceRoots []string `json:"workspace_roots"`
|
||||||
HookEventName string `json:"hook_event_name"`
|
HookEventName string `json:"hook_event_name"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
ConversationID string `json:"conversation_id"`
|
||||||
|
TranscriptPath string `json:"transcript_path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -64,7 +66,11 @@ func RunCursor(r io.Reader, cfg config.Config, event string, _ io.Writer) error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
logx.Append("hook cursor event=%s send OK via %s title=%q", event, result.Method, title)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/longbin/agent-notify/internal/config"
|
"github.com/longbin/agent-notify/internal/config"
|
||||||
"github.com/longbin/agent-notify/internal/inbox"
|
"github.com/longbin/agent-notify/internal/inbox"
|
||||||
|
"github.com/longbin/agent-notify/internal/remote"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCursorStopHookDisabled(t *testing.T) {
|
func TestCursorStopHookDisabled(t *testing.T) {
|
||||||
@@ -64,6 +67,7 @@ func TestCursorHookUploadsInboxRecord(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
cfg := config.Default()
|
cfg := config.Default()
|
||||||
|
cfg.Inbox.Enabled = true
|
||||||
err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"]}`)), cfg, "stop", &bytes.Buffer{})
|
err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"]}`)), cfg, "stop", &bytes.Buffer{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -87,6 +91,7 @@ func TestCursorHookFallbacksWhenInboxUploadFails(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
cfg := config.Default()
|
cfg := config.Default()
|
||||||
|
cfg.Inbox.Enabled = true
|
||||||
err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"]}`)), cfg, "stop", &bytes.Buffer{})
|
err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"]}`)), cfg, "stop", &bytes.Buffer{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -130,6 +135,7 @@ func TestClaudeHookUploadsInboxRecord(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
cfg := config.Default()
|
cfg := config.Default()
|
||||||
|
cfg.Inbox.Enabled = true
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
if err := RunClaude(strings.NewReader(`{"stop_hook_active":false}`), cfg, "stop", &out); err != nil {
|
if err := RunClaude(strings.NewReader(`{"stop_hook_active":false}`), cfg, "stop", &out); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -150,3 +156,69 @@ func stubInbox(t *testing.T, upload func(inbox.Record, config.Config) error, app
|
|||||||
appendInboxRecord = prevAppend
|
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 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