fixes
This commit is contained in:
1 parent
c8cc0fb28a
commit
e790c02d59
16 files changed
+655
-11
No files matched your search
@@ -336,6 +336,8 @@ to Docker or stored in the image.
|
|||||||
- `OTELLOG_PATH` - path to the persistent telemetry log file (default
|
- `OTELLOG_PATH` - path to the persistent telemetry log file (default
|
||||||
`otel-agent.log`). Only used when ClickHouse is not configured.
|
`otel-agent.log`). Only used when ClickHouse is not configured.
|
||||||
- `OTEL_SOURCEMAP_KEY` - shared key required to upload source maps.
|
- `OTEL_SOURCEMAP_KEY` - shared key required to upload source maps.
|
||||||
|
- `OTEL_ISSUE_STATUS_PATH` - file that keeps resolved/ignored issue decisions across restarts when ClickHouse is not used (default: next to `OTELLOG_PATH`).
|
||||||
|
- `OTEL_TODOS_PATH` - file for todos filed via `otel-todo.js` when ClickHouse is not used (default: next to `OTELLOG_PATH`).
|
||||||
- `OTEL_SOURCEMAP_CACHE_MB` - size budget (on-disk MB, default 64) for maps kept
|
- `OTEL_SOURCEMAP_CACHE_MB` - size budget (on-disk MB, default 64) for maps kept
|
||||||
parsed in memory; maps are indexed at start and parsed lazily on first use.
|
parsed in memory; maps are indexed at start and parsed lazily on first use.
|
||||||
- `OTEL_SOURCEMAP_DIR` - directory for uploaded release source maps (default
|
- `OTEL_SOURCEMAP_DIR` - directory for uploaded release source maps (default
|
||||||
|
|||||||
+36
-5
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/pprof"
|
"net/http/pprof"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.tornberg.me/go-otel/internal/abuseipdb"
|
"git.tornberg.me/go-otel/internal/abuseipdb"
|
||||||
@@ -18,6 +19,7 @@ import (
|
|||||||
"git.tornberg.me/go-otel/internal/geoip"
|
"git.tornberg.me/go-otel/internal/geoip"
|
||||||
"git.tornberg.me/go-otel/internal/honeypot"
|
"git.tornberg.me/go-otel/internal/honeypot"
|
||||||
"git.tornberg.me/go-otel/internal/investigation"
|
"git.tornberg.me/go-otel/internal/investigation"
|
||||||
|
"git.tornberg.me/go-otel/internal/issues"
|
||||||
kube "git.tornberg.me/go-otel/internal/kubernetes"
|
kube "git.tornberg.me/go-otel/internal/kubernetes"
|
||||||
"git.tornberg.me/go-otel/internal/prometheus"
|
"git.tornberg.me/go-otel/internal/prometheus"
|
||||||
"git.tornberg.me/go-otel/internal/sourcemaps"
|
"git.tornberg.me/go-otel/internal/sourcemaps"
|
||||||
@@ -185,6 +187,18 @@ func withCORS(next http.HandlerFunc) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dataPath resolves a small state file: the env override, else next to the
|
||||||
|
// telemetry log (a mounted volume in the cluster), else the working directory.
|
||||||
|
func dataPath(env, name string) string {
|
||||||
|
if p := os.Getenv(env); p != "" {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
if lp := os.Getenv("OTELLOG_PATH"); lp != "" {
|
||||||
|
return filepath.Join(filepath.Dir(lp), name)
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
// setupStore returns the telemetry store: ClickHouse when OTEL_CLICKHOUSE_ADDR
|
// setupStore returns the telemetry store: ClickHouse when OTEL_CLICKHOUSE_ADDR
|
||||||
// is set (host:port, native protocol), otherwise the JSONL file store.
|
// is set (host:port, native protocol), otherwise the JSONL file store.
|
||||||
func setupStore(ctx context.Context) storage.Store {
|
func setupStore(ctx context.Context) storage.Store {
|
||||||
@@ -295,11 +309,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if todoStore == nil {
|
if todoStore == nil {
|
||||||
path := os.Getenv("OTEL_TODOS_PATH")
|
if st, err := todos.NewFileStore(dataPath("OTEL_TODOS_PATH", "otel-todos.jsonl")); err != nil {
|
||||||
if path == "" {
|
|
||||||
path = "otel-todos.jsonl"
|
|
||||||
}
|
|
||||||
if st, err := todos.NewFileStore(path); err != nil {
|
|
||||||
log.Printf("todos disabled: %v", err)
|
log.Printf("todos disabled: %v", err)
|
||||||
} else {
|
} else {
|
||||||
todoStore = st
|
todoStore = st
|
||||||
@@ -308,6 +318,26 @@ func main() {
|
|||||||
if todoStore != nil {
|
if todoStore != nil {
|
||||||
coll.SetTodoStore(todoStore)
|
coll.SetTodoStore(todoStore)
|
||||||
}
|
}
|
||||||
|
var statusStore issues.StatusStore
|
||||||
|
if chStore, ok := store.(*storage.ClickHouseStore); ok {
|
||||||
|
if st, err := issues.NewClickHouseStatusStore(context.Background(), chStore.Conn(), chStore.Database()); err != nil {
|
||||||
|
log.Printf("issue status: clickhouse unavailable, using file store: %v", err)
|
||||||
|
} else {
|
||||||
|
statusStore = st
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if statusStore == nil {
|
||||||
|
if st, err := issues.NewFileStatusStore(dataPath("OTEL_ISSUE_STATUS_PATH", "otel-issue-status.json")); err != nil {
|
||||||
|
log.Printf("issue status will not be persisted: %v", err)
|
||||||
|
} else {
|
||||||
|
statusStore = st
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if statusStore != nil {
|
||||||
|
if err := coll.SetIssueStatusStore(context.Background(), statusStore); err != nil {
|
||||||
|
log.Printf("failed to load issue status: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
investigator := investigation.NewHandler(investigation.ConfigFromEnv(), investigationStore)
|
investigator := investigation.NewHandler(investigation.ConfigFromEnv(), investigationStore)
|
||||||
abuse := abuseipdb.NewHandler(abuseipdb.ConfigFromEnv())
|
abuse := abuseipdb.NewHandler(abuseipdb.ConfigFromEnv())
|
||||||
|
|
||||||
@@ -409,6 +439,7 @@ func main() {
|
|||||||
uiMux.Handle("GET /api/issues/{id}/similar", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleIssueSimilar)))
|
uiMux.Handle("GET /api/issues/{id}/similar", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleIssueSimilar)))
|
||||||
uiMux.Handle("POST /api/issues/{id}/similar", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleSimilarityFeedback)))
|
uiMux.Handle("POST /api/issues/{id}/similar", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleSimilarityFeedback)))
|
||||||
uiMux.Handle("GET /api/todos", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleTodoList)))
|
uiMux.Handle("GET /api/todos", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleTodoList)))
|
||||||
|
uiMux.Handle("PATCH /api/todos/{id}", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleTodoUpdate)))
|
||||||
uiMux.Handle("GET /api/patterns", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandlePatterns)))
|
uiMux.Handle("GET /api/patterns", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandlePatterns)))
|
||||||
uiMux.Handle("GET /api/topology", oidcAuth.RequireAuth(http.HandlerFunc(topo.HandleTopology)))
|
uiMux.Handle("GET /api/topology", oidcAuth.RequireAuth(http.HandlerFunc(topo.HandleTopology)))
|
||||||
uiMux.Handle("GET /api/rollouts", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleRollouts)))
|
uiMux.Handle("GET /api/rollouts", oidcAuth.RequireAuth(http.HandlerFunc(coll.HandleRollouts)))
|
||||||
|
|||||||
@@ -222,6 +222,11 @@ func (c *Collector) SetIssueStatus(id, status string) (issues.Issue, bool) {
|
|||||||
return c.issues.SetStatus(id, status)
|
return c.issues.SetStatus(id, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetIssueStatusStore makes resolved/ignored decisions survive restarts.
|
||||||
|
func (c *Collector) SetIssueStatusStore(ctx context.Context, store issues.StatusStore) error {
|
||||||
|
return c.issues.SetStatusStore(ctx, store)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Collector) SimilarIssues(id string, limit int) ([]issues.SimilarIssue, bool) {
|
func (c *Collector) SimilarIssues(id string, limit int) ([]issues.SimilarIssue, bool) {
|
||||||
return c.issues.Similar(id, limit)
|
return c.issues.Similar(id, limit)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,3 +116,30 @@ func (c *Collector) HandleTodoList(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(items)
|
_ = json.NewEncoder(w).Encode(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleTodoUpdate marks a todo done or open: PATCH /api/todos/{id} {"done": bool}.
|
||||||
|
func (c *Collector) HandleTodoUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if c.todoStore == nil {
|
||||||
|
http.Error(w, "todos not enabled", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Done *bool `json:"done"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024)).Decode(&req); err != nil || req.Done == nil {
|
||||||
|
http.Error(w, "body must be {\"done\": true|false}", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t, ok, err := c.todoStore.SetDone(r.Context(), r.PathValue("id"), *req.Done)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("failed to update todo: %v", err)
|
||||||
|
http.Error(w, "failed to update todo", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "todo not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(t)
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package issues
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TriageState is the part of an issue a person decided, kept across restarts.
|
||||||
|
// The tracker is in-memory, so without this every deploy would reopen every
|
||||||
|
// resolved and ignored issue.
|
||||||
|
type TriageState struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
ResolvedAt int64 `json:"resolvedAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusStore persists triage state by issue id.
|
||||||
|
type StatusStore interface {
|
||||||
|
Load(ctx context.Context) (map[string]TriageState, error)
|
||||||
|
Save(ctx context.Context, id string, s TriageState) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileStatusStore keeps all states in one JSON file, rewritten on each save.
|
||||||
|
type FileStatusStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
path string
|
||||||
|
states map[string]TriageState
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFileStatusStore(path string) (*FileStatusStore, error) {
|
||||||
|
fs := &FileStatusStore{path: path, states: map[string]TriageState{}}
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return fs, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &fs.states); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return fs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FileStatusStore) Load(context.Context) (map[string]TriageState, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
out := make(map[string]TriageState, len(s.states))
|
||||||
|
for k, v := range s.states {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *FileStatusStore) Save(_ context.Context, id string, st TriageState) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if st.Status == StatusOpen {
|
||||||
|
delete(s.states, id)
|
||||||
|
} else {
|
||||||
|
s.states[id] = st
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(s.states)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := s.path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, s.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClickHouseStatusStore keeps the latest state per issue id.
|
||||||
|
type ClickHouseStatusStore struct {
|
||||||
|
conn driver.Conn
|
||||||
|
db string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClickHouseStatusStore(ctx context.Context, conn driver.Conn, db string) (*ClickHouseStatusStore, error) {
|
||||||
|
err := conn.Exec(ctx, fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s.issue_status (
|
||||||
|
id String,
|
||||||
|
status LowCardinality(String),
|
||||||
|
resolved_at Int64,
|
||||||
|
updated_at DateTime64(3, 'UTC')
|
||||||
|
) ENGINE = ReplacingMergeTree(updated_at)
|
||||||
|
ORDER BY id`, db))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ClickHouseStatusStore{conn: conn, db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ClickHouseStatusStore) Load(ctx context.Context) (map[string]TriageState, error) {
|
||||||
|
rows, err := s.conn.Query(ctx, fmt.Sprintf(`SELECT id, status, resolved_at FROM %s.issue_status FINAL`, s.db))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := map[string]TriageState{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
var st TriageState
|
||||||
|
if err := rows.Scan(&id, &st.Status, &st.ResolvedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if st.Status != StatusOpen {
|
||||||
|
out[id] = st
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ClickHouseStatusStore) Save(ctx context.Context, id string, st TriageState) error {
|
||||||
|
return s.conn.Exec(ctx, fmt.Sprintf(`INSERT INTO %s.issue_status (id, status, resolved_at, updated_at) VALUES (?, ?, ?, ?)`, s.db),
|
||||||
|
id, st.Status, st.ResolvedAt, time.Now().UTC())
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package issues
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTriagePersistsAcrossRestart(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "status.json")
|
||||||
|
store, _ := NewFileStatusStore(path)
|
||||||
|
tr := NewTracker()
|
||||||
|
if err := tr.SetStatusStore(context.Background(), store); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tr.Record("span", "svc", "boom", Sample{Timestamp: 1})
|
||||||
|
id := Fingerprint("span", "svc", NormalizeTitle("boom"))
|
||||||
|
if _, ok := tr.SetStatus(id, StatusIgnored); !ok {
|
||||||
|
t.Fatal("set status")
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond) // async save
|
||||||
|
|
||||||
|
// "Restart": fresh tracker, same file.
|
||||||
|
store2, err := NewFileStatusStore(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tr2 := NewTracker()
|
||||||
|
if err := tr2.SetStatusStore(context.Background(), store2); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tr2.Record("span", "svc", "boom", Sample{Timestamp: 2})
|
||||||
|
got, _ := tr2.Get(id)
|
||||||
|
if got.Status != StatusIgnored {
|
||||||
|
t.Fatalf("status after restart = %q, want ignored", got.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolvedIssueRegresses(t *testing.T) {
|
||||||
|
tr := NewTracker()
|
||||||
|
tr.Record("span", "svc", "boom", Sample{Timestamp: 1})
|
||||||
|
id := Fingerprint("span", "svc", NormalizeTitle("boom"))
|
||||||
|
tr.SetStatus(id, StatusResolved)
|
||||||
|
tr.Record("span", "svc", "boom", Sample{Timestamp: 5})
|
||||||
|
got, _ := tr.Get(id)
|
||||||
|
if got.Status != StatusOpen || got.RegressedAt != 5 {
|
||||||
|
t.Fatalf("got status=%q regressedAt=%d", got.Status, got.RegressedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,10 @@
|
|||||||
package issues
|
package issues
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"hash/fnv"
|
"hash/fnv"
|
||||||
|
"log"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -86,6 +88,8 @@ type Issue struct {
|
|||||||
// Status is the triage state: open, resolved or ignored.
|
// Status is the triage state: open, resolved or ignored.
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
ResolvedAt int64 `json:"resolvedAt,omitempty"`
|
ResolvedAt int64 `json:"resolvedAt,omitempty"`
|
||||||
|
// RegressedAt is when a resolved issue last fired again and was reopened.
|
||||||
|
RegressedAt int64 `json:"regressedAt,omitempty"`
|
||||||
// NormalizedTitle is the deterministic, redacted form used for exact grouping.
|
// NormalizedTitle is the deterministic, redacted form used for exact grouping.
|
||||||
NormalizedTitle string `json:"normalizedTitle,omitempty"`
|
NormalizedTitle string `json:"normalizedTitle,omitempty"`
|
||||||
// PatternID groups related exact issues without merging their operational IDs.
|
// PatternID groups related exact issues without merging their operational IDs.
|
||||||
@@ -119,6 +123,10 @@ type Pattern struct {
|
|||||||
type Tracker struct {
|
type Tracker struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
issues map[string]*Issue
|
issues map[string]*Issue
|
||||||
|
// triage holds persisted resolved/ignored decisions by issue id, applied
|
||||||
|
// when an issue is (re)created after a restart.
|
||||||
|
triage map[string]TriageState
|
||||||
|
statusStore StatusStore
|
||||||
feedback map[string]string
|
feedback map[string]string
|
||||||
sink OccurrenceSink
|
sink OccurrenceSink
|
||||||
}
|
}
|
||||||
@@ -161,7 +169,43 @@ type ListPage struct {
|
|||||||
|
|
||||||
// NewTracker creates an empty Tracker.
|
// NewTracker creates an empty Tracker.
|
||||||
func NewTracker() *Tracker {
|
func NewTracker() *Tracker {
|
||||||
return &Tracker{issues: make(map[string]*Issue), feedback: make(map[string]string)}
|
return &Tracker{issues: make(map[string]*Issue), feedback: make(map[string]string), triage: make(map[string]TriageState)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetStatusStore loads persisted triage decisions and saves future ones.
|
||||||
|
func (t *Tracker) SetStatusStore(ctx context.Context, store StatusStore) error {
|
||||||
|
states, err := store.Load(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
t.statusStore = store
|
||||||
|
for id, st := range states {
|
||||||
|
t.triage[id] = st
|
||||||
|
if issue, ok := t.issues[id]; ok {
|
||||||
|
issue.Status, issue.ResolvedAt = st.Status, st.ResolvedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// persistLocked records a decision. The write happens off the lock.
|
||||||
|
func (t *Tracker) persistLocked(id string, st TriageState) {
|
||||||
|
if st.Status == StatusOpen {
|
||||||
|
delete(t.triage, id)
|
||||||
|
} else {
|
||||||
|
t.triage[id] = st
|
||||||
|
}
|
||||||
|
if t.statusStore == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
store := t.statusStore
|
||||||
|
go func() {
|
||||||
|
if err := store.Save(context.Background(), id, st); err != nil {
|
||||||
|
log.Printf("failed to persist issue status: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record adds an occurrence of an error, creating a new issue or merging into
|
// Record adds an occurrence of an error, creating a new issue or merging into
|
||||||
@@ -196,6 +240,9 @@ func (t *Tracker) record(kind, service, title string, sample Sample) (string, Oc
|
|||||||
Status: StatusOpen,
|
Status: StatusOpen,
|
||||||
NormalizedTitle: NormalizeTitle(title),
|
NormalizedTitle: NormalizeTitle(title),
|
||||||
}
|
}
|
||||||
|
if st, ok := t.triage[id]; ok {
|
||||||
|
issue.Status, issue.ResolvedAt = st.Status, st.ResolvedAt
|
||||||
|
}
|
||||||
issue.PatternSignature = patternSignature(kind, service, title, sample)
|
issue.PatternSignature = patternSignature(kind, service, title, sample)
|
||||||
issue.PatternID = Fingerprint("pattern", issue.PatternSignature)
|
issue.PatternID = Fingerprint("pattern", issue.PatternSignature)
|
||||||
t.issues[id] = issue
|
t.issues[id] = issue
|
||||||
@@ -214,6 +261,8 @@ func (t *Tracker) record(kind, service, title string, sample Sample) (string, Oc
|
|||||||
if issue.Status == StatusResolved {
|
if issue.Status == StatusResolved {
|
||||||
issue.Status = StatusOpen
|
issue.Status = StatusOpen
|
||||||
issue.ResolvedAt = 0
|
issue.ResolvedAt = 0
|
||||||
|
issue.RegressedAt = sample.Timestamp
|
||||||
|
t.persistLocked(id, TriageState{Status: StatusOpen})
|
||||||
}
|
}
|
||||||
issue.Samples = append(issue.Samples, sample)
|
issue.Samples = append(issue.Samples, sample)
|
||||||
if len(issue.Samples) > maxSamples {
|
if len(issue.Samples) > maxSamples {
|
||||||
@@ -462,6 +511,8 @@ func (t *Tracker) SetStatus(id, status string) (Issue, bool) {
|
|||||||
} else {
|
} else {
|
||||||
issue.ResolvedAt = time.Now().UnixNano()
|
issue.ResolvedAt = time.Now().UnixNano()
|
||||||
}
|
}
|
||||||
|
issue.RegressedAt = 0
|
||||||
|
t.persistLocked(id, TriageState{Status: status, ResolvedAt: issue.ResolvedAt})
|
||||||
return cloneIssue(issue), true
|
return cloneIssue(issue), true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+71
-2
@@ -27,12 +27,16 @@ type Todo struct {
|
|||||||
UserAgent string `json:"userAgent,omitempty"`
|
UserAgent string `json:"userAgent,omitempty"`
|
||||||
Session string `json:"session,omitempty"`
|
Session string `json:"session,omitempty"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
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.
|
// Store persists todos. List returns newest first; an empty project means all.
|
||||||
type Store interface {
|
type Store interface {
|
||||||
Add(ctx context.Context, t Todo) error
|
Add(ctx context.Context, t Todo) error
|
||||||
List(ctx context.Context, project string, limit int) ([]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.
|
// FileStore keeps todos in memory and appends them to a JSONL file.
|
||||||
@@ -82,6 +86,35 @@ func (s *FileStore) Add(_ context.Context, t Todo) error {
|
|||||||
return nil
|
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) {
|
func (s *FileStore) List(_ context.Context, project string, limit int) ([]Todo, error) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@@ -121,6 +154,11 @@ func NewClickHouseStore(ctx context.Context, conn driver.Conn, db string) (*Clic
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
return &ClickHouseStore{conn: conn, db: db}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +168,7 @@ func (s *ClickHouseStore) Add(ctx context.Context, t Todo) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *ClickHouseStore) List(ctx context.Context, project string, limit int) ([]Todo, error) {
|
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 FROM %s.todos`, s.db)
|
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
|
var args []any
|
||||||
if project != "" {
|
if project != "" {
|
||||||
q += ` WHERE project = ?`
|
q += ` WHERE project = ?`
|
||||||
@@ -146,10 +184,41 @@ func (s *ClickHouseStore) List(ctx context.Context, project string, limit int) (
|
|||||||
var out []Todo
|
var out []Todo
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var t Todo
|
var t Todo
|
||||||
if err := rows.Scan(&t.ID, &t.Project, &t.Text, &t.Component, &t.Path, &t.Hash, &t.URL, &t.UserAgent, &t.Session, &t.CreatedAt); err != nil {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
t.Done = done != 0
|
||||||
out = append(out, t)
|
out = append(out, t)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
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
|
||||||
|
}
|
||||||
@@ -373,6 +373,15 @@ const IssueCard = ({
|
|||||||
<span className={`inline-flex h-[23px] shrink-0 items-center gap-1.5 whitespace-nowrap rounded-[6px] px-2 text-[10.5px] font-bold uppercase tracking-wider ${tonePill[tone]}`}>
|
<span className={`inline-flex h-[23px] shrink-0 items-center gap-1.5 whitespace-nowrap rounded-[6px] px-2 text-[10.5px] font-bold uppercase tracking-wider ${tonePill[tone]}`}>
|
||||||
{toneLabel[tone]}
|
{toneLabel[tone]}
|
||||||
</span>
|
</span>
|
||||||
|
{issue.regressedAt && issueStatusOf(issue) === "open" ? (
|
||||||
|
<span
|
||||||
|
title={`Was resolved, fired again ${formatRelativeTime(issue.regressedAt / 1e6)}`}
|
||||||
|
className="inline-flex h-[23px] shrink-0 items-center gap-1.5 whitespace-nowrap rounded-[6px] bg-ds-warn-bg px-2 text-[10.5px] font-bold uppercase tracking-wider text-ds-warn"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3 w-3" aria-hidden="true" />
|
||||||
|
Regressed
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<span className="inline-flex h-[23px] shrink-0 items-center gap-1.5 whitespace-nowrap rounded-[6px] border border-ds-line-strong px-2 text-[10.5px] font-bold uppercase tracking-wider text-ds-text-2">
|
<span className="inline-flex h-[23px] shrink-0 items-center gap-1.5 whitespace-nowrap rounded-[6px] border border-ds-line-strong px-2 text-[10.5px] font-bold uppercase tracking-wider text-ds-text-2">
|
||||||
<meta.Icon className="h-3 w-3 shrink-0" aria-hidden="true" />
|
<meta.Icon className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||||
{meta.label}
|
{meta.label}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
import * as Tabs from "@radix-ui/react-tabs"
|
import * as Tabs from "@radix-ui/react-tabs"
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
@@ -28,6 +28,7 @@ import { TelemetryFilters } from "./TelemetryFilters"
|
|||||||
import { PatternsVisualizer } from "./PatternsVisualizer"
|
import { PatternsVisualizer } from "./PatternsVisualizer"
|
||||||
import { RolloutsVisualizer } from "./RolloutsVisualizer"
|
import { RolloutsVisualizer } from "./RolloutsVisualizer"
|
||||||
import { SourceIpsVisualizer } from "./SourceIpsVisualizer"
|
import { SourceIpsVisualizer } from "./SourceIpsVisualizer"
|
||||||
|
import { TodosVisualizer } from "./TodosVisualizer"
|
||||||
|
|
||||||
|
|
||||||
type SeverityMeta = {
|
type SeverityMeta = {
|
||||||
@@ -269,15 +270,20 @@ export const TabsComponent = () => {
|
|||||||
const { traces, logs, activeTab, setActiveTab } = useObservabilityData()
|
const { traces, logs, activeTab, setActiveTab } = useObservabilityData()
|
||||||
const issuesState = useIssues()
|
const issuesState = useIssues()
|
||||||
const [statusBusy, setStatusBusy] = useState(false)
|
const [statusBusy, setStatusBusy] = useState(false)
|
||||||
|
const [statusNotice, setStatusNotice] = useState<{ text: string; undo?: () => void; error?: boolean } | null>(null)
|
||||||
// Trends tab only exists when the backend has ClickHouse-backed stats;
|
// Trends tab only exists when the backend has ClickHouse-backed stats;
|
||||||
// null while the capability check is in flight.
|
// null while the capability check is in flight.
|
||||||
const statsAvailable = useStatsAvailability()
|
const statsAvailable = useStatsAvailability()
|
||||||
|
|
||||||
// Triage action: PATCH the issue status, then refresh so counts/badges
|
// Triage action: apply optimistically, PATCH, then refresh so counts and
|
||||||
// reflect the server state. Failures surface via the issues error banner.
|
// badges match the server. A failure rolls back and says so; a success
|
||||||
|
// offers Undo because the card moves into the collapsed resolved/ignored
|
||||||
|
// group and would otherwise just vanish.
|
||||||
const handleIssueStatusChange = useCallback(
|
const handleIssueStatusChange = useCallback(
|
||||||
async (issue: Issue, status: string) => {
|
async (issue: Issue, status: string) => {
|
||||||
|
const previous = issue.status ?? "open"
|
||||||
setStatusBusy(true)
|
setStatusBusy(true)
|
||||||
|
void issuesState.patch(issue.id, { status, regressedAt: undefined })
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(`/api/issues/${issue.id}`, {
|
const res = await authFetch(`/api/issues/${issue.id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
@@ -285,8 +291,14 @@ export const TabsComponent = () => {
|
|||||||
body: JSON.stringify({ status }),
|
body: JSON.stringify({ status }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
const verb = status === "resolved" ? "Resolved" : status === "ignored" ? "Ignored" : "Reopened"
|
||||||
|
setStatusNotice({
|
||||||
|
text: `${verb}: ${issue.title}`,
|
||||||
|
undo: status === "open" ? undefined : () => void handleIssueStatusChange({ ...issue, status }, previous),
|
||||||
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to update issue status:", err)
|
console.error("Failed to update issue status:", err)
|
||||||
|
setStatusNotice({ text: `Couldn't update issue: ${err instanceof Error ? err.message : String(err)}`, error: true })
|
||||||
} finally {
|
} finally {
|
||||||
await issuesState.refresh()
|
await issuesState.refresh()
|
||||||
setStatusBusy(false)
|
setStatusBusy(false)
|
||||||
@@ -295,6 +307,12 @@ export const TabsComponent = () => {
|
|||||||
[issuesState],
|
[issuesState],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!statusNotice) return
|
||||||
|
const t = setTimeout(() => setStatusNotice(null), statusNotice.error ? 8000 : 6000)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [statusNotice])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<TelemetryFilters />
|
<TelemetryFilters />
|
||||||
@@ -335,6 +353,9 @@ export const TabsComponent = () => {
|
|||||||
<Tabs.Content className="outline-none py-2" value="tab10">
|
<Tabs.Content className="outline-none py-2" value="tab10">
|
||||||
{activeTab === "tab10" && <ViewErrorBoundary><SourceIpsVisualizer /></ViewErrorBoundary>}
|
{activeTab === "tab10" && <ViewErrorBoundary><SourceIpsVisualizer /></ViewErrorBoundary>}
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
<Tabs.Content className="outline-none py-2" value="tab11">
|
||||||
|
{activeTab === "tab11" && <ViewErrorBoundary><TodosVisualizer /></ViewErrorBoundary>}
|
||||||
|
</Tabs.Content>
|
||||||
<Tabs.Content
|
<Tabs.Content
|
||||||
className="outline-none py-2"
|
className="outline-none py-2"
|
||||||
value="tab2"
|
value="tab2"
|
||||||
@@ -374,6 +395,28 @@ export const TabsComponent = () => {
|
|||||||
{activeTab === "tab6" && <ViewErrorBoundary><SearchAndWatcher /></ViewErrorBoundary>}
|
{activeTab === "tab6" && <ViewErrorBoundary><SearchAndWatcher /></ViewErrorBoundary>}
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
</Tabs.Root>
|
</Tabs.Root>
|
||||||
|
{statusNotice && (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className={`fixed bottom-6 left-1/2 z-50 flex max-w-[min(560px,calc(100vw-120px))] -translate-x-1/2 items-center gap-3 rounded-[10px] border bg-ds-surface px-4 py-2.5 text-[13px] ${
|
||||||
|
statusNotice.error ? "border-ds-critical/40 text-ds-critical" : "border-ds-line-strong text-ds-text"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="truncate">{statusNotice.text}</span>
|
||||||
|
{statusNotice.undo && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
statusNotice.undo?.()
|
||||||
|
setStatusNotice(null)
|
||||||
|
}}
|
||||||
|
className="shrink-0 font-semibold text-ds-accent hover:underline"
|
||||||
|
>
|
||||||
|
Undo
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import { CheckCircle2, ExternalLink, ListTodo, RefreshCw, RotateCcw } from "lucide-react"
|
||||||
|
import { useTodos } from "../hooks/useTodos"
|
||||||
|
import { formatRelativeTime } from "../lib/timeUtils"
|
||||||
|
import type { Todo } from "../lib/types"
|
||||||
|
import { Segmented } from "./common/Segmented"
|
||||||
|
|
||||||
|
type StatusFilter = "open" | "done" | "all"
|
||||||
|
|
||||||
|
const TodoCard = ({ todo, onDone }: { todo: Todo; onDone: (todo: Todo, done: boolean) => void }) => {
|
||||||
|
const location = `${todo.path ?? ""}${todo.hash ?? ""}` || "/"
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={`flex flex-col gap-2.5 rounded-[14px] border border-ds-line border-l-[3px] bg-ds-surface px-5 py-4 ${
|
||||||
|
todo.done ? "border-l-ds-ok" : "border-l-ds-line-strong"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<p className={`min-w-0 flex-1 whitespace-pre-wrap break-words text-[14px] ${todo.done ? "text-ds-text-3 line-through" : "text-ds-text"}`}>
|
||||||
|
{todo.text}
|
||||||
|
</p>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{todo.url && (
|
||||||
|
<a
|
||||||
|
href={todo.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-[9px] border border-ds-line-strong px-2.5 py-1.5 text-[12.5px] font-medium text-ds-text-2 hover:bg-ds-surface-2 hover:text-ds-text"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
Open page
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDone(todo, !todo.done)}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-[9px] border border-ds-line-strong px-2.5 py-1.5 text-[12.5px] font-medium text-ds-text-2 hover:bg-ds-surface-2 hover:text-ds-text"
|
||||||
|
>
|
||||||
|
{todo.done ? <RotateCcw className="h-3.5 w-3.5" aria-hidden="true" /> : <CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />}
|
||||||
|
{todo.done ? "Reopen" : "Done"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-[11px] text-ds-text-3">
|
||||||
|
<span className="rounded-[6px] border border-ds-line-strong px-2 py-0.5 font-bold uppercase tracking-wider text-ds-text-2">{todo.project}</span>
|
||||||
|
{todo.component && (
|
||||||
|
<span className="rounded-[6px] border border-ds-accent/30 bg-ds-accent-bg px-2 py-0.5 font-bold uppercase tracking-wider text-ds-accent">
|
||||||
|
{todo.component}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="font-[family-name:var(--font-data)]">{location}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span title={new Date(todo.createdAt).toLocaleString()}>{formatRelativeTime(new Date(todo.createdAt).getTime())}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TodosVisualizer = () => {
|
||||||
|
const { todos, loading, error, refresh, setDone } = useTodos()
|
||||||
|
const [status, setStatus] = useState<StatusFilter>("open")
|
||||||
|
const [project, setProject] = useState("")
|
||||||
|
|
||||||
|
const projects = useMemo(() => {
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
for (const t of todos) if (!t.done) counts.set(t.project, (counts.get(t.project) ?? 0) + 1)
|
||||||
|
for (const t of todos) if (!counts.has(t.project)) counts.set(t.project, 0)
|
||||||
|
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||||
|
}, [todos])
|
||||||
|
|
||||||
|
const visible = useMemo(
|
||||||
|
() =>
|
||||||
|
todos.filter(
|
||||||
|
(t) => (!project || t.project === project) && (status === "all" || (status === "done" ? t.done : !t.done)),
|
||||||
|
),
|
||||||
|
[todos, project, status],
|
||||||
|
)
|
||||||
|
const openCount = todos.filter((t) => !t.done).length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<h1 className="flex items-center gap-2 font-[family-name:var(--font-display)] text-[17px] font-bold text-ds-text">
|
||||||
|
<ListTodo className="h-5 w-5 text-ds-text-3" aria-hidden="true" />
|
||||||
|
Todos from the site
|
||||||
|
</h1>
|
||||||
|
<span className="font-[family-name:var(--font-data)] text-[12px] text-ds-text-3">{openCount} open</span>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<Segmented
|
||||||
|
label="Status"
|
||||||
|
value={status}
|
||||||
|
onChange={setStatus}
|
||||||
|
options={[
|
||||||
|
{ value: "open", label: "Open" },
|
||||||
|
{ value: "done", label: "Done" },
|
||||||
|
{ value: "all", label: "All" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void refresh()}
|
||||||
|
aria-label="Refresh todos"
|
||||||
|
className="inline-flex h-8 w-8 items-center justify-center rounded-[9px] border border-ds-line text-ds-text-3 hover:bg-ds-surface-2 hover:text-ds-text"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{projects.length > 1 && (
|
||||||
|
<div role="group" aria-label="Project" className="flex flex-wrap gap-2">
|
||||||
|
{[["", openCount] as [string, number], ...projects].map(([name, count]) => (
|
||||||
|
<button
|
||||||
|
key={name || "all"}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={project === name}
|
||||||
|
onClick={() => setProject(name)}
|
||||||
|
className={`rounded-[8px] border px-2.5 py-1 text-[12px] font-medium ${
|
||||||
|
project === name
|
||||||
|
? "border-ds-accent/40 bg-ds-accent-bg text-ds-accent"
|
||||||
|
: "border-ds-line-strong text-ds-text-2 hover:bg-ds-surface-2"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{name || "All projects"} <span className="text-ds-text-3">{count}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div role="alert" className="rounded-[10px] border border-ds-critical/40 bg-ds-critical-bg px-4 py-2.5 text-[13px] text-ds-critical">
|
||||||
|
Couldn't load todos: {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{visible.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 rounded-[14px] border border-dashed border-ds-line py-16 text-center">
|
||||||
|
<ListTodo className="h-8 w-8 text-ds-text-3" aria-hidden="true" />
|
||||||
|
<p className="text-[14px] font-medium text-ds-text">
|
||||||
|
{loading ? "Loading…" : todos.length === 0 ? "No todos yet" : "Nothing matches"}
|
||||||
|
</p>
|
||||||
|
{todos.length === 0 && !loading && (
|
||||||
|
<p className="max-w-sm text-[12.5px] text-ds-text-3">
|
||||||
|
Press Alt+Shift+T on a page running otel-rum.js to file one.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{visible.map((t) => (
|
||||||
|
<TodoCard key={t.id} todo={t} onDone={(todo, done) => void setDone(todo, done)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TodosVisualizer
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
GitBranch,
|
GitBranch,
|
||||||
Globe,
|
Globe,
|
||||||
LineChart,
|
LineChart,
|
||||||
|
ListTodo,
|
||||||
Radar,
|
Radar,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
@@ -64,6 +65,7 @@ export const IconRail: React.FC = () => {
|
|||||||
{ tab: "tab2", label: "Metrics & APM", Icon: LineChart },
|
{ tab: "tab2", label: "Metrics & APM", Icon: LineChart },
|
||||||
{ tab: "tab7", label: "RUM sessions", Icon: Globe },
|
{ tab: "tab7", label: "RUM sessions", Icon: Globe },
|
||||||
{ tab: "tab10", label: "Source IPs", Icon: Fingerprint },
|
{ tab: "tab10", label: "Source IPs", Icon: Fingerprint },
|
||||||
|
{ tab: "tab11", label: "Todos from the site", Icon: ListTodo },
|
||||||
...(statsAvailable ? [{ tab: "tab5", label: "Historical trends", Icon: TrendingUp }] : []),
|
...(statsAvailable ? [{ tab: "tab5", label: "Historical trends", Icon: TrendingUp }] : []),
|
||||||
{ tab: "tab6", label: "Archive & trap", Icon: Radar },
|
{ tab: "tab6", label: "Archive & trap", Icon: Radar },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ export function useIssues() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
const refresh = useCallback(() => mutate(), [mutate])
|
const refresh = useCallback(() => mutate(), [mutate])
|
||||||
|
// Optimistic local edit so a triage click shows immediately; the next
|
||||||
|
// refresh replaces it with the server's truth.
|
||||||
|
const patch = useCallback(
|
||||||
|
(id: string, changes: Partial<Issue>) =>
|
||||||
|
mutate((current) => current?.map((i) => (i.id === id ? { ...i, ...changes } : i)) ?? current, { revalidate: false }),
|
||||||
|
[mutate],
|
||||||
|
)
|
||||||
|
|
||||||
const { issuesVersion } = useObservabilityData()
|
const { issuesVersion } = useObservabilityData()
|
||||||
|
|
||||||
@@ -46,5 +53,6 @@ export function useIssues() {
|
|||||||
loading: isLoading,
|
loading: isLoading,
|
||||||
error: error instanceof Error ? error.message : error ? String(error) : null,
|
error: error instanceof Error ? error.message : error ? String(error) : null,
|
||||||
refresh,
|
refresh,
|
||||||
|
patch,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useCallback } from "react"
|
||||||
|
import useSWR from "swr"
|
||||||
|
import { authFetch, authSWRFetcher, tokens } from "../lib/auth"
|
||||||
|
import type { Todo } from "../lib/types"
|
||||||
|
|
||||||
|
const KEY = "/api/todos?limit=500"
|
||||||
|
|
||||||
|
// Todos filed from the site by otel-todo.js (GET /api/todos). All projects
|
||||||
|
// are fetched once; the view filters by project client-side.
|
||||||
|
export function useTodos() {
|
||||||
|
const { data, error, isLoading, mutate } = useSWR<Todo[]>(tokens() ? KEY : null, authSWRFetcher, {
|
||||||
|
refreshInterval: 30_000,
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
shouldRetryOnError: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const setDone = useCallback(
|
||||||
|
async (todo: Todo, done: boolean) => {
|
||||||
|
// Optimistic, then reconcile with the server.
|
||||||
|
await mutate(
|
||||||
|
async (current) => {
|
||||||
|
const res = await authFetch(`/api/todos/${todo.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ done }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
const updated = (await res.json()) as Todo
|
||||||
|
return (current ?? []).map((t) => (t.id === todo.id ? updated : t))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
optimisticData: (current) => (current ?? []).map((t) => (t.id === todo.id ? { ...t, done } : t)),
|
||||||
|
rollbackOnError: true,
|
||||||
|
revalidate: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[mutate],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
todos: data ?? [],
|
||||||
|
loading: isLoading,
|
||||||
|
error: error instanceof Error ? error.message : error ? String(error) : null,
|
||||||
|
refresh: () => mutate(),
|
||||||
|
setDone,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ export const TAB_PATHS: Record<string, string> = {
|
|||||||
"/patterns": "tab8",
|
"/patterns": "tab8",
|
||||||
"/rollouts": "tab9",
|
"/rollouts": "tab9",
|
||||||
"/ips": "tab10",
|
"/ips": "tab10",
|
||||||
|
"/todos": "tab11",
|
||||||
"/traces": "tab1",
|
"/traces": "tab1",
|
||||||
"/metrics": "tab2",
|
"/metrics": "tab2",
|
||||||
"/logs": "tab3",
|
"/logs": "tab3",
|
||||||
|
|||||||
@@ -201,6 +201,8 @@ export interface Issue {
|
|||||||
trend?: Record<string, number>;
|
trend?: Record<string, number>;
|
||||||
status?: "open" | "resolved" | "ignored" | string;
|
status?: "open" | "resolved" | "ignored" | string;
|
||||||
resolvedAt?: number;
|
resolvedAt?: number;
|
||||||
|
/** Set when a resolved issue fired again and was reopened. */
|
||||||
|
regressedAt?: number;
|
||||||
normalizedTitle?: string;
|
normalizedTitle?: string;
|
||||||
patternId?: string;
|
patternId?: string;
|
||||||
patternSignature?: string;
|
patternSignature?: string;
|
||||||
@@ -334,3 +336,18 @@ export interface SourceIpEvent {
|
|||||||
|
|
||||||
/** Reason from the server's bad-actor rules (features.MarkBadActors). */
|
/** Reason from the server's bad-actor rules (features.MarkBadActors). */
|
||||||
export type BadActorReason = "" | "probe-scanner" | "enumerator" | "bot-4xx"
|
export type BadActorReason = "" | "probe-scanner" | "enumerator" | "bot-4xx"
|
||||||
|
|
||||||
|
export interface Todo {
|
||||||
|
id: string;
|
||||||
|
project: string;
|
||||||
|
text: string;
|
||||||
|
component?: string;
|
||||||
|
path?: string;
|
||||||
|
hash?: string;
|
||||||
|
url?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
session?: string;
|
||||||
|
createdAt: string;
|
||||||
|
done: boolean;
|
||||||
|
doneAt?: string;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user