diff --git a/CHANGELOG.md b/CHANGELOG.md index 961a9e0..0562f46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,30 @@ product surface; all optional dependencies degrade gracefully). - **Assistant frontend component** — chat interface with draft confirmation cards for all semantic layer entities - **Assistant tests** — 33 unit tests for agent normalizers and service branching +### Added (Phase 2 - Durable analytics artifacts) +- **Saved queries** (migration `005`) — named, owned, re-runnable NL question + pinned SQL with + typed parameters (`{{date_from}}`, `{{region}}`); versioned, clone/fork. Connection-scoped, + mirroring the semantic-layer ownership model. Save directly from a query result. +- **Result cache + snapshots** — runs are persisted to a Postgres `result_snapshots` table that + doubles as a cache keyed by `sha256(final_sql + params + connection_id)`; cache-first re-runs + within `RESULT_CACHE_TTL_SECONDS` (default 300) with a manual-refresh override, so dashboards + don't re-hit the warehouse on every load. +- **Charts** — a persisted chart config per saved query (table/line/bar/area/pie/scatter), + rendered with Recharts. +- **Export** — client-side CSV/JSON of any result, plus a backend CSV/JSON/XLSX export endpoint + for saved queries (`openpyxl` via the optional `export` extra). +- **Type-safe parameter rendering** — `saved_query_service.render_sql` substitutes `{{param}}` + placeholders with validated, escaped SQL literals (defense-in-depth on top of the read-only + SQL safety blocklist). +- **Dashboards** (migration `006`) — workspace-scoped, shareable dashboards composed of tiles in a + draggable/resizable grid (react-grid-layout); each tile renders a saved query as a chart or + table with optional per-tile auto-refresh. +- **Dashboard-level filters** — a dashboard defines named filters whose values flow into every + tile's run; they reuse the saved-query parameter system, so a tile consumes only the filters its + SQL references. +- New optional dependency extra: `export` (`openpyxl`). Frontend adds `recharts` and + `react-grid-layout`. + ## [1.0.0] - 2026-06-04 First stable release: natural-language-to-SQL with a semantic metadata layer. diff --git a/CLAUDE.md b/CLAUDE.md index 1124a66..2dec3e3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ ## Project Overview -QueryWise — a text-to-SQL application with a semantic metadata layer. Users ask natural language questions, an LLM generates SQL using business context, executes against their database, and returns human-readable answers. The conversational Assistant enables editing the semantic layer in plain language. +QueryWise — a text-to-SQL application with a semantic metadata layer. Users ask natural language questions, an LLM generates SQL using business context, executes against their database, and returns human-readable answers. The conversational Assistant enables editing the semantic layer in plain language. Answers become durable, shareable artifacts: saved queries (pinned SQL + typed params), charts, and workspace dashboards. ## Tech Stack @@ -94,7 +94,7 @@ frontend/src/ ├── api/ # Axios API clients (one per resource) ├── components/layout/ # AppShell with sidebar navigation ├── hooks/ # React Query hooks -├── pages/ # Route pages (Query, Connections, Glossary, Metrics, Dictionary, Knowledge, History) +├── pages/ # Route pages (Query, SavedQueries, Dashboards, Connections, Glossary, Metrics, Dictionary, Knowledge, History) └── types/ # TypeScript interfaces matching backend schemas ``` @@ -246,3 +246,15 @@ Real users, teams, roles, and ownership. Single-tenant per deployment; isolation - **AuthZ in services** (per the existing convention): `connection_service` scopes by org+workspace and enforces role; metadata endpoints authorize through the connection (the cascade root) via `app/api/v1/deps.py` (`require_connection_read/write`, `require_column_read/write`). Non-request entry points — startup auto-setup, the MCP server, the seed script via `DISABLE_AUTH` — act under `identity_service.system_context()` (admin in the default workspace). - **Endpoints:** `/auth/*` (login, register, magic-link request/verify, logout, me, providers), `/teams` + `/teams/{id}/members` (admin-managed), `/api-keys` (per-user, plaintext shown once). - **Heads-up:** once auth is enforced, the current (pre-auth) frontend gets 401s — run with `DISABLE_AUTH=true` until the Phase 1 frontend (login + auth context + workspace switcher) lands. + +## Durable analytics artifacts (Phase 2) + +One-shot answers become saved, owned, re-runnable, shareable objects. Two milestones; migrations `005` (artifacts) and `006` (dashboards). + +- **Models** (`app/db/models/`): `SavedQuery` (pinned SQL + typed `params` + `version`/`status`), `Chart` (viz config per saved query), `ResultSnapshot` (result persistence that doubles as the cache), `Dashboard` + `DashboardTile`. +- **Scoping:** saved queries / charts / snapshots are **connection-scoped** (carry `organization_id` + `connection_id`, authorize through the connection via `require_connection_read/write`), matching the semantic-layer convention. `Dashboard` is the first **workspace-scoped** artifact (`workspace_id`); its endpoints use `get_org_context` + `ctx.require_role(...)` directly (like `teams.py`), and `dashboard_service._assert_access` mirrors `connection_service._assert_access`. +- **Re-runs & cache** (`app/services/saved_query_service.py`): `render_sql` substitutes `{{param}}` placeholders with **type-safe, escaped SQL literals** (defense-in-depth atop the read-only blocklist), then `run_saved_query` is cache-first — a `ResultSnapshot` keyed by `sha256(final_sql + params + connection_id)` within `RESULT_CACHE_TTL_SECONDS` (default 300), with a `refresh` override. Execution reuses `query_service.execute_raw_sql`. +- **Dashboards** (`app/services/dashboard_service.py`): tiles run via `run_saved_query` (so they inherit connection auth + the cache). Dashboard-level **filters reuse the param system** — filter values are passed as supplied params and a tile only consumes the `{{name}}`s its SQL references. `_finalize` refreshes server-side `onupdate` timestamps after UPDATEs to avoid async lazy-load errors during response serialization. +- **Export:** client-side CSV/JSON in the frontend; backend CSV/JSON/XLSX for saved queries (XLSX needs the optional `export` extra → `openpyxl`). +- **Endpoints:** `/connections/{id}/saved-queries` (+ `/run`, `/clone`, `/export`, `/charts`), `/dashboards` (+ `/tiles`, `/layout`, `/tiles/{id}/run`). +- **Frontend:** Recharts (`components/charts/ChartView.tsx`) for viz; `react-grid-layout` for the dashboard grid; shared typed `components/common/ParamInputs.tsx` for params/filters. Charts are managed inside the saved-query view (no separate Charts page). Note: the frontend container's anonymous `node_modules` volume means new deps (recharts, react-grid-layout) need `docker compose exec frontend npm install` or an image rebuild. diff --git a/README.md b/README.md index aa8ae4b..271e0ad 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,9 @@ A full-stack application that translates natural language questions into SQL que - **Schema introspection** — auto-discovers tables, columns, types, relationships from target databases - **Conversational Assistant** — chat panel for NL queries and semantic layer editing (glossary terms, metrics, dictionary entries, knowledge) - **Identity, teams, and ownership** — real users, roles (viewer/editor), teams, and workspace-based ownership +- **Saved queries** — name and pin a question + SQL with typed parameters (`{{region}}`); re-run, version, clone, and export (CSV/JSON/XLSX) +- **Charts & result caching** — visualize a saved query (line/bar/area/pie/scatter via Recharts); results are snapshotted to a Postgres cache so re-runs don't re-hit the warehouse +- **Dashboards** — compose saved queries into a shareable, draggable tile grid with dashboard-level filters that flow into every tile's SQL - **Production hardening** — rate limiting, async job queue, OpenTelemetry tracing, structured logging, health probes @@ -448,7 +451,12 @@ querywise/ │ │ │ ├── dictionary.py # DictionaryEntry (value mappings) │ │ │ ├── knowledge.py # KnowledgeDocument + KnowledgeChunk (with embedding vector) │ │ │ ├── sample_query.py # SampleQuery (with embedding vector) -│ │ │ └── query_history.py# QueryExecution (full audit log) +│ │ │ ├── query_history.py# QueryExecution (full audit log) +│ │ │ ├── saved_query.py # SavedQuery (pinned SQL + typed params) +│ │ │ ├── chart.py # Chart (viz config per saved query) +│ │ │ ├── result_snapshot.py # ResultSnapshot (result persistence + cache) +│ │ │ ├── dashboard.py # Dashboard (workspace-scoped, with filters) +│ │ │ └── dashboard_tile.py # DashboardTile (grid position + refresh) │ │ ├── api/v1/ │ │ │ ├── router.py # Aggregates all endpoint routers │ │ │ ├── endpoints/ @@ -461,7 +469,9 @@ querywise/ │ │ │ │ ├── sample_queries.py │ │ │ │ ├── knowledge.py # Knowledge document CRUD + URL fetch │ │ │ │ ├── query.py # POST /query (full pipeline), POST /query/sql-only -│ │ │ │ └── query_history.py# History list + favorite toggle +│ │ │ │ ├── query_history.py# History list + favorite toggle +│ │ │ │ ├── saved_queries.py# Saved query CRUD + run/clone/export + charts +│ │ │ │ └── dashboards.py # Dashboard + tile CRUD, layout, tile run │ │ │ └── schemas/ # Pydantic request/response models │ │ ├── services/ │ │ │ ├── query_service.py # Full pipeline orchestrator @@ -519,16 +529,26 @@ querywise/ ├── main.tsx # MantineProvider + QueryClient + Router ├── App.tsx # Route definitions ├── api/ - │ ├── client.ts # Axios instance + │ ├── client.ts # Axios instance (session cookie + workspace header) │ ├── connectionApi.ts # Connection endpoints │ ├── queryApi.ts # Query + history endpoints │ ├── glossaryApi.ts # Glossary + metrics + dictionary endpoints -│ └── knowledgeApi.ts # Knowledge document CRUD + URL fetch + │ ├── knowledgeApi.ts # Knowledge document CRUD + URL fetch + │ ├── savedQueriesApi.ts # Saved query CRUD + run/clone/export + charts + │ └── dashboardsApi.ts # Dashboard + tile CRUD, layout, tile run ├── components/ - │ └── layout/ - │ └── AppLayout.tsx # Mantine AppShell with sidebar nav + │ ├── layout/ + │ │ └── AppLayout.tsx # Mantine AppShell with sidebar nav + │ ├── charts/ + │ │ └── ChartView.tsx # Recharts renderer (line/bar/area/pie/scatter) + │ ├── common/ + │ │ └── ParamInputs.tsx # Typed param/filter inputs (shared) + │ ├── savedQueries/ # Run drawer + form modal + │ └── dashboards/ # Grid, tile card, filters bar, modals ├── hooks/ - │ └── useConnections.ts # React Query hooks for connections + │ ├── useConnections.ts # React Query hooks for connections + │ ├── useSavedQueries.ts # Saved query + chart hooks + │ └── useDashboards.ts # Dashboard + tile hooks ├── pages/ │ ├── QueryPage.tsx # NL input → SQL preview → results table │ ├── ConnectionsPage.tsx # Add/edit/delete/test/introspect connections @@ -536,7 +556,10 @@ querywise/ │ ├── MetricsPage.tsx # Metric definition management │ ├── DictionaryPage.tsx # Column value mapping management │ ├── KnowledgePage.tsx # Knowledge document import/manage (text + URL fetch) - │ └── HistoryPage.tsx # Query execution history + favorites + │ ├── HistoryPage.tsx # Query execution history + favorites + │ ├── SavedQueriesPage.tsx# Saved queries: run, chart, export + │ ├── DashboardsPage.tsx # Dashboard list + │ └── DashboardDetailPage.tsx # Dashboard grid + filters └── types/ └── api.ts # TypeScript interfaces ``` @@ -708,6 +731,29 @@ All endpoints are under `/api/v1`. | `POST` | `/query` | Execute NL query (full pipeline) | | `POST` | `/query/sql-only` | Generate SQL without executing | +### Saved Queries + +| Method | Path | Description | +|--------|------|-------------| +| `GET/POST` | `/connections/{id}/saved-queries` | List/create saved queries | +| `GET/PUT/DELETE` | `/connections/{id}/saved-queries/{sq_id}` | Get/update/delete saved query | +| `POST` | `/connections/{id}/saved-queries/{sq_id}/run` | Run (cache-first; `refresh` to bypass) | +| `POST` | `/connections/{id}/saved-queries/{sq_id}/clone` | Clone a saved query | +| `GET` | `/connections/{id}/saved-queries/{sq_id}/export` | Export results (`format=csv\|json\|xlsx`) | +| `GET/POST` | `/connections/{id}/saved-queries/{sq_id}/charts` | List/create charts | +| `PUT/DELETE` | `/connections/{id}/saved-queries/{sq_id}/charts/{chart_id}` | Update/delete chart | + +### Dashboards + +| Method | Path | Description | +|--------|------|-------------| +| `GET/POST` | `/dashboards` | List/create dashboards (workspace-scoped) | +| `GET/PUT/DELETE` | `/dashboards/{id}` | Get/update/delete dashboard | +| `POST` | `/dashboards/{id}/tiles` | Add a tile | +| `PUT/DELETE` | `/dashboards/{id}/tiles/{tile_id}` | Update/delete a tile | +| `PUT` | `/dashboards/{id}/layout` | Bulk-save tile positions | +| `POST` | `/dashboards/{id}/tiles/{tile_id}/run` | Run a tile with dashboard filters | + ### History | Method | Path | Description | 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); + }} + /> +