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.
- reactifact/__init__.py +96 -0
- reactifact/__main__.py +10 -0
- reactifact/_extras.py +36 -0
- reactifact/agents.py +173 -0
- reactifact/artifacts.py +130 -0
- reactifact/branching.py +255 -0
- reactifact/budget.py +41 -0
- reactifact/chat.py +373 -0
- reactifact/checkpoints.py +329 -0
- reactifact/cli/__init__.py +73 -0
- reactifact/cli/branch.py +77 -0
- reactifact/cli/common.py +67 -0
- reactifact/cli/context.py +53 -0
- reactifact/cli/graph.py +21 -0
- reactifact/cli/replay.py +69 -0
- reactifact/cli/scenario.py +94 -0
- reactifact/cli/trace.py +45 -0
- reactifact/commit.py +97 -0
- reactifact/commit_log.py +235 -0
- reactifact/consume.py +96 -0
- reactifact/context.py +599 -0
- reactifact/effects.py +232 -0
- reactifact/eval.py +319 -0
- reactifact/events.py +34 -0
- reactifact/interrupt.py +22 -0
- reactifact/llm_agent.py +172 -0
- reactifact/operations.py +192 -0
- reactifact/patches.py +112 -0
- reactifact/produce.py +226 -0
- reactifact/prompts.py +111 -0
- reactifact/providers/__init__.py +153 -0
- reactifact/providers/_retry.py +61 -0
- reactifact/providers/anthropic.py +182 -0
- reactifact/providers/azure.py +31 -0
- reactifact/providers/cerebras.py +11 -0
- reactifact/providers/chat.py +417 -0
- reactifact/providers/contracts.py +105 -0
- reactifact/providers/deepseek.py +11 -0
- reactifact/providers/fake.py +40 -0
- reactifact/providers/fireworks.py +17 -0
- reactifact/providers/gemini.py +284 -0
- reactifact/providers/github_models.py +13 -0
- reactifact/providers/groq.py +18 -0
- reactifact/providers/image.py +157 -0
- reactifact/providers/mistral.py +17 -0
- reactifact/providers/nvidia.py +18 -0
- reactifact/providers/ollama.py +18 -0
- reactifact/providers/openai.py +44 -0
- reactifact/providers/openrouter.py +70 -0
- reactifact/providers/perplexity.py +11 -0
- reactifact/providers/qwen.py +17 -0
- reactifact/providers/speech.py +347 -0
- reactifact/providers/together.py +17 -0
- reactifact/providers/video.py +407 -0
- reactifact/providers/xai.py +11 -0
- reactifact/providers/zai.py +11 -0
- reactifact/py.typed +0 -0
- reactifact/recipes/__init__.py +63 -0
- reactifact/recipes/inputs.py +34 -0
- reactifact/recipes/memory.py +166 -0
- reactifact/recipes/resolve.py +51 -0
- reactifact/recipes/rollback.py +87 -0
- reactifact/recipes/search.py +81 -0
- reactifact/recipes/skills.py +108 -0
- reactifact/recipes/status.py +79 -0
- reactifact/recipes/text.py +202 -0
- reactifact/relations.py +104 -0
- reactifact/replay.py +187 -0
- reactifact/resources.py +45 -0
- reactifact/runtime.py +498 -0
- reactifact/scheduler.py +188 -0
- reactifact/session.py +75 -0
- reactifact/sources.py +498 -0
- reactifact/streaming.py +58 -0
- reactifact/structured.py +245 -0
- reactifact/testing/__init__.py +48 -0
- reactifact/testing/assertions.py +326 -0
- reactifact/testing/exceptions.py +27 -0
- reactifact/testing/fault.py +164 -0
- reactifact/testing/lab.py +350 -0
- reactifact/testing/mock.py +166 -0
- reactifact/testing/record.py +50 -0
- reactifact/testing/registry.py +87 -0
- reactifact/tool_use.py +528 -0
- reactifact/tools.py +111 -0
- reactifact/tracing/__init__.py +29 -0
- reactifact/tracing/langfuse.py +125 -0
- reactifact/tracing/models.py +93 -0
- reactifact/tracing/postgres.py +220 -0
- reactifact/tracing/store.py +254 -0
- reactifact/tracing/templates/ui.html +196 -0
- reactifact/tracing/templates/ui_run.html +264 -0
- reactifact/tracing/tracer.py +370 -0
- reactifact/tracing/web.py +117 -0
- reactifact/triggers.py +41 -0
- reactifact/viz.py +248 -0
- reactifact/web.py +117 -0
- reactifact-0.6.0.dist-info/METADATA +226 -0
- reactifact-0.6.0.dist-info/RECORD +103 -0
- reactifact-0.6.0.dist-info/WHEEL +5 -0
- reactifact-0.6.0.dist-info/entry_points.txt +2 -0
- reactifact-0.6.0.dist-info/licenses/LICENSE +21 -0
- 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)=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|