Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 7 additions & 18 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: ci
on:
push:
branches: [main]
tags: ['v*']
tags: ['v*.*.*']
pull_request:
branches: [main]
workflow_dispatch:
Expand All @@ -26,7 +26,6 @@ jobs:
examples: ${{ steps.filter.outputs.examples }}
unit: ${{ steps.filter.outputs.unit }}
build: ${{ steps.filter.outputs.build }}
e2e: ${{ steps.filter.outputs.e2e }}
bundle: ${{ steps.filter.outputs.bundle }}
steps:
- uses: actions/checkout@v7
Expand Down Expand Up @@ -60,14 +59,6 @@ jobs:
- 'package.json'
- 'package-lock.json'
- '.github/workflows/**'
e2e:
- 'src/**'
- 'schemas/**'
- 'tests/e2e/**'
- 'playwright.config.js'
- 'package.json'
- 'package-lock.json'
- '.github/workflows/**'
bundle:
- 'src/**'
- 'schemas/**'
Expand Down Expand Up @@ -224,18 +215,16 @@ jobs:
sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck
shellcheck install.sh build/bundle.sh

# Real-browser regression tests (Playwright). Runs on Linux runners where the
# Gecko/Chromium binaries launch normally — separate from the unit suite so a
# missing browser binary can't mask a unit failure, and vice versa. The
# harness imports /src directly over a python http.server (started by the
# Nightly/manual/release real-browser regression tests (Playwright). Keep the
# costly browser matrix out of ordinary PR and branch-push CI; unit tests
# cover each commit, while release tags validate the shipped image/artifact.
# The harness imports /src directly over a python http.server (started by the
# Playwright config's webServer), so no build step is needed.
e2e:
needs: changes
if: >-
github.event_name == 'push' ||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) ||
github.event_name == 'schedule' ||
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' && needs.changes.outputs.e2e == 'true')
github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
Expand Down
47 changes: 4 additions & 43 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -1,52 +1,18 @@
name: docker

# Build the production nginx image and publish it multi-arch to GHCR.
# • pull request → build only (validation, no push)
# • push to main → :edge + :sha-<short>
# • push tag v X.Y.Z → :X.Y.Z + :latest (this is the release image)
# A version-tag push is the release boundary: PR and main CI validate the
# application itself, while this workflow publishes the release image.
on:
push:
branches: [main]
tags: ['v*.*.*']
pull_request:
workflow_dispatch:

env:
REGISTRY: ghcr.io
IMAGE_NAME: altinity/altinity-sql-browser

jobs:
# Keep the workflow/check present on every PR, but avoid QEMU + a multi-arch
# build when no production-image input changed. Job-level skips satisfy
# required checks; workflow-level path filters do not reliably do so.
changes:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
docker: ${{ steps.filter.outputs.docker }}
steps:
- uses: actions/checkout@v7
if: github.event_name == 'pull_request'
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590
if: github.event_name == 'pull_request'
id: filter
with:
filters: |
docker:
- 'Dockerfile'
- 'src/**'
- 'schemas/**'
- 'build/**'
- 'deploy/**'
- 'package.json'
- 'package-lock.json'
- '.github/workflows/docker.yml'

docker:
needs: changes
if: github.event_name != 'pull_request' || needs.changes.outputs.docker == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
Expand All @@ -60,9 +26,7 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4

# No push on PRs — build-only validation. GITHUB_TOKEN is enough for GHCR.
- name: Log in to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
Expand All @@ -75,18 +39,15 @@ jobs:
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=raw,value=edge,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=sha,prefix=sha-
type=raw,value=latest

- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
annotations: ${{ steps.meta.outputs.annotations }}
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
name: release

# Cut a release by pushing a tag, e.g.: git tag v0.1.0 && git push origin v0.1.0
# Cut a release by pushing a semver tag, e.g.: git tag v0.1.0 && git push origin v0.1.0
on:
push:
tags: ['v*']
tags: ['v*.*.*']

permissions:
contents: write # create the GitHub Release + upload assets
Expand Down Expand Up @@ -36,7 +36,7 @@ jobs:

# Package the Helm chart and push it to GHCR as an OCI artifact — same
# approach as altinity-mcp. The container image itself is built+pushed by
# docker.yml (also on the v* tag).
# docker.yml (also on the vX.Y.Z tag).
- uses: azure/setup-helm@v5
with:
version: latest
Expand Down
18 changes: 16 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,22 @@ auto-generated per-PR notes; this file is the curated, human-readable history.

## [Unreleased]

### Fixed
- Keep Grafana-grid KPI hover chrome control-only: saved-query titles and
descriptions no longer overlay metric values while moving or editing a tile.
- Keep the shared connection host visible after the server-version probe,
restore Dashboard filters to their initial default-derived active state, and
retain Dashboard time-range announcements when no ordinary filters exist.

### Added
- **Unified SQL Browser and Dashboard chrome.** Both surfaces now share the
same brand, surface, workspace, connection, examples, shortcuts, theme, and
user zones. Dashboard layout, tile count/search, time range, refresh, and
View/Edit controls live in a sticky primary tool row; ordinary parameters
live in a separate sticky filter row. Tile search matches effective titles
and descriptions without rerunning queries or changing saved layout/order,
and ordinary-filter Clear all restores defaults in one execution wave while
preserving every time-range pair.
- **Unified `/sql` routing for Workbench and Dashboard surfaces** (#407).
Workspace identity, surface, and presentation mode now live in canonical
`ws`, `surface`, and `mode` query parameters. Workbench/Dashboard switches
Expand All @@ -19,8 +34,7 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
falls back, and an empty workspace offers Create only in edit mode. The old
surface becomes an inert loading route before cross-workspace navigation,
while same-workspace Back/Forward switches immediately. Dashboard uses the
shared header row for its surface, tile count, File, active-style menu, name,
View/Edit, update, Refresh, and theme controls. Global Workbench shortcuts
shared application header and route controls. Global Workbench shortcuts
fail closed outside a ready matching route, and renderer generations let in-flight
durable writes finish without obsolete Dashboard or Workbench callbacks
repainting the selected surface. Direct Dashboard startup now shares the
Expand Down
79 changes: 62 additions & 17 deletions src/dashboard/application/dashboard-viewer-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ export interface ViewerTileState {
tileId: string;
queryId: string;
title: string;
/** Dashboard-local description override, then the saved-query description. */
description: string;
status: ViewerTileStatus;
isKpi: boolean;
/** The resolved effective panel (base + variant + override), or null when the
Expand Down Expand Up @@ -171,7 +173,13 @@ export type DashboardLayoutView =
| { engine: 'grafana-grid'; grid: GrafanaGridLayoutModel; renderMode: GridRenderMode };

export interface DashboardViewState {
/** Search-matched tiles only, in their unchanged saved order. */
tiles: ViewerTileState[];
totalTileCount: number;
visibleTileCount: number;
tileSearch: string;
/** Filter ids whose current value/activation differs from its declared default. */
resettableFilterIds: string[];
filters: ViewerFilterState[];
layout: DashboardLayoutView;
/** Count of ACTIVE filter DEFINITIONS (not non-empty stored values, #188). */
Expand Down Expand Up @@ -301,9 +309,13 @@ export interface DashboardViewerSession {
/** Deactivate one filter WITHOUT discarding its value (reactivation restores
* it); one affected-panel wave (#188 clear-one). */
clearFilter(filterId: string): Promise<void>;
/** Reset every filter to its `defaultActive`/`defaultValue`, coalesced into
/** Restore every filter to its initial default-derived state, coalesced into
* ONE affected-panel wave (#188 clear-all). */
clearAllFilters(): Promise<void>;
/** Reset only the named filter definitions to their declared defaults. */
resetFilters(filterIds: readonly string[]): Promise<void>;
/** Set the transient presentation-only tile search. */
setTileSearch(query: string): void;
/** Abort one tile's in-flight request. */
cancelTile(tileId: string): void;
/** Adopt a layout/order-edited document (reorder, span/height, preset) WITHOUT
Expand Down Expand Up @@ -421,6 +433,14 @@ const toParamValue = (value: unknown): unknown =>
* state must never alias an array a caller (the document, the persisted
* seed, `setFilter`/`applyFilter`'s own caller) still holds a reference to. */
const copyValue = (value: unknown): unknown => (Array.isArray(value) ? value.slice() : value);
const filterInitialActive = (def: DashboardFilterDefinitionV1): boolean =>
def.defaultActive ?? (Array.isArray(def.defaultValue)
? def.defaultValue.length > 0
: def.defaultValue != null && def.defaultValue !== '');
const filterDefaultValue = (def: DashboardFilterDefinitionV1): string | string[] =>
(Array.isArray(def.defaultValue)
? def.defaultValue.map(toValueString)
: toValueString(def.defaultValue));

/** Local copy of `effectiveFilterActive` (state.ts is off-limits to this
* layer): a param with an explicit activation entry uses it; otherwise a
Expand Down Expand Up @@ -469,6 +489,7 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa
// outside `documentRef` — never read/written by any command, never
// persisted. A fresh session always starts at 'tiles'.
let gridRenderMode: GridRenderMode = 'tiles';
let tileSearch = '';
// #335: the single wall-clock snapshot the LATEST execution wave resolved its
// relative tokens against (published as `state.waveWallNowMs`). `null` until
// the first wave; every wave entry point (`refresh`/`runAffectedWave`/the
Expand Down Expand Up @@ -505,8 +526,10 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa
const isText = type === 'text';
const explicit: Panel | null = isObject(panel) && isObject(panel.cfg) ? (panel as unknown as Panel) : null;
const title = (typeof tile.title === 'string' && tile.title) || (query ? queryName(query) : tile.queryId) || tile.id;
const description = (typeof tile.description === 'string' && tile.description)
|| (typeof query?.spec?.description === 'string' ? query.spec.description : '');
const state: ViewerTileState = {
tileId: tile.id, queryId: tile.queryId, title, isKpi, panel,
tileId: tile.id, queryId: tile.queryId, title, description, isKpi, panel,
status: presentationError ? 'error' : 'idle',
columns: null, rows: null, meta: null,
error: presentationError ? presentationError.message : null,
Expand All @@ -529,14 +552,8 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa

// Filter runtime records, in filter order.
const filters: FilterRuntime[] = (Array.isArray(documentRef.filters) ? documentRef.filters : []).map((def) => {
const defaultValue = copyValue(def.defaultValue ?? '');
// Merge-gate review (Finding B): array-aware — an omitted `defaultActive`
// infers INACTIVE from an empty array default (`[]`, matching `''`), and
// ACTIVE from a non-empty one, the same length-based rule `setFilter`'s
// own value-implies-active already applies to a committed array.
const defaultActive = def.defaultActive ?? (Array.isArray(def.defaultValue)
? def.defaultValue.length > 0
: (def.defaultValue != null && def.defaultValue !== ''));
const defaultValue = filterDefaultValue(def);
const defaultActive = filterInitialActive(def);
// #303: a persisted seed for this filter's id overrides the pure-default
// init above (untouched when `initialFilters` is absent/empty, or has no
// entry for `def.id`). #189: `copyValue` defends against aliasing the
Expand Down Expand Up @@ -872,7 +889,15 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa

function buildState(running: boolean, updatedAt: number | null): DashboardViewState {
const mobile = !!deps.isMobile?.();
const visible = tiles.map((runtime) => ({ id: runtime.tile.id, isKpi: runtime.isKpi }));
const normalizeSearch = (value: string): string =>
value.trim().replace(/\s+/g, ' ').toLocaleLowerCase();
const normalizedSearch = normalizeSearch(tileSearch);
const visibleRuntimes = normalizedSearch
? tiles.filter((runtime) =>
normalizeSearch(runtime.state.title).includes(normalizedSearch)
|| normalizeSearch(runtime.state.description).includes(normalizedSearch))
: tiles;
const visible = visibleRuntimes.map((runtime) => ({ id: runtime.tile.id, isKpi: runtime.isKpi }));
// #291: route to whichever engine the CURRENT document's layout resolves
// to (`resolveLayoutPluginSync` — the same sync helper the application
// layer's other non-awaitable call sites use, since this runs on every
Expand All @@ -891,7 +916,15 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa
}
: { engine: 'flow', ...computeFlowLayout({ tiles: visible, layout: documentRef.layout, mobile }) };
return {
tiles: tiles.map((runtime) => ({ ...runtime.state })),
tiles: visibleRuntimes.map((runtime) => ({ ...runtime.state })),
totalTileCount: tiles.length,
visibleTileCount: visibleRuntimes.length,
tileSearch,
resettableFilterIds: filters.filter((filter) => {
const defaultActive = filterInitialActive(filter.def);
const defaultValue = filterDefaultValue(filter.def);
return filter.state.active !== defaultActive || !sameSelection(filter.state.value, defaultValue);
}).map((filter) => filter.def.id),
filters: filters.map((filter) => ({ ...filter.state })),
layout,
activeFilterCount: filters.filter((filter) => filter.state.active).length,
Expand Down Expand Up @@ -1662,27 +1695,39 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa
}

async function clearAllFilters(): Promise<void> {
await resetFilters(filters.map((filter) => filter.def.id));
}

async function resetFilters(filterIds: readonly string[]): Promise<void> {
if (destroyed) return;
const ids = new Set(filterIds);
const changed: string[] = [];
for (const filter of filters) {
const nextActive = filter.def.defaultActive ?? false;
if (!ids.has(filter.def.id)) continue;
const nextActive = filterInitialActive(filter.def);
// #189: `copyValue` defends the default against aliasing (a
// `defaultValue` array literal on the document); `sameSelection`
// (filter-selection.ts) compares STRUCTURALLY so an array value/default
// never falls through the old `!==` reference check into a spurious
// "changed" on every reset.
const nextValue = copyValue(filter.def.defaultValue ?? '');
const nextValue = filterDefaultValue(filter.def);
if (filter.state.active !== nextActive || !sameSelection(filter.state.value, nextValue)) {
changed.push(filter.def.parameter);
}
filter.state.active = nextActive;
filter.state.value = nextValue;
}
publish();
if (changed.length) publish();
// Coalesce every reset into ONE affected-panel wave (#188 clear-all).
if (changed.length) await commitAndRerun(changed);
}

function setTileSearch(query: string): void {
if (destroyed || tileSearch === query) return;
tileSearch = query;
publish();
}

function cancelTile(tileId: string): void {
const runtime = tiles.find((entry) => entry.tile.id === tileId);
if (!runtime) return;
Expand Down Expand Up @@ -1735,7 +1780,7 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa
return {
state: stateSignal as ReadonlySignal<DashboardViewState>,
controls, timeRangeGroups, getFilterField,
start, refresh, refreshTile, setFilter, applyFilter, applyFilters, clearFilter, clearAllFilters,
cancelTile, syncDocument, setGridRenderMode, destroy,
start, refresh, refreshTile, setFilter, applyFilter, applyFilters, clearFilter, clearAllFilters, resetFilters,
setTileSearch, cancelTile, syncDocument, setGridRenderMode, destroy,
};
}
Loading
Loading