mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 16:02:19 +00:00
9c6cd8c852
* [ee] feat(license): offline (URL-bound) license keys Offline keys are a 4-segment variant for air-gapped customers — no phone-home, embedded seat/CU caps, locked to the instance's base_url. Existing 3-segment online keys are unchanged. Companion PRs: - windmill-labs/windmill-ee-private (full design + EE impl) - windmill-labs/windmill-customer-service (issuance + portal) - windmill-labs/windmill-cf-worker-keygen (signing) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [ee] refactor(license): bind offline keys via instance hash; simpler CU enforcement - /settings/license_status now surfaces an `instance_hash` superadmins share with support when requesting an offline key - OfflineMetadata: `hash` replaces `base_url`; OfflineCapStatus reports `current_cu` (last 2min) and drops the grace-period fields - verify_license_key now takes a db so EE can recheck the hash - InstanceSetting.svelte: hash copy-block + simpler status panel - Bump ee-repo-ref Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [ee] chore(license): bump ee-repo-ref Pulls in the current_cu clamp + prod public key restoration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [ee] refactor(license): split instance_hash endpoint; minimal cap UI; restore workers expiry toast - `instance_hash` is no longer part of /settings/license_status responses; it lives at GET /settings/instance_hash (super-admin only) so it isn't re-emitted on every status poll. The UI doesn't show it — admins fetch it explicitly when requesting a key from support. - InstanceSetting offline cap UI is now two compact green/red status lines (Seats X.X/Y and CUs X.X/Y) placed above the action buttons, matching the existing "Latest key renewal" badge style. The block-panel is gone. - "Latest key renewal" line and the "Renew key" button are now hidden when an offline key is loaded (renewal is server-disabled for offline keys). - Restore parseLicenseKey + checkLicenseExpiration toast on /workers (works for both 3- and 4-segment keys). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [ee] chore(license): bump ee-repo-ref Pulls in the plain-SHA256 instance hash + stats_ee revert. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [ee] chore(license): bump ee-repo-ref Picks up the alert wording change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [ee] chore(license): bump ee-repo-ref Picks up the instance_uid cache so the periodic verify_license_key cycle no longer hits global_settings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [ee] refactor(license): rename /settings/license_status → /offline_license_status The endpoint was only used by the offline-license UI; the other fields it returned (license_key_id, license_key_valid, kind, offline metadata) were unused. Rename to clarify scope and flatten the response — it now returns just the OfflineCapStatus (or null when no offline license is loaded). Frontend uses `offlineCapStatus != null` as the "is offline" check. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [ee] fix(ci): regenerate sqlx cache for the inline worker_ping query After reverting unused stats_ee helpers (fetch_worker_pings*), the inline `sqlx::query_as!(WorkerPingRecord, ...)` in get_stats_payload lost its cache entry — CI's check_ee_full + cargo_test were failing under SQLX_OFFLINE=true with E0282 type-inference errors. Re-running update_sqlx.sh regenerates the cache file under its current hash and prunes a couple of stale entries. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [ee] fix(license): address cubic-bot review - get_offline_license_status: propagate enforce_offline_caps errors as 500 instead of swallowing into a "no offline license" (Option::None) response - canonical_base_url: rewrite the doc to match the actual fallback behavior (lowercase + trailing-slash strip on URL parse failure); the original cross-service contract is gone since the customer-service no longer canonicalizes (treats the instance hash as opaque) - check_seat_cap_for_new_user: take an email and short-circuit when the email is already in `usr ∪ workspace_invite` so net-zero invite upserts and invite→user transitions aren't spuriously blocked at cap. Mirrors the dedup rule the count itself uses. - Bump ee-repo-ref to pull in the EE-side change Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [ee] chore(license): bump ee-repo-ref Picks up the exact-delta seat-cap check (replaces the simple existence short-circuit). Regenerates the new sqlx cache for the bool_and query. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [ee] fix(license): propagate get_instance_hash errors; bump ee-repo-ref - get_instance_hash: replace `.ok().flatten()` with map_err+? so DB errors during instance_uid lookup surface as 500 instead of silently returning `{"instance_hash": null}` (same pattern get_offline_license_status already uses) - Bump ee-repo-ref to pull in the enforce_offline_caps cached-state preservation Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref to c6cd1afe2d9e04809b30751cd1687b28a65e62b1 This commit updates the EE repository reference after PR #566 was merged in windmill-ee-private. Previous ee-repo-ref: a6d91016ae0d43c46604313aecae3aa9c778c8e0 New ee-repo-ref: c6cd1afe2d9e04809b30751cd1687b28a65e62b1 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Ruben Fiszel <ruben@windmill.dev> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
776 lines
24 KiB
Svelte
776 lines
24 KiB
Svelte
<script lang="ts">
|
|
import { isCloudHosted } from '$lib/cloud'
|
|
import { enterpriseLicense, isCriticalAlertsUIOpen } from '$lib/stores'
|
|
import { AlertCircle, BadgeCheck, BadgeX, Info } from 'lucide-svelte'
|
|
import type { Setting } from './instanceSettings'
|
|
import { OTEL_TRACING_PROXY_LANGUAGES } from './instanceSettings'
|
|
import { LanguageIcon } from './common/languageIcons'
|
|
import ObjectStoreConfigSettings from './ObjectStoreConfigSettings.svelte'
|
|
import { sendUserToast } from '$lib/toast'
|
|
import { ConfigService, SettingService, type ListAvailablePythonVersionsResponse } from '$lib/gen'
|
|
import { Button, SecondsInput, Skeleton } from './common'
|
|
import Password from './Password.svelte'
|
|
import { classNames } from '$lib/utils'
|
|
import Popover from './Popover.svelte'
|
|
import DropdownV2 from './DropdownV2.svelte'
|
|
import Toggle from './Toggle.svelte'
|
|
import type { Writable } from 'svelte/store'
|
|
import { createEventDispatcher, untrack } from 'svelte'
|
|
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
|
|
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
|
|
import SimpleEditor from './SimpleEditor.svelte'
|
|
import CriticalAlertChannels from './instanceSettings/CriticalAlertChannels.svelte'
|
|
import SmtpSettings from './instanceSettings/SmtpSettings.svelte'
|
|
import SecretBackendConfig from './instanceSettings/SecretBackendConfig.svelte'
|
|
import GhesAppSettings from './instanceSettings/GhesAppSettings.svelte'
|
|
import WsConnectivityTest from './instanceSettings/WsConnectivityTest.svelte'
|
|
import IndexerMemorySettings from './instanceSettings/IndexerMemorySettings.svelte'
|
|
import IndexerJobIndexSettings from './instanceSettings/IndexerJobIndexSettings.svelte'
|
|
import IndexerLogIndexSettings from './instanceSettings/IndexerLogIndexSettings.svelte'
|
|
import TextInput from './text_input/TextInput.svelte'
|
|
import SettingCard from './instanceSettings/SettingCard.svelte'
|
|
|
|
interface Props {
|
|
setting: Setting
|
|
version: string
|
|
values: Writable<Record<string, any>>
|
|
loading?: boolean
|
|
openSmtpSettings?: () => void
|
|
oauths?: Record<string, any>
|
|
warning?: string
|
|
}
|
|
|
|
let {
|
|
setting,
|
|
version,
|
|
values,
|
|
loading = true,
|
|
openSmtpSettings,
|
|
oauths,
|
|
warning
|
|
}: Props = $props()
|
|
const dispatch = createEventDispatcher()
|
|
|
|
let latestKeyRenewalAttempt: {
|
|
result: string
|
|
attempted_at: string
|
|
} | null = $state(null)
|
|
|
|
let offlineCapStatus: {
|
|
seats_used: number
|
|
seats_cap: number
|
|
author_count: number
|
|
operator_count: number
|
|
current_cu: number
|
|
cu_cap: number
|
|
cu_over_cap: boolean
|
|
} | null = $state(null)
|
|
|
|
function showSetting(setting: string, values: Record<string, any>) {
|
|
if (setting == 'dev_instance') {
|
|
if (values['license_key'] == undefined) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
let renewing = $state(false)
|
|
let opening = $state(false)
|
|
|
|
async function reloadKeyrenewalAttemptInfo() {
|
|
latestKeyRenewalAttempt = await SettingService.getLatestKeyRenewalAttempt()
|
|
}
|
|
|
|
async function reloadLicenseStatus() {
|
|
try {
|
|
offlineCapStatus = (await SettingService.getOfflineLicenseStatus()) as any
|
|
} catch {
|
|
offlineCapStatus = null
|
|
}
|
|
}
|
|
|
|
async function reloadLicenseKey() {
|
|
$values['license_key'] = await SettingService.getGlobal({
|
|
key: 'license_key'
|
|
})
|
|
}
|
|
|
|
$effect(() => {
|
|
if (setting.key == 'license_key') {
|
|
untrack(() => {
|
|
reloadKeyrenewalAttemptInfo()
|
|
reloadLicenseStatus()
|
|
})
|
|
}
|
|
})
|
|
|
|
export async function renewLicenseKey() {
|
|
renewing = true
|
|
try {
|
|
await SettingService.renewLicenseKey({
|
|
licenseKey: $values['license_key'] || undefined
|
|
})
|
|
sendUserToast('Key renewal successful')
|
|
reloadLicenseKey()
|
|
} catch (err) {
|
|
throw err
|
|
} finally {
|
|
reloadKeyrenewalAttemptInfo()
|
|
renewing = false
|
|
}
|
|
}
|
|
|
|
export async function openCustomerPortal() {
|
|
opening = true
|
|
try {
|
|
const url = await SettingService.createCustomerPortalSession({
|
|
licenseKey: $values['license_key'] || undefined
|
|
})
|
|
window.open(url, '_blank')
|
|
} finally {
|
|
opening = false
|
|
}
|
|
}
|
|
|
|
function parseLicenseKey(key: string): {
|
|
valid: boolean
|
|
expiration?: string
|
|
} {
|
|
let splitted = key.split('.')
|
|
if (splitted.length >= 3) {
|
|
try {
|
|
let i = parseInt(splitted[1])
|
|
let date = new Date(i * 1000)
|
|
const stringDate = date.toLocaleDateString()
|
|
if (stringDate !== 'Invalid Date') {
|
|
return {
|
|
valid: date.getTime() > Date.now(),
|
|
expiration: date.toLocaleDateString()
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
return {
|
|
valid: false
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
if (setting.key === 'license_key') {
|
|
const key = $values['license_key'] ?? ''
|
|
const { valid } = parseLicenseKey(key)
|
|
if (valid) {
|
|
$enterpriseLicense = key.split('.')[0]
|
|
}
|
|
}
|
|
})
|
|
|
|
let pythonAvailableVersions: ListAvailablePythonVersionsResponse = $state([])
|
|
|
|
let isPyFetching = $state(false)
|
|
async function fetch_available_python_versions() {
|
|
if (isPyFetching) return
|
|
isPyFetching = true
|
|
try {
|
|
pythonAvailableVersions = await ConfigService.listAvailablePythonVersions()
|
|
} catch (error) {
|
|
console.error('Error fetching python versions:', error)
|
|
} finally {
|
|
isPyFetching = false
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
if (setting.fieldType == 'select_python') {
|
|
untrack(() => fetch_available_python_versions())
|
|
}
|
|
})
|
|
|
|
$effect(() => {
|
|
if (
|
|
(setting.fieldType == 'select' || setting.fieldType == 'select_python') &&
|
|
$values[setting.key] == undefined &&
|
|
setting.defaultValue
|
|
) {
|
|
untrack(() => {
|
|
if (setting.defaultValue) {
|
|
$values[setting.key] = setting.defaultValue()
|
|
}
|
|
})
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<!-- {JSON.stringify($values, null, 2)} -->
|
|
{#if (!setting.cloudonly || isCloudHosted()) && showSetting(setting.key, $values) && !(setting.hiddenIfNull && $values[setting.key] == null) && !(setting.hiddenIfEmpty && !$values[setting.key]) && !(setting.hiddenInEe && $enterpriseLicense)}
|
|
{#if setting.fieldType == 'select'}
|
|
<SettingCard
|
|
label={setting.label}
|
|
description={setting.description}
|
|
ee_only={setting.ee_only}
|
|
settingKey={setting.key}
|
|
>
|
|
<ToggleButtonGroup bind:selected={$values[setting.key]}>
|
|
{#snippet children({ item: toggleButton })}
|
|
{#each setting.select_items ?? [] as item}
|
|
<ToggleButton
|
|
value={item.value ?? item.label}
|
|
label={item.label}
|
|
tooltip={item.tooltip}
|
|
item={toggleButton}
|
|
/>
|
|
{/each}
|
|
{/snippet}
|
|
</ToggleButtonGroup>
|
|
</SettingCard>
|
|
{:else if setting.fieldType == 'select_python'}
|
|
<SettingCard
|
|
label={setting.label}
|
|
description={setting.description}
|
|
ee_only={setting.ee_only}
|
|
settingKey={setting.key}
|
|
>
|
|
<ToggleButtonGroup
|
|
bind:selected={() => $values[setting.key] ?? 'default', (v) => ($values[setting.key] = v)}
|
|
>
|
|
{#snippet children({ item: toggleButton })}
|
|
{#each setting.select_items ?? [] as item}
|
|
<ToggleButton
|
|
value={item.value ?? item.label}
|
|
label={item.label}
|
|
tooltip={item.tooltip}
|
|
item={toggleButton}
|
|
/>
|
|
{/each}
|
|
|
|
<DropdownV2
|
|
items={() =>
|
|
pythonAvailableVersions.map((v) => ({
|
|
displayName: v,
|
|
action: () => {
|
|
$values[setting.key] = v
|
|
}
|
|
}))}
|
|
>
|
|
{#snippet buttonReplacement()}
|
|
{#if setting.select_items?.some((e) => e.label == $values[setting.key] || e.value == $values[setting.key])}
|
|
<Button variant="subtle" btnClasses="font-normal" nonCaptureEvent={true}
|
|
>Select Custom</Button
|
|
>
|
|
{:else}
|
|
<Button
|
|
variant="default"
|
|
btnClasses="font-normal bg-surface-input"
|
|
nonCaptureEvent={true}>Custom | {$values[setting.key]}</Button
|
|
>
|
|
{/if}
|
|
{/snippet}
|
|
</DropdownV2>
|
|
{/snippet}
|
|
</ToggleButtonGroup>
|
|
</SettingCard>
|
|
{:else if setting.fieldType == 'indexer_rates'}
|
|
{#if $values[setting.key]}
|
|
{@const fieldErrors = setting.validate?.($values[setting.key]) ?? {}}
|
|
<SettingCard
|
|
label="Memory"
|
|
description="Configure the memory budget for the indexer and manage index clearing."
|
|
ee_only=""
|
|
settingKey="indexer_settings_memory"
|
|
>
|
|
<div class="p-4 rounded-md border mt-2">
|
|
<IndexerMemorySettings {values} disabled={!$enterpriseLicense} errors={fieldErrors} />
|
|
</div>
|
|
</SettingCard>
|
|
<SettingCard
|
|
label="Completed Job Index"
|
|
description="Configure indexing parameters for completed jobs."
|
|
ee_only=""
|
|
settingKey="indexer_settings_jobs"
|
|
>
|
|
<div class="p-4 rounded-md border mt-2">
|
|
<IndexerJobIndexSettings {values} disabled={!$enterpriseLicense} errors={fieldErrors} />
|
|
</div>
|
|
</SettingCard>
|
|
<SettingCard
|
|
label="Service Logs Index"
|
|
description="Configure indexing parameters for service logs."
|
|
ee_only=""
|
|
settingKey="indexer_settings_logs"
|
|
>
|
|
<div class="p-4 rounded-md border mt-2">
|
|
<IndexerLogIndexSettings {values} disabled={!$enterpriseLicense} errors={fieldErrors} />
|
|
</div>
|
|
</SettingCard>
|
|
{/if}
|
|
{:else}
|
|
<SettingCard
|
|
label={setting.key === 'disable_stats'
|
|
? $enterpriseLicense
|
|
? 'Minimal telemetry'
|
|
: 'Disable telemetry'
|
|
: setting.fieldType != 'smtp_connect'
|
|
? setting.label
|
|
: undefined}
|
|
description={setting.key === 'disable_stats'
|
|
? $enterpriseLicense
|
|
? 'Reduces telemetry to only what is needed for license compliance (no job usage data).'
|
|
: 'Disables telemetry entirely.'
|
|
: setting.description}
|
|
ee_only={setting.ee_only}
|
|
tooltip={setting.tooltip}
|
|
settingKey={setting.key}
|
|
actionButton={setting.actionButton}
|
|
values={$values}
|
|
>
|
|
{#if $values}
|
|
{@const hasError = setting.isValid && !setting.isValid($values[setting.key])}
|
|
<div class="h-1"></div>
|
|
{#if loading}
|
|
<Skeleton layout={[[2.5]]} />
|
|
{:else if setting.fieldType == 'text'}
|
|
<TextInput
|
|
inputProps={{
|
|
type: 'text',
|
|
id: setting.key,
|
|
disabled: setting.ee_only != undefined && !$enterpriseLicense,
|
|
placeholder: setting.placeholder
|
|
}}
|
|
bind:value={$values[setting.key]}
|
|
class="max-w-lg"
|
|
/>
|
|
{#if warning}
|
|
<span class="text-yellow-600 dark:text-yellow-500 text-2xs">
|
|
{warning}
|
|
</span>
|
|
{/if}
|
|
{#if setting.advancedToggle}
|
|
<div class="mt-1">
|
|
<Toggle
|
|
size="xs"
|
|
options={{ right: setting.advancedToggle.label }}
|
|
checked={setting.advancedToggle.checked($values)}
|
|
on:change={() => {
|
|
if (setting.advancedToggle) {
|
|
$values = setting.advancedToggle.onChange($values)
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
{:else if setting.fieldType == 'textarea'}
|
|
<textarea
|
|
id={setting.key}
|
|
disabled={!$enterpriseLicense}
|
|
rows="2"
|
|
placeholder={setting.placeholder}
|
|
bind:value={$values[setting.key]}
|
|
></textarea>
|
|
{#if setting.key == 'saml_metadata'}
|
|
<div class="flex mt-2">
|
|
<Button
|
|
disabled={!$enterpriseLicense}
|
|
on:click={async (e) => {
|
|
try {
|
|
const res = await SettingService.testMetadata({
|
|
requestBody: $values[setting.key]
|
|
})
|
|
sendUserToast(`Metadata valid: ${res}`)
|
|
} catch (error) {
|
|
sendUserToast(`Invalid metadata`, true, error.message)
|
|
}
|
|
}}>Test content/url</Button
|
|
>
|
|
</div>
|
|
{/if}
|
|
{:else if setting.fieldType == 'codearea'}
|
|
<SimpleEditor
|
|
autoHeight
|
|
class="editor"
|
|
lang={setting.codeAreaLang ?? 'txt'}
|
|
bind:code={$values[setting.key]}
|
|
fixedOverflowWidgets={false}
|
|
/>
|
|
{:else if setting.fieldType == 'license_key'}
|
|
{@const { valid, expiration } = parseLicenseKey($values[setting.key] ?? '')}
|
|
<div class="flex gap-2">
|
|
<Password
|
|
id={setting.key}
|
|
small
|
|
placeholder={setting.placeholder}
|
|
onBlur={() => {
|
|
if ($values[setting.key] && typeof $values[setting.key] === 'string') {
|
|
$values[setting.key] = $values[setting.key].trim()
|
|
}
|
|
}}
|
|
bind:password={$values[setting.key]}
|
|
/>
|
|
<Button
|
|
variant="default"
|
|
unifiedSize="md"
|
|
disabled={!$values[setting.key]}
|
|
on:click={async () => {
|
|
await SettingService.testLicenseKey({
|
|
requestBody: { license_key: $values[setting.key] }
|
|
})
|
|
sendUserToast('Valid key')
|
|
}}
|
|
>
|
|
Test key
|
|
</Button>
|
|
</div>
|
|
<div class="mt-1 flex flex-col gap-1 items-start">
|
|
{#if $values[setting.key]?.length > 0}
|
|
{#if valid}
|
|
<div class="flex flex-row gap-1 items-center">
|
|
<Info size={12} class="text-primary" />
|
|
<span class="text-primary text-xs">License key expires on {expiration ?? ''}</span
|
|
>
|
|
</div>
|
|
{:else if expiration}
|
|
<div class="flex flex-row gap-1 items-center">
|
|
<AlertCircle size={12} class="text-red-600" />
|
|
<span class="text-red-600 dark:text-red-400 text-xs">
|
|
{#if $values[setting.key]?.endsWith('__dev')}
|
|
Dev license key expired on {expiration}.<br />If even after successful
|
|
renewal, your dev license key is still expired, it means that your production
|
|
key has expired due to unpaid invoices or excessive use of your production
|
|
instance.
|
|
{:else}
|
|
License key expired on {expiration}.
|
|
{/if}
|
|
</span>
|
|
</div>
|
|
{:else}
|
|
<div class="flex flex-row gap-1 items-center">
|
|
<AlertCircle size={12} class="text-red-600" />
|
|
<span class="text-red-600 dark:text-red-400 text-xs"
|
|
>Invalid license key format</span
|
|
>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
{#if latestKeyRenewalAttempt && !offlineCapStatus}
|
|
{@const attemptedAt = new Date(latestKeyRenewalAttempt.attempted_at).toLocaleString()}
|
|
{@const isTrial = latestKeyRenewalAttempt.result.startsWith('error: trial:')}
|
|
<div class="relative">
|
|
<Popover notClickable>
|
|
<div class="flex flex-row items-center gap-1">
|
|
{#if latestKeyRenewalAttempt.result === 'success'}
|
|
<BadgeCheck class="text-green-600" size={12} />
|
|
{:else}
|
|
<BadgeX class={isTrial ? 'text-yellow-600' : 'text-red-600'} size={12} />
|
|
{/if}
|
|
<span
|
|
class={classNames(
|
|
'text-xs',
|
|
latestKeyRenewalAttempt.result === 'success'
|
|
? 'text-green-600'
|
|
: isTrial
|
|
? 'text-yellow-600'
|
|
: 'text-red-600'
|
|
)}
|
|
>
|
|
{#if latestKeyRenewalAttempt.result === 'success' && $values[setting.key]?.endsWith('__dev')}
|
|
Latest dev key renewal succeeded on {attemptedAt}. The dev key expiry was
|
|
updated to align with your current production key's expiration date.
|
|
{:else}
|
|
{latestKeyRenewalAttempt.result === 'success'
|
|
? 'Latest key renewal succeeded'
|
|
: isTrial
|
|
? 'Latest key renewal ignored because in trial'
|
|
: 'Latest key renewal failed'}
|
|
on {attemptedAt}
|
|
{/if}
|
|
</span>
|
|
</div>
|
|
{#snippet text()}
|
|
<div>
|
|
{#if latestKeyRenewalAttempt?.result === 'success'}
|
|
<span class="text-green-300">
|
|
Latest key renewal succeeded on {attemptedAt}
|
|
</span>
|
|
{:else if isTrial}
|
|
<span class="text-yellow-300">
|
|
License key cannot be renewed during trial ({attemptedAt})
|
|
</span>
|
|
{:else}
|
|
<span class="text-red-600 dark:text-red-400">
|
|
Latest key renewal failed on {attemptedAt}: {latestKeyRenewalAttempt?.result.replace(
|
|
'error: ',
|
|
''
|
|
)}
|
|
</span>
|
|
{/if}
|
|
<br />
|
|
As long as invoices are paid and usage corresponds to the subscription, the key
|
|
is renewed daily with a validity of 35 days (grace period).
|
|
</div>
|
|
{/snippet}
|
|
</Popover>
|
|
</div>
|
|
{/if}
|
|
{#if $values[setting.key]?.length > 0 && version.includes('CE')}
|
|
<div class="flex flex-row gap-1 items-center">
|
|
<Info size={12} class="text-blue-600" />
|
|
<span class="text-blue-600 dark:text-blue-400 text-xs">
|
|
License key is set but the current image is Community Edition ({version}). Switch
|
|
to the EE image to finalize the upgrade.
|
|
</span>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if offlineCapStatus}
|
|
{@const cap = offlineCapStatus}
|
|
{@const seatsOver = cap.seats_used > cap.seats_cap}
|
|
{@const cuOver = cap.cu_over_cap}
|
|
<div class="mt-1 flex flex-row items-center gap-2 text-xs">
|
|
<div class="flex flex-row items-center gap-1">
|
|
{#if seatsOver}
|
|
<BadgeX class="text-red-600" size={12} />
|
|
{:else}
|
|
<BadgeCheck class="text-green-600" size={12} />
|
|
{/if}
|
|
<span class={seatsOver ? 'text-red-600' : 'text-green-600'}>
|
|
Seats: {cap.seats_used.toFixed(1)} / {cap.seats_cap}
|
|
</span>
|
|
</div>
|
|
<div class="flex flex-row items-center gap-1">
|
|
{#if cuOver}
|
|
<BadgeX class="text-red-600" size={12} />
|
|
{:else}
|
|
<BadgeCheck class="text-green-600" size={12} />
|
|
{/if}
|
|
<span class={cuOver ? 'text-red-600' : 'text-green-600'}>
|
|
CUs: {cap.current_cu.toFixed(2)} / {cap.cu_cap.toFixed(2)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if valid || expiration}
|
|
<div class="flex flex-row gap-2 mt-1">
|
|
{#if !offlineCapStatus}
|
|
<Button on:click={renewLicenseKey} loading={renewing} size="xs" variant="accent"
|
|
>Renew key
|
|
</Button>
|
|
{/if}
|
|
<Button variant="accent" size="xs" loading={opening} on:click={openCustomerPortal}>
|
|
Open customer portal
|
|
</Button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{:else if setting.fieldType == 'email'}
|
|
<TextInput
|
|
inputProps={{
|
|
type: 'email',
|
|
placeholder: setting.placeholder,
|
|
id: setting.key
|
|
}}
|
|
bind:value={$values[setting.key]}
|
|
/>
|
|
{:else if setting.key == 'critical_alert_mute_ui'}
|
|
<div class="flex flex-col gap-y-2">
|
|
<Toggle
|
|
disabled={!$enterpriseLicense}
|
|
bind:checked={$values[setting.key]}
|
|
id={setting.key}
|
|
/>
|
|
<div class="flex flex-row">
|
|
<Button
|
|
variant="default"
|
|
disabled={!$enterpriseLicense}
|
|
size="xs"
|
|
on:click={() => {
|
|
isCriticalAlertsUIOpen.set(true)
|
|
dispatch('closeDrawer')
|
|
}}
|
|
>
|
|
Show critical alerts
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{:else if setting.fieldType == 'critical_error_channels'}
|
|
<CriticalAlertChannels {values} {openSmtpSettings} {oauths} />
|
|
{:else if setting.fieldType == 'otel'}
|
|
<div class="flex flex-col gap-4 p-4 rounded-md border">
|
|
{#if $values[setting.key]}
|
|
<div class="flex gap-8">
|
|
<Toggle
|
|
disabled={!$enterpriseLicense}
|
|
id="tracing_enabled"
|
|
bind:checked={$values[setting.key].tracing_enabled}
|
|
options={{ right: 'Tracing' }}
|
|
/>
|
|
<Toggle
|
|
disabled={!$enterpriseLicense}
|
|
id="logs_enabled"
|
|
bind:checked={$values[setting.key].logs_enabled}
|
|
options={{ right: 'Logs' }}
|
|
/>
|
|
<Toggle
|
|
disabled={!$enterpriseLicense}
|
|
id="metrics_enabled"
|
|
bind:checked={$values[setting.key].metrics_enabled}
|
|
options={{ right: 'Metrics' }}
|
|
/>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-1">
|
|
<label
|
|
for="OTEL_EXPORTER_OTLP_ENDPOINT"
|
|
class="block text-xs font-semibold text-emphasis">Endpoint</label
|
|
>
|
|
<TextInput
|
|
inputProps={{
|
|
type: 'text',
|
|
placeholder: 'http://otel-collector.example.com:4317',
|
|
id: 'OTEL_EXPORTER_OTLP_ENDPOINT',
|
|
disabled: !$enterpriseLicense
|
|
}}
|
|
bind:value={$values[setting.key].otel_exporter_otlp_endpoint}
|
|
/>
|
|
</div>
|
|
<div class="flex flex-col gap-1">
|
|
<label
|
|
for="OTEL_EXPORTER_OTLP_HEADERS"
|
|
class="block text-xs font-semibold text-emphasis">Headers</label
|
|
>
|
|
<TextInput
|
|
inputProps={{
|
|
type: 'text',
|
|
placeholder: 'Authorization=Bearer my-secret-token,Env=production',
|
|
id: 'OTEL_EXPORTER_OTLP_HEADERS',
|
|
disabled: !$enterpriseLicense
|
|
}}
|
|
bind:value={$values[setting.key].otel_exporter_otlp_headers}
|
|
/>
|
|
</div>
|
|
<div class="flex flex-col gap-1">
|
|
<label
|
|
for="OTEL_EXPORTER_OTLP_PROTOCOL"
|
|
class="block text-xs font-semibold text-emphasis">Protocol</label
|
|
>
|
|
<select
|
|
id="OTEL_EXPORTER_OTLP_PROTOCOL"
|
|
class="!text-xs"
|
|
disabled={!$enterpriseLicense}
|
|
bind:value={$values[setting.key].otel_exporter_otlp_protocol}
|
|
>
|
|
<option value={undefined}>grpc (default)</option>
|
|
<option value="http/protobuf">http/protobuf</option>
|
|
</select>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{:else if setting.fieldType == 'otel_tracing_proxy'}
|
|
{@const tracingProxyVal = $values[setting.key] ?? {
|
|
enabled: false,
|
|
enabled_languages: [...OTEL_TRACING_PROXY_LANGUAGES]
|
|
}}
|
|
<div class="flex flex-col gap-4">
|
|
<Toggle
|
|
id="otel_tracing_proxy_enabled"
|
|
checked={tracingProxyVal.enabled ?? false}
|
|
on:change={(e) => {
|
|
$values[setting.key] = { ...tracingProxyVal, enabled: e.detail }
|
|
}}
|
|
options={{ right: 'Enabled' }}
|
|
/>
|
|
{#if tracingProxyVal.enabled}
|
|
<div class="flex flex-wrap gap-2">
|
|
{#each OTEL_TRACING_PROXY_LANGUAGES as lang (lang)}
|
|
{@const isEnabled = (tracingProxyVal.enabled_languages ?? []).includes(lang)}
|
|
<button
|
|
class="flex flex-col items-center gap-1 p-2 rounded border transition-all {isEnabled
|
|
? 'border-blue-500 bg-blue-500/10'
|
|
: 'border-gray-300 opacity-40 hover:opacity-70'}"
|
|
onclick={() => {
|
|
const current = tracingProxyVal.enabled_languages ?? []
|
|
const newLangs = isEnabled
|
|
? current.filter((l) => l !== lang)
|
|
: [...current, lang]
|
|
$values[setting.key] = { ...tracingProxyVal, enabled_languages: newLangs }
|
|
}}
|
|
>
|
|
<LanguageIcon {lang} size={24} />
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{:else if setting.fieldType == 'object_store_config'}
|
|
<ObjectStoreConfigSettings bind:bucket_config={$values[setting.key]} />
|
|
{:else if setting.fieldType == 'critical_alerts_on_db_oversize'}
|
|
{#if $values[setting.key]}
|
|
<div class="flex flex-row flex-wrap gap-2 p-0 items-center">
|
|
<div class="p-1">
|
|
<Toggle
|
|
disabled={!$enterpriseLicense}
|
|
bind:checked={$values[setting.key].enabled}
|
|
id={setting.key}
|
|
/>
|
|
</div>
|
|
{#if $values[setting.key].enabled}
|
|
<label class="block shrink min-w-0">
|
|
<input
|
|
type="number"
|
|
placeholder={setting.placeholder}
|
|
bind:value={$values[setting.key].value}
|
|
/>
|
|
</label>
|
|
<span class="text-primary font-semibold text-sm">GB</span>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{:else if setting.fieldType == 'number'}
|
|
<TextInput
|
|
inputProps={{
|
|
type: 'number',
|
|
placeholder: setting.placeholder,
|
|
id: setting.key
|
|
}}
|
|
bind:value={$values[setting.key]}
|
|
class="max-w-lg"
|
|
/>
|
|
{:else if setting.fieldType == 'password'}
|
|
<Password small placeholder={setting.placeholder} bind:password={$values[setting.key]} />
|
|
{:else if setting.fieldType == 'boolean'}
|
|
<Toggle
|
|
disabled={setting.ee_only != undefined && !$enterpriseLicense}
|
|
bind:checked={$values[setting.key]}
|
|
id={setting.key}
|
|
/>
|
|
{:else if setting.fieldType == 'seconds'}
|
|
<div>
|
|
<SecondsInput
|
|
max={setting.ee_only != undefined && !$enterpriseLicense
|
|
? 60 * 60 * 24 * 30
|
|
: undefined}
|
|
bind:seconds={$values[setting.key]}
|
|
clearable
|
|
/>
|
|
</div>
|
|
{:else if setting.fieldType == 'smtp_connect'}
|
|
<SmtpSettings {values} disabled={loading} />
|
|
{:else if setting.fieldType == 'secret_backend'}
|
|
<SecretBackendConfig {values} disabled={loading} />
|
|
{:else if setting.fieldType == 'github_enterprise_app'}
|
|
<GhesAppSettings {values} disabled={loading || !$enterpriseLicense} />
|
|
{:else if setting.fieldType == 'ws_connectivity'}
|
|
<WsConnectivityTest {values} />
|
|
{/if}
|
|
{#if hasError}
|
|
<span class="text-red-600 dark:text-red-400 text-xs">
|
|
{setting.error ?? ''}
|
|
</span>
|
|
{/if}
|
|
{:else}
|
|
<input disabled placeholder="Loading..." />
|
|
{/if}
|
|
</SettingCard>
|
|
{/if}
|
|
{/if}
|