mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-30 00:16:53 +00:00
feat: add operator mode
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
import { goto } from '$app/navigation'
|
||||
import { page } from '$app/stores'
|
||||
import { FlowService, ScheduleService, type Flow } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
encodeState,
|
||||
formatCron,
|
||||
@@ -186,80 +186,84 @@
|
||||
let flowViewer: Drawer
|
||||
</script>
|
||||
|
||||
<UnsavedConfirmationModal />
|
||||
{#if !$userStore?.operator}
|
||||
<UnsavedConfirmationModal />
|
||||
|
||||
<Drawer bind:this={flowViewer} size="75%">
|
||||
<DrawerContent title="View Graph" on:close={flowViewer.closeDrawer} noPadding>
|
||||
<div class="overflow-hidden h-full w-full">
|
||||
<FlowGraphViewer flow={$flowStore} />
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
<Drawer bind:this={flowViewer} size="75%">
|
||||
<DrawerContent title="View Graph" on:close={flowViewer.closeDrawer} noPadding>
|
||||
<div class="overflow-hidden h-full w-full">
|
||||
<FlowGraphViewer flow={$flowStore} />
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<div class="flex flex-col flex-1 h-screen">
|
||||
<!-- Nav between steps-->
|
||||
<div
|
||||
class="justify-between flex flex-row w-full items-center pl-2.5 pr-6 space-x-4 overflow-x-auto scrollbar-hidden max-h-12 h-full"
|
||||
>
|
||||
<div class="flex flex-row">
|
||||
<FlowImportExportMenu />
|
||||
<Button
|
||||
btnClasses="inline-flex"
|
||||
startIcon={{ icon: faEye }}
|
||||
variant="border"
|
||||
color="light"
|
||||
size="sm"
|
||||
on:click={flowViewer.openDrawer}
|
||||
>
|
||||
View Graph
|
||||
</Button>
|
||||
</div>
|
||||
<div class="gap-1 flex-row hidden md:flex shrink overflow-hidden">
|
||||
<Button
|
||||
btnClasses="hidden lg:inline-flex"
|
||||
startIcon={{ icon: faPen }}
|
||||
variant="contained"
|
||||
color="light"
|
||||
size="xs"
|
||||
on:click={async () => {
|
||||
select('settings')
|
||||
document.getElementById('path')?.focus()
|
||||
}}
|
||||
>
|
||||
{$flowStore.path && $flowStore.path != '' ? $flowStore.path : 'Choose a path'}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={{ icon: faPen }}
|
||||
variant="contained"
|
||||
color="light"
|
||||
size="xs"
|
||||
on:click={async () => {
|
||||
select('settings')
|
||||
document.getElementById('flow-summary')?.focus()
|
||||
}}
|
||||
>
|
||||
<div class="max-w-[10em] !truncate">
|
||||
{$flowStore.summary == '' || !$flowStore.summary ? 'No summary' : $flowStore.summary}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-row space-x-2">
|
||||
<FlowPreviewButtons />
|
||||
<div class="center-center">
|
||||
<div class="flex flex-col flex-1 h-screen">
|
||||
<!-- Nav between steps-->
|
||||
<div
|
||||
class="justify-between flex flex-row w-full items-center pl-2.5 pr-6 space-x-4 overflow-x-auto scrollbar-hidden max-h-12 h-full"
|
||||
>
|
||||
<div class="flex flex-row">
|
||||
<FlowImportExportMenu />
|
||||
<Button
|
||||
disabled={pathError != ''}
|
||||
startIcon={{ icon: faSave }}
|
||||
btnClasses="inline-flex"
|
||||
startIcon={{ icon: faEye }}
|
||||
variant="border"
|
||||
color="light"
|
||||
size="sm"
|
||||
on:click={saveFlow}>Save</Button
|
||||
on:click={flowViewer.openDrawer}
|
||||
>
|
||||
View Graph
|
||||
</Button>
|
||||
</div>
|
||||
<div class="gap-1 flex-row hidden md:flex shrink overflow-hidden">
|
||||
<Button
|
||||
btnClasses="hidden lg:inline-flex"
|
||||
startIcon={{ icon: faPen }}
|
||||
variant="contained"
|
||||
color="light"
|
||||
size="xs"
|
||||
on:click={async () => {
|
||||
select('settings')
|
||||
document.getElementById('path')?.focus()
|
||||
}}
|
||||
>
|
||||
{$flowStore.path && $flowStore.path != '' ? $flowStore.path : 'Choose a path'}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={{ icon: faPen }}
|
||||
variant="contained"
|
||||
color="light"
|
||||
size="xs"
|
||||
on:click={async () => {
|
||||
select('settings')
|
||||
document.getElementById('flow-summary')?.focus()
|
||||
}}
|
||||
>
|
||||
<div class="max-w-[10em] !truncate">
|
||||
{$flowStore.summary == '' || !$flowStore.summary ? 'No summary' : $flowStore.summary}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-row space-x-2">
|
||||
<FlowPreviewButtons />
|
||||
<div class="center-center">
|
||||
<Button
|
||||
disabled={pathError != ''}
|
||||
startIcon={{ icon: faSave }}
|
||||
size="sm"
|
||||
on:click={saveFlow}>Save</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- metadata -->
|
||||
{#if $flowStateStore}
|
||||
<FlowEditor {initialPath} {loading} />
|
||||
{:else}
|
||||
<CenteredPage>Loading...</CenteredPage>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- metadata -->
|
||||
{#if $flowStateStore}
|
||||
<FlowEditor {initialPath} {loading} />
|
||||
{:else}
|
||||
<CenteredPage>Loading...</CenteredPage>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
Flow Builder not available to operators
|
||||
{/if}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
company
|
||||
}
|
||||
})
|
||||
sendUserToast(`Successfully added ${email}. Welcome to them!`)
|
||||
sendUserToast(`Added ${email}`)
|
||||
dispatch('new')
|
||||
}
|
||||
</script>
|
||||
@@ -39,7 +39,7 @@
|
||||
<div class="flex flex-row space-x-1">
|
||||
<input type="email" on:keyup={handleKeyUp} placeholder="email" bind:value={email} />
|
||||
|
||||
<Toggle class="mx-2" bind:checked={is_super_admin} options={{ right: 'admin' }} />
|
||||
<Toggle class="mx-2" bind:checked={is_super_admin} options={{ right: 'superadmin' }} />
|
||||
<input on:keyup={handleKeyUp} type="password" placeholder="password" bind:value={password} />
|
||||
<input type="text" on:keyup={handleKeyUp} placeholder="name" bind:value={name} />
|
||||
<input type="text" on:keyup={handleKeyUp} placeholder="company" bind:value={company} />
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { WorkspaceService } from '$lib/gen'
|
||||
import { Button } from './common'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { Button, ToggleButton, ToggleButtonGroup } from './common'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
@@ -24,18 +24,33 @@
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
email,
|
||||
is_admin
|
||||
is_admin: selected == 'admin',
|
||||
operator: selected == 'operator'
|
||||
}
|
||||
})
|
||||
sendUserToast(`Successfully invited ${email}. Welcome to them!`)
|
||||
sendUserToast(`Invited ${email}`)
|
||||
dispatch('new')
|
||||
}
|
||||
|
||||
let selected: 'operator' | 'author' | 'admin' = 'author'
|
||||
</script>
|
||||
|
||||
<div class="flex flex-row">
|
||||
<input type="email" on:keyup={handleKeyUp} placeholder="email" bind:value={email} class="mr-4" />
|
||||
|
||||
<Toggle bind:checked={is_admin} options={{ right: 'admin' }} />
|
||||
<ToggleButtonGroup bind:selected>
|
||||
<ToggleButton position="left" value="operator" size="sm"
|
||||
>Operator <Tooltip
|
||||
>An operator can only execute and view scripts/flows/apps from your workspace, and only
|
||||
those that he has visibility on</Tooltip
|
||||
></ToggleButton
|
||||
>
|
||||
<ToggleButton position="center" value="author" size="sm"
|
||||
>Author <Tooltip
|
||||
>An Author can execute and view scripts/flows/apps, but he can also create new ones</Tooltip
|
||||
></ToggleButton
|
||||
>
|
||||
<ToggleButton position="right" value="admin" size="sm">Admin</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="blue"
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
path: resourceToEdit.path,
|
||||
requestBody: { path, value: args, description }
|
||||
})
|
||||
sendUserToast(`Successfully updated resource at ${path}`)
|
||||
sendUserToast(`Updated resource at ${path}`)
|
||||
dispatch('refresh')
|
||||
drawer.closeDrawer?.()
|
||||
} else {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { page } from '$app/stores'
|
||||
import { inferArgs } from '$lib/infer'
|
||||
import { initialCode, isInitialCode } from '$lib/script_helpers'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { encodeState, sendUserToast, setQueryWithoutLoad } from '$lib/utils'
|
||||
import Path from './Path.svelte'
|
||||
import RadioButton from './RadioButton.svelte'
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
export let script: Script
|
||||
export let initialPath: string = ''
|
||||
export let template: 'pgsql'| 'mysql' | 'script' = 'script'
|
||||
export let template: 'pgsql' | 'mysql' | 'script' = 'script'
|
||||
export let initialArgs: Record<string, any> = {}
|
||||
|
||||
const langs: [string, SupportedLanguage][] = [
|
||||
@@ -91,228 +91,232 @@
|
||||
$: kind = script.kind as 'script' | 'trigger' | 'approval' | undefined
|
||||
</script>
|
||||
|
||||
<UnsavedConfirmationModal />
|
||||
<div class="flex flex-col h-screen">
|
||||
<!-- Nav between steps-->
|
||||
<div class="flex flex-col w-full px-2 py-1 border-b shadow-sm">
|
||||
<div
|
||||
class="justify-between flex flex-row w-full items-center overflow-x-auto scrollbar-hidden px-2"
|
||||
>
|
||||
<div class="flex flex-row py-1">
|
||||
<Breadcrumb
|
||||
items={['Metadata', 'Code', 'Advanced']}
|
||||
selectedIndex={step}
|
||||
on:select={(e) => changeStep(e.detail.index + 1)}
|
||||
disabled={pathError != ''}
|
||||
>
|
||||
<svelte:fragment slot="separator">/</svelte:fragment>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
{#if !$userStore?.operator}
|
||||
<UnsavedConfirmationModal />
|
||||
<div class="flex flex-col h-screen">
|
||||
<!-- Nav between steps-->
|
||||
<div class="flex flex-col w-full px-2 py-1 border-b shadow-sm">
|
||||
<div
|
||||
class="justify-between flex flex-row w-full items-center overflow-x-auto scrollbar-hidden px-2"
|
||||
>
|
||||
<div class="flex flex-row py-1">
|
||||
<Breadcrumb
|
||||
items={['Metadata', 'Code', 'Advanced']}
|
||||
selectedIndex={step}
|
||||
on:select={(e) => changeStep(e.detail.index + 1)}
|
||||
disabled={pathError != ''}
|
||||
>
|
||||
<svelte:fragment slot="separator">/</svelte:fragment>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
|
||||
<div class="gap-1 flex-row hidden md:flex shrink overflow-hidden">
|
||||
<Button
|
||||
btnClasses="hidden lg:inline-flex"
|
||||
startIcon={{ icon: faPen }}
|
||||
variant="contained"
|
||||
color="light"
|
||||
size="xs"
|
||||
on:click={async () => {
|
||||
await changeStep(1)
|
||||
setTimeout(() => pathC?.focus(), 100)
|
||||
}}
|
||||
>
|
||||
{script.path}
|
||||
</Button>
|
||||
<div class="gap-1 flex-row hidden md:flex shrink overflow-hidden">
|
||||
<Button
|
||||
btnClasses="hidden lg:inline-flex"
|
||||
startIcon={{ icon: faPen }}
|
||||
variant="contained"
|
||||
color="light"
|
||||
size="xs"
|
||||
on:click={async () => {
|
||||
await changeStep(1)
|
||||
setTimeout(() => pathC?.focus(), 100)
|
||||
}}
|
||||
>
|
||||
{script.path}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
startIcon={{ icon: faPen }}
|
||||
variant="contained"
|
||||
color="light"
|
||||
size="xs"
|
||||
on:click={async () => {
|
||||
await changeStep(1)
|
||||
setTimeout(() => summaryC?.focus(), 100)
|
||||
}}
|
||||
>
|
||||
<div class="max-w-[10em] !truncate">
|
||||
{script.summary == '' || !script.summary ? 'No summary' : script.summary}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-row gap-x-2">
|
||||
<Button
|
||||
variant="border"
|
||||
size="sm"
|
||||
btnClasses={step == 1 ? 'hidden sm:invisible' : ''}
|
||||
on:click={() => changeStep(step - 1)}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={step == 1 ? 'contained' : 'border'}
|
||||
btnClasses={step == 3 ? 'invisible' : ''}
|
||||
disabled={step === 1 && pathError !== ''}
|
||||
on:click={() => changeStep(step + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={step == 1 ? 'border' : 'contained'}
|
||||
disabled={step === 1 && pathError !== ''}
|
||||
btnClasses={step == 1 && initialPath == '' ? 'invisible' : ''}
|
||||
startIcon={{ icon: faSave }}
|
||||
on:click={editScript}>Save</Button
|
||||
>
|
||||
<Button
|
||||
startIcon={{ icon: faPen }}
|
||||
variant="contained"
|
||||
color="light"
|
||||
size="xs"
|
||||
on:click={async () => {
|
||||
await changeStep(1)
|
||||
setTimeout(() => summaryC?.focus(), 100)
|
||||
}}
|
||||
>
|
||||
<div class="max-w-[10em] !truncate">
|
||||
{script.summary == '' || !script.summary ? 'No summary' : script.summary}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-row gap-x-2">
|
||||
<Button
|
||||
variant="border"
|
||||
size="sm"
|
||||
btnClasses={step == 1 ? 'hidden sm:invisible' : ''}
|
||||
on:click={() => changeStep(step - 1)}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={step == 1 ? 'contained' : 'border'}
|
||||
btnClasses={step == 3 ? 'invisible' : ''}
|
||||
disabled={step === 1 && pathError !== ''}
|
||||
on:click={() => changeStep(step + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={step == 1 ? 'border' : 'contained'}
|
||||
disabled={step === 1 && pathError !== ''}
|
||||
btnClasses={step == 1 && initialPath == '' ? 'invisible' : ''}
|
||||
startIcon={{ icon: faSave }}
|
||||
on:click={editScript}>Save</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- metadata -->
|
||||
{#if step === 1}
|
||||
<CenteredPage>
|
||||
<div class="space-y-6">
|
||||
<h2 class="border-b pb-1 mt-4">Path & Summary</h2>
|
||||
<Path
|
||||
bind:this={pathC}
|
||||
bind:error={pathError}
|
||||
bind:path={script.path}
|
||||
{initialPath}
|
||||
on:enter={() => changeStep(2)}
|
||||
namePlaceholder="my_script"
|
||||
kind="script"
|
||||
/>
|
||||
<label class="block ">
|
||||
<span class="text-gray-700 text-sm">Summary <Required required={false} /></span>
|
||||
<input
|
||||
type="text"
|
||||
bind:this={summaryC}
|
||||
bind:value={script.summary}
|
||||
placeholder="A very short summary of the script displayed when the script is listed"
|
||||
rows="1"
|
||||
<!-- metadata -->
|
||||
{#if step === 1}
|
||||
<CenteredPage>
|
||||
<div class="space-y-6">
|
||||
<h2 class="border-b pb-1 mt-4">Path & Summary</h2>
|
||||
<Path
|
||||
bind:this={pathC}
|
||||
bind:error={pathError}
|
||||
bind:path={script.path}
|
||||
{initialPath}
|
||||
on:enter={() => changeStep(2)}
|
||||
namePlaceholder="my_script"
|
||||
kind="script"
|
||||
/>
|
||||
</label>
|
||||
<h2 class="border-b pb-1 mt-4">Language</h2>
|
||||
<div class="flex flex-row gap-2 flex-wrap">
|
||||
{#each langs as [label, lang]}
|
||||
{@const isPicked = script.language == lang && template == 'script'}
|
||||
<label class="block ">
|
||||
<span class="text-gray-700 text-sm">Summary <Required required={false} /></span>
|
||||
<input
|
||||
type="text"
|
||||
bind:this={summaryC}
|
||||
bind:value={script.summary}
|
||||
placeholder="A very short summary of the script displayed when the script is listed"
|
||||
rows="1"
|
||||
/>
|
||||
</label>
|
||||
<h2 class="border-b pb-1 mt-4">Language</h2>
|
||||
<div class="flex flex-row gap-2 flex-wrap">
|
||||
{#each langs as [label, lang]}
|
||||
{@const isPicked = script.language == lang && template == 'script'}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="border"
|
||||
color={isPicked ? 'blue' : 'dark'}
|
||||
btnClasses={isPicked ? '!border-2 !bg-blue-50/75' : 'm-[1px]'}
|
||||
on:click={() => {
|
||||
script.language = lang
|
||||
template = 'script'
|
||||
initContent(lang, script.kind, template)
|
||||
}}
|
||||
>
|
||||
<LanguageIcon {lang} /><span class="ml-2">{label}</span>
|
||||
</Button>
|
||||
{/each}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="border"
|
||||
color={isPicked ? 'blue' : 'dark'}
|
||||
btnClasses={isPicked ? '!border-2 !bg-blue-50/75' : 'm-[1px]'}
|
||||
color={template == 'pgsql' ? 'blue' : 'dark'}
|
||||
btnClasses={template == 'pgsql' ? '!border-2 !bg-blue-50/75' : 'm-[1px]'}
|
||||
on:click={() => {
|
||||
script.language = lang
|
||||
template = 'script'
|
||||
initContent(lang, script.kind, template)
|
||||
script.language = Script.language.DENO
|
||||
template = 'pgsql'
|
||||
initContent(script.language, script.kind, template)
|
||||
}}
|
||||
>
|
||||
<LanguageIcon {lang} /><span class="ml-2">{label}</span>
|
||||
<LanguageIcon lang="pgsql" /><span class="ml-2">PostgreSQL</span>
|
||||
</Button>
|
||||
{/each}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="border"
|
||||
color={template == 'pgsql' ? 'blue' : 'dark'}
|
||||
btnClasses={template == 'pgsql' ? '!border-2 !bg-blue-50/75' : 'm-[1px]'}
|
||||
on:click={() => {
|
||||
script.language = Script.language.DENO
|
||||
template = 'pgsql'
|
||||
initContent(script.language, script.kind, template)
|
||||
}}
|
||||
>
|
||||
<LanguageIcon lang="pgsql" /><span class="ml-2">PostgreSQL</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="border"
|
||||
color={template == 'mysql' ? 'blue' : 'dark'}
|
||||
btnClasses={template == 'mysql' ? '!border-2 !bg-blue-50/75' : 'm-[1px]'}
|
||||
on:click={() => {
|
||||
script.language = Script.language.DENO
|
||||
template = 'mysql'
|
||||
initContent(script.language, script.kind, template)
|
||||
}}
|
||||
>
|
||||
<LanguageIcon lang="mysql" /><span class="ml-2">MySQL</span>
|
||||
</Button>
|
||||
</div>
|
||||
<h2 class="border-b pb-1 mt-4">Advanced</h2>
|
||||
<div>
|
||||
<Button
|
||||
color="light"
|
||||
size="sm"
|
||||
endIcon={{ icon: viewScriptKind ? faChevronUp : faChevronDown }}
|
||||
on:click={() => (viewScriptKind = !viewScriptKind)}
|
||||
>
|
||||
Tag this script as having a specific purpose inside flows
|
||||
</Button>
|
||||
</div>
|
||||
{#if viewScriptKind}
|
||||
<div class="max-w-lg">
|
||||
<RadioButton
|
||||
label="Script Type"
|
||||
options={[
|
||||
['Common Script', Script.kind.SCRIPT],
|
||||
[
|
||||
{
|
||||
title: 'Trigger Script',
|
||||
desc: `First module of flows to trigger them based on watching changes external periodically using an internal state`
|
||||
},
|
||||
Script.kind.TRIGGER
|
||||
],
|
||||
[
|
||||
{
|
||||
title: 'Error Handler',
|
||||
desc: `Handle errors for flows after all retries attempts have been exhausted`
|
||||
},
|
||||
Script.kind.FAILURE
|
||||
],
|
||||
[
|
||||
{
|
||||
title: 'Approval Script',
|
||||
desc: `Send notification externally to ask for approval to continue a flow`
|
||||
},
|
||||
Script.kind.APPROVAL
|
||||
]
|
||||
|
||||
// ['Command Handler', Script.kind.COMMAND]
|
||||
]}
|
||||
on:change={(e) => {
|
||||
if (isInitialCode(script.content)) {
|
||||
template = 'script'
|
||||
initContent(script.language, e.detail, template)
|
||||
}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="border"
|
||||
color={template == 'mysql' ? 'blue' : 'dark'}
|
||||
btnClasses={template == 'mysql' ? '!border-2 !bg-blue-50/75' : 'm-[1px]'}
|
||||
on:click={() => {
|
||||
script.language = Script.language.DENO
|
||||
template = 'mysql'
|
||||
initContent(script.language, script.kind, template)
|
||||
}}
|
||||
bind:value={script.kind}
|
||||
>
|
||||
<LanguageIcon lang="mysql" /><span class="ml-2">MySQL</span>
|
||||
</Button>
|
||||
</div>
|
||||
<h2 class="border-b pb-1 mt-4">Advanced</h2>
|
||||
<div>
|
||||
<Button
|
||||
color="light"
|
||||
size="sm"
|
||||
endIcon={{ icon: viewScriptKind ? faChevronUp : faChevronDown }}
|
||||
on:click={() => (viewScriptKind = !viewScriptKind)}
|
||||
>
|
||||
Tag this script as having a specific purpose inside flows
|
||||
</Button>
|
||||
</div>
|
||||
{#if viewScriptKind}
|
||||
<div class="max-w-lg">
|
||||
<RadioButton
|
||||
label="Script Type"
|
||||
options={[
|
||||
['Common Script', Script.kind.SCRIPT],
|
||||
[
|
||||
{
|
||||
title: 'Trigger Script',
|
||||
desc: `First module of flows to trigger them based on watching changes external periodically using an internal state`
|
||||
},
|
||||
Script.kind.TRIGGER
|
||||
],
|
||||
[
|
||||
{
|
||||
title: 'Error Handler',
|
||||
desc: `Handle errors for flows after all retries attempts have been exhausted`
|
||||
},
|
||||
Script.kind.FAILURE
|
||||
],
|
||||
[
|
||||
{
|
||||
title: 'Approval Script',
|
||||
desc: `Send notification externally to ask for approval to continue a flow`
|
||||
},
|
||||
Script.kind.APPROVAL
|
||||
]
|
||||
|
||||
// ['Command Handler', Script.kind.COMMAND]
|
||||
]}
|
||||
on:change={(e) => {
|
||||
if (isInitialCode(script.content)) {
|
||||
template = 'script'
|
||||
initContent(script.language, e.detail, template)
|
||||
}
|
||||
}}
|
||||
bind:value={script.kind}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="ml-3">
|
||||
<Toggle
|
||||
bind:checked={script.is_template}
|
||||
options={{ right: 'Save as a workspace template' }}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="ml-3">
|
||||
<Toggle
|
||||
bind:checked={script.is_template}
|
||||
options={{ right: 'Save as a workspace template' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CenteredPage>
|
||||
{:else if step === 2}
|
||||
<ScriptEditor
|
||||
bind:schema={script.schema}
|
||||
path={script.path}
|
||||
bind:code={script.content}
|
||||
lang={script.language}
|
||||
{initialArgs}
|
||||
{kind}
|
||||
/>
|
||||
{:else if step === 3}
|
||||
<CenteredPage>
|
||||
<ScriptSchema
|
||||
bind:summary={script.summary}
|
||||
bind:description={script.description}
|
||||
</CenteredPage>
|
||||
{:else if step === 2}
|
||||
<ScriptEditor
|
||||
bind:schema={script.schema}
|
||||
path={script.path}
|
||||
bind:code={script.content}
|
||||
lang={script.language}
|
||||
{initialArgs}
|
||||
{kind}
|
||||
/>
|
||||
</CenteredPage>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if step === 3}
|
||||
<CenteredPage>
|
||||
<ScriptSchema
|
||||
bind:summary={script.summary}
|
||||
bind:description={script.description}
|
||||
bind:schema={script.schema}
|
||||
/>
|
||||
</CenteredPage>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
Script Builder not available to operators
|
||||
{/if}
|
||||
|
||||
@@ -26,9 +26,6 @@
|
||||
let users: GlobalUserInfo[] = []
|
||||
let filteredUsers: GlobalUserInfo[] = []
|
||||
|
||||
let deleteConfirmedCallback: (() => void) | undefined = undefined
|
||||
$: openConfirmation = Boolean(deleteConfirmedCallback)
|
||||
|
||||
async function loadVersion(): Promise<void> {
|
||||
version = await SettingsService.backendVersion()
|
||||
}
|
||||
@@ -47,39 +44,13 @@
|
||||
|
||||
<Drawer bind:this={drawer} on:open={listUsers} size="900px">
|
||||
<DrawerContent overflow_y={false} title="Superadmin Settings" on:close={drawer.closeDrawer}>
|
||||
<ConfirmationModal
|
||||
open={openConfirmation}
|
||||
title="Remove user"
|
||||
confirmationText="Remove"
|
||||
on:canceled={() => {
|
||||
deleteConfirmedCallback = undefined
|
||||
}}
|
||||
on:confirmed={() => {
|
||||
if (deleteConfirmedCallback) {
|
||||
deleteConfirmedCallback()
|
||||
}
|
||||
deleteConfirmedCallback = undefined
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col w-full space-y-4">
|
||||
<span>Are you sure you want to remove this user?</span>
|
||||
<Alert type="info" title="Bypass confirmation">
|
||||
<div>
|
||||
You can press
|
||||
<Badge color="dark-gray">SHIFT</Badge>
|
||||
while removing a variable to bypass confirmation.
|
||||
</div>
|
||||
</Alert>
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
<div>
|
||||
<div class="text-xs pt-1 text-gray-500 ">
|
||||
Windmill {version}
|
||||
</div>
|
||||
|
||||
<PageHeader title="All users" primary={false} />
|
||||
<PageHeader title="All global users" primary={false} />
|
||||
|
||||
<div class="pb-1" />
|
||||
<InviteGlobalUser on:new={listUsers} />
|
||||
@@ -91,21 +62,23 @@
|
||||
<TableCustom>
|
||||
<tr slot="header-row" class="sticky top-0 bg-white border-b">
|
||||
<th>email</th>
|
||||
<th>superadmin</th>
|
||||
<th>auth</th>
|
||||
<th>name</th>
|
||||
<th>company</th>
|
||||
<th />
|
||||
<th />
|
||||
</tr>
|
||||
<tbody slot="body" class="overflow-y-auto h-full max-h-full">
|
||||
{#if filteredUsers && users}
|
||||
{#each filteredUsers as { email, super_admin, login_type, name, company } (email)}
|
||||
<tr class="border">
|
||||
<td>{email}</td>
|
||||
<td>{super_admin ? 'yes' : ''}</td>
|
||||
<td>{login_type}</td>
|
||||
<td><span class="break-words">{name ?? ''}</span></td>
|
||||
<td><span class="break-words">{company ?? ''}</span></td>
|
||||
<td
|
||||
>{#if super_admin}<Badge>Superadmin</Badge>{/if}</td
|
||||
>
|
||||
<td>
|
||||
<div class="flex flex-row gap-x-1">
|
||||
<button
|
||||
@@ -119,18 +92,15 @@
|
||||
})
|
||||
sendUserToast('User updated')
|
||||
listUsers()
|
||||
}}>{super_admin ? 'demote' : 'promote'}</button
|
||||
}}>{super_admin ? 'non-superadmin' : 'superadmin'}</button
|
||||
>
|
||||
|
|
||||
<button
|
||||
class="text-red-500"
|
||||
on:click={async () => {
|
||||
deleteConfirmedCallback = async () => {
|
||||
await UserService.globalUserDelete({ email })
|
||||
sendUserToast(`User ${email} removed`)
|
||||
await listUsers()
|
||||
}
|
||||
openConfirmation = false
|
||||
await UserService.globalUserDelete({ email })
|
||||
sendUserToast(`User ${email} removed`)
|
||||
listUsers()
|
||||
}}>remove</button
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
description: variable.description
|
||||
}
|
||||
})
|
||||
sendUserToast(`Successfully created variable ${path}`)
|
||||
sendUserToast(`Created variable ${path}`)
|
||||
dispatch('create')
|
||||
drawer.closeDrawer()
|
||||
}
|
||||
@@ -115,7 +115,7 @@
|
||||
description: getV.description != variable.description ? variable.description : undefined
|
||||
}
|
||||
})
|
||||
sendUserToast(`Successfully updated variable at ${initialPath}`)
|
||||
sendUserToast(`Updated variable ${initialPath}`)
|
||||
dispatch('create')
|
||||
drawer.closeDrawer()
|
||||
} catch (err) {
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
import ContextPanel from './contextPanel/ContextPanel.svelte'
|
||||
import { classNames } from '$lib/utils'
|
||||
import AppPreview from './AppPreview.svelte'
|
||||
import { userStore } from '$lib/stores'
|
||||
|
||||
export let app: App
|
||||
export let path: string
|
||||
@@ -67,63 +68,67 @@
|
||||
$: width = $breakpoint === 'sm' ? 'w-[640px]' : 'w-full '
|
||||
</script>
|
||||
|
||||
{#if initialMode !== 'preview'}
|
||||
<AppEditorHeader bind:title={$appStore.title} bind:mode={$mode} bind:breakpoint={$breakpoint} />
|
||||
{/if}
|
||||
{#if !$userStore?.operator}
|
||||
{#if initialMode !== 'preview'}
|
||||
<AppEditorHeader bind:title={$appStore.title} bind:mode={$mode} bind:breakpoint={$breakpoint} />
|
||||
{/if}
|
||||
|
||||
{#if previewing}
|
||||
<AppPreview app={$appStore} />
|
||||
{#if previewing}
|
||||
<AppPreview app={$appStore} />
|
||||
{:else}
|
||||
<SplitPanesWrapper class="max-w-full overflow-hidden">
|
||||
<Pane size={20} minSize={15} maxSize={40}>
|
||||
<ContextPanel appPath={path} />
|
||||
</Pane>
|
||||
<Pane size={60}>
|
||||
<div class="p-4 bg-gray-100 min-h-full w-full relative">
|
||||
{#if $appStore.grid}
|
||||
<div class={classNames('mx-auto h-full', width)}>
|
||||
<GridEditor />
|
||||
</div>
|
||||
{/if}
|
||||
{#if $connectingInput.opened}
|
||||
<div
|
||||
class="absolute top-0 left-0 w-full h-full bg-black border-2 bg-opacity-25 z-1 flex justify-center items-center"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</Pane>
|
||||
<Pane size={30} minSize={25} maxSize={40}>
|
||||
<Tabs bind:selected={selectedTab}>
|
||||
<Tab value="insert" size="xs">
|
||||
<div class="m-1 flex flex-row gap-2">
|
||||
<Icon data={faPlus} />
|
||||
<span>Insert</span>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab value="settings" size="xs">
|
||||
<div class="m-1 flex flex-row gap-2">
|
||||
<Icon data={faSliders} />
|
||||
<span>Settings</span>
|
||||
</div>
|
||||
</Tab>
|
||||
<svelte:fragment slot="content">
|
||||
<TabContent value="settings">
|
||||
{#if $selectedComponent !== undefined}
|
||||
{#each $appStore.grid as gridItem (gridItem.id)}
|
||||
{#if gridItem.data.id === $selectedComponent}
|
||||
<ComponentPanel bind:component={gridItem.data} />
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
{#if $selectedComponent === undefined}
|
||||
<div class="p-4 text-sm">No component selected.</div>
|
||||
{/if}
|
||||
</TabContent>
|
||||
<TabContent value="insert">
|
||||
<ComponentList />
|
||||
</TabContent>
|
||||
</svelte:fragment>
|
||||
</Tabs>
|
||||
</Pane>
|
||||
</SplitPanesWrapper>
|
||||
{/if}
|
||||
{:else}
|
||||
<SplitPanesWrapper class="max-w-full overflow-hidden">
|
||||
<Pane size={20} minSize={15} maxSize={40}>
|
||||
<ContextPanel appPath={path} />
|
||||
</Pane>
|
||||
<Pane size={60}>
|
||||
<div class="p-4 bg-gray-100 min-h-full w-full relative">
|
||||
{#if $appStore.grid}
|
||||
<div class={classNames('mx-auto h-full', width)}>
|
||||
<GridEditor />
|
||||
</div>
|
||||
{/if}
|
||||
{#if $connectingInput.opened}
|
||||
<div
|
||||
class="absolute top-0 left-0 w-full h-full bg-black border-2 bg-opacity-25 z-1 flex justify-center items-center"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</Pane>
|
||||
<Pane size={30} minSize={25} maxSize={40}>
|
||||
<Tabs bind:selected={selectedTab}>
|
||||
<Tab value="insert" size="xs">
|
||||
<div class="m-1 flex flex-row gap-2">
|
||||
<Icon data={faPlus} />
|
||||
<span>Insert</span>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab value="settings" size="xs">
|
||||
<div class="m-1 flex flex-row gap-2">
|
||||
<Icon data={faSliders} />
|
||||
<span>Settings</span>
|
||||
</div>
|
||||
</Tab>
|
||||
<svelte:fragment slot="content">
|
||||
<TabContent value="settings">
|
||||
{#if $selectedComponent !== undefined}
|
||||
{#each $appStore.grid as gridItem (gridItem.id)}
|
||||
{#if gridItem.data.id === $selectedComponent}
|
||||
<ComponentPanel bind:component={gridItem.data} />
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
{#if $selectedComponent === undefined}
|
||||
<div class="p-4 text-sm">No component selected.</div>
|
||||
{/if}
|
||||
</TabContent>
|
||||
<TabContent value="insert">
|
||||
<ComponentList />
|
||||
</TabContent>
|
||||
</svelte:fragment>
|
||||
</Tabs>
|
||||
</Pane>
|
||||
</SplitPanesWrapper>
|
||||
App editor not available to operators
|
||||
{/if}
|
||||
|
||||
@@ -41,10 +41,10 @@
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
sendUserToast('Saved successfully.')
|
||||
sendUserToast('Saved')
|
||||
})
|
||||
.catch(() => {
|
||||
sendUserToast('Error during saving. Please try again later.', true)
|
||||
sendUserToast('Error during saving. Please try again later', true)
|
||||
})
|
||||
.finally(() => {
|
||||
loading.save = false
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import Dropdown from '$lib/components/Dropdown.svelte'
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import { AppService, type ListableApp } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { faEdit, faEye, faTrashAlt } from '@fortawesome/free-solid-svg-icons'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import Button from '../button/Button.svelte'
|
||||
@@ -32,18 +32,20 @@
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="actions">
|
||||
<span class="hidden md:inline-flex gap-x-1">
|
||||
{#if canWrite}
|
||||
<div>
|
||||
<Button
|
||||
color="light"
|
||||
size="xs"
|
||||
variant="border"
|
||||
startIcon={{ icon: faEdit }}
|
||||
href="/apps/edit/{path}"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
{#if !$userStore?.operator}
|
||||
{#if canWrite}
|
||||
<div>
|
||||
<Button
|
||||
color="light"
|
||||
size="xs"
|
||||
variant="border"
|
||||
startIcon={{ icon: faEdit }}
|
||||
href="/apps/edit/{path}"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<Button
|
||||
href="/apps/get/{path}"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import type ShareModal from '$lib/components/ShareModal.svelte'
|
||||
|
||||
import { FlowService, type Flow } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/utils'
|
||||
import {
|
||||
faArchive,
|
||||
@@ -34,7 +34,7 @@
|
||||
try {
|
||||
await FlowService.archiveFlowByPath({ workspace: $workspaceStore!, path })
|
||||
dispatch('change')
|
||||
sendUserToast(`Successfully archived flow ${path}`)
|
||||
sendUserToast(`Archived flow ${path}`)
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not archive this flow ${err.body}`, true)
|
||||
}
|
||||
@@ -56,30 +56,32 @@
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="actions">
|
||||
<span class="hidden md:inline-flex gap-x-1">
|
||||
{#if canWrite}
|
||||
<div>
|
||||
<Button
|
||||
color="light"
|
||||
size="xs"
|
||||
variant="border"
|
||||
startIcon={{ icon: faEdit }}
|
||||
href="/flows/edit/{path}"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<Button
|
||||
color="light"
|
||||
size="xs"
|
||||
variant="border"
|
||||
startIcon={{ icon: faCodeFork }}
|
||||
href="/flows/add?template={path}"
|
||||
>
|
||||
Fork
|
||||
</Button>
|
||||
</div>
|
||||
{#if !$userStore?.operator}
|
||||
{#if canWrite}
|
||||
<div>
|
||||
<Button
|
||||
color="light"
|
||||
size="xs"
|
||||
variant="border"
|
||||
startIcon={{ icon: faEdit }}
|
||||
href="/flows/edit/{path}"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<Button
|
||||
color="light"
|
||||
size="xs"
|
||||
variant="border"
|
||||
startIcon={{ icon: faCodeFork }}
|
||||
href="/flows/add?template={path}"
|
||||
>
|
||||
Fork
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import type ShareModal from '$lib/components/ShareModal.svelte'
|
||||
|
||||
import { ScriptService, type Script } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { capitalize, sendUserToast } from '$lib/utils'
|
||||
import {
|
||||
faArchive,
|
||||
@@ -44,7 +44,7 @@
|
||||
async function archiveScript(path: string): Promise<void> {
|
||||
await ScriptService.archiveScriptByPath({ workspace: $workspaceStore!, path })
|
||||
dispatch('change')
|
||||
sendUserToast(`Successfully archived script ${path}`)
|
||||
sendUserToast(`Archived script ${path}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -74,30 +74,32 @@
|
||||
|
||||
<svelte:fragment slot="actions">
|
||||
<span class="hidden md:inline-flex gap-x-1">
|
||||
{#if canWrite}
|
||||
<div>
|
||||
<Button
|
||||
color="light"
|
||||
size="xs"
|
||||
variant="border"
|
||||
startIcon={{ icon: faEdit }}
|
||||
href="/scripts/edit/{hash}?step=2"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<Button
|
||||
color="light"
|
||||
size="xs"
|
||||
variant="border"
|
||||
startIcon={{ icon: faCodeFork }}
|
||||
href="/scripts/add?template={path}"
|
||||
>
|
||||
Fork
|
||||
</Button>
|
||||
</div>
|
||||
{#if !$userStore?.operator}
|
||||
{#if canWrite}
|
||||
<div>
|
||||
<Button
|
||||
color="light"
|
||||
size="xs"
|
||||
variant="border"
|
||||
startIcon={{ icon: faEdit }}
|
||||
href="/scripts/edit/{hash}?step=2"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<Button
|
||||
color="light"
|
||||
size="xs"
|
||||
variant="border"
|
||||
startIcon={{ icon: faCodeFork }}
|
||||
href="/scripts/add?template={path}"
|
||||
>
|
||||
Fork
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
@@ -128,6 +130,7 @@
|
||||
icon: faEye,
|
||||
href: `/scripts/get/${hash}`
|
||||
},
|
||||
|
||||
{
|
||||
displayName: 'Edit',
|
||||
icon: faEdit,
|
||||
|
||||
@@ -55,7 +55,8 @@
|
||||
return {
|
||||
canWrite:
|
||||
canWrite(script.path, script.extra_perms, $userStore) &&
|
||||
script.workspace_id == $workspaceStore,
|
||||
script.workspace_id == $workspaceStore &&
|
||||
!$userStore?.operator,
|
||||
...script
|
||||
}
|
||||
})
|
||||
@@ -66,7 +67,10 @@
|
||||
async function loadFlows(): Promise<void> {
|
||||
flows = (await FlowService.listFlows({ workspace: $workspaceStore! })).map((x: Flow) => {
|
||||
return {
|
||||
canWrite: canWrite(x.path, x.extra_perms, $userStore) && x.workspace_id == $workspaceStore,
|
||||
canWrite:
|
||||
canWrite(x.path, x.extra_perms, $userStore) &&
|
||||
x.workspace_id == $workspaceStore &&
|
||||
!$userStore?.operator,
|
||||
...x
|
||||
}
|
||||
})
|
||||
@@ -77,7 +81,9 @@
|
||||
apps = (await AppService.listApps({ workspace: $workspaceStore! })).map((app: ListableApp) => {
|
||||
return {
|
||||
canWrite:
|
||||
canWrite(app.path!, app.extra_perms!, $userStore) && app.workspace_id == $workspaceStore,
|
||||
canWrite(app.path!, app.extra_perms!, $userStore) &&
|
||||
app.workspace_id == $workspaceStore &&
|
||||
!$userStore?.operator,
|
||||
...app
|
||||
}
|
||||
})
|
||||
|
||||
@@ -18,21 +18,21 @@
|
||||
const mainMenuLinks = [
|
||||
{ label: 'Home', href: '/', icon: faHomeAlt },
|
||||
{ label: 'Runs', href: '/runs', icon: faPlay },
|
||||
{ label: 'Variables', href: '/variables', icon: faDollarSign },
|
||||
{ label: 'Resources', href: '/resources', icon: faCubes }
|
||||
{ label: 'Variables', href: '/variables', icon: faDollarSign, disabled: $userStore?.operator },
|
||||
{ label: 'Resources', href: '/resources', icon: faCubes, disabled: $userStore?.operator }
|
||||
]
|
||||
|
||||
$: secondaryMenuLinks = [
|
||||
{ label: 'Schedules', href: '/schedules', icon: faCalendar },
|
||||
{ label: 'Groups', href: '/groups', icon: faUsersCog },
|
||||
{ label: 'Audit Logs', href: '/audit_logs', icon: faEye },
|
||||
{ label: 'Schedules', href: '/schedules', icon: faCalendar, disabled: $userStore?.operator },
|
||||
{ label: 'Groups', href: '/groups', icon: faUsersCog, disabled: $userStore?.operator },
|
||||
{ label: 'Audit Logs', href: '/audit_logs', icon: faEye, disabled: $userStore?.operator },
|
||||
{
|
||||
label: 'Workspace',
|
||||
href: '/workspace_settings',
|
||||
icon: faCog,
|
||||
disabled: !($userStore?.is_admin || $superadmin)
|
||||
},
|
||||
{ label: 'Workers', href: '/workers', icon: faRobot }
|
||||
{ label: 'Workers', href: '/workers', icon: faRobot, disabled: $userStore?.operator }
|
||||
]
|
||||
|
||||
const thirdMenuLinks = [
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import { userStore, usersWorkspaceStore, superadmin, usageStore } from '$lib/stores'
|
||||
import { classNames, isCloudHosted } from '$lib/utils'
|
||||
import { faCrown, faUser } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faCrown, faPlay, faUser } from '@fortawesome/free-solid-svg-icons'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
import Icon from 'svelte-awesome'
|
||||
@@ -31,6 +31,8 @@
|
||||
{$userStore?.username ?? ($superadmin ? $superadmin : '___')}
|
||||
{#if $userStore?.is_admin || $superadmin}
|
||||
<Icon data={faCrown} scale={0.6} />
|
||||
{:else if $userStore?.operator}
|
||||
<Icon class="ml-2" data={faPlay} scale={0.8} />
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -42,7 +44,15 @@
|
||||
<p class="text-sm font-medium text-gray-900 truncate" role="none">
|
||||
{$usersWorkspaceStore?.email}
|
||||
</p>
|
||||
<span class="text-xs text-gray-600">
|
||||
{#if $userStore?.is_admin}
|
||||
Admin of this workspace <Icon data={faCrown} scale={0.6} />
|
||||
{:else if $userStore?.operator}
|
||||
Operator in this workspace <Icon class="ml-2" data={faPlay} scale={0.8} />
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="py-1" role="none">
|
||||
<a
|
||||
on:click={() => dispatch('user-settings')}
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface UserExt {
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
is_super_admin: boolean;
|
||||
operator: boolean;
|
||||
created_at: string;
|
||||
groups: string[];
|
||||
pgroups: string[];
|
||||
|
||||
@@ -157,25 +157,27 @@
|
||||
>
|
||||
Run
|
||||
</Button>
|
||||
<Button
|
||||
href="/flows/edit/{path}"
|
||||
variant="contained"
|
||||
color="blue"
|
||||
size="xs"
|
||||
startIcon={{ icon: faEdit }}
|
||||
disabled={!can_write}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
href="/flows/add?template={flow.path}"
|
||||
variant="contained"
|
||||
color="blue"
|
||||
size="xs"
|
||||
startIcon={{ icon: faCodeFork }}
|
||||
>
|
||||
Use as template/Fork
|
||||
</Button>
|
||||
{#if !$userStore?.operator}
|
||||
<Button
|
||||
href="/flows/edit/{path}"
|
||||
variant="contained"
|
||||
color="blue"
|
||||
size="xs"
|
||||
startIcon={{ icon: faEdit }}
|
||||
disabled={!can_write}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
href="/flows/add?template={flow.path}"
|
||||
variant="contained"
|
||||
color="blue"
|
||||
size="xs"
|
||||
startIcon={{ icon: faCodeFork }}
|
||||
>
|
||||
Use as template/Fork
|
||||
</Button>
|
||||
{/if}
|
||||
<Button href="/runs/{flow.path}" color="blue" size="xs" startIcon={{ icon: faList }}>
|
||||
View runs
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { FlowService, type OpenFlow } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { Alert, Button, Drawer, DrawerContent, Tab, Tabs } from '$lib/components/common'
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
import CreateActionsFlow from '$lib/components/flows/CreateActionsFlow.svelte'
|
||||
@@ -130,32 +130,36 @@
|
||||
{/if}
|
||||
<PageHeader title="Home">
|
||||
<div class="flex flex-row gap-2 flex-wrap">
|
||||
<CreateApp />
|
||||
<CreateActionsScript />
|
||||
<CreateActionsFlow />
|
||||
{#if !$userStore?.operator}
|
||||
<CreateApp />
|
||||
<CreateActionsScript />
|
||||
<CreateActionsFlow />
|
||||
{/if}
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<Tabs bind:selected={tab}>
|
||||
<Tab size="md" value="workspace">
|
||||
<div class="flex gap-2 items-center my-1">
|
||||
<Building size={18} />
|
||||
Workspace
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab size="md" value="hubscripts">
|
||||
<div class="flex gap-2 items-center my-1">
|
||||
<Globe2 size={18} />
|
||||
Hub Scripts
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab size="md" value="hubflows">
|
||||
<div class="flex gap-2 items-center my-1">
|
||||
<Globe2 size={18} />
|
||||
Hub Flows
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
{#if !$userStore?.operator}
|
||||
<Tabs bind:selected={tab}>
|
||||
<Tab size="md" value="workspace">
|
||||
<div class="flex gap-2 items-center my-1">
|
||||
<Building size={18} />
|
||||
Workspace
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab size="md" value="hubscripts">
|
||||
<div class="flex gap-2 items-center my-1">
|
||||
<Globe2 size={18} />
|
||||
Hub Scripts
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab size="md" value="hubflows">
|
||||
<div class="flex gap-2 items-center my-1">
|
||||
<Globe2 size={18} />
|
||||
Hub Flows
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
{/if}
|
||||
<div class="my-2" />
|
||||
<div class="flex flex-col gap-y-16">
|
||||
<div class="flex flex-col">
|
||||
|
||||
@@ -193,14 +193,16 @@
|
||||
size="xs"
|
||||
startIcon={{ icon: faRefresh }}>Run again</Button
|
||||
>
|
||||
{#if canWrite(job?.script_path ?? '', {}, $userStore)}
|
||||
<Button
|
||||
disabled={not_same_workspace}
|
||||
href={editHref}
|
||||
color="blue"
|
||||
size="xs"
|
||||
startIcon={{ icon: faEdit }}>Edit</Button
|
||||
>
|
||||
{#if !$userStore?.operator}
|
||||
{#if canWrite(job?.script_path ?? '', {}, $userStore)}
|
||||
<Button
|
||||
disabled={not_same_workspace}
|
||||
href={editHref}
|
||||
color="blue"
|
||||
size="xs"
|
||||
startIcon={{ icon: faEdit }}>Edit</Button
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
<Button
|
||||
disabled={not_same_workspace}
|
||||
|
||||
@@ -157,24 +157,26 @@
|
||||
>
|
||||
Run
|
||||
</Button>
|
||||
<Button
|
||||
href={`/scripts/edit/${script.hash}?step=2`}
|
||||
color="blue"
|
||||
size="md"
|
||||
startIcon={{ icon: faEdit }}
|
||||
disabled={!can_write}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
{#if !topHash}
|
||||
{#if !$userStore?.operator}
|
||||
<Button
|
||||
href={`/scripts/add?template=${script.path}`}
|
||||
variant="border"
|
||||
href={`/scripts/edit/${script.hash}?step=2`}
|
||||
color="blue"
|
||||
size="md"
|
||||
startIcon={{ icon: faCodeFork }}
|
||||
startIcon={{ icon: faEdit }}
|
||||
disabled={!can_write}
|
||||
>
|
||||
Fork
|
||||
Edit
|
||||
</Button>
|
||||
{#if !topHash}
|
||||
<Button
|
||||
href={`/scripts/add?template=${script.path}`}
|
||||
variant="border"
|
||||
size="md"
|
||||
startIcon={{ icon: faCodeFork }}
|
||||
>
|
||||
Fork
|
||||
</Button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -69,8 +69,7 @@
|
||||
requestBody: args,
|
||||
scheduledFor
|
||||
})
|
||||
sendUserToast(`Job <a href='/run/${run}?workspace=${$workspaceStore}'>${run}</a> started`)
|
||||
goto('/run/' + run + '?workspace=' + $workspaceStore)
|
||||
await goto('/run/' + run + '?workspace=' + $workspaceStore)
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not create job: ${err.body}`, true)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import CenteredModal from '$lib/components/CenteredModal.svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
|
||||
const rd = $page.url.searchParams.get('rd')
|
||||
|
||||
@@ -45,10 +46,10 @@
|
||||
if (auto_invite) {
|
||||
await WorkspaceService.editAutoInvite({
|
||||
workspace: id,
|
||||
requestBody: { set: true }
|
||||
requestBody: { operator: operatorOnly }
|
||||
})
|
||||
}
|
||||
sendUserToast(`Successfully created workspace id: ${id}`)
|
||||
sendUserToast(`Created workspace id: ${id}`)
|
||||
|
||||
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
|
||||
workspaceStore.set(id)
|
||||
@@ -94,6 +95,7 @@
|
||||
$: domain = $usersWorkspaceStore?.email.split('@')[1]
|
||||
|
||||
let auto_invite = false
|
||||
let operatorOnly = false
|
||||
</script>
|
||||
|
||||
<CenteredModal title="New Workspace">
|
||||
@@ -120,6 +122,16 @@
|
||||
bind:checked={auto_invite}
|
||||
options={{ right: `Auto invite users with the same domain (${domain})` }}
|
||||
/>
|
||||
{#if auto_invite}
|
||||
<Toggle
|
||||
bind:checked={operatorOnly}
|
||||
options={{ right: `Auto invited users to join as operators` }}
|
||||
/>
|
||||
<Tooltip
|
||||
>An operator can only execute and view scripts/flows/apps from your workspace, and only those
|
||||
that he has visibility on</Tooltip
|
||||
>
|
||||
{/if}
|
||||
{#if !isDomainAllowed}
|
||||
<div class="text-gray-600 text-sm mb-4 mt-2">{domain} domain not allowed for auto-invite</div>
|
||||
{/if}
|
||||
|
||||
@@ -131,6 +131,8 @@
|
||||
<span class="font-mono font-semibold">{invite.workspace_id}</span>
|
||||
{#if invite.is_admin}
|
||||
<span class="text-sm">as an admin</span>
|
||||
{:else if invite.operator}
|
||||
<span class="text-sm">as an operator</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex justify-end items-center flex-col sm:flex-row gap-1">
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import { faScroll, faBarsStaggered } from '@fortawesome/free-solid-svg-icons'
|
||||
import SearchItems from '$lib/components/SearchItems.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton/ToggleButton.svelte'
|
||||
|
||||
let users: User[] | undefined = undefined
|
||||
let invites: WorkspaceInvite[] = []
|
||||
@@ -38,6 +41,7 @@
|
||||
let team_name: string | undefined
|
||||
let auto_invite_domain: string | undefined
|
||||
let itemKind: 'flow' | 'script' = 'flow'
|
||||
let operatorOnly: boolean | undefined = undefined
|
||||
|
||||
// function getDropDownItems(username: string): DropdownItem[] {
|
||||
// return [
|
||||
@@ -76,6 +80,7 @@
|
||||
const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
|
||||
team_name = settings.slack_name
|
||||
auto_invite_domain = settings.auto_invite_domain
|
||||
operatorOnly = settings.auto_invite_operator
|
||||
scriptPath = (settings.slack_command_script ?? '').split('/').slice(1).join('/')
|
||||
initialPath = scriptPath
|
||||
}
|
||||
@@ -104,6 +109,34 @@
|
||||
loadSettings()
|
||||
}
|
||||
}
|
||||
|
||||
$: operatorOnly != undefined && auto_invite_domain && setOperatorOnly()
|
||||
|
||||
async function removeAllInvitesFromDomain() {
|
||||
await Promise.all(
|
||||
invites
|
||||
.filter((x) => x.email.endsWith('@' + auto_invite_domain ?? ''))
|
||||
.map(({ email, is_admin, operator }) =>
|
||||
WorkspaceService.deleteInvite({
|
||||
workspace: $workspaceStore ?? '',
|
||||
requestBody: {
|
||||
email,
|
||||
is_admin,
|
||||
operator
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
async function setOperatorOnly() {
|
||||
await removeAllInvitesFromDomain()
|
||||
await WorkspaceService.editAutoInvite({
|
||||
workspace: $workspaceStore ?? '',
|
||||
requestBody: { operator: operatorOnly }
|
||||
})
|
||||
loadSettings()
|
||||
listInvites()
|
||||
}
|
||||
</script>
|
||||
|
||||
<SearchItems
|
||||
@@ -127,47 +160,94 @@
|
||||
<tr slot="header-row">
|
||||
<th>email</th>
|
||||
<th>username</th>
|
||||
<th>role</th>
|
||||
<th colspan="3">jobs & flows (<abbr title="past two weeks">2w</abbr>)</th>
|
||||
<th
|
||||
>executions (<abbr title="past 5 weeks">5w</abbr>) <Tooltip
|
||||
>An execution is calculated as 1 for any runs of scripts + 1 for each seconds above
|
||||
the first one</Tooltip
|
||||
>
|
||||
</th>
|
||||
<th />
|
||||
<th />
|
||||
<th />
|
||||
</tr>
|
||||
<tbody slot="body">
|
||||
{#if filteredUsers}
|
||||
{#each filteredUsers as { email, username, is_admin, usage } (email)}
|
||||
{#each filteredUsers as { email, username, is_admin, operator, usage, disabled } (email)}
|
||||
<tr class="border">
|
||||
<td>{email}</td>
|
||||
<td>{username}</td>
|
||||
<td>{is_admin ? 'admin' : 'user'}</td>
|
||||
<td>{usage?.jobs}</td>
|
||||
<td>{usage?.flows}</td>
|
||||
<td>{msToSec(usage?.duration_ms)}s</td>
|
||||
<td class="whitespace-nowrap"
|
||||
><button
|
||||
class="ml-2 text-red-500"
|
||||
on:click={async () => {
|
||||
await UserService.deleteUser({
|
||||
workspace: $workspaceStore ?? '',
|
||||
username
|
||||
})
|
||||
sendUserToast('User removed')
|
||||
listUsers()
|
||||
}}>remove</button
|
||||
>
|
||||
-
|
||||
<button
|
||||
class="text-blue-500"
|
||||
on:click={async () => {
|
||||
await UserService.updateUser({
|
||||
workspace: $workspaceStore ?? '',
|
||||
username,
|
||||
requestBody: {
|
||||
is_admin: !is_admin
|
||||
}
|
||||
})
|
||||
sendUserToast('User updated')
|
||||
listUsers()
|
||||
}}>{is_admin ? 'demote' : 'promote'}</button
|
||||
<td>{usage?.executions}</td>
|
||||
<td
|
||||
><div class="flex gap-1"
|
||||
>{#if disabled}
|
||||
<Badge color="red">disabled</Badge>
|
||||
{/if}</div
|
||||
></td
|
||||
>
|
||||
<td>
|
||||
<div>
|
||||
<ToggleButtonGroup
|
||||
selected={is_admin ? 'admin' : operator ? 'operator' : 'author'}
|
||||
on:selected={async (e) => {
|
||||
const body =
|
||||
e.detail == 'admin'
|
||||
? { is_admin: true, operator: false }
|
||||
: e.detail == 'operator'
|
||||
? { is_admin: false, operator: true }
|
||||
: { is_admin: false, operator: false }
|
||||
await UserService.updateUser({
|
||||
workspace: $workspaceStore ?? '',
|
||||
username,
|
||||
requestBody: body
|
||||
})
|
||||
listUsers()
|
||||
}}
|
||||
>
|
||||
<ToggleButton position="left" value="operator" size="xs"
|
||||
>Operator <Tooltip
|
||||
>An operator can only execute and view scripts/flows/apps from your
|
||||
workspace, and only those that he has visibility on</Tooltip
|
||||
></ToggleButton
|
||||
>
|
||||
<ToggleButton position="center" value="author" size="xs"
|
||||
>Author <Tooltip
|
||||
>An Author can execute and view scripts/flows/apps, but he can also create
|
||||
new ones</Tooltip
|
||||
></ToggleButton
|
||||
>
|
||||
<ToggleButton position="right" value="admin" size="xs">Admin</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class="text-blue-500"
|
||||
on:click={async () => {
|
||||
await UserService.updateUser({
|
||||
workspace: $workspaceStore ?? '',
|
||||
username,
|
||||
requestBody: {
|
||||
disabled: !disabled
|
||||
}
|
||||
})
|
||||
listUsers()
|
||||
}}>{disabled ? 'enable' : 'disable'}</button
|
||||
>
|
||||
|
|
||||
<button
|
||||
class="text-red-500"
|
||||
on:click={async () => {
|
||||
await UserService.deleteUser({
|
||||
workspace: $workspaceStore ?? '',
|
||||
username
|
||||
})
|
||||
sendUserToast('User removed')
|
||||
listUsers()
|
||||
}}>remove</button
|
||||
>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{:else}
|
||||
@@ -196,10 +276,12 @@
|
||||
<th />
|
||||
</tr>
|
||||
<tbody slot="body">
|
||||
{#each invites as { email, is_admin }}
|
||||
{#each invites as { email, is_admin, operator }}
|
||||
<tr class="border">
|
||||
<td>{email}</td>
|
||||
<td>{is_admin ? 'admin' : 'user'}</td>
|
||||
<td
|
||||
>{#if operator}<Badge>operator</Badge>{:else if is_admin}<Badge>admin</Badge>{/if}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
class="ml-2 text-red-500"
|
||||
@@ -208,11 +290,12 @@
|
||||
workspace: $workspaceStore ?? '',
|
||||
requestBody: {
|
||||
email,
|
||||
is_admin
|
||||
is_admin,
|
||||
operator
|
||||
}
|
||||
})
|
||||
listInvites()
|
||||
}}>remove</button
|
||||
}}>cancel</button
|
||||
></td
|
||||
>
|
||||
</tr>
|
||||
@@ -224,29 +307,42 @@
|
||||
<PageHeader title="Auto Invite" primary={false} />
|
||||
<div class="flex gap-2">
|
||||
{#if auto_invite_domain != domain}
|
||||
<Button
|
||||
disabled={!allowedAutoDomain}
|
||||
on:click={async () => {
|
||||
await WorkspaceService.editAutoInvite({
|
||||
workspace: $workspaceStore ?? '',
|
||||
requestBody: { set: true }
|
||||
})
|
||||
loadSettings()
|
||||
listInvites()
|
||||
}}>Set auto-invite to {domain}</Button
|
||||
>
|
||||
<div>
|
||||
<Button
|
||||
disabled={!allowedAutoDomain}
|
||||
on:click={async () => {
|
||||
await WorkspaceService.editAutoInvite({
|
||||
workspace: $workspaceStore ?? '',
|
||||
requestBody: { operator: false }
|
||||
})
|
||||
loadSettings()
|
||||
listInvites()
|
||||
}}>Set auto-invite to {domain}</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{#if auto_invite_domain}
|
||||
<Button
|
||||
on:click={async () => {
|
||||
await WorkspaceService.editAutoInvite({
|
||||
workspace: $workspaceStore ?? '',
|
||||
requestBody: { set: false }
|
||||
})
|
||||
loadSettings()
|
||||
listInvites()
|
||||
}}>Unset auto-invite from {auto_invite_domain}</Button
|
||||
>
|
||||
<div class="flex flex-col gap-y-2">
|
||||
<Toggle
|
||||
bind:checked={operatorOnly}
|
||||
options={{
|
||||
right: `Auto-invited users to join as operators`
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<Button
|
||||
on:click={async () => {
|
||||
await removeAllInvitesFromDomain()
|
||||
await WorkspaceService.editAutoInvite({
|
||||
workspace: $workspaceStore ?? '',
|
||||
requestBody: { operator: undefined }
|
||||
})
|
||||
loadSettings()
|
||||
listInvites()
|
||||
}}>Unset auto-invite from {auto_invite_domain} domain</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !allowedAutoDomain}
|
||||
@@ -334,7 +430,7 @@
|
||||
btnClasses="mt-2"
|
||||
on:click={async () => {
|
||||
await WorkspaceService.deleteWorkspace({ workspace: $workspaceStore ?? '' })
|
||||
sendUserToast(`Successfully deleted workspace ${$workspaceStore}`)
|
||||
sendUserToast(`Deleted workspace ${$workspaceStore}`)
|
||||
workspaceStore.set(undefined)
|
||||
usersWorkspaceStore.set(undefined)
|
||||
goto('/user/workspaces')
|
||||
|
||||
Reference in New Issue
Block a user