Compare commits

...

13 Commits

Author SHA1 Message Date
Ruben Fiszel
0ffe9f95a1 progress 2023-01-21 17:31:34 +01:00
Ruben Fiszel
433ebe023b Merge branch 'main' into rf/strip2 2023-01-21 10:20:45 +01:00
Ruben Fiszel
23f77dc21d progress 2023-01-21 07:55:53 +01:00
Ruben Fiszel
fabf09f55b Merge branch 'main' into rf/strip2 2023-01-20 21:19:04 +01:00
Ruben Fiszel
ca11713a1e progress 2023-01-20 18:59:05 +01:00
Ruben Fiszel
d004e08270 progress 2023-01-20 11:47:31 +01:00
Ruben Fiszel
85bbb79d22 progress 2023-01-20 11:47:24 +01:00
Ruben Fiszel
536f9cf58f progress 2023-01-19 21:00:50 +01:00
Ruben Fiszel
d7a2323c4e progress 2023-01-19 20:24:55 +01:00
Ruben Fiszel
dbb10c8737 progress 2023-01-19 19:53:32 +01:00
Ruben Fiszel
42d02c3c90 progress 2023-01-19 17:55:52 +01:00
Ruben Fiszel
cdca022a64 Merge branch 'main' into rf/strip2 2023-01-19 13:51:03 +01:00
Ruben Fiszel
7eeffcd618 progress 2023-01-19 11:24:55 +01:00
20 changed files with 744 additions and 381 deletions

View File

@@ -0,0 +1 @@
-- Add down migration script here

View File

@@ -0,0 +1,3 @@
-- Add up migration script here
ALTER TABLE workspace_settings ADD COLUMN customer_id VARCHAR(100);
ALTER TABLE workspace_settings ADD COLUMN plan VARCHAR(40);

View File

@@ -879,6 +879,10 @@ paths:
type: string
auto_invite_operator:
type: boolean
plan:
type: string
customer_id:
type: string
/w/{workspace}/workspaces/premium_info:
get:

View File

@@ -6,7 +6,7 @@
* LICENSE-AGPL for a copy of the license.
*/
use std::sync::Arc;
use std::{str::FromStr, sync::Arc};
use crate::{
db::{UserDB, DB},
@@ -20,13 +20,14 @@ use axum::{
body::StreamBody,
extract::{Extension, Path, Query},
headers,
response::IntoResponse,
response::{IntoResponse, Redirect},
routing::{delete, get, post},
Json, Router,
};
use stripe::CustomerId;
use windmill_audit::{audit_log, ActionKind};
use windmill_common::{
error::{Error, JsonResult, Result},
error::{to_anyhow, Error, JsonResult, Result},
flows::Flow,
scripts::{Schema, Script, ScriptLang},
utils::{paginate, rd_string, require_admin, Pagination},
@@ -54,6 +55,7 @@ pub fn workspaced_service() -> Router {
.route("/tarball", get(tarball_workspace))
.route("/premium_info", get(premium_info))
.route("/checkout", get(stripe_checkout))
.route("/billing_portal", get(stripe_portal))
}
pub fn global_service() -> Router {
Router::new()
@@ -86,6 +88,8 @@ pub struct WorkspaceSettings {
pub slack_email: String,
pub auto_invite_domain: Option<String>,
pub auto_invite_operator: Option<bool>,
pub customer_id: Option<String>,
pub plan: Option<String>,
}
#[derive(FromRow, Serialize, Debug)]
@@ -205,45 +209,138 @@ async fn premium_info(
Ok(Json(row))
}
async fn stripe_checkout(authed: Authed, Extension(base_url): Extension<Arc<BaseUrl>>) {
#[cfg(feature = "enterprise")]
#[derive(Deserialize)]
struct PlanQuery {
plan: String,
}
async fn stripe_checkout(
authed: Authed,
Path(w_id): Path<String>,
Query(plan): Query<PlanQuery>,
Extension(base_url): Extension<Arc<BaseUrl>>,
) -> Result<Redirect> {
// #[cfg(feature = "enterprise")]
{
require_admin(authed.is_admin, &authed.username)?;
let client = stripe::Client::new(std::env::var("STRIPE_KEY").expect("STRIPE_KEY"));
let success_rd = format!(
"{}/workspace_settings?session={{CHECKOUT_SESSION_ID}}",
base_url.0
);
let failure_rd = format!("{}/workspace_settings", base_url.0);
let success_rd = format!("{}/workspace_settings/checkout?success=true", base_url.0);
let failure_rd = format!("{}/workspace_settings/checkout?success=false", base_url.0);
let checkout_session = {
let mut params = stripe::CreateCheckoutSession::new(&failure_rd, &success_rd);
params.mode = Some(stripe::CheckoutSessionMode::Subscription);
params.line_items = Some(vec![
stripe::CreateCheckoutSessionLineItems {
quantity: None,
price: Some("price_1MQzMHGU3NdFi9eLWFC7IXEv".to_string()),
..Default::default()
},
stripe::CreateCheckoutSessionLineItems {
quantity: None,
price: Some("price_1MR2BZGU3NdFi9eLNRuibxPx".to_string()),
..Default::default()
},
]);
params.line_items = match plan.plan.as_str() {
"team" => Some(vec![
stripe::CreateCheckoutSessionLineItems {
quantity: None,
price: Some("price_1MSdSyGU3NdFi9eLMdV6cS6F".to_string()),
..Default::default()
},
stripe::CreateCheckoutSessionLineItems {
quantity: None,
price: Some("price_1MShsNGU3NdFi9eLJMEZUW8b".to_string()),
..Default::default()
},
]),
"enterprise" => Some(vec![
stripe::CreateCheckoutSessionLineItems {
quantity: None,
price: Some("price_1MSdf6GU3NdFi9eLJFRkntlx".to_string()),
..Default::default()
},
stripe::CreateCheckoutSessionLineItems {
quantity: None,
price: Some("price_1MShsNGU3NdFi9eLJMEZUW8b".to_string()),
..Default::default()
},
]),
_ => Err(Error::BadRequest("invalid plan".to_string()))?,
};
params.customer_email = Some(&authed.email);
params.client_reference_id = Some("foo");
params.client_reference_id = Some(&w_id);
stripe::CheckoutSession::create(&client, params)
.await
.unwrap()
};
println!(
"created a {} at {}",
checkout_session.payment_status,
checkout_session.url.unwrap()
);
let uri = checkout_session
.url
.ok_or_else(|| Error::InternalErr(format!("stripe checkout redirect issue")))?;
Ok(Redirect::to(&uri))
}
}
async fn stripe_portal(
authed: Authed,
Path(w_id): Path<String>,
Extension(db): Extension<DB>,
Extension(base_url): Extension<Arc<BaseUrl>>,
) -> Result<Redirect> {
require_admin(authed.is_admin, &authed.username)?;
let customer_id = sqlx::query_scalar!(
"SELECT customer_id FROM workspace_settings WHERE workspace_id = $1",
w_id
)
.fetch_one(&db)
.await?
.ok_or_else(|| Error::InternalErr(format!("no customer id for workspace {}", w_id)))?;
let client = stripe::Client::new(std::env::var("STRIPE_KEY").expect("STRIPE_KEY"));
let success_rd = format!("{}/workspace_settings?tab=premium", base_url.0);
let portal_session = {
let customer_id = CustomerId::from_str(&customer_id).unwrap();
let mut params = stripe::CreateBillingPortalSession::new(customer_id);
params.return_url = Some(&success_rd);
stripe::BillingPortalSession::create(&client, params)
.await
.map_err(to_anyhow)?
};
Ok(Redirect::to(&portal_session.url))
}
// async fn stripe_usage(
// authed: Authed,
// Path(w_id): Path<String>,
// Extension(db): Extension<DB>,
// Extension(base_url): Extension<Arc<BaseUrl>>,
// ) -> Result<Redirect> {
// require_admin(authed.is_admin, &authed.username)?;
// let customer_id = sqlx::query_scalar!(
// "SELECT customer_id FROM workspace_settings WHERE workspace_id = $1",
// w_id
// )
// .fetch_one(&db)
// .await?
// .ok_or_else(|| Error::InternalErr(format!("no customer id for workspace {}", w_id)))?;
// let client = stripe::Client::new(std::env::var("STRIPE_KEY").expect("STRIPE_KEY"));
// let success_rd = format!("{}/workspace_settings?tab=premium", base_url.0);
// let portal_session = {
// let customer_id = CustomerId::from_str(&customer_id).unwrap();
// let subscriptions = stripe::Subscription::list(
// &client,
// stripe::ListSubscriptions { customer: Some(customer_id), ..Default::default() },
// )
// .await
// .map_err(to_anyhow)?
// .data[0];
// let getUsage =
// stripe::SubscriptionItem::list(
// &client,
// stripe::ListSubscriptionItems {
// subscription: subscription.id,
// ..Default::default()
// },
// )
// .await
// .map_err(to_anyhow)
// };
// let mut params = stripe::ListSubscriptionItems::new(customer_id);
// params.return_url = Some(&success_rd);
// stripe::BillingPortalSession::create(&client, params)
// .await
// .map_err(to_anyhow)?
// };
// }
async fn exists_workspace(
authed: Authed,
Extension(user_db): Extension<UserDB>,

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import type { Schema } from '$lib/common'
import { ScriptService, type FlowModule, type Job } from '$lib/gen'
import { ScriptService, type FlowModule, type InputTransform, type Job } from '$lib/gen'
import { getScriptByPath, sendUserToast, truncateRev } from '$lib/utils'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import RunForm from './RunForm.svelte'
@@ -8,7 +8,7 @@
import LogViewer from './LogViewer.svelte'
import DisplayResult from './DisplayResult.svelte'
import Button from './common/button/Button.svelte'
import { flowStateStore } from './flows/flowState'
import { flowStateStore, testStepStore } from './flows/flowState'
import { flowStore } from './flows/flowStore'
import { workspaceStore } from '$lib/stores'
import { Loader2 } from 'lucide-svelte'
@@ -21,7 +21,18 @@
let testIsLoading = false
let testJob: Job | undefined = undefined
let stepArgs: Record<string, any> = {}
let stepArgs: Record<string, any> | undefined =
$testStepStore[mod.id] ??
Object.entries(mod.value['input_transforms'] ?? {}).reduce((acc, [k, v]) => {
let t = v as InputTransform
if (t.type == 'static') {
acc[k] = t.value
return acc
}
return acc
}, {})
$: $testStepStore[mod.id] = stepArgs
export function runTestWithStepArgs() {
runTest(stepArgs)

View File

@@ -18,6 +18,7 @@ export type FlowState = Record<string, FlowModuleState>
* We also hold the data of the results of a test job, ran by the user.
*/
export const flowStateStore = writable<FlowState>({})
export const testStepStore = writable<Record<string, any>>({})
export async function initFlowState(flow: Flow) {
const modulesState: FlowState = {}

View File

@@ -43,7 +43,7 @@
{#if retry}
<Popover notClickable>
<div
transition:fade={{duration: 200}}
transition:fade={{ duration: 200 }}
class="center-center bg-white rounded border border-gray-400 text-gray-700 px-1 py-0.5"
>
<Repeat size={14} />
@@ -54,7 +54,7 @@
{#if earlyStop}
<Popover notClickable>
<div
transition:fade={{duration: 200}}
transition:fade={{ duration: 200 }}
class="center-center bg-white rounded border border-gray-400 text-gray-700 px-1 py-0.5"
>
<Square size={14} />
@@ -65,7 +65,7 @@
{#if suspend}
<Popover notClickable>
<div
transition:fade={{duration: 200}}
transition:fade={{ duration: 200 }}
class="center-center bg-white rounded border border-gray-400 text-gray-700 px-1 py-0.5"
>
<PhoneIncoming size={14} />
@@ -76,7 +76,7 @@
{#if sleep}
<Popover notClickable>
<div
transition:fade={{duration: 200}}
transition:fade={{ duration: 200 }}
class="center-center bg-white rounded border border-gray-400 text-gray-700 px-1 py-0.5"
>
<Bed size={14} />
@@ -103,7 +103,7 @@
class="absolute -top-2 right-0 rounded-full h-4 w-4 trash center-center
border-[1.5px] border-gray-700 bg-white duration-150 hover:bg-red-400 hover:text-white
hover:border-red-700 {selected ? '' : '!hidden'}"
on:click={(event) => dispatch('delete', event)}
on:click|preventDefault|stopPropagation={(event) => dispatch('delete', event)}
>
<X size={12} strokeWidth={2} />
</button>

View File

@@ -19,9 +19,6 @@
import FlowInputsItem from './FlowInputsItem.svelte'
import InsertModuleButton from './InsertModuleButton.svelte'
import { slide } from 'svelte/transition'
import FlowModuleSchemaItem from './FlowModuleSchemaItem.svelte'
import { Icon } from 'svelte-awesome'
import { faDollarSign } from '@fortawesome/free-solid-svg-icons'
import FlowConstantsItem from './FlowConstantsItem.svelte'
export let root: boolean = false
@@ -59,7 +56,6 @@
}
function removeAtIndex(index: number): void {
select('settings-graph')
if (!modules) return
const [removedModule] = modules.splice(index, 1)
modules = modules
@@ -67,6 +63,7 @@
const leaves = findLeaves(removedModule)
leaves.forEach((leafId: string) => deleteFlowStateById(leafId))
select('settings-graph')
}
function findLeaves(flowModule: FlowModule): string[] {

View File

@@ -34,7 +34,7 @@
role="menuitem"
tabindex="-1"
>
<Icon data={faCode} scale={0.8} class="mr-1" />
<Icon data={faCode} scale={0.8} class="mr-2" />
Action (Script)
</button>
{#if trigger}
@@ -47,12 +47,12 @@
role="menuitem"
tabindex="-1"
>
<Icon data={faBolt} scale={0.8} class="mr-1" />
<Icon data={faBolt} scale={0.8} class="mr-2" />
Trigger (Script)
</button>
{/if}
<button
class="w-full text-left p-2 hover:bg-gray-100"
class="w-full text-left gap-1 p-2 hover:bg-gray-100"
on:click={() => {
close()
dispatch('new', 'approval')
@@ -60,18 +60,18 @@
role="menuitem"
tabindex="-1"
>
<Icon data={faCheck} scale={0.8} class="mr-1" />
<Icon data={faCheck} class="mr-1.5" scale={0.8} />
Approval (Script)
</button>
<button
class="w-full inline-flex gap-1 text-left p-2 hover:bg-gray-100"
class="w-full inline-flex text-left p-2 hover:bg-gray-100"
on:click={() => {
close()
dispatch('new', 'forloop')
}}
role="menuitem"
>
<span>
<span class="mr-2">
<Repeat size={14} />
</span>
@@ -86,7 +86,7 @@
}}
role="menuitem"
>
<Icon data={faCodeBranch} scale={0.8} class="mr-1" />
<Icon data={faCodeBranch} scale={0.8} class="mr-2" />
Branch to one
</button>
@@ -98,7 +98,7 @@
}}
role="menuitem"
>
<Icon data={faCodeBranch} scale={0.8} class="mr-1" />
<Icon data={faCodeBranch} scale={0.8} class="mr-2" />
Branch to all
</button>
@@ -110,7 +110,7 @@
}}
role="menuitem"
>
<Icon data={faBarsStaggered} scale={0.8} class="mr-1" />
<Icon data={faBarsStaggered} scale={0.8} class="mr-2" />
Flow
</button>
</div>

View File

@@ -92,10 +92,10 @@ export async function loadSchemaFromModule(module: FlowModule): Promise<{
if (JSON.stringify(keys.sort()) !== JSON.stringify(Object.keys(input_transforms).sort())) {
input_transforms = keys.reduce((accu, key) => {
let nv = input_transforms[key] ?? (module.id == 'failure' && ['message', 'name'].includes(key)) ? { type: 'javascript', expr: `error.${key}` } : {
let nv = input_transforms[key] ?? ((module.id == 'failure' && ['message', 'name'].includes(key)) ? { type: 'javascript', expr: `error.${key}` } : {
type: 'static',
value: undefined
}
})
accu[key] = nv
return accu
}, {})

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { pluralize, truncate } from '$lib/utils'
import { copyToClipboard, pluralize, truncate } from '$lib/utils'
import { createEventDispatcher } from 'svelte'
import { Badge } from '../common'
@@ -38,7 +38,10 @@
const dispatch = createEventDispatcher()
function selectProp(key: string) {
function selectProp(key: string, value: any) {
if (pureViewer) {
copyToClipboard(value)
}
dispatch('select', rawKey ? key : computeKey(key, isArray, currentPath))
}
</script>
@@ -58,7 +61,7 @@
<ul class="w-full">
{#each keys as key, index}
<li class="pt-1">
<button on:click={() => selectProp(key)} class="whitespace-nowrap">
<button on:click={() => selectProp(key, key)} class="whitespace-nowrap">
{#if topLevelNode}
<Badge baseClass="border border-blue-600" color="indigo">{key}</Badge>
{:else}
@@ -86,7 +89,7 @@
class="val {pureViewer
? 'cursor-auto'
: ''} rounded hover:bg-blue-100 {getTypeAsString(json[key])}"
on:click={() => selectProp(key)}
on:click={() => selectProp(key, json[key])}
>
{#if json[key] === NEVER_TESTED_THIS_FAR}
<WarningMessage />

View File

@@ -1,15 +1,17 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { logout } from '$lib/logout'
import { userStore, usersWorkspaceStore, superadmin, usageStore } from '$lib/stores'
import { classNames, isCloudHosted } from '$lib/utils'
import {
faAngleDoubleDown,
faCrown,
faHardHat,
faPlay,
faUser
} from '@fortawesome/free-solid-svg-icons'
userStore,
usersWorkspaceStore,
superadmin,
usageStore,
workspaceStore,
premiumStore
} from '$lib/stores'
import { classNames, isCloudHosted } from '$lib/utils'
import { faCrown, faHardHat, faUser } from '@fortawesome/free-solid-svg-icons'
import { createEventDispatcher } from 'svelte'
import Icon from 'svelte-awesome'
@@ -19,7 +21,7 @@
export let isCollapsed: boolean = false
</script>
<Menu placement="bottom-start">
<Menu let:close placement="bottom-start">
<button
slot="trigger"
type="button"
@@ -95,11 +97,43 @@
</button>
</div>
{#if isCloudHosted()}
<div class="py-1" role="none">
<span class="text-gray-700 block w-full text-left px-4 py-2 text-sm"
>{$usageStore}/1000 free-tier executions</span
>
</div>
{#if !$premiumStore.premium}
<div class="py-1" role="none">
<span class="text-gray-700 block w-full text-left px-4 py-2 text-sm"
>{$usageStore}/1000 free-tier executions</span
>
<div class="w-full bg-gray-200 h-1">
<div class="bg-blue-400 h-1" style="width: {Math.min($usageStore, 1000) / 10}%" />
</div>
{#if $userStore?.is_admin}
<button
type="button"
class="text-gray-700 block font-normal w-full text-left px-4 py-2 text-sm hover:bg-gray-100 hover:text-gray-900"
role="menuitem"
tabindex="-1"
on:click={() => {
close()
goto('/workspace_settings?tab=premium')
}}
>
Upgrade
</button>
{/if}
</div>
{:else}
<div class="py-1" role="none">
<button
type="button"
class="text-gray-700 block font-normal w-full text-left px-4 py-2 text-sm hover:bg-gray-100 hover:text-gray-900"
role="menuitem"
tabindex="-1"
on:click={() => {
close()
goto('/workspace_settings?tab=premium')
}}>Premium plan</button
>
</div>
{/if}
{/if}
</div>
</Menu>

View File

@@ -2,7 +2,8 @@ import { browser } from "$app/environment";
import { derived, type Readable, writable } from "svelte/store";
import type { UserWorkspaceList } from "$lib/gen/models/UserWorkspaceList.js";
import { getUserExt } from "./user";
import type { TokenResponse } from "./gen";
import { WorkspaceService, type TokenResponse } from "./gen";
import { isCloudHosted } from "./utils";
export interface UserExt {
email: string;
@@ -24,6 +25,7 @@ export const userStore = writable<UserExt | undefined>(undefined);
export const workspaceStore = writable<string | undefined>(
persistedWorkspace ? String(persistedWorkspace) : undefined,
);
export const premiumStore = writable<{ premium: boolean, usage?: number }>({ premium: false });
export const starStore = writable(1);
export const usersWorkspaceStore = writable<UserWorkspaceList | undefined>(
undefined,
@@ -72,6 +74,9 @@ if (browser) {
}
userStore.set(await getUserExt(workspace));
if (isCloudHosted()) {
premiumStore.set((await WorkspaceService.getPremiumInfo({ workspace })));
}
} else {
userStore.set(undefined);
}

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')
}
export function isObject(obj: any) {

View File

@@ -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,32 @@
)
)
}
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/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>10k</b> executions.`,
`<div class="text-lg mt-4"><b>10k executions/user</b></div>`,
`$0.001 per additional execution (1$ per 1000 executions)`
],
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.`,
`<b>Dedicated workers and database</b>`,
`<b>SAML support</b>`,
`<b>Priority support including an automation engineer</b>`,
`<div class="text-lg mt-4"><b>50k executions/user</b><div>`,
`$0.001 per additional execution (1$ per 1000 executions)`
]
}
</script>
<SearchItems
@@ -137,357 +171,475 @@
/>
<CenteredPage>
{#if $userStore?.is_admin}
{#if $userStore?.is_admin || $superadmin}
<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) => {
if (is_admin && e.detail != 'admin') {
sendUserToast(
'Admins cannot be demoted by themselves, ask another admin to demote you',
true
)
e.preventDefault()
listUsers()
return
}
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-4" />
{#if customer_id}
<div class="mt-2 mb-6">
<Button
endIcon={{ icon: faExternalLink }}
href="/api/w/{$workspaceStore}/workspaces/billing_portal">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}
<div class="flex flex-col gap-1">
<div
>Current plan: <div class=" inline text-2xl font-bold">{plan ?? 'Free plan'}</div
></div
>
{#if plan}
{@const team_factor = plan == 'team' ? 10 : 50}
{@const max = (users?.length ?? 0) * team_factor}
<div>
Current number of seats in this workspace:
<div class="inline text-2xl font-bold"
>{users?.length ?? 0} * ${team_factor} = ${(users?.length ?? 0) *
team_factor}/mo</div
>
<Tooltip
>Actual pricing is calculated on the MAXIMUM number of users in a given billing
period, see the customer portal for more info.</Tooltip
>
</div>
<div>
Included number of executions based on your seats and plan:
<div class=" inline text-2xl font-bold"
>{users?.length ?? 0} seats x {plan == 'team' ? '10k' : '50k'} = {max}k
</div>
</div>
{/if}
</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 overflow-hidden">
<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 mx-auto">
<Button disabled href="/api/w/{$workspaceStore}/workspaces/checkout?plan=team"
>Upgrade to the Team plan (Coming soon)</Button
>
</div>
{:else}
<div class="mx-auto text-lg font-semibold">Workspace is on the team plan</div>
{/if}
{:else if planTitle == 'Enterprise'}
{#if plan != 'enterprise'}
<div class="mt-4 mx-auto">
<Button
on:click={() =>
sendUserToast(
'Contact contact@windmill.dev to have your dedicated instance provisioned'
)}>Upgrade to the Enterprise plan</Button
>
</div>
{:else}
<div class="mx-auto text-lg font-semibold">Workspace is on enterprise plan</div>
{/if}
{:else if !plan}
<div class="mx-auto text-lg font-semibold">Workspace is on the free 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'}
<div class="mt-2"
><Alert type="info" title="Send commands from slack"
>Connect your windmill workspace to your slack workspace to trigger a script or a flow
with a '/windmill' command</Alert
></div
>
<p class="text-xs text-gray-700 my-1 mt-2">
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'}
<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>

View File

@@ -0,0 +1,5 @@
export function load() {
return {
stuff: { title: 'Checkout callback' }
}
}

View File

@@ -0,0 +1,50 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import CenteredModal from '$lib/components/CenteredModal.svelte'
import { Alert } from '$lib/components/common'
import { WindmillIcon } from '$lib/components/icons'
import { WorkspaceService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/utils'
let success = $page.url.searchParams.get('success') === 'true'
let attempt = 0
if (!success) {
setTimeout(() => {
goto('/workspace_settings?tab=premium')
}, 5000)
} else {
let interval = setInterval(async () => {
attempt += 1
if ((await WorkspaceService.getSettings({ workspace: $workspaceStore! })).customer_id) {
goto('/workspace_settings?tab=premium')
} else if (attempt > 10) {
sendUserToast('Subscription upgrade failed. Contact contact@windmill.dev', true)
clearInterval(interval)
goto('/workspace_settings?tab=premium')
}
}, 5000)
}
</script>
<!-- svelte-ignore missing-declaration -->
<CenteredModal title="Subscription upgrade {success ? 'succeeded' : 'failed'}">
{#if !success}
<div class="my-2">
<Alert type="error" title="Checkout failed">
The checkout failed, your subscription has not been updated.
</Alert>
</div>
<p class="text-sm my-6 text-gray-600">
You will be redirected to the workspace settings page in 5 seconds...
</p>
{:else}
<p class="text-sm my-6 text-gray-600"> Waiting for your upgrade to be processed... </p>
{/if}
<div class="block m-auto w-20">
<WindmillIcon class="animate-[spin_6s_linear_infinite]" height="80px" width="80px" />
</div>
</CenteredModal>