Skip to content

Commit b01fa18

Browse files
committed
feat: destination cards, DAG run log, notifications, source management, and UI fixes
- Pipeline: add destination card rendering with proper CSS styling - DAG: add run log CRUD + pause/resume/stop/retry/delete endpoints - Run Log: repository for DAG run log persistence - Notifications: live toast with auto-dismiss + notification page - Sources: table counts tracking, source list, create/edit forms - Dashboard: pipeline-level metrics card - Users: user management list - UI: CSS style overhaul for all pages - Clean: remove .dockerignore, remove .bak files
1 parent 49bdb39 commit b01fa18

19 files changed

Lines changed: 1415 additions & 269 deletions

‎.dockerignore‎

Lines changed: 0 additions & 3 deletions
This file was deleted.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""add table_count and enabled_table_count columns to sources table
2+
3+
Revision ID: 011
4+
Revises: 010
5+
Create Date: 2026-06-16 10:45:00.000000
6+
"""
7+
from typing import Sequence, Union
8+
9+
from alembic import op
10+
import sqlalchemy as sa
11+
12+
# revision identifiers, used by Alembic.
13+
revision: str = '011'
14+
down_revision: Union[str, None] = '010'
15+
branch_labels: Union[str, Sequence[str], None] = None
16+
depends_on: Union[str, Sequence[str], None] = None
17+
18+
19+
def upgrade() -> None:
20+
op.add_column(
21+
'sources',
22+
sa.Column('table_count', sa.Integer(), nullable=False, server_default=sa.text('0')),
23+
schema='arus_config'
24+
)
25+
op.add_column(
26+
'sources',
27+
sa.Column('enabled_table_count', sa.Integer(), nullable=False, server_default=sa.text('0')),
28+
schema='arus_config'
29+
)
30+
31+
32+
def downgrade() -> None:
33+
op.drop_column('sources', 'table_count', schema='arus_config')
34+
op.drop_column('sources', 'enabled_table_count', schema='arus_config')

‎arus/modules/dag/router.py‎

Lines changed: 72 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@
1010

1111
from arus.shared.db.session import get_db
1212
from arus.modules.auth.router import get_current_user
13-
from arus.modules.run_log.models import Run
13+
from arus.modules.run_log.models import Run, RunTableStat
1414
from arus.modules.pipeline.models import Pipeline, PipelineTable
1515
from arus.modules.source.models import Source
16+
from arus.modules.destination.models import Destination
1617

1718
router = APIRouter(prefix="/api/dag", tags=["dag"])
1819

@@ -28,19 +29,40 @@ async def get_dag_data(
2829

2930
for p in pipelines:
3031
source = db.query(Source).filter(Source.id == p.source_id).first()
32+
destination = db.query(Destination).filter(Destination.id == p.destination_id).first()
3133
tables = db.query(PipelineTable).filter(
3234
PipelineTable.pipeline_id == p.id, PipelineTable.enabled == True
3335
).all()
3436

35-
# Get last run status per table
37+
# Get last run and per-table stats
3638
last_run = (
3739
db.query(Run)
3840
.filter(Run.pipeline_id == str(p.id))
3941
.order_by(desc(Run.started_at))
4042
.first()
4143
)
42-
4344
pipeline_status = last_run.status if last_run else "not_started"
45+
currently_running = last_run and last_run.status == "running"
46+
47+
# Get per-table stats from the last completed run
48+
table_stats = {}
49+
if last_run:
50+
stats = db.query(RunTableStat).filter(RunTableStat.run_id == last_run.id).all()
51+
for s in stats:
52+
table_stats[s.table_name] = s
53+
54+
def asset_status(table_name):
55+
"""Derive per-asset status. If currently running, all show 'running'.
56+
Otherwise check per-table stat, fallback to pipeline-level status."""
57+
if currently_running:
58+
return "running"
59+
stat = table_stats.get(table_name)
60+
if stat:
61+
if stat.error_message:
62+
return "failed"
63+
return "success"
64+
return pipeline_status
65+
4466
safe_name = source.name.lower().replace("-", "_").replace(" ", "_") if source else "unknown"
4567

4668
assets = []
@@ -49,40 +71,80 @@ async def get_dag_data(
4971

5072
for t in tables:
5173
load_mode = getattr(t, "load_mode", None) or p.load_mode or "direct"
74+
status = asset_status(t.source_table)
75+
stat = table_stats.get(t.source_table)
5276

53-
# Source asset always exists
77+
# ---- Source asset ----
5478
assets.append({
5579
"name": t.source_table,
5680
"layer": "source",
57-
"status": pipeline_status,
81+
"status": status,
5882
"table": t.source_table,
83+
"schema": t.source_schema,
84+
"sync_mode": t.sync_mode,
85+
"source_type": source.type if source else None,
86+
"source_host": source.host if source else None,
87+
"source_port": source.port if source else None,
88+
"source_database": source.database if source else None,
89+
"rows": stat.rows_extracted if stat else 0,
90+
"duration_ms": stat.duration_ms if stat else 0,
91+
"error": stat.error_message if stat else None,
5992
})
6093

6194
if load_mode == "raw":
62-
# Raw mode: source → raw → target
95+
# ---- Raw mode: source → raw → target ----
6396
has_raw = True
6497
raw_name = f"{safe_name}_{t.source_table}_raw"
98+
raw_status = asset_status(raw_name)
99+
raw_stat = table_stats.get(raw_name)
100+
65101
assets.append({
66102
"name": raw_name,
67103
"layer": "raw",
68-
"status": pipeline_status,
104+
"status": raw_status,
69105
"table": t.source_table,
106+
"schema": t.source_schema,
107+
"sync_mode": t.sync_mode,
108+
"raw_table": raw_name,
109+
"raw_schema": destination.raw_schema if destination else "staging",
110+
"rows": raw_stat.rows_loaded_raw if raw_stat else 0,
111+
"duration_ms": raw_stat.duration_ms if raw_stat else 0,
112+
"error": raw_stat.error_message if raw_stat else None,
70113
})
114+
115+
target_status = asset_status(t.source_table)
116+
target_stat = table_stats.get(t.source_table)
117+
71118
assets.append({
72119
"name": t.source_table,
73120
"layer": "target",
74-
"status": pipeline_status,
121+
"status": target_status,
75122
"table": t.source_table,
123+
"schema": t.source_schema,
124+
"sync_mode": t.sync_mode,
125+
"target_table": t.source_table,
126+
"target_schema": t.target_schema or (destination.target_schema if destination else None),
127+
"rows": target_stat.rows_loaded_analytics if target_stat else 0,
128+
"duration_ms": target_stat.duration_ms if target_stat else 0,
129+
"error": target_stat.error_message if target_stat else None,
76130
})
131+
77132
edges.append({"from": t.source_table, "to": raw_name})
78133
edges.append({"from": raw_name, "to": t.source_table})
79134
else:
80-
# Direct mode: source → target
135+
# ---- Direct mode: source → target ----
81136
assets.append({
82137
"name": t.source_table,
83138
"layer": "target",
84-
"status": pipeline_status,
139+
"status": status,
85140
"table": t.source_table,
141+
"schema": t.source_schema,
142+
"sync_mode": t.sync_mode,
143+
"target_table": t.source_table,
144+
"target_schema": t.target_schema or (destination.target_schema if destination else None),
145+
"rows": stat.rows_loaded_analytics if stat else 0,
146+
"duration_ms": stat.duration_ms if stat else 0,
147+
"error": stat.error_message if stat else None,
86148
})
87149
edges.append({"from": t.source_table, "to": t.source_table})
88150

‎arus/modules/run_log/repository.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,39 @@ def get_runs(self, pipeline_id: str, limit: int = 20, offset: int = 0) -> list[R
3232
.all()
3333
)
3434

35+
def get_runs_by_asset(
36+
self, pipeline_id: str, asset_name: str, limit: int = 20, offset: int = 0
37+
) -> list[dict]:
38+
"""Get runs for a pipeline, joined with RunTableStat filtered by asset_name.
39+
Returns per-asset metrics instead of pipeline-level totals."""
40+
rows = (
41+
self.db.query(Run, RunTableStat)
42+
.join(RunTableStat, RunTableStat.run_id == Run.id)
43+
.filter(Run.pipeline_id == pipeline_id)
44+
.filter(RunTableStat.table_name == asset_name)
45+
.order_by(desc(Run.started_at))
46+
.offset(offset)
47+
.limit(limit)
48+
.all()
49+
)
50+
return [
51+
{
52+
"id": str(run.Run.id),
53+
"pipeline_id": str(run.Run.pipeline_id),
54+
"status": run.Run.status,
55+
"started_at": run.Run.started_at,
56+
"finished_at": run.Run.finished_at,
57+
"trigger_type": run.Run.trigger_type,
58+
"rows_synced": (
59+
(run.RunTableStat.rows_loaded_analytics or 0)
60+
+ (run.RunTableStat.rows_loaded_raw or 0)
61+
),
62+
"duration_ms": run.RunTableStat.duration_ms or run.Run.duration_ms,
63+
"error_message": run.RunTableStat.error_message or run.Run.error_message,
64+
}
65+
for run in rows
66+
]
67+
3568
def get_recent_runs(self, limit: int = 10) -> list[Run]:
3669
return self.db.query(Run).order_by(desc(Run.started_at)).limit(limit).all()
3770

‎arus/modules/run_log/router.py‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@ async def list_runs(
1919
pipeline_id: str,
2020
limit: int = Query(20, le=100),
2121
offset: int = Query(0, ge=0),
22+
asset_name: str = Query(None, description="Filter runs by asset/table name"),
2223
repo: RunLogRepository = Depends(get_run_log_repo),
2324
user: dict = Depends(get_current_user),
2425
):
25-
runs = repo.get_runs(pipeline_id, limit=limit, offset=offset)
26-
return {
27-
"status": "ok",
28-
"data": [
26+
if asset_name:
27+
runs = repo.get_runs_by_asset(pipeline_id, asset_name, limit=limit, offset=offset)
28+
else:
29+
rows = repo.get_runs(pipeline_id, limit=limit, offset=offset)
30+
runs = [
2931
{
3032
"id": str(r.id),
3133
"pipeline_id": str(r.pipeline_id),
@@ -34,10 +36,14 @@ async def list_runs(
3436
"finished_at": r.finished_at,
3537
"duration_ms": r.duration_ms,
3638
"trigger_type": r.trigger_type,
39+
"rows_synced": r.rows_synced or 0,
3740
"error_message": r.error_message,
3841
}
39-
for r in runs
40-
],
42+
for r in rows
43+
]
44+
return {
45+
"status": "ok",
46+
"data": runs,
4147
}
4248

4349

‎arus/modules/source/models.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ class Source(Base):
2424
table_include = Column(ARRAY(String), default=[])
2525
table_exclude = Column(ARRAY(String), default=[])
2626
schema_include = Column(ARRAY(String), default=[])
27+
table_count = Column(Integer, default=0)
28+
enabled_table_count = Column(Integer, default=0)
2729
status = Column(String(20), default="registered")
2830
last_tested = Column(DateTime(timezone=True), nullable=True)
2931
created_at = Column(DateTime(timezone=True), server_default=func.now())

‎arus/modules/source/router.py‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ async def list_sources(
4040
"uri": s.uri,
4141
"auth_source": s.auth_source,
4242
"status": s.status,
43-
"table_count": 0,
44-
"enabled_table_count": 0,
43+
"table_count": s.table_count or 0,
44+
"enabled_table_count": s.enabled_table_count or 0,
4545
"schema_include": s.schema_include or [],
4646
"last_tested": s.last_tested,
4747
"created_at": s.created_at,
@@ -92,6 +92,8 @@ async def get_source(
9292
"table_include": source.table_include,
9393
"table_exclude": source.table_exclude,
9494
"schema_include": source.schema_include or [],
95+
"table_count": source.table_count or 0,
96+
"enabled_table_count": source.enabled_table_count or 0,
9597
"status": source.status,
9698
"last_tested": source.last_tested,
9799
"created_at": source.created_at,
@@ -178,4 +180,15 @@ async def update_source_tables(
178180

179181
pipe_service = PipelineService(PipelineRepository(db), db)
180182
result = pipe_service.auto_create_from_source(source_id, str(default_dest.id), req.tables)
183+
184+
# Update source table counts after saving selection
185+
source_repo = SourceRepository(db)
186+
source = source_repo.get_by_id(source_id)
187+
if source:
188+
enabled = sum(1 for t in req.tables if t.get("enabled", True))
189+
source.table_count = len(req.tables)
190+
source.enabled_table_count = enabled
191+
source.status = "connected"
192+
db.commit()
193+
181194
return {"status": "ok", "data": result}

‎arus/modules/source/service.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from arus.shared.crypto import decrypt_password
33
from arus.modules.connector.registry import get_source
44
from arus.modules.source.repository import SourceRepository
5+
from sqlalchemy import func
56

67

78
class SourceService:
@@ -63,8 +64,15 @@ def discover_tables(self, source_id: str) -> list[dict]:
6364
"watermark_column": sync.watermark_column,
6465
"enabled": True,
6566
})
67+
# Update source metadata after successful discovery
68+
source.table_count = len(tables)
69+
source.last_tested = func.now()
70+
source.status = "connected"
71+
self.repo.db.commit()
6672
return result
6773
except Exception as e:
74+
source.status = "error"
75+
self.repo.db.commit()
6876
raise DiscoveryFailedError(f"Discovery failed: {str(e)}")
6977

7078
def discover_schemas(self, source_id: str) -> list[str]:

0 commit comments

Comments
 (0)