feat: add sqlite session store for remote server
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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.51.0
|
||||
)
|
||||
|
||||
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-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
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.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U=
|
||||
modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||
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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user