feat(frontend): Operator mode (#2973)

* feat(frontend): Operator mode

* feat(frontend): Operator mode

* feat(frontend): operator menu v2

* feat(frontend): operator menu v2

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
Faton Ramadani
2024-01-13 13:25:37 +01:00
committed by GitHub
parent b094cfa085
commit f94149f401
10 changed files with 341 additions and 103 deletions
@@ -8,6 +8,7 @@
export let placement: any = 'bottom-start'
export let justifyEnd: boolean = false
export let lightMode: boolean = false
const [floatingRef, floatingContent] = createFloatingActions({
strategy: 'fixed',
@@ -35,7 +36,10 @@
leaveTo="transform opacity-0 scale-95"
>
<MenuItems
class="border w-56 origin-top-right rounded-md bg-surface shadow-md focus:outline-none"
class={twMerge(
'border w-56 origin-top-right rounded-md shadow-md focus:outline-none',
lightMode ? 'bg-surface-inverse' : 'bg-surface'
)}
>
<div class="my-1">
<slot />
@@ -5,6 +5,8 @@
import MenuItem from '$lib/components/common/menu/MenuItem.svelte'
import { classNames } from '$lib/utils'
import ErrorHandlerToggleButton from './ErrorHandlerToggleButton.svelte'
import { twMerge } from 'tailwind-merge'
import { userStore } from '$lib/stores'
type MainButton = {
label: string
@@ -34,7 +36,14 @@
<div class="mx-auto">
<div class="flex w-full flex-wrap md:flex-nowrap justify-end gap-x-2 gap-y-4 items-center">
<div class="grow px-2 inline-flex items-center gap-4 min-w-0">
<div class="text-lg min-w-24 font-bold truncate">{title}</div>{#if tag}
<div
class={twMerge(
'text-lg min-w-24 font-bold truncate',
$userStore?.operator ? 'pl-10' : ''
)}
>
{title}
</div>{#if tag}
<Badge>tag: {tag}</Badge>
{/if}
<slot />
@@ -5,6 +5,7 @@
import Menu from '../common/menu/MenuV2.svelte'
import MenuButton from './MenuButton.svelte'
import { MenuItem } from '@rgossiaux/svelte-headlessui'
export let lightMode: boolean = false
export let isCollapsed: boolean = false
export let favoriteLinks = [] as {
@@ -16,7 +17,7 @@
<Menu>
<div slot="trigger">
<MenuButton class="!text-xs" icon={Star} label={'Favorites'} {isCollapsed} />
<MenuButton class="!text-xs" icon={Star} label={'Favorites'} {isCollapsed} {lightMode} />
</div>
<div class="overflow-hidden" role="none">
@@ -1,21 +1,22 @@
<script lang="ts">
import { classNames } from '$lib/utils'
import { twMerge } from 'tailwind-merge'
import Popover from '../Popover.svelte'
export let label: string
export let label: string | undefined = undefined
export let icon: any | undefined = undefined
export let isCollapsed: boolean
export let disabled: boolean = false
let isSelected = false
export let lightMode: boolean = false
</script>
{#if !disabled}
<Popover appearTimeout={0} disappearTimeout={0} class="w-full" disablePopup={!isCollapsed}>
<button
class={classNames(
class={twMerge(
'group flex items-center px-2 py-2 font-light rounded-md h-8 gap-3 w-full',
isSelected ? 'bg-[#30404e] hover:bg-[#30404e]' : 'hover:bg-[#34363c]',
lightMode
? 'text-primary hover:bg-surface-hover '
: ' hover:bg-[#34363c] text-primary-inverse',
'transition-all',
$$props.class
)}
@@ -25,23 +26,21 @@
<svelte:component
this={icon}
size={16}
class={classNames(
class={twMerge(
'flex-shrink-0',
isSelected
? 'text-blue-100 group-hover:text-white'
: 'text-gray-100 group-hover:text-white',
lightMode
? 'text-primary group-hover:text-secondary'
: 'text-primary-inverse group-hover:text-secondary-inverse',
'transition-all'
)}
/>
{/if}
{#if !isCollapsed}
{#if !isCollapsed && label}
<span
class={classNames(
class={twMerge(
'whitespace-pre truncate',
isSelected
? 'text-blue-100 group-hover:text-white font-semibold'
: 'text-gray-100 group-hover:text-white',
lightMode ? 'text-primary' : 'text-primary-inverse',
'transition-all',
$$props.class
)}
@@ -51,7 +50,9 @@
{/if}
</button>
<svelte:fragment slot="text">
{label}
{#if label}
{label}
{/if}
</svelte:fragment>
</Popover>
{/if}
@@ -8,6 +8,7 @@
export let icon: any | undefined = undefined
export let isCollapsed: boolean
export let disabled: boolean = false
export let lightMode: boolean = false
let isSelected = false
@@ -26,7 +27,14 @@
{href}
class={classNames(
'group flex items-center px-2 py-2 text-sm font-light rounded-md h-8 gap-3',
isSelected ? 'bg-frost-700 hover:bg-[#30404e]' : 'hover:bg-[#34363c]',
isSelected
? lightMode
? 'bg-surface-selected hover:bg-surface-hover rounded-none'
: 'bg-frost-700 hover:bg-[#30404e]'
: lightMode
? 'hover:bg-surface-hover rounded-none'
: 'hover:bg-[#34363c]',
'hover:transition-all',
$$props.class
)}
@@ -40,7 +48,11 @@
class={classNames(
'flex-shrink-0',
isSelected
? 'text-frost-200 group-hover:text-white'
? lightMode
? 'text-primary group-hover:text-secondary'
: 'text-frost-200 group-hover:text-white'
: lightMode
? 'text-primary group-hover:text-secondary'
: 'text-gray-100 group-hover:text-white',
'transition-all'
)}
@@ -52,7 +64,11 @@
class={classNames(
'whitespace-pre truncate',
isSelected
? 'text-blue-100 group-hover:text-white font-semibold'
? lightMode
? 'text-primary group-hover:text-secondary'
: 'text-frost-200 group-hover:text-white'
: lightMode
? 'text-primary group-hover:text-secondary'
: 'text-gray-100 group-hover:text-white',
'transition-all duration-75'
)}
@@ -0,0 +1,119 @@
<script lang="ts">
import { superadmin, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { Home, Menu as MenuIcon, Play, Settings } from 'lucide-svelte'
import Menu from '../common/menu/MenuV2.svelte'
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import { switchWorkspace } from '$lib/storeUtils'
import MultiplayerMenu from './MultiplayerMenu.svelte'
import { enterpriseLicense } from '$lib/stores'
import MenuButton from './MenuButton.svelte'
import { MenuItem } from '@rgossiaux/svelte-headlessui'
import UserMenu from './UserMenu.svelte'
import MenuLink from './MenuLink.svelte'
import FavoriteMenu from './FavoriteMenu.svelte'
export let isCollapsed: boolean = false
export let favoriteLinks = [] as {
label: string
href: string
kind: 'script' | 'flow' | 'app' | 'raw_app'
}[]
const mainMenuLinks = [
{ label: 'Home', href: '/', icon: Home },
{ label: 'Runs', href: '/runs', icon: Play }
]
async function toggleSwitchWorkspace(id: string) {
if ($workspaceStore === id) {
return
}
const editPages = [
'/scripts/edit/',
'/flows/edit/',
'/apps/edit/',
'/scripts/get/',
'/flows/get/',
'/apps/get/'
]
const isOnEditPage = editPages.some((editPage) => $page.route.id?.includes(editPage) ?? false)
if (!isOnEditPage) {
switchWorkspace(id)
} else {
await goto('/')
switchWorkspace(id)
}
}
</script>
<Menu>
<div slot="trigger">
<MenuButton class="!text-xs" icon={MenuIcon} {isCollapsed} lightMode label={undefined} />
</div>
{#each mainMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
<MenuLink class="!text-xs" {...menuLink} {isCollapsed} lightMode />
{/each}
<FavoriteMenu {favoriteLinks} {isCollapsed} lightMode />
<div class="divide-y" role="none">
<UserMenu {isCollapsed} lightMode />
<div class="py-1">
{#each $userWorkspaces as workspace}
<MenuItem>
<button
class="text-xs min-w-0 w-full overflow-hidden flex flex-col py-1.5
{$workspaceStore === workspace.id
? 'cursor-default bg-surface-selected'
: 'cursor-pointer hover:bg-surface-hover'}"
on:click={async () => {
await toggleSwitchWorkspace(workspace.id)
}}
>
<div class="text-primary pl-4 truncate text-left text-[1.2em]">{workspace.name}</div>
<div class="text-tertiary font-mono pl-4 text-2xs whitespace-nowrap truncate text-left">
{workspace.id}
</div>
</button>
</MenuItem>
{/each}
</div>
<div class="py-1" role="none">
<a
href="/user/workspaces"
on:click={() => {
localStorage.removeItem('workspace')
}}
class="text-primary block px-4 py-2 text-xs hover:bg-surface-hover hover:text-primary"
role="menuitem"
tabindex="-1"
>
All workspaces & invites
</a>
</div>
{#if $userStore?.is_admin || $superadmin}
<div class="py-1" role="none">
<MenuItem>
<a
href="/workspace_settings"
class="text-secondary px-4 py-2 text-xs hover:bg-surface-hover hover:text-primary flex flex-flow gap-2"
role="menuitem"
tabindex="-1"
>
<Settings size={16} />
Workspace Settings
</a>
</MenuItem>
</div>
{/if}
</div>
{#if $enterpriseLicense}
<MultiplayerMenu />
{/if}
</Menu>
@@ -30,6 +30,7 @@
import { clearStores } from '$lib/storeUtils'
import { goto } from '$app/navigation'
import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte'
import { twMerge } from 'tailwind-merge'
$: mainMenuLinks = [
{ label: 'Home', href: '/', icon: Home },
@@ -150,20 +151,24 @@
]
export let isCollapsed: boolean = false
export let noGap: boolean = false
let leaveWorkspaceModal = false
</script>
<nav
class="grow flex flex-col overflow-x-hidden scrollbar-hidden px-2 md:pb-2 gap-16 justify-between"
class={twMerge(
'grow flex flex-col overflow-x-hidden scrollbar-hidden px-2 md:pb-2 justify-between',
noGap ? 'gap-0' : 'gap-16'
)}
>
<div class="space-y-1 pt-4 mb-6 md:mb-10">
<div class={twMerge('space-y-1 pt-4 ', noGap ? 'md:mb-0 mb-0' : 'mb-6 md:mb-10')}>
{#each mainMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
<MenuLink class="!text-xs" {...menuLink} {isCollapsed} />
{/each}
</div>
<div class="flex flex-col h-full justify-end">
<div class="space-y-0.5 mb-6 md:mb-10">
<div class={twMerge('space-y-0.5 mb-6 md:mb-10', noGap ? 'md:mb-0 mb-0' : 'mb-6 md:mb-10')}>
<UserMenu {isCollapsed} />
{#each secondaryMenuLinks as menuLink (menuLink.href ?? menuLink.label)}
{#if menuLink.subItems}
@@ -176,7 +181,7 @@
<div class="py-1" role="none">
{#if subItem?.['action']}
<button
class="{subItem['class']} px-4 py-2 !text-2xs"
class="text-secondary block px-4 py-2 text-xs hover:bg-surface-hover hover:text-primary"
on:click={subItem?.['action']}
>
<div class="flex flex-row items-center gap-2">
@@ -190,7 +195,9 @@
{:else}
<a
href={subItem.href}
class="text-secondary block px-4 py-2 text-2xs hover:bg-surface-hover hover:text-primary"
class={twMerge(
'text-secondary block px-4 py-2 text-2xs hover:bg-surface-hover hover:text-primary'
)}
role="menuitem"
tabindex="-1"
>
@@ -13,6 +13,7 @@
let darkMode: boolean = false
export let isCollapsed: boolean = false
export let lightMode: boolean = false
</script>
<Menu>
@@ -22,6 +23,7 @@
icon={User}
label={`User (${$userStore?.username ?? $userStore?.email})`}
{isCollapsed}
{lightMode}
/>
</div>
<div class="divide-y">
@@ -11,7 +11,6 @@
WorkspaceService
} from '$lib/gen'
import { classNames } from '$lib/utils'
import WorkspaceMenu from '$lib/components/sidebar/WorkspaceMenu.svelte'
import SidebarContent from '$lib/components/sidebar/SidebarContent.svelte'
import {
@@ -37,6 +36,8 @@
import { ArrowLeft } from 'lucide-svelte'
import { getUserExt } from '$lib/user'
import { workspacedOpenai } from '$lib/components/copilot/lib'
import { twMerge } from 'tailwind-merge'
import OperatorMenu from '$lib/components/sidebar/OperatorMenu.svelte'
OpenAPI.WITH_CREDENTIALS = true
let menuOpen = false
@@ -201,6 +202,10 @@
}, 5000)
}
}
$: if (isCollapsed && $userStore?.operator) {
isCollapsed = false
}
</script>
<svelte:window bind:innerWidth />
@@ -219,39 +224,158 @@
<SuperadminSettings bind:this={superadminSettings} />
{/if}
<div>
<div
class={classNames(
'relative md:hidden',
menuOpen ? 'z-40' : 'pointer-events-none',
devOnly ? 'hidden' : ''
)}
role="dialog"
aria-modal="true"
>
{#if !$userStore?.operator}
<div
class={classNames(
'fixed inset-0 dark:bg-[#1e232e] bg-[#202125] dark:bg-opacity-75 bg-opacity-75 transition-opacity ease-linear duration-300 z-40 !dark',
menuOpen ? 'opacity-100' : 'opacity-0'
'relative md:hidden',
menuOpen ? 'z-40' : 'pointer-events-none',
devOnly ? 'hidden' : ''
)}
/>
<div class="fixed inset-0 flex z-40">
role="dialog"
aria-modal="true"
>
<div
class={classNames(
'relative flex-1 flex flex-col max-w-min w-full bg-surface transition ease-in-out duration-300 transform',
menuOpen ? 'translate-x-0' : '-translate-x-full'
'fixed inset-0 dark:bg-[#1e232e] bg-[#202125] dark:bg-opacity-75 bg-opacity-75 transition-opacity ease-linear duration-300 z-40 !dark',
menuOpen ? 'opacity-100' : 'opacity-0'
)}
/>
<div class="fixed inset-0 flex z-40">
<div
class={classNames(
'relative flex-1 flex flex-col max-w-min w-full bg-surface transition ease-in-out duration-300 transform',
menuOpen ? 'translate-x-0' : '-translate-x-full'
)}
>
<div
class={classNames(
'absolute top-0 right-0 -mr-12 pt-2 ease-in-out duration-300',
menuOpen ? 'opacity-100' : 'opacity-0'
)}
>
<button
type="button"
on:click={() => {
menuOpen = !menuOpen
}}
class="ml-1 flex items-center justify-center h-8 w-8 rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white border border-white"
>
<svg
class="h-6 w-6 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="dark:bg-[#1e232e] bg-[#202125] h-full !dark">
<div
class="flex gap-x-2 flex-shrink-0 p-4 font-semibold text-gray-200 w-10"
class:w-40={!isCollapsed}
>
<WindmillIcon white={true} height="20px" width="20px" />
{#if !isCollapsed}Windmill{/if}
</div>
<div class="px-2 py-4 space-y-2 border-y border-gray-500">
<WorkspaceMenu />
<FavoriteMenu {favoriteLinks} />
</div>
<SidebarContent {isCollapsed} />
</div>
</div>
</div>
</div>
<div
id="sidebar"
class={classNames(
'hidden md:flex md:flex-col md:fixed md:inset-y-0 transition-all ease-in-out duration-200 shadow-md z-40 ',
isCollapsed ? 'md:w-12' : 'md:w-40',
devOnly ? '!hidden' : ''
)}
>
<div
class="flex-1 flex flex-col min-h-0 h-screen shadow-lg dark:bg-[#1e232e] bg-[#202125] !dark"
>
<button
on:click={() => {
goto('/')
}}
>
<div
class="flex-row flex-shrink-0 px-3.5 py-3.5 text-opacity-70 h-12 flex items-center gap-1.5"
class:w-40={!isCollapsed}
>
<div class:mr-1={!isCollapsed}>
<WindmillIcon white={true} height="20px" width="20px" />
</div>
{#if !isCollapsed}
<div class="text-sm mt-0.5 text-white"> Windmill </div>
{/if}
</div>
</button>
<div class="px-2 py-4 space-y-2 border-y border-gray-700">
<WorkspaceMenu {isCollapsed} />
<FavoriteMenu {favoriteLinks} {isCollapsed} />
</div>
<SidebarContent {isCollapsed} />
<div class="flex-shrink-0 flex px-4 pb-3.5">
<button
on:click={() => {
isCollapsed = !isCollapsed
}}
>
<ArrowLeft
size={16}
class={classNames(
'flex-shrink-0 h-4 w-4 transition-all ease-in-out duration-200 text-white',
isCollapsed ? 'rotate-180' : 'rotate-0'
)}
/>
</button>
</div>
</div>
</div>
{:else}
<div class="absolute top-2 left-2 z5000">
<OperatorMenu />
</div>
{/if}
<div
class={classNames(
'fixed inset-0 dark:bg-[#1e232e] bg-[#202125] dark:bg-opacity-75 bg-opacity-75 transition-opacity ease-linear duration-300 !dark',
'opacity-0'
)}
>
<div class={twMerge('fixed inset-0 flex ', '-z-0')}>
<div
class={classNames(
'relative flex-1 flex flex-col max-w-min w-full bg-surface transition ease-in-out duration-100 transform',
'-translate-x-full'
)}
>
<div
class={classNames(
'absolute top-0 right-0 -mr-12 pt-2 ease-in-out duration-300',
menuOpen ? 'opacity-100' : 'opacity-0'
'absolute top-0 right-0 -mr-12 pt-2 ease-in-out duration-100',
'opacity-0'
)}
>
<button
type="button"
on:click={() => {
menuOpen = !menuOpen
// menuSlide = !menuSlide
}}
class="ml-1 flex items-center justify-center h-8 w-8 rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white border border-white"
>
@@ -287,64 +411,11 @@
</div>
</div>
</div>
<div
id="sidebar"
class={classNames(
'hidden md:flex md:flex-col md:fixed md:inset-y-0 transition-all ease-in-out duration-200 shadow-md z-40 ',
isCollapsed ? 'md:w-12' : 'md:w-40',
devOnly ? '!hidden' : ''
)}
>
<div
class="flex-1 flex flex-col min-h-0 h-screen shadow-lg dark:bg-[#1e232e] bg-[#202125] !dark"
>
<button
on:click={() => {
goto('/')
}}
>
<div
class="flex-row flex-shrink-0 px-3.5 py-3.5 text-opacity-70 h-12 flex items-center gap-1.5"
class:w-40={!isCollapsed}
>
<div class:mr-1={!isCollapsed}>
<WindmillIcon white={true} height="20px" width="20px" />
</div>
{#if !isCollapsed}
<div class="text-sm mt-0.5 text-white"> Windmill </div>
{/if}
</div>
</button>
<div class="px-2 py-4 space-y-2 border-y border-gray-700">
<WorkspaceMenu {isCollapsed} />
<FavoriteMenu {favoriteLinks} {isCollapsed} />
</div>
<SidebarContent {isCollapsed} />
<div class="flex-shrink-0 flex px-4 pb-3.5">
<button
on:click={() => {
isCollapsed = !isCollapsed
}}
>
<ArrowLeft
size={16}
class={classNames(
'flex-shrink-0 h-4 w-4 transition-all ease-in-out duration-200 text-white',
isCollapsed ? 'rotate-180' : 'rotate-0'
)}
/>
</button>
</div>
</div>
</div>
<div
id="content"
class={classNames(
'w-full flex flex-col flex-1 h-full',
devOnly ? '!pl-0' : isCollapsed ? 'md:pl-12' : 'md:pl-40',
devOnly || $userStore?.operator ? '!pl-0' : isCollapsed ? 'md:pl-12' : 'md:pl-40',
'transition-all ease-in-out duration-200'
)}
>
@@ -353,7 +424,7 @@
<div
class={classNames(
'py-2 px-2 sm:px-4 md:px-8 flex justify-between items-center shadow-sm max-w-7xl mx-auto md:hidden',
devOnly ? 'hidden' : ''
devOnly || $userStore?.operator ? 'hidden' : ''
)}
>
<button
@@ -31,6 +31,7 @@
import { goto } from '$app/navigation'
import RunsQueue from '$lib/components/runs/RunsQueue.svelte'
import { forLater } from '$lib/forLater'
import { twMerge } from 'tailwind-merge'
let jobs: Job[] | undefined
let intervalId: NodeJS.Timeout | undefined
@@ -436,7 +437,14 @@
<div class="px-2">
<div class="flex items-center space-x-2 flex-row justify-between">
<div class="flex flex-row flex-wrap justify-between py-2 my-4 px-4 gap-1 items-center">
<h1 class="!text-2xl font-semibold leading-6 tracking-tight"> Runs </h1>
<h1
class={twMerge(
'!text-2xl font-semibold leading-6 tracking-tight',
$userStore?.operator ? 'pl-10' : ''
)}
>
Runs
</h1>
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/monitor_past_and_future_runs"