From 58cc153a2c371e53c06842f85a0163a734176c52 Mon Sep 17 00:00:00 2001 From: cosmin chauciuc Date: Mon, 8 Jun 2026 12:19:29 +0300 Subject: [PATCH 1/2] Add Phase 2 Milestone 2: dashboards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composes saved queries/charts into workspace-shared dashboards: a draggable tile grid with per-tile refresh and dashboard-level filters that flow into each tile's SQL. Backend: - Models Dashboard + DashboardTile (workspace-scoped — the first artifact scoped by workspace_id rather than connection); migration 006. - dashboard_service: _assert_access (workspace scope + role, mirrors connection_service), CRUD, tile CRUD, bulk layout update, and run_tile. Tiles run via saved_query_service.run_saved_query, so they authorize through the saved query's connection and reuse the M1 result cache. - Filters reuse the saved-query param system: dashboard filter values are passed as supplied params; render_sql only consumes the ones a tile's SQL references and ignores the rest — no per-tile wiring. - _finalize refreshes server-side onupdate timestamps after UPDATEs to avoid a lazy-load (MissingGreenlet) during async response serialization. - Endpoints registered under /dashboards (get_org_context, like teams.py). Frontend (react-grid-layout): - Dashboards list + detail (draggable/resizable grid), tile cards that render a chart (reusing ChartView) or table with a cached/fresh badge and optional auto-refresh, a filters bar, add-tile and dashboard-form modals. - Extracted shared ParamInputs (used by the saved-query run drawer and the dashboard filters bar); routes + nav item. Verified: 155 backend tests pass (9 new for access rules + filter passthrough); ruff clean; frontend lint + build pass; live Docker run confirmed migration 005->6, dashboard/tile CRUD, filters driving tile results, cache reuse, and layout persistence. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/alembic/versions/006_dashboards.py | 98 ++++++++ backend/app/api/v1/endpoints/dashboards.py | 134 +++++++++++ backend/app/api/v1/router.py | 2 + backend/app/api/v1/schemas/dashboard.py | 107 +++++++++ backend/app/db/models/__init__.py | 4 + backend/app/db/models/dashboard.py | 46 ++++ backend/app/db/models/dashboard_tile.py | 42 ++++ backend/app/services/dashboard_service.py | 227 ++++++++++++++++++ backend/tests/test_dashboard_service.py | 107 +++++++++ frontend/package-lock.json | 70 ++++++ frontend/package.json | 2 + frontend/src/App.tsx | 4 + frontend/src/api/dashboardsApi.ts | 49 ++++ .../src/components/common/ParamInputs.tsx | 61 +++++ .../components/dashboards/AddTileModal.tsx | 104 ++++++++ .../dashboards/DashboardFiltersBar.tsx | 39 +++ .../dashboards/DashboardFormModal.tsx | 151 ++++++++++++ .../components/dashboards/DashboardGrid.tsx | 78 ++++++ .../dashboards/DashboardTileCard.tsx | 122 ++++++++++ frontend/src/components/layout/AppLayout.tsx | 2 + .../savedQueries/SavedQueryRunDrawer.tsx | 44 +--- frontend/src/hooks/useDashboards.ts | 59 +++++ frontend/src/pages/DashboardDetailPage.tsx | 128 ++++++++++ frontend/src/pages/DashboardsPage.tsx | 130 ++++++++++ frontend/src/types/api.ts | 50 ++++ planfull.md | 2 +- 26 files changed, 1823 insertions(+), 39 deletions(-) create mode 100644 backend/alembic/versions/006_dashboards.py create mode 100644 backend/app/api/v1/endpoints/dashboards.py create mode 100644 backend/app/api/v1/schemas/dashboard.py create mode 100644 backend/app/db/models/dashboard.py create mode 100644 backend/app/db/models/dashboard_tile.py create mode 100644 backend/app/services/dashboard_service.py create mode 100644 backend/tests/test_dashboard_service.py create mode 100644 frontend/src/api/dashboardsApi.ts create mode 100644 frontend/src/components/common/ParamInputs.tsx create mode 100644 frontend/src/components/dashboards/AddTileModal.tsx create mode 100644 frontend/src/components/dashboards/DashboardFiltersBar.tsx create mode 100644 frontend/src/components/dashboards/DashboardFormModal.tsx create mode 100644 frontend/src/components/dashboards/DashboardGrid.tsx create mode 100644 frontend/src/components/dashboards/DashboardTileCard.tsx create mode 100644 frontend/src/hooks/useDashboards.ts create mode 100644 frontend/src/pages/DashboardDetailPage.tsx create mode 100644 frontend/src/pages/DashboardsPage.tsx diff --git a/backend/alembic/versions/006_dashboards.py b/backend/alembic/versions/006_dashboards.py new file mode 100644 index 0000000..a23bd0b --- /dev/null +++ b/backend/alembic/versions/006_dashboards.py @@ -0,0 +1,98 @@ +"""Dashboards and tiles (Phase 2 — Milestone 2) + +Revision ID: 006 +Revises: 005 +Create Date: 2026-06-08 + +Adds workspace-scoped dashboards and their tiles. A dashboard composes saved +queries (from any connection in the workspace) into a draggable grid; tiles +render a saved query as a chart or table, and dashboard-level filters flow into +each tile's run via the saved-query parameter system. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB, UUID + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "006" +down_revision: str = "005" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "dashboards", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column( + "organization_id", + UUID(as_uuid=True), + sa.ForeignKey("organizations.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "workspace_id", + UUID(as_uuid=True), + sa.ForeignKey("teams.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "owner_id", + UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("description", sa.Text, nullable=True), + sa.Column("filters", JSONB, server_default=sa.text("'[]'::jsonb")), + sa.Column("is_public", sa.Boolean, nullable=False, server_default=sa.text("false")), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index("ix_dashboards_workspace_id", "dashboards", ["workspace_id"]) + + op.create_table( + "dashboard_tiles", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column( + "organization_id", + UUID(as_uuid=True), + sa.ForeignKey("organizations.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "dashboard_id", + UUID(as_uuid=True), + sa.ForeignKey("dashboards.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "saved_query_id", + UUID(as_uuid=True), + sa.ForeignKey("saved_queries.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "chart_id", + UUID(as_uuid=True), + sa.ForeignKey("charts.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("title", sa.String(255), nullable=True), + sa.Column("position", JSONB, server_default=sa.text("'{}'::jsonb")), + sa.Column("refresh_interval", sa.Integer, nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + op.create_index("ix_dashboard_tiles_dashboard_id", "dashboard_tiles", ["dashboard_id"]) + + +def downgrade() -> None: + op.drop_index("ix_dashboard_tiles_dashboard_id", table_name="dashboard_tiles") + op.drop_table("dashboard_tiles") + op.drop_index("ix_dashboards_workspace_id", table_name="dashboards") + op.drop_table("dashboards") diff --git a/backend/app/api/v1/endpoints/dashboards.py b/backend/app/api/v1/endpoints/dashboards.py new file mode 100644 index 0000000..b3ce9af --- /dev/null +++ b/backend/app/api/v1/endpoints/dashboards.py @@ -0,0 +1,134 @@ +import uuid + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.v1.schemas.dashboard import ( + DashboardCreate, + DashboardResponse, + DashboardTileCreate, + DashboardTileResponse, + DashboardTileUpdate, + DashboardUpdate, + TileLayoutUpdate, + TileRunRequest, + TileRunResponse, +) +from app.core.auth import AuthContext, get_org_context +from app.db.session import get_db +from app.services import dashboard_service + +router = APIRouter(prefix="/dashboards", tags=["dashboards"]) + + +@router.get("", response_model=list[DashboardResponse]) +async def list_dashboards( + ctx: AuthContext = Depends(get_org_context), + db: AsyncSession = Depends(get_db), +): + return await dashboard_service.list_dashboards(db, ctx) + + +@router.post("", response_model=DashboardResponse, status_code=201) +async def create_dashboard( + body: DashboardCreate, + ctx: AuthContext = Depends(get_org_context), + db: AsyncSession = Depends(get_db), +): + data = body.model_dump() + data["filters"] = [f.model_dump() for f in body.filters] + return await dashboard_service.create_dashboard(db, ctx, **data) + + +@router.get("/{dashboard_id}", response_model=DashboardResponse) +async def get_dashboard( + dashboard_id: uuid.UUID, + ctx: AuthContext = Depends(get_org_context), + db: AsyncSession = Depends(get_db), +): + return await dashboard_service.get_dashboard(db, dashboard_id, ctx) + + +@router.put("/{dashboard_id}", response_model=DashboardResponse) +async def update_dashboard( + dashboard_id: uuid.UUID, + body: DashboardUpdate, + ctx: AuthContext = Depends(get_org_context), + db: AsyncSession = Depends(get_db), +): + updates = body.model_dump(exclude_unset=True) + if "filters" in updates and body.filters is not None: + updates["filters"] = [f.model_dump() for f in body.filters] + return await dashboard_service.update_dashboard(db, dashboard_id, ctx, updates) + + +@router.delete("/{dashboard_id}", status_code=204) +async def delete_dashboard( + dashboard_id: uuid.UUID, + ctx: AuthContext = Depends(get_org_context), + db: AsyncSession = Depends(get_db), +): + await dashboard_service.delete_dashboard(db, dashboard_id, ctx) + + +# --------------------------------------------------------------------------- # +# Tiles +# --------------------------------------------------------------------------- # +@router.post("/{dashboard_id}/tiles", response_model=DashboardTileResponse, status_code=201) +async def add_tile( + dashboard_id: uuid.UUID, + body: DashboardTileCreate, + ctx: AuthContext = Depends(get_org_context), + db: AsyncSession = Depends(get_db), +): + data = body.model_dump() + data["position"] = body.position.model_dump() + return await dashboard_service.add_tile(db, dashboard_id, ctx, data) + + +@router.put("/{dashboard_id}/tiles/{tile_id}", response_model=DashboardTileResponse) +async def update_tile( + dashboard_id: uuid.UUID, + tile_id: uuid.UUID, + body: DashboardTileUpdate, + ctx: AuthContext = Depends(get_org_context), + db: AsyncSession = Depends(get_db), +): + updates = body.model_dump(exclude_unset=True) + if "position" in updates and body.position is not None: + updates["position"] = body.position.model_dump() + return await dashboard_service.update_tile(db, dashboard_id, tile_id, ctx, updates) + + +@router.delete("/{dashboard_id}/tiles/{tile_id}", status_code=204) +async def delete_tile( + dashboard_id: uuid.UUID, + tile_id: uuid.UUID, + ctx: AuthContext = Depends(get_org_context), + db: AsyncSession = Depends(get_db), +): + await dashboard_service.delete_tile(db, dashboard_id, tile_id, ctx) + + +@router.put("/{dashboard_id}/layout", response_model=DashboardResponse) +async def update_layout( + dashboard_id: uuid.UUID, + body: TileLayoutUpdate, + ctx: AuthContext = Depends(get_org_context), + db: AsyncSession = Depends(get_db), +): + layout = [item.model_dump() for item in body.layout] + return await dashboard_service.update_layout(db, dashboard_id, ctx, layout) + + +@router.post("/{dashboard_id}/tiles/{tile_id}/run", response_model=TileRunResponse) +async def run_tile( + dashboard_id: uuid.UUID, + tile_id: uuid.UUID, + body: TileRunRequest, + ctx: AuthContext = Depends(get_org_context), + db: AsyncSession = Depends(get_db), +): + return await dashboard_service.run_tile( + db, dashboard_id, tile_id, ctx, body.filters, refresh=body.refresh + ) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 698f901..38cf094 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -5,6 +5,7 @@ assistant, auth, connections, + dashboards, dictionary, glossary, health, @@ -33,5 +34,6 @@ api_router.include_router(dictionary.router) api_router.include_router(sample_queries.router) api_router.include_router(saved_queries.router) +api_router.include_router(dashboards.router) api_router.include_router(query_history.router) api_router.include_router(knowledge.router) diff --git a/backend/app/api/v1/schemas/dashboard.py b/backend/app/api/v1/schemas/dashboard.py new file mode 100644 index 0000000..b776b8c --- /dev/null +++ b/backend/app/api/v1/schemas/dashboard.py @@ -0,0 +1,107 @@ +from datetime import datetime +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, Field + +from app.api.v1.schemas.saved_query import ParamDef + +# A dashboard filter is shaped like a saved-query parameter; its value is passed +# to each tile's run and consumed only by tiles whose SQL references {{name}}. +DashboardFilter = ParamDef + + +class TilePosition(BaseModel): + x: int = 0 + y: int = 0 + w: int = 4 + h: int = 6 + + +class DashboardTileCreate(BaseModel): + saved_query_id: UUID + chart_id: UUID | None = None + title: str | None = None + position: TilePosition = Field(default_factory=TilePosition) + refresh_interval: int | None = None + + +class DashboardTileUpdate(BaseModel): + chart_id: UUID | None = None + title: str | None = None + position: TilePosition | None = None + refresh_interval: int | None = None + + +class DashboardTileResponse(BaseModel): + id: UUID + dashboard_id: UUID + saved_query_id: UUID + chart_id: UUID | None + title: str | None + position: dict | None + refresh_interval: int | None + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class DashboardCreate(BaseModel): + name: str = Field(min_length=1, max_length=255) + description: str | None = None + filters: list[DashboardFilter] = Field(default_factory=list) + is_public: bool = False + + +class DashboardUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=255) + description: str | None = None + filters: list[DashboardFilter] | None = None + is_public: bool | None = None + + +class DashboardResponse(BaseModel): + id: UUID + workspace_id: UUID + owner_id: UUID | None + name: str + description: str | None + filters: list[DashboardFilter] | None + is_public: bool + tiles: list[DashboardTileResponse] + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class TileLayoutItem(BaseModel): + tile_id: UUID + x: int + y: int + w: int + h: int + + +class TileLayoutUpdate(BaseModel): + layout: list[TileLayoutItem] + + +class TileRunRequest(BaseModel): + filters: dict[str, Any] = Field(default_factory=dict) + refresh: bool = False + + +class TileRunResponse(BaseModel): + columns: list[str] + column_types: list[str] + rows: list[list[Any]] + row_count: int + truncated: bool + execution_time_ms: float | None + cached: bool + taken_at: datetime + # Present when the tile has an associated chart. + chart_type: str | None = None + chart_config: dict | None = None diff --git a/backend/app/db/models/__init__.py b/backend/app/db/models/__init__.py index fe4e81f..0263631 100644 --- a/backend/app/db/models/__init__.py +++ b/backend/app/db/models/__init__.py @@ -1,6 +1,8 @@ from app.db.models.api_key import ApiKey from app.db.models.chart import Chart from app.db.models.connection import DatabaseConnection +from app.db.models.dashboard import Dashboard +from app.db.models.dashboard_tile import DashboardTile from app.db.models.dictionary import DictionaryEntry from app.db.models.glossary import GlossaryTerm from app.db.models.knowledge import KnowledgeChunk, KnowledgeDocument @@ -35,4 +37,6 @@ "SavedQuery", "Chart", "ResultSnapshot", + "Dashboard", + "DashboardTile", ] diff --git a/backend/app/db/models/dashboard.py b/backend/app/db/models/dashboard.py new file mode 100644 index 0000000..fea3203 --- /dev/null +++ b/backend/app/db/models/dashboard.py @@ -0,0 +1,46 @@ +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +class Dashboard(Base): + """A workspace-shared grid of tiles composed from saved queries. + + Unlike the connection-scoped artifacts (saved queries, charts), a dashboard + composes tiles that may draw on saved queries from different connections in + the same workspace, so it is scoped directly by ``workspace_id``. + """ + + __tablename__ = "dashboards" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + organization_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False + ) + workspace_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("teams.id", ondelete="CASCADE"), nullable=False + ) + owner_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL") + ) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + # List of filter defs {name, type, label, default} — same shape as a saved-query ParamDef. + # Values are passed to each tile's run; a tile only consumes the filters its SQL references. + filters: Mapped[list | None] = mapped_column(JSONB, default=list) + is_public: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + tiles: Mapped[list["DashboardTile"]] = relationship( # noqa: F821 + back_populates="dashboard", + cascade="all, delete-orphan", + order_by="DashboardTile.created_at", + ) diff --git a/backend/app/db/models/dashboard_tile.py b/backend/app/db/models/dashboard_tile.py new file mode 100644 index 0000000..9936e2b --- /dev/null +++ b/backend/app/db/models/dashboard_tile.py @@ -0,0 +1,42 @@ +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String, func +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +class DashboardTile(Base): + """A single tile on a dashboard: a saved query rendered as a chart or table.""" + + __tablename__ = "dashboard_tiles" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + organization_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False + ) + dashboard_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("dashboards.id", ondelete="CASCADE"), nullable=False + ) + # The data source. A tile can't function without its query, so deleting the + # saved query removes its tiles. + saved_query_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("saved_queries.id", ondelete="CASCADE"), nullable=False + ) + # Optional visualization; null = render as a table. + chart_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("charts.id", ondelete="SET NULL") + ) + title: Mapped[str | None] = mapped_column(String(255)) + # react-grid-layout position: {x, y, w, h} + position: Mapped[dict | None] = mapped_column(JSONB, default=dict) + # Auto-refresh interval in seconds; null = manual refresh only. + refresh_interval: Mapped[int | None] = mapped_column(Integer) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) + + dashboard: Mapped["Dashboard"] = relationship(back_populates="tiles") # noqa: F821 diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py new file mode 100644 index 0000000..9361c9b --- /dev/null +++ b/backend/app/services/dashboard_service.py @@ -0,0 +1,227 @@ +"""Dashboard service — workspace-scoped CRUD, tile management, and tile runs. + +Dashboards are the first workspace-scoped artifact (saved queries/charts are +connection-scoped). Tiles run their saved query through +:func:`saved_query_service.run_saved_query`, which authorizes via the saved +query's connection and reuses the Milestone-1 result cache. Dashboard-level +filter values are passed straight through as the run's supplied params; a tile +only consumes the filters its SQL references (``render_sql`` ignores the rest). +""" + +from __future__ import annotations + +import uuid +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.core.auth import AuthContext +from app.core.exceptions import AuthorizationError, NotFoundError +from app.db.models.chart import Chart +from app.db.models.dashboard import Dashboard +from app.db.models.dashboard_tile import DashboardTile +from app.db.models.membership import ROLE_ADMIN, ROLE_EDITOR +from app.db.models.saved_query import SavedQuery +from app.services import saved_query_service + + +def _assert_access(dashboard: Dashboard, ctx: AuthContext, *, write: bool = False) -> None: + """Enforce workspace scoping + role for a dashboard. + + Cross-workspace access raises 404 (don't leak existence); private dashboards + are visible only to their owner or a workspace admin. + """ + if ( + dashboard.organization_id != ctx.organization_id + or dashboard.workspace_id != ctx.workspace_id + ): + raise NotFoundError("Dashboard", str(dashboard.id)) + if ( + not dashboard.is_public + and dashboard.owner_id != ctx.user_id + and not ctx.has_role(ROLE_ADMIN) + ): + raise AuthorizationError("This dashboard is private to its owner.") + if write: + ctx.require_role(ROLE_EDITOR) + + +async def _load( + db: AsyncSession, dashboard_id: uuid.UUID, ctx: AuthContext, *, write: bool = False +) -> Dashboard: + result = await db.execute( + select(Dashboard).where(Dashboard.id == dashboard_id).options(selectinload(Dashboard.tiles)) + ) + dashboard = result.scalar_one_or_none() + if dashboard is None: + raise NotFoundError("Dashboard", str(dashboard_id)) + _assert_access(dashboard, ctx, write=write) + return dashboard + + +async def _finalize(db: AsyncSession, dashboard: Dashboard) -> Dashboard: + """Flush and repopulate server-side columns after a write. + + UPDATEs don't use RETURNING for ``onupdate`` timestamps the way INSERTs do, + so the modified rows' attributes are left expired; without an explicit + refresh the response serializer would trigger a lazy load outside the async + context (MissingGreenlet). Refresh the scalar columns and reload the tiles. + """ + await db.flush() + await db.refresh(dashboard) + await db.refresh(dashboard, ["tiles"]) + return dashboard + + +async def list_dashboards(db: AsyncSession, ctx: AuthContext) -> list[Dashboard]: + result = await db.execute( + select(Dashboard) + .where( + Dashboard.organization_id == ctx.organization_id, + Dashboard.workspace_id == ctx.workspace_id, + ) + .options(selectinload(Dashboard.tiles)) + .order_by(Dashboard.updated_at.desc()) + ) + dashboards = list(result.scalars().all()) + # Hide other people's private dashboards from non-admins. + if not ctx.has_role(ROLE_ADMIN): + dashboards = [d for d in dashboards if d.is_public or d.owner_id == ctx.user_id] + return dashboards + + +async def get_dashboard(db: AsyncSession, dashboard_id: uuid.UUID, ctx: AuthContext) -> Dashboard: + return await _load(db, dashboard_id, ctx) + + +async def create_dashboard(db: AsyncSession, ctx: AuthContext, **data: Any) -> Dashboard: + ctx.require_role(ROLE_EDITOR) + dashboard = Dashboard( + organization_id=ctx.organization_id, + workspace_id=ctx.workspace_id, + owner_id=ctx.user_id, + **data, + ) + db.add(dashboard) + await db.flush() + await db.refresh(dashboard, ["tiles"]) + return dashboard + + +async def update_dashboard( + db: AsyncSession, dashboard_id: uuid.UUID, ctx: AuthContext, updates: dict[str, Any] +) -> Dashboard: + dashboard = await _load(db, dashboard_id, ctx, write=True) + for key, value in updates.items(): + setattr(dashboard, key, value) + return await _finalize(db, dashboard) + + +async def delete_dashboard(db: AsyncSession, dashboard_id: uuid.UUID, ctx: AuthContext) -> None: + dashboard = await _load(db, dashboard_id, ctx, write=True) + await db.delete(dashboard) + await db.flush() + + +# --------------------------------------------------------------------------- # +# Tiles +# --------------------------------------------------------------------------- # +async def _get_saved_query( + db: AsyncSession, saved_query_id: uuid.UUID, ctx: AuthContext +) -> SavedQuery: + saved = await db.get(SavedQuery, saved_query_id) + if saved is None or saved.organization_id != ctx.organization_id: + raise NotFoundError("SavedQuery", str(saved_query_id)) + return saved + + +def _get_tile(dashboard: Dashboard, tile_id: uuid.UUID) -> DashboardTile: + for tile in dashboard.tiles: + if tile.id == tile_id: + return tile + raise NotFoundError("DashboardTile", str(tile_id)) + + +async def add_tile( + db: AsyncSession, dashboard_id: uuid.UUID, ctx: AuthContext, data: dict[str, Any] +) -> DashboardTile: + dashboard = await _load(db, dashboard_id, ctx, write=True) + # Ensure the saved query is visible in this workspace before pinning it. + await _get_saved_query(db, data["saved_query_id"], ctx) + tile = DashboardTile( + organization_id=ctx.organization_id, + dashboard_id=dashboard.id, + **data, + ) + db.add(tile) + await db.flush() + return tile + + +async def update_tile( + db: AsyncSession, + dashboard_id: uuid.UUID, + tile_id: uuid.UUID, + ctx: AuthContext, + updates: dict[str, Any], +) -> DashboardTile: + dashboard = await _load(db, dashboard_id, ctx, write=True) + tile = _get_tile(dashboard, tile_id) + for key, value in updates.items(): + setattr(tile, key, value) + await db.flush() + await db.refresh(tile) + return tile + + +async def delete_tile( + db: AsyncSession, dashboard_id: uuid.UUID, tile_id: uuid.UUID, ctx: AuthContext +) -> None: + dashboard = await _load(db, dashboard_id, ctx, write=True) + tile = _get_tile(dashboard, tile_id) + await db.delete(tile) + await db.flush() + + +async def update_layout( + db: AsyncSession, + dashboard_id: uuid.UUID, + ctx: AuthContext, + layout: list[dict[str, Any]], +) -> Dashboard: + dashboard = await _load(db, dashboard_id, ctx, write=True) + by_id = {tile.id: tile for tile in dashboard.tiles} + for item in layout: + tile = by_id.get(item["tile_id"]) + if tile is not None: + tile.position = {"x": item["x"], "y": item["y"], "w": item["w"], "h": item["h"]} + return await _finalize(db, dashboard) + + +async def run_tile( + db: AsyncSession, + dashboard_id: uuid.UUID, + tile_id: uuid.UUID, + ctx: AuthContext, + filter_values: dict[str, Any], + *, + refresh: bool = False, +) -> dict: + dashboard = await _load(db, dashboard_id, ctx) + tile = _get_tile(dashboard, tile_id) + saved = await _get_saved_query(db, tile.saved_query_id, ctx) + + # Dashboard filter values are the supplied params; run_saved_query authorizes + # via the saved query's connection and uses the M1 result cache. + result = await saved_query_service.run_saved_query( + db, saved, ctx, filter_values, refresh=refresh + ) + + if tile.chart_id is not None: + chart = await db.get(Chart, tile.chart_id) + if chart is not None and chart.saved_query_id == saved.id: + result["chart_type"] = chart.chart_type + result["chart_config"] = chart.config + return result diff --git a/backend/tests/test_dashboard_service.py b/backend/tests/test_dashboard_service.py new file mode 100644 index 0000000..ae66ceb --- /dev/null +++ b/backend/tests/test_dashboard_service.py @@ -0,0 +1,107 @@ +"""Unit tests for dashboard_service access rules + filter passthrough (no DB).""" + +import uuid +from types import SimpleNamespace + +import pytest + +from app.core.auth import AuthContext +from app.core.exceptions import AuthorizationError, NotFoundError +from app.db.models.dashboard import Dashboard +from app.db.models.membership import ROLE_ADMIN, ROLE_EDITOR, ROLE_VIEWER +from app.services import dashboard_service as svc +from app.services import saved_query_service + + +def _ctx(role=ROLE_EDITOR, *, org=None, ws=None, user=None) -> AuthContext: + return AuthContext( + user=SimpleNamespace(id=user or uuid.uuid4()), + organization_id=org or uuid.uuid4(), + workspace_id=ws or uuid.uuid4(), + role=role, + ) + + +def _dashboard(ctx: AuthContext, *, is_public=True, owner=None) -> Dashboard: + return Dashboard( + id=uuid.uuid4(), + organization_id=ctx.organization_id, + workspace_id=ctx.workspace_id, + owner_id=owner if owner is not None else ctx.user_id, + name="d", + is_public=is_public, + ) + + +# --------------------------------------------------------------------------- # +# _assert_access — scope + role +# --------------------------------------------------------------------------- # +def test_same_workspace_read_ok(): + ctx = _ctx(ROLE_VIEWER) + svc._assert_access(_dashboard(ctx), ctx) # no raise + + +def test_cross_workspace_is_404(): + ctx = _ctx() + d = _dashboard(ctx) + other = _ctx(org=ctx.organization_id) # same org, different workspace + with pytest.raises(NotFoundError): + svc._assert_access(d, other) + + +def test_cross_org_is_404(): + ctx = _ctx() + d = _dashboard(ctx) + other = _ctx(ws=ctx.workspace_id) # same workspace id value, different org + with pytest.raises(NotFoundError): + svc._assert_access(d, other) + + +def test_private_dashboard_hidden_from_non_owner_non_admin(): + owner = uuid.uuid4() + ctx = _ctx(ROLE_VIEWER) + d = _dashboard(ctx, is_public=False, owner=owner) + with pytest.raises(AuthorizationError): + svc._assert_access(d, ctx) + + +def test_private_dashboard_visible_to_admin(): + owner = uuid.uuid4() + ctx = _ctx(ROLE_ADMIN) + d = _dashboard(ctx, is_public=False, owner=owner) + svc._assert_access(d, ctx) # no raise + + +def test_write_requires_editor(): + ctx = _ctx(ROLE_VIEWER) + d = _dashboard(ctx) + with pytest.raises(AuthorizationError): + svc._assert_access(d, ctx, write=True) + + +def test_write_ok_for_editor(): + ctx = _ctx(ROLE_EDITOR) + svc._assert_access(_dashboard(ctx), ctx, write=True) # no raise + + +# --------------------------------------------------------------------------- # +# Dashboard filters flow through render_sql: only referenced params are used, +# extras are ignored. (Tiles share this mechanism with saved-query runs.) +# --------------------------------------------------------------------------- # +def test_dashboard_filter_reaches_referenced_param(): + sql = saved_query_service.render_sql( + "SELECT * FROM t WHERE stage >= {{min_stage}}", + [{"name": "min_stage", "type": "number"}], + {"min_stage": 2, "region": "EU"}, # 'region' is an extra dashboard filter + ) + assert sql == "SELECT * FROM t WHERE stage >= 2" + + +def test_extra_dashboard_filters_are_ignored(): + # A tile whose SQL references no params ignores all dashboard filter values. + sql = saved_query_service.render_sql( + "SELECT 1", + [], + {"region": "EU", "min_stage": 5}, + ) + assert sql == "SELECT 1" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 828af56..7d84109 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,7 @@ "axios": "^1.13.5", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-grid-layout": "^1.5.3", "react-router-dom": "^7.13.0", "recharts": "^3.8.1" }, @@ -27,6 +28,7 @@ "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "@types/react-grid-layout": "^1.3.6", "@vitejs/plugin-react": "^5.1.1", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", @@ -1795,6 +1797,16 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-grid-layout": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@types/react-grid-layout/-/react-grid-layout-1.3.6.tgz", + "integrity": "sha512-Cw7+sb3yyjtmxwwJiXtEXcu5h4cgs+sCGkHwHXsFmPyV30bf14LeD/fa2LwQovuD2HWxCcjIdNhDlcYGj95qGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -2931,6 +2943,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-equals": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", + "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==", + "license": "MIT" + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3770,6 +3788,38 @@ "react": "^19.2.4" } }, + "node_modules/react-draggable": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.6.0.tgz", + "integrity": "sha512-g4vqY53xhmPrBnZvGP+1YQV0eYnB3o0VLzoi6q2IpwnQrxIZ34tYRKpVtsWIXPg4D/pvLn+oYCW5gOK2cWIrgA==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-grid-layout": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.5.3.tgz", + "integrity": "sha512-KaG6IbjD6fYhagUtIvOzhftXG+ViKZjCjADe86X1KHl7C/dsBN2z0mi14nbvZKTkp0RKiil9RPcJBgq3LnoA8g==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "fast-equals": "^4.0.3", + "prop-types": "^15.8.1", + "react-draggable": "^4.4.6", + "react-resizable": "^3.0.5", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -3866,6 +3916,20 @@ } } }, + "node_modules/react-resizable": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.2.0.tgz", + "integrity": "sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==", + "license": "MIT", + "dependencies": { + "prop-types": "15.x", + "react-draggable": "^4.5.0" + }, + "peerDependencies": { + "react": ">= 16.3", + "react-dom": ">= 16.3" + } + }, "node_modules/react-router": { "version": "7.13.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz", @@ -4010,6 +4074,12 @@ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", "license": "MIT" }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 6a77df9..c55e6a0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "axios": "^1.13.5", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-grid-layout": "^1.5.3", "react-router-dom": "^7.13.0", "recharts": "^3.8.1" }, @@ -29,6 +30,7 @@ "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "@types/react-grid-layout": "^1.3.6", "@vitejs/plugin-react": "^5.1.1", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 62a1ebe..b97a496 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,8 @@ import { ConnectionsPage } from './pages/ConnectionsPage'; import { GlossaryPage } from './pages/GlossaryPage'; import { MetricsPage } from './pages/MetricsPage'; import { SavedQueriesPage } from './pages/SavedQueriesPage'; +import { DashboardsPage } from './pages/DashboardsPage'; +import { DashboardDetailPage } from './pages/DashboardDetailPage'; import { DictionaryPage } from './pages/DictionaryPage'; import { KnowledgePage } from './pages/KnowledgePage'; import { HistoryPage } from './pages/HistoryPage'; @@ -25,6 +27,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/dashboardsApi.ts b/frontend/src/api/dashboardsApi.ts new file mode 100644 index 0000000..1a19c84 --- /dev/null +++ b/frontend/src/api/dashboardsApi.ts @@ -0,0 +1,49 @@ +import { api } from './client'; +import type { + Dashboard, + DashboardTile, + TilePosition, + TileRunResult, +} from '../types/api'; + +export const dashboardsApi = { + list: () => api.get('/dashboards').then((r) => r.data), + get: (id: string) => api.get(`/dashboards/${id}`).then((r) => r.data), + create: (data: Partial) => + api.post('/dashboards', data).then((r) => r.data), + update: (id: string, data: Partial) => + api.put(`/dashboards/${id}`, data).then((r) => r.data), + delete: (id: string) => api.delete(`/dashboards/${id}`), + + addTile: ( + dashboardId: string, + data: { + saved_query_id: string; + chart_id?: string | null; + title?: string | null; + position?: TilePosition; + refresh_interval?: number | null; + }, + ) => api.post(`/dashboards/${dashboardId}/tiles`, data).then((r) => r.data), + updateTile: (dashboardId: string, tileId: string, data: Partial) => + api + .put(`/dashboards/${dashboardId}/tiles/${tileId}`, data) + .then((r) => r.data), + deleteTile: (dashboardId: string, tileId: string) => + api.delete(`/dashboards/${dashboardId}/tiles/${tileId}`), + + updateLayout: ( + dashboardId: string, + layout: { tile_id: string; x: number; y: number; w: number; h: number }[], + ) => api.put(`/dashboards/${dashboardId}/layout`, { layout }).then((r) => r.data), + + runTile: ( + dashboardId: string, + tileId: string, + filters: Record = {}, + refresh = false, + ) => + api + .post(`/dashboards/${dashboardId}/tiles/${tileId}/run`, { filters, refresh }) + .then((r) => r.data), +}; diff --git a/frontend/src/components/common/ParamInputs.tsx b/frontend/src/components/common/ParamInputs.tsx new file mode 100644 index 0000000..94c1514 --- /dev/null +++ b/frontend/src/components/common/ParamInputs.tsx @@ -0,0 +1,61 @@ +import { NumberInput, Stack, Switch, TextInput } from '@mantine/core'; +import type { ParamDef } from '../../types/api'; + +/** + * Renders typed input controls for a set of param/filter definitions. + * Shared by the saved-query run drawer and the dashboard filters bar. + * Values are held by the parent; `onChange(name, value)` reports edits. + */ +export function ParamInputs({ + params, + values, + onChange, + inline = false, +}: { + params: ParamDef[]; + values: Record; + onChange: (name: string, value: unknown) => void; + inline?: boolean; +}) { + const Wrapper = inline ? 'div' : Stack; + const wrapperProps = inline + ? { style: { display: 'flex', gap: 12, flexWrap: 'wrap' as const, alignItems: 'flex-end' } } + : { gap: 'xs' as const }; + + return ( + + {params.map((p) => { + const label = p.label || p.name; + if (p.type === 'number') { + return ( + onChange(p.name, v)} + /> + ); + } + if (p.type === 'boolean') { + return ( + onChange(p.name, e.currentTarget.checked)} + /> + ); + } + return ( + onChange(p.name, e.currentTarget.value)} + /> + ); + })} + + ); +} diff --git a/frontend/src/components/dashboards/AddTileModal.tsx b/frontend/src/components/dashboards/AddTileModal.tsx new file mode 100644 index 0000000..2d6ad7c --- /dev/null +++ b/frontend/src/components/dashboards/AddTileModal.tsx @@ -0,0 +1,104 @@ +import { useState } from 'react'; +import { Button, Group, Modal, Select, Stack, TextInput } from '@mantine/core'; +import { notifications } from '@mantine/notifications'; +import { useConnections } from '../../hooks/useConnections'; +import { useSavedQueries, useCharts } from '../../hooks/useSavedQueries'; +import { useAddTile } from '../../hooks/useDashboards'; + +export function AddTileModal({ + opened, + onClose, + dashboardId, +}: { + opened: boolean; + onClose: () => void; + dashboardId: string; +}) { + const { data: connections } = useConnections(); + const [connectionId, setConnectionId] = useState(null); + const [savedQueryId, setSavedQueryId] = useState(null); + const [chartId, setChartId] = useState(null); + const [title, setTitle] = useState(''); + + const { data: savedQueries } = useSavedQueries(connectionId ?? undefined); + const { data: charts } = useCharts(connectionId ?? undefined, savedQueryId ?? undefined); + const addTile = useAddTile(dashboardId); + + const reset = () => { + setSavedQueryId(null); + setChartId(null); + setTitle(''); + }; + + const handleAdd = () => { + if (!savedQueryId) return; + const chosen = savedQueries?.find((s) => s.id === savedQueryId); + addTile.mutate( + { + saved_query_id: savedQueryId, + chart_id: chartId, + title: title || chosen?.name || null, + }, + { + onSuccess: () => { + notifications.show({ message: 'Tile added', color: 'green' }); + reset(); + onClose(); + }, + onError: (err) => + notifications.show({ message: (err as Error).message, color: 'red' }), + }, + ); + }; + + return ( + + + ({ value: s.id, label: s.name })) ?? []} + value={savedQueryId} + onChange={(v) => { + setSavedQueryId(v); + setChartId(null); + }} + /> +