diff --git a/frontend/src/lib/components/sidebar/SettingsMenu.svelte b/frontend/src/lib/components/sidebar/SettingsMenu.svelte
new file mode 100644
index 0000000000..421800cb60
--- /dev/null
+++ b/frontend/src/lib/components/sidebar/SettingsMenu.svelte
@@ -0,0 +1,378 @@
+
+
+{#snippet criticalAlertsBadge()}
+ {#if numUnacknowledgedCriticalAlerts > 0}
+
+ {/if}
+{/snippet}
+
+{#snippet helpPing()}
+ {#if hasNewChangelogs}
+
+
+
+
+ {/if}
+{/snippet}
+
+
+
+
+ {#if $devopsRole || $userStore?.is_admin}
+
+ {#snippet buttonReplacement()}
+
+
+ {#if !isCollapsed}
+ Logs
+
+ {#if numUnacknowledgedCriticalAlerts > 0}
+
+ {/if}
+
+
+ {:else if numUnacknowledgedCriticalAlerts > 0}
+
+
+
+ {/if}
+
+ {/snippet}
+
+ {:else}
+
+ {/if}
+
+
+
+
+ {#snippet buttonReplacement()}
+
+
+ {#if !isCollapsed}
+ Settings
+
+ {/if}
+ {#if hasNewChangelogs}
+
+
+
+
+ {/if}
+
+ {/snippet}
+
+
+
{
+ leaveWorkspaceModal = false
+ }}
+ on:confirmed={() => {
+ leaveWorkspaceModal = false
+ void leaveCurrentWorkspace()
+ }}
+>
+
+ Are you sure you want to leave this workspace?
+
+
+
+
+
+
diff --git a/frontend/src/lib/components/sidebar/SidebarContent.svelte b/frontend/src/lib/components/sidebar/SidebarContent.svelte
index 82f48c35b6..abfd23f751 100644
--- a/frontend/src/lib/components/sidebar/SidebarContent.svelte
+++ b/frontend/src/lib/components/sidebar/SidebarContent.svelte
@@ -4,7 +4,6 @@
superadmin,
usedTriggerKinds,
userStore,
- userWorkspaces,
workspaceStore,
isCriticalAlertsUIOpen,
enterpriseLicense,
@@ -12,7 +11,6 @@
tutorialsToDo,
skippedAll
} from '$lib/stores'
- import { findWorkspaceDescendants, workspaceIsFork } from '$lib/utils/workspaceHierarchy'
import { syncTutorialsTodos } from '$lib/tutorialUtils'
import { SIDEBAR_SHOW_SCHEDULES } from '$lib/consts'
import {
@@ -35,12 +33,12 @@
Route,
Settings,
UserCog,
+ Users,
Plus,
Unplug,
AlertCircle,
Database,
Pyramid,
- Trash2,
MailIcon,
ChevronDown,
ChevronRight
@@ -49,16 +47,10 @@
import { slide } from 'svelte/transition'
import UserMenu from './UserMenu.svelte'
import DiscordIcon from '../icons/brands/Discord.svelte'
- import { WorkspaceService } from '$lib/gen'
- import { sendUserToast } from '$lib/toast'
- import { clearStores, switchWorkspace } from '$lib/storeUtils'
- import Toggle from '$lib/components/Toggle.svelte'
- import { goto } from '$lib/navigation'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
import { twMerge } from 'tailwind-merge'
import { onMount } from 'svelte'
import { base } from '$lib/base'
- import { type Changelog, changelogs } from './changelogs'
import { page } from '$app/state'
import SideBarNotification from './SideBarNotification.svelte'
import KafkaIcon from '../icons/KafkaIcon.svelte'
@@ -81,137 +73,11 @@
import MenuButton from './MenuButton.svelte'
import GoogleCloudIcon from '../icons/GoogleCloudIcon.svelte'
import AzureIcon from '../icons/AzureIcon.svelte'
- import {
- deleteSessionsForWorkspace,
- reconcileAfterWorkspaceChange
- } from '$lib/components/sessions/sessionState.svelte'
+ import { leaveCurrentWorkspace } from './leaveWorkspace'
+ import { markChangelogsOpened, readRecentChangelogs } from './changelogs'
- async function leaveWorkspace() {
- await WorkspaceService.leaveWorkspace({ workspace: $workspaceStore ?? '' })
- sendUserToast('You left the workspace')
- clearStores()
- goto('/user/workspaces')
- }
-
- type ForkedDatatable = {
- name: string
- resourceType: string
- resourcePath: string
- dropOnDelete: boolean
- }
- let forkedDatatables: ForkedDatatable[] = $state([])
-
- async function loadForkedDatatables() {
- if (!$workspaceStore) return
- try {
- const settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore })
- const datatables = settings.datatable?.datatables ?? {}
- forkedDatatables = Object.entries(datatables)
- .filter(([_, dt]) => dt.forked_from != null)
- .map(([name, dt]) => ({
- name,
- resourceType: dt.database.resource_type ?? 'instance',
- resourcePath: dt.database.resource_path ?? '',
- dropOnDelete: true
- }))
- } catch {
- forkedDatatables = []
- }
- }
-
- async function deleteFork() {
- const workspace = $workspaceStore ?? ''
- // Capture the parent before delete so we can land the user there
- // instead of dropping them back on the workspace-picker menu.
- // Only valid if the parent is still in the user's workspace list.
- const parentId = $userWorkspaces.find((w) => w.id === workspace)?.parent_workspace_id
- const parentStillAccessible = !!(parentId && $userWorkspaces.find((w) => w.id === parentId))
- const dbsToDrop = forkedDatatables.filter((dt) => dt.dropOnDelete).map((dt) => dt.name)
-
- if (dbsToDrop.length > 0) {
- const errors = await WorkspaceService.dropForkedDatatableDatabases({
- workspace,
- requestBody: { datatable_names: dbsToDrop }
- })
- for (const err of errors) {
- sendUserToast(err, true)
- }
- }
-
- // Fork-scoped ducklake namespaces (metadata schemas + data files) — driven by the
- // backend registry, so no per-lake selection is needed. Best-effort: a failure is
- // surfaced but doesn't block the workspace delete (the registry rows survive a
- // failed cleanup and re-creating the same fork id retries it — the toast tells the
- // user something was left behind).
- try {
- const dlErrors = await WorkspaceService.dropForkedDucklakeNamespaces({ workspace })
- for (const err of dlErrors) {
- sendUserToast(err, true)
- }
- } catch (err) {
- sendUserToast(`Failed to drop fork ducklake namespaces: ${err}`, true)
- }
-
- if (deleteForkedChildren && forkedDescendants.length > 0) {
- for (const child of forkedDescendants) {
- try {
- await WorkspaceService.dropForkedDucklakeNamespaces({ workspace: child.id }).then(
- (errs) => errs.forEach((e) => sendUserToast(e, true)),
- (err) =>
- sendUserToast(`Failed to drop fork ducklake namespaces of ${child.id}: ${err}`, true)
- )
- await WorkspaceService.deleteWorkspace({ workspace: child.id })
- } catch (err) {
- sendUserToast(`Failed to delete forked child ${child.id}: ${err}`, true)
- return
- }
- // Backend delete is authoritative; session cleanup is best-effort so a
- // local failure can't abort the remaining (parent) deletes.
- await deleteSessionsForWorkspace(child.id).catch((e) =>
- console.error(`Session cleanup for ${child.id} failed`, e)
- )
- }
- }
-
- await WorkspaceService.deleteWorkspace({ workspace })
- await deleteSessionsForWorkspace(workspace).catch((e) =>
- console.error('Session cleanup after workspace delete failed', e)
- )
- sendUserToast('You deleted the workspace')
- if (parentStillAccessible && parentId) {
- // Refresh the workspace list AND reconcile session lifecycle before
- // landing on the parent. The refresh keeps the sidebar's
- // `visibleSessions` filter from rendering every committed session as
- // "Fork — no longer available" (it reads `$userWorkspaces`, which
- // `clearStores()` would null). The reconcile re-roots surviving child
- // forks: deleting this fork without "delete children" re-parents them
- // via the backend's ON DELETE SET NULL, so their sessions' stored
- // `workspace_root_id` must be recomputed off the now-deleted ancestor.
- // A reconcile failure must NOT strand the user on the just-deleted
- // workspace — always fall through to the parent switch + navigation.
- try {
- await reconcileAfterWorkspaceChange()
- } catch (e) {
- console.error('Failed to reconcile sessions after workspace delete', e)
- }
- switchWorkspace(parentId)
- await goto('/')
- } else {
- clearStores()
- await goto('/user/workspaces')
- }
- }
-
- let deleteForkedChildren = $state(false)
- const forkedDescendants = $derived(
- $workspaceStore ? findWorkspaceDescendants($workspaceStore, $userWorkspaces ?? []) : []
- )
- // Fork/dev workspaces are detected by their parent link, not the `wm-fork-` id prefix.
- const currentWsIsFork = $derived(workspaceIsFork($workspaceStore, $userWorkspaces ?? []))
-
- let hasNewChangelogs = $state(false)
- let recentChangelogs: Changelog[] = $state([])
- let lastOpened = localStorage.getItem('changelogsLastOpened')
+ const { recent: recentChangelogs, hasNew } = readRecentChangelogs()
+ let hasNewChangelogs = $state(hasNew)
let availableNativeServices = $state<
Array<{ service: NativeServiceName; icon: any; config: any }>
>([])
@@ -242,21 +108,12 @@
)
onMount(async () => {
- if (lastOpened) {
- // @ts-ignore
- recentChangelogs = changelogs.filter((changelog) => changelog.date > lastOpened)
- hasNewChangelogs =
- recentChangelogs.length > 0 && lastOpened !== new Date().toISOString().split('T')[0]
- } else {
- recentChangelogs = changelogs.slice(0, 3)
- }
// Sync tutorial progress on mount
await syncTutorialsTodos()
})
function openChangelogs() {
- const today = new Date().toISOString().split('T')[0]
- localStorage.setItem('changelogsLastOpened', today)
+ markChangelogsOpened()
hasNewChangelogs = false
}
@@ -312,12 +169,25 @@
interface Props {
numUnacknowledgedCriticalAlerts?: number
isCollapsed?: boolean
+ // Render the workspace-content nav (Home/Runs/Variables/… + triggers).
+ showMain?: boolean
+ // Render the bottom account group (User, Settings, Workers, Folders, Logs, Help).
+ // Splitting these lets a host show content nav and account nav in separate rails.
+ showSecondary?: boolean
+ // Main-menu labels to omit here because the host renders them elsewhere
+ // (e.g. the session-mode rail lifts Home/Runs up next to Favorites/Search).
+ excludeMainLabels?: string[]
}
- let { numUnacknowledgedCriticalAlerts = 0, isCollapsed = false }: Props = $props()
+ let {
+ numUnacknowledgedCriticalAlerts = 0,
+ isCollapsed = false,
+ showMain = true,
+ showSecondary = true,
+ excludeMainLabels = []
+ }: Props = $props()
let leaveWorkspaceModal = $state(false)
- let deleteWorkspaceForkModal = $state(false)
function computeAllNotificationsCount(menuItems: any[]) {
let count = 0
@@ -330,63 +200,81 @@
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'
- },
- // Add Tutorials to main menu only if not all completed and not skipped
- ...($tutorialsToDo.length > 0 && !$skippedAll
- ? [
- {
- label: 'Tutorials',
- href: `${base}/tutorials`,
- icon: GraduationCap,
- aiId: 'sidebar-menu-link-tutorials-main',
- aiDescription: 'Button to navigate to tutorials'
- }
- ]
- : [])
- ])
+ 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'
+ },
+ // Add Tutorials to main menu only if not all completed and not skipped
+ ...($tutorialsToDo.length > 0 && !$skippedAll
+ ? [
+ {
+ label: 'Tutorials',
+ href: `${base}/tutorials`,
+ icon: GraduationCap,
+ aiId: 'sidebar-menu-link-tutorials-main',
+ aiDescription: 'Button to navigate to tutorials'
+ }
+ ]
+ : [])
+ ].filter((l) => !excludeMainLabels.includes(l.label))
+ )
let defaultExtraTriggerLinks = $derived([
{
label: 'HTTP',
@@ -570,20 +458,6 @@
faIcon: undefined
}
]
- : []),
- ...(currentWsIsFork
- ? [
- {
- label: 'Delete Forked Workspace',
- action: async () => {
- await loadForkedDatatables()
- deleteForkedChildren = false
- deleteWorkspaceForkModal = true
- },
- icon: Trash2,
- faIcon: undefined
- }
- ]
: [])
],
disabled: $userStore?.operator
@@ -596,33 +470,6 @@
aiId: 'sidebar-menu-link-workers',
aiDescription: 'Button to navigate to workers'
},
- {
- label: 'Folders & Groups',
- icon: FolderOpen,
- aiId: 'sidebar-menu-link-folders-groups',
- aiDescription: 'Button to navigate to folders and groups',
- subItems: [
- {
- label: 'Folders',
- href: `${base}/folders`,
- icon: FolderOpen,
- disabled: $userStore?.operator,
- aiId: 'sidebar-menu-link-folders',
- aiDescription: 'Button to navigate to folders',
- faIcon: undefined
- },
- {
- label: 'Groups',
- href: `${base}/groups`,
- icon: UserCog,
- disabled: $userStore?.operator,
- aiId: 'sidebar-menu-link-groups',
- aiDescription: 'Button to navigate to groups',
- faIcon: undefined
- }
- ],
- disabled: $userStore?.operator
- },
$devopsRole || $userStore?.is_admin
? {
label: 'Logs',
@@ -675,217 +522,224 @@
])
-