Skip to content

Commit 2a82e8a

Browse files
feat(admin-ui): surface anchor-coverage warnings and unify schema labels
Hoist the anchor-coverage query to PolicyEditPage so the side-nav and panel share one cache; add a red count pill on the `Anchor coverage` section, a top-of-page banner on other sections, and redesign the panel into per-table cards. Introduce `effectiveSchemaName` as the single schema-label rule across RelationshipsPanel, PolicyAnchorCoveragePanel, and the `?focus=` deep-link matcher, rendering the upstream name in muted parens where helpful. Smooth the anchor creation flow from the warning deep-link with auto-selected viable relationships and inline empty-state guidance (switch to alias mode or add a relationship without leaving the form). Extend the proxy's AnchorCoverageTableEntry with `schema_upstream` to support alias-aware labels.
1 parent 0efec6f commit 2a82e8a

17 files changed

Lines changed: 1404 additions & 165 deletions

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- **[Admin UI] Anchor-coverage warning is harder to miss and easier to act on**`PolicyEditPage` now hoists the `useQuery(['policy-anchor-coverage'])` to the page level so the side-nav and the panel share one cache entry. The `Anchor coverage` section in the secondary nav now carries a red count pill (`SectionDef.indicator`) when the policy will silently deny on any table; a top-of-page red banner ("This row filter will silently deny on N tables") with a `Review` button appears on every section except the coverage section itself, and inherits the active section's max-width so it lines up with the form/content below. The coverage panel itself was redesigned: each broken table is its own card with bold header (data source + `schema.table`), per-column rows with a clearer reason line, and a tertiary `Add anchor →` button instead of a plain underline. The outer wrapper switched from red wash to a neutral white card so the per-table red still reads as the alarm without desensitizing.
13+
- **[Admin UI] Consistent schema-alias labeling across relationship/anchor surfaces** — new `effectiveSchemaName(schema_name, schema_alias)` helper in `src/utils/schemaLabel.ts` is now the single rule used by `RelationshipsPanel`, `PolicyAnchorCoveragePanel`, and the `?focus=` deep-link matcher: display the alias if set, raw upstream name otherwise. Dropdowns and selection contexts also render the upstream name in muted parens (`pg.orders (postgres.orders)`) so admins can verify the mapping without bouncing to the discovery wizard. The anchor table's relationship cell now shows the full `child_schema.child_table.child_col → parent_schema.parent_table.parent_col` join path (the child side was previously missing), and the column header was renamed from `Via relationship` to `Resolves via` to honestly cover both the FK-walk and same-table-alias modes.
14+
- **[Admin UI] Anchor creation flow on deep-link from the policy warning** — when arriving via the `Add anchor →` link, the form now auto-selects the relationship if exactly one candidate's parent table contains the resolved column. Multiple viable candidates are sorted first in the dropdown and prefixed with ``. When the prefilled child table has zero relationships, the form replaces the previous one-line amber sentence with an empty-state guidance block: a "Switch to Same-table alias" button + an inline `FkSuggestionsList` filtered to the child table + a compact `InlineManualRelationshipForm` (parent table + columns) that auto-injects the new relationship into the anchor on success. The user no longer has to leave the anchor flow to fix the missing prerequisite.
15+
16+
### Added
17+
18+
- **[Proxy] `schema_upstream` field on the anchor-coverage response**`AnchorCoverageTableEntry` now carries both `schema` (effective name, alias if set) and `schema_upstream` (raw upstream name). The admin UI uses this to render `pg.payments (postgres.payments)` in the warning panel when an alias is in play, while continuing to deep-link by the effective name (which is what the proxy keys columns by at query time).
19+
- **[Admin UI] `SectionDef.indicator` slot on `SecondaryNav`** — sections can now carry an optional `{ tone: 'red' | 'yellow'; label: string; ariaLabel?: string }` indicator that renders as a small count pill on the right of the nav button. Currently consumed by `PolicyEditPage` for anchor coverage; the slot is generic so future sections can adopt it without further changes to the component.
20+
1021
## [0.17.0] - 2026-04-23
1122

1223
### Added

admin-ui/CLAUDE.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ Before building a new page or extending an existing one, read `admin-ui/DESIGN.m
1717
- `src/components/RoleMemberPanel.tsx` — Effective member list (direct + inherited with source badges), add/remove for direct members
1818
- `src/components/RoleInheritancePanel.tsx` — Parent/child role management with cycle detection feedback
1919
- `src/components/RoleAccessPanel.tsx` — Checkbox-based role access panel for datasource edit page
20-
- `src/components/PolicyAnchorCoveragePanel.tsx` — Edit-time silent-deny warning on `PolicyEditPage` for `row_filter` policies. Calls `GET /policies/{id}/anchor-coverage` (keyed on `(policyId, version)` so saves auto-refetch via the existing `['policy', policyId]` invalidation); hidden for non-row-filter types. Renders a green banner when every (assigned table × referenced column) resolves cleanly, or a red panel listing each broken `(table, column)` pair with a link to the datasource page where the missing anchor can be added. Rendered inside the `Anchor coverage` section of the policy edit page's T1 sidebar (omitted from the nav for non-row-filter policies).
21-
- `src/components/RelationshipsPanel.tsx` — Datasource edit page panel for admin-curated `table_relationship` + `column_anchor` CRUD. Surfaces live FK candidates from `GET /datasources/{id}/fk-suggestions` (already-added tuples greyed out) and the resolved-column → anchor designations that drive transitive row-filter resolution. The anchor form's "Resolve via" radio picks between **Relationship (FK walk)** — rendered via a dropdown of relationships scoped to the selected child table — and **Same-table alias** — rendered as a text input with a `<datalist>` populated from the child table's discovered columns. Long child→parent labels stack on two lines so Delete buttons stay reachable inside the narrow `max-w-2xl` page container. See `docs/security-vectors.md` vector 73 for the trust model.
20+
- `src/components/PolicyAnchorCoveragePanel.tsx` — Pure display component for the edit-time silent-deny warning on `PolicyEditPage`. Receives `data: PolicyAnchorCoverageResponse | undefined` as a prop (the query is hoisted to `PolicyEditPage` so the side-nav indicator and the panel share one cache entry). Renders a green banner when every (assigned table × referenced column) resolves cleanly, or a red panel listing each broken `(table, column)` pair grouped by `(data_source_name, schema.table)` as a card row with an `Add anchor →` button link to the datasource page. When the schema has an alias, shows the upstream raw name in muted parens next to the table label. Rendered inside the `Anchor coverage` section of the policy edit page's T1 sidebar (omitted from the nav for non-row-filter policies).
21+
- `src/components/RelationshipsPanel.tsx` — Datasource edit page panel for admin-curated `table_relationship` + `column_anchor` CRUD. Surfaces live FK candidates from `GET /datasources/{id}/fk-suggestions` (already-added tuples greyed out) and the resolved-column → anchor designations that drive transitive row-filter resolution. The anchor table's `Resolves via` column shows the full `child_schema.child_table.child_col → parent_schema.parent_table.parent_col` join path for relationship-mode anchors and the `alias → .col` shape for same-table-alias mode. The anchor form's "Resolve via" radio picks between **Relationship (FK walk)** — rendered via a dropdown of relationships scoped to the selected child table, with viable candidates (parent table contains the resolved column) sorted first and prefixed `✓`; auto-selected when there is exactly one viable candidate — and **Same-table alias** — rendered as a text input with a `<datalist>` populated from the child table's discovered columns. When the prefilled child table has zero relationships, the form shows an empty-state guidance block with two CTAs (Switch to Same-table alias / Add a relationship ↓); the latter expands an inline `FkSuggestionsList` filtered to the child table plus an `InlineManualRelationshipForm` mini-form, both of which auto-populate the anchor's `relationshipId` on success. Long child→parent labels stack on two lines so Delete buttons stay reachable inside the narrow `max-w-2xl` page container. See `docs/security-vectors.md` vector 73 for the trust model.
22+
- `src/utils/schemaLabel.ts``effectiveSchemaName(schema_name, schema_alias)` helper. **Convention: every admin-ui surface that displays a schema name uses the effective name (alias if set, else raw upstream).** This matches what policies and queries operate on (the proxy keys columns by the effective/df name in `resolution/graph.rs`) and what the policy target dropdowns already do via `useCatalogHints`. When a place needs to surface the raw upstream alongside (selection contexts like dropdowns, the anchor coverage warning), render `(schema_upstream.table)` muted next to the effective label. Used by `RelationshipsPanel.tsx`, `PolicyAnchorCoveragePanel.tsx`, and the `?focus=` deep-link matcher in `ColumnAnchorsSection`.
2223
- `src/api/catalog.ts` — API client for discovery catalog + `table_relationship` / `column_anchor` / FK suggestions (`listRelationships`, `createRelationship`, `deleteRelationship`, `listFkSuggestions`, `listColumnAnchors`, `createColumnAnchor`, `deleteColumnAnchor`)
2324
- `src/types/catalog.ts` — TypeScript interfaces for catalog + relationships (`TableRelationship`, `CreateTableRelationshipRequest`, `ColumnAnchor`, `CreateColumnAnchorRequest`, `FkSuggestion`)
2425
- `src/components/AuditTimeline.tsx` — Reusable admin audit timeline (used on role/user/policy/datasource detail pages)
@@ -47,7 +48,7 @@ Before building a new page or extending an existing one, read `admin-ui/DESIGN.m
4748
- `src/components/Layout.tsx` — App shell layout with sidebar navigation
4849
- `src/pages/PoliciesListPage.tsx` — Paginated policy list
4950
- `src/pages/PolicyCreatePage.tsx` — Policy creation page
50-
- `src/pages/PolicyEditPage.tsx` — T1 sidebar edit page. Sections: Details, Assignments, Anchor coverage (row_filter only), View as code, Activity. Danger zone offers typed-name delete with a "Disable instead" escape via `is_enabled`. Save keeps the user on the page (toast-only); 409 conflicts show an inline banner.
51+
- `src/pages/PolicyEditPage.tsx` — T1 sidebar edit page. Sections: Details, Assignments, Anchor coverage (row_filter only), View as code, Activity. Owns the `useQuery(['policy-anchor-coverage', policyId, version])` and passes the result down to `PolicyAnchorCoveragePanel`; `sectionsFor()` reads the broken-entry count and stamps a red count pill on the `Anchor coverage` nav item via `SectionDef.indicator`. Danger zone offers typed-name delete with a "Disable instead" escape via `is_enabled`. Save keeps the user on the page (toast-only); 409 conflicts show an inline banner.
5152
- `src/pages/QueryAuditPage.tsx` — Query audit log viewer
5253
- `src/test/test-utils.tsx``renderWithProviders` (QueryClient + AuthProvider + MemoryRouter)
5354
- `src/test/factories.ts``makeUser`, `makeDataSource`, `makeDataSourceType`, `makeDiscoveredSchema/Table/Column`, `makeDecisionFunction`, `makePolicy`, `makePolicyAssignment`
Lines changed: 84 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,43 @@
1-
import { describe, it, expect, vi, afterEach } from 'vitest'
2-
import { screen, waitFor } from '@testing-library/react'
1+
import { describe, it, expect } from 'vitest'
2+
import { screen } from '@testing-library/react'
33
import { PolicyAnchorCoveragePanel } from './PolicyAnchorCoveragePanel'
44
import { renderWithProviders } from '../test/test-utils'
5-
import * as policiesApi from '../api/policies'
65
import type { PolicyAnchorCoverageResponse } from '../types/policy'
76

8-
afterEach(() => {
9-
vi.restoreAllMocks()
10-
})
11-
12-
function mockCoverage(coverage: PolicyAnchorCoverageResponse) {
13-
vi.spyOn(policiesApi, 'getPolicyAnchorCoverage').mockResolvedValue(coverage)
14-
}
15-
167
describe('PolicyAnchorCoveragePanel', () => {
17-
it('renders nothing for non-row-filter policies', () => {
18-
const { container } = renderWithProviders(
19-
<PolicyAnchorCoveragePanel
20-
policyId="p-1"
21-
policyType="column_mask"
22-
version={1}
23-
/>,
24-
)
8+
it('shows a loading shim when data is undefined', () => {
9+
renderWithProviders(<PolicyAnchorCoveragePanel data={undefined} />)
10+
expect(document.body.textContent).toMatch(/checking anchor coverage/i)
11+
})
12+
13+
it('renders nothing when there are zero assigned tables', () => {
14+
const data: PolicyAnchorCoverageResponse = {
15+
policy_id: 'p-1',
16+
policy_type: 'row_filter',
17+
coverage: [],
18+
}
19+
const { container } = renderWithProviders(<PolicyAnchorCoveragePanel data={data} />)
2520
expect(container.firstChild).toBeNull()
2621
})
2722

28-
it('shows green banner when all verdicts pass', async () => {
29-
mockCoverage({
23+
it('shows green banner when all verdicts pass', () => {
24+
const data: PolicyAnchorCoverageResponse = {
3025
policy_id: 'p-1',
3126
policy_type: 'row_filter',
3227
coverage: [
3328
{
3429
data_source_id: 'ds-1',
3530
data_source_name: 'prod',
3631
schema: 'public',
32+
schema_upstream: 'public',
3733
table: 'orders',
3834
verdicts: [{ kind: 'on_table', column: 'tenant' }],
3935
},
4036
{
4137
data_source_id: 'ds-1',
4238
data_source_name: 'prod',
4339
schema: 'public',
40+
schema_upstream: 'public',
4441
table: 'payments',
4542
verdicts: [
4643
{
@@ -55,48 +52,43 @@ describe('PolicyAnchorCoveragePanel', () => {
5552
],
5653
},
5754
],
58-
})
55+
}
5956

60-
renderWithProviders(
61-
<PolicyAnchorCoveragePanel policyId="p-1" policyType="row_filter" version={1} />,
62-
)
57+
renderWithProviders(<PolicyAnchorCoveragePanel data={data} />)
6358

64-
await waitFor(() =>
65-
expect(screen.getByTestId('anchor-coverage-clean')).toBeInTheDocument(),
66-
)
59+
expect(screen.getByTestId('anchor-coverage-clean')).toBeInTheDocument()
6760
expect(document.body.textContent).toMatch(/2 tables resolve cleanly/i)
6861
})
6962

70-
it('shows red panel listing broken pairs with datasource link', async () => {
71-
mockCoverage({
63+
it('shows red panel listing broken pairs with datasource link', () => {
64+
const data: PolicyAnchorCoverageResponse = {
7265
policy_id: 'p-1',
7366
policy_type: 'row_filter',
7467
coverage: [
7568
{
7669
data_source_id: 'ds-42',
7770
data_source_name: 'prod',
7871
schema: 'public',
72+
schema_upstream: 'public',
7973
table: 'invoices',
8074
verdicts: [{ kind: 'missing_anchor', column: 'tenant' }],
8175
},
8276
{
8377
data_source_id: 'ds-42',
8478
data_source_name: 'prod',
8579
schema: 'public',
80+
schema_upstream: 'public',
8681
table: 'orders',
8782
verdicts: [{ kind: 'on_table', column: 'tenant' }],
8883
},
8984
],
90-
})
85+
}
9186

92-
renderWithProviders(
93-
<PolicyAnchorCoveragePanel policyId="p-1" policyType="row_filter" version={3} />,
94-
)
87+
renderWithProviders(<PolicyAnchorCoveragePanel data={data} />)
9588

96-
await waitFor(() =>
97-
expect(screen.getByTestId('anchor-coverage-broken')).toBeInTheDocument(),
98-
)
99-
expect(document.body.textContent).toMatch(/silently deny on 1 of 2 tables/i)
89+
expect(screen.getByTestId('anchor-coverage-broken')).toBeInTheDocument()
90+
expect(document.body.textContent).toMatch(/silently deny on 1 table/i)
91+
expect(document.body.textContent).not.toMatch(/of 2/)
10092
expect(document.body.textContent).toMatch(/invoices/)
10193
expect(document.body.textContent).toMatch(/no anchor configured/i)
10294

@@ -106,4 +98,58 @@ describe('PolicyAnchorCoveragePanel', () => {
10698
`/datasources/ds-42/edit?section=anchors&focus=${encodeURIComponent('public.invoices.tenant')}`,
10799
)
108100
})
101+
102+
it('shows the upstream schema in muted parens when an alias is set', () => {
103+
const data: PolicyAnchorCoverageResponse = {
104+
policy_id: 'p-1',
105+
policy_type: 'row_filter',
106+
coverage: [
107+
{
108+
data_source_id: 'ds-99',
109+
data_source_name: 'staging',
110+
schema: 'pg',
111+
schema_upstream: 'postgres',
112+
table: 'payments',
113+
verdicts: [{ kind: 'missing_anchor', column: 'tenant_id' }],
114+
},
115+
],
116+
}
117+
118+
renderWithProviders(<PolicyAnchorCoveragePanel data={data} />)
119+
120+
expect(document.body.textContent).toMatch(/pg\.payments/)
121+
expect(document.body.textContent).toMatch(/\(postgres\.payments\)/)
122+
123+
const link = screen.getByRole('link', { name: /add anchor/i })
124+
expect(link).toHaveAttribute(
125+
'href',
126+
`/datasources/ds-99/edit?section=anchors&focus=${encodeURIComponent('pg.payments.tenant_id')}`,
127+
)
128+
})
129+
130+
it('reports alias-target verdicts as their dedicated message', () => {
131+
const data: PolicyAnchorCoverageResponse = {
132+
policy_id: 'p-1',
133+
policy_type: 'row_filter',
134+
coverage: [
135+
{
136+
data_source_id: 'ds-1',
137+
data_source_name: 'prod',
138+
schema: 'public',
139+
schema_upstream: 'public',
140+
table: 'orders',
141+
verdicts: [
142+
{
143+
kind: 'missing_column_on_alias_target',
144+
column: 'tenant_id',
145+
actual_column_name: 'org_id',
146+
},
147+
],
148+
},
149+
],
150+
}
151+
renderWithProviders(<PolicyAnchorCoveragePanel data={data} />)
152+
expect(document.body.textContent).toMatch(/alias points at missing column/i)
153+
expect(document.body.textContent).toMatch(/org_id/)
154+
})
109155
})

0 commit comments

Comments
 (0)