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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
6 changes: 6 additions & 0 deletions deploy/helm/cockpit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions deploy/helm/cockpit/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
13 changes: 13 additions & 0 deletions deploy/helm/cockpit/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 0 additions & 1 deletion e2e/bookmarks.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
57 changes: 57 additions & 0 deletions e2e/global-bookmarks.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
13 changes: 10 additions & 3 deletions e2e/sidebar-bookmarks.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
5 changes: 3 additions & 2 deletions src/hooks.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
94 changes: 94 additions & 0 deletions src/lib/components/dashboard/BookmarkCard.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
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 { getProduct } from '$lib/dashboard/products';
import ProductIcon from '$lib/components/dashboard/ProductIcon.svelte';
import { bookmarkHostname, type BookmarkLink } from '$lib/dashboard/types';

let {
bookmark,
pinPending = false,
onTogglePin,
onEdit
}: {
bookmark: BookmarkLink;
pinPending?: boolean;
/** Without handlers the card is read-only (global bookmarks). */
onTogglePin?: () => void;
onEdit?: () => void;
} = $props();

const product = $derived(getProduct(bookmark.productId));
</script>

<div class="border-base-300 bg-base-100 flex items-center gap-3 rounded-lg border p-3">
<!-- Mouse shortcut only: duplicates the title link, so it is hidden from
assistive technology and the tab order. -->
<a
href={bookmark.url}
target="_blank"
rel="external noopener noreferrer"
class="shrink-0"
tabindex="-1"
aria-hidden="true"
>
<ProductIcon {product} size="md" />
</a>
<div class="min-w-0 flex-1">
<div class="min-w-0">
<a
href={bookmark.url}
target="_blank"
rel="external noopener noreferrer"
class="text-base-content block truncate text-[13.5px] leading-tight font-bold"
>
{bookmark.name}
</a>
</div>
<div class="mt-1 flex flex-wrap items-center gap-1.5">
<span class="bg-base-300 text-muted rounded-full px-2 py-0.5 text-[11px]">
{product.name}
</span>
{#if bookmark.environment}
<span class="bg-base-300 text-muted rounded-full px-2 py-0.5 text-[11px]">
{bookmark.environment}
</span>
{/if}
</div>
<div class="min-w-0">
<span class="text-base-content/70 block truncate font-mono text-[11.5px]">
{bookmarkHostname(bookmark.url)}
</span>
</div>
</div>
{#if onTogglePin}
<button
type="button"
class="btn btn-ghost btn-circle btn-sm size-7 shrink-0"
aria-label={bookmark.pinned
? m.bookmark_card_unpin({ name: bookmark.name })
: m.bookmark_card_pin({ name: bookmark.name })}
class:btn-disabled={pinPending}
aria-disabled={pinPending}
onclick={onTogglePin}
>
{#if bookmark.pinned}
<IconStar class="text-warning size-3.5" />
{:else}
<IconStarOutline class="size-3.5" />
{/if}
</button>
{/if}
{#if onEdit}
<button
type="button"
class="btn btn-ghost btn-circle btn-sm size-7 shrink-0"
aria-label={m.bookmark_card_edit({ name: bookmark.name })}
onclick={onEdit}
>
<IconEdit class="size-3.5" />
</button>
{/if}
</div>
Loading
Loading