diff --git a/internal/collector/todos.go b/internal/collector/todos.go
index c65157b..977ca7b 100644
--- a/internal/collector/todos.go
+++ b/internal/collector/todos.go
@@ -31,14 +31,16 @@ func clipTo(s string, n int) string {
}
type todoPayload struct {
- Project string `json:"project"`
- Text string `json:"text"`
- Component string `json:"component"`
- Path string `json:"path"`
- Hash string `json:"hash"`
- URL string `json:"url"`
- UserAgent string `json:"ua"`
- Session string `json:"session"`
+ Project string `json:"project"`
+ Text string `json:"text"`
+ Component string `json:"component"`
+ Path string `json:"path"`
+ Hash string `json:"hash"`
+ URL string `json:"url"`
+ UserAgent string `json:"ua"`
+ Session string `json:"session"`
+ ElementSelector string `json:"elementSelector"`
+ ElementLabel string `json:"elementLabel"`
}
// HandleTodoCreate ingests a todo posted by otel-todo.js. It is guarded by
@@ -72,16 +74,18 @@ func (c *Collector) HandleTodoCreate(w http.ResponseWriter, r *http.Request) {
id := make([]byte, 8)
_, _ = rand.Read(id)
t := todos.Todo{
- ID: hex.EncodeToString(id),
- Project: project,
- Text: text,
- Component: clipTo(p.Component, maxTodoMeta),
- Path: clipTo(p.Path, maxTodoMeta),
- Hash: clipTo(p.Hash, maxTodoMeta),
- URL: clipTo(p.URL, maxTodoMeta),
- UserAgent: clipTo(p.UserAgent, maxTodoMeta),
- Session: clipTo(p.Session, maxTodoMeta),
- CreatedAt: time.Now().UTC(),
+ ID: hex.EncodeToString(id),
+ Project: project,
+ Text: text,
+ Component: clipTo(p.Component, maxTodoMeta),
+ Path: clipTo(p.Path, maxTodoMeta),
+ Hash: clipTo(p.Hash, maxTodoMeta),
+ URL: clipTo(p.URL, maxTodoMeta),
+ UserAgent: clipTo(p.UserAgent, maxTodoMeta),
+ Session: clipTo(p.Session, maxTodoMeta),
+ ElementSelector: clipTo(p.ElementSelector, maxTodoMeta),
+ ElementLabel: clipTo(p.ElementLabel, maxTodoMeta),
+ CreatedAt: time.Now().UTC(),
}
if err := c.todoStore.Add(r.Context(), t); err != nil {
log.Printf("failed to store todo: %v", err)
diff --git a/internal/todos/todos.go b/internal/todos/todos.go
index 0c2ad70..77184eb 100644
--- a/internal/todos/todos.go
+++ b/internal/todos/todos.go
@@ -17,18 +17,20 @@ import (
// 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"`
+ 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"`
+ ElementSelector string `json:"elementSelector,omitempty"` // CSS selector of the referenced element, if picked
+ ElementLabel string `json:"elementLabel,omitempty"` // human-readable tag/text summary of that element
+ 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.
@@ -154,7 +156,7 @@ func NewClickHouseStore(ctx context.Context, conn driver.Conn, db string) (*Clic
if err != nil {
return nil, err
}
- for _, col := range []string{"done UInt8", "done_at DateTime64(3, 'UTC')"} {
+ for _, col := range []string{"done UInt8", "done_at DateTime64(3, 'UTC')", "element_selector String", "element_label String"} {
if err := conn.Exec(ctx, fmt.Sprintf(`ALTER TABLE %s.todos ADD COLUMN IF NOT EXISTS %s`, db, col)); err != nil {
return nil, err
}
@@ -163,12 +165,12 @@ func NewClickHouseStore(ctx context.Context, conn driver.Conn, db string) (*Clic
}
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())
+ return s.conn.Exec(ctx, fmt.Sprintf(`INSERT INTO %s.todos (id, project, text, component, path, hash, url, user_agent, session, element_selector, element_label, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, s.db),
+ t.ID, t.Project, t.Text, t.Component, t.Path, t.Hash, t.URL, t.UserAgent, t.Session, t.ElementSelector, t.ElementLabel, 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)
+ q := fmt.Sprintf(`SELECT id, project, text, component, path, hash, url, user_agent, session, element_selector, element_label, created_at, done, done_at FROM %s.todos`, s.db)
var args []any
if project != "" {
q += ` WHERE project = ?`
@@ -185,7 +187,7 @@ func (s *ClickHouseStore) List(ctx context.Context, project string, limit int) (
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 {
+ if err := rows.Scan(&t.ID, &t.Project, &t.Text, &t.Component, &t.Path, &t.Hash, &t.URL, &t.UserAgent, &t.Session, &t.ElementSelector, &t.ElementLabel, &t.CreatedAt, &done, &t.DoneAt); err != nil {
return nil, err
}
t.Done = done != 0
diff --git a/otel-ui/src/components/Tabs.tsx b/otel-ui/src/components/Tabs.tsx
index dd2d3b0..21e7061 100644
--- a/otel-ui/src/components/Tabs.tsx
+++ b/otel-ui/src/components/Tabs.tsx
@@ -7,6 +7,7 @@ import {
CheckCircle2,
ChevronDown,
ChevronUp,
+ X,
} from "lucide-react"
import type { LucideIcon } from "lucide-react"
import { MetricsVisualizer } from "./MetricsVisualizer"
@@ -398,11 +399,27 @@ export const TabsComponent = () => {
{statusNotice && (
-
{statusNotice.text}
+
+
+ {statusNotice.error ? : }
+
+
+ {statusNotice.text}
+
{statusNotice.undo && (
)}
+
)}
diff --git a/otel-ui/src/components/TodosVisualizer.tsx b/otel-ui/src/components/TodosVisualizer.tsx
index ea2fbcb..d2ec506 100644
--- a/otel-ui/src/components/TodosVisualizer.tsx
+++ b/otel-ui/src/components/TodosVisualizer.tsx
@@ -1,5 +1,5 @@
import { useMemo, useState } from "react"
-import { CheckCircle2, ExternalLink, ListTodo, RefreshCw, RotateCcw } from "lucide-react"
+import { CheckCircle2, Crosshair, ExternalLink, ListTodo, RefreshCw, RotateCcw } from "lucide-react"
import { useTodos } from "../hooks/useTodos"
import { formatRelativeTime } from "../lib/timeUtils"
import type { Todo } from "../lib/types"
@@ -16,9 +16,20 @@ const TodoCard = ({ todo, onDone }: { todo: Todo; onDone: (todo: Todo, done: boo
}`}
>
-
- {todo.text}
-
+
+
+ {todo.text}
+
+ {todo.elementSelector && (
+
+
+ {todo.elementLabel || todo.elementSelector}
+
+ )}
+
diff --git a/otel-ui/src/lib/types.ts b/otel-ui/src/lib/types.ts
index 70cfc32..58f9b92 100644
--- a/otel-ui/src/lib/types.ts
+++ b/otel-ui/src/lib/types.ts
@@ -347,6 +347,8 @@ export interface Todo {
url?: string;
userAgent?: string;
session?: string;
+ elementSelector?: string;
+ elementLabel?: string;
createdAt: string;
done: boolean;
doneAt?: string;
diff --git a/static/otel-todo.js b/static/otel-todo.js
index f0f36bc..557396e 100644
--- a/static/otel-todo.js
+++ b/static/otel-todo.js
@@ -1,10 +1,12 @@
/**
* otel-todo.js - file todos / issues straight from the page.
*
- * Shows a small floating symbol and listens for a keyboard shortcut. Either
- * opens a native
' +
+ '' +
+ '
' +
'' +
'' +
'' +
@@ -84,8 +103,118 @@
var selectEl = form.elements.component;
var statusEl = form.querySelector('.status');
var metaEl = form.querySelector('.meta');
+ var pickBtn = form.querySelector('.otel-todo-pick');
+ var refEl = form.querySelector('.otel-todo-ref');
+ var refLabelEl = refEl.querySelector('span');
document.body.appendChild(dlg);
+ // --- element picker: click a node on the page to attach its selector ---
+ var picked = null; // { selector, label }
+ var hoverBox = null;
+ var picking = false;
+ var hint = null;
+
+ function cssPath(el) {
+ if (!(el instanceof Element)) return '';
+ var parts = [];
+ var node = el;
+ while (node && node.nodeType === 1 && parts.length < 6) {
+ if (node.id) { parts.unshift('#' + CSS.escape(node.id)); break; }
+ var part = node.tagName.toLowerCase();
+ var cls = (node.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean).slice(0, 2);
+ if (cls.length) part += '.' + cls.map(function (c) { return CSS.escape(c); }).join('.');
+ var parent = node.parentElement;
+ if (parent) {
+ var siblings = [].filter.call(parent.children, function (c) { return c.tagName === node.tagName; });
+ if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(node) + 1) + ')';
+ }
+ parts.unshift(part);
+ node = parent;
+ }
+ return parts.join(' > ');
+ }
+
+ function describeElement(el) {
+ var tag = el.tagName.toLowerCase();
+ var text = (el.getAttribute('aria-label') || el.textContent || '').replace(/\s+/g, ' ').trim();
+ if (text.length > 60) text = text.slice(0, 60) + '…';
+ return { selector: cssPath(el), label: text ? tag + ' – "' + text + '"' : tag };
+ }
+
+ function setRef(ref) {
+ picked = ref;
+ if (ref) {
+ refLabelEl.textContent = ref.label;
+ refLabelEl.title = ref.selector;
+ refEl.classList.add('show');
+ } else {
+ refEl.classList.remove('show');
+ }
+ }
+
+ function ensureHoverBox() {
+ if (!hoverBox) {
+ hoverBox = document.createElement('div');
+ hoverBox.className = 'otel-todo-hover-outline';
+ document.body.appendChild(hoverBox);
+ }
+ return hoverBox;
+ }
+
+ function onPickMove(e) {
+ var el = document.elementFromPoint(e.clientX, e.clientY);
+ if (!el || dlg.contains(el)) { ensureHoverBox().style.display = 'none'; return; }
+ var r = el.getBoundingClientRect();
+ var box = ensureHoverBox();
+ box.style.display = 'block';
+ box.style.left = r.left + 'px';
+ box.style.top = r.top + 'px';
+ box.style.width = r.width + 'px';
+ box.style.height = r.height + 'px';
+ }
+
+ function onPickClick(e) {
+ var el = document.elementFromPoint(e.clientX, e.clientY);
+ if (!el || dlg.contains(el)) return; // let dialog controls work normally
+ e.preventDefault();
+ e.stopPropagation();
+ setRef(describeElement(el));
+ stopPicking();
+ }
+
+ function onPickKey(e) {
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); stopPicking(); }
+ }
+
+ function startPicking() {
+ if (picking) return;
+ picking = true;
+ pickBtn.setAttribute('aria-pressed', 'true');
+ pickBtn.textContent = '⌖ Click an element… (Esc to cancel)';
+ hint = document.createElement('div');
+ hint.className = 'otel-todo-pick-hint';
+ hint.textContent = 'Click the element you mean — Esc to cancel';
+ document.body.appendChild(hint);
+ document.addEventListener('mousemove', onPickMove, true);
+ document.addEventListener('click', onPickClick, true);
+ document.addEventListener('keydown', onPickKey, true);
+ }
+
+ function stopPicking() {
+ if (!picking) return;
+ picking = false;
+ pickBtn.setAttribute('aria-pressed', 'false');
+ pickBtn.textContent = '⌖ Reference an element';
+ document.removeEventListener('mousemove', onPickMove, true);
+ document.removeEventListener('click', onPickClick, true);
+ document.removeEventListener('keydown', onPickKey, true);
+ if (hoverBox) { hoverBox.style.display = 'none'; }
+ if (hint) { hint.remove(); hint = null; }
+ }
+
+ pickBtn.addEventListener('click', function () { picking ? stopPicking() : startPicking(); });
+ refEl.querySelector('[data-clear-ref]').addEventListener('click', function () { setRef(null); });
+
function components() {
var seen = {};
var list = [];
@@ -113,12 +242,16 @@
selectEl.hidden = list.length === 0;
metaEl.textContent = project + ' · ' + location.pathname + location.hash;
statusEl.textContent = '';
+ setRef(null);
// Non-modal so the page stays usable while the note is being written.
dlg.show();
textEl.focus();
}
- function close() { if (dlg.open) dlg.close(); }
+ function close() {
+ stopPicking();
+ if (dlg.open) dlg.close();
+ }
form.querySelector('[data-cancel]').addEventListener('click', close);
dlg.addEventListener('keydown', function (e) {
@@ -145,11 +278,14 @@
hash: location.hash,
url: location.href,
ua: navigator.userAgent,
- session: sessionId
+ session: sessionId,
+ elementSelector: picked ? picked.selector : '',
+ elementLabel: picked ? picked.label : ''
})
}).then(function (res) {
if (!res.ok) throw new Error('HTTP ' + res.status);
textEl.value = '';
+ setRef(null);
close();
}).catch(function (err) {
statusEl.textContent = 'Failed: ' + err.message;