From fa253976e213c1053d1832c1d1fb7bedf7ce8c45 Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:52:41 +0200 Subject: [PATCH 01/37] feat: isolate the raw-app editor preview when the app opts into sandboxing (#11572) * feat: isolate the raw-app editor preview when the app opts into sandboxing Co-Authored-By: Claude Opus 5.5 * fix: gate job reads from a sandboxed editor preview like the deployed app Co-Authored-By: Claude Opus 5.5 * fix: send a plain user in a sandboxed raw app's startup message Co-Authored-By: Claude Opus 5.5 * chore: pin ui_builder to d3fdb70 Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- backend/windmill-api/src/static_assets.rs | 4 +- frontend/package-lock.json | 7 - frontend/package.json | 1 - frontend/scripts/ui_builder_artifact.json | 4 +- .../raw_apps/RawAppBackgroundRunner.svelte | 28 +- .../components/raw_apps/RawAppEditor.svelte | 571 +++++++++--------- .../components/raw_apps/RawAppPreview.svelte | 43 +- frontend/src/lib/components/raw_apps/utils.ts | 32 + 8 files changed, 367 insertions(+), 323 deletions(-) diff --git a/backend/windmill-api/src/static_assets.rs b/backend/windmill-api/src/static_assets.rs index 78c2b48f3b..e469e91249 100644 --- a/backend/windmill-api/src/static_assets.rs +++ b/backend/windmill-api/src/static_assets.rs @@ -103,7 +103,9 @@ fn serve_path(path: &str, original_path: &str, query: Option<&str>) -> Response< // The raw-app preview shell evaluates whatever js is posted to it, so it is // only ever served opaque-origin, with the deployed app wrapper's exact flags // (`get_raw_app_data`). Matched on the embedded file, not the request path, - // so `//ui_builder/...` is covered. The editor loads a same-origin blob: copy. + // so `//ui_builder/...` is covered. A sandboxed app's editor preview loads it + // as served, framed with the same flags (`RAW_APP_SANDBOX_FLAGS` in the + // frontend); an unsandboxed one loads a same-origin blob: copy. if path == "ui_builder/app-preview.html" { res = res.header( header::CONTENT_SECURITY_POLICY, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dfb2336b70..894d7067ab 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -61,7 +61,6 @@ "mdast-util-find-and-replace": "^3.0.2", "mermaid": "^11.16.1", "minimatch": "^10.2.3", - "modern-screenshot": "^4.7.0", "monaco-editor": "npm:@codingame/monaco-vscode-editor-api@=25.0.0", "monaco-languageclient": "10.6.0", "monaco-vim": "^0.4.1", @@ -9657,12 +9656,6 @@ "dev": true, "license": "MIT" }, - "node_modules/modern-screenshot": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/modern-screenshot/-/modern-screenshot-4.7.0.tgz", - "integrity": "sha512-9YxN+ddPSMMlhylOv25VHzXrl9u67QRxoh7+SEewGtgUw7t6hHTrjptSDJUSne9oG4Xk/h2cwG15nIt4Hc9ujg==", - "license": "MIT" - }, "node_modules/monaco-editor": { "name": "@codingame/monaco-vscode-editor-api", "version": "25.0.0", diff --git a/frontend/package.json b/frontend/package.json index 1785e9b2ad..22f7723b2d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -127,7 +127,6 @@ "mdast-util-find-and-replace": "^3.0.2", "mermaid": "^11.16.1", "minimatch": "^10.2.3", - "modern-screenshot": "^4.7.0", "monaco-editor": "npm:@codingame/monaco-vscode-editor-api@=25.0.0", "monaco-languageclient": "10.6.0", "monaco-vim": "^0.4.1", diff --git a/frontend/scripts/ui_builder_artifact.json b/frontend/scripts/ui_builder_artifact.json index 8940f265ce..c82e327126 100644 --- a/frontend/scripts/ui_builder_artifact.json +++ b/frontend/scripts/ui_builder_artifact.json @@ -1,5 +1,5 @@ { "baseUrl": "https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev", - "version": "9fddc52", - "sha256": "b45558951f3d4c9dce7b88b5139d66b0f5f01fe561a22efcb0b8400a13240522" + "version": "d3fdb70", + "sha256": "8c894b3f02d5d1872a966c37d43314f05ce42900f7330bdcf45a18a772deb2b9" } diff --git a/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte b/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte index 6cdcd6a427..c3eadc1f68 100644 --- a/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte @@ -5,6 +5,7 @@ import type { JobById } from '../apps/types' import { JobService } from '$lib/gen' import type { Runnable } from './rawAppPolicy' + import { RAW_APP_PREVIEW_RELAY } from './utils' import { undefinedIfEmpty } from '$lib/utils' interface Props { @@ -19,10 +20,9 @@ * Restrict waitJob/getJob/streamJob to job ids launched by this app * instance (WIN-2006): a SANDBOXED bundle must not read arbitrary * workspace jobs through the credentialed bridge. Off for unsandboxed - * renders (the default, and editor preview) — there the bundle holds - * the same credential as the bridge, so gating adds nothing and would - * only break unsandboxed apps that poll persisted or runnable-returned - * job ids. + * renders — there the bundle holds the same credential as the bridge, + * so gating adds nothing and would only break unsandboxed apps that + * poll persisted or runnable-returned job ids. */ gateJobIds?: boolean /** @@ -32,7 +32,7 @@ * `window.opener` (this window), so the bridge must accept its * `event.source` and reply to it. A getter so it tracks the live handle * without a reactive prop. Editor-only — the detached window runs the - * same unsandboxed bundle as the inline preview. + * same bundle, sandboxed or not, as the inline preview. */ extraSourceWindow?: () => Window | null | undefined } @@ -62,7 +62,11 @@ if (!iframe || !sourceWindow) return if (sourceWindow !== iframe.contentWindow && sourceWindow !== detachedWindow) return - const data = event.data + // A sandboxed detached preview speaks through a same-origin relay, wrapped. + const data = + sourceWindow === detachedWindow && event.data?.type === RAW_APP_PREVIEW_RELAY + ? event.data.data + : event.data // Reply to whichever window sent the request (inline iframe or the // detached preview), not a hardcoded target — otherwise the detached @@ -81,7 +85,7 @@ result = e } - if (event.data.type == 'backend' || event.data.type == 'waitJob') { + if (data.type == 'backend' || data.type == 'waitJob') { respond({ result, error }) } if (editor) { @@ -101,7 +105,7 @@ } return result } - if (event.data.type == 'backend' || event.data.type == 'backendAsync') { + if (data.type == 'backend' || data.type == 'backendAsync') { const runnable_id = data.runnable_id let runnable = runnables[runnable_id] if (runnable) { @@ -152,7 +156,7 @@ ) launchedJobs.add(uuid) let job: JobById = { component: runnable_id, created_at: Date.now(), job: uuid } - if (event.data.type == 'backendAsync') { + if (data.type == 'backendAsync') { let result = uuid respond({ result }) } @@ -169,20 +173,20 @@ } else { console.error('No runnable found for', runnable_id) } - } else if (event.data.type == 'waitJob') { + } else if (data.type == 'waitJob') { if (gateJobIds && !launchedJobs.has(data.jobId)) { respond({ result: { message: 'Unknown job' }, error: true }) return } await respondWithResult(data.jobId) - } else if (event.data.type == 'getJob') { + } else if (data.type == 'getJob') { if (gateJobIds && !launchedJobs.has(data.jobId)) { respond({ result: { message: 'Unknown job' }, error: true }) return } const job = await JobService.getJob({ workspace, id: data.jobId }) respond({ result: job }) - } else if (event.data.type == 'streamJob') { + } else if (data.type == 'streamJob') { // Stream job results using SSE const jobId = data.jobId const reqId = data.reqId diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index d74c3f7791..8a0655d70e 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -26,7 +26,12 @@ type RawAppRuntimeLogRequester, type RawAppRunSummary, type RawAppRunsProvider, - type RawAppScreenshotRequester + type RawAppScreenshotRequester, + RAW_APP_SANDBOX_FLAGS, + RAW_APP_PREVIEW_RELAY, + rawAppStorageKey, + readRawAppStorage, + applyRawAppStorageOp } from './utils' import { runDomQueryOnHtml, type RawAppDomQuery, type RawAppDomRequester } from './rawAppDom' import InlineElementPrompt from './InlineElementPrompt.svelte' @@ -72,7 +77,7 @@ appDatatableRole } from './dataTableRefUtils' import { datatableReference } from '../dbTypes' - import { randomUUID } from '$lib/utils/uuid' + import { randomSecret, randomUUID } from '$lib/utils/uuid' import { editorFontSize } from '$lib/editorFontSize.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' @@ -370,11 +375,11 @@ let iframe: HTMLIFrameElement | undefined = $state(undefined) const PREVIEW_SHELL_URL = '/ui_builder/app-preview.html' - // The served shell is `CSP: sandbox` (opaque origin), but the editor reads the - // preview's DOM and feeds it builds as a same-origin document, so it loads a - // per-mount blob: copy instead — the same split as `unsandboxedRawAppHtml`, and - // like it with no ``, so the app resolves URLs as it will once deployed. - let previewShellUrl: string | undefined = $state(undefined) + // The served shell is `CSP: sandbox` (opaque origin). An app without sandbox + // isolation runs same-origin once deployed, so its preview loads a per-mount + // blob: copy instead — the same split as `unsandboxedRawAppHtml`, and like it + // with no ``, so the app resolves URLs as it will once deployed. + let previewBlobUrl: string | undefined = $state(undefined) onMount(() => { let url: string | undefined let destroyed = false @@ -386,7 +391,7 @@ .then((html) => { if (destroyed) return url = URL.createObjectURL(new Blob([html], { type: 'text/html' })) - previewShellUrl = url + previewBlobUrl = url }) .catch((e) => sendUserToast(`Could not load the app preview: ${e}`, true)) return () => { @@ -394,8 +399,50 @@ if (url) URL.revokeObjectURL(url) } }) + // An app with sandbox isolation previews the way it runs once deployed: in the + // served, opaque-origin shell, framed with the deployed wrapper's flags. The nonce + // in its URL authenticates its `windmill:ready`, as in `RawAppPreview`. + const sandboxed = $derived(policy?.sandbox === true) + const previewStorageKey = $derived(rawAppStorageKey(opWorkspace ?? '', path, true)) + const handshakeNonce = randomSecret() + const sandboxedShellUrl = `${PREVIEW_SHELL_URL}?wm_hs=${handshakeNonce}` + const previewShellUrl = $derived(sandboxed ? sandboxedShellUrl : previewBlobUrl) + // The pop-out of a sandboxed preview: a same-origin page framing the shell. A + // top-level window can't be sandboxed by its opener, and the opener can't address + // an opaque-origin window safely. It relays the frame's messages, and their + // ports, to the editor, and the editor's replies back down. Relayed messages + // carry our origin, so they travel wrapped: listeners that trust a same-origin + // message must not see the isolated app's as their own. + let previewControllerUrl: string | undefined = undefined + onDestroy(() => { + if (previewControllerUrl) URL.revokeObjectURL(previewControllerUrl) + }) + function getPreviewControllerUrl(): string { + previewControllerUrl ??= URL.createObjectURL( + new Blob( + [ + `App preview + + + - - Where dbt projects in this workspace run. A project names a warehouse by name in its descriptor (profile.warehouse) and reaches - {DEFAULT_WAREHOUSE} when it names none, so a project carries no - connection of its own. The name is also what its tables are keyed on in the asset graph (dbt://{DEFAULT_WAREHOUSE}/schema/table), so two projects on one warehouse share their nodes. Each entry points either at one of - Windmill's own connection resources, whose fields are translated into a dbt target, or at a - dbt_profile resource, which carries a - profiles.yml target as it is and so reaches any adapter dbt has. Configuring - one here is what makes it available: anyone who may run a dbt script builds with it and reads its models, - without being granted the resource, the same bargain workspace object storage makes. - +profile.warehouse in its descriptor, and gets ${DEFAULT_WAREHOUSE} when it names none. Point each warehouse at a Windmill connection resource, or at a dbt_profile resource holding a profiles.yml target for any other adapter. Anyone who can run a dbt script builds with these warehouses without being granted the resource.`} + link="https://www.windmill.dev/docs/getting_started/scripts_quickstart/dbt" +/> @@ -154,14 +146,10 @@ class="min-w-24" /> - - +
+ +
From 71901852fb3cc40515d6675a0a88cd158e942d56 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 7 Oct 2026 11:57:03 +0200 Subject: [PATCH 04/37] feat: set up a new app in one modal over its AI session, fix its deploy (#11582) * fix: set up a new app before starting with AI and follow its deploy Co-Authored-By: Claude Opus 5.5 (1M context) * feat: set up a new app in one modal over its ai session Co-Authored-By: Claude Opus 5.5 (1M context) * fix: show schema creation progress and put the new app modal over the page Co-Authored-By: Claude Opus 5.5 (1M context) * fix: say when no worker can create a new app's schema instead of waiting Co-Authored-By: Claude Opus 5.5 (1M context) * fix: drop the summary from the new app modal Co-Authored-By: Claude Opus 5.5 (1M context) * feat: list existing tables only under an existing schema, explore from the schema row Co-Authored-By: Claude Opus 5.5 (1M context) * feat: start a new app from two cards, with ai recommended Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../components/common/ViewportOverlays.svelte | 10 + .../components/common/overlayHost.svelte.ts | 6 + .../copilot/chat/global/core.test.ts | 1 + .../components/copilot/chat/global/core.ts | 28 +- .../raw_apps/RawAppDataTableDrawer.svelte | 9 +- .../raw_apps/RawAppTemplatePicker.svelte | 661 ++++++++++-------- .../components/sessions/PreviewTabHost.svelte | 16 +- .../sessions/RawAppEditorView.svelte | 93 ++- .../sessions/SessionEditorTarget.svelte | 10 +- .../sessions/sessionPreviewTabs.svelte.ts | 20 + .../sessions/sessionPreviewTabs.test.ts | 15 + .../sessions/sessionRuntime.svelte.ts | 47 +- .../sessions/sessionSwitch.svelte.ts | 20 +- .../(root)/(logged)/apps_raw/add/+page.ts | 37 +- .../apps_raw/edit/[...path]/+page.svelte | 56 +- 15 files changed, 639 insertions(+), 390 deletions(-) create mode 100644 frontend/src/lib/components/common/ViewportOverlays.svelte diff --git a/frontend/src/lib/components/common/ViewportOverlays.svelte b/frontend/src/lib/components/common/ViewportOverlays.svelte new file mode 100644 index 0000000000..59dc2e55b6 --- /dev/null +++ b/frontend/src/lib/components/common/ViewportOverlays.svelte @@ -0,0 +1,10 @@ + + +{@render children?.()} diff --git a/frontend/src/lib/components/common/overlayHost.svelte.ts b/frontend/src/lib/components/common/overlayHost.svelte.ts index e556b51e67..824e239519 100644 --- a/frontend/src/lib/components/common/overlayHost.svelte.ts +++ b/frontend/src/lib/components/common/overlayHost.svelte.ts @@ -25,6 +25,12 @@ export function setOverlayHost(host: OverlayHostContext) { setContext(OVERLAY_HOST_KEY, host) } +/** Hand this subtree's overlays back to the viewport, for one that speaks for the whole + * page rather than the pane it happens to be opened from. */ +export function clearOverlayHost() { + setContext(OVERLAY_HOST_KEY, undefined) +} + export function getOverlayHost(): OverlayHostContext | undefined { return getContext(OVERLAY_HOST_KEY) } diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index dd89a1fbec..692c78bfb8 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -4709,6 +4709,7 @@ describe('global AI tools', () => { expect(onDeployed).toHaveBeenCalledWith({ sessionId: 'sess-123', kind: 'raw_app', + storagePath: 'f/apps/report', path: 'f/apps/report' }) } finally { diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index e75f15d1eb..41bbad5abe 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -5038,6 +5038,9 @@ function getSessionScreenshot(sessionId: string | undefined): Promise void @@ -8651,24 +8654,29 @@ async function deployDraft( // synthetic storage key never exists deployed, so the entry would otherwise // stop matching anything after the draft is gone. const deployedKind = itemKindFor(type, triggerKind) + const draftStoragePath = getGlobalDraftStoragePath(workspace, type, path, triggerKind) if (deployedKind) { - toolCallbacks.onItemDeployed?.( - deployedKind, - getGlobalDraftStoragePath(workspace, type, path, triggerKind), - deployedPath - ) + toolCallbacks.onItemDeployed?.(deployedKind, draftStoragePath, deployedPath) } - // Reload the session preview if it's open on the deployed item. Map the - // deploy type to the preview kind — a raw app deploys under 'app' but the - // preview addresses it as 'raw_app'; non-previewable types map to undefined. + // Reload the session preview if it's open on the deployed item, which its editor + // knows by the draft's storage path. Map the deploy type to the preview kind — a + // raw app deploys under 'app' but the preview addresses it as 'raw_app'; + // non-previewable types map to undefined. const previewKindByType: Partial> = { script: 'script', flow: 'flow', app: 'raw_app' } const kind = previewKindByType[type] - if (kind) deployedInSessionHandler?.({ sessionId, kind, path }) + if (kind) { + deployedInSessionHandler?.({ + sessionId, + kind, + storagePath: draftStoragePath, + path: deployedPath + }) + } toolCallbacks.setToolStatus(toolId, { content: `Deployed ${type} "${path}"`, @@ -8942,8 +8950,6 @@ export function prepareGlobalUserMessage( content += `path: ${activeEditor.path}\n` content += `isLiveDraft: true\n` if (activeEditor.isNew) { - // The template only lives in the open editor until the first edit saves it, so - // init_app would see no draft and overwrite it with a second app. content += 'isNew: true — the user just started this item from the new-item builder; it holds the starter template and is the item to build. Edit it in place; do not create another one.\n' } diff --git a/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte b/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte index 7ccf89a275..af0af7ed5b 100644 --- a/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte @@ -256,11 +256,14 @@ selectedRole = undefined } - export function openDrawer() { + /** Opens on `at` when given, a data table and optionally one of its schemas. */ + export function openDrawer(at?: { datatable: string; schema?: string }) { resetSelection() - selectedSchemaKey = undefined + selectedSchemaKey = at?.schema selectedTableKey = undefined - selectDatatable(datatables.current.includes('main') ? 'main' : datatables.current[0]) + selectDatatable( + at?.datatable ?? (datatables.current.includes('main') ? 'main' : datatables.current[0]) + ) expand = false open = true } diff --git a/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte b/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte index 27ebdbda85..0928ddce7a 100644 --- a/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte @@ -6,13 +6,13 @@ List, Ban, ExternalLinkIcon, - Loader2, Code, - MessagesSquare, - WandSparkles + Database, + Loader2 } from 'lucide-svelte' - import { AIBtnClasses } from '$lib/components/copilot/chat/AIButtonStyle' - import type { Policy } from '$lib/gen' + import { WorkerService, type Policy } from '$lib/gen' + import MissingWorkerTagAlert from '$lib/components/jobs/MissingWorkerTagAlert.svelte' + import { hasWorkerForTag, queuedWithoutWorkerMessage } from '$lib/components/jobs/missingWorker' import { superadmin, userStore } from '$lib/stores' import { base } from '$lib/base' import { sendUserToast } from '$lib/toast' @@ -22,8 +22,7 @@ import Select from '$lib/components/select/Select.svelte' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' - import { Alert } from '$lib/components/common' - import { prefersSessionHandoff } from '$lib/components/copilot/chat/global/gate' + import { Alert, Badge } from '$lib/components/common' import { copilotInfo, copilotWorkspace } from '$lib/aiStore' import { loadCopilot } from '$lib/components/copilot/loadCopilot' import { react18Template, react19Template, svelte5Template } from './templates' @@ -52,11 +51,10 @@ files: Record runnables: Record data: RawAppData - summary: string policy: Policy } - /** `ai` hands the new app to the AI right away; `code` opens the editor on the setup's choices. */ + /** Where the set-up app opens: `ai` hands it to the AI, `code` opens it in the editor. */ export type RawAppBuildMode = 'ai' | 'code' let { @@ -68,18 +66,16 @@ } = $props() const templates = [ - { name: 'React 19', icon: 'tsx', files: react19Template }, + { name: 'React 19', icon: 'tsx', files: react19Template, recommended: true }, { name: 'React 18', icon: 'tsx', files: react18Template }, { name: 'Svelte 5', icon: 'svelte', files: svelte5Template } ] - let step = $state<'choice' | 'setup'>('choice') let selectedTemplateIndex = $state(0) let selectedDatatable = $state(undefined) let schemaMode = $state<'none' | 'new' | 'existing'>('new') let selectedSchema = $state(undefined) let newSchemaName = $state('') - let appSummary = $state('') let preWhitelistedTables = $state([]) /** The role each pre-whitelisted table's data table was browsed as. */ let preWhitelistedRoles = $state>({}) @@ -271,12 +267,7 @@ // would announce AI as unconfigured while it is merely unknown. Gate on the // config describing opWs, and load it here so the claim owns its own evidence. const aiConfigLoaded = $derived(!!opWs && $copilotWorkspace === opWs) - // Say where the card leads: the route hands the app to a fresh AI session for - // everyone who has one, and opens the docked chat for the rest. - const handsOffToSession = $derived(prefersSessionHandoff($userStore?.operator)) const isAiEnabled = $derived(aiConfigLoaded && $copilotInfo.enabled) - // The AI starts on the default data table and its role, so both must have answered. - const dataSettled = $derived(hasNoDatatables || (rolesSettled && accessSettled)) $effect(() => { if (open && opWs && !aiConfigLoaded) { @@ -285,9 +276,11 @@ }) // With AI turned off for the workspace there is nothing to choose between. - const shownStep = $derived($copilotInfo.workspaceDisabled ? 'setup' : step) - const choiceCardClasses = - 'h-full flex-col items-start justify-start gap-3 rounded-lg p-6 text-left whitespace-normal' + const aiOffered = $derived(!$copilotInfo.workspaceDisabled) + + const canStart = $derived( + !!templates[selectedTemplateIndex] && !newSchemaAlreadyExists && rolesSettled && accessSettled + ) function appPolicy(): Policy { return { @@ -299,60 +292,103 @@ } } - /** The AI builds on the default template and the default data table: it picks the framework - * and creates the tables it needs itself. */ - function buildWithAI() { - const datatable = usableDatatable - const role = rolesAnswered ? effectiveRole : undefined - open = false - onStart( - { - files: templates[0].files, - runnables: {}, - data: { - tables: [], - datatable, - schema: undefined, - roles: datatable && role ? { [datatable]: role } : undefined - }, - summary: '', - policy: appPolicy() - }, - 'ai' + const startCardClasses = + 'relative h-full flex-col items-start justify-start gap-1.5 rounded-lg p-3 text-left whitespace-normal' + + async function createSchema(input: { + workspace: string + datatable: string + schema: string + role: string | undefined + migrationRole: string | undefined + }) { + try { + const { dbSchemaOpsWithPreviewScripts } = await import('$lib/components/dbOps') + const dbOps = dbSchemaOpsWithPreviewScripts({ + workspace: input.workspace, + input: { + type: 'database', + resourceType: 'postgresql', + resourcePath: `datatable://${input.datatable}`, + role: input.role, + migrationRole: input.migrationRole + } + }) + await dbOps.onCreateSchema({ schema: input.schema }) + } catch (e) { + console.error('Failed to create schema:', e) + sendUserToast(`Failed to create schema ${input.schema}: ${e}`, true) + } + } + + // The start that is in flight, and the schema it is waiting on: creating one is a job, + // which can sit in the queue long enough for a silent modal to look dead. + let starting = $state<{ mode: RawAppBuildMode; schema?: string } | undefined>(undefined) + + const SCHEMA_JOB_TAG = 'postgresql' + + /** Whether a worker can pick up the job that creates a schema. Only a definite "no" + * counts: with per-workspace default tags the job runs on a variant of the tag, and a + * failed lookup establishes nothing. */ + async function schemaJobsServed(workspace: string): Promise { + try { + if (await WorkerService.isDefaultTagsPerWorkspace()) return true + return await hasWorkerForTag(workspace, SCHEMA_JOB_TAG) + } catch { + return true + } + } + + // Existing tables go with an existing schema: under the other modes the list is not shown, + // and what is not shown is not saved. + const tablesToUse = $derived(schemaMode === 'existing' ? preWhitelistedTables : []) + + function exploreDatatable() { + dataTableDrawer?.openDrawer( + selectedDatatable + ? { + datatable: selectedDatatable, + schema: schemaMode === 'existing' ? selectedSchema : undefined + } + : undefined ) } - async function start() { + async function start(mode: RawAppBuildMode) { + if (starting) return const template = templates[selectedTemplateIndex] if (schemaMode === 'new' && newSchemaName && usableDatatable && opWs) { - try { - const { dbSchemaOpsWithPreviewScripts } = await import('$lib/components/dbOps') - const dbOps = dbSchemaOpsWithPreviewScripts({ - workspace: opWs, - input: { - type: 'database', - resourceType: 'postgresql', - resourcePath: `datatable://${usableDatatable}`, - role: effectiveRole, - migrationRole: defaultMigrationRole( - usableDatatable, - roles.current.permissioned, - roles.current.defaultRole - ) - } - }) - await dbOps.onCreateSchema({ schema: newSchemaName }) - } catch (e) { - console.error('Failed to create schema:', e) - sendUserToast(`Failed to create schema: ${e}`, true) + starting = { mode, schema: newSchemaName } + const creation = createSchema({ + workspace: opWs, + datatable: usableDatatable, + schema: newSchemaName, + role: effectiveRole, + migrationRole: defaultMigrationRole( + usableDatatable, + roles.current.permissioned, + roles.current.defaultRole + ) + }) + if (await schemaJobsServed(opWs)) { + await creation + } else { + // The job stays queued for as long as no worker serves its tag: waiting on it + // would hold the app back for nothing, so say why the schema is missing instead. + sendUserToast( + `Schema "${newSchemaName}" is not created yet. ${queuedWithoutWorkerMessage(SCHEMA_JOB_TAG)}`, + true + ) } } - const formattedTables = preWhitelistedTables.map(formatDataTableRef) + starting = undefined + + const formattedTables = tablesToUse.map(formatDataTableRef) const keepsDatatable = usableDatatable !== undefined // The roles shown, for the data tables the app ends up using. - const usedDatatables = new Set(preWhitelistedTables.map((t) => t.datatable)) + const usedDatatables = new Set(tablesToUse.map((t) => t.datatable)) if (keepsDatatable) usedDatatables.add(usableDatatable!) const shownRoles = Object.entries(pickerRoles ?? {}).filter(([dt]) => usedDatatables.has(dt)) const appRoles = shownRoles.length > 0 ? Object.fromEntries(shownRoles) : undefined @@ -371,10 +407,9 @@ files: template.files, runnables: {}, data, - summary: appSummary.trim(), policy: appPolicy() }, - 'code' + mode ) } @@ -385,240 +420,181 @@ Modal closes its own UI but the picker's `open` prop stays true, so the route's `templatePicker → false` watcher never fires and autosave stays suspended after the dismissal. --> - - {#if shownStep === 'choice'} -
-
- - + +
+
+

Framework

+
+ {#each templates as t, i} + + {/each}
-

You can always switch later.

- {#if aiConfigLoaded && !isAiEnabled} - - You can still create an app manually but using AI is highly recommended. +
+ +
+

Data configuration

+ + {#if hasNoDatatables} + + You can still create an app, but for data storage you won't be able to use data tables + which are highly recommended.
{#if $userStore?.is_admin} - Configure AI in + Configure datatables in workspace settings - - {#if $superadmin} - or - instance settings - - {/if} to enable this feature. - {:else if $superadmin} - Configure AI in - instance settings to enable this feature. {:else} - Ask your workspace admin to configure AI in workspace settings to enable this feature. + Ask your workspace admin to configure datatables in workspace settings to enable this + feature. {/if}
- {/if} -
- {:else} -
-
-

Summary

- -
- -
-

Framework

-
- {#each templates as t, i} - - {/each} -
-
- -
-

Data configuration

- - {#if hasNoDatatables} - - You can still create an app, but for data storage you won't be able to use data tables - which are highly recommended. -
- {#if $userStore?.is_admin} - Configure datatables in - workspace settings - to enable this feature. - {:else} - Ask your workspace admin to configure datatables in workspace settings to enable - this feature. - {/if} -
- {:else} -
-
- Default settings for new tables -
-
-
- -
+ {:else} +
+ +
+ Default settings for new tables +
+
+
+ +
+ ({ value: r, label: r }))} + bind:value={() => selectedRole, pickRole} + clearable={false} + placeholder="Role" size="sm" class="w-40" /> - {#if showRolePicker} - - as - -
- {/if} -
- {#if newSchemaAlreadyExists} - Schema "{newSchemaName}" already exists + {rolesUnknown + ? 'could not read its roles' + : noUsableRole + ? 'no role you can use' + : 'could not reach it'}; the app is created without a default data + table + {/if}
+
+ Schema +
+
+ + {#snippet children({ item })} + + + + {/snippet} + +
+
diff --git a/frontend/src/lib/components/breadcrumbPath.test.ts b/frontend/src/lib/components/breadcrumbPath.test.ts new file mode 100644 index 0000000000..427b153800 --- /dev/null +++ b/frontend/src/lib/components/breadcrumbPath.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest' +import { splitItemPath } from './breadcrumbPath' + +describe('splitItemPath', () => { + it('walks the scope, each subfolder, then the item, each with its cumulative path', () => { + expect(splitItemPath('f/demo/sub/weather_report')).toEqual({ + dirs: [ + { name: 'f/demo', fullPath: 'f/demo' }, + { name: 'sub', fullPath: 'f/demo/sub' } + ], + leaf: { name: 'weather_report', fullPath: 'f/demo/sub/weather_report' } + }) + }) + + it('gives up before there is a scope', () => { + expect(splitItemPath('f/demo')).toBeUndefined() + }) + + it('keeps the folders while the name is still being typed', () => { + // The editor binds the path live, so every keystroke reaches the breadcrumb: the trail must + // not drop out and come back on the first character of the name. + expect(splitItemPath('f/demo/')).toEqual({ + dirs: [{ name: 'f/demo', fullPath: 'f/demo' }], + leaf: undefined + }) + }) +}) diff --git a/frontend/src/lib/components/breadcrumbPath.ts b/frontend/src/lib/components/breadcrumbPath.ts new file mode 100644 index 0000000000..880d558360 --- /dev/null +++ b/frontend/src/lib/components/breadcrumbPath.ts @@ -0,0 +1,30 @@ +/** A directory level of an item's path, with the full path down to it. */ +export type PathDir = { name: string; fullPath: string } + +/** + * Splits `f/demo/weather_report` into the levels a breadcrumb walks: the scope (`f/` or + * `u/`), then each subfolder, then the item itself. Each level carries the cumulative path, + * which is what the picker opens and highlights. + * + * Returns undefined before there is even a scope. The leaf is undefined while the path names a + * folder but no item yet (`f/demo/`) — the levels it does name still come back. + */ +export function splitItemPath( + path: string +): { dirs: PathDir[]; leaf: PathDir | undefined } | undefined { + const parts = path.split('/') + if (parts.length < 3) return undefined + const scope = parts.slice(0, 2).join('/') + const slug = parts.slice(2) + const dirs: PathDir[] = [{ name: scope, fullPath: scope }] + let acc = scope + for (let i = 0; i < slug.length - 1; i++) { + acc = `${acc}/${slug[i]}` + dirs.push({ name: slug[i], fullPath: acc }) + } + // A leaf with an empty name draws a blank segment. The editor binds the path live, so the + // breadcrumb sees every keystroke: the folders stay put while the name is being typed rather + // than the whole trail dropping out and coming back. + const name = slug[slug.length - 1] + return { dirs, leaf: name ? { name, fullPath: path } : undefined } +} diff --git a/frontend/src/lib/components/common/button/Button.svelte b/frontend/src/lib/components/common/button/Button.svelte index f684d79a9f..f3cfc78478 100644 --- a/frontend/src/lib/components/common/button/Button.svelte +++ b/frontend/src/lib/components/common/button/Button.svelte @@ -148,6 +148,13 @@ }: Props = $props() const activeTone = $derived(dropdownItems ? undefined : tone) + // `dropdownItems` may be a function, whose `.length` is its arity — 0 for the `() => [...]` + // every caller writes. Asking it for its items is the only way to know whether there are any, + // and a split button that answers `.length` instead renders no caret at all. + const hasDropdown = $derived( + !!dropdownItems && + (typeof dropdownItems === 'function' ? dropdownItems().length : dropdownItems.length) > 0 + ) function computeDropdowns(menuItems: MenuItem[] | (() => MenuItem[])): Item[] { const items = typeof menuItems === 'function' ? menuItems() : menuItems @@ -197,12 +204,7 @@ ] : ButtonType.VariantStyles[variant] // For default variant with dropdowns, remove border from button since it's on wrapper - if ( - variant === 'default' && - dropdownItems && - ((typeof dropdownItems === 'function' && dropdownItems().length > 0) || - dropdownItems.length > 0) - ) { + if (variant === 'default' && hasDropdown) { style = style.replace('border border-border-light', '') } return style @@ -278,7 +280,7 @@ getSpacingClass(variant, size, spacingSize, iconOnly, unifiedSize, extendedSize), unifiedSize ? ButtonType.UnifiedFontSizes[unifiedSize] : '', 'focus-visible:ring-2', - dropdownItems && dropdownItems.length > 0 ? 'rounded-l-md' : 'rounded-md', + hasDropdown ? 'rounded-l-md' : 'rounded-md', 'justify-center items-center text-center inline-flex gap-2', 'active:opacity-80 transition-[background-color,opacity] duration-150', disabled @@ -345,7 +347,7 @@
0 ? dividerClass : '', + hasDropdown ? dividerClass : '', 'shrink-0', wrapperClasses, 'flex flex-row rounded-md', @@ -385,6 +387,7 @@ class={buttonClass} {id} {target} + {title} tabindex={disabled ? -1 : 0} {...rest} {style} @@ -484,7 +487,7 @@ {/if} {/if} - {#if dropdownItems && dropdownItems.length > 0} + {#if hasDropdown && dropdownItems} = { '2xs': 'px-1', // Compact horizontal padding xs: 'px-2', - sm: 'px-2', // Regular horizontal padding + // One step of its own rather than sharing xs's: sm is the size a page's header buttons are, + // where a label needs room to read as a button rather than as text with a box around it. + sm: 'px-3', md: 'px-4', lg: 'px-6' } diff --git a/frontend/src/lib/components/common/tabs/DraggableTabs.svelte b/frontend/src/lib/components/common/tabs/DraggableTabs.svelte index f3870b3bb6..ae2c4c4bae 100644 --- a/frontend/src/lib/components/common/tabs/DraggableTabs.svelte +++ b/frontend/src/lib/components/common/tabs/DraggableTabs.svelte @@ -50,6 +50,9 @@ /** Render after the right-pinned tabs, outside the scroll area so it stays * pinned (e.g. a "Split with Preview" toggle). */ trailing?: import('svelte').Snippet + /** Tab height. `sm` (the default) is the 24px pill the panel strips use; `md` is 28px, for a + * strip that fills a 44px bar, where the smaller pill leaves too much air around it. */ + size?: 'sm' | 'md' /** Render inside each tab (after the label; receives the tab + isActive). Clicks * bubble to the tab unless the accessory stops them. The tab is position:relative * so an `absolute inset-0 pointer-events-none` child can anchor a whole-tab @@ -65,6 +68,7 @@ onReorder, onActiveClick, class: c = '', + size = 'sm', afterTabs, trailing, tabAccessory @@ -180,7 +184,8 @@ function tabClasses(isActive: boolean) { return twMerge( - 'group relative inline-flex items-center gap-1.5 px-2.5 h-6 text-xs rounded-md select-none cursor-pointer whitespace-nowrap transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-border-selected focus-visible:ring-inset', + 'group relative inline-flex items-center gap-1.5 text-xs rounded-md select-none cursor-pointer whitespace-nowrap transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-border-selected focus-visible:ring-inset', + size === 'md' ? 'h-7 px-3 gap-2' : 'h-6 px-2.5', isActive ? 'bg-surface-tertiary text-emphasis' : 'bg-transparent text-hint hover:text-secondary' diff --git a/frontend/src/lib/components/common/toggleButton-v2/ToggleButtonMore.svelte b/frontend/src/lib/components/common/toggleButton-v2/ToggleButtonMore.svelte index 1300dc4322..86f04dfa9f 100644 --- a/frontend/src/lib/components/common/toggleButton-v2/ToggleButtonMore.svelte +++ b/frontend/src/lib/components/common/toggleButton-v2/ToggleButtonMore.svelte @@ -70,7 +70,14 @@
- + +
diff --git a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte index 1a1412b9f8..3e0351f249 100644 --- a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte +++ b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte @@ -3,14 +3,12 @@ import { Pane, Splitpanes } from 'svelte-splitpanes' import SessionsBetaBanner from '$lib/components/sessions/SessionsBetaBanner.svelte' import { zIndexes } from '$lib/zIndexes' - import { userStore, workspaceStore } from '$lib/stores' + import { workspaceStore } from '$lib/stores' import { chatState } from './sharedChatState.svelte' import { loadCopilot } from '$lib/components/copilot/loadCopilot' import { copilotInfo } from '$lib/aiStore' import { aiChatManager } from './AIChatManager.svelte' import { onDestroy } from 'svelte' - import Button from '$lib/components/common/button/Button.svelte' - import { Menu } from 'lucide-svelte' import CreatedResourceActionDrawers from './CreatedResourceActionDrawers.svelte' interface Props { @@ -20,7 +18,6 @@ transitionClass?: string isMobile?: boolean children: any - onMenuOpen?: () => void disableAi?: boolean // Whether this layout loads the workspace AI config. It gates far more than // the docked pane (code completion, metadata generation, the "open in AI @@ -40,15 +37,14 @@ transitionClass = 'transition-all ease-in-out duration-200', isMobile = false, children, - onMenuOpen, disableAi, loadAiConfig = true, showSessionsBetaBanner = false }: Props = $props() // The desktop rail is fixed-positioned, so the content is offset by a matching - // left padding (in rem, matching the rail). Mobile/operator/borderless: no rail. - let contentPadLeft = $derived(noBorder || $userStore?.operator || isMobile ? 0 : sidebarWidth) + // left padding (in rem, matching the rail). Drawer (mobile or detached)/borderless: no rail. + let contentPadLeft = $derived(noBorder || isMobile ? 0 : sidebarWidth) $effect(() => { chatState.dockedChatAvailable = !disableAi @@ -83,23 +79,6 @@ }) -{#snippet burgerRow()} -
-
-{/snippet} - {#if !disableAi} @@ -111,7 +90,6 @@ >
- {@render burgerRow()}
{@render children?.()}
@@ -138,12 +116,14 @@ {/if} {:else} +
- {@render burgerRow()}
{@render children?.()}
diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 23a80c4026..3e3baa36d7 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -2719,7 +2719,7 @@ function allowedTriggerKinds(): PageTriggerKind[] { } // Pages an operator may reach: exactly the ones enabled in the workspace's -// operator_settings, the same source OperatorMenu gates on. Operators are never admins, so +// operator_settings, the same source the sidebar gates on. Operators are never admins, so // workspace_settings is excluded whatever the settings say. function operatorOpenPages(workspaceId: string | undefined): OpenPageName[] { const settings = get(userWorkspaces).find((w) => w.id === workspaceId)?.operator_settings diff --git a/frontend/src/lib/components/details/DetailPageHeader.svelte b/frontend/src/lib/components/details/DetailPageHeader.svelte index 72c39ba512..34a7fab355 100644 --- a/frontend/src/lib/components/details/DetailPageHeader.svelte +++ b/frontend/src/lib/components/details/DetailPageHeader.svelte @@ -3,10 +3,10 @@ import DropdownV2 from '$lib/components/DropdownV2.svelte' import ErrorHandlerToggleButton from './ErrorHandlerToggleButton.svelte' - import { twMerge } from 'tailwind-merge' - import { userStore } from '$lib/stores' - import { createEventDispatcher, getContext, tick } from 'svelte' + import { createEventDispatcher, getContext, tick, type Snippet } from 'svelte' import SummaryPathDisplay from '$lib/components/SummaryPathDisplay.svelte' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' + import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte' import type { TriggerContext } from '../triggers' import type { Item } from '$lib/utils' import { Bell, BellOff, Calendar } from 'lucide-svelte' @@ -37,9 +37,10 @@ } // An agent's page has no triggers, and so no context for them. - const { triggersCount, triggersState } = $state( - getContext('TriggerContext') ?? ({} as Partial) - ) + const triggerContext = getContext('TriggerContext') + const { triggersCount, triggersState } = $state(triggerContext ?? ({} as Partial)) + // The buttons below render in the page header, away from this page's tree. + const headerContexts = new Map([['TriggerContext', triggerContext]]) interface Props { mainButtons?: MainButton[] @@ -49,6 +50,9 @@ tag?: string | undefined /** Unset for what has no workspace error handler to mute, such as an agent. */ errorHandlerKind?: 'flow' | 'script' + /** What the rename popover is renaming, when that is not the error handler's kind — an + * agent has no error handler but is still renamed from here. */ + itemKind?: 'flow' | 'script' | 'agent' scriptOrFlowPath?: string errorHandlerMuted?: boolean | undefined labels?: string[] | undefined @@ -56,6 +60,10 @@ onSaved?: (newPath: string) => void children?: import('svelte').Snippet trigger_badges?: import('svelte').Snippet + /** True for the route's own page: its header becomes the page header. A host that renders + * this inside something else — a session's preview panel — leaves it false and gets the + * row below. */ + ownsPageHeader?: boolean /** Whether the bar is wide enough to hold every button beside the summary, decided by * the layout from its own width. Not a viewport media query: the same page also renders * inside an AI session's preview panel, where the window is wide and the pane is not. */ @@ -71,6 +79,7 @@ path, tag, errorHandlerKind, + itemKind, scriptOrFlowPath, errorHandlerMuted = $bindable(), labels = $bindable(), @@ -78,13 +87,30 @@ onSaved, children, trigger_badges, + ownsPageHeader = false, wide = true, leading_actions }: Props = $props() const dispatch = createEventDispatcher() - const barButtons = $derived(wide ? mainButtons : mainButtons.filter((b) => !b.narrow)) + // A flow and a script are renamed as what their error handler already names them; an agent has + // no error handler and says so itself. + const renameKind = $derived(itemKind ?? errorHandlerKind) + + // Two reasons the row can be too tight, and either one collapses it. The host knows when its + // own box is narrow while the window is not — a session's preview panel — and says so through + // `wide`. The band knows its own width, which the host cannot see: these buttons share that row + // with the breadcrumb, and the trail and summary want ~580px against the set's ~530. Unmeasured + // (0) counts as wide, because the bar measures itself on mount and starting narrow would pop + // the buttons out of the menu a frame later. + // Measured on a flow's page: a 1192px bar leaves the last button 4px past the edge, so the row + // has to fold before that rather than at the ~1150 the two parts add up to on paper. + const COLLAPSE_BELOW = 1250 + const roomInBar = $derived(pageHeader.barWidth === 0 || pageHeader.barWidth >= COLLAPSE_BELOW) + const wideRow = $derived(wide && roomInBar) + + const barButtons = $derived(wideRow ? mainButtons : mainButtons.filter((b) => !b.narrow)) function dropdownHost(btn: MainButton): MainButton | undefined { if (typeof btn.narrow !== 'object') return undefined @@ -104,7 +130,7 @@ } const allMenuItems: Item[] = $derived([ - ...(wide ? [] : mainButtons.filter((b) => b.narrow && !dropdownHost(b))).map((b) => ({ + ...(wideRow ? [] : mainButtons.filter((b) => b.narrow && !dropdownHost(b))).map((b) => ({ displayName: b.label, description: b.description, icon: b.buttonProps.startIcon, @@ -113,7 +139,7 @@ disabled: b.buttonProps.disabled, type: 'action' as const })), - ...(wide || !errorHandlerKind + ...(wideRow || !errorHandlerKind ? [] : [ { @@ -129,12 +155,12 @@ action: item.onclick, disabled: item.disabled, type: item.color === 'red' ? ('delete' as const) : ('action' as const), - separatorTop: i === 0 && !wide + separatorTop: i === 0 && !wideRow })) ]) function dropdownItemsOf(host: MainButton) { - if (wide) return undefined + if (wideRow) return undefined const items = mainButtons .filter((b) => dropdownHost(b) === host) .map((b) => ({ @@ -149,74 +175,138 @@ } -
-
-
+{/snippet} + + +{#snippet pathTrigger(pathLabel: Snippet, triggerClass: string)} + +{/snippet} + + +{#snippet badges(size: 'sm' | 'md')} + {#if tag} + tag: {tag} + {/if} + {@render children?.()} + {#if triggersState?.triggers?.some((t) => t.isPrimary && !t.isDraft)} + {@const primarySchedule = triggersState.triggers.findIndex((t) => t.isPrimary && !t.isDraft)} + + {/if} + {@render trigger_badges?.()} +{/snippet} + +{#snippet controls(size: 'sm' | 'md')} + {@render leading_actions?.()} + {#if allMenuItems.length > 0} + {#key allMenuItems} + + {/key} + {/if} + {#if wideRow && errorHandlerKind && scriptOrFlowPath} + + {/if} + {#each barButtons as btn (btn.label)} + {@const dropdownItems = dropdownItemsOf(btn)} + + {/each} +{/snippet} + +{#snippet actions()} + {@render badges('sm')} + {@render controls('sm')} +{/snippet} + +{#if ownsPageHeader} + + +{:else} + +
+
+
+
+
+ +
+ {@render badges('md')} +
+
+ {@render controls('md')}
- {#if tag} - tag: {tag} - {/if} - {@render children?.()} - {#if triggersState?.triggers?.some((t) => t.isPrimary && !t.isDraft)} - {@const primarySchedule = triggersState.triggers.findIndex( - (t) => t.isPrimary && !t.isDraft - )} - - {/if} - {@render trigger_badges?.()} -
-
- {@render leading_actions?.()} - {#if allMenuItems.length > 0} - {#key allMenuItems} - - {/key} - {/if} - {#if wide && errorHandlerKind && scriptOrFlowPath} - - {/if} - {#each barButtons as btn (btn.label)} - {@const dropdownItems = dropdownItemsOf(btn)} - - {/each}
-
+{/if} diff --git a/frontend/src/lib/components/details/ErrorHandlerToggleButton.svelte b/frontend/src/lib/components/details/ErrorHandlerToggleButton.svelte index 49622c8af2..48e833e2ff 100644 --- a/frontend/src/lib/components/details/ErrorHandlerToggleButton.svelte +++ b/frontend/src/lib/components/details/ErrorHandlerToggleButton.svelte @@ -13,9 +13,16 @@ scriptOrFlowPath: string errorHandlerMuted: boolean | undefined iconOnly?: boolean + unifiedSize?: 'xs' | 'sm' | 'md' | 'lg' } - let { kind, scriptOrFlowPath, errorHandlerMuted = $bindable(), iconOnly = true }: Props = $props() + let { + kind, + scriptOrFlowPath, + errorHandlerMuted = $bindable(), + iconOnly = true, + unifiedSize = 'md' + }: Props = $props() async function toggleErrorHandler(): Promise { const next = await toggleWorkspaceErrorHandler( @@ -32,7 +39,7 @@ title={errorHandlerMuted === undefined || !errorHandlerMuted ? 'Disable workspace error handler for this script' : 'Enable workspace error handler for this script'} - unifiedSize="md" + {unifiedSize} on:click={toggleErrorHandler} variant="subtle" startIcon={{ diff --git a/frontend/src/lib/components/details/FlowDetail.svelte b/frontend/src/lib/components/details/FlowDetail.svelte index 0e9f47816b..ce7378ede4 100644 --- a/frontend/src/lib/components/details/FlowDetail.svelte +++ b/frontend/src/lib/components/details/FlowDetail.svelte @@ -656,6 +656,7 @@ {#snippet header({ wide }: { wide: boolean })} { detailLayout?.showTriggers() }} diff --git a/frontend/src/lib/components/details/ScriptDetail.svelte b/frontend/src/lib/components/details/ScriptDetail.svelte index 078ab20a47..f33f00fb8f 100644 --- a/frontend/src/lib/components/details/ScriptDetail.svelte +++ b/frontend/src/lib/components/details/ScriptDetail.svelte @@ -838,6 +838,7 @@ {#snippet header({ wide }: { wide: boolean })} - import { - ChevronRight, - FlaskConical, - Settings, - FormInput, - History, - MessageSquare, - Save - } from 'lucide-svelte' + import { FlaskConical, Settings, FormInput, History, MessageSquare, Save } from 'lucide-svelte' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' - import { onDestroy, untrack } from 'svelte' + import { onDestroy, untrack, type Snippet } from 'svelte' import { resource } from 'runed' import Modal, { type ModalTrailSegment } from '$lib/components/common/modal/Modal.svelte' import PagedContent from '$lib/components/common/modal/PagedContent.svelte' @@ -39,6 +31,10 @@ import AgentSettings from './AgentSettings.svelte' import AgentEvalsModal from './AgentEvalsModal.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' + import PathEditPopover from '$lib/components/PathEditPopover.svelte' + import { emptyString } from '$lib/utils' + import Tooltip from '$lib/components/Tooltip.svelte' const operatingWorkspace = useOperatingWorkspace() @@ -93,6 +89,9 @@ let host = $state | undefined>(undefined) let versionDrawer: Drawer | undefined = $state(undefined) let settingsDrawer: Drawer | undefined = $state(undefined) + /** Held by the rename popover while it is open; the band's trail reads it so the path does not + * reflow under the pointer as the user types. */ + let pathSnapshot = $state(undefined) let evalsModal: AgentEvalsModal | undefined = $state(undefined) let saving = $state(false) @@ -136,9 +135,10 @@ let canEvaluate = $derived( draft != undefined && !draft.loading && !draftOnly && !readOnly && !refused ) - // A never-deployed agent is stored at a minted `draft_` path, so it is named by the path - // its first deploy will create, as the home list names it. - let shownPath = $derived((draftOnly && draft?.state?.path) || target?.path) + // The draft's path, so a rename shows where it is typed — including a never-deployed agent, + // stored at a minted `draft_` path and named by the path its first deploy will create, + // as the home list names it. Falls back to the target while the draft is still loading. + let shownPath = $derived(draft?.state?.path || target?.path) // Where the evals pane is within itself, so its levels extend this dialog's trail rather than // opening a dialog of their own. Cleared on the way in: the pane reports a level once it is on @@ -312,34 +312,22 @@ {@render body()} {:else} + +
-
-
-
- {#each trail as segment, i (i)} - {#if i > 0} - - {/if} - {#if segment.onclick} - - {:else} - {segment.label} - {/if} - {#if i === 0} - {@render titleBadge()} - {/if} - {/each} - {@render levelBadge()} -
- {#if description} - {description} - {/if} -
-
- {@render settings()} -
{@render body()}
@@ -347,6 +335,95 @@ {/if} {/key} + + {#snippet agentPathTrigger(pathLabel: Snippet, triggerClass: string)} + + {#if draft?.state} + draft.state?.description ?? '', + (v) => { + if (draft.state) draft.state.description = v + } + } + bind:path={ + () => draft.state?.path ?? '', + (v) => { + if (draft.state) draft.state.path = v + } + } + bind:snapshotPath={pathSnapshot} + bind:error={() => host?.pathError(), (error) => host?.setPathError(error)} + savedPath={draft?.noDeployed ? undefined : target?.path} + kind="resource" + workspaceId={ws} + pathEditable={!readOnly} + summaryEditable={!readOnly} + /> + {:else} + {@render pathLabel()} + {/if} + {/snippet} + + {#snippet agentSummary()} + +
+ {#if draft?.state} + {#snippet summaryText()} + {emptyString(draft.state?.description) + ? 'Add a summary...' + : draft.state?.description} + {/snippet} + draft.state?.description ?? '', + (v) => { + if (draft.state) draft.state.description = v + } + } + bind:path={ + () => draft.state?.path ?? '', + (v) => { + if (draft.state) draft.state.path = v + } + } + bind:snapshotPath={pathSnapshot} + bind:error={() => host?.pathError(), (error) => host?.setPathError(error)} + savedPath={draft?.noDeployed ? undefined : target?.path} + kind="resource" + workspaceId={ws} + pathEditable={!readOnly} + summaryEditable={!readOnly} + penVisibility={emptyString(draft.state.description) ? 'always' : 'hover'} + /> + {/if} + {@render titleBadge()} +
+ {/snippet} + {#snippet agentHint()} + {#if description} + {description} + {/if} + {/snippet} {#snippet titleBadge()} diff --git a/frontend/src/lib/components/home/HomeAIChat.svelte b/frontend/src/lib/components/home/HomeAIChat.svelte index a8e4d8b973..62479b70d1 100644 --- a/frontend/src/lib/components/home/HomeAIChat.svelte +++ b/frontend/src/lib/components/home/HomeAIChat.svelte @@ -46,10 +46,20 @@ import HomeConnectDrawer from './HomeConnectDrawer.svelte' import { USER_SETTINGS_HASH } from '../sidebar/settings' import { prefersSessionHandoff } from '../copilot/chat/global/gate' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' + import DropdownV2 from '$lib/components/DropdownV2.svelte' + import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte' import { onboardingProfile } from '$lib/onboardingProfile' const COLLAPSED_SETTING = 'home-ai-composer-collapsed' + /** Bar width from which the two side trips stand in the bar rather than folding into the menu. + * Home's trail is ~250px and the rest of its actions ~400, so this leaves room for both + * labelled buttons without the trail starting to truncate. Below it they fold back. */ + const BAR_FITS_SIDE_TRIPS = 1024 + const sideTripsInBar = $derived(pageHeader.barWidth >= BAR_FITS_SIDE_TRIPS) + const hubOffered = $derived(!$userStore?.operator && HOME_SHOW_HUB) + let value = $state('') // The stock examples, unless the invite that brought this person here wrote prompts for // them — those replace the set outright rather than joining it, since a prompt written @@ -73,6 +83,26 @@ storeLocalSetting(COLLAPSED_SETTING, next ? 'true' : undefined) } + // Removing puts the composer away rather than throwing it out, and the way back is one item in + // the band's menu — so the ellipsis flashes as it appears, in a corner nobody is watching while + // the hero collapses in the middle of the page. + // + // A keyframe animation, not a transition: this mounts in the same flush that collapses the + // hero, so the frame that would paint it at rest is the frame it is already told to leave, and + // one `requestAnimationFrame` is not reliably past that paint. The flash then goes straight to + // invisible without ever being drawn. Mounted only while pulsing, so each removal replays it. + const PULSE_MS = 1000 + let pulsing = $state(false) + let pulseEndTimer: ReturnType | undefined + + function removeComposer() { + setCollapsed(true) + if (reducedMotion.val) return + clearTimeout(pulseEndTimer) + pulsing = true + pulseEndTimer = setTimeout(() => (pulsing = false), PULSE_MS) + } + // In global-AI mode the layout's chat panel is disabled and never loads the copilot // config, so the home chat loads it for the current workspace itself. $effect(() => { @@ -107,8 +137,9 @@ // unlocked, so the far commoner unlocked workspace never pops the composer in mid-load. let runOnlyWorkspace = $derived(isRuleActive('DisableDirectDeployment')) - // The composer hands off to /sessions, which refuses operators — so hide it from them (the - // prompt would be silently dropped) while the AI-independent CLI/MCP row below stays. + // The composer hands off to /sessions, which refuses operators and users opted out of the + // sessions beta — so hide it from them (the prompt would be silently dropped) while the + // AI-independent CLI/MCP row below stays. let showComposer = $derived( prefersSessionHandoff($userStore?.operator) && !runOnlyWorkspace && @@ -116,7 +147,7 @@ ) // The hero's margins and centered column are for the full block. The lone button row left - // by a collapsed, operator, run-only or hidden-assistant view is a hint line and should + // by a collapsed, opted-out, run-only or hidden-assistant view is a hint line and should // cost the page almost nothing: no top margin, and the content column's full width so it // hugs the right edge instead of floating centered in empty space. let hero = $derived(showComposer && !collapsed) @@ -149,7 +180,7 @@ const FADE_MS = 600 // Rotate the example prompt every CYCLE_MS: fade the placeholder out, swap it, fade it back in. - // Only while the composer is shown — otherwise (operators) it would loop forever driving an + // Only while the composer is shown — otherwise it would loop forever driving an // unrendered input — and not under reduced motion, where the first prompt simply stays put. // The index lives outside the effect so re-showing the composer resumes the rotation from the // prompt currently displayed rather than restarting it. @@ -185,7 +216,7 @@
- setCollapsed(true)} /> +
{/if}
@@ -272,9 +303,7 @@ {freeTierExhausted ? 'Add your own API key' : 'Configure AI'} {/if} - +
{/if} @@ -295,49 +324,106 @@ {/each}
- {:else if showComposer} - - - {:else} -
{/if} - - -
- - {#if !$userStore?.operator && HOME_SHOW_HUB} - - {/if} -
+ + + + + +{#snippet homeMenu()} + + + + {#if sideTripsInBar} + + + {#if hubOffered} + + {/if} + {/if} + + + {#if !sideTripsInBar || (showComposer && collapsed)} + + + {#if pulsing} + + + {/if} + setCollapsed(false) + } + ] + : []), + ...(sideTripsInBar + ? [] + : [ + { + displayName: 'CLI / MCP', + icon: PlugZap, + action: () => homeConnectDrawer?.openDrawer?.() + }, + ...(hubOffered + ? [ + { + displayName: 'Hub', + icon: Globe2, + href: $hubBaseUrlStore, + hrefTarget: '_blank' as const + } + ] + : []) + ]) + ]} + /> + + {/if} + +{/snippet} diff --git a/frontend/src/lib/components/meltComponents/Menu.svelte b/frontend/src/lib/components/meltComponents/Menu.svelte index ab6de64123..02ed1d90f7 100644 --- a/frontend/src/lib/components/meltComponents/Menu.svelte +++ b/frontend/src/lib/components/meltComponents/Menu.svelte @@ -16,6 +16,10 @@ interface Props { placement?: Placement + /** Inline style on the panel. A margin here shifts it after floating-ui has placed it, + * which is the only way to anchor it to a box wider than the trigger: melt's menubar + * menu ignores `positioning.offset`. */ + contentStyle?: string justifyEnd?: boolean lightMode?: boolean maxHeight?: number @@ -45,6 +49,7 @@ let { placement = 'right-start', + contentStyle = '', justifyEnd = false, lightMode = false, maxHeight = 900, @@ -221,6 +226,7 @@ onmouseenter={cancelPendingClose} onmouseleave={scheduleClose} transition:placementFly={{ duration: 100, placement }} + style={contentStyle} class={twMerge( 'z-[6000] border w-56 origin-top-right rounded-md shadow-md focus:outline-none', // Default: scroll on the melt element. submenuSafe moves it to the inner diff --git a/frontend/src/lib/components/moveRenameManager.ts b/frontend/src/lib/components/moveRenameManager.ts index 1500f8fc69..e5844a3351 100644 --- a/frontend/src/lib/components/moveRenameManager.ts +++ b/frontend/src/lib/components/moveRenameManager.ts @@ -1,7 +1,8 @@ -import { AppService, FlowService, ScriptService } from '$lib/gen' +import { AppService, FlowService, ResourceService, ScriptService } from '$lib/gen' +import { agentEditorRefusal } from './flows/agentResourceUtils' import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte' -type ItemKind = 'flow' | 'script' | 'app' +type ItemKind = 'flow' | 'script' | 'app' | 'agent' /** * Check whether a flow uses on_behalf_of_email. @@ -71,6 +72,24 @@ export async function updateItemPathAndSummary(opts: { skip_draft_deletion: true } }) + } else if (kind === 'agent') { + // An agent is a resource, and its summary is the resource's description. The read before + // the write is the same one the agent editor does: an update carries no resource type, so + // a path deleted and recreated as something else in the meantime would otherwise take an + // agent's config. + const current = await ResourceService.getResource({ workspace, path: initialPath }) + const refused = agentEditorRefusal(initialPath, current.resource_type) + if (refused) { + throw new Error(refused) + } + // Only what this popover edits: the backend sets a column per field it is given, so the + // agent's own config, its type and `ws_specific` are left where they are rather than read + // here and written back over whatever changed in between. + await ResourceService.updateResource({ + workspace, + path: initialPath, + requestBody: { path: newPath, description: newSummary, labels } + }) } else if (kind === 'app') { await AppService.updateApp({ workspace, diff --git a/frontend/src/lib/components/operatingWorkspace.svelte.ts b/frontend/src/lib/components/operatingWorkspace.svelte.ts index 93a7f2c0de..70f1c63c1c 100644 --- a/frontend/src/lib/components/operatingWorkspace.svelte.ts +++ b/frontend/src/lib/components/operatingWorkspace.svelte.ts @@ -22,6 +22,14 @@ export function setOperatingWorkspace(resolve: () => string | undefined): void { setContext(KEY, outer ? () => resolve() ?? outer() : resolve) } +/** This subtree's operating workspace as an entry for a `ContextBridge` contexts map. A snippet + * handed to the page header's band renders outside the subtree that declared the workspace, so + * without this it would quietly act on the navigation workspace instead. Reads context: call + * during component initialisation. */ +export function operatingWorkspaceContextEntry(): [any, any] { + return [KEY, getContext(KEY)] +} + /** The workspace this component acts on, as a store. Reads context: call during component * initialisation. Falls back to the navigation workspace where no host set one, or where the * host's resolves to nothing. */ diff --git a/frontend/src/lib/components/pageHeaderRegistry.svelte.ts b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts new file mode 100644 index 0000000000..3167d636e9 --- /dev/null +++ b/frontend/src/lib/components/pageHeaderRegistry.svelte.ts @@ -0,0 +1,185 @@ +import type { Snippet } from 'svelte' +import type { WorkspaceItemKind } from './workspacePicker' + +/** An item the page is working on: the breadcrumb walks its path and ends with its summary. */ +export type PageHeaderItem = { + /** Absent for an item the picker does not file under a kind — an agent's detail page. The + * breadcrumb then names the item without claiming it is one of the three. */ + kind?: WorkspaceItemKind + /** Shown whole. Clicking it opens whatever the page hangs off it with `pathTrigger`, and copies + * the path where there is none. */ + path: string | undefined + /** Wraps the band's own path segment in whatever the page wants behind it — its rename + * popover, anchored there rather than beside the summary, so the editor opens under the half + * of the name that was clicked. The band passes the path as it renders it and the classes its + * own segment wears, so the page's control keeps the breadcrumb's look rather than inventing + * one. Without this the segment copies the path on click. */ + pathTrigger?: Snippet<[Snippet, string]> + summary?: string + /** Rendered in place of the plain summary, for a page whose summary is itself a control — + * a detail page's rename-and-labels popover, an editor's editable title. */ + summaryContent?: Snippet +} + +/** A page with no item of its own, such as a list: the breadcrumb is the section alone. */ +export type PageHeaderSection = { + label: string + /** Rendered in place of `label` when the name is itself a control, such as an editable title. */ + content?: Snippet + /** Scopes the segment's picker, when the section maps to a kind of item. */ + kind?: WorkspaceItemKind +} + +export type PageHeaderContent = { + item?: PageHeaderItem + section?: PageHeaderSection + /** Rendered at the right end of the bar — the page's own buttons. */ + actions?: Snippet + /** Orders these buttons among the other registrations', ascending; registration order breaks a + * tie. For a menu that must stay at the far end however many pages register before it. */ + actionsOrder?: number + /** These actions can give width back under pressure, so the bar lets their box shrink on a + * phone. Two ways to earn it: a control that is narrower when it has to be (the Runs filter + * row, whose search field yields), or a set that scrolls inside its own box (the app and + * pipeline editors, whose rows are too wide for any bar). Without it the box holds its content + * width, because squeezing a plain row of buttons only pushes them out of the bar. */ + actionsFlexible?: boolean + /** These actions are not a row of buttons at the far end but a surface of their own, running + * from the breadcrumb to the bar's right edge: a session in full screen, whose tab strip and + * controls share one shape that opens into the page below. The bar gives up its right padding + * to it, since the surface is what meets the edge. */ + actionsFill?: boolean + /** Rendered right after the page's name in the breadcrumb: a mark that belongs to the name, + * like a documentation tooltip, or a control that acts on the thing named — an app's Edit, + * which at the far end of the bar would be a journey away from what it edits. */ + afterName?: Snippet + /** The workspace this page acts on, when it is not the one the app is pointed at — a session + * running in a fork. The breadcrumb then names that workspace, and its fork part shows only + * when it really is a fork. */ + actingWorkspaceId?: string + /** Width in px, at the right of the viewport, that this page owns from the top down — a + * session's side panel. The band stops there instead of running over it, and the band floats + * above the page rather than pushing it down, so that column starts at the top. */ + barRightInset?: number + /** The bottom edge under the bar, which the bar owns so a page cannot draw a second one. + * `onScroll` (the default) shows it only while something is passing under the bar — at the top + * of a list there is nothing to divide. `always` is for a page whose content starts right + * under the bar and needs the division at rest: an editor, a detail page, a settings pane. + * `none` is for a page that genuinely draws its own first line, such as the script editor's + * toolbar or the flow editor's canvas. */ + separator?: 'onScroll' | 'always' | 'none' + /** Contexts this registration's snippets look up — its actions, what it hangs after the name, + * and its summary. They all render under the header rather than under the page that wrote + * them, so anything the page's tree provides has to travel with them. */ + contexts?: Map +} + +// The bar belongs to the root layout and is always on screen; a route page fills its breadcrumb +// and actions by registering here. +// +// A registration is a getter, not a snapshot: the layout calls it while rendering, so a path or +// summary the page edits reaches the bar through the layout's own dependency tracking, with no +// effect keeping two copies in step. +// +// Registrations stack, and what the bar shows is their merge (see `content`). Pages register on +// mount, so the first entry is the outermost one — an editor nested in a session pane or a drawer +// is asked to register nothing. +let entries = $state<{ id: number; get: () => PageHeaderContent }[]>([]) +let nextId = 0 + +/** + * Below this bar width a page keeps one button — the thing the page is for — and folds the rest + * into the menu it already shows when compact. A phone in portrait is ~390px of bar and a tablet + * in portrait ~720; the breadcrumb with its workspace disc takes ~150 of either, which is why the + * line sits above the tablet rather than between the two. + */ +export const PHONE_BAR = 800 + +/** The bar's height, and the padding a page owes the band when the band floats over it rather than + * sitting in the flow (see `barRightInset`). They are one measurement in two forms: raise one and + * the other has to follow, or the page's own content disappears under the bar. Written as literal + * class names so Tailwind sees them. */ +export const BAR_HEIGHT = 'h-12' +export const BAR_TOP_PAD = 'pt-12' +/** The same height in px, for the places that animate to the bar's bottom edge rather than + * standing under it — a transition's `css` and a measurement's fallback take a number. */ +export const BAR_HEIGHT_PX = 48 + +// The bar's own width, written by the bar and read by the pages that fill it. A page's buttons +// share that row with the breadcrumb, so it is the width they crowd against — not the window's, +// which the sidebar and a page's side panel both take from. +let barWidth = $state(0) + +/** + * One registration's content, or undefined while it cannot answer. + * + * An entry is a getter closing over another component's props, evaluated here, in a tree of its + * own. Nothing orders a parent clearing those props against the child's teardown: a route that + * drops the `{#if}` around an editor and the value it binds into it does both in one flush, and + * this derived re-runs on the same flush, before the editor's own cleanup releases its entry. The + * entry has nothing to say in that window, which is not the same as the page having no header — + * so it is skipped, and whatever registered before it still stands. + */ +function read(e: { get: () => PageHeaderContent }): PageHeaderContent | undefined { + try { + return e.get() + } catch { + return undefined + } +} + +export const pageHeader = { + /** + * The registrations merged, later ones winning field by field: a route sets the frame (where + * the bar goes) and something nested in it — the active session, say — fills the breadcrumb + * and the actions. + */ + get content(): PageHeaderContent | undefined { + if (entries.length === 0) return undefined + const merged: PageHeaderContent = {} + for (const e of entries) { + const c = read(e) + if (!c) continue + if (c.item !== undefined) merged.item = c.item + if (c.section !== undefined) merged.section = c.section + if (c.afterName !== undefined) merged.afterName = c.afterName + if (c.contexts !== undefined) merged.contexts = c.contexts + if (c.actingWorkspaceId !== undefined) merged.actingWorkspaceId = c.actingWorkspaceId + if (c.barRightInset !== undefined) merged.barRightInset = c.barRightInset + if (c.actionsFlexible !== undefined) merged.actionsFlexible = c.actionsFlexible + if (c.actionsFill !== undefined) merged.actionsFill = c.actionsFill + if (c.separator !== undefined) merged.separator = c.separator + } + return merged + }, + /** + * Every registration's actions, in registration order: a page and something inside it can both + * put buttons in the band, and each renders with the contexts its own registration named. + */ + get actions(): { render: Snippet; contexts?: Map }[] { + return entries + .flatMap((e, i) => { + const c = read(e) + return c?.actions + ? [{ render: c.actions, contexts: c.contexts, order: c.actionsOrder ?? 0, i }] + : [] + }) + .sort((a, b) => a.order - b.order || a.i - b.i) + }, + /** Width of the bar in px, or 0 before it has been measured. */ + get barWidth(): number { + return barWidth + }, + setBarWidth(w: number) { + barWidth = w + }, + /** Registers on mount and returns the id to release on destroy. */ + register(get: () => PageHeaderContent): number { + const id = nextId++ + entries = [...entries, { id, get }] + return id + }, + release(id: number) { + entries = entries.filter((e) => e.id !== id) + } +} diff --git a/frontend/src/lib/components/pageHeaderRegistry.test.ts b/frontend/src/lib/components/pageHeaderRegistry.test.ts new file mode 100644 index 0000000000..035eabb60e --- /dev/null +++ b/frontend/src/lib/components/pageHeaderRegistry.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, afterEach } from 'vitest' +import { pageHeader, type PageHeaderContent } from './pageHeaderRegistry.svelte' +import type { Snippet } from 'svelte' + +// The merge rule every registration site depends on: a page sets the frame and something nested +// in it fills the breadcrumb, so whether a component registers at all decides whose name the band +// shows. A viewer that registered wherever it was mounted renamed the band of the session hosting +// it. +const snippet = (name: string) => name as unknown as Snippet + +const ids: number[] = [] +function register(c: PageHeaderContent) { + ids.push(pageHeader.register(() => c)) +} +afterEach(() => { + while (ids.length) pageHeader.release(ids.pop()!) +}) + +describe('pageHeader', () => { + it('is empty with nothing registered', () => { + expect(pageHeader.content).toBeUndefined() + }) + + it('lets a later registration win field by field', () => { + register({ section: { label: 'Sessions' }, barRightInset: 400 }) + register({ item: { path: 'u/admin/app' } }) + expect(pageHeader.content).toEqual({ + section: { label: 'Sessions' }, + item: { path: 'u/admin/app' }, + barRightInset: 400 + }) + }) + + it('gives the fields of a released registration back', () => { + register({ section: { label: 'Sessions' } }) + register({ item: { path: 'u/admin/app' } }) + pageHeader.release(ids.pop()!) + expect(pageHeader.content?.item).toBeUndefined() + expect(pageHeader.content?.section).toEqual({ label: 'Sessions' }) + }) + + it('orders actions by actionsOrder, then by registration', () => { + register({ actions: snippet('page'), actionsOrder: 10 }) + register({ actions: snippet('nested') }) + register({ actions: snippet('alsoNested') }) + expect(pageHeader.actions.map((a) => a.render)).toEqual(['nested', 'alsoNested', 'page']) + }) + + it('carries the contexts of each registration with its actions', () => { + const pageContexts = new Map([['TriggerContext', 1]]) + register({ actions: snippet('page'), contexts: pageContexts }) + register({ actions: snippet('nested') }) + expect(pageHeader.actions.map((a) => a.contexts)).toEqual([pageContexts, undefined]) + }) +}) diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 2288403438..d0d1b40de1 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -154,6 +154,8 @@ * it as `draft_path` so the home row shows the friendly name, not `draft_{uuid}`. */ pendingDraftPath?: string | undefined // Threaded to the AutosaveIndicator's "Reset to deployed" button. + /** `Exit & see details`: see RawAppEditorHeader. */ + onDetails?: (e: { path: string }) => void onResetToDeployed?: () => void | Promise /** The app_version the draft forked from, for the deploy-time "new version * deployed" guard: deploying is refused with a confirmation while it is not @@ -198,6 +200,8 @@ // EditorHeader's path/breadcrumb row dropped (summary only). Used by the // session preview to save vertical room. condensedHeader?: boolean + /** True for the route's own editor: its top bar becomes the page header. */ + ownsPageHeader?: boolean } let { @@ -221,6 +225,7 @@ autosavePath = undefined, defaultSplitWithPreview = true, pendingDraftPath = $bindable(undefined), + onDetails, onResetToDeployed, loadedFromDraft = false, othersDraftsCount = 0, @@ -237,6 +242,7 @@ onRestore, onSavedNewAppPath, condensedHeader = false, + ownsPageHeader = false, version = undefined, onTakeLatest = undefined, draftBaseVersion = undefined @@ -2564,6 +2570,7 @@ addEventListener('message', function (e) { {getBundle} {onNavigate} {onDeploy} + {onDetails} {onResetToDeployed} {loadedFromDraft} {othersDraftsCount} @@ -2576,6 +2583,7 @@ addEventListener('message', function (e) { sidebarCollapsed={sidebarCollapsed.val} onToggleSidebar={() => (sidebarCollapsed.val = !sidebarCollapsed.val)} {condensedHeader} + {ownsPageHeader} /> void liveEditorDraftStoragePath?: string /** Indicator-only overrides for the sessions preview: the AutosaveIndicator @@ -180,6 +186,9 @@ pendingDraftPath?: string | undefined // Threaded to the `AutosaveIndicator` popover so its "Reset to // deployed" button can do the same thing the load-time toast offers. + /** `Exit & see details`: leaves the editor for the app's deployed page. A session hosts that + * page itself, so it flips the tab there rather than navigating out of the session. */ + onDetails?: (e: { path: string }) => void onResetToDeployed?: () => void | Promise // See ScriptBuilderProps — same semantics for the raw-app editor's // indicator. @@ -223,12 +232,14 @@ sidebarCollapsed = false, onToggleSidebar = undefined, condensedHeader = false, + ownsPageHeader = false, onNavigate = undefined, liveEditorDraftStoragePath = undefined, autosaveWorkspace = undefined, autosavePath = undefined, onDeploy = undefined, pendingDraftPath = $bindable(undefined), + onDetails, onResetToDeployed, loadedFromDraft = false, othersDraftsCount = 0, @@ -308,11 +319,34 @@ // Top-bar responsive collapse — container width, not viewport. let topbarWidth = $state(0) - const compactTopbar = $derived(topbarWidth > 0 && topbarWidth < 720) + // In the page header the buttons share the row with the breadcrumb, so they collapse sooner: + // the trail and the summary take ~430px of the same row, and the whole group — the menu, Diff, + // Jobs, Export, AI and Deploy — is ~560px. + const compactBelow = $derived(ownsPageHeader ? 1350 : 720) + const compactTopbar = $derived.by(() => { + const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth + return w > 0 && w < compactBelow + }) + // A phone's bar keeps the trail, the file sidebar's toggle, Diff and Deploy. AI stands down — + // the sidebar's own Ask AI is the way in at that width — while Jobs and the export are already + // in the menu, Jobs from `compactTopbar` (true at any phone width) and the export always. + const phoneTopbar = $derived( + ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR + ) // Top-bar button size + bar height. Condensed (session preview) uses the // smallest well-supported unified size (`sm`) so the bar is thinner. - const headerBtnSize = $derived(condensedHeader ? 'sm' : 'md') + // In the page header the buttons ride a 44px band, the same size the condensed session-pane + // header uses. + const headerBtnSize = $derived(condensedHeader || ownsPageHeader ? 'sm' : 'md') + + /** Held by the rename popover while it is open; the band's trail reads it so the path does not + * reflow under the pointer as the user types. */ + let pathSnapshot = $state(undefined) + + // The actions render under the band, outside the subtree RawAppEditor declared the operating + // workspace in, so it has to travel with them: OpenInSessionButton asks who the acting user is. + const headerContexts = new Map([operatingWorkspaceContextEntry()]) function closeSaveDrawer() { saveDrawerOpen = false @@ -903,121 +937,212 @@ errorByComponent={{}} /> -
- + ` placeholder, and the trail would name that instead of the path Deploy + // will create — which is why `newEditedPath` refuses the placeholder. Frozen while the + // rename popover is open so the trail holds still as the user types. + kind: 'app', + path: pathSnapshot ?? (newEditedPath || appPath || newPath || undefined), + summaryContent: appSummary, + pathTrigger: rawAppPathTrigger + }} + actions={rawAppHeaderActions} + contexts={headerContexts} + separator="none" + /> +{:else} +
+ -
- {#if onToggleSidebar} -
+{/if} + +{#snippet sidebarToggle()} + {#if onToggleSidebar} + - {/snippet} - +{/snippet} - +{#snippet rawAppPathTrigger(pathLabel: Snippet, triggerClass: string)} + +{/snippet} + +{#snippet appSummary()} + +
+ {#snippet summaryText()} + {emptyString(summary) ? 'Add a summary...' : summary} + {/snippet} + +
+{/snippet} + +{#snippet rawAppHeaderActions()} + + {@render sidebarToggle()} + {@render autosaveIndicator()} + {@render awarenessMark()} + {@render rawAppActions()} +{/snippet} + +{#snippet rawAppActions()} + + {#snippet buttonReplacement()} + + {/snippet} + + + -
+ -
+ Diff + +
-
- -
- +
({jobs?.length > 99 ? '99+' : (jobs?.length ?? 0)})
+
+ +
+ + + {#if !phoneTopbar} {#snippet fallback()} -
-
+ } + ] + : []) + ] + : undefined} + > + Deploy + +{/snippet} diff --git a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte index 557b2a13ad..8a79b4b8f9 100644 --- a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte @@ -277,6 +277,6 @@ src={iframeSrc} sandbox={sandboxAttr} referrerpolicy={unsandboxed ? undefined : 'no-referrer'} - class="w-full h-full min-h-screen bg-white border-none" + class="w-full h-full bg-white border-none" > {/if} diff --git a/frontend/src/lib/components/raw_apps/utils.ts b/frontend/src/lib/components/raw_apps/utils.ts index ade1d62d47..3070572785 100644 --- a/frontend/src/lib/components/raw_apps/utils.ts +++ b/frontend/src/lib/components/raw_apps/utils.ts @@ -294,6 +294,9 @@ export function unsandboxedRawAppHtml( App + + -{#snippet externalLinkHint()} - -{/snippet} - {#if !session || !runtime}
Session not found
{:else} @@ -411,95 +376,81 @@ sessions have their own empty-state affordances above. --> {#snippet sessionEmptyHint()}{/snippet} + {#snippet sessionHeader()} + renameSession(session.id, v)} + class="text-xs font-normal" + inputClass="!text-xs !font-normal" + /> + + summaryInput?.edit() + }, + ...(session.archived + ? // No Unarchive when the workspace is gone — it can't persist + // (putSession guard) and reconcile would re-archive it. + isUnavailable + ? [] + : [ + { + displayName: 'Unarchive', + icon: ArchiveRestore, + action: () => setSessionArchived(session.id, false) + } + ] + : [ + { + displayName: 'Archive', + icon: Archive, + action: () => archiveAndReset() + } + ]), + { + displayName: 'Delete', + icon: Trash2, + type: 'delete', + action: () => (deleteConfirmOpen = true) + } + ]} + > + {#snippet buttonReplacement()} + + + + {/snippet} + + {/snippet} + + {#if headerInPage} + + + {/if} +
-
- renameSession(session.id, v)} - class="text-sm font-semibold" - inputClass="!text-sm !font-semibold" - /> - summaryInput?.edit() - }, - ...(session.archived - ? // No Unarchive when the workspace is gone — it can't persist - // (putSession guard) and reconcile would re-archive it. - isUnavailable - ? [] - : [ - { - displayName: 'Unarchive', - icon: ArchiveRestore, - action: () => setSessionArchived(session.id, false) - } - ] - : [ - { - displayName: 'Archive', - icon: Archive, - action: () => archiveAndReset() - } - ]), - { - displayName: 'Delete', - icon: Trash2, - type: 'delete', - action: () => (deleteConfirmOpen = true) - } - ]} - > - {#snippet buttonReplacement()} - - - - {/snippet} - - {#if acting && hasFirstUserMessage} - -
- Acting on - - - - -
- {/if} -
{f.name} {#if f.is_dev_workspace} - {devBadgeText(f.dev_workspace_label)} + {/if} {#if isSelected(f.id)} diff --git a/frontend/src/lib/components/sessions/sessionMode.svelte.ts b/frontend/src/lib/components/sessions/sessionMode.svelte.ts index a2c20e08a8..294cca19fb 100644 --- a/frontend/src/lib/components/sessions/sessionMode.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionMode.svelte.ts @@ -47,6 +47,30 @@ export function withMenuHidden(url: string, workspaceId?: string): string { } } +// Remembers `nomenubar` for the rest of this iframe's life. Navigating inside a preview drops +// the query param (client-side routing and full loads both), and the global nav must not pop +// back in. sessionStorage so it survives a reload within the frame; the top window never writes +// it, so an ordinary tab and the oauth callback still toggle normally. +export function rememberMenuHidden(url: URL): void { + if (typeof window === 'undefined' || window.self === window.top) return + if (url.searchParams.get('nomenubar') !== 'true') return + try { + sessionStorage.setItem('nomenubar_embedded', 'true') + } catch {} +} + +// Whether this page is shown without the workspace navigation. The layout drops the sidebar and +// the band's trail, so a page whose only control lives in the band has to carry it itself — see +// InWorkspaceAppViewer's Edit button. Read this rather than the raw query param: a preview tab +// that has navigated no longer carries it. +export function isMenuHidden(url: URL): boolean { + return ( + url.searchParams.get('nomenubar') === 'true' || + url.pathname.startsWith('/oauth/callback/') || + isSessionPreviewFrame() + ) +} + // True when this window is a sessions-preview iframe: embedded, with the `nomenubar` flag // the preview always sets and the logged layout stickies into sessionStorage. export function isSessionPreviewFrame(): boolean { diff --git a/frontend/src/lib/components/sidebar/MenuButton.svelte b/frontend/src/lib/components/sidebar/MenuButton.svelte index 3d0286a4af..e6f23361bf 100644 --- a/frontend/src/lib/components/sidebar/MenuButton.svelte +++ b/frontend/src/lib/components/sidebar/MenuButton.svelte @@ -54,6 +54,8 @@ // Classes for the label line only — `class` reaches the button, the label and the // sublabel alike, which is the wrong tool for colouring one line of the two. labelClass?: string | undefined + // Draw the colour disc at 20px rather than 26px, for denser rows such as the page header. + compact?: boolean } let { @@ -79,7 +81,8 @@ emphasizeLabel = false, disableTitle = false, ariaLabel = undefined, - labelClass = undefined + labelClass = undefined, + compact = false }: Props = $props() let buttonRef: HTMLButtonElement | HTMLAnchorElement | undefined = $state(undefined) @@ -94,7 +97,7 @@ @@ -119,7 +122,8 @@ {href} data-light-mode={lightMode} class={twMerge( - 'group flex items-center px-2 py-2 font-light rounded-md gap-2 w-full', + 'group flex items-center px-2 py-2 font-light rounded-md gap-2', + compact ? 'w-auto' : 'w-full', sidebarClasses.hoverBg, 'transition-all relative', sublabel ? 'h-10' : 'h-8', @@ -134,11 +138,23 @@ {#if icon} {@const SvelteComponent = icon} {#if color} - - - + {@const disc = compact ? 20 : 26} + {@const glyph = compact ? 12 : 16} + + + @@ -158,7 +174,7 @@ {/if} {/if} -
+
{#if !isCollapsed && label}
- import { - Home, - Menu as MenuIcon, - Play, - Settings, - LogOut, - Moon, - Sun, - Code2, - LayoutDashboard, - Calendar, - ServerCog, - Table2, - GraduationCap - } from 'lucide-svelte' - import SwitchWorkspaceSubmenu from './SwitchWorkspaceSubmenu.svelte' - import { - OPERATOR_MAIN_LINKS, - OPERATOR_SECONDARY_LINKS, - OPERATOR_TRIGGER_LINKS, - type OperatorMenuLink, - type OperatorTriggerLink - } from './operatorRoutes' - import { base } from '$lib/base' - import { page } from '$app/state' - import { TOUR_PARAM, TOUR_PARAM_VALUE } from '$lib/components/tutorials/operatorTour' - - import MultiplayerMenu from './MultiplayerMenu.svelte' - import { Plus } from 'lucide-svelte' - import { - enterpriseLicense, - superadmin, - usedTriggerKinds, - userWorkspaces, - workspaceColor, - workspaceStore - } from '$lib/stores' - import { twMerge } from 'tailwind-merge' - import { getContrastTextColor } from '$lib/utils' - import { USER_SETTINGS_HASH } from './settings' - import { logout } from '$lib/logoutKit' - import DarkModeObserver from '../DarkModeObserver.svelte' - import BarsStaggered from '../icons/BarsStaggered.svelte' - import { Menu, Menubar, MenuItem } from '$lib/components/meltComponents' - import MenuButton, { sidebarClasses } from './MenuButton.svelte' - import MenuLink from './MenuLink.svelte' - import type { FavoriteKind } from './FavoriteMenu.svelte' - let darkMode: boolean = $state(false) - let showExtraTriggers = $state(false) - // The menu opens on hover, so this doubles as "the pointer is on the button": - // it slides the workspace name out beside the icon, and keeps it out once a - // click has pinned the menu open and the pointer has moved away. - let menuOpen = $state(false) - - interface Props { - isCollapsed?: boolean - favoriteLinks?: { - label: string - href: string - kind: FavoriteKind - }[] - } - - let { isCollapsed = false, favoriteLinks = [] }: Props = $props() - - const MAIN_LINK_ICONS: Record = { home: Home, runs: Play, schedules: Calendar } - let activeWorkspace = $derived($userWorkspaces?.find((w) => w.id === $workspaceStore)) - let workspaceName = $derived(activeWorkspace?.name ?? $workspaceStore ?? '') - // Home is where an operator lands and gets their bearings, so the name stays out there - // rather than waiting for a hover. Everywhere else the button is the page's own chrome - // and stays out of the way until asked. - let showWorkspaceName = $derived(menuOpen || page.url.pathname === `${base}/`) - // Most workspaces never get a colour (it is only ever set explicitly), and this button - // floats over page content with nothing else behind it — so the disc falls back to a - // neutral rather than vanishing, keeping the glyph on a ground of its own everywhere. - // A token rather than a hex so it follows the theme; getContrastTextColor only parses - // hex, so it returns undefined here and the glyph keeps its themed `text-hint`. - const NEUTRAL_DISC = 'rgb(var(--color-surface-sunken))' - - let mainMenuLinks = $derived( - OPERATOR_MAIN_LINKS.map((link) => ({ ...link, icon: MAIN_LINK_ICONS[link.id] })).filter( - (link) => link.id === 'home' || filterLink(link) - ) - ) - - function filterLink(link: OperatorMenuLink) { - if (!$userWorkspaces || !$workspaceStore) return false - return activeWorkspace?.operator_settings?.[link.id] === true - } - let secondMenuLinks: OperatorMenuLink[] = $derived(OPERATOR_SECONDARY_LINKS.filter(filterLink)) - let allTriggerLinks: OperatorTriggerLink[] = $derived(OPERATOR_TRIGGER_LINKS.filter(filterLink)) - let secondMenuTriggerLinks = $derived( - allTriggerLinks.filter((link) => $usedTriggerKinds.includes(link.kind)) - ) - let extraTriggerLinks = $derived( - allTriggerLinks.filter((link) => !$usedTriggerKinds.includes(link.kind)) - ) - - - - {#snippet children({ createMenu })} - (showExtraTriggers = false)} - > - {#snippet triggr({ trigger, pinned })} - {@const iconColor = getContrastTextColor($workspaceColor)} - -
- -
- {/snippet} - {#snippet children({ item, builders })} -
- {#each favoriteLinks ?? [] as favorite (favorite.href)} - - - {#if favorite.kind == 'script'} - - {:else if favorite.kind == 'flow'} - - {:else if favorite.kind == 'app' || favorite.kind == 'raw_app'} - - {:else if favorite.kind == 'asset'} - - {/if} - - - {favorite.label} - - - {/each} -
- - {#each mainMenuLinks as menuLink (menuLink.href ?? menuLink.label)} - - {/each} - -
-
- - - Account settings - - - - - Take the tour - -
- -
- { - if (!document.documentElement.classList.contains('dark')) { - document.documentElement.classList.add('dark') - window.localStorage.setItem('dark-mode', 'dark') - } else { - document.documentElement.classList.remove('dark') - window.localStorage.setItem('dark-mode', 'light') - } - }} - lightMode - class={twMerge( - 'w-full flex gap-3.5 px-2 py-2', - 'transition-colors', - sidebarClasses.text, - 'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary' - )} - {item} - > - {#if darkMode} - - {:else} - - {/if} - Switch theme - - - - {#if $superadmin} - - - Instance settings - - {/if} - - logout()} - class={twMerge( - 'flex flex-row gap-3.5 items-center px-2 py-2 w-full', - 'text-primary text-xs', - 'cursor-pointer', - 'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary' - )} - {item} - > - - Sign out - -
-
- {#snippet renderSecondMenuLinks(menuLinks: OperatorMenuLink[])} - {#each menuLinks as menuLink (menuLink.href ?? menuLink.label)} - - {menuLink.label} - - {/each} - {/snippet} - {#if secondMenuLinks.length || secondMenuTriggerLinks.length || extraTriggerLinks.length} -
- {#if secondMenuLinks.length}
{@render renderSecondMenuLinks(secondMenuLinks)}
{/if} - {#if secondMenuTriggerLinks.length}
{@render renderSecondMenuLinks(secondMenuTriggerLinks)}
{/if} - {#if extraTriggerLinks.length}
- -
{ - // This row expands the list below it instead of acting on the selection, and - // melt keeps the menu open only for a click it sees as defaultPrevented. - // Svelte delegates onclick to the root, which runs after melt's own listener, - // and a capture listener on the item itself would be ordered only by - // registration, so an ancestor's capture phase is what reliably wins. - e.preventDefault() - showExtraTriggers = !showExtraTriggers - }} - > - - - More triggers - -
- {#if showExtraTriggers} - {#each extraTriggerLinks as menuLink (menuLink.href)} - - {menuLink.label} - - {/each} - {/if} -
{/if} -
- {/if} - {#if $enterpriseLicense} - - {/if} -
-
- {/snippet} -
- {/snippet} -
- - diff --git a/frontend/src/lib/components/sidebar/SettingsMenu.svelte b/frontend/src/lib/components/sidebar/SettingsMenu.svelte index e0951f37cd..fed773c84c 100644 --- a/frontend/src/lib/components/sidebar/SettingsMenu.svelte +++ b/frontend/src/lib/components/sidebar/SettingsMenu.svelte @@ -18,7 +18,8 @@ Crown, Gauge, Trash2, - KeyRound + KeyRound, + GraduationCap } from 'lucide-svelte' import { base } from '$app/paths' import { goto } from '$lib/navigation' @@ -38,6 +39,8 @@ import { USER_SETTINGS_HASH, SUPERADMIN_SETTINGS_HASH } from './settings' import { accountSetup } from './accountSetup.svelte' import { EXECUTIONS_HINT } from './executionsHint' + import { sidebarPageAllowed } from './operatorRoutes' + import { TOUR_PARAM, TOUR_PARAM_VALUE } from '$lib/components/tutorials/operatorTour' import { userWorkspaces, workspaceStore, @@ -248,6 +251,15 @@ icon: Settings, action: () => goto(USER_SETTINGS_HASH) }, + ...($userStore?.operator + ? [ + { + displayName: 'Take the tour', + icon: GraduationCap, + action: () => goto(`${base}/?${TOUR_PARAM}=${TOUR_PARAM_VALUE}`) + } + ] + : []), ...(cloudHosted && $isPremiumStore === false ? [ { @@ -307,6 +319,7 @@ label="Workers" href="{base}/workers" icon={ServerCog} + disabled={!sidebarPageAllowed($userStore?.operator, currentWs, 'workers')} {isCollapsed} aiId="sidebar-menu-link-workers" aiDescription="Button to navigate to workers" @@ -352,7 +365,7 @@ label="Audit logs" href="{base}/audit_logs" icon={Eye} - disabled={$userStore?.operator} + disabled={!sidebarPageAllowed($userStore?.operator, currentWs, 'audit_logs')} {isCollapsed} aiId="sidebar-menu-link-audit-logs" aiDescription="Button to navigate to audit logs" diff --git a/frontend/src/lib/components/sidebar/SidebarContent.svelte b/frontend/src/lib/components/sidebar/SidebarContent.svelte index b43bc2988e..d8f27ff0c6 100644 --- a/frontend/src/lib/components/sidebar/SidebarContent.svelte +++ b/frontend/src/lib/components/sidebar/SidebarContent.svelte @@ -12,31 +12,25 @@ } from '$lib/stores' import { isForkOwner } from '$lib/utils/workspaceHierarchy' import { SIDEBAR_SHOW_SCHEDULES } from '$lib/consts' + import { NAV_PAGES } from './navPages' import { BookOpen, ServerCog, - Boxes, Calendar, - DollarSign, Eye, Logs, FolderCog, - FolderOpen, Github, HelpCircle, - Home, LogOut, Newspaper, - Play, Route, Settings, UserCog, - Users, Plus, Unplug, AlertCircle, Database, - Pyramid, MailIcon, ChevronDown, ChevronRight @@ -72,6 +66,7 @@ import GoogleCloudIcon from '../icons/triggers/GoogleCloudIcon.svelte' import AzureIcon from '../icons/triggers/AzureIcon.svelte' import { leaveCurrentWorkspace } from './leaveWorkspace' + import { sidebarPageAllowed, type OperatorPageKey } from './operatorRoutes' import { markChangelogsOpened, readRecentChangelogs } from './changelogs' const { recent: recentChangelogs, hasNew } = readRecentChangelogs() @@ -188,78 +183,63 @@ return count } + const currentWs = $derived($userWorkspaces?.find((w) => w.id === $workspaceStore)) + function allows(key: OperatorPageKey) { + return sidebarPageAllowed($userStore?.operator, currentWs, key) + } + const itemClass = twMerge( 'text-secondary font-normal w-full block px-4 py-2 text-2xs data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary' ) - let mainMenuLinks = $derived( - [ - { - label: 'Home', - href: `${base}/`, - icon: Home, - aiId: 'sidebar-menu-link-home', - aiDescription: - "Button to navigate to home which contains all the user's scripts, flows and apps" - }, - { - label: 'Runs', - href: `${base}/runs`, - icon: Play, - aiId: 'sidebar-menu-link-runs', - aiDescription: 'Button to navigate to runs', - onclick: () => { - setTimeout(() => { - window.dispatchEvent(new Event('popstate')) - }, 100) - } - }, - { - label: 'Variables', - href: `${base}/variables`, - icon: DollarSign, - disabled: $userStore?.operator, - aiId: 'sidebar-menu-link-variables', - aiDescription: 'Button to navigate to variables' - }, - { - label: 'Resources', - href: `${base}/resources`, - icon: Boxes, - disabled: $userStore?.operator, - aiId: 'sidebar-menu-link-resources', - aiDescription: 'Button to navigate to resources' - }, - { - label: 'Assets', - href: `${base}/assets`, - icon: Pyramid, - aiId: 'sidebar-menu-link-assets', - aiDescription: 'Button to navigate to assets' - }, - { - label: 'Folders', - href: `${base}/folders`, - icon: FolderOpen, - disabled: $userStore?.operator, - aiId: 'sidebar-menu-link-folders', - aiDescription: 'Button to navigate to folders' - }, - { - label: 'Groups', - href: `${base}/groups`, - icon: Users, - disabled: $userStore?.operator, - aiId: 'sidebar-menu-link-groups', - aiDescription: 'Button to navigate to groups' + // Labels, paths and icons come from the shared page table, so the nav and the page header's + // breadcrumb always call a page the same thing. Everything else here is nav-only. + const MAIN_MENU_EXTRAS: Record< + string, + { aiId: string; aiDescription: string; onclick?: () => void } + > = { + Home: { + aiId: 'sidebar-menu-link-home', + aiDescription: + "Button to navigate to home which contains all the user's scripts, flows and apps" + }, + Runs: { + aiId: 'sidebar-menu-link-runs', + aiDescription: 'Button to navigate to runs', + onclick: () => { + setTimeout(() => { + window.dispatchEvent(new Event('popstate')) + }, 100) } - ].filter((l) => !excludeMainLabels.includes(l.label)) + }, + Variables: { + aiId: 'sidebar-menu-link-variables', + aiDescription: 'Button to navigate to variables' + }, + Resources: { + aiId: 'sidebar-menu-link-resources', + aiDescription: 'Button to navigate to resources' + }, + Assets: { aiId: 'sidebar-menu-link-assets', aiDescription: 'Button to navigate to assets' }, + Folders: { aiId: 'sidebar-menu-link-folders', aiDescription: 'Button to navigate to folders' }, + Groups: { aiId: 'sidebar-menu-link-groups', aiDescription: 'Button to navigate to groups' } + } + let mainMenuLinks = $derived( + NAV_PAGES.filter((p) => p.label in MAIN_MENU_EXTRAS) + .map((p) => ({ + label: p.label, + href: p.path === '/' ? `${base}/` : `${base}${p.path}`, + icon: p.icon, + disabled: p.operatorKey ? !allows(p.operatorKey) : false, + ...MAIN_MENU_EXTRAS[p.label] + })) + .filter((l) => !excludeMainLabels.includes(l.label)) ) let defaultExtraTriggerLinks = $derived([ { label: 'HTTP', href: '/routes', icon: Route, - disabled: $userStore?.operator, + disabled: !allows('triggers'), kind: 'http', aiId: 'sidebar-menu-link-http', aiDescription: 'Button to navigate to HTTP routes' @@ -268,7 +248,7 @@ label: 'WebSockets', href: '/websocket_triggers', icon: Unplug, - disabled: $userStore?.operator, + disabled: !allows('triggers'), kind: 'ws', aiId: 'sidebar-menu-link-ws', aiDescription: 'Button to navigate to websocket triggers' @@ -277,7 +257,7 @@ label: 'Postgres', href: '/postgres_triggers', icon: Database, - disabled: $userStore?.operator, + disabled: !allows('triggers'), kind: 'postgres', aiId: 'sidebar-menu-link-postgres', aiDescription: 'Button to navigate to Postgres triggers' @@ -286,7 +266,7 @@ label: 'Kafka' + ($enterpriseLicense ? '' : ' (EE)'), href: '/kafka_triggers', icon: KafkaIcon, - disabled: $userStore?.operator || !$enterpriseLicense, + disabled: !allows('triggers') || !$enterpriseLicense, kind: 'kafka', aiId: 'sidebar-menu-link-kafka', aiDescription: 'Button to navigate to Kafka triggers' @@ -295,7 +275,7 @@ label: 'NATS' + ($enterpriseLicense ? '' : ' (EE)'), href: '/nats_triggers', icon: NatsIcon, - disabled: $userStore?.operator || !$enterpriseLicense, + disabled: !allows('triggers') || !$enterpriseLicense, kind: 'nats', aiId: 'sidebar-menu-link-nats', aiDescription: 'Button to navigate to NATS triggers' @@ -304,7 +284,7 @@ label: 'SQS' + ($enterpriseLicense ? '' : ' (EE)'), href: '/sqs_triggers', icon: AwsIcon, - disabled: $userStore?.operator || !$enterpriseLicense, + disabled: !allows('triggers') || !$enterpriseLicense, kind: 'sqs', aiId: 'sidebar-menu-link-sqs', aiDescription: 'Button to navigate to SQS triggers' @@ -313,7 +293,7 @@ label: 'GCP Pub/Sub' + ($enterpriseLicense ? '' : ' (EE)'), href: '/gcp_triggers', icon: GoogleCloudIcon, - disabled: $userStore?.operator || !$enterpriseLicense, + disabled: !allows('triggers') || !$enterpriseLicense, kind: 'gcp', aiId: 'sidebar-menu-link-gcp', aiDescription: 'Button to navigate to GCP Pub/Sub triggers' @@ -322,7 +302,7 @@ label: 'Azure Event Grid' + ($enterpriseLicense ? '' : ' (EE)'), href: '/azure_triggers', icon: AzureIcon, - disabled: $userStore?.operator || !$enterpriseLicense, + disabled: !allows('triggers') || !$enterpriseLicense, kind: 'azure', aiId: 'sidebar-menu-link-azure', aiDescription: 'Button to navigate to Azure Event Grid triggers' @@ -331,7 +311,7 @@ label: 'MQTT', href: '/mqtt_triggers', icon: MqttIcon, - disabled: $userStore?.operator, + disabled: !allows('triggers'), kind: 'mqtt', aiId: 'sidebar-menu-link-mqtt', aiDescription: 'Button to navigate to MQTT triggers' @@ -340,7 +320,7 @@ label: 'AMQP', href: '/amqp_triggers', icon: AmqpIcon, - disabled: $userStore?.operator, + disabled: !allows('triggers'), kind: 'amqp', aiId: 'sidebar-menu-link-amqp', aiDescription: 'Button to navigate to AMQP triggers' @@ -349,7 +329,7 @@ label: 'Email', href: '/email_triggers', icon: MailIcon, - disabled: $userStore?.operator, + disabled: !allows('triggers'), kind: 'email', aiId: 'sidebar-menu-link-email', aiDescription: 'Button to navigate to Email triggers' @@ -375,7 +355,7 @@ label: 'Schedules', href: `${base}/schedules`, icon: Calendar, - disabled: !SIDEBAR_SHOW_SCHEDULES || $userStore?.operator, + disabled: !SIDEBAR_SHOW_SCHEDULES || !allows('schedules'), aiId: 'sidebar-menu-link-schedules', aiDescription: 'Button to navigate to schedules' }, @@ -390,7 +370,6 @@ ) // Admins, superadmins, and fork creators reach workspace settings (the latter // for the fork members screen; see isForkOwner / backend authorize_fork_owner_add_user). - const currentWs = $derived($userWorkspaces?.find((w) => w.id === $workspaceStore)) const canManageWorkspace = $derived( $userStore?.is_admin || $superadmin || isForkOwner(currentWs, $userStore?.email) ) @@ -460,7 +439,7 @@ label: 'Workers', href: `${base}/workers`, icon: ServerCog, - disabled: $userStore?.operator, + disabled: !allows('workers'), aiId: 'sidebar-menu-link-workers', aiDescription: 'Button to navigate to workers' }, @@ -509,7 +488,7 @@ label: 'Audit logs', href: `${base}/audit_logs`, icon: Eye, - disabled: $userStore?.operator, + disabled: !allows('audit_logs'), aiId: 'sidebar-menu-link-audit-logs', aiDescription: 'Button to navigate to audit logs' } @@ -551,7 +530,7 @@ {#each triggerMenuLinks as menuLink (menuLink.href ?? menuLink.label)} {/each} - {#if extraTriggerLinks.length > 0 && !$userStore?.operator} + {#if extraTriggerLinks.length > 0 && allows('triggers')} {#snippet triggr({ trigger })} - import { untrack } from 'svelte' - import { melt } from '@melt-ui/svelte' - import type { MenubarMenuBuilders, MenubarMenuElements } from '@melt-ui/svelte' - import { Building, ChevronRight } from 'lucide-svelte' - import { twMerge } from 'tailwind-merge' - import { sidebarClasses } from './MenuButton.svelte' - import WorkspacePickerBody from './WorkspacePickerBody.svelte' - import { MenuItem } from '$lib/components/meltComponents' - import { clearWorkspaceFromStorage } from '$lib/stores' - import { base } from '$lib/base' - - interface Props { - // Submenu builders from the enclosing melt Menu — createSubmenu must be - // called against this specific menu instance (same pattern as DropdownSubmenuItem). - builders: MenubarMenuBuilders - // Melt uses the parent menu's item builder for submenu rows too, so the - // picker body's MenuItems work here unchanged. - item: MenubarMenuElements['item'] - } - - let { builders, item }: Props = $props() - - const { - elements: { subTrigger, subMenu }, - states: { subOpen } - } = untrack(() => builders).createSubmenu() - - - - -{#if $subOpen} -
- - -
- clearWorkspaceFromStorage()} - class={twMerge( - 'w-full flex flex-row gap-2 px-4 py-2 font-normal', - sidebarClasses.text, - 'hover:bg-surface-hover hover:text-primary', - 'data-[highlighted]:bg-surface-hover data-[highlighted]:text-primary' - )} - {item} - > - All workspaces - -
-
-{/if} diff --git a/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte b/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte index ddcbd9ca95..3902715d5a 100644 --- a/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte +++ b/frontend/src/lib/components/sidebar/WorkspaceMenu.svelte @@ -14,9 +14,19 @@ createMenu: MenubarBuilders['createMenu'] // When used outside of the side bar, where links to workspace settings and such don't make as much sense. strictWorkspaceSelect?: boolean + // Denser trigger for the page header bar: a smaller disc and a lighter name, so the + // workspace reads as the scope of the breadcrumb beside it rather than a heading. + compact?: boolean } - let { isCollapsed = false, createMenu, strictWorkspaceSelect = false }: Props = $props() + let { + isCollapsed = false, + createMenu, + strictWorkspaceSelect = false, + compact = false + }: Props = $props() + + let menu: Menu | undefined = $state(undefined) // The active workspace's family root — shown in the trigger so a forked active workspace still // surfaces its family name here (the fork itself is shown in the breadcrumb). Resolved exactly @@ -29,7 +39,7 @@ const ambiguousNames = $derived(ambiguousWorkspaceNames($userWorkspaces)) - + {#snippet triggr({ trigger })} @@ -41,7 +51,7 @@ name={currentFamily?.name ?? $workspaceStore ?? ''} id={currentFamily?.id ?? $workspaceStore ?? ''} disablePopup={isCollapsed} - class="block w-full" + class={compact ? 'block' : 'block w-full'} > @@ -63,6 +75,11 @@ {#snippet children({ item })} - + menu?.close()} + /> {/snippet} diff --git a/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte b/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte index fa4468fa6a..e25dc83286 100644 --- a/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte +++ b/frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte @@ -1,5 +1,5 @@ -
+
{#snippet trigger()} {/snippet} diff --git a/frontend/src/lib/components/sidebar/navDetached.svelte.ts b/frontend/src/lib/components/sidebar/navDetached.svelte.ts new file mode 100644 index 0000000000..19568eaddc --- /dev/null +++ b/frontend/src/lib/components/sidebar/navDetached.svelte.ts @@ -0,0 +1,28 @@ +import { fromStore } from 'svelte/store' +import { userStore } from '$lib/stores' +import { useLocalStorageValue } from '$lib/svelte5Utils.svelte' + +// One preference per role rather than one per account. The same person is both — an operator in +// one workspace, a developer in the next — and the two want different things from the sidebar, so +// a single key would carry whichever was pressed last into the other role and never give it back. +// Empty until that role presses detach or attach once. +const operatorPref = useLocalStorageValue('nav_detached_operator', '', 'string') +const devPref = useLocalStorageValue('nav_detached_dev', '', 'string') +const user = fromStore(userStore) + +/** Whether the sidebar is detached: hidden behind a floating button that slides it in over the page. */ +export const navDetached = { + get val(): boolean { + // Unasked, operators start detached: many of them navigate through a workspace's own app, + // and a docked sidebar would take its width. + const operator = !!user.current?.operator + const pref = operator ? operatorPref : devPref + if (pref.val === 'true') return true + if (pref.val === 'false') return false + return operator + }, + set val(detached: boolean) { + const pref = user.current?.operator ? operatorPref : devPref + pref.val = String(detached) + } +} diff --git a/frontend/src/lib/components/sidebar/navHandlePlacement.svelte.ts b/frontend/src/lib/components/sidebar/navHandlePlacement.svelte.ts new file mode 100644 index 0000000000..5fb7b43860 --- /dev/null +++ b/frontend/src/lib/components/sidebar/navHandlePlacement.svelte.ts @@ -0,0 +1,50 @@ +import { BAR_HEIGHT_PX } from '../pageHeaderRegistry.svelte' + +// Opening the detached sidebar's card, from the band's own handle or from the corner control that +// stands in for it on a page which hides the band. +let opener: (() => void) | undefined +// Closing it once the pointer is off both the handle and the card, which the layout owns because +// the card is its markup. +let hoverClose: { schedule: () => void; cancel: () => void } | undefined + +// Where the card's top edge goes: the header's bottom edge, so the two meet in a line rather than +// the card starting at whatever height the control that opened it happens to have. Measured at open +// time rather than fixed, so the card follows the header's own height. On a page that hides the +// header, the header is still what the card will sit under — it comes down with it — so its height +// is read even while it is off-screen. The fallback matches the header's resting height. +const FALLBACK_CARD_TOP = BAR_HEIGHT_PX +const GAP_UNDER_HANDLE = 6 +let cardTop = $state(FALLBACK_CARD_TOP) + +export const navHandleSlot = { + /** Distance from the top of the viewport to the card's top edge, in px. */ + get cardTop(): number { + return cardTop + }, + /** The root layout owns the card, so it registers how to open and hover-close it. */ + setOpener(open: () => void, close?: { schedule: () => void; cancel: () => void }) { + opener = open + hoverClose = close + }, + /** Pointer left the handle: close, unless it lands on the card before the delay is up. */ + scheduleClose() { + hoverClose?.schedule() + }, + /** + * Opens the card under the page header, or under `anchor` when there is no header to sit + * beneath — an embed, where whatever reached for the nav is all the card has to hang from. + */ + open(anchor?: Element | null) { + hoverClose?.cancel() + const band = document.querySelector('[data-page-header]') + const bandHeight = band?.getBoundingClientRect().height + if (bandHeight) { + cardTop = Math.round(bandHeight) + } else { + const bottom = anchor?.getBoundingClientRect().bottom + cardTop = + bottom != null && bottom > 0 ? Math.round(bottom) + GAP_UNDER_HANDLE : FALLBACK_CARD_TOP + } + opener?.() + } +} diff --git a/frontend/src/lib/components/sidebar/navPages.test.ts b/frontend/src/lib/components/sidebar/navPages.test.ts new file mode 100644 index 0000000000..6e68912a15 --- /dev/null +++ b/frontend/src/lib/components/sidebar/navPages.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest' +import { navPageFor } from './navPages' + +describe('navPageFor', () => { + it('names the page a route belongs to, under the app base', () => { + expect(navPageFor('/base/runs', '/base')?.label).toBe('Runs') + }) + + it('keeps an item route on its page', () => { + expect(navPageFor('/runs/f/demo/x', '')?.label).toBe('Runs') + }) + + it('does not let Home swallow every route', () => { + expect(navPageFor('/audit_logs', '')?.label).toBe('Audit logs') + expect(navPageFor('/', '')?.label).toBe('Home') + }) + + it('falls back to the first segment for a route the table does not name', () => { + expect(navPageFor('/service_logs/x', '')).toMatchObject({ + label: 'Service logs', + path: '/service_logs' + }) + }) +}) diff --git a/frontend/src/lib/components/sidebar/navPages.ts b/frontend/src/lib/components/sidebar/navPages.ts new file mode 100644 index 0000000000..b9dc2eff63 --- /dev/null +++ b/frontend/src/lib/components/sidebar/navPages.ts @@ -0,0 +1,68 @@ +import { + Boxes, + Calendar, + DollarSign, + Eye, + FolderOpen, + Home, + HardHat, + Play, + Pyramid, + Unplug, + Users +} from 'lucide-svelte' +import type { OperatorPageKey } from './operatorRoutes' + +/** + * The names the page header's breadcrumb gives the workspace's own pages, and the rows the + * sidebar's main group is built from. + * + * Every entry names a page for the breadcrumb, and its `icon` goes there too. Only the entries + * `SidebarContent`'s `MAIN_MENU_EXTRAS` also lists become sidebar links from here — Schedules, + * HTTP routes, Workers and Audit logs are grouped differently there and carry their own row, so + * for those four the `operatorKey` below goes unread. Keep the labels in step with those rows by + * hand. + */ +export type NavPage = { + label: string + /** Path under `base`, matched against the current route by longest prefix. */ + path: string + icon: any + /** The `operator_settings` key that admits an operator, when the page has one. */ + operatorKey?: OperatorPageKey +} + +export const NAV_PAGES: NavPage[] = [ + { label: 'Home', path: '/', icon: Home }, + { label: 'Runs', path: '/runs', icon: Play, operatorKey: 'runs' }, + { label: 'Variables', path: '/variables', icon: DollarSign, operatorKey: 'variables' }, + { label: 'Resources', path: '/resources', icon: Boxes, operatorKey: 'resources' }, + { label: 'Assets', path: '/assets', icon: Pyramid, operatorKey: 'assets' }, + { label: 'Folders', path: '/folders', icon: FolderOpen, operatorKey: 'folders' }, + { label: 'Groups', path: '/groups', icon: Users, operatorKey: 'groups' }, + { label: 'Schedules', path: '/schedules', icon: Calendar, operatorKey: 'schedules' }, + // "Triggers" is the group the sidebar files this under, not the page: the row there reads HTTP. + { label: 'HTTP routes', path: '/routes', icon: Unplug, operatorKey: 'triggers' }, + { label: 'Workers', path: '/workers', icon: HardHat, operatorKey: 'workers' }, + { label: 'Audit logs', path: '/audit_logs', icon: Eye, operatorKey: 'audit_logs' } +] + +/** + * The page a route belongs to. Longest match wins, so `/runs/f/demo/x` is still Runs, and a route + * the table does not name falls back to its first segment (`/service_logs` → "Service logs"). + */ +export function navPageFor(pathname: string, base: string): NavPage | undefined { + const path = pathname.startsWith(base) ? pathname.slice(base.length) || '/' : pathname + const match = NAV_PAGES.filter( + (p) => p.path === path || (p.path !== '/' && path.startsWith(p.path + '/')) + ).sort((a, b) => b.path.length - a.path.length)[0] + if (match) return match + const first = path.split('/').filter(Boolean)[0] + if (!first) return undefined + const label = first.replace(/_/g, ' ') + return { + label: label.charAt(0).toUpperCase() + label.slice(1), + path: `/${first}`, + icon: undefined + } +} diff --git a/frontend/src/lib/components/sidebar/operatorRoutes.test.ts b/frontend/src/lib/components/sidebar/operatorRoutes.test.ts new file mode 100644 index 0000000000..bf14926d00 --- /dev/null +++ b/frontend/src/lib/components/sidebar/operatorRoutes.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest' +import { sidebarPageAllowed } from './operatorRoutes' +import type { UserWorkspace } from '$lib/stores' + +const ws = (operator_settings: any): UserWorkspace => + ({ id: 'w', name: 'w', username: 'u', operator_settings }) as UserWorkspace + +describe('sidebarPageAllowed', () => { + it('shows every page to anyone who is not an operator', () => { + expect(sidebarPageAllowed(false, ws(null), 'runs')).toBe(true) + expect(sidebarPageAllowed(undefined, undefined, 'runs')).toBe(true) + }) + + it('admits an operator only where the workspace turned the page on', () => { + expect(sidebarPageAllowed(true, ws({ runs: true }), 'runs')).toBe(true) + expect(sidebarPageAllowed(true, ws({ runs: false }), 'runs')).toBe(false) + }) + + it('withholds a page the settings do not mention', () => { + // `=== true`, not truthiness: a right absent from the jsonb, or settings that were never + // written, must not read as granted. + expect(sidebarPageAllowed(true, ws({ variables: true }), 'runs')).toBe(false) + expect(sidebarPageAllowed(true, ws(null), 'runs')).toBe(false) + expect(sidebarPageAllowed(true, undefined, 'runs')).toBe(false) + }) +}) diff --git a/frontend/src/lib/components/sidebar/operatorRoutes.ts b/frontend/src/lib/components/sidebar/operatorRoutes.ts index 1abd7aeb93..6824c36c62 100644 --- a/frontend/src/lib/components/sidebar/operatorRoutes.ts +++ b/frontend/src/lib/components/sidebar/operatorRoutes.ts @@ -1,61 +1,41 @@ -import { base } from '$lib/base' +import type { OperatorSettings } from '$lib/gen' import type { UserWorkspace } from '$lib/stores' /** - * The pages an operator can reach, each paired with the `operator_settings` key - * that admits them — the operator menu lists only the entries the active - * workspace permits. + * An `operator_settings` key that admits an operator to one page (`triggers` to all of them). + * + * Only the visibility flags: the same object also carries the write rights `builder_flows`, + * `manage_schedules` and `manage_triggers`, which the `=== true` rule below answers wrongly. A + * write right is granted unless withdrawn, so its absence means "never configured" rather than + * "no" — see `docs/operator-write-rights.md`. */ -export type OperatorMenuLink = { - label: string - /** `operator_settings` key gating the page. */ - id: string - href: string +export type OperatorPageKey = Extract< + keyof NonNullable, + | 'runs' + | 'schedules' + | 'resources' + | 'variables' + | 'assets' + | 'audit_logs' + | 'triggers' + | 'groups' + | 'folders' + | 'workers' +> + +/** + * Whether the sidebar shows the page behind `key`. Everyone but an operator sees every page + * (other gates, such as admin-only entries, still apply); an operator sees only the pages the + * workspace's `operator_settings` turn on. + */ +export function sidebarPageAllowed( + isOperator: boolean | undefined, + workspace: UserWorkspace | undefined, + key: OperatorPageKey +): boolean { + return !isOperator || workspace?.operator_settings?.[key] === true } -export type OperatorTriggerLink = OperatorMenuLink & { kind: string } - -/** Home needs no setting — it is the one page every operator always has. */ -export const OPERATOR_MAIN_LINKS: OperatorMenuLink[] = [ - { label: 'Home', id: 'home', href: `${base}/` }, - { label: 'Runs', id: 'runs', href: `${base}/runs` }, - { label: 'Schedules', id: 'schedules', href: `${base}/schedules` } -] - -export const OPERATOR_SECONDARY_LINKS: OperatorMenuLink[] = [ - { label: 'Resources', id: 'resources', href: `${base}/resources` }, - { label: 'Variables', id: 'variables', href: `${base}/variables` }, - { label: 'Assets', id: 'assets', href: `${base}/assets` }, - { label: 'Groups', id: 'groups', href: `${base}/groups` }, - { label: 'Folders', id: 'folders', href: `${base}/folders` }, - { label: 'Workers', id: 'workers', href: `${base}/workers` }, - { label: 'Audit logs', id: 'audit_logs', href: `${base}/audit_logs` } -] - -export const OPERATOR_TRIGGER_LINKS: OperatorTriggerLink[] = [ - { label: 'Custom HTTP routes', id: 'triggers', href: `${base}/routes`, kind: 'http' }, - { label: 'Websocket triggers', id: 'triggers', href: `${base}/websocket_triggers`, kind: 'ws' }, - { - label: 'Postgres triggers', - id: 'triggers', - href: `${base}/postgres_triggers`, - kind: 'postgres' - }, - { label: 'Kafka triggers', id: 'triggers', href: `${base}/kafka_triggers`, kind: 'kafka' }, - { label: 'NATS triggers', id: 'triggers', href: `${base}/nats_triggers`, kind: 'nats' }, - { label: 'SQS triggers', id: 'triggers', href: `${base}/sqs_triggers`, kind: 'sqs' }, - { label: 'GCP Pub/Sub triggers', id: 'triggers', href: `${base}/gcp_triggers`, kind: 'gcp' }, - { - label: 'Azure Event Grid triggers', - id: 'triggers', - href: `${base}/azure_triggers`, - kind: 'azure' - }, - { label: 'MQTT triggers', id: 'triggers', href: `${base}/mqtt_triggers`, kind: 'mqtt' }, - { label: 'AMQP triggers', id: 'triggers', href: `${base}/amqp_triggers`, kind: 'amqp' }, - { label: 'Email triggers', id: 'triggers', href: `${base}/email_triggers`, kind: 'email' } -] - /** * Whether the user is an operator in `workspace`. The server nulls * `operator_settings` for a non-operator, so a non-null value narrows an operator diff --git a/frontend/src/lib/components/sidebar/workspaceMenuHref.test.ts b/frontend/src/lib/components/sidebar/workspaceMenuHref.test.ts index df8e668fba..24925f2198 100644 --- a/frontend/src/lib/components/sidebar/workspaceMenuHref.test.ts +++ b/frontend/src/lib/components/sidebar/workspaceMenuHref.test.ts @@ -46,6 +46,43 @@ describe('workspaceMenuHref', () => { ).toBe('/runs?workspace=w') }) + // The compare page reads the workspace it compares from `workspace_id`, so a switch that + // left it behind sent the page back to the workspace just left on the next reload. + it('swaps workspace_id alongside workspace', () => { + expect( + workspaceMenuHref({ + pathname: '/forks/compare', + searchParams: new URLSearchParams('workspace_id=wm-fork-a&mode=fork'), + id: 'wm-fork-b', + sameFamily: true + }) + ).toBe('/forks/compare?workspace_id=wm-fork-b&mode=fork&workspace=wm-fork-b') + }) + + it('does not add workspace_id to a page that had none', () => { + expect( + workspaceMenuHref({ + pathname: '/runs', + searchParams: new URLSearchParams('job=123'), + id: 'w', + sameFamily: true + }) + ).toBe('/runs?job=123&workspace=w') + }) + + // `target` is the compare page's one-off migration destination, picked for the workspace + // being left. Carried over it can point the new workspace at itself. + it('drops an arbitrary compare target', () => { + expect( + workspaceMenuHref({ + pathname: '/forks/compare', + searchParams: new URLSearchParams('workspace_id=wm-fork-a&target=wm-fork-b&mode=fork'), + id: 'wm-fork-b', + sameFamily: true + }) + ).toBe('/forks/compare?workspace_id=wm-fork-b&mode=fork&workspace=wm-fork-b') + }) + // An operator lands on home in the workspace they switch into. The href has to carry // that too, not just the click handler: this is what a middle-click opens. it('points at home, dropping the current page and its params, when landOnHome is set', () => { diff --git a/frontend/src/lib/components/sidebar/workspaceMenuHref.ts b/frontend/src/lib/components/sidebar/workspaceMenuHref.ts index 57342f2b08..589326c0f6 100644 --- a/frontend/src/lib/components/sidebar/workspaceMenuHref.ts +++ b/frontend/src/lib/components/sidebar/workspaceMenuHref.ts @@ -24,6 +24,17 @@ export function workspaceMenuHref(args: { } const params = new URLSearchParams(args.searchParams) params.set('workspace', args.id) + // `workspace_id` names the workspace a page acts on rather than the one the app is pointed at + // — the compare page's source. A switch moves it too, or the link lands on a page still + // working on the workspace just left. Only rewritten, never added: a page that does not use + // it has no business gaining it. + if (params.has('workspace_id')) { + params.set('workspace_id', args.id) + } + // The compare page's one-off migration destination, chosen for the workspace being left. Kept, + // it would survive into a workspace it was never picked for — including the target itself, + // leaving that page comparing a workspace with itself. + params.delete('target') if (!args.sameFamily) { params.delete('session') params.delete('session_name') diff --git a/frontend/src/lib/components/sidebar/workspaceSwitchUrl.ts b/frontend/src/lib/components/sidebar/workspaceSwitchUrl.ts index 82130e3f95..c8bc6d070c 100644 --- a/frontend/src/lib/components/sidebar/workspaceSwitchUrl.ts +++ b/frontend/src/lib/components/sidebar/workspaceSwitchUrl.ts @@ -1,13 +1,20 @@ import { page } from '$app/state' -import { replaceState } from '$app/navigation' import { goto } from '$lib/navigation' // Post-workspace-switch URL fixup shared by the sidebar workspace pickers. // Item-scoped pages would show a wrong-workspace (or missing) item after a -// switch — go home instead. Otherwise, really rewrite a ?workspace= param in -// the address bar (mutating page.url is a no-op there): left stale, it gets -// re-applied on reload or when exiting session mode restores the route, +// switch — go home instead. Otherwise, really rewrite the params in the address +// bar that name a workspace (mutating page.url is a no-op there): left stale, +// they get re-applied on reload or when exiting session mode restores the route, // silently switching the workspace back. +// +// `workspace_id` names the workspace a page acts on (the compare page's pair) and +// `workspace` the one the app is pointed at; a switch moves both. `target`, the +// compare page's one-off migration destination, is dropped instead of rewritten: +// it was chosen for the workspace being left, and keeping it could point the new +// one at itself. +const WORKSPACE_PARAMS = ['workspace', 'workspace_id'] +const STALE_AFTER_SWITCH = ['target'] const EDIT_PAGES = [ '/scripts/edit/', '/flows/edit/', @@ -28,9 +35,32 @@ export async function fixupUrlAfterWorkspaceSwitch( ): Promise { if (EDIT_PAGES.some((p) => page.route.id?.includes(p) ?? false) || opts?.landOnHome) { await goto('/') - } else if (page.url.searchParams.get('workspace')) { - const url = new URL(window.location.href) - url.searchParams.set('workspace', id) - replaceState(url, page.state) + return } + // `window.location`, never `page.url`: a param written by a previous shallow update + // is in the address bar but not in `page.url`, so rebuilding from it would undo that + // write (see the note on `setQuery` in $lib/navigation). + const url = new URL(window.location.href) + let changed = false + for (const key of WORKSPACE_PARAMS) { + if (url.searchParams.get(key) === id) continue + if (!url.searchParams.has(key)) continue + url.searchParams.set(key, id) + changed = true + } + for (const key of STALE_AFTER_SWITCH) { + if (!url.searchParams.has(key)) continue + url.searchParams.delete(key) + changed = true + } + if (!changed) return + // `goto` rather than `replaceState`: a page reading these params through `page.url` + // (the compare page derives the workspace it compares from them) sees nothing when + // they are written shallowly, since SvelteKit refreshes `page.url` only on a + // router-driven navigation. + await goto(`${url.pathname}${url.search}${url.hash}`, { + replaceState: true, + noScroll: true, + keepFocus: true + }) } diff --git a/frontend/src/lib/components/tutorials/OperatorTour.svelte b/frontend/src/lib/components/tutorials/OperatorTour.svelte index cc7f4ab4a5..8c414cac7a 100644 --- a/frontend/src/lib/components/tutorials/OperatorTour.svelte +++ b/frontend/src/lib/components/tutorials/OperatorTour.svelte @@ -1,8 +1,7 @@ -
+
| undefined>(undefined) let agent = $derived(host?.draftHandle()) let testPane = $derived(host?.testPaneHandle()) @@ -86,11 +88,25 @@ Every flow that links {path} will fail at its agent step once it is deleted. -
+
+ { + // A rename moves the page; a summary saved in place has to be read again, since + // what is shown here came from the load this page keys on. + if (newPath !== path) await goto(`${base}/agents/get/${newPath}`) + else reloaded++ + } + : undefined} mainButtons={[ // Evaluating an agent builds datasets and runs against it: authoring, as editing is. ...(canEdit @@ -154,7 +170,7 @@ {/snippet}
- {#key `${ws}:${path}`} + {#key `${ws}:${path}:${reloaded}`} +
{ goto(`/apps/edit/${url}`) diff --git a/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte index 4ff8670cda..c911a8a64c 100644 --- a/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/get/[...path]/+page.svelte @@ -24,7 +24,7 @@ must fully remount — otherwise the previous app (and in sandbox mode its path-scoped token) sticks around. --> {#key `${workspace}/${path}`} - + {/key} {:else} diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index fec356d89a..9ed2ca296a 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -624,9 +624,11 @@ {#if files} {#key redraw} -
+
goto(`/apps_raw/get/${e.path}?workspace=${$workspaceStore}`)} onSavedNewAppPath={(savedPath) => { draftSync.remove() goto(`/apps_raw/edit/${savedPath}`) diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/get/[...path]/+page.svelte index 79c8145731..672b0f9feb 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/get/[...path]/+page.svelte @@ -18,7 +18,7 @@ {#key `${workspace}/${path}`} - + {/key} {:else} diff --git a/frontend/src/routes/(root)/(logged)/audit_logs/+page.svelte b/frontend/src/routes/(root)/(logged)/audit_logs/+page.svelte index 59c961febb..42d8ff996e 100644 --- a/frontend/src/routes/(root)/(logged)/audit_logs/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/audit_logs/+page.svelte @@ -7,6 +7,8 @@ import AuditLogsTable from '$lib/components/auditLogs/AuditLogsTable.svelte' import AuditLogMobileFilters from '$lib/components/auditLogs/AuditLogMobileFilters.svelte' import { Alert, DrawerContent, Skeleton } from '$lib/components/common' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' + import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte' import Drawer from '$lib/components/common/drawer/Drawer.svelte' import AnimatedPane from '$lib/components/splitPanes/AnimatedPane.svelte' @@ -64,11 +66,26 @@ ) let auditLogDrawer: Drawer | undefined = $state() - // The inline filters show only when their one-line width fits in the space the title leaves. - // Both are measured, so the sidebar, side panels, zoom and filter values are all accounted for. - let filtersSlotWidth = $state(0) + // The inline filters show only when their one-line width fits the bar. The row is measured, so + // the sidebar, side panels, zoom and the filters' own values are all accounted for; what it is + // measured against is the bar, not the box it sits in. That box is the band's action area, + // whose width is its own content's — measuring the row against it would ask whether the row + // fits inside itself, which it always does, and the filters would never fold. let filtersRowWidth = $state(0) - let inlineFilters = $derived(filtersRowWidth > 0 && filtersRowWidth <= filtersSlotWidth) + /** What the breadcrumb takes before the filters get any: the workspace disc and name, the + * page's name and the hint beside it. Measured at 361-398px across bar widths. */ + const BREADCRUMB_WIDTH = 400 + /** Clear space held between the breadcrumb and the first filter. The breadcrumb yields width + * grudgingly and the filter row not at all, so when the two are sized to just meet they + * overlap instead of compressing — and mid-resize, before the breadcrumb has settled, the + * tooltip at the end of it lands on the timeframe button. Measured without this: the gap + * reached 4px across a 1600->1200 sweep. */ + const BREADCRUMB_GAP = 48 + let inlineFilters = $derived( + filtersRowWidth > 0 && + pageHeader.barWidth > 0 && + filtersRowWidth <= pageHeader.barWidth - BREADCRUMB_WIDTH - BREADCRUMB_GAP + ) // Function to fetch missing job execution audit logs async function fetchMissingJobSpan(jobId: string, jobLogs: AuditLog[]): Promise { @@ -117,19 +134,21 @@

Page not available for operators

{:else} -
-
-
-

Audit logs

- - You can only see your own audit logs unless you are an admin. - -
- -
+ +
+ + + + {#snippet auditHint()} + + You can only see your own audit logs unless you are an admin. + + {/snippet} + + {#snippet auditActions()} +
-
+ {/snippet} {#if !$enterpriseLicense || $enterpriseLicense.endsWith('_pro')}
diff --git a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte index 835735c0c8..d724c4873d 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -557,6 +557,7 @@
{:else if renderEditor} { // The version the drawer showed as head; see /scripts/edit. diff --git a/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte b/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte index a21fa36835..aa158d2f11 100644 --- a/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/forks/compare/+page.svelte @@ -1,4 +1,5 @@ + + +
@@ -440,10 +505,12 @@ onModeSelected={selectMode} /> {:else if compareTargetId} - - {#key compareTargetId} + + {#key pairKey} Pipelines — Windmill -
-
-
- -

Pipelines

- - Alpha - -
-
+ + +{#snippet alphaBadge()} + + Alpha + +{/snippet} + +
diff --git a/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte b/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte index b4ddb8350c..a16b1a1e49 100644 --- a/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/pipeline/[folder]/+page.svelte @@ -2,6 +2,7 @@ import { workspaceStore, userStore } from '$lib/stores' import { PIPELINE_DRAFT_KIND, pipelineBundlePath } from '$lib/pipelinePaths' import { base } from '$lib/base' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' import { page } from '$app/state' import Button from '$lib/components/common/button/Button.svelte' import DropdownV2 from '$lib/components/DropdownV2.svelte' @@ -98,7 +99,6 @@ FolderSearch, History, Loader2, - NetworkIcon, Play, RefreshCw, Save, @@ -2386,60 +2386,71 @@ Pipeline · {folder} — Windmill -
-
-
- + {:else} + + {#snippet buttonReplacement()} + f/{folder} - - {:else} - - {#snippet buttonReplacement()} - - - f/{folder} - - - {/snippet} - - {/if} - {#if summary.length > 0} - · {summary.join(' · ')} - {/if} -
+ + {/snippet} + + {/if} + {#if summary.length > 0} + · {summary.join(' · ')} + {/if} +{/snippet} + +{#snippet pipelineActions()} + +
{#if !isOperator} - -
+ +
setMode(m)} /> {#if mode === 'view' && pe.drafts.size > 0} + graph — "what View will show once they're deployed". -->
{/if} -
- {#if !isOperator && allPipelineScripts.length > 0} - - {#if recordingMode} + {#if !isOperator && allPipelineScripts.length > 0} + + {#if recordingMode} + + {/if} + + + {/if} + {#if mode === 'edit' && saveErrors.size > 0} + + + {#snippet trigger()} - {/if} - - - {/if} - {#if mode === 'edit' && saveErrors.size > 0} - - - {#snippet trigger()} - - {/snippet} - {#snippet content()} -
- Save errors -
- {#each [...saveErrors.entries()] as [path, message]} -
- {path} - - {message} - -
- {/each} -
+ {/snippet} + {#snippet content()} +
+ Save errors +
+ {#each [...saveErrors.entries()] as [path, message]} +
+ {path} + + {message} + +
+ {/each}
- {/snippet} - - {/if} - {#if mode === 'edit' && pe.drafts.size > 0} - - {#if $workspaceStore} - - {/if} - - {/if} - {#if mode === 'view'} - +
+ {/snippet} + + {/if} + {#if mode === 'edit' && pe.drafts.size > 0} + + {#if $workspaceStore} + {/if}
+ startIcon={{ icon: savingAll ? Loader2 : Save }} + onclick={saveAllDrafts} + disabled={savingAll} + title={savingAll ? 'Saving drafts…' : `Deploy all ${pe.drafts.size} drafts`} + > + {savingAll ? 'Saving…' : `Save all (${pe.drafts.size})`} + + {/if} + {#if mode === 'view'} + + {/if} +
+{/snippet} +
{#if graphRes.loading && !graphRes.current}
diff --git a/frontend/src/routes/(root)/(logged)/remote_deploy/callback/+page.svelte b/frontend/src/routes/(root)/(logged)/remote_deploy/callback/+page.svelte index c702f66471..58739ee36a 100644 --- a/frontend/src/routes/(root)/(logged)/remote_deploy/callback/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/remote_deploy/callback/+page.svelte @@ -58,7 +58,7 @@ }) - + {#if status === 'connecting'}
{:else if status === 'connected'} diff --git a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte index 183d10a003..48d667cc35 100644 --- a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte @@ -70,7 +70,9 @@ import HighlightCode from '$lib/components/HighlightCode.svelte' import JobLoader from '$lib/components/JobLoader.svelte' import LogViewer from '$lib/components/LogViewer.svelte' - import { ActionRow, Button, Skeleton, Tab, Alert, DrawerContent } from '$lib/components/common' + import { Button, Skeleton, Tab, Alert, DrawerContent } from '$lib/components/common' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' + import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte' import JobDetailHeader from '$lib/components/runs/JobDetailHeader.svelte' import ScriptRetryChain from '$lib/components/runs/ScriptRetryChain.svelte' import FlowExecutionStatus from '$lib/components/runs/FlowExecutionStatus.svelte' @@ -648,6 +650,10 @@ } }) onDestroy(resetFavicon) + + // The bar shares its row with the breadcrumb, and this page's name is a uuid that gives way + // grudgingly, so the buttons drop their labels rather than push each other off the end. + const compactBar = $derived(pageHeader.barWidth > 0 && pageHeader.barWidth < 1150) @@ -805,289 +811,305 @@ class="max-w-7xl p-4 mx-auto w-full" loading={!job} layout={[ - // 1. Top Action Bar (buttons on right side) - [ - { h: 2.5, w: 60 }, - { h: 2.5, w: 40 } - ], - 1, - // 2. Job Header + // 1. Job Header [{ h: 12, w: 100 }], 1, - // 3. Progress Bar + // 2. Progress Bar [{ h: 2, w: 100 }], 1.5 ]} /> - - {#snippet left()} -

run/{page.params.run}

- {/snippet} - {#snippet right()} - {@const isScript = job?.job_kind === 'script'} - {@const isHubFlowPreview = isFlowPreview(job?.job_kind) && isHubFlowPath(job?.script_path)} - {@const runsHref = `/runs/${job?.script_path}${!isScript ? '?jobKind=flow' : ''}`} - {#if job && 'deleted' in job && !job?.deleted && ($superadmin || ($userStore?.is_admin ?? false))} - { - job?.id && deleteCompletedJob(job.id) - }, - type: 'delete' - } - ]} - > - {#snippet buttonReplacement()} - - {/if} - {/if} - {#if job} - job && shareReadLink(job.id) + + + + {#snippet runActions()} + {@const isScript = job?.job_kind === 'script'} + {@const isHubFlowPreview = isFlowPreview(job?.job_kind) && isHubFlowPath(job?.script_path)} + {@const runsHref = `/runs/${job?.script_path}${!isScript ? '?jobKind=flow' : ''}`} + {#if job && 'deleted' in job && !job?.deleted && ($superadmin || ($userStore?.is_admin ?? false))} + { + job?.id && deleteCompletedJob(job.id) }, + type: 'delete' + } + ]} + > + {#snippet buttonReplacement()} + + {/if} + {/if} + {#if job} + job && shareReadLink(job.id) + }, + { + displayName: 'Copy public link', + icon: Globe, + disabled: !canSharePublicly, + tooltip: canSharePublicly + ? "Read-only link that anyone on the internet can open, without logging in. It shows a minimal version of this page: this run's inputs, result and logs, and for a flow its graph plus every step's inputs, result, logs and code. The link cannot be revoked." + : 'Sharing a run publicly is restricted in this workspace. Ask an admin to share it, or to grant you a bypass on the ruleset.', + action: () => job && sharePublicLink(job.id) + } + ]} + > + {#snippet buttonReplacement()} + + {/snippet} + + {/if} + {@const stem = job?.job_kind === 'script_hub' ? '/scripts' : `/${job?.job_kind}s`} + {@const viewHref = `${stem}/get/${isScript ? job?.script_hash : job?.script_path}`} + {#if (job?.job_kind == 'flow' || isFlowPreview(job?.job_kind)) && job?.['running'] && job?.parent_job == undefined} +
+ job && sharePublicLink(job.id) + displayName: 'Show Flow Debug Info', + action: () => { + debugInfo() + } } ]} + class="h-auto" > {#snippet buttonReplacement()} - {/snippet} - {/if} - {@const stem = job?.job_kind === 'script_hub' ? '/scripts' : `/${job?.job_kind}s`} - {@const viewHref = `${stem}/get/${isScript ? job?.script_hash : job?.script_path}`} - {#if (job?.job_kind == 'flow' || isFlowPreview(job?.job_kind)) && job?.['running'] && job?.parent_job == undefined} -
- { - debugInfo() - } - } - ]} - class="h-auto" - > - {#snippet buttonReplacement()} - - {/snippet} - -
- {/if} - {#if isFlowPreview(job?.job_kind) || isScriptPreview(job?.job_kind)} +
+ {/if} + {#if isFlowPreview(job?.job_kind) || isScriptPreview(job?.job_kind)} + + {/if} + {#if persistentScriptDefinition !== undefined} + + {/if} + {#if canRunNow} + + {/if} + {#if job && job?.type != 'CompletedJob' && (!job?.schedule_path || job?.['running'] == true)} + {#if !forceCancel} - {/if} - {#if persistentScriptDefinition !== undefined} - - {/if} - {#if canRunNow} - - {/if} - {#if job && job?.type != 'CompletedJob' && (!job?.schedule_path || job?.['running'] == true)} - {#if !forceCancel} - - {:else} - - {/if} - {/if} - {#if job?.schedule_path} - - {/if} - {#if restartStep !== undefined && canRestart(restart)} - {@render flowRestartButton(restart, restartStep, 'button')} - {/if} - {#if job?.job_kind === 'script' || job?.job_kind === 'script_hub' || job?.job_kind === 'flow'} - - {/if} - {#if job?.job_kind === 'script' || job?.job_kind === 'flow'} - {#if !$userStore?.operator} - {#if canWrite(job?.script_path ?? '', {}, $userStore)} - - {#if showEditButton} - - - {/if} - {/if} - {#if !showEditButton && !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces)} - - {/if} - {/if} - {/if} - {#if job?.job_kind === 'script' || job?.job_kind === 'script_hub' || job?.job_kind === 'flow'} - + {:else} + {/if} - {/snippet} -
+ {/if} + {#if job?.schedule_path} + + {/if} + {#if restartStep !== undefined && canRestart(restart)} + {@render flowRestartButton(restart, restartStep, 'button')} + {/if} + {#if job?.job_kind === 'script' || job?.job_kind === 'script_hub' || job?.job_kind === 'flow'} + + {/if} + {#if job?.job_kind === 'script' || job?.job_kind === 'flow'} + {#if !$userStore?.operator} + {#if canWrite(job?.script_path ?? '', {}, $userStore)} + + {#if showEditButton} + + + {/if} + {/if} + {#if !showEditButton && !isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces)} + + {/if} + {/if} + {/if} + {#if job?.job_kind === 'script' || job?.job_kind === 'script_hub' || job?.job_kind === 'flow'} + + {/if} + {/snippet}
-
+
{#if job} -{#snippet title()} -
-

Service logs

- - Logs written by the servers, workers and indexers of this instance, grouped by host. - -
-{/snippet} - -
+ +
{#if $devopsRole == false}
- {@render title()} Ask one of them to look at the logs, or to grant you the devops role.
{:else} - + {/if}
diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index 1662c15d79..97ed4b95b6 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -1,21 +1,25 @@ - + +{#if $userStore?.operator} +
+ Page not available for operators +
+{:else} + +{/if} diff --git a/frontend/src/routes/(root)/(logged)/user/cli-success/+page.svelte b/frontend/src/routes/(root)/(logged)/user/cli-success/+page.svelte index 645a928473..408a2c1d55 100644 --- a/frontend/src/routes/(root)/(logged)/user/cli-success/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/cli-success/+page.svelte @@ -8,4 +8,4 @@ goto('/') - + diff --git a/frontend/src/routes/(root)/(logged)/user/cli/+page.svelte b/frontend/src/routes/(root)/(logged)/user/cli/+page.svelte index 010abe2bd8..6e5f674b44 100644 --- a/frontend/src/routes/(root)/(logged)/user/cli/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/cli/+page.svelte @@ -30,7 +30,7 @@ } - +

Token will be posted to {host == 'localhost' ? 'your local machine' : host} to port {port}

diff --git a/frontend/src/routes/(root)/(logged)/user/remote_deploy_authorize/+page.svelte b/frontend/src/routes/(root)/(logged)/user/remote_deploy_authorize/+page.svelte index 2362b7f116..ae22b66147 100644 --- a/frontend/src/routes/(root)/(logged)/user/remote_deploy_authorize/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/remote_deploy_authorize/+page.svelte @@ -73,7 +73,7 @@ } - + {#if framed} This page cannot be used inside another page. Open it in its own tab. diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index f055e92ca3..022048bbf7 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -3,7 +3,7 @@ import { page } from '$app/stores' import { isCloudHosted } from '$lib/cloud' import CenteredPage from '$lib/components/CenteredPage.svelte' - import { Alert, Button, CopyButton, Section, Skeleton, Tab, Tabs } from '$lib/components/common' + import { Alert, Button, Section, Skeleton, Tab, Tabs } from '$lib/components/common' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' @@ -13,7 +13,7 @@ import ErrorOrRecoveryHandler, { handlerFullPath } from '$lib/components/ErrorOrRecoveryHandler.svelte' - import PageHeader from '$lib/components/PageHeader.svelte' + import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' import ScriptPicker from '$lib/components/ScriptPicker.svelte' import Tooltip from '$lib/components/Tooltip.svelte' @@ -1459,24 +1459,19 @@ ) - + {#if canAdmin || isForkOwner} - - {#snippet titleActions()} - {#if $workspaceStore} - - {/if} - {/snippet} - {#if $superadmin} - - {/if} + +
-
+ +
-
+
{#if !loadedSettings} {:else if tab == 'users'} @@ -2435,5 +2430,13 @@ export async function main( tabMode={true} /> +{#snippet settingsActions()} + {#if $superadmin} + + {/if} +{/snippet} + diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/checkout/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/checkout/+page.svelte index b25b8db5c8..62c25fc6b3 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/checkout/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/checkout/+page.svelte @@ -31,7 +31,7 @@ - + {#if !success}
diff --git a/frontend/src/routes/a/[...path]/+page.svelte b/frontend/src/routes/a/[...path]/+page.svelte index e9fd20877c..417216608e 100644 --- a/frontend/src/routes/a/[...path]/+page.svelte +++ b/frontend/src/routes/a/[...path]/+page.svelte @@ -187,25 +187,29 @@ } - { - refresh = requestTokenRefresh - loadApp() - }} -> - {#snippet viewer()} - loadApp()} - > - {/snippet} - + +
+ { + refresh = requestTokenRefresh + loadApp() + }} + > + {#snippet viewer()} + loadApp()} + > + {/snippet} + +
diff --git a/frontend/src/routes/app_embed/[workspace]/[...path]/+page.svelte b/frontend/src/routes/app_embed/[workspace]/[...path]/+page.svelte index ee83869ee8..85a72dd7e3 100644 --- a/frontend/src/routes/app_embed/[workspace]/[...path]/+page.svelte +++ b/frontend/src/routes/app_embed/[workspace]/[...path]/+page.svelte @@ -78,23 +78,28 @@ } - { - refresh = requestTokenRefresh - loadApp() - }} -> - {#snippet viewer()} - loadApp()} - > - {/snippet} - + +
+ { + refresh = requestTokenRefresh + loadApp() + }} + > + {#snippet viewer()} + loadApp()} + > + {/snippet} + +
diff --git a/frontend/src/routes/public/[workspace]/[...secret]/+page.svelte b/frontend/src/routes/public/[workspace]/[...secret]/+page.svelte index b2a29b6316..8229c5af6c 100644 --- a/frontend/src/routes/public/[workspace]/[...secret]/+page.svelte +++ b/frontend/src/routes/public/[workspace]/[...secret]/+page.svelte @@ -160,25 +160,29 @@ } - { - refresh = requestTokenRefresh - loadApp() - }} -> - {#snippet viewer()} - loadApp()} - > - {/snippet} - + +
+ { + refresh = requestTokenRefresh + loadApp() + }} + > + {#snippet viewer()} + loadApp()} + > + {/snippet} + +
From c860b025783d7a4fd5bf1ac4a0bbf6677bcf554c Mon Sep 17 00:00:00 2001 From: hugocasa Date: Thu, 8 Oct 2026 08:50:32 +0200 Subject: [PATCH 27/37] describe team-plan seat billing accurately in workspace settings (#11603) Co-authored-by: Claude Opus 5.5 --- .../lib/components/settings/PremiumInfo.svelte | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/components/settings/PremiumInfo.svelte b/frontend/src/lib/components/settings/PremiumInfo.svelte index b96ab5e22b..766b2c6495 100644 --- a/frontend/src/lib/components/settings/PremiumInfo.svelte +++ b/frontend/src/lib/components/settings/PremiumInfo.svelte @@ -261,8 +261,7 @@
Developers

- Calculated on the MAXIMUM number of users in a given billing period, see the - Customer Portal for more info. + 1 seat each.

@@ -277,8 +276,7 @@
Operators

- Calculated on the MAXIMUM number of operators in a given billing period, see - the Customer Portal for more info. + Half a seat each.

@@ -293,7 +291,7 @@
Service accounts

- Enabled service accounts, half a seat each like operators, whatever their role. + Half a seat each, whatever their role.

@@ -383,8 +381,9 @@ Used seats (billed)

- Highest between seats from 'Developers + Operators' and 'Seats from - executions'. This is the number of seats used for billing this month. + 'Seats from users' plus 'Extra seats from computations'. Billed on the + highest total reached during the month; your invoices in the Customer Portal + show what was billed.

u + c = {formatNumber(premiumInfo.usedSeats)} @@ -417,7 +416,7 @@

Estimate your monthly cost

This is a rough estimate based on your expected team size and workload. Actual billing is - based on the maximum number of users and executions in a given month. + based on the highest seat total reached during the month.

From 8f7e0c72848b56fa97f6f0141d930c7d15044864 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Thu, 8 Oct 2026 08:52:59 +0200 Subject: [PATCH 28/37] feat: add gpt-6.1-sol to the chat, agent steps and weekly ai evals (#11584) Co-authored-by: Claude Opus 5.5 Co-authored-by: Ruben Fiszel --- .github/workflows/ai-evals-scheduled.yml | 6 +++--- ai_evals/core/models.ts | 9 +++++++++ backend/windmill-ai/src/providers/mod.rs | 8 ++++++-- frontend/src/lib/components/copilot/lib.ts | 1 + frontend/src/lib/components/copilot/reasoningParity.json | 6 ++++++ frontend/src/lib/components/copilot/reasoningRegistry.ts | 5 +++-- 6 files changed, 28 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ai-evals-scheduled.yml b/.github/workflows/ai-evals-scheduled.yml index 4780eb02eb..94ecc7c6c8 100644 --- a/.github/workflows/ai-evals-scheduled.yml +++ b/.github/workflows/ai-evals-scheduled.yml @@ -15,8 +15,8 @@ on: description: "Space-separated modes (global, cli, flow, app, script)" default: "global cli" models: - description: "Space-separated model aliases (bun run cli -- models); cli skips those it has no agent for" - default: "sonnet-5.5 opus-5.5 gpt-6-sol gpt-6-astra deepseek-v4-pro" + description: "Space-separated model aliases (bun run cli -- models); cli skips those it has no agent for"" + default: "sonnet-5.5 opus-5.5 gpt-6.1-sol gpt-6-astra deepseek-v4-pro" runs: description: "Runs per case" default: "3" @@ -28,7 +28,7 @@ concurrency: group: ai-evals-scheduled-${{ github.ref }} env: - MODELS: ${{ inputs.models || 'sonnet-5.5 opus-5.5 gpt-6-sol gpt-6-astra deepseek-v4-pro' }} + MODELS: ${{ inputs.models || 'sonnet-5.5 opus-5.5 gpt-6.1-sol gpt-6-astra deepseek-v4-pro' }} RUNS: ${{ inputs.runs || '3' }} REASONING: ${{ inputs.reasoning }} # Routes on the cloud instance are prefixed with their workspace. diff --git a/ai_evals/core/models.ts b/ai_evals/core/models.ts index 22af20d5c3..cf131337fd 100644 --- a/ai_evals/core/models.ts +++ b/ai_evals/core/models.ts @@ -148,6 +148,15 @@ export const EVAL_MODELS: EvalModelSpec[] = [ model: "gpt-6-astra", }, }, + { + id: "gpt-6.1-sol", + label: "GPT-6.1 Sol", + aliases: ["gpt-6.1-sol"], + frontend: { + provider: "openai", + model: "gpt-6.1-sol", + }, + }, { id: "gpt-6-sol", label: "GPT-6 Sol", diff --git a/backend/windmill-ai/src/providers/mod.rs b/backend/windmill-ai/src/providers/mod.rs index e08b038bb0..7dcddc93a3 100644 --- a/backend/windmill-ai/src/providers/mod.rs +++ b/backend/windmill-ai/src/providers/mod.rs @@ -62,9 +62,13 @@ const REASONING_RULES: &[ReasoningRule] = &[ can_disable: false, completions_tools_need_off: false, }, - // Live-verified: astra takes low..max only, where sol and luna also take `none`. + // Live-verified: gpt-6-astra and gpt-6.1-sol take low..max only, where gpt-6-sol and + // gpt-6-luna also take `none`. ReasoningRule { - matches: |m| base_id(m).starts_with("gpt-6-astra"), + matches: |m| { + let base = base_id(m); + base.starts_with("gpt-6-astra") || base.starts_with("gpt-6.1-sol") + }, can_disable: false, completions_tools_need_off: true, }, diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index c53028031d..1d111a2f20 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -71,6 +71,7 @@ interface AIProviderDetails { // the frontier model. The gpt-5 family is deprecated (retires 2026-12-11) but // still served, so it stays in the list below the 5.6 models. const OPENAI_MODELS = [ + 'gpt-6.1-sol', 'gpt-6-sol', 'gpt-6-astra', 'gpt-6-luna', diff --git a/frontend/src/lib/components/copilot/reasoningParity.json b/frontend/src/lib/components/copilot/reasoningParity.json index 93b8ac7eb2..1f20def9be 100644 --- a/frontend/src/lib/components/copilot/reasoningParity.json +++ b/frontend/src/lib/components/copilot/reasoningParity.json @@ -47,6 +47,12 @@ "canDisable": false, "completionsToolsNeedOff": true }, + { + "provider": "openai", + "model": "gpt-6.1-sol", + "canDisable": false, + "completionsToolsNeedOff": true + }, { "provider": "openai", "model": "gpt-6-sol", diff --git a/frontend/src/lib/components/copilot/reasoningRegistry.ts b/frontend/src/lib/components/copilot/reasoningRegistry.ts index 68934c22f3..0cbf935832 100644 --- a/frontend/src/lib/components/copilot/reasoningRegistry.ts +++ b/frontend/src/lib/components/copilot/reasoningRegistry.ts @@ -131,9 +131,10 @@ const BEDROCK_RULES: ReasoningRule[] = [ // Azure names gpt-3.5 `gpt-35-turbo`. `minimal` exists on gpt-5 only, `xhigh` from // gpt-5.5, `max` from gpt-5.6. const OPENAI_RULES: ReasoningRule[] = [ - // Live-verified: astra takes low..max only, where sol and luna also take `none`. + // Live-verified: gpt-6-astra and gpt-6.1-sol take low..max only, where gpt-6-sol and + // gpt-6-luna also take `none`. { - match: /(?:^|\/)gpt-6-astra/, + match: /(?:^|\/)gpt-6(?:-astra|\.1-sol)/, levels: LOW_TO_MAX, canDisable: false, completionsToolsNeedOff: true From d7d18f00af4e2df8fcb1362c9439d2250d0ce4b6 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:53:23 +0200 Subject: [PATCH 29/37] feat: improve data table roles and grants UX (#11562) * feat: improve data table roles and grants UX Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep selected bordered badges the same height as unselected ones Co-Authored-By: Claude Opus 5.5 (1M context) * fix: refuse taking over roles with members and test grant statements Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep grant filter chips while a filter is set Co-Authored-By: Claude Opus 5.5 (1M context) * fix: check a taken-over role can log in with the new password Co-Authored-By: Claude Opus 5.5 (1M context) * feat: select several grant filter tags at once Co-Authored-By: Claude Opus 5.5 (1M context) * feat: explain where schema and table permissions are set Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep the grant create row on one line Co-Authored-By: Claude Opus 5.5 (1M context) * feat: show grants as a role and its privileges Co-Authored-By: Claude Opus 5.5 (1M context) * feat: show grants in a table with role, privileges and scope Co-Authored-By: Claude Opus 5.5 (1M context) * fix: hide the grant scope when it can only be the object itself Co-Authored-By: Claude Opus 5.5 (1M context) * feat: grant schema usage along with grants inside the schema Co-Authored-By: Claude Opus 5.5 (1M context) * feat: confirm before closing the roles drawer with unsaved changes Co-Authored-By: Claude Opus 5.5 (1M context) * chore: update ee-repo-ref to c4002f777f56d90bb0471164291889850c85179d This commit updates the EE repository reference after PR #848 was merged in windmill-ee-private. Previous ee-repo-ref: 7937634a52d04caeb083da94e189705cd00796b3 New ee-repo-ref: c4002f777f56d90bb0471164291889850c85179d Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- .../tests/datatable_roles.rs | 83 +++++ .../src/datatable_acl.rs | 43 ++- backend/windmill-api/openapi.yaml | 13 + .../windmill-common/src/datatable_roles.rs | 12 +- .../src/datatable_roles_oss.rs | 3 +- frontend/src/lib/components/DBManager.svelte | 23 +- .../src/lib/components/DBManagerDrawer.svelte | 1 + .../src/lib/components/DropdownV2Inner.svelte | 16 +- .../lib/components/common/badge/Badge.svelte | 3 +- .../ConfirmationModal.svelte | 23 +- .../common/drawer/Disposable.svelte | 32 +- .../components/common/drawer/Drawer.svelte | 6 +- .../datatableAcl/PgAclEditor.svelte | 334 +++++++++++++----- .../datatableAcl/PgGrantBuilder.svelte | 96 +++-- .../components/datatableAcl/aclScopes.test.ts | 30 +- .../lib/components/datatableAcl/aclScopes.ts | 50 +-- .../DataTablePermissionsButton.svelte | 226 +++++++----- .../DataTableRolesSection.svelte | 31 +- .../workspaceSettings/datatableRoleModals.ts | 120 +++++++ 20 files changed, 864 insertions(+), 283 deletions(-) create mode 100644 frontend/src/lib/components/workspaceSettings/datatableRoleModals.ts diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index fffcc99883..560bcfb41b 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -1600d49ad5513af8b54ad65688d3d1c7edc977d9 \ No newline at end of file +6f23819e947b18878000e580ddefccfd01a73166 diff --git a/backend/windmill-api-integration-tests/tests/datatable_roles.rs b/backend/windmill-api-integration-tests/tests/datatable_roles.rs index ddb94f8c2b..9800d97841 100644 --- a/backend/windmill-api-integration-tests/tests/datatable_roles.rs +++ b/backend/windmill-api-integration-tests/tests/datatable_roles.rs @@ -417,6 +417,89 @@ async fn concurrent_role_creations_both_survive(db: Pool) -> anyhow::R outcome } +#[cfg(all(feature = "private", feature = "enterprise"))] +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn an_existing_role_is_taken_over_only_when_asked_and_never_with_members( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let suffix: String = uuid::Uuid::new_v4().simple().to_string()[..8].to_string(); + let plain = format!("wmtest_plain_{suffix}"); + let group = format!("wmtest_group_{suffix}"); + let member = format!("wmtest_member_{suffix}"); + for sql in [ + // An expired password and no connection slot: kept as is, the login Windmill sets is dead. + format!( + "CREATE ROLE \"{plain}\" LOGIN PASSWORD 'theirs' VALID UNTIL '2000-01-01' \ + CONNECTION LIMIT 0" + ), + format!("CREATE ROLE \"{group}\" NOLOGIN"), + format!("CREATE ROLE \"{member}\" LOGIN"), + format!("GRANT \"{group}\" TO \"{member}\""), + ] { + sqlx::query(&sql).execute(&db).await?; + } + + let create = |name: String, take_over: bool| async move { + let resp = authed( + client().post(format!( + "http://localhost:{port}/api/settings/datatable_roles" + )), + "SECRET_TOKEN", + ) + .json(&json!({ "name": name, "take_over": take_over })) + .send() + .await?; + Ok::<_, anyhow::Error>((resp.status().as_u16(), resp.text().await?)) + }; + let outcome = async { + let (status, body) = create(plain.clone(), false).await?; + assert_eq!( + status, 409, + "an existing role is not taken without asking: {body}" + ); + + // The group's member would inherit every grant the data table role gets. + let (status, body) = create(group.clone(), true).await?; + assert_eq!(status, 400, "{body}"); + assert!( + body.contains(&member), + "the refusal names the member: {body}" + ); + + let (status, body) = create(plain.clone(), true).await?; + assert_eq!(status, 200, "{body}"); + let catalog = windmill_common::datatable_roles::read_role_catalog( + &db, + windmill_common::datatable_roles::DatatableRoleCluster::Instance, + ) + .await?; + let taken = catalog + .values() + .find(|r| r.name == plain) + .ok_or_else(|| anyhow::anyhow!("{plain} is not in the catalog: {catalog:?}"))?; + let options = (*db.connect_options()) + .clone() + .username(&plain) + .password(taken.pwd.as_deref().unwrap_or_default()); + let mut login = ::connect_with(&options).await?; + sqlx::query("SELECT 1").execute(&mut login).await?; + Ok::<_, anyhow::Error>(()) + } + .await; + + // Roles are cluster-wide and outlive this test's database, whatever happened above. + for name in [&member, &group, &plain] { + let _ = sqlx::query(&format!("DROP ROLE IF EXISTS \"{name}\"")) + .execute(&db) + .await; + } + outcome +} + #[cfg(all(feature = "private", feature = "enterprise"))] #[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] async fn a_role_delete_that_fails_part_way_leaves_the_role_disabled( diff --git a/backend/windmill-api-workspaces/src/datatable_acl.rs b/backend/windmill-api-workspaces/src/datatable_acl.rs index ebab6214ce..efaa712481 100644 --- a/backend/windmill-api-workspaces/src/datatable_acl.rs +++ b/backend/windmill-api-workspaces/src/datatable_acl.rs @@ -683,6 +683,9 @@ pub(crate) struct CatalogFacts { pub(crate) former_owner: Option, /// For a revoke: each grant it takes back. pub(crate) revoked_grants: Vec, + /// For a grant on what a schema holds: the role cannot reach the schema yet, so the grant + /// would give it nothing it can use. + pub(crate) lacks_schema_usage: bool, } /// A grant a revoke takes back, as the catalog records it: what `source` gave on `object` (the @@ -1526,7 +1529,24 @@ async fn build_plan( ))); } } - let facts = CatalogFacts { other_pg_roles, existing_objects, former_owner, revoked_grants }; + let lacks_schema_usage = match (&change, target) { + (AclChange::Grant { scope, .. }, AclTarget::Schema { schema }) + if !matches!(scope, GrantScope::Target) => + { + !has_schema_usage(client, schema, &pg_role).await? + } + (AclChange::Grant { .. }, AclTarget::Table { schema, .. }) => { + !has_schema_usage(client, schema, &pg_role).await? + } + _ => false, + }; + let facts = CatalogFacts { + other_pg_roles, + existing_objects, + former_owner, + revoked_grants, + lacks_schema_usage, + }; let mut plan = crate::datatable_acl_oss::plan_statements(target, &change, dbname, &pg_role, &facts)?; if matches!(change, AclChange::SetOwner { .. }) { @@ -1540,6 +1560,27 @@ async fn build_plan( Ok(plan) } +/// Whether `pg_role` may look into `schema`, directly or through a role it inherits from. +async fn has_schema_usage( + client: &tokio_postgres::Client, + schema: &str, + pg_role: &str, +) -> Result { + Ok(client + .query_one( + "SELECT has_schema_privilege($1::name, $2::text, 'USAGE')", + &[&pg_role, &schema], + ) + .await + .map_err(|e| { + Error::internal_err(format!( + "Failed to read the role's privileges on schema {schema}: {}", + pg_error_message(&e) + )) + })? + .get(0)) +} + /// Postgres only hands an object to a role that could have created it: a table to one with /// `CREATE` on its schema, a schema to one with `CREATE` on the database. async fn missing_owner_privilege( diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 1b2aea604e..873fcb7405 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1765,6 +1765,12 @@ paths: type: string cluster: $ref: "#/components/schemas/DatatableRoleCluster" + take_over: + type: boolean + description: > + manage a Postgres role of that name that already exists on the cluster, + replacing its password, instead of refusing it. A role with cluster-wide + attributes or memberships is refused either way responses: "200": description: the created role @@ -1772,6 +1778,8 @@ paths: application/json: schema: $ref: "#/components/schemas/InstanceDatatableRole" + "409": + description: a Postgres role of that name already exists on the cluster and take_over was not set /settings/datatable_roles/{id}: post: @@ -34525,6 +34533,11 @@ components: type: boolean cluster: $ref: "#/components/schemas/DatatableRoleCluster" + in_use: + type: boolean + description: >- + Whether any data table, in any workspace, lets someone connect as this role. Only set + in a data table's available_roles. DatatableRoleTenants: type: object diff --git a/backend/windmill-common/src/datatable_roles.rs b/backend/windmill-common/src/datatable_roles.rs index f355e733cb..e7e490a6a4 100644 --- a/backend/windmill-common/src/datatable_roles.rs +++ b/backend/windmill-common/src/datatable_roles.rs @@ -331,6 +331,12 @@ pub async fn converge_connect_grants_with( /// `CREATE ROLE LOGIN PASSWORD ...; GRANT TO custom_instance_user` on `cluster`. No /// privileges beyond that — an admin grants them through SQL or the ACL editor. /// +/// A role of that name that already exists is refused with a 409, unless `take_over`: then its +/// password is replaced and it is managed from here on like one created here. A role holding +/// cluster-wide privileges, or inheriting another role's, is refused either way, since every tenant +/// would get them. Returns whether an existing role was taken over: undoing a failed take-over +/// must not drop a role that was there before. +/// /// On Windmill's own cluster the DDL runs on `tx`, so it commits with the catalog row. The external /// cluster is another server: the role is created there before `tx` commits, and callers MUST drop /// it again ([`drop_datatable_role`]) if `tx` then fails to commit. @@ -343,8 +349,10 @@ pub async fn create_datatable_role( cluster: DatatableRoleCluster, name: &str, password: &str, -) -> Result<()> { - crate::datatable_roles_oss::create_datatable_role(db, tx, cluster, name, password).await + take_over: bool, +) -> Result { + crate::datatable_roles_oss::create_datatable_role(db, tx, cluster, name, password, take_over) + .await } /// Authorization: alters a cluster-wide Postgres login. Callers MUST restrict this to superadmin diff --git a/backend/windmill-common/src/datatable_roles_oss.rs b/backend/windmill-common/src/datatable_roles_oss.rs index a97a936827..33d711e7f9 100644 --- a/backend/windmill-common/src/datatable_roles_oss.rs +++ b/backend/windmill-common/src/datatable_roles_oss.rs @@ -125,7 +125,8 @@ mod ce { _cluster: DatatableRoleCluster, _name: &str, _password: &str, - ) -> Result<()> { + _take_over: bool, + ) -> Result { Err(unavailable()) } diff --git a/frontend/src/lib/components/DBManager.svelte b/frontend/src/lib/components/DBManager.svelte index 5204a876b9..837569c575 100644 --- a/frontend/src/lib/components/DBManager.svelte +++ b/frontend/src/lib/components/DBManager.svelte @@ -375,7 +375,8 @@ } if (!isInstanceDatatable(datatable)) return { - reason: 'Only data tables stored in the instance database have roles and grants.', + reason: + "Roles and grants are not supported on data tables backed by a Postgres resource. Store the data table on Windmill's database or on an external Postgres instance (set up by a superadmin in instance settings) to use them.", ee: false } if (needsAdmin && !canManageDatatable) @@ -458,6 +459,11 @@ // default to open — actually be folded; a plain "expanded" set could never // close them, since the default would keep winning. // What the permissions drawer is open on, if anything: a schema, or a table. + let aclEditor = $state(undefined) + /** The open access editor lists the data table's roles, which the roles drawer can change. */ + export function refreshAcl() { + aclEditor?.refresh() + } let aclDrawer = $state<{ datatable: string | undefined; target: AclTarget } | undefined>( undefined ) @@ -1466,7 +1472,7 @@ (aclDrawer = undefined)}> (aclDrawer = undefined)} tooltip="Who owns this, and what each role may do with it." > @@ -1474,7 +1480,18 @@ {@const dt = aclDrawer.datatable ?? currentDatatable} {#if dt} {#key `${dt}~${JSON.stringify(aclDrawer.target)}`} - + onDatatableAction?.(dt, 'roles'), + blocker: accessBlocker(dt, true)?.reason + } + : undefined} + /> {/key} {/if} {/if} diff --git a/frontend/src/lib/components/DBManagerDrawer.svelte b/frontend/src/lib/components/DBManagerDrawer.svelte index bd016544b0..30fc93377d 100644 --- a/frontend/src/lib/components/DBManagerDrawer.svelte +++ b/frontend/src/lib/components/DBManagerDrawer.svelte @@ -311,6 +311,7 @@ function refreshRoles() { datatables.refetch() usableRoles.refetch() + dbManagerContent?.dbManager()?.refreshAcl() } async function handleExportSchema(explicitSource?: string) { diff --git a/frontend/src/lib/components/DropdownV2Inner.svelte b/frontend/src/lib/components/DropdownV2Inner.svelte index 31fd9426f5..70ed83398d 100644 --- a/frontend/src/lib/components/DropdownV2Inner.svelte +++ b/frontend/src/lib/components/DropdownV2Inner.svelte @@ -107,9 +107,19 @@ {})} /> {:else if item.disabled && item.tooltip} -
- {@render menuItem(item)} + lets the hover reach it so the user learns why the item is disabled. The ⓘ sits + outside the disabled button, the only place it can receive a hover. --> +
+
+ {@render menuItem(item)} +
+
+ + {#snippet text()} + {item.tooltip} + {/snippet} + +
{:else} {@render menuItem(item)} diff --git a/frontend/src/lib/components/common/badge/Badge.svelte b/frontend/src/lib/components/common/badge/Badge.svelte index c57d41029c..09a56c427a 100644 --- a/frontend/src/lib/components/common/badge/Badge.svelte +++ b/frontend/src/lib/components/common/badge/Badge.svelte @@ -85,7 +85,8 @@ ['dark-indigo']: 'bg-indigo-700 text-indigo-100 dark:bg-indigo-800 dark:text-indigo-200', ['dark-orange']: 'bg-orange-700 text-orange-100 dark:bg-orange-800 dark:text-orange-200', ['dark-violet']: 'bg-violet-700 text-violet-100 dark:bg-violet-800 dark:text-violet-200', - transparent: 'bg-surface-accent-selected text-accent border-gray-400 dark:border-gray-500' + // Keeps the unselected chip's border, so selecting one does not change its height. + transparent: 'bg-surface-accent-selected text-accent border border-surface-accent-selected' } const hovers: Partial> = { diff --git a/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte b/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte index 524817ba34..4b419536fc 100644 --- a/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte +++ b/frontend/src/lib/components/common/confirmationModal/ConfirmationModal.svelte @@ -29,6 +29,8 @@ * up confines it. Off by default: it moves the dialog out of its DOM position, so opt * in per call site rather than assuming every caller wants it. */ alwaysPortal?: boolean + /** For a notice with nothing to decide: the confirm button is the only one. */ + hideCancel?: boolean children?: Snippet onConfirmed?: () => void | Promise onCanceled?: () => void @@ -47,6 +49,7 @@ trashbin = false, zIndexClass = 'z-[9999]', alwaysPortal = false, + hideCancel = false, children, onConfirmed, onCanceled @@ -228,15 +231,17 @@ {/if} {confirmationText} - + {#if !hideCancel} + + {/if}
diff --git a/frontend/src/lib/components/common/drawer/Disposable.svelte b/frontend/src/lib/components/common/drawer/Disposable.svelte index 886c14042b..4fd472729f 100644 --- a/frontend/src/lib/components/common/drawer/Disposable.svelte +++ b/frontend/src/lib/components/common/drawer/Disposable.svelte @@ -32,6 +32,9 @@ children?: import('svelte').Snippet<[any]> onOpen?: () => void onClose?: () => void + /** Asked before a click away or Escape closes it; resolving false keeps it open, e.g. to + * confirm discarding unsaved changes. */ + confirmClose?: () => boolean | Promise } let { @@ -42,9 +45,25 @@ minZIndex = 0, children, onOpen, - onClose + onClose, + confirmClose }: Props = $props() + // While the question is on screen, the keys and clicks are its own. + let confirming = false + async function requestClose() { + if (confirming) return + if (confirmClose) { + confirming = true + try { + if (!(await confirmClose())) return + } finally { + confirming = false + } + } + closeDrawer() + } + let offset = $state(untrack(() => initialOffset)) // Note: when a Modal with minZIndex is open, all disposables (including // already-open Drawers) are elevated. This is acceptable — relative @@ -115,7 +134,7 @@ const last = stack.val[stack.val.length - 1] if (last === id) { e.stopPropagation() - closeDrawer() + requestClose() } } @@ -125,7 +144,16 @@ if (open) { switch (event.key) { case 'Escape': + // The confirmation on screen answers this Escape itself, as its Cancel. + if (confirming) break if ((id == stack.val[stack.val.length - 1] || stack.val.length == 0) && !preventEscape) { + if (confirmClose) { + event.preventDefault() + event.stopPropagation() + event.stopImmediatePropagation() + requestClose() + break + } stack.val.pop() event.preventDefault() event.stopPropagation() diff --git a/frontend/src/lib/components/common/drawer/Drawer.svelte b/frontend/src/lib/components/common/drawer/Drawer.svelte index 76502735ca..45cf99c7be 100644 --- a/frontend/src/lib/components/common/drawer/Drawer.svelte +++ b/frontend/src/lib/components/common/drawer/Drawer.svelte @@ -28,6 +28,8 @@ positionClass?: string | undefined name?: string children?: import('svelte').Snippet<[any]> + /** Asked before a click away or Escape closes it; resolving false keeps it open. */ + confirmClose?: () => boolean | Promise } let { @@ -43,7 +45,8 @@ class: clazz = '', positionClass = undefined, name = undefined, - children: children_render + children: children_render, + confirmClose }: Props = $props() if (open === undefined) { @@ -146,6 +149,7 @@ onOpen={() => dispatch('open')} onClose={() => dispatch('close')} {preventEscape} + {confirmClose} > {#snippet children({ handleClickAway, zIndex, isTop })}
{#if info.editable} - info.owner, + (role) => { + // The select shows what the database says; a pick is a request, and only the + // applied change moves it. + if (role && role !== info.owner) { + confirm({ type: 'set_owner', role }, `Ownership transferred to ${role}`) + } } } - } - /> + bottomSnippet={manageRoles ? manageRolesEntry : undefined} + /> + {/snippet} + {@render withDisabledReason(ownerSelect, 'w-64')} {:else} {info.owner} {/if} @@ -168,75 +290,76 @@ : 'What each role may do here, beyond what it owns.'}
- {#if info.editable} - - confirm( - { type: 'grant', role, privileges, scope }, - `Granted ${privileges.join(', ')} to ${role}` - )} - /> + + {#if grantRows.length > 1 || roleFilters.length > 0 || privilegeFilters.length > 0} +
+ {#each roleChips as role (`role:${role}`)} + {@render filterChip(role, roleFilters.includes(role), () => { + roleFilters = toggled(roleFilters, role) + })} + {/each} + {#each privilegeChips as privilege (`privilege:${privilege}`)} + {@render filterChip(privilege, privilegeFilters.includes(privilege), () => { + privilegeFilters = toggled(privilegeFilters, privilege) + })} + {/each} +
{/if} - {#if grantRows.length === 0} - No grants yet. - {:else} - - - - Role - Privileges + + + + Role + Privileges + {#if showCoverage} On - - - - - {#each grantRows as grant (grantKey(grant))} - {@const revokeScope = revokeScopeOf(grant)} - {@const revocable = revocablePrivileges(grant, target)} - {@const blocked = blockingSources(grant, revocable)} - {@const uncovered = uncoveredCreators(grant, info.roles)} - - {grant.grantee} - {grant.privileges.join(', ')} - - {grantScopeLabel(grant)} - {#if blocked.length > 0} - - from {blocked.join(', ')} - - {/if} - {#if uncovered.length > 0} - - · not for what {uncovered.join(', ')} - {uncovered.length === 1 ? 'creates' : 'create'} - - {/if} + {/if} + + + + + {#each shownGrantRows as grant (grantKey(grant))} + {@const revokeScope = revokeScopeOf(grant)} + {@const revocable = revocablePrivileges(grant, target)} + {@const blocked = blockingSources(grant, revocable)} + {@const uncovered = uncoveredCreators(grant, info.roles)} + {@const unrevocable = unrevocableReason(grant, revokeScope, revocable, blocked)} + + {grant.grantee} + +
+ {#each grant.privileges as privilege (privilege)} + {@render tag(privilege)} + {/each} +
+ {#if blocked.length > 0 || uncovered.length > 0} +
+ {[ + blocked.length > 0 && `granted by ${blocked.join(', ')}`, + uncovered.length > 0 && + `not for what ${uncovered.join(', ')} ${uncovered.length === 1 ? 'creates' : 'create'}: a default privilege only covers the roles it was granted for` + ] + .filter(Boolean) + .join(' · ')} +
+ {/if} +
+ {#if showCoverage} + + {grantCoverage(grant, target) ?? targetLabel} - - - {#if info.editable && revokeScope && revocable.length > 0 && blocked.length === 0 && info.roles.includes(grant.grantee) && grant.grantee !== ADMIN_ROLE} + {/if} + + {#if info.editable} + {#snippet revokeButton()}
{/if} diff --git a/frontend/src/lib/components/datatableAcl/PgGrantBuilder.svelte b/frontend/src/lib/components/datatableAcl/PgGrantBuilder.svelte index 5e5119abb6..745c1a1db5 100644 --- a/frontend/src/lib/components/datatableAcl/PgGrantBuilder.svelte +++ b/frontend/src/lib/components/datatableAcl/PgGrantBuilder.svelte @@ -1,17 +1,18 @@ -
-
- GRANT - ({ value: p, label: p }))} - placeholder="privileges" - {disabled} - size="sm" - class="min-w-48" - /> - ON + +
+ GRANT + ({ value: p, label: p }))} + placeholder="privileges" + {disabled} + size="md" + class="min-w-0 flex-[4]" + /> + + {#if scopes.length > 1} + ON ({ value: r, label: r }))} - placeholder="role" - {disabled} - size="sm" - class="w-40" - /> - -
- {#if statement} -
{statement}
{/if} + TO + - {#if info?.supported && permissioned && !hasUnsavedChanges} + {#if info?.supported && permissioned}
- + +
+ +
{/if} {/if} @@ -430,10 +506,7 @@
+ {/await} {/if} -{#if notExists} -
There was an error loading the app, is the url correct? Go to Windmill -
-{:else if noPermission} - {#if guestAppPath && !$userStore} -
Sign in to open this app
-
- You do not need a Windmill account. Signing in lets you open this app and nothing else. -
- {:else} -
- This app requires read access -
-
- {#if $userStore}You are logged in but have no read access to this app{:else if globalUser && effectiveWorkspace} - You are logged in but are not a member of the workspace {effectiveWorkspace} this app is part of - {:else}You must be logged in and have read access to this app{/if}
- {/if} -
- {#if !jwtError} - - {/if} -
+{#if notExists || noPermission} + {#await Promise.all([loadAppCss(), import('./PublicAppAccess.svelte')]) then [, { default: PublicAppAccess }]} + + {/await} {:else if app} {#key app} {#if app.raw_app && effectiveWorkspace} - -
+ above it falls back to 150px. Styled inline: app.css may not be loaded here. --> +
} @@ -178,58 +118,28 @@ />
{:else if app.raw_app && !effectiveWorkspace} -
- - Unable to load raw app: workspace information is missing. - -
+ {#await Promise.all([loadAppCss(), import('$lib/components/common/alert/Alert.svelte')]) then [, { default: Alert }]} +
+ + Unable to load raw app: workspace information is missing. + +
+ {/await} {:else} -
- {#await loadAppPreview()} - - {:then Module} - goto(path)} - gotoFn={(path, opt) => (embedNav ? embedNav.navigateTop(path) : goto(path, opt))} - /> - {:catch} -
- Reload the page to try again. -
- {/await} -
+ {#await Promise.all([loadAppCss(), import('./PublicLowCodeApp.svelte')]) then [, { default: PublicLowCodeApp }]} + + {/await} {/if} {/key} {:else} - + {#await loadAppCss() then} + + {/await} {/if} diff --git a/frontend/src/lib/components/apps/editor/PublicAppAccess.svelte b/frontend/src/lib/components/apps/editor/PublicAppAccess.svelte new file mode 100644 index 0000000000..9a993b4972 --- /dev/null +++ b/frontend/src/lib/components/apps/editor/PublicAppAccess.svelte @@ -0,0 +1,81 @@ + + +{#if notExists} +
There was an error loading the app, is the url correct? Go to Windmill +
+{:else} + {#if guestAppPath && !user} +
Sign in to open this app
+
+ You do not need a Windmill account. Signing in lets you open this app and nothing else. +
+ {:else} +
+ This app requires read access +
+
+ {#if user}You are logged in but have no read access to this app{:else if globalUser && workspace} + You are logged in but are not a member of the workspace {workspace} this app is part of + {:else}You must be logged in and have read access to this app{/if}
+ {/if} +
+ {#if !jwtError} + { + dropPrefetched() + onLoginSuccess() + }} + popup + guestApp={guestAppPath} + rd={page.url.pathname + page.url.search + page.url.hash} + /> + {/if} +
+{/if} diff --git a/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte b/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte index 934511abce..dbb9b4b88b 100644 --- a/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte +++ b/frontend/src/lib/components/apps/editor/PublicAppFrame.svelte @@ -23,24 +23,44 @@ * bearer token (no cookie); the raw wrapper document always carries `CSP: sandbox`. */ import { BROWSER } from 'esm-env' - import { OpenAPI, UserService } from '$lib/gen' + import { OpenAPI } from '$lib/gen/core/OpenAPI' import { page } from '$app/state' import { onDestroy, onMount, setContext, type Snippet } from 'svelte' - import Alert from '$lib/components/common/alert/Alert.svelte' import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte' import { base } from '$app/paths' import { goto } from '$app/navigation' - import Login from '$lib/components/Login.svelte' - import { WINDMILL_RESERVED_QUERY_PARAMS } from '$lib/utils' + import { WINDMILL_RESERVED_QUERY_PARAMS } from '$lib/utils/queryParams' import { EMBED_NAV_CONTEXT_KEY, type EmbedNav } from '../types' import { loadAppPreview } from './loadAppPreview' - import RawAppSdkConsent from '$lib/components/raw_apps/RawAppSdkConsent.svelte' + import { dropPrefetched, loadAppCss } from './publicAppApi' import { hasStoredSdkConsent, sdkConsentCovers, storeSdkConsent } from '$lib/components/raw_apps/sdkScopes' + // Everything but a rendered app (sign-in, errors, the SDK consent prompt) loads on + // demand: a raw app that renders right away needs none of it. See publicAppApi.ts. + let frameUi: + | Promise<{ + Alert: typeof import('$lib/components/common/alert/Alert.svelte').default + Login: typeof import('$lib/components/Login.svelte').default + RawAppSdkConsent: typeof import('$lib/components/raw_apps/RawAppSdkConsent.svelte').default + }> + | undefined + function loadFrameUi() { + return (frameUi ??= Promise.all([ + loadAppCss(), + import('$lib/components/common/alert/Alert.svelte'), + import('$lib/components/Login.svelte'), + import('$lib/components/raw_apps/RawAppSdkConsent.svelte') + ]).then(([, alert, login, consent]) => ({ + Alert: alert.default, + Login: login.default, + RawAppSdkConsent: consent.default + }))) + } + type EmbedToken = { token?: string | null raw_app?: boolean @@ -235,7 +255,8 @@ if (offerSignIn && accountSession === 'unknown') { // Workspace-less, so it answers for an account and never for a guest // (pinned to its workspace) or for nobody. - UserService.getCurrentEmail() + import('$lib/gen') + .then(({ UserService }) => UserService.getCurrentEmail()) .then(() => (accountSession = 'held')) .catch(() => (accountSession = 'none')) } @@ -249,8 +270,12 @@ let staleGuestLogoutFailed = $state(false) $effect(() => { if (deniedStatus === 403 && guestEntry === 'guest' && !staleGuestCleared) { - UserService.logout() - .then(() => (staleGuestCleared = true)) + import('$lib/gen') + .then(({ UserService }) => UserService.logout()) + .then(() => { + dropPrefetched() + staleGuestCleared = true + }) .catch(() => (staleGuestLogoutFailed = true)) } }) @@ -427,7 +452,10 @@ // Render the app directly on this origin: same-origin when unsandboxed // (the default), or a single opaque bundle iframe when it's a sandboxed // raw app. A low-code app's runtime downloads alongside the app payload. - if (!isRaw) loadAppPreview().catch(() => {}) + if (!isRaw) { + loadAppCss().catch(() => {}) + loadAppPreview().catch(() => {}) + } onViewerReady?.(undefined, requestTokenRefresh) } else { // Sandboxed low-code: hand the scoped token to the opaque viewer iframe. @@ -537,6 +565,7 @@ onMount(() => { if (isViewer) { // Only a sandboxed low-code app is ever framed as a viewer. + loadAppCss().catch(() => {}) loadAppPreview().catch(() => {}) window.addEventListener('message', handleViewerMessage) installHashRelay() @@ -569,105 +598,120 @@ {#if viewerReady} {@render viewer()} {:else if viewerOrphaned} -
- - This is a Windmill app viewer and must be loaded by Windmill. If you embedded it in your own - page, use the app's public URL without the wm_embed parameter. - -
+ {#await loadFrameUi() then { Alert }} +
+ + This is a Windmill app viewer and must be loaded by Windmill. If you embedded it in your + own page, use the app's public URL without the wm_embed parameter. + +
+ {/await} {:else} - + {#await loadAppCss() then} + + {/await} {/if} {:else if status === 'loading'} - -{:else if status === 'notExists' && !offerSignIn} -
- - There was an error loading the app, is the url correct? - Go to Windmill - -
-{:else if status === 'sdkPrompt'} - - -{:else if offerSignIn && (guestEntry === 'pending' || accountSession === 'unknown' || (deniedStatus === 403 && guestEntry === 'guest' && !staleGuestCleared && !staleGuestLogoutFailed))} - -{:else if offerSignIn && (guestEntry === 'error' || staleGuestLogoutFailed)} -
- - The app could not be reached to find out who may open it. Reload to try again. - -
-{:else if offerSignIn} - - {#if signInDidNotHelp} - -
- You are signed in, but this app is not open to you -
-
- It is open to the people it was shared with{guestAppPath - ? ', and to guests who have no Windmill account' - : ''}. Ask the person who shared it to give your account access. -
+ {#await loadAppCss() then} + + {/await} +{:else if status === 'ready'} + {#if unsandboxed} + + {@render viewer()} + {:else if isRaw} + + {@render viewer()} {:else} - {#if guestAppPath} -
Sign in to open this app
-
- You do not need a Windmill account. Signing in lets you open this app and nothing else. -
- {:else} -
- This app requires read access -
- {/if} - {#if signInError} -
- {signInError} -
- {/if} -
- { - signInError = undefined - accountSession = 'unknown' - initEmbedder() - }} - onLoginError={(message) => (signInError = message)} - popup - guestApp={guestAppPath} - rd={page.url.pathname + page.url.search + page.url.hash} - /> -
+ + {/if} -{:else if unsandboxed} - - {@render viewer()} -{:else if isRaw} - - {@render viewer()} {:else} - - + {#await loadFrameUi() then { Alert, Login, RawAppSdkConsent }} + {#if status === 'notExists' && !offerSignIn} +
+ + There was an error loading the app, is the url correct? + Go to Windmill + +
+ {:else if status === 'sdkPrompt'} + + + {:else if offerSignIn && (guestEntry === 'pending' || accountSession === 'unknown' || (deniedStatus === 403 && guestEntry === 'guest' && !staleGuestCleared && !staleGuestLogoutFailed))} + + {:else if offerSignIn && (guestEntry === 'error' || staleGuestLogoutFailed)} +
+ + The app could not be reached to find out who may open it. Reload to try again. + +
+ {:else if offerSignIn} + + {#if signInDidNotHelp} + +
+ You are signed in, but this app is not open to you +
+
+ It is open to the people it was shared with{guestAppPath + ? ', and to guests who have no Windmill account' + : ''}. Ask the person who shared it to give your account access. +
+ {:else} + {#if guestAppPath} +
Sign in to open this app
+
+ You do not need a Windmill account. Signing in lets you open this app and nothing else. +
+ {:else} +
+ This app requires read access +
+ {/if} + {#if signInError} +
+ {signInError} +
+ {/if} +
+ { + dropPrefetched() + signInError = undefined + accountSession = 'unknown' + initEmbedder() + }} + onLoginError={(message) => (signInError = message)} + popup + guestApp={guestAppPath} + rd={page.url.pathname + page.url.search + page.url.hash} + /> +
+ {/if} + {/if} + {/await} {/if} diff --git a/frontend/src/lib/components/apps/editor/PublicLowCodeApp.svelte b/frontend/src/lib/components/apps/editor/PublicLowCodeApp.svelte new file mode 100644 index 0000000000..f74be67166 --- /dev/null +++ b/frontend/src/lib/components/apps/editor/PublicLowCodeApp.svelte @@ -0,0 +1,91 @@ + + +
+ {#await loadAppPreview()} + + {:then Module} + goto(path)} + gotoFn={(path, opt) => (embedNav ? embedNav.navigateTop(path) : goto(path, opt))} + /> + {:catch} +
+ Reload the page to try again. +
+ {/await} +
diff --git a/frontend/src/lib/components/apps/editor/publicAppApi.ts b/frontend/src/lib/components/apps/editor/publicAppApi.ts new file mode 100644 index 0000000000..674cb0b622 --- /dev/null +++ b/frontend/src/lib/components/apps/editor/publicAppApi.ts @@ -0,0 +1,82 @@ +// The public app routes (`/public/...`, `/a/...`) render a raw app as little more than its +// bundle's iframe, so they must not statically import the generated client, `$lib/stores` +// or `$lib/utils`: each drags in most of the app shell. `vite.config.js` +// (`assertLeanPublicAppRoutes`) fails the build if they do. Anything heavier is imported +// lazily by the branch that needs it. +import { OpenAPI } from '$lib/gen/core/OpenAPI' +import type { AppWithLastVersion, User } from '$lib/gen/types.gen' +import type { UserExt } from '$lib/stores' +import { mapUserToUserExt } from '$lib/userExt' + +export type PublicAppValue = AppWithLastVersion & { value: any; workspace_id?: string } + +declare global { + interface Window { + /** Responses `app.html` requested before any module loaded, keyed by URL. */ + __wmPublicAppPrefetch?: Record> + } +} + +/** Carries `status` like the generated client's `ApiError`, which callers branch on. */ +export class PublicApiError extends Error { + constructor(public status: number) { + super(`Request failed with status ${status}`) + } +} + +/** Prefetched responses answer for the credential the page loaded with; call this when it + * changes (a sign-in or logout on the page) so later reads go to the network. */ +export function dropPrefetched(): void { + delete window.__wmPublicAppPrefetch +} + +function takePrefetched(url: string): Promise | undefined { + const prefetched = window.__wmPublicAppPrefetch + const res = prefetched?.[url] + if (res) delete prefetched![url] + return res +} + +/** GET an API path with the page credential: the bearer token in `OpenAPI.TOKEN` when the + * share link carries one, the session cookie otherwise. A prefetched response is only + * taken in the cookie case, which is the only credential `app.html` sends. */ +export async function publicGet(path: string): Promise { + const url = `${OpenAPI.BASE}${path}` + const token = typeof OpenAPI.TOKEN === 'string' && OpenAPI.TOKEN ? OpenAPI.TOKEN : undefined + const res = await ((!token && takePrefetched(url)) || + fetch(url, { + // As the generated client does: the opaque viewer frame is cross-origin to the + // API, whose `Access-Control-Allow-Origin: *` refuses a credentialed request. + credentials: OpenAPI.WITH_CREDENTIALS ? OpenAPI.CREDENTIALS : undefined, + headers: token ? { Authorization: `Bearer ${token}` } : {} + })) + if (!res.ok) throw new PublicApiError(res.status) + return (res.headers.get('content-type')?.includes('json') ? res.json() : res.text()) as T +} + +export async function getUserExtOrUndefined(workspace: string): Promise { + try { + return mapUserToUserExt( + await publicGet(`/w/${encodeURI(workspace)}/users/whoami`), + workspace + ) + } catch { + return undefined + } +} + +/** Only fades the "Powered by" badge, so a failure is not worth surfacing. */ +export async function getLicenseOrUndefined(): Promise { + try { + return (await publicGet('/ee_license')) || undefined + } catch { + return undefined + } +} + +let appCss: Promise | undefined +/** The app-wide stylesheet, which these routes need only off the raw-app path: sign-in, + * errors, skeletons and low-code apps. A raw app's bundle brings its own styles. */ +export function loadAppCss(): Promise { + return (appCss ??= import('$lib/assets/app.css')) +} diff --git a/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte b/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte index c3eadc1f68..9f675664b1 100644 --- a/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppBackgroundRunner.svelte @@ -52,6 +52,11 @@ // Job ids launched by this app instance — see `gateJobIds`. const launchedJobs = new Set() + /** For a host that loads this component after the bundle may already have sent requests. */ + export function handleMessage(event: MessageEvent) { + return listener(event) + } + let listener = async (event) => { // Only accept messages from the bundle iframe (opaque origin) or the // detached preview window we opened, so other frames/extensions can't diff --git a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte index 8a79b4b8f9..9eced2d0e6 100644 --- a/frontend/src/lib/components/raw_apps/RawAppPreview.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppPreview.svelte @@ -1,6 +1,6 @@ - +{#await runnerModule then { default: Runner }} + +{:catch} + {#if requestRefused} + +
+ Could not load the app runtime. Reload the page to try again. +
+ {/if} +{/await} {#if iframeSrc} + {/if} diff --git a/frontend/src/lib/user.ts b/frontend/src/lib/user.ts index 006c2ab60c..12d6f237ba 100644 --- a/frontend/src/lib/user.ts +++ b/frontend/src/lib/user.ts @@ -1,5 +1,6 @@ -import { type User, UserService } from '$lib/gen' +import { UserService } from '$lib/gen' import type { UserExt } from './stores.js' +import { mapUserToUserExt } from './userExt' async function fetchUserExt(workspace: string): Promise { return mapUserToUserExt(await UserService.whoami({ workspace }), workspace) @@ -42,16 +43,3 @@ export function getWorkspaceRole(workspace: string): Promise { export function clearWorkspaceRoleCache(): void { workspaceRoleCache.clear() } - -function mapUserToUserExt(user: User, workspace: string): UserExt { - const ext: UserExt = { - ...user, - workspace_id: workspace, - groups: user.groups!, - pgroups: user.groups!.map((x) => `g/${x}`) - } - if (ext.is_service_account && sessionStorage.getItem('pre_impersonation_token')) { - ext.impersonating_email = sessionStorage.getItem('pre_impersonation_email') ?? undefined - } - return ext -} diff --git a/frontend/src/lib/userExt.ts b/frontend/src/lib/userExt.ts new file mode 100644 index 0000000000..d06e1cd04d --- /dev/null +++ b/frontend/src/lib/userExt.ts @@ -0,0 +1,15 @@ +import type { User } from '$lib/gen/types.gen' +import type { UserExt } from './stores' + +export function mapUserToUserExt(user: User, workspace: string): UserExt { + const ext: UserExt = { + ...user, + workspace_id: workspace, + groups: user.groups!, + pgroups: user.groups!.map((x) => `g/${x}`) + } + if (ext.is_service_account && sessionStorage.getItem('pre_impersonation_token')) { + ext.impersonating_email = sessionStorage.getItem('pre_impersonation_email') ?? undefined + } + return ext +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index ec112607fd..8540b964e8 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1322,28 +1322,7 @@ export function isCodeInjection(expr: string | undefined): boolean { return dynamicTemplateRegex.test(expr) } -// Query params Windmill consumes internally and that should not be exposed to -// app logic via the `query` context. Only params we actually own are listed -// here — the `wm_` prefix is a naming convention, not a reserved namespace, so -// we don't strip it wholesale (that would break apps reading their own `wm_*` -// params). `wm_coep` is a transport flag for cross-origin isolation headers; -// `wm_embed`/`wm_embedder_origin` are the opaque app viewer transport params -// (see PublicAppFrame). -export const WINDMILL_RESERVED_QUERY_PARAMS = new Set(['wm_coep', 'wm_embed', 'wm_embedder_origin']) - -export function urlParamsToObject( - params: URLSearchParams, - opts?: { stripReserved?: boolean } -): Record { - const result: Record = {} - params.forEach((value, key) => { - if (opts?.stripReserved && WINDMILL_RESERVED_QUERY_PARAMS.has(key)) { - return - } - result[key] = value - }) - return result -} +export { WINDMILL_RESERVED_QUERY_PARAMS, urlParamsToObject } from './utils/queryParams' export async function tryEvery({ tryCode, diff --git a/frontend/src/lib/utils/queryParams.ts b/frontend/src/lib/utils/queryParams.ts new file mode 100644 index 0000000000..ffefc47eea --- /dev/null +++ b/frontend/src/lib/utils/queryParams.ts @@ -0,0 +1,22 @@ +// Query params Windmill consumes internally and that should not be exposed to +// app logic via the `query` context. Only params we actually own are listed +// here — the `wm_` prefix is a naming convention, not a reserved namespace, so +// we don't strip it wholesale (that would break apps reading their own `wm_*` +// params). `wm_coep` is a transport flag for cross-origin isolation headers; +// `wm_embed`/`wm_embedder_origin` are the opaque app viewer transport params +// (see PublicAppFrame). +export const WINDMILL_RESERVED_QUERY_PARAMS = new Set(['wm_coep', 'wm_embed', 'wm_embedder_origin']) + +export function urlParamsToObject( + params: URLSearchParams, + opts?: { stripReserved?: boolean } +): Record { + const result: Record = {} + params.forEach((value, key) => { + if (opts?.stripReserved && WINDMILL_RESERVED_QUERY_PARAMS.has(key)) { + return + } + result[key] = value + }) + return result +} diff --git a/frontend/src/routes/(root)/(logged)/user/(user)/logout/+page@.svelte b/frontend/src/routes/(root)/(logged)/user/(user)/logout/+page@.svelte index 75e31a9a00..c9623bc340 100644 --- a/frontend/src/routes/(root)/(logged)/user/(user)/logout/+page@.svelte +++ b/frontend/src/routes/(root)/(logged)/user/(user)/logout/+page@.svelte @@ -1,4 +1,6 @@ + +{@render children?.()} diff --git a/frontend/src/routes/app_embed/[workspace]/[...path]/+page.svelte b/frontend/src/routes/(styled)/app_embed/[workspace]/[...path]/+page.svelte similarity index 99% rename from frontend/src/routes/app_embed/[workspace]/[...path]/+page.svelte rename to frontend/src/routes/(styled)/app_embed/[workspace]/[...path]/+page.svelte index 85a72dd7e3..9b3a9c1e41 100644 --- a/frontend/src/routes/app_embed/[workspace]/[...path]/+page.svelte +++ b/frontend/src/routes/(styled)/app_embed/[workspace]/[...path]/+page.svelte @@ -93,6 +93,7 @@ + // app.css is imported by the layouts below this one, not here: the public app routes + // (`public/`, `a/`) load it only when they render more than a raw app's bundle. + // A production build links a layout's CSS ahead of its page's, but in dev a stylesheet + // lands when its module evaluates, and a page's can evaluate before its layout's. This + // module is imported at startup, so loading it here in dev keeps app.css first. + if (import.meta.env.DEV && !/^\/(public|a)\//.test(location.pathname)) { + import('$lib/assets/app.css') + } + + -
+ body in `display: contents`), and the viewer's own boxes are full-height. Styled + inline: a raw app renders here without app.css. --> +
import { BROWSER } from 'esm-env' - import { AppService, OpenAPI, type AppWithLastVersion } from '$lib/gen' - import { userStore } from '$lib/stores' - - import { setLicense } from '$lib/enterpriseUtils' - - import { getUserExt } from '$lib/user' + import { OpenAPI } from '$lib/gen/core/OpenAPI' + import type { GuestEntry } from '$lib/gen/types.gen' + import type { UserExt } from '$lib/stores' import { page } from '$app/state' import { base } from '$lib/base' import PublicApp from '$lib/components/apps/editor/PublicApp.svelte' import PublicAppFrame from '$lib/components/apps/editor/PublicAppFrame.svelte' + import { + getLicenseOrUndefined, + getUserExtOrUndefined, + publicGet, + type PublicAppValue + } from '$lib/components/apps/editor/publicAppApi' - let app: (AppWithLastVersion & { value: any }) | undefined = $state(undefined) + let app: PublicAppValue | undefined = $state(undefined) + let user: UserExt | undefined = $state(undefined) + let license: string | undefined = $state(undefined) let notExists = $state(false) let noPermission = $state(false) let jwtError = $state(false) @@ -70,35 +75,21 @@ } else if (parsedSecret.jwt) { OpenAPI.TOKEN = 'jwt_ext_' + parsedSecret.jwt } - const headers: Record = {} - if (typeof OpenAPI.TOKEN === 'string' && OpenAPI.TOKEN) { - headers['Authorization'] = `Bearer ${OpenAPI.TOKEN}` - } const consent = opts?.sdkConsent ? '?sdk_consent=true' : '' - const res = await fetch( - `${OpenAPI.BASE}/w/${workspace}/apps_u/embed_token/${parsedSecret.secret}${consent}`, - { headers } - ) - if (!res.ok) { - const err: any = new Error('Failed to fetch embed token') - err.status = res.status - throw err - } - return await res.json() + return await publicGet(`/w/${workspace}/apps_u/embed_token/${parsedSecret.secret}${consent}`) } // Viewer side: load the app + user using the embed token handed to the iframe. async function loadApp() { + // Neither waits on the other: `app.html` already requested both. + const userLoad = getUserExtOrUndefined(workspace) + const appLoad = publicGet( + `/w/${workspace}/apps_u/public_app/${encodeURI(parsedSecret.secret)}` + ) + appLoad.catch(() => {}) + user = await userLoad try { - userStore.set(await getUserExt(workspace)) - } catch (e) { - console.warn('Anonymous user') - } - try { - app = await AppService.getPublicAppBySecret({ - workspace, - path: parsedSecret.secret - }) + app = await appLoad noPermission = false notExists = false } catch (e) { @@ -126,7 +117,9 @@ if (guestEntrySettled()) return for (let attempt = 0; attempt < 3; attempt++) { try { - const entry = await AppService.getGuestEntry({ workspace, path: parsedSecret.secret }) + const entry = await publicGet( + `/w/${workspace}/apps_u/guest_entry/${encodeURI(parsedSecret.secret)}` + ) guestAppPath = `${workspace}/${entry.app_path}` guestEntry = 'guest' return @@ -156,13 +149,14 @@ } if (BROWSER) { - setLicense() + getLicenseOrUndefined().then((l) => (license = l)) } -
+ body in `display: contents`), and the viewer's own boxes are full-height. Styled + inline: a raw app renders here without app.css. --> +
Object.keys(bundle).some((file) => file.startsWith('_app/immutable/')) /** - * Fail the build if a public app URL statically loads the low-code runtime or monaco. + * Fail the build if a public app URL statically loads more than a raw app needs. * * These pages also serve raw apps, which only need a small shell around their bundle's - * iframe. One static import of AppPreview (or of anything reaching monaco) makes every - * public app page preload ~650 chunks instead of ~55. See loadAppPreview.ts. + * iframe, and the page shows nothing until its static imports have loaded. One static + * import of AppPreview (or of anything reaching monaco) makes every public app page + * preload ~650 chunks; one of the generated client, `$lib/stores` or `$lib/utils` drags + * in most of the app shell. The root layout wraps these pages, so it must not import + * app.css either. See loadAppPreview.ts and publicAppApi.ts. */ function assertLeanPublicAppRoutes() { // Route directories, so +page.js counts as well as +page.svelte. const routes = ['/src/routes/public/[workspace]/[...secret]/', '/src/routes/a/[...path]/'] const forbidden = [ '/src/lib/components/apps/editor/AppPreview.svelte', - '/node_modules/monaco-editor/' + '/node_modules/monaco-editor/', + '/src/lib/assets/app.css', + '/src/lib/gen/services.gen.ts', + '/src/lib/stores.ts', + '/src/lib/utils.ts', + '/src/lib/components/Login.svelte', + '/node_modules/@melt-ui/' ] return { name: 'wm-assert-lean-public-app-routes', generateBundle(_options, bundle) { if (!isClientBuild(bundle)) return - for (const route of routes) { - assertStaticClosureExcludes(this, bundle, route, forbidden, 'loadAppPreview.ts') + for (const route of [...routes, '/src/routes/+layout.svelte']) { + assertStaticClosureExcludes(this, bundle, route, forbidden, 'publicAppApi.ts') } } } @@ -372,7 +381,19 @@ const config = { // which makes the *chunk* graph cyclic where the module graph is not, and // modules then evaluate against uninitialized bindings. See // docs/frontend-import-cycles.md. - advancedChunks: { groups: [{ name: 'gen', test: /[\\/]src[\\/]lib[\\/]gen[\\/]/ }] } + // The client's config is split out for the public app routes, which set the + // credential without loading the client. It imports nothing at runtime, so + // its chunk cannot be part of a cycle. + advancedChunks: { + groups: [ + { + name: 'gen-config', + test: /[\\/]src[\\/]lib[\\/]gen[\\/]core[\\/]OpenAPI\.ts$/, + priority: 1 + }, + { name: 'gen', test: /[\\/]src[\\/]lib[\\/]gen[\\/]/ } + ] + } } } }, From 8b9222d778c3d9cf1f842f10b40a53afff6c8d52 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 8 Oct 2026 09:15:49 +0200 Subject: [PATCH 35/37] chore: fix rust client retry param and ai-evals workflow yaml --- .github/workflows/ai-evals-scheduled.yml | 2 +- rust-client/src/client.rs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ai-evals-scheduled.yml b/.github/workflows/ai-evals-scheduled.yml index 94ecc7c6c8..580a757c21 100644 --- a/.github/workflows/ai-evals-scheduled.yml +++ b/.github/workflows/ai-evals-scheduled.yml @@ -15,7 +15,7 @@ on: description: "Space-separated modes (global, cli, flow, app, script)" default: "global cli" models: - description: "Space-separated model aliases (bun run cli -- models); cli skips those it has no agent for"" + description: "Space-separated model aliases (bun run cli -- models); cli skips those it has no agent for" default: "sonnet-5.5 opus-5.5 gpt-6.1-sol gpt-6-astra deepseek-v4-pro" runs: description: "Runs per case" diff --git a/rust-client/src/client.rs b/rust-client/src/client.rs index 451dc58d85..bf47e7e95a 100644 --- a/rust-client/src/client.rs +++ b/rust-client/src/client.rs @@ -785,6 +785,7 @@ impl Windmill { None, None, None, + None, ) .await? } else { @@ -808,6 +809,7 @@ impl Windmill { None, None, None, + None, ) .await? }; From ff028babe88c300f2ca8de7a216f6eae5b6989cc Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:30:37 +0200 Subject: [PATCH 36/37] close raw app preview pop-out when the editor tab reloads or closes (#11607) --- .../lib/components/raw_apps/RawAppEditor.svelte | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index d0d1b40de1..f708b97efe 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -1839,13 +1839,15 @@ addEventListener('message', function (e) { syncExternalPreview() } - onDestroy(() => { - // Don't leave a detached preview behind when the editor unmounts: it - // would stop receiving builds and, once refreshed, has no opener to - // re-feed it — a permanently blank orphan. + // Don't leave a detached preview behind when the editor goes away, by + // unmounting or by its tab reloading or closing (`pagehide`, which skips + // `onDestroy`): it would stop receiving builds, and its document is a blob + // URL owned by this page, so refreshing it gives "not found". + function closeExternalPreview() { if (externalPreviewWindow && !externalPreviewWindow.closed) externalPreviewWindow.close() externalPreviewWindow = null - }) + } + onDestroy(closeExternalPreview) function openExternalPreview() { // Reuse an already-open window instead of spawning duplicates. @@ -2512,7 +2514,7 @@ addEventListener('message', function (e) { } - + { From d5059166099f9e7542ec6f3caf95ccb0f2c605a9 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 8 Oct 2026 09:44:54 +0200 Subject: [PATCH 37/37] chore(main): release 1.828.0 (#11611) * chore(main): release 1.828.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 23 +++ backend/Cargo.lock | 178 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 ++--- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- chat-sdk/package-lock.json | 4 +- chat-sdk/package.json | 2 +- cli/src/core/constants.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- windmill-yaml-validator/package-lock.json | 4 +- windmill-yaml-validator/package.json | 2 +- 22 files changed, 159 insertions(+), 136 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 62ac9abafe..cc72934156 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.827.0" + ".": "1.828.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b157fa61d..c958457c7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## [1.828.0](https://github.com/windmill-labs/windmill/compare/v1.827.0...v1.828.0) (2026-10-08) + + +### Features + +* add gpt-6.1-sol to the chat, agent steps and weekly ai evals ([#11584](https://github.com/windmill-labs/windmill/issues/11584)) ([8f7e0c7](https://github.com/windmill-labs/windmill/commit/8f7e0c72848b56fa97f6f0141d930c7d15044864)) +* add wmill user list and document post-suspend approvers context ([#11600](https://github.com/windmill-labs/windmill/issues/11600)) ([1c2667c](https://github.com/windmill-labs/windmill/commit/1c2667cd5530925893cf69fd622938b6ffa55d1d)) +* ask before minting the frontend sdk token in the raw app editor ([#11604](https://github.com/windmill-labs/windmill/issues/11604)) ([6e948b0](https://github.com/windmill-labs/windmill/commit/6e948b07d13b85e9251c3a200bf149f167649bc4)) +* improve data table roles and grants UX ([#11562](https://github.com/windmill-labs/windmill/issues/11562)) ([d7d18f0](https://github.com/windmill-labs/windmill/commit/d7d18f00af4e2df8fcb1362c9439d2250d0ce4b6)) +* one page header across routes, and one sidebar for operators and developers ([#11310](https://github.com/windmill-labs/windmill/issues/11310)) ([41162b2](https://github.com/windmill-labs/windmill/commit/41162b270c70ddac6a256bde9acf31a629990e24)) + + +### Bug Fixes + +* add retry to runScriptByPathAsync and runScriptByHashAsync ([#11485](https://github.com/windmill-labs/windmill/issues/11485)) ([3f3b1c2](https://github.com/windmill-labs/windmill/commit/3f3b1c2c308ddaba377db45308711e7c660d8775)) +* describe exponential retry as multiplier × seconds^attempt ([#11546](https://github.com/windmill-labs/windmill/issues/11546)) ([a2e51cd](https://github.com/windmill-labs/windmill/commit/a2e51cdbaafb7d71b8e8bbd818e40c472098f9ba)) +* keep capturing job logs after a non-UTF-8 output line ([#11595](https://github.com/windmill-labs/windmill/issues/11595)) ([0e7d974](https://github.com/windmill-labs/windmill/commit/0e7d974ce9b468353ecb6618dace710704690687)) + + +### Performance Improvements + +* load public raw apps without the loading screen ([#11608](https://github.com/windmill-labs/windmill/issues/11608)) ([c09764f](https://github.com/windmill-labs/windmill/commit/c09764fa6516badddbdbb520af32e5d1541cf996)) + ## [1.827.0](https://github.com/windmill-labs/windmill/compare/v1.826.0...v1.827.0) (2026-10-07) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index aeb7670d17..b5f3d9a306 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -6685,9 +6685,9 @@ checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" [[package]] name = "inventory" -version = "0.3.24" +version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +checksum = "6928282826c822ad91bf1c9a1cb90a30ba1c26770749929b4656cd6be829cd7c" dependencies = [ "rustversion", ] @@ -9617,7 +9617,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.15+spec-1.1.0", + "toml_edit 0.25.16+spec-1.1.0", ] [[package]] @@ -13622,9 +13622,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] @@ -13644,21 +13644,21 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.15+spec-1.1.0" +version = "0.25.16+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +checksum = "88f38f3af8bf71841c67a990f4e2b52143b2d0a1a994c72513ff4c14977fc722" dependencies = [ "indexmap 2.14.2", - "toml_datetime 1.1.1+spec-1.1.0", + "toml_datetime 1.1.2+spec-1.1.0", "toml_parser", "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "bed810ee007d8e7f9cf20b4774e7198142fa27186e0f805871da7f3c39eaf333" dependencies = [ "winnow 1.0.4", ] @@ -14829,7 +14829,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-nats", @@ -14918,7 +14918,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.827.0" +version = "1.828.0" dependencies = [ "async-stream", "async-trait", @@ -14952,7 +14952,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14965,7 +14965,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "argon2", @@ -15105,7 +15105,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15128,7 +15128,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15145,7 +15145,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15171,7 +15171,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.827.0" +version = "1.828.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -15181,7 +15181,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15198,7 +15198,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -15220,7 +15220,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15243,7 +15243,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15259,7 +15259,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15281,7 +15281,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15303,7 +15303,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15317,7 +15317,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-nats", @@ -15353,7 +15353,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15379,7 +15379,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15407,7 +15407,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15429,7 +15429,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15449,7 +15449,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15491,7 +15491,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15520,7 +15520,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.827.0" +version = "1.828.0" dependencies = [ "lazy_static", "serde", @@ -15532,7 +15532,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.827.0" +version = "1.828.0" dependencies = [ "argon2", "axum 0.8.9", @@ -15556,7 +15556,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15570,7 +15570,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.827.0" +version = "1.828.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15609,7 +15609,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.827.0" +version = "1.828.0" dependencies = [ "chrono", "lazy_static", @@ -15624,7 +15624,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15643,7 +15643,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.827.0" +version = "1.828.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -15757,7 +15757,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.827.0" +version = "1.828.0" dependencies = [ "chrono", "futures", @@ -15777,7 +15777,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.827.0" +version = "1.828.0" dependencies = [ "regex", "serde", @@ -15794,7 +15794,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -15821,7 +15821,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "futures", @@ -15838,7 +15838,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.827.0" +version = "1.828.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -15854,7 +15854,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -15875,7 +15875,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -15906,7 +15906,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "arc-swap", @@ -15931,7 +15931,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-stream", @@ -15967,7 +15967,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "futures", @@ -15985,7 +15985,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.827.0" +version = "1.828.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -15994,7 +15994,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -16006,7 +16006,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "serde_json", @@ -16018,7 +16018,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "gosyn", @@ -16030,7 +16030,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -16042,7 +16042,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "serde_json", @@ -16054,7 +16054,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "nu-parser", @@ -16065,7 +16065,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16076,7 +16076,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16088,7 +16088,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16099,7 +16099,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-recursion", @@ -16121,7 +16121,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "serde_json", @@ -16133,7 +16133,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -16147,7 +16147,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16164,7 +16164,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -16177,7 +16177,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "serde", @@ -16189,7 +16189,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -16207,7 +16207,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16223,7 +16223,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16239,7 +16239,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -16253,7 +16253,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-recursion", @@ -16292,7 +16292,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "const_format", @@ -16332,7 +16332,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.827.0" +version = "1.828.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -16343,7 +16343,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-recursion", @@ -16378,7 +16378,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16402,7 +16402,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16435,7 +16435,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16462,7 +16462,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16495,7 +16495,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16515,7 +16515,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16549,7 +16549,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16586,7 +16586,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16609,7 +16609,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16633,7 +16633,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-nats", @@ -16657,7 +16657,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16692,7 +16692,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16720,7 +16720,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-trait", @@ -16745,7 +16745,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "bitflags 2.13.2", @@ -16764,7 +16764,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-once-cell", @@ -16883,7 +16883,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.827.0" +version = "1.828.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 06fdc001f0..e0b97275b2 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.827.0" +version = "1.828.0" authors.workspace = true edition.workspace = true @@ -88,7 +88,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.827.0" +version = "1.828.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 99d82bb43b..1bdbdd6097 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6216,7 +6216,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.827.0" +version = "1.828.0" dependencies = [ "aho-corasick", "anyhow", @@ -6302,7 +6302,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.827.0" +version = "1.828.0" dependencies = [ "proc-macro2", "quote", @@ -6314,7 +6314,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.827.0" +version = "1.828.0" dependencies = [ "convert_case", "serde", @@ -6323,7 +6323,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -6335,7 +6335,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "serde_json", @@ -6347,7 +6347,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "gosyn", @@ -6359,7 +6359,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -6371,7 +6371,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "serde_json", @@ -6383,7 +6383,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "nu-parser", @@ -6394,7 +6394,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6405,7 +6405,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6428,7 +6428,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "async-recursion", @@ -6450,7 +6450,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "serde_json", @@ -6462,7 +6462,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -6476,7 +6476,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "convert_case", @@ -6493,7 +6493,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -6506,7 +6506,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "serde", @@ -6518,7 +6518,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -6536,7 +6536,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6552,7 +6552,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6568,7 +6568,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6600,7 +6600,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "lazy_static", @@ -6614,7 +6614,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.827.0" +version = "1.828.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 9ac7f81c7f..b2fd850217 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.827.0" +version = "1.828.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index d8c217cbc8..9173eae28c 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.827.0 + version: 1.828.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 281ba460b6..df2044d419 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.827.0"; +export const VERSION = "v1.828.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/chat-sdk/package-lock.json b/chat-sdk/package-lock.json index 692e5d33f5..c6691e0048 100644 --- a/chat-sdk/package-lock.json +++ b/chat-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-chat", - "version": "1.827.0", + "version": "1.828.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-chat", - "version": "1.827.0", + "version": "1.828.0", "license": "Apache-2.0", "devDependencies": { "@ai-sdk/react": "^4.0.102", diff --git a/chat-sdk/package.json b/chat-sdk/package.json index 287636a9f8..a119b8645d 100644 --- a/chat-sdk/package.json +++ b/chat-sdk/package.json @@ -1,7 +1,7 @@ { "name": "windmill-chat", "description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app", - "version": "1.827.0", + "version": "1.828.0", "author": "Ruben Fiszel", "license": "Apache-2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme", diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index d05c4e60a7..860a3ddede 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork"; // (e.g. utils.ts) can read it without importing main.ts and creating a circular // dependency (main → workspace → utils → main) that triggers a TDZ. // Re-exported from main.ts for backwards compatibility. -export const VERSION = "1.827.0"; +export const VERSION = "1.828.0"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2f5ea5b721..28ecbebf42 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.827.0", + "version": "1.828.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.827.0", + "version": "1.828.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 4515459ba3..cc42f5424a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.827.0", + "version": "1.828.0", "scripts": { "dev": "vite dev", "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", diff --git a/lsp/Pipfile b/lsp/Pipfile index 4509187f28..14c2eb8fac 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.827.0" +wmill = ">=1.828.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 0fcf8a61dd..57f1fc5648 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.827.0 + version: 1.828.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 62cf4298d6..927edd49f6 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.827.0' + ModuleVersion = '1.828.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 4ffcbddd89..13ac4753c5 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.827.0" +version = "1.828.0" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 615e7885eb..45306830f5 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.827.0", + "version": "1.828.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts", "./wacError.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index 59f4b98719..98caf3895a 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.827.0", + "version": "1.828.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme", diff --git a/version.txt b/version.txt index 35d501b3a8..b08184bb19 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.827.0 +1.828.0 diff --git a/windmill-yaml-validator/package-lock.json b/windmill-yaml-validator/package-lock.json index 295ba3e97a..064996f393 100644 --- a/windmill-yaml-validator/package-lock.json +++ b/windmill-yaml-validator/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-yaml-validator", - "version": "1.827.0", + "version": "1.828.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-yaml-validator", - "version": "1.827.0", + "version": "1.828.0", "license": "Apache 2.0", "dependencies": { "@stoplight/yaml": "^4.3.0", diff --git a/windmill-yaml-validator/package.json b/windmill-yaml-validator/package.json index 05ba30630f..7e8c7a641d 100644 --- a/windmill-yaml-validator/package.json +++ b/windmill-yaml-validator/package.json @@ -1,6 +1,6 @@ { "name": "windmill-yaml-validator", - "version": "1.827.0", + "version": "1.828.0", "description": "YAML validator for Windmill flow, schedule, and trigger files", "main": "dist/index.js", "types": "dist/index.d.ts",