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.
@@ -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