This commit is contained in:
Ruben Fiszel
2023-01-20 11:47:24 +01:00
parent 536f9cf58f
commit 85bbb79d22
5 changed files with 415 additions and 309 deletions
+1 -1
View File
@@ -655,7 +655,7 @@ export function addWhitespaceBeforeCapitals(word?: string): string {
}
export function isCloudHosted(): boolean {
return get(page).url.hostname == 'app.windmill.dev'
return get(page).url.hostname == 'app.windmill.dev' || true
}
export function isObject(obj: any) {
@@ -16,14 +16,15 @@
import { goto } from '$app/navigation'
import InviteUser from '$lib/components/InviteUser.svelte'
import ScriptPicker from '$lib/components/ScriptPicker.svelte'
import { Badge, Button, Skeleton } from '$lib/components/common'
import { Alert, Badge, Button, Skeleton, Tab, Tabs } from '$lib/components/common'
import Tooltip from '$lib/components/Tooltip.svelte'
import { faScroll, faBarsStaggered } from '@fortawesome/free-solid-svg-icons'
import { faScroll, faBarsStaggered, faExternalLink } 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'
import AddUser from '$lib/components/AddUser.svelte'
import { page } from '$app/stores'
let users: User[] | undefined = undefined
let invites: WorkspaceInvite[] = []
@@ -37,6 +38,11 @@
let operatorOnly: boolean | undefined = undefined
let premium_info: { premium: boolean; usage?: number } | undefined = undefined
let nbDisplayed = 30
let plan: string | undefined = undefined
let customer_id: string | undefined = undefined
let tab: 'users' | 'slack' | 'premium' | 'export_delete' =
($page.url.searchParams.get('tab') as 'users' | 'slack' | 'premium' | 'export_delete') ??
'users'
// function getDropDownItems(username: string): DropdownItem[] {
// return [
@@ -77,6 +83,8 @@
auto_invite_domain = settings.auto_invite_domain
operatorOnly = settings.auto_invite_operator
scriptPath = (settings.slack_command_script ?? '').split('/').slice(1).join('/')
plan = settings.plan
customer_id = settings.customer_id
initialPath = scriptPath
}
@@ -127,6 +135,30 @@
)
)
}
const plans = {
Free: [
'Users use their individual global free-tier quotas when doing executions in this workspace',
'<b>1 000</b> free global executions per-user per month'
],
Team: [
`<b>$10/mo</b> per user in the workspace.`,
`Executions are not accounted for in the global user's
quotas but are accounted for in the workspace's quota.`,
`Every user in the workspace increases the pooled workspace quota by <b>50k</b> executions.`,
`1 000 executions above the pooled workspace quota is charged 1$.`
],
Enterprise: [
`<b>$50/month</b> per user in the workspace.`,
`Executions are not accounted for in the global user's
quotas but are accounted for in the workspace's quota.`,
`Every user in the workspace increases the pooled workspace quota by <b>50k</b> executions.`,
`1 000 executions above the pooled workspace quota is charged 1$.`,
`<b>Dedicated workers and database</b>`,
`<b>SAML support</b>`,
`<b>Priority support including an automation engineer</b>`
]
}
</script>
<SearchItems
@@ -140,354 +172,428 @@
{#if $userStore?.is_admin}
<PageHeader title="Workspace Settings of {$workspaceStore}" />
<PageHeader title="Members ({users?.length ?? ''})" primary={false} />
<Tabs bind:selected={tab}>
<Tab size="md" value="users">
<div class="flex gap-2 items-center my-1"> Users & Invites </div>
</Tab>
<Tab size="md" value="slack">
<div class="flex gap-2 items-center my-1"> Slack Command </div>
</Tab>
{#if isCloudHosted()}
<Tab size="md" value="premium">
<div class="flex gap-2 items-center my-1"> Premium Plans </div>
</Tab>
{/if}
<Tab size="md" value="export_delete">
<div class="flex gap-2 items-center my-1"> Export & Delete Workspace </div>
</Tab>
</Tabs>
{#if tab == 'users'}
<PageHeader title="Members ({users?.length ?? ''})" primary={false} />
<AddUser on:new={listUsers} />
<AddUser on:new={listUsers} />
<div class="pt-2 pb-1">
<input placeholder="Search users" bind:value={userFilter} class="input mt-1" />
</div>
<div class="overflow-auto max-h-screen mb-20">
<TableCustom>
<tr slot="header-row">
<th>email</th>
<th>username</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.slice(0, nbDisplayed) as { email, username, is_admin, operator, usage, disabled } (email)}
<div class="pt-2 pb-1">
<input placeholder="Search users" bind:value={userFilter} class="input mt-1" />
</div>
<div class="overflow-auto max-h-screen mb-20">
<TableCustom>
<tr slot="header-row">
<th>email</th>
<th>username</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.slice(0, nbDisplayed) as { email, username, is_admin, operator, usage, disabled } (email)}
<tr class="border">
<td>{email}</td>
<td>{username}</td>
<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}
{#if filteredUsers?.length > 50}
<span class="text-xs"
>{nbDisplayed} items out of {filteredUsers.length}
<button class="ml-4" on:click={() => (nbDisplayed += 30)}>load 30 more</button
></span
>
{/if}
{:else}
{#each new Array(6) as _}
<tr class="border">
{#each new Array(4) as _}
<td>
<Skeleton layout={[[2]]} />
</td>
{/each}
</tr>
{/each}
{/if}
</tbody>
</TableCustom>
</div>
<PageHeader title="Pending Invites ({invites.length ?? ''})" primary={false}>
<InviteUser on:new={listInvites} />
</PageHeader>
<div class="overflow-auto max-h-screen">
<TableCustom>
<tr slot="header-row">
<th>email</th>
<th>role</th>
<th />
</tr>
<tbody slot="body">
{#each invites as { email, is_admin, operator }}
<tr class="border">
<td>{email}</td>
<td>{username}</td>
<td>{usage?.executions}</td>
<td
><div class="flex gap-1"
>{#if disabled}
<Badge color="red">disabled</Badge>
{/if}</div
>{#if operator}<Badge>operator</Badge>{:else if is_admin}<Badge>admin</Badge>{/if}
</td>
<td>
<button
class="ml-2 text-red-500"
on:click={async () => {
await WorkspaceService.deleteInvite({
workspace: $workspaceStore ?? '',
requestBody: {
email,
is_admin,
operator
}
})
listInvites()
}}>cancel</button
></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}
{#if filteredUsers?.length > 50}
<span class="text-xs"
>{nbDisplayed} items out of {filteredUsers.length}
<button class="ml-4" on:click={() => (nbDisplayed += 30)}>load 30 more</button
></span
>
{/if}
{:else}
{#each new Array(6) as _}
<tr class="border">
{#each new Array(4) as _}
<td>
<Skeleton layout={[[2]]} />
</td>
{/each}
</tr>
{/each}
{/if}
</tbody>
</TableCustom>
</div>
<PageHeader title="Pending Invites ({invites.length ?? ''})" primary={false}>
<InviteUser on:new={listInvites} />
</PageHeader>
</tbody>
</TableCustom>
</div>
<div class="overflow-auto max-h-screen">
<TableCustom>
<tr slot="header-row">
<th>email</th>
<th>role</th>
<th />
</tr>
<tbody slot="body">
{#each invites as { email, is_admin, operator }}
<tr class="border">
<td>{email}</td>
<td
>{#if operator}<Badge>operator</Badge>{:else if is_admin}<Badge>admin</Badge>{/if}
</td>
<td>
<button
class="ml-2 text-red-500"
on:click={async () => {
await WorkspaceService.deleteInvite({
workspace: $workspaceStore ?? '',
requestBody: {
email,
is_admin,
operator
}
})
listInvites()
}}>cancel</button
></td
>
</tr>
{/each}
</tbody>
</TableCustom>
</div>
{#if isCloudHosted()}
<div class="mt-10" />
<PageHeader title="Team plan" primary={false} />
{#if premium_info?.premium}This workspace is on a team plan. The number of executions is
tracked globally. Current number of executions in this workspace since it was switched to
the team Plan for this month: <b>{premium_info.usage ?? 0}</b>
{:else}
This workspace is <b>NOT</b> on a team plan. Users use their global free-tier quotas when
doing executions in this workspace. Upgrade to a Team plan to unlock unlimited execution in
this workspace.
<div class="mt-2">
<Button
on:click={() =>
sendUserToast(
'Upgrading to a team plan is a manual process for now. Send an email to ruben@windmill.dev'
)}>Upgrade to Team plan</Button
>
</div>
{/if}
{/if}
<div class="mt-10" />
<PageHeader title="Auto Invite" primary={false} />
<div class="flex gap-2">
{#if auto_invite_domain != domain}
<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}
<div class="flex flex-col gap-y-2">
<Toggle
bind:checked={operatorOnly}
options={{
right: `Auto-invited users to join as operators`
}}
on:change={async (e) => {
await removeAllInvitesFromDomain()
await WorkspaceService.editAutoInvite({
workspace: $workspaceStore ?? '',
requestBody: { operator: e.detail }
})
loadSettings()
listInvites()
}}
/>
<PageHeader title="Auto Invite" primary={false} />
<div class="flex gap-2">
{#if auto_invite_domain != domain}
<div>
<Button
disabled={!allowedAutoDomain}
on:click={async () => {
await removeAllInvitesFromDomain()
await WorkspaceService.editAutoInvite({
workspace: $workspaceStore ?? '',
requestBody: { operator: undefined }
requestBody: { operator: false }
})
loadSettings()
listInvites()
}}>Unset auto-invite from {auto_invite_domain} domain</Button
}}>Set auto-invite to {domain}</Button
>
</div>
{/if}
{#if auto_invite_domain}
<div class="flex flex-col gap-y-2">
<Toggle
bind:checked={operatorOnly}
options={{
right: `Auto-invited users to join as operators`
}}
on:change={async (e) => {
await removeAllInvitesFromDomain()
await WorkspaceService.editAutoInvite({
workspace: $workspaceStore ?? '',
requestBody: { operator: e.detail }
})
loadSettings()
listInvites()
}}
/>
<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}
<div class="text-red-400 text-sm mb-2">{domain} domain not allowed for auto-invite</div>
{/if}
{:else if tab == 'premium'}
{#if isCloudHosted()}
<div class="mt-10" />
<PageHeader title="Premium Workspace" primary={false} />
{#if customer_id || true}
<div class="mt-2 mb-6">
<Button endIcon={{ icon: faExternalLink }} href="">Customer Portal</Button>
<p class="text-xs text-gray-600 mt-1">
See invoices, change billing information or subscription details</p
>
</div>
{/if}
<div class="text-sm mb-4">
{#if premium_info?.premium}Number of executions in this workspace this month: <div
class=" inline text-2xl font-bold">{premium_info.usage ?? 0}</div
>
{:else}
This workspace is <b>NOT</b> on a team plan. Users use their global free-tier quotas when
doing executions in this workspace. Upgrade to a Team or Enterprise plan to unlock unlimited
execution in this workspace.
{/if}
</div>
<div class="my-4">
<Alert type="info" title="What is an execution">
The single credit-unit is called an "execution". An execution corresponds to a single
job whose duration is less than 1s. For any additional seconds of execution, an
additional execution is accounted for. Jobs are executed on powerful cpus. Most jobs
will take less than 200ms to execute.
</Alert>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{#each Object.entries(plans) as [planTitle, planDesc]}
<div class="box p-4 text-sm flex flex-col h-full">
<h2 class="mb-4">{planTitle}</h2>
<ul class="list-disc p-4">
{#each planDesc as item}
<li class="mt-2">{@html item}</li>
{/each}
</ul>
<div class="grow" />
{#if planTitle == 'Team'}
{#if plan != 'team'}
<div class="mt-4">
<Button href="/api/w/{$workspaceStore}/workspaces/checkout"
>Upgrade to the Team plan</Button
>
</div>
{:else}
<div class="mx-auto text-lg font-semibold">You are on this plan</div>
{/if}
{:else if planTitle == 'Enterprise'}
{#if plan != 'enterprise'}
<div class="mt-4">
<Button href="/api/w/{$workspaceStore}/workspaces/checkout"
>Upgrade to the Enterprise plan</Button
>
</div>
{:else}
<div class="mx-auto text-lg font-semibold">You are on this plan</div>
{/if}
{:else if !plan}
<div class="mx-auto text-lg font-semibold">You are on the free-tier plan</div>
{:else}
<div class="mt-4 w-full">
<Button href="/api/w/{$workspaceStore}/workspaces/checkout"
>Upgrade to the {planTitle} plan</Button
>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{#if !allowedAutoDomain}
<div class="text-red-400 text-sm mb-2">{domain} domain not allowed for auto-invite</div>
{/if}
<div class="mt-20" />
<PageHeader title="Slack integration" primary={false} />
<p class="text-xs text-gray-700 my-1">
Status: {#if team_name}Connected to slack workspace <Badge>{team_name}</Badge>{:else}Not
connected{/if}
</p>
{#if team_name}
<div class="flex flex-col gap-2 max-w-sm">
<Button
size="sm"
endIcon={{ icon: faSlack }}
btnClasses="mt-2"
variant="border"
on:click={async () => {
await OauthService.disconnectSlack({
workspace: $workspaceStore ?? ''
})
loadSettings()
sendUserToast('Disconnected Slack')
}}
>
Disconnect Slack
{:else if tab == 'slack'}
<PageHeader title="Slack integration" primary={false} />
<p class="text-xs text-gray-700 my-1">
Status: {#if team_name}Connected to slack workspace <Badge>{team_name}</Badge>{:else}Not
connected{/if}
</p>
{#if team_name}
<div class="flex flex-col gap-2 max-w-sm">
<Button
size="sm"
endIcon={{ icon: faSlack }}
btnClasses="mt-2"
variant="border"
on:click={async () => {
await OauthService.disconnectSlack({
workspace: $workspaceStore ?? ''
})
loadSettings()
sendUserToast('Disconnected Slack')
}}
>
Disconnect Slack
</Button>
<Button
size="sm"
endIcon={{ icon: faScroll }}
href="/scripts/add?hub=hub%2F314%2Fslack%2Fexample_of_responding_to_a_slack_command_slack"
>
Create a script to handle slack commands
</Button>
<Button size="sm" endIcon={{ icon: faBarsStaggered }} href="/flows/add?hub=28">
Create a flow to handle slack commands
</Button>
</div>
{:else}
<Button size="sm" endIcon={{ icon: faSlack }} href="/api/oauth/connect_slack">
Connect to Slack
</Button>
<Button
size="sm"
endIcon={{ icon: faScroll }}
href="/scripts/add?hub=hub%2F314%2Fslack%2Fexample_of_responding_to_a_slack_command_slack"
{/if}
<h3 class="mt-5 text-gray-700"
>Script or flow to run on /windmill command <Tooltip>
The script or flow to be triggered when the `/windmill` command is invoked. The script or
flow chosen is passed the parameters <pre>response_url: string, text: string</pre>
respectively the url to reply directly to the trigger and the text of the command.</Tooltip
>
Create a script to handle slack commands
</Button>
<Button size="sm" endIcon={{ icon: faBarsStaggered }} href="/flows/add?hub=28">
Create a flow to handle slack commands
</h3>
<ScriptPicker
kind={Script.kind.SCRIPT}
allowFlow
bind:itemKind
bind:scriptPath
{initialPath}
on:select={editSlackCommand}
/>
{:else if tab == 'export_delete'}
<div class="mt-10" />
<PageHeader title="Export workspace" primary={false} />
<div class="flex justify-start">
<Button size="sm" href="/api/w/{$workspaceStore ?? ''}/workspaces/tarball" target="_blank">
Export workspace as tarball
</Button>
</div>
{:else}
<Button size="sm" endIcon={{ icon: faSlack }} href="/api/oauth/connect_slack">
Connect to Slack
</Button>
{/if}
<h3 class="mt-5 text-gray-700"
>Script or flow to run on /windmill command <Tooltip>
The script or flow to be triggered when the `/windmill` command is invoked. The script or
flow chosen is passed the parameters <pre>response_url: string, text: string</pre>
respectively the url to reply directly to the trigger and the text of the command.</Tooltip
>
</h3>
<ScriptPicker
kind={Script.kind.SCRIPT}
allowFlow
bind:itemKind
bind:scriptPath
{initialPath}
on:select={editSlackCommand}
/>
<div class="mt-10" />
<PageHeader title="Export workspace" primary={false} />
<div class="flex justify-start">
<Button size="sm" href="/api/w/{$workspaceStore ?? ''}/workspaces/tarball" target="_blank">
Export workspace as tarball
</Button>
</div>
<div class="mt-20" />
<PageHeader title="Delete workspace" primary={false} />
<p class="italic text-xs">
The workspace will be archived for a short period of time and then permanently deleted
</p>
{#if $workspaceStore === 'admins' || $workspaceStore === 'starter'}
<div class="mt-20" />
<PageHeader title="Delete workspace" primary={false} />
<p class="italic text-xs">
This workspace cannot be deleted as it has a special function. Consult the documentation for
more information.
The workspace will be archived for a short period of time and then permanently deleted
</p>
{/if}
<div class="flex gap-2">
<Button
color="red"
disabled={$workspaceStore === 'admins' || $workspaceStore === 'starter'}
size="sm"
btnClasses="mt-2"
on:click={async () => {
await WorkspaceService.archiveWorkspace({ workspace: $workspaceStore ?? '' })
sendUserToast(`Archived workspace ${$workspaceStore}`)
workspaceStore.set(undefined)
usersWorkspaceStore.set(undefined)
goto('/user/workspaces')
}}
>
Archive workspace
</Button>
{#if $superadmin}
{#if $workspaceStore === 'admins' || $workspaceStore === 'starter'}
<p class="italic text-xs">
This workspace cannot be deleted as it has a special function. Consult the documentation
for more information.
</p>
{/if}
<div class="flex gap-2">
<Button
color="red"
disabled={$workspaceStore === 'admins' || $workspaceStore === 'starter'}
size="sm"
btnClasses="mt-2"
on:click={async () => {
await WorkspaceService.deleteWorkspace({ workspace: $workspaceStore ?? '' })
sendUserToast(`Deleted workspace ${$workspaceStore}`)
await WorkspaceService.archiveWorkspace({ workspace: $workspaceStore ?? '' })
sendUserToast(`Archived workspace ${$workspaceStore}`)
workspaceStore.set(undefined)
usersWorkspaceStore.set(undefined)
goto('/user/workspaces')
}}
>
Delete workspace (superadmin)
Archive workspace
</Button>
{/if}
</div>
{#if $superadmin}
<Button
color="red"
disabled={$workspaceStore === 'admins' || $workspaceStore === 'starter'}
size="sm"
btnClasses="mt-2"
on:click={async () => {
await WorkspaceService.deleteWorkspace({ workspace: $workspaceStore ?? '' })
sendUserToast(`Deleted workspace ${$workspaceStore}`)
workspaceStore.set(undefined)
usersWorkspaceStore.set(undefined)
goto('/user/workspaces')
}}
>
Delete workspace (superadmin)
</Button>
{/if}
</div>
{/if}
{:else}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4" role="alert">
<p class="font-bold">Not an admin</p>