mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-12 08:05:44 +00:00
team plans billing threshold alert (#5078)
* team plans billing threshold alert * nits * tmp ee ref * update ee ref
This commit is contained in:
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO cloud_workspace_settings (workspace_id, threshold_alert_amount) VALUES ($1, $2) ON CONFLICT (workspace_id) DO UPDATE SET threshold_alert_amount = $2, last_alert_sent = NULL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Int4"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c4e0f3eab227a798d9cd7478db50a7c1a588e69c2c8d9a1def9276ba326453d2"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT threshold_alert_amount, last_alert_sent FROM cloud_workspace_settings WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "threshold_alert_amount",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "last_alert_sent",
|
||||
"type_info": "Timestamp"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "f74589aaa72d0eb9f44157cc60eb121e63a7f31ed849834bb8ac9e8c726e81f1"
|
||||
}
|
||||
+12
-6
@@ -1,30 +1,35 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT premium, usage.usage as \"usage?\", workspace_settings.customer_id, workspace_settings.plan, workspace_settings.automatic_billing FROM workspace LEFT JOIN workspace_settings ON workspace_settings.workspace_id = $1 LEFT JOIN usage ON usage.id = $1 AND month_ = EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date) AND usage.is_workspace IS true WHERE workspace.id = $1",
|
||||
"query": "SELECT owner, premium, usage.usage as \"usage?\", workspace_settings.customer_id, workspace_settings.plan, workspace_settings.automatic_billing FROM workspace LEFT JOIN workspace_settings ON workspace_settings.workspace_id = $1 LEFT JOIN usage ON usage.id = $1 AND month_ = EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date) AND usage.is_workspace IS true WHERE workspace.id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "owner",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "premium",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"ordinal": 2,
|
||||
"name": "usage?",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"ordinal": 3,
|
||||
"name": "customer_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"ordinal": 4,
|
||||
"name": "plan",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"ordinal": 5,
|
||||
"name": "automatic_billing",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
@@ -35,6 +40,7 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
@@ -42,5 +48,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "49a8fe5c538d8fc26f6d8ece5b30dd02a128f571039714e61525e2bb86591d93"
|
||||
"hash": "f8f893fb4f6f8c16bffb233a585df0b8c007c24993979fdc013aa57583905f50"
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
fb5e285c316f10fa2188318585573c7e85999784
|
||||
ddcfdfc18a9833a5fc4e62ad62a265ef1e06a0aa
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS cloud_workspace_settings;
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS cloud_workspace_settings (
|
||||
workspace_id VARCHAR(50) NOT NULL,
|
||||
threshold_alert_amount INT NULL,
|
||||
last_alert_sent TIMESTAMP NULL,
|
||||
PRIMARY KEY (workspace_id),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE
|
||||
)
|
||||
@@ -1812,10 +1812,12 @@ paths:
|
||||
type: number
|
||||
automatic_billing:
|
||||
type: boolean
|
||||
owner:
|
||||
type: string
|
||||
required:
|
||||
- premium
|
||||
- automatic_billing
|
||||
|
||||
- owner
|
||||
/w/{workspace}/workspaces/set_automatic_billing:
|
||||
post:
|
||||
summary: set automatic billing
|
||||
@@ -1847,6 +1849,55 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
|
||||
/w/{workspace}/workspaces/threshold_alert:
|
||||
get:
|
||||
summary: get threshold alert info
|
||||
operationId: getThresholdAlert
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
|
||||
responses:
|
||||
"200":
|
||||
description: status
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
threshold_alert_amount:
|
||||
type: number
|
||||
last_alert_sent:
|
||||
type: string
|
||||
format: date-time
|
||||
post:
|
||||
summary: set threshold alert info
|
||||
operationId: setThresholdAlert
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
description: threshold alert info
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
threshold_alert_amount:
|
||||
type: number
|
||||
|
||||
responses:
|
||||
"200":
|
||||
description: status
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/edit_slack_command:
|
||||
post:
|
||||
summary: edit slack command
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
export let value = typeof initialValue === 'string' ? parseInt(initialValue) : initialValue
|
||||
export let disabled: boolean = false
|
||||
export let defaultValue: number | undefined = undefined
|
||||
export let format: (value: number) => string = (v) => `${v}`
|
||||
export let hideInput: boolean = false
|
||||
|
||||
let step: number = 1
|
||||
|
||||
@@ -23,10 +25,6 @@
|
||||
|
||||
$: axisStep = calculateAxisStep(min, max)
|
||||
|
||||
const format = (v, i, p) => {
|
||||
return `${v}`
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (disabled) return
|
||||
|
||||
@@ -85,7 +83,9 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<input bind:value type="number" class="!w-16 h-8 !text-xs mb-6" {max} {min} {disabled} />
|
||||
{#if !hideInput}
|
||||
<input bind:value type="number" class="!w-16 h-8 !text-xs mb-6" {max} {min} {disabled} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$lib/base'
|
||||
import { capitalize, sendUserToast } from '$lib/utils'
|
||||
import { capitalize, pluralize, sendUserToast } from '$lib/utils'
|
||||
import DataTable from '$lib/components/table/DataTable.svelte'
|
||||
import Cell from '$lib/components/table/Cell.svelte'
|
||||
import { WorkspaceService, type User, UserService } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { Button } from '../common'
|
||||
import Tooltip from '../Tooltip.svelte'
|
||||
import { ExternalLink, Loader2 } from 'lucide-svelte'
|
||||
import { ExternalLink, Loader2, Pen, X } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import Toggle from '../Toggle.svelte'
|
||||
import Section from '../Section.svelte'
|
||||
import Range from '../Range.svelte'
|
||||
import Label from '../Label.svelte'
|
||||
import Modal from '../common/modal/Modal.svelte'
|
||||
import { slide } from 'svelte/transition'
|
||||
|
||||
export let plan: string | undefined
|
||||
export let customer_id: string | undefined
|
||||
@@ -24,9 +29,10 @@
|
||||
developerNb: number
|
||||
operatorNb: number
|
||||
seatsFromUsers: number
|
||||
seatsFromComps: number
|
||||
seatsFromExtraComps: number
|
||||
usedSeats: number
|
||||
automatic_billing: boolean
|
||||
owner: string
|
||||
}
|
||||
| undefined = undefined
|
||||
const plans = {
|
||||
@@ -54,6 +60,7 @@
|
||||
if ($workspaceStore) {
|
||||
loadPremiumInfo()
|
||||
listUsers()
|
||||
getThresholdAlert()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,16 +73,17 @@
|
||||
const developerNb = users?.filter((x) => !x.operator)?.length ?? 0
|
||||
const operatorNb = users?.filter((x) => x.operator)?.length ?? 0
|
||||
const seatsFromUsers = Math.ceil(developerNb + operatorNb / 2)
|
||||
const seatsFromComps = Math.ceil((info.usage ?? 0) / 10000)
|
||||
const usedSeats = Math.max(seatsFromUsers, seatsFromComps)
|
||||
const seatsFromExtraComps = Math.max(Math.ceil((info.usage ?? 0) / 10000) - seatsFromUsers, 0)
|
||||
const usedSeats = seatsFromUsers + seatsFromExtraComps
|
||||
premiumInfo = {
|
||||
...info,
|
||||
usage: info.usage ?? 0,
|
||||
seats: info.seats ?? 1,
|
||||
owner: info.owner,
|
||||
developerNb,
|
||||
operatorNb,
|
||||
seatsFromUsers,
|
||||
seatsFromComps,
|
||||
seatsFromExtraComps,
|
||||
usedSeats
|
||||
}
|
||||
}
|
||||
@@ -84,7 +92,6 @@
|
||||
async function setAutomaticBilling(ev) {
|
||||
try {
|
||||
billingModeLoading = true
|
||||
console.log('toggle check', ev.detail)
|
||||
await WorkspaceService.setAutomaticBilling({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
@@ -99,8 +106,69 @@
|
||||
billingModeLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
let thresholdAlert:
|
||||
| {
|
||||
threshold_alert_amount?: number
|
||||
last_alert_sent?: string
|
||||
}
|
||||
| undefined = undefined
|
||||
let newThresholdAlertAmount: number | undefined = undefined
|
||||
|
||||
let thresholdAlertOpen = false
|
||||
async function getThresholdAlert() {
|
||||
thresholdAlert = await WorkspaceService.getThresholdAlert({ workspace: $workspaceStore! })
|
||||
}
|
||||
|
||||
async function setThresholdAlert() {
|
||||
if (thresholdAlert) {
|
||||
await WorkspaceService.setThresholdAlert({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
threshold_alert_amount: newThresholdAlertAmount
|
||||
}
|
||||
})
|
||||
await getThresholdAlert()
|
||||
sendUserToast('Threshold alert updated')
|
||||
}
|
||||
}
|
||||
|
||||
let estimatedDevs = 1
|
||||
let estimatedOps = 0
|
||||
|
||||
$: estimatedSeats = estimatedDevs + Math.ceil(estimatedOps / 2)
|
||||
|
||||
let estimatedExecs = 1
|
||||
|
||||
function updateExecs() {
|
||||
if (estimatedExecs < estimatedSeats) {
|
||||
estimatedExecs = estimatedSeats
|
||||
}
|
||||
}
|
||||
$: estimatedSeats && updateExecs()
|
||||
</script>
|
||||
|
||||
<Modal bind:open={thresholdAlertOpen} title="Threshold alert">
|
||||
<div class="flex flex-col gap-4">
|
||||
<label class="block">
|
||||
<span class="text-secondary text-sm">Threshold amount in $</span>
|
||||
<input type="number" bind:value={newThresholdAlertAmount} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<svelte:fragment slot="actions">
|
||||
<Button
|
||||
size="sm"
|
||||
on:click={() => {
|
||||
setThresholdAlert()
|
||||
thresholdAlertOpen = false
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
</Modal>
|
||||
|
||||
<div class="flex flex-col gap-4 my-8">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class=" text-primary text-lg font-semibold">Plans</div>
|
||||
@@ -121,9 +189,9 @@
|
||||
|
||||
<div class="text-xs my-4">
|
||||
{#if premiumInfo?.premium}
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="flex flex-col">
|
||||
{#if plan}
|
||||
<div class="text-base inline font-bold leading-8 mb-2">
|
||||
<div class="text-base inline font-bold leading-8">
|
||||
Current plan: {capitalize(plan)} plan{plan === 'team' && premiumInfo.automatic_billing
|
||||
? ' (usage-based)'
|
||||
: plan === 'team'
|
||||
@@ -131,20 +199,22 @@
|
||||
: ''}
|
||||
</div>
|
||||
{#if plan === 'team'}
|
||||
<div class="flex flex-row items-center gap-2 mb-4">
|
||||
<Toggle
|
||||
checked={premiumInfo?.automatic_billing}
|
||||
options={{
|
||||
left: 'Static number of seats',
|
||||
leftTooltip:
|
||||
'You will be billed for a fixed number of seats, you have to manually adapt the number of seats in the customer portal based on your usage.',
|
||||
right: 'Automatic billing based on usage',
|
||||
rightTooltip:
|
||||
'You will be billed for the maximum number of seats used in a given billing period.'
|
||||
}}
|
||||
on:change={setAutomaticBilling}
|
||||
disabled={billingModeLoading}
|
||||
/>
|
||||
<div class="flex flex-row items-center gap-2 mt-2">
|
||||
{#if !premiumInfo?.automatic_billing}
|
||||
<Toggle
|
||||
checked={premiumInfo?.automatic_billing}
|
||||
options={{
|
||||
left: 'Static number of seats',
|
||||
leftTooltip:
|
||||
'You will be billed for a fixed number of seats, you have to manually adapt the number of seats in the customer portal based on your usage.',
|
||||
right: 'Automatic billing based on usage',
|
||||
rightTooltip:
|
||||
'You will be billed for the maximum number of seats used in a given billing period.'
|
||||
}}
|
||||
on:change={setAutomaticBilling}
|
||||
disabled={billingModeLoading}
|
||||
/>
|
||||
{/if}
|
||||
{#if billingModeLoading}
|
||||
<Loader2 class="animate-spin" />
|
||||
{/if}
|
||||
@@ -155,6 +225,54 @@
|
||||
{/if}
|
||||
|
||||
{#if plan}
|
||||
{#if premiumInfo?.automatic_billing}
|
||||
<div class="mb-4 flex flex-col gap-1.5">
|
||||
<p class="font-semibold text-sm">Billing threshold email alert</p>
|
||||
<div class="flex flex-row gap-0.5 items-center">
|
||||
<p class="text-base text-secondary mr-0.5"
|
||||
>{thresholdAlert?.threshold_alert_amount
|
||||
? thresholdAlert?.threshold_alert_amount + '$'
|
||||
: 'Not set'}</p
|
||||
>
|
||||
<Button
|
||||
on:click={() => {
|
||||
newThresholdAlertAmount = thresholdAlert?.threshold_alert_amount ?? 10
|
||||
thresholdAlertOpen = true
|
||||
}}
|
||||
size="xs"
|
||||
spacingSize="xs2"
|
||||
variant="border"
|
||||
color="light"
|
||||
iconOnly
|
||||
startIcon={{
|
||||
icon: Pen
|
||||
}}
|
||||
/>
|
||||
{#if thresholdAlert?.threshold_alert_amount}
|
||||
<Button
|
||||
on:click={() => {
|
||||
if (thresholdAlert) {
|
||||
newThresholdAlertAmount = undefined
|
||||
setThresholdAlert()
|
||||
}
|
||||
}}
|
||||
variant="border"
|
||||
size="xs"
|
||||
spacingSize="xs2"
|
||||
color="light"
|
||||
iconOnly
|
||||
startIcon={{
|
||||
icon: X
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="italic text-xs"
|
||||
>An email notification will be sent to {premiumInfo.owner} if the specified threshold amount
|
||||
is reached during a given month.</p
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="w-full">
|
||||
<DataTable>
|
||||
<tbody class="divide-y">
|
||||
@@ -187,9 +305,7 @@
|
||||
</Cell>
|
||||
</tr>
|
||||
<tr>
|
||||
<Cell first
|
||||
><div class="font-semibold">Minimum number of seats needed for users</div></Cell
|
||||
>
|
||||
<Cell first><div class="font-semibold">Number of seats for users</div></Cell>
|
||||
<Cell last numeric>
|
||||
<div class="text-base font-bold">
|
||||
u = ceil({premiumInfo.developerNb} + {premiumInfo.operatorNb}/2) = {premiumInfo.seatsFromUsers}
|
||||
@@ -204,14 +320,22 @@
|
||||
</div>
|
||||
</Cell>
|
||||
</tr>
|
||||
<tr>
|
||||
<Cell first>Included computations with users (10k per user seat)</Cell>
|
||||
<Cell last numeric>
|
||||
<div class="text-base">
|
||||
- {premiumInfo.seatsFromUsers * 10000}
|
||||
</div>
|
||||
</Cell>
|
||||
</tr>
|
||||
<tr>
|
||||
<Cell first
|
||||
><div class="font-semibold">Minimum number of seats needed for computations</div
|
||||
></Cell
|
||||
><div class="font-semibold">Number of seats for extra computations</div></Cell
|
||||
>
|
||||
<Cell last numeric>
|
||||
<div class="text-base font-bold">
|
||||
c = ceil({premiumInfo.usage} / 10 000) = {premiumInfo.seatsFromComps}
|
||||
c = ceil({Math.max(0, premiumInfo.usage - premiumInfo.seatsFromUsers * 10000)} /
|
||||
10 000) = {premiumInfo.seatsFromExtraComps}
|
||||
</div>
|
||||
</Cell>
|
||||
</tr>
|
||||
@@ -246,8 +370,7 @@
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
max(u, c) = max({premiumInfo.seatsFromUsers}, {premiumInfo.seatsFromComps}) = {premiumInfo.usedSeats}{plan ===
|
||||
'team' &&
|
||||
u + c = {premiumInfo.usedSeats}{plan === 'team' &&
|
||||
premiumInfo.usedSeats > premiumInfo.seats &&
|
||||
!premiumInfo.automatic_billing
|
||||
? ` > ${premiumInfo.seats}`
|
||||
@@ -273,6 +396,62 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Section collapsable label="Cost estimator">
|
||||
<div class="border p-4 mb-4 rounded-md" transition:slide>
|
||||
<Label label="Number of developers">
|
||||
<Range min={1} max={20} bind:value={estimatedDevs} hideInput />
|
||||
</Label>
|
||||
<Label label="Number of operators">
|
||||
<Range min={0} max={20} bind:value={estimatedOps} hideInput />
|
||||
</Label>
|
||||
<Label label="Number of executions">
|
||||
<Range
|
||||
min={estimatedSeats}
|
||||
max={30}
|
||||
bind:value={estimatedExecs}
|
||||
format={(v) => `${v * 10}k`}
|
||||
hideInput
|
||||
/>
|
||||
</Label>
|
||||
<div class="mt-4 text-base">
|
||||
<div class="flex flex-row justify-between">
|
||||
<div>Seats for users = {estimatedDevs} devs + {estimatedOps} ops / 2</div>
|
||||
<div>{pluralize(estimatedSeats, 'seat')}</div>
|
||||
</div>
|
||||
<div class="flex flex-row justify-between">
|
||||
<div
|
||||
>Extra computations = {estimatedExecs * 10}k execs - {estimatedSeats * 10}k included with
|
||||
users</div
|
||||
>
|
||||
<div
|
||||
>{(estimatedExecs - estimatedSeats) * 10}k extra execs = {pluralize(
|
||||
estimatedExecs - estimatedSeats,
|
||||
'seat'
|
||||
)}</div
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-row justify-between font-medium items-center">
|
||||
<div>Total</div>
|
||||
<div class="flex flex-col items-end">
|
||||
<div>
|
||||
{pluralize(estimatedSeats + (estimatedExecs - estimatedSeats), 'seat')} = {(estimatedSeats +
|
||||
(estimatedExecs - estimatedSeats)) *
|
||||
10}$ / month
|
||||
</div>
|
||||
<button
|
||||
class="text-xs text-blue-500 underline"
|
||||
on:click={() => {
|
||||
newThresholdAlertAmount = (estimatedSeats + (estimatedExecs - estimatedSeats)) * 10
|
||||
thresholdAlertOpen = true
|
||||
}}
|
||||
>Setup threshold email alert
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<div class="text-base font-bold leading-8 mb-2 pt-8"> All plans </div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
|
||||
Reference in New Issue
Block a user