Files
windmill/frontend/src/lib/components/sidebar/SidebarContent.svelte
T

758 lines
22 KiB
Svelte

<script lang="ts">
import MenuLink from './MenuLink.svelte'
import {
superadmin,
usedTriggerKinds,
userStore,
userWorkspaces,
workspaceStore,
isCriticalAlertsUIOpen,
enterpriseLicense,
devopsRole
} from '$lib/stores'
import { isForkOwner } from '$lib/utils/workspaceHierarchy'
import { SIDEBAR_SHOW_SCHEDULES } from '$lib/consts'
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
} from 'lucide-svelte'
import { useLocalStorageValue } from '$lib/svelte5Utils.svelte'
import { slide } from 'svelte/transition'
import UserMenu from './UserMenu.svelte'
import DiscordIcon from '../icons/brands/Discord.svelte'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
import { twMerge } from 'tailwind-merge'
import { base } from '$lib/base'
import { page } from '$app/state'
import SideBarNotification from './SideBarNotification.svelte'
import KafkaIcon from '../icons/triggers/KafkaIcon.svelte'
import NatsIcon from '../icons/triggers/NatsIcon.svelte'
import MqttIcon from '../icons/triggers/MqttIcon.svelte'
import AmqpIcon from '../icons/triggers/AmqpIcon.svelte'
import AwsIcon from '../icons/triggers/AwsIcon.svelte'
import {
getAvailableNativeTriggerServices,
getServiceConfig,
getServiceIcon
} from '../triggers/native/utils'
import type { NativeServiceName } from '$lib/gen/types.gen'
import {
Menubar,
Menu,
MenuSingleItem,
MenuItem,
MeltButton
} from '$lib/components/meltComponents'
import MenuButton from './MenuButton.svelte'
import GoogleCloudIcon from '../icons/triggers/GoogleCloudIcon.svelte'
import AzureIcon from '../icons/triggers/AzureIcon.svelte'
import { leaveCurrentWorkspace } from './leaveWorkspace'
import { markChangelogsOpened, readRecentChangelogs } from './changelogs'
const { recent: recentChangelogs, hasNew } = readRecentChangelogs()
let hasNewChangelogs = $state(hasNew)
let availableNativeServices = $state<
Array<{ service: NativeServiceName; icon: any; config: any }>
>([])
async function loadAvailableNativeTriggers() {
try {
const services = await getAvailableNativeTriggerServices($workspaceStore!)
const serviceData = await Promise.all(
services.map(async (service) => ({
service,
icon: await getServiceIcon(service),
config: getServiceConfig(service)
}))
)
availableNativeServices = serviceData
} catch (err) {
console.error('Failed to load available native trigger services:', err)
availableNativeServices = []
}
}
// Native triggers are EE-only; on CE the `/exists` checks just 404. Gate the
// load by license (reactive, since the license loads asynchronously).
$effect(() => {
if ($enterpriseLicense && $workspaceStore) {
loadAvailableNativeTriggers()
}
})
const triggersCollapsed = useLocalStorageValue(
'windmill_triggers_section_collapsed',
false,
'boolean'
)
function openChangelogs() {
markChangelogsOpened()
hasNewChangelogs = false
}
const thirdMenuLinks = [
{
label: 'Help',
icon: HelpCircle,
subItems: [
{
label: 'Docs',
href: 'https://www.windmill.dev/docs/intro/',
icon: BookOpen,
aiId: 'sidebar-menu-link-docs',
aiDescription: 'Button to navigate to docs',
external: true
},
{
label: 'Feedbacks',
href: 'https://discord.gg/V7PM2YHsPB',
icon: DiscordIcon,
aiId: 'sidebar-menu-link-feedbacks',
aiDescription: 'Button to navigate to feedbacks',
external: true
},
{
label: 'Issues',
href: 'https://github.com/windmill-labs/windmill/issues/new',
icon: Github,
aiId: 'sidebar-menu-link-issues',
aiDescription: 'Button to navigate to issues',
external: true
},
{
label: 'Changelog',
href: 'https://www.windmill.dev/changelog/',
icon: Newspaper,
aiId: 'sidebar-menu-link-changelog',
aiDescription: 'Button to navigate to changelog',
external: true
}
]
}
]
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,
showMain = true,
showSecondary = true,
excludeMainLabels = []
}: Props = $props()
let leaveWorkspaceModal = $state(false)
function computeAllNotificationsCount(menuItems: any[]) {
let count = 0
for (const menuItem of menuItems) {
count += menuItem?.['notificationCount'] ?? 0
}
return count
}
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'
}
].filter((l) => !excludeMainLabels.includes(l.label))
)
let defaultExtraTriggerLinks = $derived([
{
label: 'HTTP',
href: '/routes',
icon: Route,
disabled: $userStore?.operator,
kind: 'http',
aiId: 'sidebar-menu-link-http',
aiDescription: 'Button to navigate to HTTP routes'
},
{
label: 'WebSockets',
href: '/websocket_triggers',
icon: Unplug,
disabled: $userStore?.operator,
kind: 'ws',
aiId: 'sidebar-menu-link-ws',
aiDescription: 'Button to navigate to websocket triggers'
},
{
label: 'Postgres',
href: '/postgres_triggers',
icon: Database,
disabled: $userStore?.operator,
kind: 'postgres',
aiId: 'sidebar-menu-link-postgres',
aiDescription: 'Button to navigate to Postgres triggers'
},
{
label: 'Kafka' + ($enterpriseLicense ? '' : ' (EE)'),
href: '/kafka_triggers',
icon: KafkaIcon,
disabled: $userStore?.operator || !$enterpriseLicense,
kind: 'kafka',
aiId: 'sidebar-menu-link-kafka',
aiDescription: 'Button to navigate to Kafka triggers'
},
{
label: 'NATS' + ($enterpriseLicense ? '' : ' (EE)'),
href: '/nats_triggers',
icon: NatsIcon,
disabled: $userStore?.operator || !$enterpriseLicense,
kind: 'nats',
aiId: 'sidebar-menu-link-nats',
aiDescription: 'Button to navigate to NATS triggers'
},
{
label: 'SQS' + ($enterpriseLicense ? '' : ' (EE)'),
href: '/sqs_triggers',
icon: AwsIcon,
disabled: $userStore?.operator || !$enterpriseLicense,
kind: 'sqs',
aiId: 'sidebar-menu-link-sqs',
aiDescription: 'Button to navigate to SQS triggers'
},
{
label: 'GCP Pub/Sub' + ($enterpriseLicense ? '' : ' (EE)'),
href: '/gcp_triggers',
icon: GoogleCloudIcon,
disabled: $userStore?.operator || !$enterpriseLicense,
kind: 'gcp',
aiId: 'sidebar-menu-link-gcp',
aiDescription: 'Button to navigate to GCP Pub/Sub triggers'
},
{
label: 'Azure Event Grid' + ($enterpriseLicense ? '' : ' (EE)'),
href: '/azure_triggers',
icon: AzureIcon,
disabled: $userStore?.operator || !$enterpriseLicense,
kind: 'azure',
aiId: 'sidebar-menu-link-azure',
aiDescription: 'Button to navigate to Azure Event Grid triggers'
},
{
label: 'MQTT',
href: '/mqtt_triggers',
icon: MqttIcon,
disabled: $userStore?.operator,
kind: 'mqtt',
aiId: 'sidebar-menu-link-mqtt',
aiDescription: 'Button to navigate to MQTT triggers'
},
{
label: 'AMQP',
href: '/amqp_triggers',
icon: AmqpIcon,
disabled: $userStore?.operator,
kind: 'amqp',
aiId: 'sidebar-menu-link-amqp',
aiDescription: 'Button to navigate to AMQP triggers'
},
{
label: 'Email',
href: '/email_triggers',
icon: MailIcon,
disabled: $userStore?.operator,
kind: 'email',
aiId: 'sidebar-menu-link-email',
aiDescription: 'Button to navigate to Email triggers'
}
])
let nativeTriggerLinks = $derived(
availableNativeServices.map(({ service, icon, config }) => ({
label: config?.serviceDisplayName || service,
href: `/native_triggers/${service}`,
icon: icon,
disabled: $userStore?.operator,
kind: service,
aiId: `sidebar-menu-link-${service}`,
aiDescription: `Button to navigate to ${config?.serviceDisplayName || service} triggers`
}))
)
let allTriggerLinks = $derived([...defaultExtraTriggerLinks, ...nativeTriggerLinks])
let triggerMenuLinks = $derived([
{
label: 'Schedules',
href: `${base}/schedules`,
icon: Calendar,
disabled: !SIDEBAR_SHOW_SCHEDULES || $userStore?.operator,
aiId: 'sidebar-menu-link-schedules',
aiDescription: 'Button to navigate to schedules'
},
...allTriggerLinks.filter(
(link) => $usedTriggerKinds.includes(link.kind) || page.url.pathname.includes(link.href)
)
])
let extraTriggerLinks = $derived(
allTriggerLinks.filter((link) => {
return !page.url.pathname.includes(link.href) && !$usedTriggerKinds.includes(link.kind)
})
)
// 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)
)
let secondaryMenuLinks = $derived([
// {
// label: 'Workspace',
// href: '/workspace_settings',
// icon: Cog,
// disabled: !$userStore?.is_admin && !$userStore?.is_super_admin
// },
{
label: 'Settings',
icon: Settings,
aiId: 'sidebar-menu-link-settings',
aiDescription:
'Button to navigate to settings, including account, workspace, and instance settings',
subItems: [
{
label: 'Account',
href: '#user-settings',
icon: UserCog,
aiId: 'sidebar-menu-link-account',
aiDescription: 'Button to navigate to account settings',
faIcon: undefined
},
...(canManageWorkspace
? [
{
label: 'Workspace',
href: `${base}/workspace_settings`,
icon: FolderCog,
aiId: 'sidebar-menu-link-workspace',
aiDescription: 'Button to navigate to workspace settings',
faIcon: undefined
}
]
: []),
...($superadmin
? [
{
label: 'Instance',
href: '#superadmin-settings',
icon: ServerCog,
aiId: 'sidebar-menu-link-instance',
aiDescription: 'Button to navigate to instance settings',
faIcon: undefined
}
]
: []),
...(!$superadmin && !$userStore?.is_admin
? [
{
label: 'Leave workspace',
action: () => {
leaveWorkspaceModal = true
},
class: 'text-red-400',
icon: LogOut,
faIcon: undefined
}
]
: [])
],
disabled: $userStore?.operator
},
{
label: 'Workers',
href: `${base}/workers`,
icon: ServerCog,
disabled: $userStore?.operator,
aiId: 'sidebar-menu-link-workers',
aiDescription: 'Button to navigate to workers'
},
$devopsRole || $userStore?.is_admin
? {
label: 'Logs',
icon: Logs,
aiId: 'sidebar-menu-link-logs',
aiDescription: 'Button to navigate to logs',
subItems: [
{
label: 'Audit logs',
href: `${base}/audit_logs`,
icon: Eye,
aiId: 'sidebar-menu-link-audit-logs',
aiDescription: 'Button to navigate to audit logs'
},
...($devopsRole
? [
{
label: 'Service logs',
href: `${base}/service_logs`,
icon: Logs,
aiId: 'sidebar-menu-link-service-logs',
aiDescription: 'Button to navigate to service logs'
}
]
: []),
...($enterpriseLicense
? [
{
label: 'Critical alerts',
action: () => {
isCriticalAlertsUIOpen.set(true)
},
icon: AlertCircle,
notificationCount: numUnacknowledgedCriticalAlerts,
aiId: 'sidebar-menu-link-critical-alerts',
aiDescription: 'Button to navigate to critical alerts'
}
]
: [])
]
}
: {
label: 'Audit logs',
href: `${base}/audit_logs`,
icon: Eye,
disabled: $userStore?.operator,
aiId: 'sidebar-menu-link-audit-logs',
aiDescription: 'Button to navigate to audit logs'
}
])
</script>
<nav class={twMerge('flex flex-col overflow-x-hidden scrollbar-hidden px-2 md:pb-2 gap-2')}>
<div class={twMerge('flex flex-col')}>
{#if showMain}
<div class="space-y-1">
{#each mainMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
<MenuLink class="!text-xs" {...menuLink} {isCollapsed} />
{/each}
</div>
<div class="pt-4">
{#if isCollapsed}
<div class="text-secondary text-[0.5rem] uppercase transition-opacity opacity-0">
Triggers
</div>
{:else}
<button
type="button"
onclick={() => (triggersCollapsed.val = !triggersCollapsed.val)}
class="text-secondary text-[0.5rem] uppercase flex flex-row items-center gap-1 rounded px-1 -mx-1 py-0.5 hover:bg-surface-hover focus:outline-none"
aria-expanded={!triggersCollapsed.val}
>
Triggers
{#if triggersCollapsed.val}
<ChevronRight size={10} />
{:else}
<ChevronDown size={10} />
{/if}
</button>
{/if}
{#if isCollapsed || !triggersCollapsed.val}
<div transition:slide={{ duration: 180 }}>
<Menubar class="flex flex-col gap-1">
{#snippet children({ createMenu })}
{#each triggerMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
<MenuLink class="!text-xs" {...menuLink} {isCollapsed} />
{/each}
{#if extraTriggerLinks.length > 0 && !$userStore?.operator}
<Menu {createMenu} usePointerDownOutside>
{#snippet triggr({ trigger })}
<MeltButton
aiId="sidebar-menu-link-add-trigger"
aiDescription="Button to add a new trigger. Can be HTTP, WebSocket, Postgres, Kafka, NATS, SQS, GCP Pub/Sub, or MQTT"
class={twMerge(
'w-full text-secondary text-2xs flex flex-row gap-1 py-1 items-center px-2 hover:bg-surface-hover rounded',
'data-[highlighted]:bg-surface-hover'
)}
meltElement={trigger}
>
<Plus size={14} />
</MeltButton>
{/snippet}
{#snippet children({ item })}
{#each extraTriggerLinks as subItem (subItem.href ?? subItem.label)}
<MenuItem
aiId={subItem.aiId}
aiDescription={subItem.aiDescription}
href={subItem.disabled ? '' : subItem.href}
class={twMerge(
itemClass,
subItem.disabled ? 'pointer-events-none opacity-50' : ''
)}
{item}
disabled={subItem.disabled}
>
<div class="flex flex-row items-center gap-2">
{#if subItem.icon}
<subItem.icon size={16} />
{/if}
{subItem.label}
</div>
</MenuItem>
{/each}
{/snippet}
</Menu>
{/if}
{/snippet}
</Menubar>
</div>
{/if}
</div>
{/if}
{#if showSecondary}
<div class="flex flex-col gap-2 mt-auto pt-4">
<!-- Single Menubar so melt-ui's hover-to-switch spans the whole bottom
group (Settings/Workers/Folders/Logs AND Help). With Help in its own
Menubar the menus stack instead of switching (WIN-1993). Each group
keeps its own flex container for spacing. -->
<Menubar class="flex flex-col gap-2">
{#snippet children({ createMenu })}
<div class="flex flex-col gap-1">
<UserMenu {isCollapsed} {createMenu} />
{#each secondaryMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
{#if menuLink.subItems}
{@const notificationsCount = computeAllNotificationsCount(menuLink.subItems)}
<Menu {createMenu} usePointerDownOutside>
{#snippet triggr({ trigger })}
<MenuButton
class="!text-2xs"
{...menuLink}
{isCollapsed}
{notificationsCount}
showChevron
{trigger}
/>
{/snippet}
{#snippet children({ item })}
{#each menuLink.subItems as subItem (subItem.href ?? subItem.label)}
<MenuItem
class={itemClass}
href={subItem.href}
{item}
onClick={() => {
subItem?.['action']?.()
}}
aiId={subItem.aiId}
aiDescription={subItem.aiDescription}
>
<div class="flex flex-row items-center gap-2">
{#if subItem.icon}
<subItem.icon size={16} />
{/if}
{subItem.label}
{#if subItem?.['notificationCount']}
<div class="ml-auto">
<SideBarNotification
notificationCount={subItem['notificationCount']}
/>
</div>
{/if}
</div>
</MenuItem>
{/each}
{/snippet}
</Menu>
{:else}
<MenuSingleItem>
{#snippet children({})}
<MenuLink class="!text-2xs" {...menuLink} {isCollapsed} />
{/snippet}
</MenuSingleItem>
{/if}
{/each}
</div>
<div class="flex flex-col gap-1">
{#each thirdMenuLinks as menuLink (menuLink)}
{#if menuLink.subItems}
<Menu {createMenu} usePointerDownOutside>
{#snippet triggr({ trigger })}
<button
class="relative w-full"
onclick={() => {
if (menuLink.label === 'Help') {
openChangelogs()
}
}}
>
<MenuButton
class="!text-2xs"
{...menuLink}
{isCollapsed}
showChevron
{trigger}
/>
{#if menuLink.label === 'Help' && hasNewChangelogs}
<span
class={twMerge(
'flex h-2 w-2 absolute',
isCollapsed ? 'top-1 right-1' : 'right-2 top-1/2 -translate-y-1/2'
)}
>
<span
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-frost-400 opacity-75"
></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-frost-500"
></span>
</span>
{/if}
</button>
{/snippet}
{#snippet children({ item })}
{#each menuLink.subItems as subItem (subItem.href ?? subItem.label)}
<MenuItem
href={subItem.href}
class={itemClass}
target={subItem.external !== false ? '_blank' : undefined}
{item}
>
<div class="flex flex-row items-center gap-2">
{#if subItem.icon}
<subItem.icon size={16} />
{/if}
{subItem.label}
</div>
</MenuItem>
{/each}
{#if recentChangelogs.length > 0}
<div class="w-full h-1 border-t"></div>
<span class="text-xs px-4 font-bold"> Latest changelogs </span>
{#each recentChangelogs as changelog}
<MenuItem href={changelog.href} class={itemClass} target="_blank" {item}>
<div class="flex flex-row items-center gap-2">
{changelog.label}
</div>
</MenuItem>
{/each}
{/if}
{/snippet}
</Menu>
{/if}
{/each}
</div>
{/snippet}
</Menubar>
</div>
{/if}
</div></nav
>
<!-- `alwaysPortal`: the sidebar's mobile slide-in panel keeps a `transform` that would
otherwise confine the dialog to the panel's width. -->
<ConfirmationModal
open={leaveWorkspaceModal}
alwaysPortal
title="Leave workspace"
confirmationText="Leave workspace"
on:canceled={() => {
leaveWorkspaceModal = false
}}
on:confirmed={() => {
void leaveCurrentWorkspace()
}}
>
<div class="flex flex-col w-full space-y-4">
<span>Are you sure you want to leave this workspace?</span>
</div>
</ConfirmationModal>