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 @@
+
+
+