diff --git a/.env.example b/.env.example index 2bd6f7f4..5520e989 100644 --- a/.env.example +++ b/.env.example @@ -56,6 +56,9 @@ DATABASE_PASSWORD=change-me # STACKABLE_COCKPIT_COMPLETION_ENABLED=false # Disable SQL editor code completion (default: true) # STACKABLE_COCKPIT_STORAGE_BROWSER_ENABLED=true # Enable S3/HDFS file browser (default: false) # PUBLIC_STACKABLE_COCKPIT_STORAGE_TRANSFER_ENABLED=true # Enables cut, copy, paste, rename and drag-and-drop move in the storage browser; also enforced by the transfer API (default: false) +# Bookmarks shown to every user (JSON array; productId, name and http(s) url required, environment and pinned optional). +# Invalid JSON or entries stop the server at startup. +# STACKABLE_COCKPIT_GLOBAL_BOOKMARKS='[{"productId":"superset","name":"Dashboards","url":"https://superset.example.com"}]' # Storage browser limits and settings. Invalid values stop the server at startup. # STACKABLE_COCKPIT_TEXT_PREVIEW_BYTES=262144 # Max bytes fetched for text, TSV and JSON previews; editable files are fetched completely (default: 262144) diff --git a/.env.test b/.env.test index 23c4b8ae..3c74a4f6 100644 --- a/.env.test +++ b/.env.test @@ -17,6 +17,8 @@ PUBLIC_STACKABLE_COCKPIT_DEFAULT_PAGE_SIZE=25 PUBLIC_STACKABLE_COCKPIT_MAX_RECENT_FILES=15 PUBLIC_STACKABLE_COCKPIT_UPLOAD_CONCURRENCY=3 PUBLIC_STACKABLE_COCKPIT_STORAGE_TRANSFER_ENABLED=true +# Shown on every page, so names must not clash with other tests' locators. +STACKABLE_COCKPIT_GLOBAL_BOOKMARKS='[{"productId":"opensearch","name":"Cluster Logs","environment":"prod","url":"https://logs.example.com","pinned":true},{"productId":"custom","name":"Team Wiki","url":"https://wiki.example.com"}]' # One object at a time, so the cancel E2E test can stop a move mid-way. STACKABLE_COCKPIT_STORAGE_TRANSFER_CONCURRENCY=1 GARAGE_ADMIN_URL=http://localhost:30902 diff --git a/README.md b/README.md index 1e2b6c16..d4f7dda8 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,12 @@ Invalid values (e.g. `abc` for an integer) stop the server at startup with an er | ------------------------------------------------ | --------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | | `STACKABLE_COCKPIT_STORAGE_TRANSFER_CONCURRENCY` | `integer` | `8` | Objects copied at once within one copy, move or rename request. Copies run server-side in the storage backend (S3 CopyObject); no data streams through the app. | `STACKABLE_COCKPIT_STORAGE_TRANSFER_CONCURRENCY=4` | +### Dashboard Bookmarks + +| Variable | Type | Default | Description | Example | +| ------------------------------------ | ------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | +| `STACKABLE_COCKPIT_GLOBAL_BOOKMARKS` | `JSON` array | none | Bookmarks shown to every user, before their own, on the dashboard and in the sidebar. Users cannot change them. Each entry needs `productId` (e.g. `trino`, `superset`, or `custom`; unknown ids show as a custom link), `name` (up to 40 characters) and an HTTP(S) `url`; `environment` and `pinned` are optional. Invalid JSON or entries stop the server at startup. | `STACKABLE_COCKPIT_GLOBAL_BOOKMARKS='[{"productId":"superset","name":"Dashboards","url":"https://superset.example.com"}]'` | + ### Request Body Size | Variable | Type | Default | Description | Example | diff --git a/deploy/helm/cockpit/README.md b/deploy/helm/cockpit/README.md index 245972ac..b5cfab6b 100644 --- a/deploy/helm/cockpit/README.md +++ b/deploy/helm/cockpit/README.md @@ -145,6 +145,12 @@ Limits and tuning for the file browser. Empty values use the application default | `storage.parquetPreviewDisallowedCompressionTypes` | Comma-separated Parquet compression codecs that are not previewed. A `-no_offset` suffix (e.g. `gzip-no_offset`) only blocks files without an offset index. | `""` (application default `gzip-no_offset`) | | `storage.downloadHistoryRetentionDays` | Days a user's download history is kept. Integer from 1 to 3650. | `""` (application default `30`) | +### Bookmark Parameters + +| Parameter | Description | Default | +| --- | --- | --- | +| `bookmarks.global` | Bookmarks shown to every user, before their own; users cannot change them. A list of entries with `productId` (e.g. `trino`, `superset` or `custom`; unknown ids show as a custom link), `name` (up to 40 characters) and an HTTP(S) `url`, plus optional `environment` and `pinned`. Must be a YAML list, not a JSON string. Invalid entries stop the application at startup. | `[]` | + ### Authentication Parameters OIDC is the only supported auth mechanism. Disable for local testing. diff --git a/deploy/helm/cockpit/templates/deployment.yaml b/deploy/helm/cockpit/templates/deployment.yaml index 60ed19bf..7cad21ca 100644 --- a/deploy/helm/cockpit/templates/deployment.yaml +++ b/deploy/helm/cockpit/templates/deployment.yaml @@ -140,6 +140,13 @@ spec: - name: STACKABLE_COCKPIT_DOWNLOAD_HISTORY_RETENTION_DAYS value: {{ include "cockpit.envValue" .Values.storage.downloadHistoryRetentionDays }} {{- end }} + {{- with .Values.bookmarks.global }} + {{- if not (kindIs "slice" .) }} + {{- fail "bookmarks.global must be a YAML list of bookmarks; a JSON string is not accepted" }} + {{- end }} + - name: STACKABLE_COCKPIT_GLOBAL_BOOKMARKS + value: {{ toJson . | quote }} + {{- end }} {{- if .Values.trino.queryTtl }} - name: STACKABLE_COCKPIT_QUERY_TTL value: {{ .Values.trino.queryTtl | int64 |quote }} diff --git a/deploy/helm/cockpit/values.yaml b/deploy/helm/cockpit/values.yaml index 0e810288..e1fcc9dd 100644 --- a/deploy/helm/cockpit/values.yaml +++ b/deploy/helm/cockpit/values.yaml @@ -189,6 +189,19 @@ database: # The Secret Operator mounts ca.crt into /stackable/database-tls. secretClass: "" +# Dashboard bookmarks. +bookmarks: + # Bookmarks shown to every user, before their own. Users cannot change them. + # productId (e.g. trino, superset or custom; unknown ids show as a custom link), + # name (up to 40 characters) and an http(s) url are required; environment and + # pinned are optional. Must be a YAML list, not a JSON string. Invalid entries stop + # the application at startup. + global: [] + # - productId: superset + # name: Dashboards + # url: https://superset.example.com + # environment: prod + # Storage browser settings that are not feature flags. Empty or omitted values use the application # defaults; invalid values stop the application at startup. storage: diff --git a/e2e/bookmarks.spec.ts b/e2e/bookmarks.spec.ts index 61260a00..a0ea1727 100644 --- a/e2e/bookmarks.spec.ts +++ b/e2e/bookmarks.spec.ts @@ -147,6 +147,5 @@ test.describe('Dashboard bookmarks', () => { await expect(confirmDialog).toBeHidden(); await expect(editDialog).toBeHidden(); await expect(page.getByLabel('Bookmarks').getByText('Dashboards')).not.toBeVisible(); - await expect(page.getByText(/no bookmarks yet/i)).toBeVisible(); }); }); diff --git a/e2e/global-bookmarks.spec.ts b/e2e/global-bookmarks.spec.ts new file mode 100644 index 00000000..18265d25 --- /dev/null +++ b/e2e/global-bookmarks.spec.ts @@ -0,0 +1,57 @@ +import type { Page } from '@playwright/test'; +import { test, expect } from './support/fixtures.js'; +import { waitForHydration } from './support/helpers'; +import { resetBookmarks } from './support/bookmarks.js'; + +// The global bookmarks come from STACKABLE_COCKPIT_GLOBAL_BOOKMARKS in .env.test. +test.describe('Global bookmarks', () => { + test.use({ locale: 'en-US' }); + + const section = (page: Page) => page.getByLabel('Bookmarks'); + const sidebarNav = (page: Page) => page.getByRole('navigation', { name: 'Main navigation' }); + + test.beforeEach(async ({ page }) => { + await resetBookmarks(page, [ + { productId: 'superset', name: 'Dashboards', url: 'https://superset.example.com' } + ]); + }); + + test.afterEach(async ({ page }) => { + await resetBookmarks(page); + }); + + // Card rendering and the read-only cards are covered by the DashboardBookmarks + // component tests; this checks the wiring from the env var to the page. + test('shows the configured bookmarks before the user’s own', async ({ page }) => { + await page.goto('/'); + await waitForHydration(page); + + // Only the title links: the image links are hidden from assistive technology. + await expect(section(page).getByRole('link')).toHaveText([ + 'Cluster Logs', + 'Team Wiki', + 'Dashboards' + ]); + }); + + test('lists global bookmarks in the sidebar by their configured pin', async ({ + page, + viewport + }) => { + test.skip((viewport?.width ?? 1280) < 1024, 'Sidebar is off-canvas on small screens'); + await page.addInitScript(() => localStorage.removeItem('sidebar_tools_open')); + await page.goto('/'); + await waitForHydration(page); + + await expect(sidebarNav(page).getByRole('link', { name: 'Cluster Logs' })).toHaveAttribute( + 'href', + 'https://logs.example.com' + ); + await expect(sidebarNav(page).getByRole('link', { name: 'Team Wiki' })).toBeVisible(); + + // Collapsing Tools hides the unpinned global bookmark but keeps the pinned one. + await page.getByRole('button', { name: 'Tools', exact: true }).click(); + await expect(sidebarNav(page).getByRole('link', { name: 'Team Wiki' })).not.toBeVisible(); + await expect(sidebarNav(page).getByRole('link', { name: 'Cluster Logs' })).toBeVisible(); + }); +}); diff --git a/e2e/sidebar-bookmarks.spec.ts b/e2e/sidebar-bookmarks.spec.ts index e361554e..873ab168 100644 --- a/e2e/sidebar-bookmarks.spec.ts +++ b/e2e/sidebar-bookmarks.spec.ts @@ -37,11 +37,18 @@ test.describe('Sidebar bookmarks', () => { await page.goto('/'); await waitForHydration(page); + // The pinned global bookmark from .env.test always shows Favourites, so check + // which list each of the user's bookmarks lands in. + const listWith = (name: string) => + sidebarNav(page) + .getByRole('list') + .filter({ has: page.getByRole('link', { name }) }); await expect(page.getByText('Favourites')).toBeVisible(); - await expect(sidebarNav(page).getByRole('link', { name: 'Pipelines' })).toBeVisible(); + await expect(listWith('Pipelines')).toBeVisible(); + await expect(listWith('Pipelines').getByRole('link', { name: 'Dashboards' })).toHaveCount(0); await expect(page.getByRole('button', { name: 'Tools', exact: true })).toBeVisible(); - await expect(sidebarNav(page).getByRole('link', { name: 'Dashboards' })).toBeVisible(); - await expect(sidebarNav(page).getByRole('link', { name: 'Trino' })).toBeVisible(); + await expect(listWith('Trino').getByRole('link', { name: 'Dashboards' })).toBeVisible(); + await expect(listWith('Trino').getByRole('link', { name: 'Pipelines' })).toHaveCount(0); }); test('opens each bookmark in a new tab', async ({ page }) => { diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 6df0fe73..90eacd04 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -10,9 +10,10 @@ import { requestLogger, logger } from '$lib/server/logging'; import { getConnectionFromHeader } from '$lib/server/storage/connection.js'; import { STORAGE_CONNECTION_ID_HEADER } from '$lib/storage/connection-id-header.js'; import { storageBrowserEnabled } from '$lib/server/feature-flags.js'; -// Imported for its side effect: parses the storage and PUBLIC_ client config at -// startup, so invalid values stop the server instead of failing the first request. +// Imported for their side effect: parse the storage, dashboard and PUBLIC_ client +// config at startup, so invalid values stop the server instead of failing the first request. import '$lib/server/storage/config.js'; +import '$lib/server/dashboard/config.js'; import { storageEncryptionKey } from '$lib/server/storage/encryption-key.js'; import { notConnected } from '$lib/server/storage/not-connected.js'; import { runMigrations } from '$lib/server/migrate.js'; diff --git a/src/lib/components/dashboard/BookmarkCard.svelte b/src/lib/components/dashboard/BookmarkCard.svelte new file mode 100644 index 00000000..7f4bc90c --- /dev/null +++ b/src/lib/components/dashboard/BookmarkCard.svelte @@ -0,0 +1,94 @@ + + +
+ + +
+ +
+ + {product.name} + + {#if bookmark.environment} + + {bookmark.environment} + + {/if} +
+
+ + {bookmarkHostname(bookmark.url)} + +
+
+ {#if onTogglePin} + + {/if} + {#if onEdit} + + {/if} +
diff --git a/src/lib/components/dashboard/DashboardBookmarks.svelte b/src/lib/components/dashboard/DashboardBookmarks.svelte index eb7288ec..e0d880d6 100644 --- a/src/lib/components/dashboard/DashboardBookmarks.svelte +++ b/src/lib/components/dashboard/DashboardBookmarks.svelte @@ -2,27 +2,32 @@ import { SvelteSet } from 'svelte/reactivity'; import * as m from '$lib/paraglide/messages.js'; import IconAdd from 'virtual:icons/material-symbols/add'; - import IconEdit from 'virtual:icons/material-symbols/edit'; - import IconStar from 'virtual:icons/material-symbols/star'; - import IconStarOutline from 'virtual:icons/material-symbols/star-outline'; import AddBookmarkModal from '$lib/components/dashboard/AddBookmarkModal.svelte'; + import BookmarkCard from '$lib/components/dashboard/BookmarkCard.svelte'; import { updateBookmark } from '$lib/dashboard/bookmarks-api.js'; import { addToast } from '$lib/stores/toast.svelte.js'; - import { getProduct } from '$lib/dashboard/products'; - import ProductIcon from '$lib/components/dashboard/ProductIcon.svelte'; - import { bookmarkHostname, type Bookmark } from '$lib/dashboard/types'; + import { bookmarksInDisplayOrder, type Bookmark, type BookmarkLink } from '$lib/dashboard/types'; let { - bookmarks + bookmarks, + globalBookmarks = [], + loadFailed = false }: { - /** Null when loading them failed: the section says so instead of listing them. */ + /** The user's bookmarks; null when unavailable, which leaves only the global ones. */ bookmarks: Bookmark[] | null; + /** Read-only bookmarks for everyone, shown before the user's. */ + globalBookmarks?: BookmarkLink[]; + /** Loading the user's bookmarks failed: the section says so. */ + loadFailed?: boolean; } = $props(); let addModalOpen = $state(false); let editingBookmark: Bookmark | null = $state(null); const pinning = new SvelteSet(); + const allBookmarks = $derived(bookmarksInDisplayOrder(globalBookmarks, bookmarks)); + const count = $derived(allBookmarks.length); + function openEditBookmark(bookmark: Bookmark) { editingBookmark = bookmark; addModalOpen = true; @@ -60,9 +65,9 @@ > {m.bookmark_section_title()} - {#if bookmarks?.length} + {#if count > 0} - {m.bookmark_count_links({ count: bookmarks.length })} + {m.bookmark_count_links({ count })} {/if} @@ -78,80 +83,27 @@ {/if} - {#if !bookmarks} -
{m.bookmark_load_error()}
- {:else if bookmarks.length > 0} + {#if loadFailed} +
{m.bookmark_load_error()}
+ {/if} + {#if count > 0}
- {#each bookmarks as bookmark (bookmark.id)} - {@const product = getProduct(bookmark.productId)} -
- - -
- -
- - {product.name} - - {#if bookmark.environment} - - {bookmark.environment} - - {/if} -
-
- - {bookmarkHostname(bookmark.url)} - -
-
- - -
+ {#each allBookmarks as bookmark (bookmark.id)} + + {@const own = bookmarks?.find((b) => b.id === bookmark.id)} + {#if own} + togglePin(own)} + onEdit={() => openEditBookmark(own)} + /> + {:else} + + {/if} {/each}
- {:else} + {:else if !loadFailed}

{m.dashboard_bookmarks_empty()}

diff --git a/src/lib/components/dashboard/DashboardBookmarks.svelte.spec.ts b/src/lib/components/dashboard/DashboardBookmarks.svelte.spec.ts index fb4cf75e..bc9cba8b 100644 --- a/src/lib/components/dashboard/DashboardBookmarks.svelte.spec.ts +++ b/src/lib/components/dashboard/DashboardBookmarks.svelte.spec.ts @@ -1,7 +1,7 @@ import { page } from 'vitest/browser'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { render } from 'vitest-browser-svelte'; -import type { Bookmark } from '$lib/dashboard/types'; +import type { Bookmark, BookmarkLink } from '$lib/dashboard/types'; import DashboardBookmarks from './DashboardBookmarks.svelte'; const { updateBookmark, addToast } = vi.hoisted(() => ({ @@ -27,6 +27,15 @@ const bookmark: Bookmark = { createdAt: '2026-01-01T00:00:00.000Z' }; +const globalBookmark: BookmarkLink = { + id: 'global-0', + productId: 'custom', + name: 'Team Wiki', + environment: '', + url: 'https://wiki.example.com', + pinned: true +}; + describe('DashboardBookmarks', () => { beforeEach(() => vi.clearAllMocks()); @@ -41,7 +50,7 @@ describe('DashboardBookmarks', () => { }); it('shows a message instead of the list, without adding, when loading failed', async () => { - render(DashboardBookmarks, { bookmarks: null }); + render(DashboardBookmarks, { bookmarks: null, loadFailed: true }); await expect .element(page.getByRole('heading', { level: 3, name: 'Bookmarks' })) @@ -55,6 +64,19 @@ describe('DashboardBookmarks', () => { await expect.element(page.getByText(/no bookmarks yet/i)).not.toBeInTheDocument(); }); + it('still shows the global bookmarks when loading the user’s failed', async () => { + render(DashboardBookmarks, { + bookmarks: null, + globalBookmarks: [globalBookmark], + loadFailed: true + }); + + await expect + .element(page.getByText('Bookmarks could not be loaded. Please reload the page later.')) + .toBeVisible(); + await expect.element(page.getByRole('link', { name: 'Team Wiki' })).toBeVisible(); + }); + it('shows an empty state when there are no bookmarks', async () => { render(DashboardBookmarks, { bookmarks: [] }); @@ -108,6 +130,37 @@ describe('DashboardBookmarks', () => { } }); + it('shows global bookmarks first, read-only, and counts them', async () => { + render(DashboardBookmarks, { bookmarks: [bookmark], globalBookmarks: [globalBookmark] }); + + const titles = page + .getByRole('link') + .elements() + .map((link) => link.textContent?.trim()); + expect(titles).toEqual(['Team Wiki', 'Shared Dashboard']); + await expect.element(page.getByText('2 links')).toBeVisible(); + await expect + .element(page.getByRole('button', { name: 'Edit bookmark Team Wiki' })) + .not.toBeInTheDocument(); + await expect + .element(page.getByRole('button', { name: /pin bookmark Team Wiki/i })) + .not.toBeInTheDocument(); + await expect + .element(page.getByRole('button', { name: 'Edit bookmark Shared Dashboard' })) + .toBeVisible(); + }); + + it('shows only the global bookmarks, without adding, when the user’s are unavailable', async () => { + render(DashboardBookmarks, { bookmarks: null, globalBookmarks: [globalBookmark] }); + + await expect.element(page.getByRole('link', { name: 'Team Wiki' })).toBeVisible(); + await expect + .element(page.getByRole('button', { name: /add bookmark/i })) + .not.toBeInTheDocument(); + await expect.element(page.getByText(/bookmarks are added by hand/i)).not.toBeInTheDocument(); + await expect.element(page.getByText(/no bookmarks yet/i)).not.toBeInTheDocument(); + }); + it('opens the add dialog', async () => { render(DashboardBookmarks, { bookmarks: [] }); diff --git a/src/lib/components/layout/sidebar/Sidebar.svelte b/src/lib/components/layout/sidebar/Sidebar.svelte index 25a68e79..ead78e8d 100644 --- a/src/lib/components/layout/sidebar/Sidebar.svelte +++ b/src/lib/components/layout/sidebar/Sidebar.svelte @@ -12,7 +12,7 @@ import { getPlatformSection, getToolsSection } from './nav-items.js'; import { getProduct } from '$lib/dashboard/products'; import ProductIcon from '$lib/components/dashboard/ProductIcon.svelte'; - import type { Bookmark } from '$lib/dashboard/types'; + import type { BookmarkLink } from '$lib/dashboard/types'; import stackableLogo from '$lib/logos/stackable.svg?enhanced'; import TooltipTrigger from '$lib/components/TooltipTrigger.svelte'; @@ -25,7 +25,8 @@ collapsed?: boolean; mobileOpen?: boolean; storageBrowserEnabled?: boolean; - bookmarks?: Bookmark[]; + /** Global bookmarks first, then the user's; both are plain links here. */ + bookmarks?: BookmarkLink[]; } = $props(); const platformSection = $derived(getPlatformSection()); @@ -154,12 +155,12 @@ {/snippet} -{#snippet bookmarkIcon(bookmark: Bookmark)} +{#snippet bookmarkIcon(bookmark: BookmarkLink)} {@const product = getProduct(bookmark.productId)} {/snippet} -{#snippet bookmarkItem(bookmark: Bookmark)} +{#snippet bookmarkItem(bookmark: BookmarkLink)}
  • { expect(bookmarkHostname('superset.example')).toBe('superset.example'); }); }); + +describe('bookmarksInDisplayOrder', () => { + const link = (id: string) => ({ + id, + productId: 'custom', + name: id, + environment: '', + url: 'https://example.com', + pinned: false + }); + + it('lists global bookmarks before the user’s own', () => { + const own = { ...link('own'), createdAt: '2026-10-27T10:00:00.000Z' }; + + expect(bookmarksInDisplayOrder([link('global')], [own]).map((b) => b.id)).toEqual([ + 'global', + 'own' + ]); + }); + + it('lists only the global bookmarks when the user’s are unavailable', () => { + expect(bookmarksInDisplayOrder([link('global')], null).map((b) => b.id)).toEqual(['global']); + }); +}); diff --git a/src/lib/dashboard/types.ts b/src/lib/dashboard/types.ts index 60c9f85f..62171b16 100644 --- a/src/lib/dashboard/types.ts +++ b/src/lib/dashboard/types.ts @@ -18,8 +18,11 @@ export const bookmarkFormSchema = z.object({ pinned: z.boolean() }); -/** A stored bookmark as returned by the API; `createdAt` is an ISO timestamp. */ -export type Bookmark = z.infer & { id: string; createdAt: string }; +/** The fields every bookmark is displayed with; the id keys it in lists. */ +export type BookmarkLink = z.infer & { id: string }; + +/** A user's stored bookmark as returned by the API; `createdAt` is an ISO timestamp. */ +export type Bookmark = BookmarkLink & { createdAt: string }; /** * Body of a bookmark create request. Stored bookmarks keep any product id, so a @@ -36,6 +39,17 @@ export const bookmarkUpdateSchema = bookmarkCreateSchema export type BookmarkUpdate = z.infer; +/** + * All bookmarks in the order the dashboard and the sidebar list them: the + * global ones first, then the user's own. + */ +export function bookmarksInDisplayOrder( + globalBookmarks: BookmarkLink[], + bookmarks: Bookmark[] | null +): BookmarkLink[] { + return [...globalBookmarks, ...(bookmarks ?? [])]; +} + /** Hostname of a bookmark URL, or the input unchanged if it does not parse. */ export function bookmarkHostname(url: string): string { try { diff --git a/src/lib/env-schema.test.ts b/src/lib/env-schema.test.ts index df99abe0..f083fb41 100644 --- a/src/lib/env-schema.test.ts +++ b/src/lib/env-schema.test.ts @@ -1,6 +1,13 @@ import { describe, it, expect } from 'vitest'; import { z } from 'zod'; -import { envBoolean, envInteger, envList, envOptionalInteger, parseEnv } from './env-schema.js'; +import { + envBoolean, + envInteger, + envJson, + envList, + envOptionalInteger, + parseEnv +} from './env-schema.js'; describe('envBoolean', () => { const schema = envBoolean(false); @@ -52,6 +59,27 @@ describe('envList', () => { }); }); +describe('envJson', () => { + const schema = envJson(z.array(z.number()), []); + + it('parses and validates the JSON', () => { + expect(schema.parse('[1, 2]')).toEqual([1, 2]); + expect(schema.safeParse('["a"]').success).toBe(false); + }); + + it('uses the default when unset or blank', () => { + expect(schema.parse(undefined)).toEqual([]); + expect(schema.parse(' ')).toEqual([]); + }); + + it('rejects invalid JSON without repeating the value', () => { + const result = schema.safeParse('[secret'); + expect(result.error?.issues).toEqual([ + expect.objectContaining({ message: 'must be valid JSON' }) + ]); + }); +}); + describe('parseEnv', () => { const schema = z.object({ A: envInteger(1), B: envBoolean(true) }); diff --git a/src/lib/env-schema.ts b/src/lib/env-schema.ts index 4a40f54d..623c4dc9 100644 --- a/src/lib/env-schema.ts +++ b/src/lib/env-schema.ts @@ -56,6 +56,29 @@ export function envList(defaultValue: string[], separator: RegExp | string = ',' ); } +/** JSON validated with `schema`, or `defaultValue` when unset. */ +export function envJson( + schema: T, + defaultValue: Exclude, undefined> +) { + return z.preprocess( + blankAsUnset, + z + .string() + .transform((value, ctx): unknown => { + try { + return JSON.parse(value); + } catch { + // No parser message: it can quote the value, which may hold credentials. + ctx.addIssue({ code: 'custom', message: 'must be valid JSON' }); + return z.NEVER; + } + }) + .pipe(schema) + .default(defaultValue) + ); +} + /** * Parses `env` with `schema`. Throws one error that names every invalid * variable, so a misconfigured deployment fails at startup with all problems diff --git a/src/lib/server/dashboard/config.test.ts b/src/lib/server/dashboard/config.test.ts new file mode 100644 index 00000000..22ef2617 --- /dev/null +++ b/src/lib/server/dashboard/config.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('$lib/server/logging', () => import('$lib/test-utils/mock-logger.js')); + +import { parseDashboardConfig } from './config.js'; + +const parseGlobalBookmarks = (value: string | undefined) => + parseDashboardConfig({ STACKABLE_COCKPIT_GLOBAL_BOOKMARKS: value }).globalBookmarks; + +const json = (value: unknown) => JSON.stringify(value); + +describe('parseDashboardConfig', () => { + it('has no global bookmarks when the variable is unset or blank', () => { + expect(parseGlobalBookmarks(undefined)).toEqual([]); + expect(parseGlobalBookmarks(' ')).toEqual([]); + }); + + it('parses bookmarks, numbers their ids and defaults the optional fields', () => { + const bookmarks = parseGlobalBookmarks( + json([ + { + productId: 'superset', + name: 'Dashboards', + environment: 'prod', + url: 'https://superset.example.com', + pinned: true + }, + { productId: 'custom', name: 'Team Wiki', url: 'https://wiki.example.com' } + ]) + ); + + expect(bookmarks).toEqual([ + { + id: 'global-0', + productId: 'superset', + name: 'Dashboards', + environment: 'prod', + url: 'https://superset.example.com', + pinned: true + }, + { + id: 'global-1', + productId: 'custom', + name: 'Team Wiki', + environment: '', + url: 'https://wiki.example.com', + pinned: false + } + ]); + }); + + it('rejects invalid JSON', () => { + expect(() => parseGlobalBookmarks('[{')).toThrow( + 'STACKABLE_COCKPIT_GLOBAL_BOOKMARKS: must be valid JSON' + ); + }); + + it('rejects anything but an array', () => { + expect(() => parseGlobalBookmarks(json({ name: 'Wiki' }))).toThrow( + /STACKABLE_COCKPIT_GLOBAL_BOOKMARKS: .*array/ + ); + }); + + it.each([ + ['a non-HTTP URL', { url: 'javascript:alert(1)' }, 'url'], + ['a missing name', { name: undefined }, 'name'], + ['a name above the length limit', { name: 'x'.repeat(41) }, 'name'], + ['a misspelt key', { pined: true }, 'pined'] + ])('rejects %s and names the entry and field', (_, override, field) => { + const entry = { productId: 'custom', name: 'Wiki', url: 'https://wiki.example.com' }; + + const parse = () => parseGlobalBookmarks(json([entry, { ...entry, ...override }])); + + expect(parse).toThrow('STACKABLE_COCKPIT_GLOBAL_BOOKMARKS.1'); + expect(parse).toThrow(field); + }); + + it('does not repeat the configured values in the error', () => { + const url = 'ftp://user:secret@files.example.com'; + + expect(() => parseGlobalBookmarks(json([{ productId: 'custom', name: 'Files', url }]))).toThrow( + expect.objectContaining({ message: expect.not.stringContaining('secret') }) + ); + }); +}); diff --git a/src/lib/server/dashboard/config.ts b/src/lib/server/dashboard/config.ts new file mode 100644 index 00000000..0e9dbfde --- /dev/null +++ b/src/lib/server/dashboard/config.ts @@ -0,0 +1,42 @@ +// Dashboard settings: configuration, not feature toggles (those are in +// `$lib/server/feature-flags`). Parsed once at module init. + +import { z } from 'zod'; +import { env } from '$env/dynamic/private'; +import { envJson, parseEnv } from '$lib/env-schema.js'; +import { logger } from '$lib/server/logging'; +import { bookmarkFormSchema, type BookmarkLink } from '$lib/dashboard/types'; + +const log = logger.child({ module: 'dashboard-config' }); + +// Strict, so a misspelt key such as "pined" fails instead of being ignored. An +// unknown productId is accepted and shown as a custom link, as for user bookmarks. +const globalBookmarkSchema = z.strictObject({ + ...bookmarkFormSchema.shape, + environment: bookmarkFormSchema.shape.environment.default(''), + pinned: bookmarkFormSchema.shape.pinned.default(false) +}); + +const schema = z.object({ + STACKABLE_COCKPIT_GLOBAL_BOOKMARKS: envJson(z.array(globalBookmarkSchema), []) +}); + +/** + * Throws on invalid configuration. The error names the failing entries and + * fields but not their values, as URLs can contain credentials. + */ +export function parseDashboardConfig(env: Record) { + const config = parseEnv(schema, env); + const globalBookmarks: BookmarkLink[] = config.STACKABLE_COCKPIT_GLOBAL_BOOKMARKS.map( + (bookmark, index) => ({ id: `global-${index}`, ...bookmark }) + ); + return { globalBookmarks }; +} + +const config = parseDashboardConfig(env); +log.info({ count: config.globalBookmarks.length }, 'Loaded global bookmarks'); + +/** Bookmarks shown to every user, before their own, on the dashboard and in the + * sidebar. Users cannot change them. + * Controlled by `STACKABLE_COCKPIT_GLOBAL_BOOKMARKS` (JSON array). Default: none. */ +export const globalBookmarks = config.globalBookmarks; diff --git a/src/routes/(app)/+layout.server.ts b/src/routes/(app)/+layout.server.ts index 2c13f544..3e47a053 100644 --- a/src/routes/(app)/+layout.server.ts +++ b/src/routes/(app)/+layout.server.ts @@ -1,12 +1,18 @@ import type { LayoutServerLoad } from './$types'; import { storageBrowserEnabled } from '$lib/server/feature-flags.js'; import { listBookmarks } from '$lib/server/dashboard/bookmarks-db.js'; +import { globalBookmarks } from '$lib/server/dashboard/config.js'; import { dashboardBookmarksTotal } from '$lib/server/metrics.js'; import { BOOKMARKS_DEPENDENCY, type Bookmark } from '$lib/dashboard/types'; export const load: LayoutServerLoad = async ({ locals, depends }) => { depends(BOOKMARKS_DEPENDENCY); - return { user: locals.user, storageBrowserEnabled, ...(await loadBookmarks(locals)) }; + return { + user: locals.user, + storageBrowserEnabled, + globalBookmarks, + ...(await loadBookmarks(locals)) + }; }; /** diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index 5aa94105..7e13224b 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -6,6 +6,7 @@ import Header from '$lib/components/layout/header/Header.svelte'; import ToastHost from '$lib/components/ToastHost.svelte'; import SessionExpiredModal from '$lib/components/layout/SessionExpiredModal.svelte'; + import { bookmarksInDisplayOrder } from '$lib/dashboard/types'; let { children, data } = $props(); @@ -78,7 +79,7 @@ bind:collapsed={sidebarCollapsed} bind:mobileOpen storageBrowserEnabled={data.storageBrowserEnabled} - bookmarks={data.bookmarks ?? []} + bookmarks={bookmarksInDisplayOrder(data.globalBookmarks, data.bookmarks)} />
    diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte index 99b18442..d459a9ba 100644 --- a/src/routes/(app)/+page.svelte +++ b/src/routes/(app)/+page.svelte @@ -8,9 +8,13 @@

    {m.dashboard_welcome()}

    - - {#if data.bookmarks || data.bookmarksFailed} - + + {#if data.bookmarks || data.bookmarksFailed || data.globalBookmarks.length > 0} + {/if}
    diff --git a/src/routes/(app)/layout.server.test.ts b/src/routes/(app)/layout.server.test.ts index 885c95ee..b6dfe0e9 100644 --- a/src/routes/(app)/layout.server.test.ts +++ b/src/routes/(app)/layout.server.test.ts @@ -4,9 +4,21 @@ const { listBookmarks } = vi.hoisted(() => ({ listBookmarks: vi.fn() })); vi.mock('$lib/server/dashboard/bookmarks-db.js', () => ({ listBookmarks })); vi.mock('$lib/server/feature-flags.js', () => ({ storageBrowserEnabled: false })); +vi.mock('$lib/server/dashboard/config.js', () => ({ globalBookmarks: [GLOBAL_BOOKMARK] })); import { load } from './+layout.server.js'; +const { GLOBAL_BOOKMARK } = vi.hoisted(() => ({ + GLOBAL_BOOKMARK: { + id: 'global-0', + productId: 'custom', + name: 'Team Wiki', + environment: '', + url: 'https://wiki.example.com', + pinned: true + } +})); + const BOOKMARK = { id: '00000000-0000-0000-0000-000000000001', productId: 'trino', @@ -33,15 +45,23 @@ describe('(app) layout load', () => { const data = await load(event); - expect(data).toMatchObject({ bookmarks: [BOOKMARK], bookmarksFailed: false }); + expect(data).toMatchObject({ + bookmarks: [BOOKMARK], + bookmarksFailed: false, + globalBookmarks: [GLOBAL_BOOKMARK] + }); expect(listBookmarks).toHaveBeenCalledWith('user-1'); expect(event.depends).toHaveBeenCalledWith('app:bookmarks'); }); - it('has no bookmarks without a signed-in user', async () => { + it('has only the global bookmarks without a signed-in user', async () => { const data = await load(mockEvent(undefined)); - expect(data).toMatchObject({ bookmarks: null, bookmarksFailed: false }); + expect(data).toMatchObject({ + bookmarks: null, + bookmarksFailed: false, + globalBookmarks: [GLOBAL_BOOKMARK] + }); expect(listBookmarks).not.toHaveBeenCalled(); }); @@ -51,7 +71,11 @@ describe('(app) layout load', () => { const data = await load(event); - expect(data).toMatchObject({ bookmarks: null, bookmarksFailed: true }); + expect(data).toMatchObject({ + bookmarks: null, + bookmarksFailed: true, + globalBookmarks: [GLOBAL_BOOKMARK] + }); expect(event.locals.logger.error).toHaveBeenCalled(); }); }); diff --git a/src/routes/(app)/page.svelte.spec.ts b/src/routes/(app)/page.svelte.spec.ts index 2f1ce1a8..a18519e7 100644 --- a/src/routes/(app)/page.svelte.spec.ts +++ b/src/routes/(app)/page.svelte.spec.ts @@ -1,12 +1,30 @@ import { page } from 'vitest/browser'; import { describe, expect, it } from 'vitest'; import { render } from 'vitest-browser-svelte'; -import type { Bookmark } from '$lib/dashboard/types'; +import type { Bookmark, BookmarkLink } from '$lib/dashboard/types'; import Page from './+page.svelte'; -const renderPage = (bookmarks: Bookmark[] | null = [], bookmarksFailed = false) => +const globalBookmark: BookmarkLink = { + id: 'global-0', + productId: 'custom', + name: 'Team Wiki', + environment: '', + url: 'https://wiki.example.com', + pinned: false +}; + +const renderPage = ( + bookmarks: Bookmark[] | null = [], + { globalBookmarks = [] as BookmarkLink[], bookmarksFailed = false } = {} +) => render(Page, { - data: { user: undefined, storageBrowserEnabled: false, bookmarks, bookmarksFailed } + data: { + user: undefined, + storageBrowserEnabled: false, + bookmarks, + bookmarksFailed, + globalBookmarks + } } as unknown as Parameters>[1]); describe('/(app)/+page.svelte', () => { @@ -23,7 +41,7 @@ describe('/(app)/+page.svelte', () => { }); it('says so when loading the bookmarks failed', async () => { - renderPage(null, true); + renderPage(null, { bookmarksFailed: true }); await expect .element(page.getByText('Bookmarks could not be loaded. Please reload the page later.')) @@ -33,7 +51,16 @@ describe('/(app)/+page.svelte', () => { .not.toBeInTheDocument(); }); - it('hides everything bookmark-related without a signed-in user', async () => { + it('shows the global bookmarks when the user’s are unavailable', async () => { + renderPage(null, { globalBookmarks: [globalBookmark] }); + + await expect.element(page.getByRole('link', { name: 'Team Wiki' })).toBeVisible(); + await expect + .element(page.getByRole('button', { name: /add bookmark/i })) + .not.toBeInTheDocument(); + }); + + it('hides everything bookmark-related without a user or global bookmarks', async () => { renderPage(null); await expect.element(page.getByRole('heading', { level: 2 })).toBeInTheDocument();