From 7f0bdb499f111f3b8546244de2e24c0c4ef68ca6 Mon Sep 17 00:00:00 2001 From: Pavel Lineitsev Date: Mon, 6 Jul 2026 17:28:38 -0400 Subject: [PATCH] feat: add KeyValueEditor component for managing key-value pairs in settings - Implemented KeyValueEditor.svelte to handle key-value pairs with validation for empty keys/values. - Integrated KeyValueEditor into PackParametersDialog and SchemaForm components. - Refactored config page to utilize KeyValueEditor for default tags management. - Added utility functions for object and entry conversions in KeyValueEditor. - Enhanced form validation to prevent saving of blank entries in settings. - Created reusable field components for better UI consistency and structure. - Updated AppConfig type to include new configuration options related to tags and logging. --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/settings-page/spec.md | 0 .../tasks.md | 26 +- openspec/specs/settings-page/spec.md | 96 ++++ .../src/lib/components/KeyValueEditor.svelte | 117 +++++ .../components/PackParametersDialog.svelte | 8 +- .../src/lib/components/SchemaForm.svelte | 96 +--- .../components/ui/field/field-content.svelte | 20 + .../ui/field/field-description.svelte | 25 + .../components/ui/field/field-error.svelte | 58 +++ .../components/ui/field/field-group.svelte | 23 + .../components/ui/field/field-label.svelte | 25 + .../components/ui/field/field-legend.svelte | 27 ++ .../ui/field/field-separator.svelte | 35 ++ .../lib/components/ui/field/field-set.svelte | 23 + .../components/ui/field/field-title.svelte | 23 + .../src/lib/components/ui/field/field.svelte | 47 ++ .../src/lib/components/ui/field/index.ts | 33 ++ web/frontend/src/lib/types/index.ts | 9 + web/frontend/src/routes/config/+page.svelte | 433 +++++++++++++----- 22 files changed, 914 insertions(+), 210 deletions(-) rename openspec/changes/{settings-page-redesign => archive/2026-07-06-settings-page-redesign}/.openspec.yaml (100%) rename openspec/changes/{settings-page-redesign => archive/2026-07-06-settings-page-redesign}/design.md (100%) rename openspec/changes/{settings-page-redesign => archive/2026-07-06-settings-page-redesign}/proposal.md (100%) rename openspec/changes/{settings-page-redesign => archive/2026-07-06-settings-page-redesign}/specs/settings-page/spec.md (100%) rename openspec/changes/{settings-page-redesign => archive/2026-07-06-settings-page-redesign}/tasks.md (54%) create mode 100644 openspec/specs/settings-page/spec.md create mode 100644 web/frontend/src/lib/components/KeyValueEditor.svelte create mode 100644 web/frontend/src/lib/components/ui/field/field-content.svelte create mode 100644 web/frontend/src/lib/components/ui/field/field-description.svelte create mode 100644 web/frontend/src/lib/components/ui/field/field-error.svelte create mode 100644 web/frontend/src/lib/components/ui/field/field-group.svelte create mode 100644 web/frontend/src/lib/components/ui/field/field-label.svelte create mode 100644 web/frontend/src/lib/components/ui/field/field-legend.svelte create mode 100644 web/frontend/src/lib/components/ui/field/field-separator.svelte create mode 100644 web/frontend/src/lib/components/ui/field/field-set.svelte create mode 100644 web/frontend/src/lib/components/ui/field/field-title.svelte create mode 100644 web/frontend/src/lib/components/ui/field/field.svelte create mode 100644 web/frontend/src/lib/components/ui/field/index.ts diff --git a/openspec/changes/settings-page-redesign/.openspec.yaml b/openspec/changes/archive/2026-07-06-settings-page-redesign/.openspec.yaml similarity index 100% rename from openspec/changes/settings-page-redesign/.openspec.yaml rename to openspec/changes/archive/2026-07-06-settings-page-redesign/.openspec.yaml diff --git a/openspec/changes/settings-page-redesign/design.md b/openspec/changes/archive/2026-07-06-settings-page-redesign/design.md similarity index 100% rename from openspec/changes/settings-page-redesign/design.md rename to openspec/changes/archive/2026-07-06-settings-page-redesign/design.md diff --git a/openspec/changes/settings-page-redesign/proposal.md b/openspec/changes/archive/2026-07-06-settings-page-redesign/proposal.md similarity index 100% rename from openspec/changes/settings-page-redesign/proposal.md rename to openspec/changes/archive/2026-07-06-settings-page-redesign/proposal.md diff --git a/openspec/changes/settings-page-redesign/specs/settings-page/spec.md b/openspec/changes/archive/2026-07-06-settings-page-redesign/specs/settings-page/spec.md similarity index 100% rename from openspec/changes/settings-page-redesign/specs/settings-page/spec.md rename to openspec/changes/archive/2026-07-06-settings-page-redesign/specs/settings-page/spec.md diff --git a/openspec/changes/settings-page-redesign/tasks.md b/openspec/changes/archive/2026-07-06-settings-page-redesign/tasks.md similarity index 54% rename from openspec/changes/settings-page-redesign/tasks.md rename to openspec/changes/archive/2026-07-06-settings-page-redesign/tasks.md index a41a8fd..dfaf37e 100644 --- a/openspec/changes/settings-page-redesign/tasks.md +++ b/openspec/changes/archive/2026-07-06-settings-page-redesign/tasks.md @@ -2,22 +2,22 @@ ## 1. Foundations -- [ ] 1.1 Add the shadcn-svelte `field` component (`npx shadcn-svelte@latest add field`) -- [ ] 1.2 Extract the string-map editor from `SchemaForm.svelte` into a shared `KeyValueEditor` component (entries state, `objectToEntries`/`entriesToObject`, blank-entry validation, optional read-only inherited rows); wire `SchemaForm` to delegate to it -- [ ] 1.3 Verify pack parameters dialog behavior is unchanged after the extraction (add/remove/validate/save `default_tags`) -- [ ] 1.4 Align the frontend `AppConfig` TS type with the Go struct (all 8 keys + `default_tags`) +- [x] 1.1 Add the shadcn-svelte `field` component (`npx shadcn-svelte@latest add field`) +- [x] 1.2 Extract the string-map editor from `SchemaForm.svelte` into a shared `KeyValueEditor` component (entries state, `objectToEntries`/`entriesToObject`, blank-entry validation, optional read-only inherited rows); wire `SchemaForm` to delegate to it +- [x] 1.3 Verify pack parameters dialog behavior is unchanged after the extraction (add/remove/validate/save `default_tags`) +- [x] 1.4 Align the frontend `AppConfig` TS type with the Go struct (all 8 keys + `default_tags`) ## 2. Settings page rewrite -- [ ] 2.1 Rebuild `src/routes/config/+page.svelte` with four tabs (General default, Default tags, Retention, About) using `Tabs` + `Field.FieldGroup`/`Field.Field`; delete the generic KV loop and sensitive-key masking -- [ ] 2.2 General tab: parallelism (number), terraform version (text), pack logs (switch), SSH session logging (switch); human labels + descriptions -- [ ] 2.3 Default tags tab: `KeyValueEditor` for `default_tags` with precedence description; blank entries rejected client-side -- [ ] 2.4 Retention tab: enable switch + days input pairs; days disabled while switch is off -- [ ] 2.5 About tab: version, commit, build date, Go version (read-only) -- [ ] 2.6 Save behavior: switches persist on toggle; per-tab Save writes only dirty keys; failed saves show an Alert and keep edits +- [x] 2.1 Rebuild `src/routes/config/+page.svelte` with four tabs (General default, Default tags, Retention, About) using `Tabs` + `Field.FieldGroup`/`Field.Field`; delete the generic KV loop and sensitive-key masking +- [x] 2.2 General tab: parallelism (number), terraform version (text), pack logs (switch), SSH session logging (switch); human labels + descriptions +- [x] 2.3 Default tags tab: `KeyValueEditor` for `default_tags` with precedence description; blank entries rejected client-side +- [x] 2.4 Retention tab: enable switch + days input pairs; days disabled while switch is off +- [x] 2.5 About tab: version, commit, build date, Go version (read-only) +- [x] 2.6 Save behavior: switches persist on toggle; per-tab Save writes only dirty keys; failed saves show an Alert and keep edits ## 3. Verification -- [ ] 3.1 `mise run build-frontend` and svelte-check pass -- [ ] 3.2 Manual flow: edit every setting type (switch, number, text, tags map), reload, confirm persistence; verify save-failure path keeps edits (e.g. invalid retention days rejected by server validation) -- [ ] 3.3 Verify pack parameters dialog still edits `default_tags` correctly through the shared editor +- [x] 3.1 `mise run build-frontend` and svelte-check pass +- [x] 3.2 Manual flow: edit every setting type (switch, number, text, tags map), reload, confirm persistence; verify save-failure path keeps edits (e.g. invalid retention days rejected by server validation) +- [x] 3.3 Verify pack parameters dialog still edits `default_tags` correctly through the shared editor diff --git a/openspec/specs/settings-page/spec.md b/openspec/specs/settings-page/spec.md new file mode 100644 index 0000000..eeb300f --- /dev/null +++ b/openspec/specs/settings-page/spec.md @@ -0,0 +1,96 @@ +# Settings Page Specification + +## Purpose +Defines the Settings page UI: how app-level admin settings from +`GET/PUT /api/config` are organized into tabs, rendered as typed +controls with human-readable labels, and saved. Also covers the +Default tags editing surface and the shared string-map editor +component reused by the pack parameters dialog. Backend storage and +validation of `default_tags` is specified in `app-settings`. + +## Requirements + +### Requirement: Tabbed Settings Layout +The Settings page SHALL organize settings into four tabs — General, +Default tags, Retention, and About — with General as the default tab. +General SHALL contain parallelism, terraform version, pack logs, and +SSH session logging. Retention SHALL contain run log retention and run +retention. About SHALL contain read-only version information (version, +commit, build date, Go version). + +#### Scenario: Landing on General +- **WHEN** a user navigates to the Settings page +- **THEN** the General tab is active, showing parallelism, terraform version, pack logs, and SSH session logging controls + +#### Scenario: About is read-only +- **WHEN** a user opens the About tab +- **THEN** version, commit, build date, and Go version are displayed with no editable controls + +### Requirement: Typed Controls with Human Labels +Each known setting SHALL render with a human-readable label and +description and a control matching its type: switches for booleans, +number inputs for integer settings, text inputs for strings. Raw +`app_config` keys SHALL NOT be used as labels. The system SHALL NOT +render settings through a generic key/value loop and SHALL NOT apply +sensitive-key masking. + +#### Scenario: Boolean is a switch +- **WHEN** the General tab renders the pack logs setting (`pack_logs_enabled`) +- **THEN** it is a switch with a human label, not a text input containing "true" + +#### Scenario: Unknown keys not rendered +- **WHEN** `app_config` contains a key not known to the frontend +- **THEN** the Settings page does not render a control for it + +### Requirement: Save Behavior +Switch settings SHALL persist immediately on toggle via +`PUT /api/config`. Tabs containing text or number inputs SHALL provide +a single Save action that persists only the dirty keys of that tab. +Failed saves SHALL surface an error without discarding the user's +edits. + +#### Scenario: Toggle saves immediately +- **WHEN** a user toggles SSH session logging +- **THEN** the value is persisted via `PUT /api/config` without a separate Save action + +#### Scenario: Per-tab save of dirty keys +- **WHEN** a user edits parallelism on the General tab and clicks Save +- **THEN** only the changed key is written; unchanged keys are not re-persisted + +#### Scenario: Save failure keeps edits +- **WHEN** a save request fails +- **THEN** an error is shown and the edited values remain in the form + +### Requirement: Retention Enable/Days Pairing +Each retention setting SHALL render as an enable switch paired with a +days input, and the days input SHALL be disabled while its switch is +off. + +#### Scenario: Days disabled when retention off +- **WHEN** run retention is toggled off +- **THEN** the run retention days input is disabled + +### Requirement: Default Tags Tab +The Default tags tab SHALL edit the org-wide `default_tags` setting +with a key/value editor and SHALL describe where the tags apply and +that packs can override individual tags. Blank keys or values SHALL be +rejected before saving. + +#### Scenario: Editing org tags +- **WHEN** a user adds `owner: secops` in the Default tags tab and saves +- **THEN** `PUT /api/config` persists `default_tags` as `{"owner": "secops"}` + +#### Scenario: Blank entry rejected client-side +- **WHEN** a user adds an entry with an empty key and saves +- **THEN** a validation message is shown and no request is sent + +### Requirement: Shared String-Map Editor +The key/value map editor SHALL be a single shared component used by +both the pack parameters dialog (via `SchemaForm`) and the Default tags +tab, preserving the existing editor behavior (add/remove entries, +blank-entry validation) and supporting read-only inherited rows for the +pack dialog's inheritance display. + +#### Scenario: One editor, two surfaces +- **WHEN** a user edits `default_tags` in the pack parameters dialog and in the Settings Default tags tab +- **THEN** both use the same key/value editor component with identical add/remove/validation behavior diff --git a/web/frontend/src/lib/components/KeyValueEditor.svelte b/web/frontend/src/lib/components/KeyValueEditor.svelte new file mode 100644 index 0000000..9d41f20 --- /dev/null +++ b/web/frontend/src/lib/components/KeyValueEditor.svelte @@ -0,0 +1,117 @@ + + + + +
+ {#if inheritedEntries.length > 0} + {@const packKeys = new Set(entries.map((e) => e.key.trim()))} +
+ {#each inheritedEntries as [k, v] (k)} + {@const overridden = packKeys.has(k)} +
+ + {k} + + + {v} + + {overridden ? 'overridden' : ''} +
+ {/each} +

{inheritedLabel}

+
+ {/if} + {#each entries as entry, i} +
+ { + const next = entries.slice(); + next[i] = { ...next[i], key: (e.target as HTMLInputElement).value }; + onchange(entriesToObject(next)); + }} + /> + { + const next = entries.slice(); + next[i] = { ...next[i], value: (e.target as HTMLInputElement).value }; + onchange(entriesToObject(next)); + }} + /> + +
+ {/each} + +
diff --git a/web/frontend/src/lib/components/PackParametersDialog.svelte b/web/frontend/src/lib/components/PackParametersDialog.svelte index 33ec4ad..d6673e5 100644 --- a/web/frontend/src/lib/components/PackParametersDialog.svelte +++ b/web/frontend/src/lib/components/PackParametersDialog.svelte @@ -10,6 +10,7 @@ ValidationError } from '$lib/api/client'; import SchemaForm from '$lib/components/SchemaForm.svelte'; + import { hasBlankEntry } from '$lib/components/KeyValueEditor.svelte'; import type { AppConfig, PackManifest } from '$lib/types'; let { @@ -86,11 +87,8 @@ if (prop?.type !== 'object' || prop.additionalProperties?.type !== 'string') continue; const map = values[name]; if (!map || typeof map !== 'object') continue; - for (const [k, v] of Object.entries(map as Record)) { - if (k.trim() === '' || typeof v !== 'string' || v.trim() === '') { - errs[name] = 'Keys and values cannot be empty — remove or fill blank entries.'; - break; - } + if (hasBlankEntry(map as Record)) { + errs[name] = 'Keys and values cannot be empty — remove or fill blank entries.'; } } return errs; diff --git a/web/frontend/src/lib/components/SchemaForm.svelte b/web/frontend/src/lib/components/SchemaForm.svelte index 84aab1b..43be8ed 100644 --- a/web/frontend/src/lib/components/SchemaForm.svelte +++ b/web/frontend/src/lib/components/SchemaForm.svelte @@ -7,9 +7,9 @@ import { Input } from '$lib/components/ui/input/index.js'; import { Label } from '$lib/components/ui/label/index.js'; import { Switch } from '$lib/components/ui/switch/index.js'; + import KeyValueEditor from '$lib/components/KeyValueEditor.svelte'; import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'; import InfoIcon from '@lucide/svelte/icons/info'; - import PlusIcon from '@lucide/svelte/icons/plus'; import XIcon from '@lucide/svelte/icons/x'; import AlertTriangleIcon from '@lucide/svelte/icons/alert-triangle'; @@ -92,28 +92,6 @@ function isRequired(name: string): boolean { return required.includes(name); } - - // ----- String→string map editor state helpers ---------------------- - - type MapEntry = { key: string; value: string }; - - function objectToEntries(v: unknown): MapEntry[] { - if (!v || typeof v !== 'object') return []; - return Object.entries(v as Record).map(([key, value]) => ({ - key, - value: typeof value === 'string' ? value : JSON.stringify(value) - })); - } - - function entriesToObject(entries: MapEntry[]): Record { - const out: Record = {}; - for (const e of entries) { - const k = e.key.trim(); - if (!k) continue; - out[k] = e.value; - } - return out; - } {#snippet field(name: string, prop: Property)} @@ -158,73 +136,11 @@ {:else if prop.type === 'object' && prop.additionalProperties?.type === 'string'} - {@const entries = objectToEntries(value)} - {@const inherited = name === 'default_tags' ? Object.entries(inheritedDefaultTags) : []} -
- {#if inherited.length > 0} - {@const packKeys = new Set(entries.map((e) => e.key.trim()))} -
- {#each inherited as [k, v] (k)} - {@const overridden = packKeys.has(k)} -
- - {k} - - - {v} - - {overridden ? 'overridden' : ''} -
- {/each} -

Inherited from Settings

-
- {/if} - {#each entries as entry, i} -
- { - const next = entries.slice(); - next[i] = { ...next[i], key: (e.target as HTMLInputElement).value }; - update(name, entriesToObject(next)); - }} - /> - { - const next = entries.slice(); - next[i] = { ...next[i], value: (e.target as HTMLInputElement).value }; - update(name, entriesToObject(next)); - }} - /> - -
- {/each} - -
+ update(name, next)} + /> {:else} {@const stringValue = typeof value === 'string' ? value : value == null ? '' : String(value)} + import { cn, type WithElementRef } from '$lib/utils.js'; + import type { HTMLAttributes } from 'svelte/elements'; + + let { + ref = $bindable(null), + class: className, + children, + ...restProps + }: WithElementRef> = $props(); + + +
+ {@render children?.()} +
diff --git a/web/frontend/src/lib/components/ui/field/field-description.svelte b/web/frontend/src/lib/components/ui/field/field-description.svelte new file mode 100644 index 0000000..5feced7 --- /dev/null +++ b/web/frontend/src/lib/components/ui/field/field-description.svelte @@ -0,0 +1,25 @@ + + +

a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4', + className + )} + {...restProps} +> + {@render children?.()} +

diff --git a/web/frontend/src/lib/components/ui/field/field-error.svelte b/web/frontend/src/lib/components/ui/field/field-error.svelte new file mode 100644 index 0000000..c66e708 --- /dev/null +++ b/web/frontend/src/lib/components/ui/field/field-error.svelte @@ -0,0 +1,58 @@ + + +{#if hasContent} + +{/if} diff --git a/web/frontend/src/lib/components/ui/field/field-group.svelte b/web/frontend/src/lib/components/ui/field/field-group.svelte new file mode 100644 index 0000000..51fb72a --- /dev/null +++ b/web/frontend/src/lib/components/ui/field/field-group.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/web/frontend/src/lib/components/ui/field/field-label.svelte b/web/frontend/src/lib/components/ui/field/field-label.svelte new file mode 100644 index 0000000..0c4758c --- /dev/null +++ b/web/frontend/src/lib/components/ui/field/field-label.svelte @@ -0,0 +1,25 @@ + + + diff --git a/web/frontend/src/lib/components/ui/field/field-legend.svelte b/web/frontend/src/lib/components/ui/field/field-legend.svelte new file mode 100644 index 0000000..de6cd6e --- /dev/null +++ b/web/frontend/src/lib/components/ui/field/field-legend.svelte @@ -0,0 +1,27 @@ + + + + {@render children?.()} + diff --git a/web/frontend/src/lib/components/ui/field/field-separator.svelte b/web/frontend/src/lib/components/ui/field/field-separator.svelte new file mode 100644 index 0000000..9616575 --- /dev/null +++ b/web/frontend/src/lib/components/ui/field/field-separator.svelte @@ -0,0 +1,35 @@ + + +
+ + {#if children} + + {@render children()} + + {/if} +
diff --git a/web/frontend/src/lib/components/ui/field/field-set.svelte b/web/frontend/src/lib/components/ui/field/field-set.svelte new file mode 100644 index 0000000..c8e06a4 --- /dev/null +++ b/web/frontend/src/lib/components/ui/field/field-set.svelte @@ -0,0 +1,23 @@ + + +
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3 flex flex-col', + className + )} + {...restProps} +> + {@render children?.()} +
diff --git a/web/frontend/src/lib/components/ui/field/field-title.svelte b/web/frontend/src/lib/components/ui/field/field-title.svelte new file mode 100644 index 0000000..ebcf3f2 --- /dev/null +++ b/web/frontend/src/lib/components/ui/field/field-title.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/web/frontend/src/lib/components/ui/field/field.svelte b/web/frontend/src/lib/components/ui/field/field.svelte new file mode 100644 index 0000000..3f3d79d --- /dev/null +++ b/web/frontend/src/lib/components/ui/field/field.svelte @@ -0,0 +1,47 @@ + + + + +
+ {@render children?.()} +
diff --git a/web/frontend/src/lib/components/ui/field/index.ts b/web/frontend/src/lib/components/ui/field/index.ts new file mode 100644 index 0000000..0c207d8 --- /dev/null +++ b/web/frontend/src/lib/components/ui/field/index.ts @@ -0,0 +1,33 @@ +import Field from './field.svelte'; +import Set from './field-set.svelte'; +import Legend from './field-legend.svelte'; +import Group from './field-group.svelte'; +import Content from './field-content.svelte'; +import Label from './field-label.svelte'; +import Title from './field-title.svelte'; +import Description from './field-description.svelte'; +import Separator from './field-separator.svelte'; +import Error from './field-error.svelte'; + +export { + Field, + Set, + Legend, + Group, + Content, + Label, + Title, + Description, + Separator, + Error, + // + Set as FieldSet, + Legend as FieldLegend, + Group as FieldGroup, + Content as FieldContent, + Label as FieldLabel, + Title as FieldTitle, + Description as FieldDescription, + Separator as FieldSeparator, + Error as FieldError +}; diff --git a/web/frontend/src/lib/types/index.ts b/web/frontend/src/lib/types/index.ts index b07b6e6..927d8b8 100644 --- a/web/frontend/src/lib/types/index.ts +++ b/web/frontend/src/lib/types/index.ts @@ -197,6 +197,15 @@ export interface AssessmentListResponse { // Config types export interface AppConfig { [key: string]: unknown; + parallelism?: number; + terraform_version?: string; + pack_logs_enabled?: boolean; + ssh_logging_enabled?: boolean; + run_log_retention_enabled?: boolean; + run_log_retention_days?: number; + run_retention_enabled?: boolean; + run_retention_days?: number; + default_tags?: Record; } // Version diff --git a/web/frontend/src/routes/config/+page.svelte b/web/frontend/src/routes/config/+page.svelte index d0a059e..ab599ef 100644 --- a/web/frontend/src/routes/config/+page.svelte +++ b/web/frontend/src/routes/config/+page.svelte @@ -2,56 +2,55 @@ import { onMount } from 'svelte'; import * as Alert from '$lib/components/ui/alert/index.js'; import * as Card from '$lib/components/ui/card/index.js'; + import * as Field from '$lib/components/ui/field/index.js'; + import * as InputGroup from '$lib/components/ui/input-group/index.js'; + import * as Tabs from '$lib/components/ui/tabs/index.js'; import { Button } from '$lib/components/ui/button/index.js'; import { Input } from '$lib/components/ui/input/index.js'; - import { Label } from '$lib/components/ui/label/index.js'; - import { Separator } from '$lib/components/ui/separator/index.js'; import { Skeleton } from '$lib/components/ui/skeleton/index.js'; + import { Switch } from '$lib/components/ui/switch/index.js'; + import KeyValueEditor, { hasBlankEntry } from '$lib/components/KeyValueEditor.svelte'; import { getConfig, updateConfig, getVersion } from '$lib/api/client'; + import { toast } from 'svelte-sonner'; import type { AppConfig, VersionInfo } from '$lib/types'; let loading = $state(true); let error = $state(''); let config = $state({}); - let editedValues = $state>({}); - let revealedKeys = $state>(new Set()); - let saving = $state>({}); let version = $state(null); - const sensitivePatterns = ['key', 'secret', 'password', 'token', 'credential']; + let parallelism = $state(0); + let terraformVersion = $state(''); + let packLogsEnabled = $state(false); + let sshLoggingEnabled = $state(false); + let generalSaving = $state(false); + let generalError = $state(''); - function isSensitive(key: string): boolean { - const lower = key.toLowerCase(); - return sensitivePatterns.some((p) => lower.includes(p)); - } + let defaultTags = $state>({}); + let tagsSaving = $state(false); + let tagsError = $state(''); - function displayValue(key: string, value: unknown): string { - const str = typeof value === 'string' ? value : JSON.stringify(value); - if (isSensitive(key) && !revealedKeys.has(key)) { - return '********'; - } - return str; - } - - function toggleReveal(key: string) { - const next = new Set(revealedKeys); - if (next.has(key)) { - next.delete(key); - } else { - next.add(key); - } - revealedKeys = next; - } + let runLogRetentionEnabled = $state(false); + let runLogRetentionDays = $state(7); + let runRetentionEnabled = $state(false); + let runRetentionDays = $state(30); + let retentionSaving = $state(false); + let retentionError = $state(''); onMount(async () => { try { const [cfg, ver] = await Promise.all([getConfig(), getVersion()]); config = cfg; version = ver; - editedValues = {}; - for (const [key, value] of Object.entries(cfg)) { - editedValues[key] = typeof value === 'string' ? value : JSON.stringify(value); - } + parallelism = cfg.parallelism ?? 0; + terraformVersion = cfg.terraform_version ?? ''; + packLogsEnabled = cfg.pack_logs_enabled ?? false; + sshLoggingEnabled = cfg.ssh_logging_enabled ?? false; + defaultTags = cfg.default_tags ?? {}; + runLogRetentionEnabled = cfg.run_log_retention_enabled ?? false; + runLogRetentionDays = cfg.run_log_retention_days ?? 7; + runRetentionEnabled = cfg.run_retention_enabled ?? false; + runRetentionDays = cfg.run_retention_days ?? 30; } catch (e) { error = e instanceof Error ? e.message : 'Failed to load config'; } finally { @@ -59,28 +58,85 @@ } }); - async function handleSave(key: string) { - saving = { ...saving, [key]: true }; - error = ''; + // Switches are self-contained transactions: persist immediately and keep + // the toggled value even on failure, matching the tab Save behavior below. + async function toggleSwitch(key: string, value: boolean, setError: (msg: string) => void) { + setError(''); try { - let value: unknown = editedValues[key]; - try { - value = JSON.parse(editedValues[key]); - } catch { - // keep as string - } await updateConfig(key, value); config = { ...config, [key]: value }; + toast.success('Setting saved'); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to save'); + } + } + + // Persists only the keys whose value actually changed since the last load + // or save, issuing one PUT per dirty key. Failed saves leave the form + // values untouched so the user doesn't lose their edits. + async function saveKeys( + entries: [string, unknown][], + setSaving: (v: boolean) => void, + setError: (v: string) => void + ) { + const dirty = entries.filter( + ([key, value]) => JSON.stringify(value) !== JSON.stringify(config[key]) + ); + if (dirty.length === 0) return; + + setSaving(true); + setError(''); + try { + for (const [key, value] of dirty) { + await updateConfig(key, value); + } + config = { ...config, ...Object.fromEntries(dirty) }; + toast.success('Settings saved'); } catch (e) { - error = e instanceof Error ? e.message : 'Save failed'; + setError(e instanceof Error ? e.message : 'Save failed'); } finally { - saving = { ...saving, [key]: false }; + setSaving(false); } } + + function saveGeneral() { + return saveKeys( + [ + ['parallelism', Math.trunc(Number(parallelism))], + ['terraform_version', terraformVersion] + ], + (v) => (generalSaving = v), + (v) => (generalError = v) + ); + } + + function saveTags() { + tagsError = ''; + if (hasBlankEntry(defaultTags)) { + tagsError = 'Keys and values cannot be empty — remove or fill blank entries.'; + return; + } + return saveKeys( + [['default_tags', defaultTags]], + (v) => (tagsSaving = v), + (v) => (tagsError = v) + ); + } + + function saveRetention() { + return saveKeys( + [ + ['run_log_retention_days', Math.trunc(Number(runLogRetentionDays))], + ['run_retention_days', Math.trunc(Number(runRetentionDays))] + ], + (v) => (retentionSaving = v), + (v) => (retentionError = v) + ); + }
-

Configuration

+

Settings

{#if error} @@ -90,67 +146,240 @@ {#if loading}
+ -
{:else} - - - Settings - Manage simrun configuration values - - -
- {#each Object.entries(config) as [key, value]} -
- - - {#if isSensitive(key)} - - {/if} - -
- {/each} -
-
-
- - {#if version} - - - - Version Info - - -
-
- Version -

{version.version}

-
-
- Commit -

{version.commit}

-
-
- Build Date -

{version.buildDate}

-
-
- Go Version -

{version.goVersion}

-
-
-
-
- {/if} + + + General + Default tags + Retention + About + + + + + + + + Parallelism + + + Maximum number of scenarios executed concurrently per run. + + + + + Terraform version + + + Terraform CLI version used to apply simulation packs. Leave blank to use the + latest available version. + + + + + + + + Pack logs + + Capture Terraform apply/destroy output for each pack run. + + + { + packLogsEnabled = checked; + toggleSwitch('pack_logs_enabled', checked, (v) => (generalError = v)); + }} + /> + + + + + SSH session logging + + Record SSH session transcripts for detonations that use SSH connectors. + + + { + sshLoggingEnabled = checked; + toggleSwitch('ssh_logging_enabled', checked, (v) => (generalError = v)); + }} + /> + + + + {#if generalError} + + {generalError} + + {/if} + + + + + + + + + + + + Org-wide tags applied to every pack's cloud resources. A pack can override an + individual tag with its own value. + + + + (defaultTags = next)} /> + + {#if tagsError} + + {tagsError} + + {/if} + + + + + + + + + + + + Control how long run logs and whole runs are kept before they are deleted + automatically. + + + + + + + Delete old run logs + + Verbose per-run logs older than the retention period are removed; the run record + is kept. + + + { + runLogRetentionEnabled = checked; + toggleSwitch('run_log_retention_enabled', checked, (v) => (retentionError = v)); + }} + /> + + + Keep run logs for + + + + days + + + + + + + + + Delete old runs + + Whole runs older than the retention period — results and collected logs included + — are permanently deleted. + + + { + runRetentionEnabled = checked; + toggleSwitch('run_retention_enabled', checked, (v) => (retentionError = v)); + }} + /> + + + Keep runs for + + + + days + + + + + + {#if retentionError} + + {retentionError} + + {/if} + + + + + + + + + + + {#if version} +
+
+ Version +

{version.version}

+
+
+ Commit +

{version.commit}

+
+
+ Build Date +

{version.buildDate}

+
+
+ Go Version +

{version.goVersion}

+
+
+ {/if} +
+
+
+
{/if}