diff --git a/.env.example b/.env.example index 815c3004..4cbd5f80 100644 --- a/.env.example +++ b/.env.example @@ -58,6 +58,10 @@ 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. Every user can see the URLs in the page source, +# so they must not contain a user name, password, token or other secret. +# 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/CHANGELOG.md b/CHANGELOG.md index 9742cb7a..cc022280 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,5 +8,5 @@ All notable changes to this project will be documented in this file. - Trino SQL query editor. - S3 file browser. -- Bookmarks. +- Bookmarks, including global bookmarks set by the administrator. - OIDC login with user impersonation in Trino. diff --git a/README.md b/README.md index 1e2b6c16..f13ad6ba 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. URLs must not contain a user name or password, and are visible to every user in the page source, so do not put tokens or other secrets in them. 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..1dbba728 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. URLs must not contain a user name or password, and are visible to every user in the page source, so do not put tokens or other secrets in them. 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..872b8ba9 100644 --- a/deploy/helm/cockpit/values.yaml +++ b/deploy/helm/cockpit/values.yaml @@ -189,6 +189,20 @@ 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. Every user can see the URLs in the page source, so they + # must not contain a user name, password, token or other secret. + 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/messages/de.json b/messages/de.json index bd7844fb..de8b46a5 100644 --- a/messages/de.json +++ b/messages/de.json @@ -509,7 +509,7 @@ "bookmark_env_placeholder": "z.B. dev, prod", "bookmark_url_label": "URL", "bookmark_url_placeholder": "https://superset.example.com", - "bookmark_url_error": "Geben Sie eine gültige HTTP- oder HTTPS-URL ein.", + "bookmark_url_error": "Geben Sie eine gültige HTTP- oder HTTPS-URL ohne Benutzername und Passwort ein.", "bookmark_pin_label": "Lesezeichen anheften", "bookmark_preview_label": "Vorschau", "bookmark_section_title": "Lesezeichen", diff --git a/messages/en.json b/messages/en.json index 4a25cd94..977ea52f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -509,7 +509,7 @@ "bookmark_env_placeholder": "e.g. dev, prod", "bookmark_url_label": "URL", "bookmark_url_placeholder": "https://superset.example.com", - "bookmark_url_error": "Enter a valid HTTP or HTTPS URL.", + "bookmark_url_error": "Enter a valid HTTP or HTTPS URL without a user name or password.", "bookmark_pin_label": "Pin bookmark", "bookmark_preview_label": "Preview", "bookmark_section_title": "Bookmarks", diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 29787893..5429e994 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -10,10 +10,11 @@ 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 their side effect: parse the storage, PUBLIC_ client and Trino -// config at startup, so invalid values stop the server instead of failing the -// first request. +// Imported for their side effect: parse the storage, dashboard, PUBLIC_ client +// and Trino 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 '$lib/server/trino/config.js'; import { storageEncryptionKey } from '$lib/server/storage/encryption-key.js'; import { notConnected } from '$lib/server/storage/not-connected.js'; diff --git a/src/lib/components/dashboard/AddBookmarkModal.svelte.spec.ts b/src/lib/components/dashboard/AddBookmarkModal.svelte.spec.ts index 6f091e0b..fe9ba659 100644 --- a/src/lib/components/dashboard/AddBookmarkModal.svelte.spec.ts +++ b/src/lib/components/dashboard/AddBookmarkModal.svelte.spec.ts @@ -90,7 +90,9 @@ describe('AddBookmarkModal', () => { await page.getByLabelText('URL').fill('not a URL'); await userEvent.keyboard('{Enter}'); - await expect.element(page.getByText('Enter a valid HTTP or HTTPS URL.')).toBeVisible(); + await expect + .element(page.getByText('Enter a valid HTTP or HTTPS URL without a user name or password.')) + .toBeVisible(); expect(addBookmark).not.toHaveBeenCalled(); }); @@ -101,7 +103,9 @@ describe('AddBookmarkModal', () => { await page.getByLabelText('URL').fill('not a URL'); await page.getByRole('button', { name: 'Add bookmark' }).click(); - await expect.element(page.getByText('Enter a valid HTTP or HTTPS URL.')).toBeVisible(); + await expect + .element(page.getByText('Enter a valid HTTP or HTTPS URL without a user name or password.')) + .toBeVisible(); await expect.element(page.getByLabelText('URL')).toHaveAttribute('aria-invalid', 'true'); expect(addBookmark).not.toHaveBeenCalled(); }); @@ -113,7 +117,9 @@ describe('AddBookmarkModal', () => { await page.getByLabelText('URL').fill('javascript:alert(1)'); await page.getByRole('button', { name: 'Add bookmark' }).click(); - await expect.element(page.getByText('Enter a valid HTTP or HTTPS URL.')).toBeVisible(); + await expect + .element(page.getByText('Enter a valid HTTP or HTTPS URL without a user name or password.')) + .toBeVisible(); await expect.element(page.getByLabelText('URL')).toHaveAttribute('aria-invalid', 'true'); expect(addBookmark).not.toHaveBeenCalled(); }); @@ -160,7 +166,9 @@ describe('AddBookmarkModal', () => { await page.getByLabelText('URL').fill('superset.corp'); await page.getByLabelText('Name').click(); - await expect.element(page.getByText('Enter a valid HTTP or HTTPS URL.')).toBeVisible(); + await expect + .element(page.getByText('Enter a valid HTTP or HTTPS URL without a user name or password.')) + .toBeVisible(); }); it('prefills the default name of the preselected product', async () => { 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)}
  • { 'data:text/html,', 'ftp://files.example.com', 'superset.corp', - 'http://' + 'http://', + 'https://admin:secret@superset.example.com', + 'https://token@superset.example.com', + 'https://:secret@superset.example.com' ])('rejects %s', (url) => { expect(urlSchema.safeParse(url).success).toBe(false); }); @@ -37,3 +40,27 @@ describe('bookmarkHostname', () => { 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..9e583a54 100644 --- a/src/lib/dashboard/types.ts +++ b/src/lib/dashboard/types.ts @@ -14,12 +14,25 @@ export const bookmarkFormSchema = z.object({ productId: z.string().min(1).max(64), name: z.string().trim().min(1).max(BOOKMARK_MAX_LENGTH.name), environment: z.string().trim().max(BOOKMARK_MAX_LENGTH.environment), - url: z.url({ protocol: /^https?$/ }).max(BOOKMARK_MAX_LENGTH.url), + // No user name or password: bookmark URLs are sent to the browser, and global + // ones to every user. + url: z + .url({ protocol: /^https?$/ }) + .max(BOOKMARK_MAX_LENGTH.url) + // Zod runs this even when the URL check above failed, which reports that case. + .refine((url) => { + if (!URL.canParse(url)) return true; + const { username, password } = new URL(url); + return !username && !password; + }, 'URL must not contain a user name or password'), 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 +49,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 703197e0..3cc320c6 100644 --- a/src/lib/env-schema.test.ts +++ b/src/lib/env-schema.test.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { envBoolean, envInteger, + envJson, envList, envOptionalInteger, envUrl, @@ -71,6 +72,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 d36de482..ce834eb6 100644 --- a/src/lib/env-schema.ts +++ b/src/lib/env-schema.ts @@ -59,6 +59,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..700a5c58 --- /dev/null +++ b/src/lib/server/dashboard/config.test.ts @@ -0,0 +1,86 @@ +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'], + ['credentials in the URL', { url: 'https://user:secret@wiki.example.com' }, '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 = 'https://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();