Files
windmill/frontend/src/lib/components/InstanceSetting.svelte
T
hugocasa 9c6cd8c852 offline (URL-bound) license keys (#9089)
* [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>
2026-05-11 22:09:21 +00:00

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}