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
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
Dashboard gains refresh, View/Edit, and `G` navigation commands while stale
viewer sessions, loading routes, stacked overlays, and nested text inputs
fail closed. Contextual help is torn down on route and authentication
transitions so it cannot survive onto another surface or Login.
transitions so it cannot survive onto another surface or Login. Dashboard
Style adds `G G`, `G F`, `G R`, `G 2`, and `G 3` for Grid Tiles, Full view,
Report, and two/three columns; View mode previews every style without
changing the shared dashboard.

## [0.6.4] - 2026-07-24

Expand Down
45 changes: 40 additions & 5 deletions src/dashboard/application/dashboard-viewer-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,11 @@ export type DashboardLayoutView =
| (FlowLayoutModel & { engine: 'flow' })
| { engine: 'grafana-grid'; grid: GrafanaGridLayoutModel; renderMode: GridRenderMode };

/** The five presentation styles exposed by the Dashboard header. This is a
* runtime choice: View mode can select any style without changing the shared
* document, while Edit mode maps the same values to authoring commands. */
export type DashboardStyle = 'grafana-grid' | 'full' | 'report' | 'columns-2' | 'columns-3';

export interface DashboardViewState {
/** Search-matched tiles only, in their unchanged saved order. */
tiles: ViewerTileState[];
Expand All @@ -182,6 +187,8 @@ export interface DashboardViewState {
resettableFilterIds: string[];
filters: ViewerFilterState[];
layout: DashboardLayoutView;
/** The effective header style, including any session-local View-mode choice. */
style: DashboardStyle;
/** Count of ACTIVE filter DEFINITIONS (not non-empty stored values, #188). */
activeFilterCount: number;
running: boolean;
Expand Down Expand Up @@ -332,6 +339,9 @@ export interface DashboardViewerSession {
* `documentRef` entirely. A fresh session (reload/new viewer) always starts
* at 'tiles'. */
setGridRenderMode(mode: GridRenderMode): void;
/** Select a complete session-local Dashboard style. Unlike `syncDocument`,
* this never changes the source document or re-runs tile queries. */
setDashboardStyle(style: DashboardStyle): void;
/** Cancel all work and turn every later entry point into a no-op. */
destroy(): void;
}
Expand Down Expand Up @@ -489,6 +499,9 @@ 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';
// View mode may preview any header style without authoring a layout. `null`
// means render the document's authored style (plus the legacy Full override).
let dashboardStyleOverride: DashboardStyle | null = null;
let tileSearch = '';
// #335: the single wall-clock snapshot the LATEST execution wave resolved its
// relative tokens against (published as `state.waveWallNowMs`). `null` until
Expand Down Expand Up @@ -905,16 +918,31 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa
// primary with a valid flow@1 fallback still resolves to the flow plugin
// here, exactly as before #291 (`computeFlowLayout`'s own fallback
// handling, untouched) — flow behavior stays bit-identical.
const plugin = resolveLayoutPluginSync(documentRef.layout);
const selectedStyle: DashboardStyle = dashboardStyleOverride
?? (documentRef.layout.type === 'grafana-grid'
? (gridRenderMode === 'full' ? 'full' : 'grafana-grid')
: (documentRef.layout.preset as DashboardStyle));
const layoutDocument = dashboardStyleOverride === null
? documentRef.layout
: selectedStyle === 'grafana-grid' || selectedStyle === 'full'
? (documentRef.layout.type === 'grafana-grid'
? documentRef.layout
: { type: 'grafana-grid', version: 1, items: {} })
: {
type: 'flow', version: 1, preset: selectedStyle,
items: documentRef.layout.type === 'flow' ? documentRef.layout.items : {},
};
const renderMode: GridRenderMode = selectedStyle === 'full' ? 'full' : 'tiles';
const plugin = resolveLayoutPluginSync(layoutDocument);
const layout: DashboardLayoutView = plugin.type === 'grafana-grid'
? {
engine: 'grafana-grid',
grid: computeGrafanaGridLayout({
tiles: visible, layout: documentRef.layout, containerWidth: deps.containerWidth?.(), renderMode: gridRenderMode,
tiles: visible, layout: layoutDocument, containerWidth: deps.containerWidth?.(), renderMode,
}),
renderMode: gridRenderMode,
renderMode,
}
: { engine: 'flow', ...computeFlowLayout({ tiles: visible, layout: documentRef.layout, mobile }) };
: { engine: 'flow', ...computeFlowLayout({ tiles: visible, layout: layoutDocument, mobile }) };
return {
tiles: visibleRuntimes.map((runtime) => ({ ...runtime.state })),
totalTileCount: tiles.length,
Expand All @@ -927,6 +955,7 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa
}).map((filter) => filter.def.id),
filters: filters.map((filter) => ({ ...filter.state })),
layout,
style: selectedStyle,
activeFilterCount: filters.filter((filter) => filter.state.active).length,
running, updatedAt, diagnostics: presentationDiagnostics,
// #189: construction-time selection-resolution diagnostics are PERSISTENT
Expand Down Expand Up @@ -1759,6 +1788,12 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa
publish();
}

function setDashboardStyle(style: DashboardStyle): void {
if (destroyed || dashboardStyleOverride === style) return;
dashboardStyleOverride = style;
publish();
}

function destroy(): void {
destroyed = true;
for (const runtime of tiles) {
Expand All @@ -1781,6 +1816,6 @@ export function createDashboardViewerSession(deps: DashboardViewerDeps): Dashboa
state: stateSignal as ReadonlySignal<DashboardViewState>,
controls, timeRangeGroups, getFilterField,
start, refresh, refreshTile, setFilter, applyFilter, applyFilters, clearFilter, clearAllFilters, resetFilters,
setTileSearch, cancelTile, syncDocument, setGridRenderMode, destroy,
setTileSearch, cancelTile, syncDocument, setGridRenderMode, setDashboardStyle, destroy,
};
}
8 changes: 8 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,8 @@ body {
}
.fm-item:hover { background: var(--bg-hover); }
.fm-icon { display: flex; width: 15px; justify-content: center; color: var(--fg-mute); flex-shrink: 0; }
.fm-leading { display: inline-flex; flex-shrink: 0; }
.fm-trailing { display: inline-flex; flex-shrink: 0; margin-left: auto; }
.fm-label { flex: 1; white-space: nowrap; }
.fm-meta { font-size: 10px; color: var(--fg-faint); font-family: var(--mono); flex-shrink: 0; }
.fm-sep { height: 1px; background: var(--border-faint); margin: 5px 0; }
Expand Down Expand Up @@ -759,6 +761,12 @@ body {
}
.shortcut-keys { display: inline-flex; align-items: center; gap: 4px; }
.shortcut-then { color: var(--fg-faint); font-size: 11px; }
.shortcut-plus { color: var(--fg-faint); font-size: 11px; }
.dash-style-key {
font-family: var(--mono); font-size: 11px; padding: 2px 6px;
background: var(--bg-input); border: 1px solid var(--border); border-radius: 4px; color: var(--fg);
white-space: nowrap;
}
.modal-card .close-row {
margin-top: 12px; display: flex; justify-content: flex-end;
}
Expand Down
85 changes: 40 additions & 45 deletions src/ui/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ import { createDashboardChartInteractionController } from './dashboard-chart-int
import type { DashboardChartInteractionController } from './dashboard-chart-interaction.js';
import { createDashboardViewerSession } from '../dashboard/application/dashboard-viewer-session.js';
import type {
DashboardViewerSession, DashboardViewState, ViewerTileState, ViewerFilterState,
DashboardViewerSession, DashboardViewState, DashboardStyle, ViewerTileState, ViewerFilterState,
} from '../dashboard/application/dashboard-viewer-session.js';
import { defaultLayoutRegistry, resolveLayoutPluginSync } from '../dashboard/layouts/layout-registry.js';
import type { FlowLayoutModel } from '../dashboard/layouts/flow-layout.js';
Expand Down Expand Up @@ -226,10 +226,14 @@ export function disposeDashboardSurface(): void {
/** Build the Dashboard style picker with the same trigger and dropdown
* vocabulary as File. The trigger shows only the active style; `sync()`
* reflects session changes without adding a second header label. */
type LayoutOption = [value: string, label: string, title?: string];
type LayoutOption = [value: DashboardStyle, label: string, title: string, shortcut: string];
function dashboardStyleKeyCaps(shortcut: string): HTMLElement {
const key = shortcut.toUpperCase();
return h('kbd', { class: 'dash-style-key' }, `G + ${key}`);
}
function buildLayoutMenu(
doc: Document, onKeyboardOwnerChange: (owner: App['keyboardOwner']) => void,
options: LayoutOption[], getActive: () => string, onPick: (value: string) => void, ariaLabel: string,
options: LayoutOption[], getActive: () => string, onPick: (value: DashboardStyle) => void, ariaLabel: string,
): { el: HTMLButtonElement; sync: () => void } {
const label = h('span');
const el = h('button', {
Expand All @@ -251,10 +255,10 @@ function buildLayoutMenu(
document: doc,
trigger: el,
menuClass: 'dash-file-menu dash-style-menu',
rows: options.map(([value, optionLabel]) => ({
rows: options.map(([value, optionLabel, _title, shortcut]) => ({
kind: 'item',
label: optionLabel,
meta: value === active ? 'Current' : null,
trailing: dashboardStyleKeyCaps(shortcut),
extraClass: 'dash-style-item',
onClick: () => onPick(value),
})),
Expand Down Expand Up @@ -539,11 +543,6 @@ export async function renderDashboard(app: DashboardApp): Promise<void> {
recordBoundParams: (bp) => app.params.recordBoundParams(bp),
initialFilters: initialBag,
});
// The global shortcut reaches this route-local port only while its renderer
// generation is current. It is cleared by both Dashboard cleanup and every
// application surface transition.
const commandPort = { surface: 'dashboard' as const, generation: surfaceGeneration, refresh: () => session.refresh() };
app.surfaceCommands = commandPort;
let trackedSessionTileIds = new Set(viewerDoc.tiles.map((tile) => tile.id));
const syncSessionDocument = (next: DashboardDocumentV1): void => {
session.syncDocument(next);
Expand Down Expand Up @@ -577,29 +576,19 @@ export async function renderDashboard(app: DashboardApp): Promise<void> {
// but the render-mode toggle is harmless to expose read-only since it never
// persists anything.
const EDITABLE_LAYOUT_OPTIONS: LayoutOption[] = [
['grafana-grid', 'Grid Tiles', 'A responsive tile grid using authored spans and heights'],
['full', 'Full view', 'Temporary full-width view — tile widths are not saved'],
['report', 'Report', 'One centered, taller tile per row'],
['columns-2', '2 columns', 'Arrange tiles in two columns'],
['columns-3', '3 columns', 'Arrange tiles in three columns'],
];
const READONLY_LAYOUT_OPTIONS: LayoutOption[] = [
['grafana-grid', 'Grid Tiles', 'A responsive tile grid using authored spans and heights'],
['full', 'Full view', 'Temporary full-width view — tile widths are not saved'],
['grafana-grid', 'Grid Tiles', 'A responsive tile grid using authored spans and heights', 'G'],
['full', 'Full view', 'Temporary full-width view — tile widths are not saved', 'F'],
['report', 'Report', 'One centered, taller tile per row', 'R'],
['columns-2', '2 columns', 'Arrange tiles in two columns', '2'],
['columns-3', '3 columns', 'Arrange tiles in three columns', '3'],
];
const getActiveLayoutOption = (): string => (currentDoc.layout.type === 'grafana-grid'
? (gridRenderMode === 'full' ? 'full' : 'grafana-grid')
: typeof currentDoc.layout.preset === 'string' ? currentDoc.layout.preset : 'report');
const layoutMenu = buildLayoutMenu(
doc, keyboardOwnerChannel(app),
readOnly ? READONLY_LAYOUT_OPTIONS : EDITABLE_LAYOUT_OPTIONS,
getActiveLayoutOption,
(value) => {
// #321 read-only: the reduced menu offers ONLY 'grafana-grid'/'full'
// — either choice is ONLY ever the transient render-mode override, NEVER
// a command / persistence.
let layoutMenu: { el: HTMLButtonElement; sync: () => void };
const selectLayout = (value: DashboardStyle): void => {
if (readOnly) {
session.setGridRenderMode(value === 'full' ? 'full' : 'tiles');
session.setDashboardStyle(value as DashboardStyle);
layoutMenu.sync();
return;
}
Expand Down Expand Up @@ -636,18 +625,25 @@ export async function renderDashboard(app: DashboardApp): Promise<void> {
runCommand({ type: 'change-layout', layout: { ...currentDoc.layout, preset: value as FlowPresetV1 } });
}
layoutMenu.sync();
},
};
layoutMenu = buildLayoutMenu(
doc, keyboardOwnerChannel(app),
EDITABLE_LAYOUT_OPTIONS,
() => readOnly ? session.state.value.style : getActiveLayoutOption(),
selectLayout,
'Dashboard style',
);
// The global shortcut reaches this route-local port only while its renderer
// generation is current. It is cleared by both Dashboard cleanup and every
// application surface transition.
const commandPort = {
surface: 'dashboard' as const,
generation: surfaceGeneration,
refresh: () => session.refresh(),
setDashboardStyle: selectLayout,
};
app.surfaceCommands = commandPort;
const layoutWrap = h('div', { class: 'dash-layout-wrap' }, layoutMenu.el);
// #321 BLOCKER fix: the reduced read-only menu (Grid Tiles / Full view)
// is a grafana-grid-only render-mode toggle — expose it read-only ONLY when
// the persisted doc is grafana-grid. A read-only FLOW doc (report/columns-2/
// columns-3 — any pre-#321 shared doc) must hide the menu entirely: no
// engine switch is possible read-only, so this is decided once at build
// time from the static `currentDoc.layout.type`. Editable mode is unchanged
// (always the full menu).
const showLayoutSelect = !readOnly || currentDoc.layout.type === 'grafana-grid';

// Dashboard keeps the shared header's File word and placement. View exposes
// the safe Export row only; edit additionally exposes Import.
Expand Down Expand Up @@ -2178,13 +2174,12 @@ export async function renderDashboard(app: DashboardApp): Promise<void> {
);
if (sview.layout.engine !== lastEngineRendered) { lastLayoutSig = ''; lastGridSig = ''; lastEngineRendered = sview.layout.engine; }
activeEngine = sview.layout.engine;
// #321: keep the local render-mode mirror current from the published
// grafana-grid layout view — the ONLY place this session-owned, transient
// state is read back into the UI. A change re-syncs the style menu, flips
// the grid host's `is-full` class (the CSS vertical-resize-cursor hook),
// and updates every built resize handle's accessible label.
if (sview.layout.engine === 'grafana-grid' && sview.layout.renderMode !== gridRenderMode) {
gridRenderMode = sview.layout.renderMode;
// Keep the local render-mode mirror current from the published session
// layout. View mode can now project flow styles too, so leaving a Full
// grid must also clear the grid host's vertical-only resize affordance.
const nextGridRenderMode = sview.layout.engine === 'grafana-grid' ? sview.layout.renderMode : 'tiles';
if (nextGridRenderMode !== gridRenderMode) {
gridRenderMode = nextGridRenderMode;
layoutMenu.sync();
grid.classList.toggle('is-full', gridRenderMode === 'full');
for (const tileEl of tileEls.values()) applyResizeHandleMode(tileEl, gridRenderMode === 'full');
Expand All @@ -2198,7 +2193,7 @@ export async function renderDashboard(app: DashboardApp): Promise<void> {
});

const primaryToolbar = h('div', { class: 'dash-toolbar dash-toolbar-primary' },
showLayoutSelect ? layoutWrap : null,
layoutWrap,
tileCount,
tileSearch,
timeFilterHost,
Expand Down
4 changes: 3 additions & 1 deletion src/ui/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import type { KeyboardOwner } from './app.types.js';
* focus target is the row's own first focusable descendant (an `<input>`/
* `<button>`/etc.), or the node itself when it can take focus directly. */
export type MenuRow =
| { kind: 'item'; icon?: Node; label: string; meta?: string | null; onClick: () => void; extraClass?: string }
| { kind: 'item'; leading?: Node; icon?: Node; label: string; trailing?: Node; meta?: string | null; onClick: () => void; extraClass?: string }
| { kind: 'section'; label: string }
| { kind: 'sep' }
| { kind: 'custom'; node: HTMLElement; focusable?: boolean };
Expand Down Expand Up @@ -108,8 +108,10 @@ export function openMenu(opts: MenuOptions): MenuHandle {
role: 'menuitem',
onclick: () => { close(); row.onClick(); },
},
row.leading ? h('span', { class: 'fm-leading' }, row.leading) : null,
row.icon ? h('span', { class: 'fm-icon' }, row.icon) : null,
h('span', { class: 'fm-label' }, row.label),
row.trailing ? h('span', { class: 'fm-trailing' }, row.trailing) : null,
row.meta ? h('span', { class: 'fm-meta' }, row.meta) : null);
focusable.push(btn);
return btn;
Expand Down
Loading
Loading