reactifact 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. reactifact/__init__.py +96 -0
  2. reactifact/__main__.py +10 -0
  3. reactifact/_extras.py +36 -0
  4. reactifact/agents.py +173 -0
  5. reactifact/artifacts.py +130 -0
  6. reactifact/branching.py +255 -0
  7. reactifact/budget.py +41 -0
  8. reactifact/chat.py +373 -0
  9. reactifact/checkpoints.py +329 -0
  10. reactifact/cli/__init__.py +73 -0
  11. reactifact/cli/branch.py +77 -0
  12. reactifact/cli/common.py +67 -0
  13. reactifact/cli/context.py +53 -0
  14. reactifact/cli/graph.py +21 -0
  15. reactifact/cli/replay.py +69 -0
  16. reactifact/cli/scenario.py +94 -0
  17. reactifact/cli/trace.py +45 -0
  18. reactifact/commit.py +97 -0
  19. reactifact/commit_log.py +235 -0
  20. reactifact/consume.py +96 -0
  21. reactifact/context.py +599 -0
  22. reactifact/effects.py +232 -0
  23. reactifact/eval.py +319 -0
  24. reactifact/events.py +34 -0
  25. reactifact/interrupt.py +22 -0
  26. reactifact/llm_agent.py +172 -0
  27. reactifact/operations.py +192 -0
  28. reactifact/patches.py +112 -0
  29. reactifact/produce.py +226 -0
  30. reactifact/prompts.py +111 -0
  31. reactifact/providers/__init__.py +153 -0
  32. reactifact/providers/_retry.py +61 -0
  33. reactifact/providers/anthropic.py +182 -0
  34. reactifact/providers/azure.py +31 -0
  35. reactifact/providers/cerebras.py +11 -0
  36. reactifact/providers/chat.py +417 -0
  37. reactifact/providers/contracts.py +105 -0
  38. reactifact/providers/deepseek.py +11 -0
  39. reactifact/providers/fake.py +40 -0
  40. reactifact/providers/fireworks.py +17 -0
  41. reactifact/providers/gemini.py +284 -0
  42. reactifact/providers/github_models.py +13 -0
  43. reactifact/providers/groq.py +18 -0
  44. reactifact/providers/image.py +157 -0
  45. reactifact/providers/mistral.py +17 -0
  46. reactifact/providers/nvidia.py +18 -0
  47. reactifact/providers/ollama.py +18 -0
  48. reactifact/providers/openai.py +44 -0
  49. reactifact/providers/openrouter.py +70 -0
  50. reactifact/providers/perplexity.py +11 -0
  51. reactifact/providers/qwen.py +17 -0
  52. reactifact/providers/speech.py +347 -0
  53. reactifact/providers/together.py +17 -0
  54. reactifact/providers/video.py +407 -0
  55. reactifact/providers/xai.py +11 -0
  56. reactifact/providers/zai.py +11 -0
  57. reactifact/py.typed +0 -0
  58. reactifact/recipes/__init__.py +63 -0
  59. reactifact/recipes/inputs.py +34 -0
  60. reactifact/recipes/memory.py +166 -0
  61. reactifact/recipes/resolve.py +51 -0
  62. reactifact/recipes/rollback.py +87 -0
  63. reactifact/recipes/search.py +81 -0
  64. reactifact/recipes/skills.py +108 -0
  65. reactifact/recipes/status.py +79 -0
  66. reactifact/recipes/text.py +202 -0
  67. reactifact/relations.py +104 -0
  68. reactifact/replay.py +187 -0
  69. reactifact/resources.py +45 -0
  70. reactifact/runtime.py +498 -0
  71. reactifact/scheduler.py +188 -0
  72. reactifact/session.py +75 -0
  73. reactifact/sources.py +498 -0
  74. reactifact/streaming.py +58 -0
  75. reactifact/structured.py +245 -0
  76. reactifact/testing/__init__.py +48 -0
  77. reactifact/testing/assertions.py +326 -0
  78. reactifact/testing/exceptions.py +27 -0
  79. reactifact/testing/fault.py +164 -0
  80. reactifact/testing/lab.py +350 -0
  81. reactifact/testing/mock.py +166 -0
  82. reactifact/testing/record.py +50 -0
  83. reactifact/testing/registry.py +87 -0
  84. reactifact/tool_use.py +528 -0
  85. reactifact/tools.py +111 -0
  86. reactifact/tracing/__init__.py +29 -0
  87. reactifact/tracing/langfuse.py +125 -0
  88. reactifact/tracing/models.py +93 -0
  89. reactifact/tracing/postgres.py +220 -0
  90. reactifact/tracing/store.py +254 -0
  91. reactifact/tracing/templates/ui.html +196 -0
  92. reactifact/tracing/templates/ui_run.html +264 -0
  93. reactifact/tracing/tracer.py +370 -0
  94. reactifact/tracing/web.py +117 -0
  95. reactifact/triggers.py +41 -0
  96. reactifact/viz.py +248 -0
  97. reactifact/web.py +117 -0
  98. reactifact-0.6.0.dist-info/METADATA +226 -0
  99. reactifact-0.6.0.dist-info/RECORD +103 -0
  100. reactifact-0.6.0.dist-info/WHEEL +5 -0
  101. reactifact-0.6.0.dist-info/entry_points.txt +2 -0
  102. reactifact-0.6.0.dist-info/licenses/LICENSE +21 -0
  103. reactifact-0.6.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,254 @@
1
+ """Trace sinks: what accepts a finished `RunTrace`.
2
+
3
+ `TraceStore` is the SQLite sink (`runs`/`spans` tables), `PostgresStore` the
4
+ Postgres one. Both expose an async interface (`export`/`query`/`get`) — SQLite
5
+ keeps its sync `sqlite3` core and bridges it with `asyncio.to_thread`, so the
6
+ whole tracing surface is uniform `async` (this is what makes a web dashboard
7
+ over Postgres possible). Langfuse is a separate `Tracer` subclass.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import json
14
+ import sqlite3
15
+ import time
16
+ from datetime import UTC, datetime
17
+ from pathlib import Path
18
+ from typing import Any, Protocol
19
+
20
+ from .models import AgentSpan, ArtifactRef, LLMCall, RelationRef, RunTrace
21
+
22
+
23
+ class TraceSink(Protocol):
24
+ """Async interface for trace sinks (SQLite, Postgres, Langfuse…)."""
25
+
26
+ async def export(self, trace: RunTrace) -> None: ...
27
+
28
+
29
+ class TraceReader(Protocol):
30
+ """Async read interface used by the web dashboard (§54)."""
31
+
32
+ async def query(
33
+ self,
34
+ *,
35
+ session_id: str | None = None,
36
+ outcome: str | None = None,
37
+ limit: int = 50,
38
+ offset: int = 0,
39
+ ) -> dict[str, Any]: ...
40
+
41
+ async def get(self, trace_id: str) -> RunTrace | None: ...
42
+
43
+
44
+ class TraceStore:
45
+ """SQLite trace sink: writes `RunTrace` and can serve them back.
46
+
47
+ The `sqlite3` connection is used from a single worker thread: public
48
+ methods are async and delegate to sync implementations via
49
+ `asyncio.to_thread`. Pass `bridge=None` only inside tests that drive the
50
+ sync core directly.
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ path: str = "traces.db",
56
+ *,
57
+ timeout: float = 10.0,
58
+ max_runs: int | None = 200,
59
+ ):
60
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
61
+ self.max_runs = max_runs
62
+ self._conn = sqlite3.connect(path, check_same_thread=False, timeout=timeout)
63
+ self._create_schema()
64
+ self._migrate()
65
+
66
+ def close(self) -> None:
67
+ self._conn.close()
68
+
69
+ def _create_schema(self) -> None:
70
+ self._conn.executescript(
71
+ """
72
+ CREATE TABLE IF NOT EXISTS runs (
73
+ id TEXT PRIMARY KEY,
74
+ session_id TEXT NOT NULL DEFAULT '',
75
+ started_at REAL NOT NULL,
76
+ duration_ms REAL NOT NULL,
77
+ outcome TEXT NOT NULL
78
+ );
79
+ CREATE TABLE IF NOT EXISTS spans (
80
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
81
+ run_id TEXT NOT NULL REFERENCES runs(id),
82
+ agent TEXT NOT NULL,
83
+ event_type TEXT NOT NULL DEFAULT '',
84
+ latency_ms REAL NOT NULL DEFAULT 0,
85
+ error TEXT,
86
+ reads TEXT NOT NULL DEFAULT '[]',
87
+ writes TEXT NOT NULL DEFAULT '[]',
88
+ relations TEXT NOT NULL DEFAULT '[]',
89
+ llm_calls TEXT NOT NULL DEFAULT '[]'
90
+ );
91
+ CREATE INDEX IF NOT EXISTS idx_spans_run ON spans(run_id);
92
+ """
93
+ )
94
+ self._conn.commit()
95
+
96
+ def _migrate(self) -> None:
97
+ """Adds columns that appeared after the old schema (like teff)."""
98
+ try:
99
+ cols = {row[1] for row in self._conn.execute("PRAGMA table_info(spans)")}
100
+ except sqlite3.OperationalError:
101
+ return
102
+ if "llm_calls" not in cols:
103
+ self._conn.execute(
104
+ "ALTER TABLE spans ADD COLUMN llm_calls TEXT NOT NULL DEFAULT '[]'"
105
+ )
106
+ if "relations" not in cols:
107
+ self._conn.execute(
108
+ "ALTER TABLE spans ADD COLUMN relations TEXT NOT NULL DEFAULT '[]'"
109
+ )
110
+ self._conn.commit()
111
+
112
+ # ---- async public API (bridged to the sync core) ----------------------- #
113
+
114
+ async def export(self, trace: RunTrace) -> None:
115
+ await asyncio.to_thread(self._export_sync, trace)
116
+
117
+ async def prune(self, keep: int) -> int:
118
+ return await asyncio.to_thread(self._prune_sync, keep)
119
+
120
+ async def query(
121
+ self,
122
+ *,
123
+ session_id: str | None = None,
124
+ outcome: str | None = None,
125
+ limit: int = 50,
126
+ offset: int = 0,
127
+ ) -> dict[str, Any]:
128
+ return await asyncio.to_thread(
129
+ self._query_sync, session_id, outcome, limit, offset
130
+ )
131
+
132
+ async def get(self, trace_id: str) -> RunTrace | None:
133
+ return await asyncio.to_thread(self._get_sync, trace_id)
134
+
135
+ # ---- sync core --------------------------------------------------------- #
136
+
137
+ def _export_sync(self, trace: RunTrace) -> None:
138
+ started = trace.started_at.timestamp() if trace.started_at else time.time()
139
+ self._conn.execute(
140
+ "INSERT INTO runs (id, session_id, started_at, duration_ms, outcome) "
141
+ "VALUES (?, ?, ?, ?, ?)",
142
+ (trace.id, trace.session_id, started, trace.duration_ms, trace.outcome),
143
+ )
144
+ for span in trace.spans:
145
+ self._conn.execute(
146
+ "INSERT INTO spans (run_id, agent, event_type, latency_ms, error, "
147
+ "reads, writes, relations, llm_calls) "
148
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
149
+ (
150
+ trace.id,
151
+ span.agent,
152
+ span.event_type,
153
+ span.latency_ms,
154
+ span.error,
155
+ json.dumps([r.model_dump(mode="json") for r in span.reads]),
156
+ json.dumps([w.model_dump(mode="json") for w in span.writes]),
157
+ json.dumps([r.model_dump(mode="json") for r in span.relations]),
158
+ json.dumps([c.model_dump(mode="json") for c in span.llm_calls]),
159
+ ),
160
+ )
161
+ self._conn.commit()
162
+ if self.max_runs is not None:
163
+ self._prune_sync(self.max_runs)
164
+
165
+ def _prune_sync(self, keep: int) -> int:
166
+ """Keeps the last `keep` traces, deletes older ones (retention)."""
167
+ cur = self._conn.execute(
168
+ "DELETE FROM runs WHERE id NOT IN "
169
+ "(SELECT id FROM runs ORDER BY started_at DESC LIMIT ?)",
170
+ (keep,),
171
+ )
172
+ self._conn.execute(
173
+ "DELETE FROM spans WHERE run_id NOT IN (SELECT id FROM runs)"
174
+ )
175
+ self._conn.commit()
176
+ return cur.rowcount
177
+
178
+ def _query_sync(
179
+ self,
180
+ session_id: str | None,
181
+ outcome: str | None,
182
+ limit: int,
183
+ offset: int,
184
+ ) -> dict[str, Any]:
185
+ """Latest traces with filters and pagination (§54).
186
+
187
+ Returns ``{"items": [...], "total": n}``, where total is before pagination.
188
+ """
189
+ where: list[str] = []
190
+ args: list[Any] = []
191
+ if session_id is not None:
192
+ where.append("session_id = ?")
193
+ args.append(session_id)
194
+ if outcome is not None:
195
+ where.append("outcome = ?")
196
+ args.append(outcome)
197
+ where_sql = (" WHERE " + " AND ".join(where)) if where else ""
198
+
199
+ total = self._conn.execute(
200
+ f"SELECT COUNT(*) FROM runs{where_sql}", tuple(args)
201
+ ).fetchone()[0]
202
+ rows = self._conn.execute(
203
+ f"SELECT id, session_id, started_at, duration_ms, outcome, "
204
+ f"(SELECT COUNT(*) FROM spans WHERE run_id = runs.id) AS spans_count "
205
+ f"FROM runs{where_sql} ORDER BY started_at DESC LIMIT ? OFFSET ?",
206
+ (*args, limit, offset),
207
+ ).fetchall()
208
+ items = [
209
+ {
210
+ "id": row[0],
211
+ "session_id": row[1],
212
+ "started_at": row[2],
213
+ "duration_ms": round(row[3], 1),
214
+ "outcome": row[4],
215
+ "spans": row[5],
216
+ }
217
+ for row in rows
218
+ ]
219
+ return {"items": items, "total": total}
220
+
221
+ def _get_sync(self, trace_id: str) -> RunTrace | None:
222
+ row = self._conn.execute(
223
+ "SELECT id, session_id, started_at, duration_ms, outcome "
224
+ "FROM runs WHERE id = ?",
225
+ (trace_id,),
226
+ ).fetchone()
227
+ if row is None:
228
+ return None
229
+ span_rows = self._conn.execute(
230
+ "SELECT agent, event_type, latency_ms, error, reads, writes, relations, llm_calls "
231
+ "FROM spans WHERE run_id = ? ORDER BY id",
232
+ (trace_id,),
233
+ ).fetchall()
234
+ spans = [
235
+ AgentSpan(
236
+ agent=r[0],
237
+ event_type=r[1],
238
+ latency_ms=r[2],
239
+ error=r[3],
240
+ reads=[ArtifactRef(**d) for d in json.loads(r[4])],
241
+ writes=[ArtifactRef(**d) for d in json.loads(r[5])],
242
+ relations=[RelationRef(**d) for d in json.loads(r[6])],
243
+ llm_calls=[LLMCall(**d) for d in json.loads(r[7])],
244
+ )
245
+ for r in span_rows
246
+ ]
247
+ return RunTrace(
248
+ id=row[0],
249
+ session_id=row[1],
250
+ started_at=datetime.fromtimestamp(row[2], tz=UTC),
251
+ duration_ms=row[3],
252
+ outcome=row[4],
253
+ spans=spans,
254
+ )
@@ -0,0 +1,196 @@
1
+ <!doctype html>
2
+ <html lang="ru">
3
+ <head>
4
+ <meta charset="utf-8"/>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
6
+ <title>reactifact traces</title>
7
+ <script>(function(){try{var s=localStorage.getItem('reactifact-theme');var d=s?s==='dark':true;document.documentElement.classList.toggle('dark',d);}catch(e){document.documentElement.classList.add('dark');}})();</script>
8
+ <style>
9
+ :root{
10
+ --bg:#f7f7f9;--panel:#ffffff;--panel-alt:#fafafc;--border:#e6e6ee;--border-strong:#d3d3de;
11
+ --text:#17171f;--muted:#6f6f7e;--accent:#4f46e5;--accent-soft:#eef0ff;
12
+ --ok:#15803d;--err:#b91c1c;--ok-soft:#e9f7ef;--err-soft:#fdeeee;--chip:#eef0ff;--chip-text:#4338ca;
13
+ --hover:#f4f4f8;--shadow:0 1px 2px rgba(16,16,30,.04),0 1px 6px rgba(16,16,30,.05);
14
+ --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
15
+ }
16
+ html.dark{
17
+ --bg:#0f1016;--panel:#171820;--panel-alt:#1d1e27;--border:#262733;--border-strong:#33344a;
18
+ --text:#e7e7f0;--muted:#9a9aae;--accent:#8b93ff;--accent-soft:#232448;
19
+ --ok:#3ecf7c;--err:#f97c7c;--ok-soft:#123522;--err-soft:#3c1718;--chip:#262a48;--chip-text:#a9b1ff;
20
+ --hover:#1f202b;--shadow:0 1px 2px rgba(0,0,0,.4),0 1px 8px rgba(0,0,0,.3);
21
+ }
22
+ *{box-sizing:border-box;}
23
+ body{margin:0;background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Inter,sans-serif;font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased;}
24
+ header{position:sticky;top:0;z-index:10;background:var(--panel);border-bottom:1px solid var(--border);backdrop-filter:saturate(1.4) blur(6px);}
25
+ .header-inner{max-width:1240px;margin:0 auto;padding:0 28px;height:56px;display:flex;align-items:center;gap:14px;}
26
+ .logo{display:flex;align-items:center;gap:9px;color:var(--text);text-decoration:none;}
27
+ .logo b{font-size:15px;letter-spacing:.1px;}
28
+ .logo .sub{color:var(--muted);font-family:var(--mono);font-size:12px;font-weight:400;}
29
+ .spacer{flex:1;}
30
+ #theme{border:1px solid var(--border);background:var(--panel);color:var(--text);border-radius:8px;padding:5px 11px;font-size:12.5px;cursor:pointer;}
31
+ #theme:hover{border-color:var(--accent);color:var(--accent);}
32
+ main{max-width:1240px;margin:0 auto;padding:28px;}
33
+ .page-head{display:flex;align-items:center;gap:14px;margin-bottom:20px;}
34
+ .page-head h1{font-size:21px;margin:0;font-weight:650;letter-spacing:-.2px;}
35
+ .page-head .meta{color:var(--muted);font-size:13px;font-variant-numeric:tabular-nums;}
36
+ .card{background:var(--panel);border:1px solid var(--border);border-radius:12px;box-shadow:var(--shadow);}
37
+ .toolbar{display:flex;align-items:end;gap:14px;padding:16px 18px;border-bottom:1px solid var(--border);flex-wrap:wrap;}
38
+ .field{display:flex;flex-direction:column;gap:5px;}
39
+ .field label{font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;}
40
+ .pills{display:flex;gap:5px;flex-wrap:wrap;}
41
+ .pill{border:1px solid var(--border);background:var(--panel);color:var(--muted);border-radius:20px;padding:4px 12px;font-size:12.5px;cursor:pointer;}
42
+ .pill.active{background:var(--accent-soft);color:var(--accent);border-color:var(--accent);}
43
+ .toolbar input,.toolbar select{border:1px solid var(--border);background:var(--panel);color:var(--text);border-radius:8px;padding:6px 10px;font-size:13px;font-family:inherit;}
44
+ .btn{border:1px solid var(--border);background:var(--panel);color:var(--muted);border-radius:8px;padding:6px 12px;font-size:13px;cursor:pointer;}
45
+ .btn:hover{border-color:var(--accent);color:var(--accent);}
46
+ table{width:100%;border-collapse:collapse;}
47
+ thead th{text-align:left;font-size:11px;font-weight:600;letter-spacing:.6px;text-transform:uppercase;color:var(--muted);padding:10px 14px;border-bottom:1px solid var(--border);background:var(--panel);}
48
+ tbody td{padding:11px 14px;border-bottom:1px solid var(--border);font-size:13px;vertical-align:middle;}
49
+ tbody tr:last-child td{border-bottom:none;}
50
+ tbody tr.row{cursor:pointer;}
51
+ tbody tr.row:hover td{background:var(--hover);}
52
+ .status{display:inline-flex;align-items:center;gap:7px;font-weight:600;font-size:12.5px;}
53
+ .dot{width:8px;height:8px;border-radius:50%;flex:none;}
54
+ .dot.ok{background:var(--ok);} .dot.err{background:var(--err);} .dot.amber{background:#d97706;}
55
+ .status.ok{color:var(--ok);} .status.err{color:var(--err);} .status.amber{color:#d97706;}
56
+ .mono{font-family:var(--mono);} .num{font-variant-numeric:tabular-nums;color:var(--muted);}
57
+ .run-id{font-weight:600;color:var(--accent);font-family:var(--mono);}
58
+ .empty{padding:48px 20px;text-align:center;color:var(--muted);}
59
+ .empty .big{font-size:26px;display:block;margin-bottom:8px;}
60
+ .pager{display:flex;align-items:center;gap:12px;padding:12px 16px;border-top:1px solid var(--border);color:var(--muted);font-size:12.5px;}
61
+ .pager button{border:1px solid var(--border);background:var(--panel);color:var(--text);border-radius:8px;padding:5px 12px;cursor:pointer;font-size:12.5px;font-family:inherit;}
62
+ .pager button:hover:not(:disabled){border-color:var(--accent);color:var(--accent);}
63
+ .pager button:disabled{opacity:.4;cursor:default;}
64
+ .pager .pageinfo{font-variant-numeric:tabular-nums;}
65
+ .pager select{border:1px solid var(--border);background:var(--panel);color:var(--text);border-radius:8px;padding:5px 8px;font-size:12.5px;font-family:inherit;}
66
+ .poll{font-size:12px;color:var(--muted);}
67
+ </style>
68
+ </head>
69
+ <body>
70
+ <header>
71
+ <div class="header-inner">
72
+ <a class="logo" href="/traces">
73
+ <svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
74
+ <circle cx="5" cy="12" r="2.6" fill="currentColor"/>
75
+ <circle cx="19" cy="5" r="2.6" fill="currentColor"/>
76
+ <circle cx="19" cy="19" r="2.6" fill="currentColor"/>
77
+ <path d="M7.4 11 16.6 6M7.4 13l9.2 5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
78
+ </svg>
79
+ <b>reactifact <span style="color:var(--accent)">traces</span></b>
80
+ <span class="sub">artifact run observer</span>
81
+ </a>
82
+ <div class="spacer"></div>
83
+ <span class="poll" id="poll"></span>
84
+ <button id="theme" title="toggle dark mode">dark</button>
85
+ </div>
86
+ </header>
87
+ <main>
88
+ <div class="page-head">
89
+ <h1>Traces</h1>
90
+ <span class="meta" id="summary">loading…</span>
91
+ </div>
92
+ <div class="card">
93
+ <div class="toolbar">
94
+ <div class="field">
95
+ <label>outcome</label>
96
+ <div class="pills" id="outcome-pills">
97
+ <button class="pill active" data-v="">all</button>
98
+ <button class="pill" data-v="completed">completed</button>
99
+ <button class="pill" data-v="budget_runs_exceeded">budget</button>
100
+ </div>
101
+ </div>
102
+ <div class="field">
103
+ <label>session</label>
104
+ <input id="f-session" type="search" placeholder="search…" style="min-width:170px"/>
105
+ </div>
106
+ <div class="spacer"></div>
107
+ <button class="btn" id="reset">clear filters</button>
108
+ </div>
109
+ <div id="runs"></div>
110
+ <div class="pager">
111
+ <button id="prev" disabled>← prev</button>
112
+ <span class="pageinfo" id="pageinfo">–</span>
113
+ <button id="next" disabled>next →</button>
114
+ <div style="flex:1"></div>
115
+ <span>per page</span>
116
+ <select id="f-limit">
117
+ <option value="10">10</option><option value="25" selected>25</option>
118
+ <option value="50">50</option><option value="100">100</option>
119
+ </select>
120
+ </div>
121
+ </div>
122
+ </main>
123
+ <script>
124
+ const state = { outcome: '', session: '', limit: 25, offset: 0, total: 0, runs: [] };
125
+
126
+ function applyTheme(dark){document.documentElement.classList.toggle('dark',dark);document.getElementById('theme').textContent=dark?'light':'dark';try{localStorage.setItem('reactifact-theme',dark?'dark':'light');}catch(_){}}
127
+ function initTheme(){let s=null;try{s=localStorage.getItem('reactifact-theme');}catch(_){}applyTheme(s?s==='dark':true);}
128
+ document.getElementById('theme').addEventListener('click',()=>applyTheme(!document.documentElement.classList.contains('dark')));
129
+
130
+ async function load(){
131
+ const q=new URLSearchParams({limit:state.limit,offset:state.offset});
132
+ if(state.outcome)q.set('outcome',state.outcome);
133
+ if(state.session)q.set('session_id',state.session);
134
+ const res=await fetch('/api/traces?'+q);
135
+ const data=await res.json();
136
+ state.runs=data.items||[]; state.total=data.total||0;
137
+ render();
138
+ document.getElementById('poll').textContent='обновлено '+new Date().toLocaleTimeString();
139
+ }
140
+
141
+ function render(){
142
+ const el=document.getElementById('runs');
143
+ document.getElementById('summary').textContent=`${state.total} trace${state.total===1?'':'s'}`;
144
+ if(!state.runs.length){el.innerHTML='<div class="empty"><span class="big">∅</span>Нет трейсов. Запустите прогон, чтобы увидеть спаны.</div>';updatePager();return;}
145
+ let html='<table><thead><tr><th>outcome</th><th>run</th><th>session</th><th style="text-align:right">duration</th><th style="text-align:right">spans</th><th style="text-align:right">created</th></tr></thead><tbody>';
146
+ for(const r of state.runs){
147
+ const cls=r.outcome==='completed'?'ok':'err';
148
+ html+='<tr class="row" onclick="openRun(\''+r.id+'\')">'+
149
+ '<td><span class="status '+cls+'"><span class="dot '+cls+'"></span>'+escapeHtml(r.outcome)+'</span></td>'+
150
+ '<td class="run-id" title="'+escapeHtml(r.id)+'">#'+String(r.id).slice(0,8)+'</td>'+
151
+ '<td>'+escapeHtml(r.session_id||'—')+'</td>'+
152
+ '<td class="num" style="text-align:right">'+Number(r.duration_ms).toFixed(0)+' ms</td>'+
153
+ '<td class="num" style="text-align:right">'+Number(r.spans)+'</td>'+
154
+ '<td class="num" style="text-align:right">'+fmtTime(r.started_at)+'</td></tr>';
155
+ }
156
+ el.innerHTML=html+'</tbody></table>';
157
+ updatePager();
158
+ }
159
+
160
+ function updatePager(){
161
+ const pages=Math.max(1,Math.ceil(state.total/state.limit));
162
+ const page=Math.floor(state.offset/state.limit)+1;
163
+ document.getElementById('prev').disabled=state.offset<=0;
164
+ document.getElementById('next').disabled=page>=pages;
165
+ document.getElementById('pageinfo').textContent=`${Math.min(state.total,state.offset+1)}–${Math.min(state.total,state.offset+state.limit)} of ${state.total} · page ${page}/${pages}`;
166
+ }
167
+
168
+ function openRun(id){window.location.href='/traces/'+id;}
169
+ function fmtTime(t){
170
+ try{
171
+ const d=typeof t==='number'?new Date(t*1000):new Date(t);
172
+ return d.toLocaleString(undefined,{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'});
173
+ }catch(_){return '—';}
174
+ }
175
+ function escapeHtml(s){return String(s??'').replace(/[&<>"']/g,(ch)=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]));}
176
+ function debounce(fn,ms){let t;return(...a)=>{clearTimeout(t);t=setTimeout(()=>fn(...a),ms||200);};}
177
+
178
+ document.getElementById('outcome-pills').addEventListener('click',(e)=>{
179
+ const p=e.target.closest('.pill'); if(!p)return;
180
+ document.querySelectorAll('#outcome-pills .pill').forEach(x=>x.classList.remove('active'));
181
+ p.classList.add('active'); state.outcome=p.dataset.v; state.offset=0; load();
182
+ });
183
+ document.getElementById('f-session').addEventListener('input',debounce((e)=>{state.session=e.target.value.trim();state.offset=0;load();}));
184
+ document.getElementById('f-limit').addEventListener('change',(e)=>{state.limit=Number(e.target.value);state.offset=0;load();});
185
+ document.getElementById('prev').addEventListener('click',()=>{state.offset=Math.max(0,state.offset-state.limit);load();});
186
+ document.getElementById('next').addEventListener('click',()=>{state.offset+=state.limit;load();});
187
+ document.getElementById('reset').addEventListener('click',()=>{
188
+ document.querySelectorAll('#outcome-pills .pill').forEach(x=>x.classList.toggle('active',x.dataset.v===''));
189
+ document.getElementById('f-session').value='';
190
+ state.outcome='';state.session='';state.offset=0;load();
191
+ });
192
+
193
+ initTheme(); load(); setInterval(load,3000);
194
+ </script>
195
+ </body>
196
+ </html>