Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ffe9f95a1 | ||
|
|
433ebe023b | ||
|
|
23f77dc21d | ||
|
|
fabf09f55b | ||
|
|
ca11713a1e | ||
|
|
d004e08270 | ||
|
|
85bbb79d22 | ||
|
|
536f9cf58f | ||
|
|
d7a2323c4e | ||
|
|
dbb10c8737 | ||
|
|
42d02c3c90 | ||
|
|
cdca022a64 | ||
|
|
7eeffcd618 |
1
backend/migrations/20230119194229_customer_id.down.sql
Normal file
1
backend/migrations/20230119194229_customer_id.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
-- Add down migration script here
|
||||
3
backend/migrations/20230119194229_customer_id.up.sql
Normal file
3
backend/migrations/20230119194229_customer_id.up.sql
Normal 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);
|
||||
@@ -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:
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}, {})
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export function load() {
|
||||
return {
|
||||
stuff: { title: 'Checkout callback' }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
0
frontend/vite.config.js.timestamp-1674159088568.mjs
Normal file
0
frontend/vite.config.js.timestamp-1674159088568.mjs
Normal file
0
frontend/vite.config.js.timestamp-1674159119132.mjs
Normal file
0
frontend/vite.config.js.timestamp-1674159119132.mjs
Normal file
0
frontend/vite.config.js.timestamp-1674159133038.mjs
Normal file
0
frontend/vite.config.js.timestamp-1674159133038.mjs
Normal file
Reference in New Issue
Block a user