vedana-backoffice 0.1.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.
- vedana_backoffice/Caddyfile +17 -0
- vedana_backoffice/__init__.py +0 -0
- vedana_backoffice/components/__init__.py +0 -0
- vedana_backoffice/components/etl_graph.py +132 -0
- vedana_backoffice/components/ui_chat.py +236 -0
- vedana_backoffice/graph/__init__.py +0 -0
- vedana_backoffice/graph/build.py +169 -0
- vedana_backoffice/pages/__init__.py +0 -0
- vedana_backoffice/pages/chat.py +204 -0
- vedana_backoffice/pages/etl.py +353 -0
- vedana_backoffice/pages/eval.py +1006 -0
- vedana_backoffice/pages/jims_thread_list_page.py +894 -0
- vedana_backoffice/pages/main_dashboard.py +483 -0
- vedana_backoffice/py.typed +0 -0
- vedana_backoffice/start_services.py +39 -0
- vedana_backoffice/state.py +0 -0
- vedana_backoffice/states/__init__.py +0 -0
- vedana_backoffice/states/chat.py +368 -0
- vedana_backoffice/states/common.py +66 -0
- vedana_backoffice/states/etl.py +1590 -0
- vedana_backoffice/states/eval.py +1940 -0
- vedana_backoffice/states/jims.py +508 -0
- vedana_backoffice/states/main_dashboard.py +757 -0
- vedana_backoffice/ui.py +115 -0
- vedana_backoffice/util.py +71 -0
- vedana_backoffice/vedana_backoffice.py +23 -0
- vedana_backoffice-0.1.0.dist-info/METADATA +10 -0
- vedana_backoffice-0.1.0.dist-info/RECORD +30 -0
- vedana_backoffice-0.1.0.dist-info/WHEEL +4 -0
- vedana_backoffice-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import time
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import pandas as pd
|
|
7
|
+
import reflex as rx
|
|
8
|
+
import sqlalchemy as sa
|
|
9
|
+
from vedana_etl.app import app as etl_app
|
|
10
|
+
from vedana_etl.config import DBCONN_DATAPIPE
|
|
11
|
+
|
|
12
|
+
from vedana_backoffice.states.common import get_vedana_app
|
|
13
|
+
from vedana_backoffice.util import safe_render_value
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DashboardState(rx.State):
|
|
17
|
+
"""Business-oriented dashboard state for ETL status and data changes."""
|
|
18
|
+
|
|
19
|
+
# Loading / errors
|
|
20
|
+
loading: bool = False
|
|
21
|
+
error_message: str = ""
|
|
22
|
+
|
|
23
|
+
# Time window selector (in days)
|
|
24
|
+
time_window_days: int = 1
|
|
25
|
+
time_window_options: list[str] = ["1", "3", "7"]
|
|
26
|
+
|
|
27
|
+
# Graph (Memgraph) totals
|
|
28
|
+
graph_total_nodes: int = 0
|
|
29
|
+
graph_total_edges: int = 0
|
|
30
|
+
graph_nodes_by_label: list[dict[str, Any]] = []
|
|
31
|
+
graph_edges_by_type: list[dict[str, Any]] = []
|
|
32
|
+
|
|
33
|
+
# Datapipe (staging before upload to graph) totals
|
|
34
|
+
dp_nodes_total: int = 0
|
|
35
|
+
dp_edges_total: int = 0
|
|
36
|
+
dp_nodes_by_type: list[dict[str, Any]] = []
|
|
37
|
+
dp_edges_by_type: list[dict[str, Any]] = []
|
|
38
|
+
|
|
39
|
+
# Consistency (graph vs datapipe) high-level diffs
|
|
40
|
+
nodes_total_diff: int = 0
|
|
41
|
+
edges_total_diff: int = 0
|
|
42
|
+
|
|
43
|
+
# Changes in the selected time window (seconds resolution)
|
|
44
|
+
new_nodes: int = 0
|
|
45
|
+
updated_nodes: int = 0
|
|
46
|
+
deleted_nodes: int = 0
|
|
47
|
+
|
|
48
|
+
new_edges: int = 0
|
|
49
|
+
updated_edges: int = 0
|
|
50
|
+
deleted_edges: int = 0
|
|
51
|
+
|
|
52
|
+
# Ingest-side new data entries (aggregated across snapshot-style generator tables)
|
|
53
|
+
ingest_new_total: int = 0
|
|
54
|
+
ingest_updated_total: int = 0
|
|
55
|
+
ingest_deleted_total: int = 0
|
|
56
|
+
ingest_breakdown: list[dict[str, Any]] = [] # [{table, added, updated, deleted}]
|
|
57
|
+
|
|
58
|
+
# Change preview (dialog) state
|
|
59
|
+
changes_preview_open: bool = False
|
|
60
|
+
changes_preview_table_name: str = ""
|
|
61
|
+
changes_preview_columns: list[str] = []
|
|
62
|
+
changes_preview_rows: list[dict[str, Any]] = []
|
|
63
|
+
changes_has_preview: bool = False
|
|
64
|
+
|
|
65
|
+
# Server-side pagination for changes preview
|
|
66
|
+
changes_preview_page: int = 0 # 0-indexed current page
|
|
67
|
+
changes_preview_page_size: int = 100 # rows per page
|
|
68
|
+
changes_preview_total_rows: int = 0 # total count
|
|
69
|
+
changes_preview_kind: str = "" # "ingest", "nodes", or "edges"
|
|
70
|
+
changes_preview_label: str = "" # label for graph previews
|
|
71
|
+
|
|
72
|
+
# Expandable row tracking for changes preview
|
|
73
|
+
changes_preview_expanded_rows: list[str] = []
|
|
74
|
+
|
|
75
|
+
def toggle_changes_preview_row_expand(self, row_id: str) -> None:
|
|
76
|
+
"""Toggle expansion state for a changes preview row."""
|
|
77
|
+
row_id = str(row_id or "")
|
|
78
|
+
if not row_id:
|
|
79
|
+
return
|
|
80
|
+
current = set(self.changes_preview_expanded_rows)
|
|
81
|
+
if row_id in current:
|
|
82
|
+
current.remove(row_id)
|
|
83
|
+
else:
|
|
84
|
+
current.add(row_id)
|
|
85
|
+
self.changes_preview_expanded_rows = list(current)
|
|
86
|
+
# Update expanded state in rows to trigger UI refresh
|
|
87
|
+
updated_rows = []
|
|
88
|
+
for row in self.changes_preview_rows:
|
|
89
|
+
new_row = dict(row)
|
|
90
|
+
new_row["expanded"] = row.get("row_id", "") in current
|
|
91
|
+
updated_rows.append(new_row)
|
|
92
|
+
self.changes_preview_rows = updated_rows
|
|
93
|
+
|
|
94
|
+
def set_time_window_days(self, value: str) -> None:
|
|
95
|
+
try:
|
|
96
|
+
d = int(value)
|
|
97
|
+
if d <= 0:
|
|
98
|
+
d = 1
|
|
99
|
+
except Exception:
|
|
100
|
+
d = 1
|
|
101
|
+
self.time_window_days = d
|
|
102
|
+
|
|
103
|
+
def _append_log(self, msg: str) -> None:
|
|
104
|
+
"""Log a message (for consistency with EtlState pattern)."""
|
|
105
|
+
logging.warning(msg)
|
|
106
|
+
|
|
107
|
+
def load_dashboard(self):
|
|
108
|
+
"""Connecting with a background task. Used to trigger animations properly."""
|
|
109
|
+
if self.loading:
|
|
110
|
+
return
|
|
111
|
+
self.loading = True
|
|
112
|
+
self.error_message = ""
|
|
113
|
+
yield
|
|
114
|
+
yield DashboardState.load_dashboard_background()
|
|
115
|
+
|
|
116
|
+
@rx.event(background=True) # type: ignore[operator]
|
|
117
|
+
async def load_dashboard_background(self):
|
|
118
|
+
"""Background task that loads all dashboard data."""
|
|
119
|
+
try:
|
|
120
|
+
async with self:
|
|
121
|
+
await self._load_graph_counters()
|
|
122
|
+
async with self:
|
|
123
|
+
await asyncio.to_thread(self._load_datapipe_counters)
|
|
124
|
+
self._compute_consistency()
|
|
125
|
+
self._load_change_metrics_window()
|
|
126
|
+
self._load_ingest_metrics_window()
|
|
127
|
+
except Exception as e:
|
|
128
|
+
async with self:
|
|
129
|
+
self.error_message = f"Failed to load dashboard: {e}"
|
|
130
|
+
finally:
|
|
131
|
+
async with self:
|
|
132
|
+
self.loading = False
|
|
133
|
+
yield
|
|
134
|
+
|
|
135
|
+
async def _load_graph_counters(self) -> None:
|
|
136
|
+
"""Query Memgraph for totals and per-label counts."""
|
|
137
|
+
va = await get_vedana_app()
|
|
138
|
+
self.graph_total_nodes = int(await va.graph.number_of_nodes())
|
|
139
|
+
self.graph_total_edges = int(await va.graph.number_of_edges())
|
|
140
|
+
# Per-label nodes/edges from graph
|
|
141
|
+
try:
|
|
142
|
+
node_label_rows = await va.graph.execute_ro_cypher_query(
|
|
143
|
+
"MATCH (n) UNWIND labels(n) as label RETURN label, count(*) as cnt ORDER BY cnt DESC"
|
|
144
|
+
)
|
|
145
|
+
graph_nodes_count_by_label = {str(r["label"]): int(r["cnt"]) for r in node_label_rows}
|
|
146
|
+
except Exception:
|
|
147
|
+
graph_nodes_count_by_label = {}
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
edge_type_rows = await va.graph.execute_ro_cypher_query(
|
|
151
|
+
"MATCH ()-[r]->() RETURN type(r) as label, count(*) as cnt ORDER BY cnt DESC"
|
|
152
|
+
)
|
|
153
|
+
graph_edges_count_by_label = {str(r["label"]): int(r["cnt"]) for r in edge_type_rows}
|
|
154
|
+
except Exception:
|
|
155
|
+
graph_edges_count_by_label = {}
|
|
156
|
+
|
|
157
|
+
since_ts = float(time.time() - self.time_window_days * 86400)
|
|
158
|
+
con = DBCONN_DATAPIPE.con # type: ignore[attr-defined]
|
|
159
|
+
|
|
160
|
+
# Nodes
|
|
161
|
+
db_nodes_by_label: dict[str, int] = {}
|
|
162
|
+
nodes_added_by_label: dict[str, int] = {}
|
|
163
|
+
nodes_updated_by_label: dict[str, int] = {}
|
|
164
|
+
nodes_deleted_by_label: dict[str, int] = {}
|
|
165
|
+
# Edges
|
|
166
|
+
db_edges_by_label: dict[str, int] = {}
|
|
167
|
+
edges_added_by_label: dict[str, int] = {}
|
|
168
|
+
edges_updated_by_label: dict[str, int] = {}
|
|
169
|
+
edges_deleted_by_label: dict[str, int] = {}
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
with con.begin() as conn:
|
|
173
|
+
for row in conn.execute(
|
|
174
|
+
sa.text("SELECT COALESCE(node_type, '') AS label, COUNT(*) AS cnt FROM \"nodes\" GROUP BY label")
|
|
175
|
+
).fetchall():
|
|
176
|
+
db_nodes_by_label[str(row[0])] = int(row[1] or 0)
|
|
177
|
+
except Exception:
|
|
178
|
+
db_nodes_by_label = {}
|
|
179
|
+
try:
|
|
180
|
+
with con.begin() as conn:
|
|
181
|
+
for row in conn.execute(
|
|
182
|
+
sa.text("SELECT COALESCE(edge_label, '') AS label, COUNT(*) AS cnt FROM \"edges\" GROUP BY label")
|
|
183
|
+
).fetchall():
|
|
184
|
+
db_edges_by_label[str(row[0])] = int(row[1] or 0)
|
|
185
|
+
except Exception:
|
|
186
|
+
db_edges_by_label = {}
|
|
187
|
+
|
|
188
|
+
# Per-label change counts in window using *_meta
|
|
189
|
+
try:
|
|
190
|
+
with con.begin() as conn:
|
|
191
|
+
# Added nodes
|
|
192
|
+
q = sa.text(
|
|
193
|
+
f"SELECT COALESCE(node_type, '') AS label, COUNT(*) "
|
|
194
|
+
f'FROM "nodes_meta" WHERE delete_ts IS NULL AND create_ts >= {since_ts} GROUP BY label'
|
|
195
|
+
)
|
|
196
|
+
nodes_added_by_label = {str(i): int(c or 0) for i, c in conn.execute(q).fetchall()}
|
|
197
|
+
# Updated nodes
|
|
198
|
+
q = sa.text(
|
|
199
|
+
f"SELECT COALESCE(node_type, '') AS label, COUNT(*) "
|
|
200
|
+
f'FROM "nodes_meta" WHERE delete_ts IS NULL AND update_ts >= {since_ts} '
|
|
201
|
+
f"AND update_ts > create_ts AND create_ts < {since_ts} GROUP BY label"
|
|
202
|
+
)
|
|
203
|
+
nodes_updated_by_label = {str(i): int(c or 0) for i, c in conn.execute(q).fetchall()}
|
|
204
|
+
# Deleted nodes
|
|
205
|
+
q = sa.text(
|
|
206
|
+
f"SELECT COALESCE(node_type, '') AS label, COUNT(*) "
|
|
207
|
+
f'FROM "nodes_meta" WHERE delete_ts IS NOT NULL AND delete_ts >= {since_ts} GROUP BY label'
|
|
208
|
+
)
|
|
209
|
+
nodes_deleted_by_label = {str(i): int(c or 0) for i, c in conn.execute(q).fetchall()}
|
|
210
|
+
except Exception:
|
|
211
|
+
nodes_added_by_label = {}
|
|
212
|
+
nodes_updated_by_label = {}
|
|
213
|
+
nodes_deleted_by_label = {}
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
with con.begin() as conn:
|
|
217
|
+
# Added edges
|
|
218
|
+
q = sa.text(
|
|
219
|
+
f"SELECT COALESCE(edge_label, '') AS label, COUNT(*) "
|
|
220
|
+
f'FROM "edges_meta" WHERE delete_ts IS NULL AND create_ts >= {since_ts} GROUP BY label'
|
|
221
|
+
)
|
|
222
|
+
edges_added_by_label = {str(i): int(c or 0) for i, c in conn.execute(q).fetchall()}
|
|
223
|
+
# Updated edges
|
|
224
|
+
q = sa.text(
|
|
225
|
+
f"SELECT COALESCE(edge_label, '') AS label, COUNT(*) "
|
|
226
|
+
f'FROM "edges_meta" WHERE delete_ts IS NULL AND update_ts >= {since_ts} '
|
|
227
|
+
f"AND update_ts > create_ts AND create_ts < {since_ts} GROUP BY label"
|
|
228
|
+
)
|
|
229
|
+
edges_updated_by_label = {str(i): int(c or 0) for i, c in conn.execute(q).fetchall()}
|
|
230
|
+
# Deleted edges
|
|
231
|
+
q = sa.text(
|
|
232
|
+
f"SELECT COALESCE(edge_label, '') AS label, COUNT(*) "
|
|
233
|
+
f'FROM "edges_meta" WHERE delete_ts IS NOT NULL AND delete_ts >= {since_ts} GROUP BY label'
|
|
234
|
+
)
|
|
235
|
+
edges_deleted_by_label = {str(i): int(c or 0) for i, c in conn.execute(q).fetchall()}
|
|
236
|
+
except Exception:
|
|
237
|
+
edges_added_by_label = {}
|
|
238
|
+
edges_updated_by_label = {}
|
|
239
|
+
edges_deleted_by_label = {}
|
|
240
|
+
|
|
241
|
+
# Merge into per-label stats
|
|
242
|
+
node_labels = set(graph_nodes_count_by_label.keys()) | set(db_nodes_by_label.keys())
|
|
243
|
+
edge_labels = set(graph_edges_count_by_label.keys()) | set(db_edges_by_label.keys())
|
|
244
|
+
|
|
245
|
+
node_stats: list[dict[str, Any]] = []
|
|
246
|
+
for lab in sorted(node_labels):
|
|
247
|
+
g = int(graph_nodes_count_by_label.get(lab, 0))
|
|
248
|
+
d = int(db_nodes_by_label.get(lab, 0))
|
|
249
|
+
|
|
250
|
+
node_stats.append(
|
|
251
|
+
{
|
|
252
|
+
"label": lab,
|
|
253
|
+
"graph_count": g,
|
|
254
|
+
"etl_count": d,
|
|
255
|
+
"added": nodes_added_by_label.get(lab),
|
|
256
|
+
"updated": nodes_updated_by_label.get(lab),
|
|
257
|
+
"deleted": nodes_deleted_by_label.get(lab),
|
|
258
|
+
}
|
|
259
|
+
)
|
|
260
|
+
edge_stats: list[dict[str, Any]] = []
|
|
261
|
+
|
|
262
|
+
for lab in sorted(edge_labels):
|
|
263
|
+
g = int(graph_edges_count_by_label.get(lab, 0))
|
|
264
|
+
d = int(db_edges_by_label.get(lab, 0))
|
|
265
|
+
|
|
266
|
+
edge_stats.append(
|
|
267
|
+
{
|
|
268
|
+
"label": lab,
|
|
269
|
+
"graph_count": g,
|
|
270
|
+
"etl_count": d,
|
|
271
|
+
"added": edges_added_by_label.get(lab),
|
|
272
|
+
"updated": edges_updated_by_label.get(lab),
|
|
273
|
+
"deleted": edges_deleted_by_label.get(lab),
|
|
274
|
+
}
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
self.graph_nodes_by_label = node_stats
|
|
278
|
+
self.graph_edges_by_type = edge_stats
|
|
279
|
+
|
|
280
|
+
def _load_datapipe_counters(self) -> None:
|
|
281
|
+
"""Query Datapipe DB for totals and per-type counts for staging tables."""
|
|
282
|
+
con = DBCONN_DATAPIPE.con # type: ignore[attr-defined]
|
|
283
|
+
try:
|
|
284
|
+
with con.begin() as conn:
|
|
285
|
+
total_nodes = conn.execute(sa.text('SELECT COUNT(*) FROM "nodes"')).scalar()
|
|
286
|
+
total_edges = conn.execute(sa.text('SELECT COUNT(*) FROM "edges"')).scalar()
|
|
287
|
+
self.dp_nodes_total = int(total_nodes or 0)
|
|
288
|
+
self.dp_edges_total = int(total_edges or 0)
|
|
289
|
+
except Exception:
|
|
290
|
+
self.dp_nodes_total = 0
|
|
291
|
+
self.dp_edges_total = 0
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
with con.begin() as conn:
|
|
295
|
+
rows = conn.execute(
|
|
296
|
+
sa.text(
|
|
297
|
+
"SELECT COALESCE(node_type, '') AS label, COUNT(*) AS cnt "
|
|
298
|
+
'FROM "nodes" GROUP BY label ORDER BY cnt DESC'
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
self.dp_nodes_by_type = [{"label": str(r[0]), "count": int(r[1])} for r in rows.fetchall()]
|
|
302
|
+
except Exception:
|
|
303
|
+
self.dp_nodes_by_type = []
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
with con.begin() as conn:
|
|
307
|
+
rows = conn.execute(
|
|
308
|
+
sa.text(
|
|
309
|
+
"SELECT COALESCE(edge_label, '') AS label, COUNT(*) AS cnt "
|
|
310
|
+
'FROM "edges" GROUP BY label ORDER BY cnt DESC'
|
|
311
|
+
)
|
|
312
|
+
)
|
|
313
|
+
self.dp_edges_by_type = [{"label": str(r[0]), "count": int(r[1])} for r in rows.fetchall()]
|
|
314
|
+
except Exception:
|
|
315
|
+
self.dp_edges_by_type = []
|
|
316
|
+
|
|
317
|
+
def _compute_consistency(self) -> None:
|
|
318
|
+
self.nodes_total_diff = self.graph_total_nodes - self.dp_nodes_total
|
|
319
|
+
self.edges_total_diff = self.graph_total_edges - self.dp_edges_total
|
|
320
|
+
|
|
321
|
+
def _load_change_metrics_window(self) -> None:
|
|
322
|
+
"""Compute adds/edits/deletes for nodes and edges within selected window based on *_meta tables."""
|
|
323
|
+
con = DBCONN_DATAPIPE.con # type: ignore[attr-defined]
|
|
324
|
+
|
|
325
|
+
since_ts = float(time.time() - self.time_window_days * 86400)
|
|
326
|
+
|
|
327
|
+
def _counts_for(table_base: str) -> tuple[int, int, int]:
|
|
328
|
+
meta = f"{table_base}_meta"
|
|
329
|
+
q_added = sa.text(f'SELECT COUNT(*) FROM "{meta}" WHERE delete_ts IS NULL AND create_ts >= {since_ts}')
|
|
330
|
+
q_updated = sa.text(
|
|
331
|
+
f'SELECT COUNT(*) FROM "{meta}" '
|
|
332
|
+
f"WHERE delete_ts IS NULL "
|
|
333
|
+
f"AND update_ts >= {since_ts} "
|
|
334
|
+
f"AND update_ts > create_ts "
|
|
335
|
+
f"AND create_ts < {since_ts}"
|
|
336
|
+
)
|
|
337
|
+
q_deleted = sa.text(
|
|
338
|
+
f'SELECT COUNT(*) FROM "{meta}" WHERE delete_ts IS NOT NULL AND delete_ts >= {since_ts}'
|
|
339
|
+
)
|
|
340
|
+
try:
|
|
341
|
+
with con.begin() as conn:
|
|
342
|
+
added = conn.execute(q_added).scalar() or 0
|
|
343
|
+
updated = conn.execute(q_updated).scalar() or 0
|
|
344
|
+
deleted = conn.execute(q_deleted).scalar() or 0
|
|
345
|
+
return int(added), int(updated), int(deleted)
|
|
346
|
+
except Exception:
|
|
347
|
+
return 0, 0, 0
|
|
348
|
+
|
|
349
|
+
a, u, d = _counts_for("nodes")
|
|
350
|
+
self.new_nodes, self.updated_nodes, self.deleted_nodes = a, u, d
|
|
351
|
+
a, u, d = _counts_for("edges")
|
|
352
|
+
self.new_edges, self.updated_edges, self.deleted_edges = a, u, d
|
|
353
|
+
|
|
354
|
+
def _load_ingest_metrics_window(self) -> None:
|
|
355
|
+
"""Aggregate new/updated/deleted entries for ingest-side generator outputs."""
|
|
356
|
+
con = DBCONN_DATAPIPE.con # type: ignore[attr-defined]
|
|
357
|
+
|
|
358
|
+
since_ts = float(time.time() - self.time_window_days * 86400)
|
|
359
|
+
|
|
360
|
+
tables = [tt.name for t in etl_app.steps if ("stage", "extract") in t.labels for tt in t.output_dts]
|
|
361
|
+
|
|
362
|
+
breakdown: list[dict[str, Any]] = []
|
|
363
|
+
total_a = total_u = total_d = 0
|
|
364
|
+
|
|
365
|
+
for t in tables:
|
|
366
|
+
meta = f"{t}_meta"
|
|
367
|
+
q_total = sa.text(f'SELECT COUNT(*) FROM "{meta}" WHERE delete_ts IS NULL')
|
|
368
|
+
q_added = sa.text(f'SELECT COUNT(*) FROM "{meta}" WHERE delete_ts IS NULL AND create_ts >= {since_ts}')
|
|
369
|
+
q_updated = sa.text(
|
|
370
|
+
f'SELECT COUNT(*) FROM "{meta}" '
|
|
371
|
+
f"WHERE delete_ts IS NULL "
|
|
372
|
+
f"AND update_ts >= {since_ts} "
|
|
373
|
+
f"AND update_ts > create_ts "
|
|
374
|
+
f"AND create_ts < {since_ts}"
|
|
375
|
+
)
|
|
376
|
+
q_deleted = sa.text(
|
|
377
|
+
f'SELECT COUNT(*) FROM "{meta}" WHERE delete_ts IS NOT NULL AND delete_ts >= {since_ts}'
|
|
378
|
+
)
|
|
379
|
+
try:
|
|
380
|
+
with con.begin() as conn:
|
|
381
|
+
c = conn.execute(q_total).scalar_one()
|
|
382
|
+
a = conn.execute(q_added).scalar_one()
|
|
383
|
+
u = conn.execute(q_updated).scalar_one()
|
|
384
|
+
d = conn.execute(q_deleted).scalar_one()
|
|
385
|
+
except Exception as e:
|
|
386
|
+
logging.error(f"error collecting counters: {e}")
|
|
387
|
+
c = a = u = d = 0
|
|
388
|
+
|
|
389
|
+
breakdown.append({"table": t, "total": c, "added": a, "updated": u, "deleted": d})
|
|
390
|
+
total_a += a
|
|
391
|
+
total_u += u
|
|
392
|
+
total_d += d
|
|
393
|
+
|
|
394
|
+
self.ingest_breakdown = breakdown
|
|
395
|
+
self.ingest_new_total = total_a
|
|
396
|
+
self.ingest_updated_total = total_u
|
|
397
|
+
self.ingest_deleted_total = total_d
|
|
398
|
+
|
|
399
|
+
# --- Ingest change preview ---
|
|
400
|
+
def set_changes_preview_open(self, open: bool) -> None:
|
|
401
|
+
if not open:
|
|
402
|
+
self.close_changes_preview()
|
|
403
|
+
|
|
404
|
+
def close_changes_preview(self) -> None:
|
|
405
|
+
self.changes_preview_open = False
|
|
406
|
+
self.changes_preview_table_name = ""
|
|
407
|
+
self.changes_preview_columns = []
|
|
408
|
+
self.changes_preview_rows = []
|
|
409
|
+
self.changes_has_preview = False
|
|
410
|
+
self.changes_preview_page = 0
|
|
411
|
+
self.changes_preview_total_rows = 0
|
|
412
|
+
self.changes_preview_kind = ""
|
|
413
|
+
self.changes_preview_label = ""
|
|
414
|
+
self.changes_preview_expanded_rows = []
|
|
415
|
+
|
|
416
|
+
def open_changes_preview(self, table_name: str) -> None:
|
|
417
|
+
"""Load changed rows for the given ingest table using *_meta within the selected time window."""
|
|
418
|
+
self.changes_preview_table_name = table_name
|
|
419
|
+
self.changes_preview_open = True
|
|
420
|
+
self.changes_has_preview = False
|
|
421
|
+
self.changes_preview_page = 0
|
|
422
|
+
self.changes_preview_kind = "ingest"
|
|
423
|
+
self.changes_preview_label = ""
|
|
424
|
+
self.changes_preview_expanded_rows = [] # Reset expanded rows
|
|
425
|
+
|
|
426
|
+
self._load_ingest_changes_page()
|
|
427
|
+
|
|
428
|
+
def _load_ingest_changes_page(self) -> None:
|
|
429
|
+
"""Load the current page of ingest changes from the database."""
|
|
430
|
+
table_name = self.changes_preview_table_name
|
|
431
|
+
if not table_name:
|
|
432
|
+
return
|
|
433
|
+
|
|
434
|
+
con = DBCONN_DATAPIPE.con # type: ignore[attr-defined]
|
|
435
|
+
|
|
436
|
+
since_ts = float(time.time() - self.time_window_days * 86400)
|
|
437
|
+
meta = f"{table_name}_meta"
|
|
438
|
+
offset = self.changes_preview_page * self.changes_preview_page_size
|
|
439
|
+
|
|
440
|
+
# Build a join between base table and its _meta on data columns (exclude meta columns).
|
|
441
|
+
meta_exclude = {"hash", "create_ts", "update_ts", "process_ts", "delete_ts"}
|
|
442
|
+
|
|
443
|
+
# Determine data columns using reflection
|
|
444
|
+
inspector = sa.inspect(con)
|
|
445
|
+
base_cols = [c.get("name", "") for c in inspector.get_columns(table_name)]
|
|
446
|
+
meta_cols = [c.get("name", "") for c in inspector.get_columns(meta)]
|
|
447
|
+
base_cols = [str(c) for c in base_cols if c]
|
|
448
|
+
meta_cols = [str(c) for c in meta_cols if c]
|
|
449
|
+
|
|
450
|
+
data_cols: list[str] = [c for c in (meta_cols or []) if c not in meta_exclude]
|
|
451
|
+
|
|
452
|
+
# Columns to display: prefer all base table columns
|
|
453
|
+
display_cols: list[str] = [c for c in (base_cols or []) if c]
|
|
454
|
+
if not display_cols:
|
|
455
|
+
display_cols = list(data_cols)
|
|
456
|
+
|
|
457
|
+
# Get total count on first page load
|
|
458
|
+
if self.changes_preview_page == 0:
|
|
459
|
+
q_count = sa.text(
|
|
460
|
+
f"""
|
|
461
|
+
SELECT COUNT(*)
|
|
462
|
+
FROM "{meta}" AS m
|
|
463
|
+
WHERE
|
|
464
|
+
(m.delete_ts IS NOT NULL AND m.delete_ts >= {since_ts})
|
|
465
|
+
OR
|
|
466
|
+
(m.update_ts IS NOT NULL AND m.update_ts >= {since_ts}
|
|
467
|
+
AND (m.create_ts IS NULL OR m.create_ts < m.update_ts)
|
|
468
|
+
AND (m.delete_ts IS NULL OR m.delete_ts < m.update_ts))
|
|
469
|
+
OR
|
|
470
|
+
(m.create_ts IS NOT NULL AND m.create_ts >= {since_ts} AND m.delete_ts IS NULL)
|
|
471
|
+
"""
|
|
472
|
+
)
|
|
473
|
+
try:
|
|
474
|
+
with con.begin() as conn:
|
|
475
|
+
self.changes_preview_total_rows = int(conn.execute(q_count).scalar() or 0)
|
|
476
|
+
except Exception:
|
|
477
|
+
self.changes_preview_total_rows = 0
|
|
478
|
+
|
|
479
|
+
# Build SELECT list coalescing base and meta to display base values when present
|
|
480
|
+
select_exprs: list[str] = []
|
|
481
|
+
for c in display_cols:
|
|
482
|
+
if c in meta_cols:
|
|
483
|
+
select_exprs.append(f'COALESCE(b."{c}", m."{c}") AS "{c}"')
|
|
484
|
+
else:
|
|
485
|
+
select_exprs.append(f'b."{c}" AS "{c}"')
|
|
486
|
+
select_cols = ", ".join(select_exprs)
|
|
487
|
+
# Join condition across all data columns
|
|
488
|
+
on_cond = " AND ".join([f'b."{c}" = m."{c}"' for c in data_cols])
|
|
489
|
+
|
|
490
|
+
q_join = sa.text(
|
|
491
|
+
f"""
|
|
492
|
+
SELECT
|
|
493
|
+
{select_cols},
|
|
494
|
+
CASE
|
|
495
|
+
WHEN m.delete_ts IS NOT NULL AND m.delete_ts >= {since_ts} THEN 'deleted'
|
|
496
|
+
WHEN m.update_ts IS NOT NULL AND m.update_ts >= {since_ts}
|
|
497
|
+
AND (m.create_ts IS NULL OR m.create_ts < m.update_ts)
|
|
498
|
+
AND (m.delete_ts IS NULL OR m.delete_ts < m.update_ts) THEN 'updated'
|
|
499
|
+
WHEN m.create_ts IS NOT NULL AND m.create_ts >= {since_ts} AND m.delete_ts IS NULL THEN 'added'
|
|
500
|
+
ELSE NULL
|
|
501
|
+
END AS change_type,
|
|
502
|
+
m.create_ts, m.update_ts, m.delete_ts
|
|
503
|
+
FROM "{meta}" AS m
|
|
504
|
+
LEFT JOIN "{table_name}" AS b
|
|
505
|
+
ON {on_cond}
|
|
506
|
+
WHERE
|
|
507
|
+
(m.delete_ts IS NOT NULL AND m.delete_ts >= {since_ts})
|
|
508
|
+
OR
|
|
509
|
+
(m.update_ts IS NOT NULL AND m.update_ts >= {since_ts}
|
|
510
|
+
AND (m.create_ts IS NULL OR m.create_ts < m.update_ts)
|
|
511
|
+
AND (m.delete_ts IS NULL OR m.delete_ts < m.update_ts))
|
|
512
|
+
OR
|
|
513
|
+
(m.create_ts IS NOT NULL AND m.create_ts >= {since_ts} AND m.delete_ts IS NULL)
|
|
514
|
+
ORDER BY COALESCE(m.update_ts, m.create_ts, m.delete_ts) DESC
|
|
515
|
+
LIMIT {self.changes_preview_page_size} OFFSET {offset}
|
|
516
|
+
"""
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
try:
|
|
520
|
+
df = pd.read_sql(q_join, con=con)
|
|
521
|
+
except Exception as e:
|
|
522
|
+
self._append_log(f"Failed to load joined changes for {table_name}: {e}")
|
|
523
|
+
return
|
|
524
|
+
|
|
525
|
+
# Only display data columns; hide change_type and timestamps
|
|
526
|
+
self.changes_preview_columns = [str(c) for c in display_cols]
|
|
527
|
+
records_any: list[dict] = df.astype(object).where(pd.notna(df), None).to_dict(orient="records")
|
|
528
|
+
|
|
529
|
+
expanded_set = set(self.changes_preview_expanded_rows)
|
|
530
|
+
styled: list[dict[str, Any]] = []
|
|
531
|
+
row_styling = {
|
|
532
|
+
"added": {"backgroundColor": "rgba(34,197,94,0.08)"},
|
|
533
|
+
"updated": {"backgroundColor": "rgba(245,158,11,0.08)"},
|
|
534
|
+
"deleted": {"backgroundColor": "rgba(239,68,68,0.08)"},
|
|
535
|
+
}
|
|
536
|
+
for idx, r in enumerate(
|
|
537
|
+
records_any
|
|
538
|
+
): # Build display row with only data columns, coercing values to safe strings
|
|
539
|
+
row_id = f"changes-{self.changes_preview_page}-{idx}"
|
|
540
|
+
row_disp: dict[str, Any] = {k: safe_render_value(r.get(k)) for k in self.changes_preview_columns}
|
|
541
|
+
row_disp["row_style"] = row_styling.get(r.get("change_type", ""), {})
|
|
542
|
+
row_disp["row_id"] = row_id
|
|
543
|
+
row_disp["expanded"] = row_id in expanded_set
|
|
544
|
+
styled.append(row_disp)
|
|
545
|
+
|
|
546
|
+
self.changes_preview_rows = styled
|
|
547
|
+
self.changes_has_preview = len(self.changes_preview_rows) > 0
|
|
548
|
+
|
|
549
|
+
async def open_graph_per_label_changes_preview(self, kind: str, label: str) -> None:
|
|
550
|
+
base_table = "nodes" if str(kind) == "nodes" else "edges"
|
|
551
|
+
self.changes_preview_table_name = f"{base_table}:{label}"
|
|
552
|
+
self.changes_preview_open = True
|
|
553
|
+
self.changes_has_preview = False
|
|
554
|
+
self.changes_preview_page = 0
|
|
555
|
+
self.changes_preview_kind = kind
|
|
556
|
+
self.changes_preview_label = label
|
|
557
|
+
self.changes_preview_expanded_rows = [] # Reset expanded rows
|
|
558
|
+
|
|
559
|
+
await self._load_graph_changes_page()
|
|
560
|
+
|
|
561
|
+
async def _load_graph_changes_page(self) -> None:
|
|
562
|
+
"""Load the current page of graph changes from the database."""
|
|
563
|
+
kind = self.changes_preview_kind
|
|
564
|
+
label = self.changes_preview_label
|
|
565
|
+
if not kind or not label:
|
|
566
|
+
return
|
|
567
|
+
|
|
568
|
+
base_table = "nodes" if str(kind) == "nodes" else "edges"
|
|
569
|
+
label_col = "node_type" if base_table == "nodes" else "edge_label"
|
|
570
|
+
|
|
571
|
+
con = DBCONN_DATAPIPE.con # type: ignore[attr-defined]
|
|
572
|
+
|
|
573
|
+
since_ts = float(time.time() - self.time_window_days * 86400)
|
|
574
|
+
meta = f"{base_table}_meta"
|
|
575
|
+
offset = self.changes_preview_page * self.changes_preview_page_size
|
|
576
|
+
|
|
577
|
+
meta_exclude = {"hash", "create_ts", "update_ts", "process_ts", "delete_ts"} # not displaying these
|
|
578
|
+
|
|
579
|
+
inspector = sa.inspect(con)
|
|
580
|
+
display_cols = [c["name"] for c in inspector.get_columns(base_table)]
|
|
581
|
+
meta_cols = [c["name"] for c in inspector.get_columns(meta)]
|
|
582
|
+
data_cols: list[str] = [c for c in meta_cols if c not in meta_exclude]
|
|
583
|
+
|
|
584
|
+
label_escaped = label.replace("'", "''")
|
|
585
|
+
|
|
586
|
+
# Get total count on first page load
|
|
587
|
+
if self.changes_preview_page == 0:
|
|
588
|
+
q_count = sa.text(
|
|
589
|
+
f"""
|
|
590
|
+
SELECT COUNT(*)
|
|
591
|
+
FROM "{meta}" AS m
|
|
592
|
+
WHERE
|
|
593
|
+
m."{label_col}" = '{label_escaped}' AND (
|
|
594
|
+
(m.delete_ts IS NOT NULL AND m.delete_ts >= {since_ts})
|
|
595
|
+
OR
|
|
596
|
+
(m.update_ts IS NOT NULL AND m.update_ts >= {since_ts}
|
|
597
|
+
AND (m.create_ts IS NULL OR m.create_ts < m.update_ts)
|
|
598
|
+
AND (m.delete_ts IS NULL OR m.delete_ts < m.update_ts))
|
|
599
|
+
OR
|
|
600
|
+
(m.create_ts IS NOT NULL AND m.create_ts >= {since_ts} AND m.delete_ts IS NULL)
|
|
601
|
+
)
|
|
602
|
+
"""
|
|
603
|
+
)
|
|
604
|
+
try:
|
|
605
|
+
with con.begin() as conn:
|
|
606
|
+
self.changes_preview_total_rows = int(conn.execute(q_count).scalar() or 0)
|
|
607
|
+
except Exception:
|
|
608
|
+
self.changes_preview_total_rows = 0
|
|
609
|
+
|
|
610
|
+
select_exprs: list[str] = []
|
|
611
|
+
for c in display_cols:
|
|
612
|
+
if c in meta_cols:
|
|
613
|
+
select_exprs.append(f'COALESCE(b."{c}", m."{c}") AS "{c}"')
|
|
614
|
+
else:
|
|
615
|
+
select_exprs.append(f'b."{c}" AS "{c}"')
|
|
616
|
+
select_cols = ", ".join(select_exprs)
|
|
617
|
+
on_cond = " AND ".join([f'b."{c}" = m."{c}"' for c in data_cols])
|
|
618
|
+
|
|
619
|
+
q_join = sa.text(
|
|
620
|
+
f"""
|
|
621
|
+
SELECT
|
|
622
|
+
{select_cols},
|
|
623
|
+
CASE
|
|
624
|
+
WHEN m.delete_ts IS NOT NULL AND m.delete_ts >= {since_ts} THEN 'deleted'
|
|
625
|
+
WHEN m.update_ts IS NOT NULL AND m.update_ts >= {since_ts}
|
|
626
|
+
AND (m.create_ts IS NULL OR m.create_ts < m.update_ts)
|
|
627
|
+
AND (m.delete_ts IS NULL OR m.delete_ts < m.update_ts) THEN 'updated'
|
|
628
|
+
WHEN m.create_ts IS NOT NULL AND m.create_ts >= {since_ts} AND m.delete_ts IS NULL THEN 'added'
|
|
629
|
+
ELSE NULL
|
|
630
|
+
END AS change_type,
|
|
631
|
+
m.create_ts, m.update_ts, m.delete_ts
|
|
632
|
+
FROM "{meta}" AS m
|
|
633
|
+
LEFT JOIN "{base_table}" AS b
|
|
634
|
+
ON {on_cond}
|
|
635
|
+
WHERE
|
|
636
|
+
m."{label_col}" = '{label_escaped}' AND (
|
|
637
|
+
(m.delete_ts IS NOT NULL AND m.delete_ts >= {since_ts})
|
|
638
|
+
OR
|
|
639
|
+
(m.update_ts IS NOT NULL AND m.update_ts >= {since_ts}
|
|
640
|
+
AND (m.create_ts IS NULL OR m.create_ts < m.update_ts)
|
|
641
|
+
AND (m.delete_ts IS NULL OR m.delete_ts < m.update_ts))
|
|
642
|
+
OR
|
|
643
|
+
(m.create_ts IS NOT NULL AND m.create_ts >= {since_ts} AND m.delete_ts IS NULL)
|
|
644
|
+
)
|
|
645
|
+
ORDER BY COALESCE(m.update_ts, m.create_ts, m.delete_ts) DESC
|
|
646
|
+
LIMIT {self.changes_preview_page_size} OFFSET {offset}
|
|
647
|
+
"""
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
try:
|
|
651
|
+
df = pd.read_sql(q_join, con=con)
|
|
652
|
+
except Exception as e:
|
|
653
|
+
self._append_log(f"Failed to load joined label changes for {base_table}:{label}: {e}")
|
|
654
|
+
return
|
|
655
|
+
|
|
656
|
+
# Ensure key columns are present in columns list
|
|
657
|
+
key_cols: list[str] = ["node_id"] if base_table == "nodes" else ["from_node_id", "to_node_id"]
|
|
658
|
+
for kc in key_cols:
|
|
659
|
+
if kc not in display_cols:
|
|
660
|
+
display_cols = [kc, *display_cols]
|
|
661
|
+
|
|
662
|
+
self.changes_preview_columns = [str(c) for c in display_cols]
|
|
663
|
+
records_any: list[dict] = df.astype(object).where(pd.notna(df), None).to_dict(orient="records")
|
|
664
|
+
|
|
665
|
+
# Build styled rows
|
|
666
|
+
expanded_set = set(self.changes_preview_expanded_rows)
|
|
667
|
+
styled: list[dict[str, Any]] = []
|
|
668
|
+
row_styling = {
|
|
669
|
+
"added": {"backgroundColor": "rgba(34,197,94,0.08)"},
|
|
670
|
+
"updated": {"backgroundColor": "rgba(245,158,11,0.08)"},
|
|
671
|
+
"deleted": {"backgroundColor": "rgba(239,68,68,0.08)"},
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
for idx, r in enumerate(records_any):
|
|
675
|
+
row_id = f"changes-{self.changes_preview_page}-{idx}"
|
|
676
|
+
row_disp: dict[str, Any] = {k: safe_render_value(r.get(k)) for k in self.changes_preview_columns}
|
|
677
|
+
row_disp["row_style"] = row_styling.get(r.get("change_type", ""), {})
|
|
678
|
+
row_disp["row_id"] = row_id
|
|
679
|
+
row_disp["expanded"] = row_id in expanded_set
|
|
680
|
+
styled.append(row_disp)
|
|
681
|
+
|
|
682
|
+
self.changes_preview_rows = styled
|
|
683
|
+
self.changes_has_preview = len(self.changes_preview_rows) > 0
|
|
684
|
+
|
|
685
|
+
def changes_preview_next_page(self) -> None:
|
|
686
|
+
"""Load the next page of changes preview data."""
|
|
687
|
+
max_page = (
|
|
688
|
+
(self.changes_preview_total_rows - 1) // self.changes_preview_page_size
|
|
689
|
+
if self.changes_preview_total_rows > 0
|
|
690
|
+
else 0
|
|
691
|
+
)
|
|
692
|
+
if self.changes_preview_page < max_page:
|
|
693
|
+
self.changes_preview_page += 1
|
|
694
|
+
if self.changes_preview_kind == "ingest":
|
|
695
|
+
self._load_ingest_changes_page()
|
|
696
|
+
# Note: graph changes pagination would need async handling
|
|
697
|
+
|
|
698
|
+
def changes_preview_prev_page(self) -> None:
|
|
699
|
+
"""Load the previous page of changes preview data."""
|
|
700
|
+
if self.changes_preview_page > 0:
|
|
701
|
+
self.changes_preview_page -= 1
|
|
702
|
+
if self.changes_preview_kind == "ingest":
|
|
703
|
+
self._load_ingest_changes_page()
|
|
704
|
+
# Note: graph changes pagination would need async handling
|
|
705
|
+
|
|
706
|
+
def changes_preview_first_page(self) -> None:
|
|
707
|
+
"""Jump to the first page."""
|
|
708
|
+
if self.changes_preview_page != 0:
|
|
709
|
+
self.changes_preview_page = 0
|
|
710
|
+
if self.changes_preview_kind == "ingest":
|
|
711
|
+
self._load_ingest_changes_page()
|
|
712
|
+
|
|
713
|
+
def changes_preview_last_page(self) -> None:
|
|
714
|
+
"""Jump to the last page."""
|
|
715
|
+
max_page = (
|
|
716
|
+
(self.changes_preview_total_rows - 1) // self.changes_preview_page_size
|
|
717
|
+
if self.changes_preview_total_rows > 0
|
|
718
|
+
else 0
|
|
719
|
+
)
|
|
720
|
+
if self.changes_preview_page != max_page:
|
|
721
|
+
self.changes_preview_page = max_page
|
|
722
|
+
if self.changes_preview_kind == "ingest":
|
|
723
|
+
self._load_ingest_changes_page()
|
|
724
|
+
|
|
725
|
+
@rx.var
|
|
726
|
+
def changes_preview_page_display(self) -> str:
|
|
727
|
+
"""Current page display (1-indexed for users)."""
|
|
728
|
+
total_pages = (
|
|
729
|
+
(self.changes_preview_total_rows - 1) // self.changes_preview_page_size + 1
|
|
730
|
+
if self.changes_preview_total_rows > 0
|
|
731
|
+
else 1
|
|
732
|
+
)
|
|
733
|
+
return f"Page {self.changes_preview_page + 1} of {total_pages}"
|
|
734
|
+
|
|
735
|
+
@rx.var
|
|
736
|
+
def changes_preview_rows_display(self) -> str:
|
|
737
|
+
"""Display range of rows being shown."""
|
|
738
|
+
if self.changes_preview_total_rows == 0:
|
|
739
|
+
return "No rows"
|
|
740
|
+
start = self.changes_preview_page * self.changes_preview_page_size + 1
|
|
741
|
+
end = min(start + self.changes_preview_page_size - 1, self.changes_preview_total_rows)
|
|
742
|
+
return f"Rows {start}-{end} of {self.changes_preview_total_rows}"
|
|
743
|
+
|
|
744
|
+
@rx.var
|
|
745
|
+
def changes_preview_has_next(self) -> bool:
|
|
746
|
+
"""Whether there's a next page."""
|
|
747
|
+
max_page = (
|
|
748
|
+
(self.changes_preview_total_rows - 1) // self.changes_preview_page_size
|
|
749
|
+
if self.changes_preview_total_rows > 0
|
|
750
|
+
else 0
|
|
751
|
+
)
|
|
752
|
+
return self.changes_preview_page < max_page
|
|
753
|
+
|
|
754
|
+
@rx.var
|
|
755
|
+
def changes_preview_has_prev(self) -> bool:
|
|
756
|
+
"""Whether there's a previous page."""
|
|
757
|
+
return self.changes_preview_page > 0
|