225 lines
6.2 KiB
Go
225 lines
6.2 KiB
Go
// Package todos stores todo/issue notes filed from the browser by the
|
|
// otel-todo.js widget.
|
|
package todos
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
|
)
|
|
|
|
// Todo is one note filed from a page. Project is the RUM service name.
|
|
type Todo struct {
|
|
ID string `json:"id"`
|
|
Project string `json:"project"`
|
|
Text string `json:"text"`
|
|
Component string `json:"component,omitempty"`
|
|
Path string `json:"path,omitempty"`
|
|
Hash string `json:"hash,omitempty"`
|
|
URL string `json:"url,omitempty"`
|
|
UserAgent string `json:"userAgent,omitempty"`
|
|
Session string `json:"session,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
Done bool `json:"done"`
|
|
DoneAt time.Time `json:"doneAt,omitzero"`
|
|
}
|
|
|
|
// Store persists todos. List returns newest first; an empty project means all.
|
|
type Store interface {
|
|
Add(ctx context.Context, t Todo) error
|
|
List(ctx context.Context, project string, limit int) ([]Todo, error)
|
|
// SetDone marks a todo done or open; ok is false for an unknown id.
|
|
SetDone(ctx context.Context, id string, done bool) (t Todo, ok bool, err error)
|
|
}
|
|
|
|
// FileStore keeps todos in memory and appends them to a JSONL file.
|
|
type FileStore struct {
|
|
mu sync.Mutex
|
|
path string
|
|
items []Todo
|
|
}
|
|
|
|
func NewFileStore(path string) (*FileStore, error) {
|
|
fs := &FileStore{path: path}
|
|
f, err := os.Open(path)
|
|
if os.IsNotExist(err) {
|
|
return fs, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
sc := bufio.NewScanner(f)
|
|
sc.Buffer(make([]byte, 64<<10), 1<<20)
|
|
for sc.Scan() {
|
|
var t Todo
|
|
if json.Unmarshal(sc.Bytes(), &t) == nil {
|
|
fs.items = append(fs.items, t)
|
|
}
|
|
}
|
|
return fs, sc.Err()
|
|
}
|
|
|
|
func (s *FileStore) Add(_ context.Context, t Todo) error {
|
|
line, err := json.Marshal(t)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
f, err := os.OpenFile(s.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
if _, err := f.Write(append(line, '\n')); err != nil {
|
|
return err
|
|
}
|
|
s.items = append(s.items, t)
|
|
return nil
|
|
}
|
|
|
|
func (s *FileStore) SetDone(_ context.Context, id string, done bool) (Todo, bool, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for i := range s.items {
|
|
if s.items[i].ID != id {
|
|
continue
|
|
}
|
|
s.items[i].Done = done
|
|
s.items[i].DoneAt = time.Time{}
|
|
if done {
|
|
s.items[i].DoneAt = time.Now().UTC()
|
|
}
|
|
var buf []byte
|
|
for _, t := range s.items {
|
|
line, err := json.Marshal(t)
|
|
if err != nil {
|
|
return Todo{}, false, err
|
|
}
|
|
buf = append(append(buf, line...), '\n')
|
|
}
|
|
tmp := s.path + ".tmp"
|
|
if err := os.WriteFile(tmp, buf, 0o644); err != nil {
|
|
return Todo{}, false, err
|
|
}
|
|
return s.items[i], true, os.Rename(tmp, s.path)
|
|
}
|
|
return Todo{}, false, nil
|
|
}
|
|
|
|
func (s *FileStore) List(_ context.Context, project string, limit int) ([]Todo, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
var out []Todo
|
|
for _, t := range s.items {
|
|
if project == "" || t.Project == project {
|
|
out = append(out, t)
|
|
}
|
|
}
|
|
sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
|
|
if limit > 0 && len(out) > limit {
|
|
out = out[:limit]
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ClickHouseStore persists todos in a plain MergeTree table (no TTL).
|
|
type ClickHouseStore struct {
|
|
conn driver.Conn
|
|
db string
|
|
}
|
|
|
|
func NewClickHouseStore(ctx context.Context, conn driver.Conn, db string) (*ClickHouseStore, error) {
|
|
err := conn.Exec(ctx, fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s.todos (
|
|
id String,
|
|
project LowCardinality(String),
|
|
text String,
|
|
component String,
|
|
path String,
|
|
hash String,
|
|
url String,
|
|
user_agent String,
|
|
session String,
|
|
created_at DateTime64(3, 'UTC')
|
|
) ENGINE = MergeTree
|
|
ORDER BY (project, created_at)`, db))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, col := range []string{"done UInt8", "done_at DateTime64(3, 'UTC')"} {
|
|
if err := conn.Exec(ctx, fmt.Sprintf(`ALTER TABLE %s.todos ADD COLUMN IF NOT EXISTS %s`, db, col)); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return &ClickHouseStore{conn: conn, db: db}, nil
|
|
}
|
|
|
|
func (s *ClickHouseStore) Add(ctx context.Context, t Todo) error {
|
|
return s.conn.Exec(ctx, fmt.Sprintf(`INSERT INTO %s.todos (id, project, text, component, path, hash, url, user_agent, session, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, s.db),
|
|
t.ID, t.Project, t.Text, t.Component, t.Path, t.Hash, t.URL, t.UserAgent, t.Session, t.CreatedAt.UTC())
|
|
}
|
|
|
|
func (s *ClickHouseStore) List(ctx context.Context, project string, limit int) ([]Todo, error) {
|
|
q := fmt.Sprintf(`SELECT id, project, text, component, path, hash, url, user_agent, session, created_at, done, done_at FROM %s.todos`, s.db)
|
|
var args []any
|
|
if project != "" {
|
|
q += ` WHERE project = ?`
|
|
args = append(args, project)
|
|
}
|
|
q += ` ORDER BY created_at DESC LIMIT ?`
|
|
args = append(args, limit)
|
|
rows, err := s.conn.Query(ctx, q, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []Todo
|
|
for rows.Next() {
|
|
var t Todo
|
|
var done uint8
|
|
if err := rows.Scan(&t.ID, &t.Project, &t.Text, &t.Component, &t.Path, &t.Hash, &t.URL, &t.UserAgent, &t.Session, &t.CreatedAt, &done, &t.DoneAt); err != nil {
|
|
return nil, err
|
|
}
|
|
t.Done = done != 0
|
|
out = append(out, t)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *ClickHouseStore) SetDone(ctx context.Context, id string, done bool) (Todo, bool, error) {
|
|
var d uint8
|
|
doneAt := time.Unix(0, 0).UTC()
|
|
if done {
|
|
d, doneAt = 1, time.Now().UTC()
|
|
}
|
|
if err := s.conn.Exec(ctx, fmt.Sprintf(`ALTER TABLE %s.todos UPDATE done = ?, done_at = ? WHERE id = ? SETTINGS mutations_sync = 1`, s.db), d, doneAt, id); err != nil {
|
|
return Todo{}, false, err
|
|
}
|
|
rows, err := s.conn.Query(ctx, fmt.Sprintf(`SELECT id, project, text, component, path, hash, url, user_agent, session, created_at, done, done_at FROM %s.todos WHERE id = ?`, s.db), id)
|
|
if err != nil {
|
|
return Todo{}, false, err
|
|
}
|
|
defer rows.Close()
|
|
if !rows.Next() {
|
|
return Todo{}, false, nil
|
|
}
|
|
var t Todo
|
|
var dn uint8
|
|
if err := rows.Scan(&t.ID, &t.Project, &t.Text, &t.Component, &t.Path, &t.Hash, &t.URL, &t.UserAgent, &t.Session, &t.CreatedAt, &dn, &t.DoneAt); err != nil {
|
|
return Todo{}, false, err
|
|
}
|
|
t.Done = dn != 0
|
|
if !t.Done {
|
|
t.DoneAt = time.Time{}
|
|
}
|
|
return t, true, nil
|
|
}
|