diff --git a/.claude/skills/shadcn-svelte/SKILL.md b/.claude/skills/shadcn-svelte/SKILL.md new file mode 100644 index 0000000..6bbc425 --- /dev/null +++ b/.claude/skills/shadcn-svelte/SKILL.md @@ -0,0 +1,227 @@ +--- +name: shadcn-svelte +description: Manages shadcn-svelte components and projects — adding, updating, fixing, debugging, styling, and composing UI. Provides project context, component docs, and usage examples. Applies when working with shadcn-svelte, the CLI, design-system presets, or any project with a components.json file. Also triggers for "shadcn-svelte init", "add component", or registry URLs. +user-invocable: false +allowed-tools: Bash(npx shadcn-svelte@latest *), Bash(pnpm dlx shadcn-svelte@latest *), Bash(bunx --bun shadcn-svelte@latest *) +--- + +# shadcn-svelte + +A framework for building UI, components, and design systems for Svelte. Components are added as source to the user's project via the CLI. + +> **IMPORTANT:** Run all CLI commands using the project's package runner: `npx shadcn-svelte@latest`, `pnpm dlx shadcn-svelte@latest`, or `bunx --bun shadcn-svelte@latest` — based on the project's package manager. Examples below use `npx shadcn-svelte@latest` but substitute the correct runner for the project. + +## Current Project Context + +Read `components.json` at the project root and, when you need the live file layout, list the directory given by the `aliases.ui` path (resolved with the same rules as the CLI). + +## Imports (Svelte) + +Each component lives in its own folder with an `index.ts` barrel. Match the [installation docs](https://shadcn-svelte.com/docs/installation): + +- **Multi-part components** (dialog, select, card, field, tabs, …): `import * as Dialog from "$lib/components/ui/dialog"` then `Dialog.Content`, `Dialog.Title`, `Card.Root`, `Card.Header`, etc. — whatever the barrel exports (short names and/or `Root as …` aliases). +- **Single-component barrels** (only one meaningful component in the folder): **named imports** — `import { Button } from "$lib/components/ui/button"` and ` + + +
+ + + + + U + + + ++20.1% +``` + +## Component Selection + +| Need | Use | +| -------------------------- | --------------------------------------------------------------------------------------------------- | +| Button/action | `Button` with appropriate variant (`import { Button }`) | +| Form inputs | `Input`, `Select`, `Combobox`, `Switch`, `Checkbox`, `RadioGroup`, `Textarea`, `InputOTP`, `Slider` | +| Toggle between 2–5 options | `ToggleGroup.Root` + `ToggleGroup.Item` | +| Data display | `Table`, `Card`, `Badge`, `Avatar` | +| Navigation | `Sidebar`, `NavigationMenu`, `Breadcrumb`, `Tabs`, `Pagination` | +| Overlays | `Dialog` (modal), `Sheet` (side panel), `Drawer` (bottom sheet), `AlertDialog` (confirmation) | +| Feedback | `svelte-sonner` (toast), `Alert`, `Progress`, `Skeleton`, `Spinner` | +| Command palette | `Command` inside `Dialog` | +| Charts | `Chart` (LayerChart) | +| Layout | `Card`, `Separator`, `Resizable`, `ScrollArea`, `Accordion`, `Collapsible` | +| Empty states | `Empty` | +| Menus | `DropdownMenu`, `ContextMenu`, `Menubar` | +| Tooltips/info | `Tooltip`, `HoverCard`, `Popover` | + +## Key Fields + +Use `components.json` and the filesystem — not a separate `info` command: + +- **`aliases`** → use the actual alias prefix from config (e.g. `$lib/`), never hardcode unrelated projects. +- **`tailwind.css`** → the global CSS file where theme variables live. Edit this file for theme tweaks; don't add a second globals file unless the user already uses one. +- **`style`** → visual treatment (e.g. `nova`, `vega`, …) and registry style path. +- **`iconLibrary`** → determines icon packages (`@lucide/svelte`, `@tabler/icons-svelte`, etc.). Never assume `@lucide/svelte`. +- **`registry`** → where the CLI fetches components; default official registry at `shadcn-svelte.com`. +- **`resolvedPaths`** (conceptual) → the CLI resolves `aliases` to absolute paths; list `aliases.ui` on disk to see installed components. + +See [cli.md](./cli.md) for commands and flags. + +## Component Docs, Examples, and Usage + +Open `https://shadcn-svelte.com/docs/components/.md` for docs and examples. **When creating, fixing, debugging, or using a component, read the official page first** so you follow the documented APIs. + +## Workflow + +1. **Get project context** — read `components.json` and list the UI components directory when needed. +2. **Check installed components first** — before running `add`, list files under the resolved `ui` path. Don't import components that haven't been added, and don't re-add ones already present unless updating. +3. **Discover components** — `npx shadcn-svelte@latest add` with no arguments (interactive list), or the docs site. +4. **Install or update** — `npx shadcn-svelte@latest add ` or a registry **URL**. To refresh existing files from the registry, use `npx shadcn-svelte@latest update` (see [cli.md](./cli.md)). +5. **Fix imports in third-party / URL-added items** — After adding from a custom registry URL, check for hardcoded paths that don't match the project's `aliases`. Rewrite imports to use the project's `ui` / `lib` aliases from `components.json`. +6. **Review added components** — After adding, **read the added files** and verify composition (groups, titles, validation attrs). Align icon imports with `iconLibrary`. +7. **Remote registry items** — Adding by URL is explicit; if the user wants a component from an unknown source, confirm the registry URL or item before running `add`. + +## Updating Components + +Use the **`update`** command to pull the latest registry versions of components already in the project. Review changes with `git diff` after `update`. + +1. Commit or stash local work. +2. Run `npx shadcn-svelte@latest update [component]` or `--all`. +3. Resolve merge conflicts if you had customized files. +4. **Never use `--overwrite` on `add` without the user's explicit approval** when it would destroy intentional edits. + +## Quick Reference + +```bash +# Initialize shadcn-svelte in your project. +npx shadcn-svelte@latest init + +# Initialize with a preset string from the docs site builder. +npx shadcn-svelte@latest init --preset + +# Add components (interactive when run with no names). +npx shadcn-svelte@latest add +npx shadcn-svelte@latest add button card dialog +npx shadcn-svelte@latest add --all + +# Update components already installed. +npx shadcn-svelte@latest update button +npx shadcn-svelte@latest update --all --yes + +# Build a custom registry (registry authors). +npx shadcn-svelte@latest registry build +``` + +**Registry:** default `https://shadcn-svelte.com/registry` — override in `components.json` if needed. +**Docs:** [shadcn-svelte.com](https://shadcn-svelte.com) + +## Detailed References + +- [rules/forms.md](./rules/forms.md) — Field.FieldGroup, Field.Field, InputGroup, ToggleGroup, Field.FieldSet, validation states +- [rules/composition.md](./rules/composition.md) — Groups, overlays, Card, Tabs, Avatar, Alert, Empty, Toast, Separator, Skeleton, Badge, Button loading +- [rules/icons.md](./rules/icons.md) — data-icon, icon sizing, passing icon components +- [rules/styling.md](./rules/styling.md) — Semantic colors, variants, class, spacing, size, truncate, dark mode, cn(), z-index +- [cli.md](./cli.md) — Commands, flags, registry +- [customization.md](./customization.md) — Theming, CSS variables, extending components diff --git a/.claude/skills/shadcn-svelte/cli.md b/.claude/skills/shadcn-svelte/cli.md new file mode 100644 index 0000000..f8e3100 --- /dev/null +++ b/.claude/skills/shadcn-svelte/cli.md @@ -0,0 +1,138 @@ +# shadcn-svelte CLI Reference + +Configuration is read from `components.json`. See [components.json](https://shadcn-svelte.com/docs/components-json) on the docs site for the full schema. + +> **IMPORTANT:** Always run commands using the project's package runner: `npx shadcn-svelte@latest`, `pnpm dlx shadcn-svelte@latest`, or `bunx --bun shadcn-svelte@latest`. Check `packageManager` from the project (or lockfile) to choose the right one. Examples below use `npx shadcn-svelte@latest` but substitute the correct runner for the project. + +> **IMPORTANT:** Only use the flags documented below. Do not invent or guess flags — if a flag isn't listed here, it doesn't exist. The CLI auto-detects the package manager; there is no `--package-manager` flag. + +## Contents + +- Commands: `init`, `add`, `update`, `registry build` +- Proxy / outgoing requests +- Presets (via `init`) + +--- + +## Commands + +### `init` — Initialize an existing project + +```bash +npx shadcn-svelte@latest init [options] +``` + +Installs dependencies, adds the `cn` util, creates `components.json`, and sets up CSS variables. Run `init` from the root of your project. + +| Flag | Short | Description | Default | +| --------------------------- | ----- | ------------------------------------------------------------------------- | --------- | +| `--preset ` | — | Encoded design-system preset string from the docs site | — | +| `-c, --cwd ` | `-c` | Working directory | current | +| `-o, --overwrite` | — | Overwrite existing files | `false` | +| `--no-deps` | — | Do not add or install dependencies | — | +| `--skip-preflight` | — | Ignore preflight checks and continue | `false` | +| `--base-color ` | — | Base color: `neutral`, `stone`, `zinc`, `mauve`, `olive`, `mist`, `taupe` | — | +| `--css ` | — | Path to the global CSS file | — | +| `--components-alias ` | — | Import alias for components | — | +| `--lib-alias ` | — | Import alias for lib | — | +| `--utils-alias ` | — | Import alias for utils | — | +| `--hooks-alias ` | — | Import alias for hooks | — | +| `--ui-alias ` | — | Import alias for UI components | — | +| `--proxy ` | — | Fetch registry items through this proxy | env-based | +| `--design-system-url` | — | Optional design-system URL (see docs / preset builder) | — | +| `-h, --help` | `-h` | Help | — | + +--- + +### `add` — Add components + +```bash +npx shadcn-svelte@latest add [options] [components...] +``` + +Adds components from the configured registry. Arguments are component names from the registry index, or a **URL** to a registry JSON item. With **no** component names, the CLI prompts you to pick components interactively. + +| Flag | Short | Description | Default | +| ------------------ | ----- | ----------------------------------------------- | --------- | +| `-c, --cwd ` | `-c` | Working directory | current | +| `--no-deps` | — | Skip adding and installing package dependencies | — | +| `--skip-preflight` | — | Ignore preflight checks and continue | `false` | +| `-a, --all` | — | Install all UI components | `false` | +| `-y, --yes` | — | Skip confirmation prompt | `false` | +| `-o, --overwrite` | — | Overwrite existing files | `false` | +| `--proxy ` | — | Fetch components through this proxy | env-based | +| `-h, --help` | `-h` | Help | — | + +--- + +### `update` — Update installed components + +```bash +npx shadcn-svelte@latest update [options] [components...] +``` + +Re-fetches and applies registry content for components **already present** in the project. Run `shadcn-svelte update --help` for options. + +| Flag | Short | Description | Default | +| ------------------ | ----- | ----------------------------------------------- | --------- | +| `-c, --cwd ` | `-c` | Working directory | current | +| `--skip-preflight` | — | Ignore preflight checks and continue | `false` | +| `--no-deps` | — | Skip adding and installing package dependencies | — | +| `-a, --all` | — | Update every installed component | `false` | +| `-y, --yes` | — | Skip confirmation prompt | `false` | +| `--proxy ` | — | Fetch through this proxy | env-based | +| `-h, --help` | `-h` | Help | — | + +Commit your work before updating; overwrites are destructive. + +--- + +### `registry build` — Build a custom registry + +```bash +npx shadcn-svelte@latest registry build [options] [registry] +``` + +Reads a `registry.json` and writes registry JSON files for distribution. Default input: `./registry.json`, default output: `./static/r`. + +| Flag | Short | Description | Default | +| --------------------- | ----- | ------------------------------- | ------------ | +| `-c, --cwd ` | `-c` | Working directory | current | +| `-o, --output ` | `-o` | Output directory for JSON files | `./static/r` | +| `-h, --help` | `-h` | Help | — | + +--- + +## Outgoing Requests + +### Proxy + +The CLI can fetch the registry through a proxy. If `HTTP_PROXY` or `http_proxy` is set, requests respect it. You can also pass `--proxy` on `init`, `add`, or `update`. + +```bash +HTTP_PROXY="" npx shadcn-svelte@latest init +``` + +--- + +## Presets + +Design-system options (style, theme, icons, fonts, etc.) can be captured as an encoded **preset** string from the builder on [shadcn-svelte.com](https://shadcn-svelte.com/create). Pass it to **`init`** with `--preset `. + +Changing presets on an existing project: re-run **`init`** with the new preset (and confirm overwrites when prompted), or edit `components.json` and CSS and then run `add` / `update` as needed. + +--- + +## `components.json` — useful fields for agents + +| Field / path | Meaning | +| -------------------- | ---------------------------------------------------------------- | +| `tailwind.css` | Global CSS file path (Tailwind entry / theme variables) | +| `tailwind.baseColor` | Base palette (cannot change after init) | +| `aliases.*` | Import aliases; must match `svelte.config.js` / `tsconfig` paths | +| `registry` | Base registry URL (default `https://shadcn-svelte.com/registry`) | +| `style` | Registered style name (e.g. `nova`, `vega`, …) | +| `iconLibrary` | Icon set key (`lucide`, `tabler`, …) — drives generated imports | +| `typescript` | Whether TS and optional custom config path | + +Resolved paths (including `tailwindCss`, `ui`, `components`) are computed by the CLI from `components.json` and the filesystem. Read `components.json` and list the UI directory when you need a snapshot of what is installed. diff --git a/.claude/skills/shadcn-svelte/customization.md b/.claude/skills/shadcn-svelte/customization.md new file mode 100644 index 0000000..51a52fc --- /dev/null +++ b/.claude/skills/shadcn-svelte/customization.md @@ -0,0 +1,213 @@ +# Customization & Theming + +Components reference semantic CSS variable tokens. Change the variables to change every component. + +## Contents + +- How it works (CSS variables → Tailwind utilities → components) +- Color variables and OKLCH format +- Dark mode setup +- Changing the theme (presets, CSS variables) +- Adding custom colors (Tailwind v3 and v4) +- Border radius +- Customizing components (variants, class, wrappers) +- Checking for updates + +--- + +## How It Works + +1. CSS variables defined in `:root` (light) and `.dark` (dark mode). +2. Tailwind maps them to utilities: `bg-primary`, `text-muted-foreground`, etc. +3. Components use these utilities — changing a variable changes all components that reference it. + +--- + +## Color Variables + +Every color follows the `name` / `name-foreground` convention. The base variable is for backgrounds, `-foreground` is for text/icons on that background. + +| Variable | Purpose | +| -------------------------------------------- | -------------------------------- | +| `--background` / `--foreground` | Page background and default text | +| `--card` / `--card-foreground` | Card surfaces | +| `--primary` / `--primary-foreground` | Primary buttons and actions | +| `--secondary` / `--secondary-foreground` | Secondary actions | +| `--muted` / `--muted-foreground` | Muted/disabled states | +| `--accent` / `--accent-foreground` | Hover and accent states | +| `--destructive` / `--destructive-foreground` | Error and destructive actions | +| `--border` | Default border color | +| `--input` | Form input borders | +| `--ring` | Focus ring color | +| `--chart-1` through `--chart-5` | Chart/data visualization | +| `--sidebar-*` | Sidebar-specific colors | +| `--surface` / `--surface-foreground` | Secondary surface | + +Colors use OKLCH: `--primary: oklch(0.205 0 0)` where values are lightness (0–1), chroma (0 = gray), and hue (0–360). + +--- + +## Dark Mode + +Class-based toggle via `.dark` on the root element. In SvelteKit, use [mode-watcher](https://github.com/svecosystem/mode-watcher) (see [Dark mode — Svelte](https://shadcn-svelte.com/docs/dark-mode/svelte)): + +```svelte + + + +{@render children?.()} +``` + +--- + +## Changing the Theme + +Use a **preset** from the design-system builder on [shadcn-svelte.com](https://shadcn-svelte.com) and pass it to `init`: + +```bash +npx shadcn-svelte@latest init --preset +``` + +Or edit CSS variables directly in the file set in `components.json` as `tailwind.css` (for example `src/app.css`). + +To align config and components with a new preset, re-run `init` with `--preset` and confirm overwrites when prompted. + +--- + +## Adding Custom Colors + +Add variables to the global CSS file path in `components.json` (`tailwind.css`). Do not create a second global CSS file for theming unless the project already uses that pattern. + +```css +/* 1. Define in the global CSS file. */ +:root { + --warning: oklch(0.84 0.16 84); + --warning-foreground: oklch(0.28 0.07 46); +} +.dark { + --warning: oklch(0.41 0.11 46); + --warning-foreground: oklch(0.99 0.02 95); +} +``` + +```css +/* 2a. Register with Tailwind v4 (@theme inline). */ +@theme inline { + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); +} +``` + +On Tailwind v3, register in `tailwind.config.js` (see the [Tailwind v3 docs](https://tw3.shadcn-svelte.com) if you maintain a legacy setup): + +```js +// 2b. Register with Tailwind v3 (tailwind.config.js). +module.exports = { + theme: { + extend: { + colors: { + warning: "oklch(var(--warning) / )", + "warning-foreground": + "oklch(var(--warning-foreground) / )", + }, + }, + }, +}; +``` + +```svelte + +
Warning
+``` + +--- + +## Border Radius + +`--radius` controls border radius globally. Components derive values from it (`rounded-lg` = `var(--radius)`, `rounded-md` = `calc(var(--radius) - 2px)`). + +--- + +## Customizing Components + +See also: [rules/styling.md](./rules/styling.md) for Incorrect/Correct examples. + +Prefer these approaches in order: + +### 1. Built-in variants + +```svelte + + + +``` + +### 2. Tailwind classes via `class` + +```svelte + + + + ... + +``` + +### 3. Add a new variant + +Edit the component source to add a variant via `tailwind-variants` / `cva` in the `.svelte` or shared variants file: + +```ts +// e.g. in button variants +warning: "bg-warning text-warning-foreground hover:bg-warning/90", +``` + +### 4. Wrapper components + +Compose shadcn-svelte primitives into higher-level `.svelte` files: + +```svelte + + + + + {@render children?.()} + + + + {title} + {description} + + + Cancel + { + onConfirm?.(); + open = false; + }}>Confirm + + + +``` + +--- + +## Checking for Updates + +```bash +npx shadcn-svelte@latest update button +npx shadcn-svelte@latest update --all +``` + +See [Updating Components in SKILL.md](./SKILL.md#updating-components). Review `git diff` after `update` to see what changed. diff --git a/.claude/skills/shadcn-svelte/rules/composition.md b/.claude/skills/shadcn-svelte/rules/composition.md new file mode 100644 index 0000000..bfc0a93 --- /dev/null +++ b/.claude/skills/shadcn-svelte/rules/composition.md @@ -0,0 +1,244 @@ +# Component Composition + +## Contents + +- Items always inside their Group component +- Callouts use Alert +- Empty states use Empty component +- Toast notifications use svelte-sonner +- Choosing between overlay components +- Dialog, Sheet, and Drawer always need a Title +- Card structure +- Button has no isPending or isLoading prop +- Tabs.Trigger must be inside Tabs.List +- Avatar always needs Avatar.Fallback +- Use Separator instead of raw hr or border divs +- Use Skeleton for loading placeholders +- Use Badge instead of custom styled spans + +--- + +## Items always inside their Group component + +Never render items directly inside the content container. + +**Incorrect:** + +```svelte + + + + Apple + Banana + +``` + +**Correct:** + +```svelte + + + + + Apple + Banana + + +``` + +This applies to all group-based components: + +| Item | Group | +| ------------------------------------------------------------- | -------------------- | +| `Select.Item`, `Select.Label` | `Select.Group` | +| `DropdownMenu.Item`, `DropdownMenu.Label`, `DropdownMenu.Sub` | `DropdownMenu.Group` | +| `Menubar.Item` | `Menubar.Group` | +| `ContextMenu.Item` | `ContextMenu.Group` | +| `Command.Item` | `Command.Group` | + +--- + +## Callouts use Alert + +```svelte + + + + Warning + Something needs attention. + +``` + +--- + +## Empty states use Empty component + +```svelte + + + + + + No projects yet + Get started by creating a new project. + + + + + +``` + +--- + +## Toast notifications use svelte-sonner + +```svelte + +``` + +```ts +toast.success("Changes saved."); +toast.error("Something went wrong."); +toast("File deleted.", { + action: { label: "Undo", onClick: () => undoDelete() }, +}); +``` + +Mount the `Toaster` from your UI folder once in the app layout (see [Sonner](https://shadcn-svelte.com/docs/components/sonner)). + +--- + +## Choosing between overlay components + +| Use case | Component | +| ---------------------------------- | ------------- | +| Focused task that requires input | `Dialog` | +| Destructive action confirmation | `AlertDialog` | +| Side panel with details or filters | `Sheet` | +| Mobile-first bottom panel | `Drawer` | +| Quick info on hover | `HoverCard` | +| Small contextual content on click | `Popover` | + +--- + +## Dialog, Sheet, and Drawer always need a Title + +`Dialog.Title`, `Sheet.Title`, `Drawer.Title` are required for accessibility. Use `class="sr-only"` if visually hidden. + +```svelte + + + + + Edit Profile + Update your profile. + + ... + +``` + +--- + +## Card structure + +Use full composition — don't dump everything into `Card.Content`: + +```svelte + + + + + Team Members + Manage your team. + + ... + + + + +``` + +--- + +## Button has no isPending or isLoading prop + +Compose with `Spinner` inside `Button` + `disabled`: + +```svelte + + + +``` + +--- + +## Tabs.Trigger must be inside Tabs.List + +Never render `Tabs.Trigger` directly inside `Tabs.Root` — always wrap in `Tabs.List`: + +```svelte + + + + + Account + Password + + ... + +``` + +--- + +## Avatar always needs Avatar.Fallback + +Always include `Avatar.Fallback` for when the image fails to load: + +```svelte + + + + + JD + +``` + +--- + +## Use existing components instead of custom markup + +| Instead of | Use | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------- | +| `
` or `
` | `` (`import { Separator } from "$lib/components/ui/separator"`) | +| `
` with styled divs | `` (`import { Skeleton } from "$lib/components/ui/skeleton"`) | +| `` | `` (`import { Badge } from "$lib/components/ui/badge"`) | diff --git a/.claude/skills/shadcn-svelte/rules/forms.md b/.claude/skills/shadcn-svelte/rules/forms.md new file mode 100644 index 0000000..c779ee4 --- /dev/null +++ b/.claude/skills/shadcn-svelte/rules/forms.md @@ -0,0 +1,234 @@ +# Forms & Inputs + +## Contents + +- Forms use Field.FieldGroup + Field.Field +- InputGroup requires InputGroup.Input/InputGroup.Textarea +- Buttons inside inputs use InputGroup.Root + InputGroup.Addon +- Option sets (2–7 choices) use ToggleGroup.Root + ToggleGroup.Item +- Field.FieldSet + Field.FieldLegend for grouping related fields +- Field validation and disabled states + +--- + +## Forms use Field.FieldGroup + Field.Field + +Always use `Field.FieldGroup` + `Field.Field` — never raw `div` with `space-y-*`: + +```svelte + + + + + Email + + + + Password + + + +``` + +Use `Field` with `orientation="horizontal"` for settings pages. Use `Field.FieldLabel` with `class="sr-only"` for visually hidden labels. + +**Choosing form controls:** + +- Simple text input → `Input` +- Dropdown with predefined options → `Select` +- Searchable dropdown → `Combobox` +- Native HTML select (no JS) → `native-select` +- Boolean toggle → `Switch` (for settings) or `Checkbox` (for forms) +- Single choice from few options → `RadioGroup` +- Toggle between 2–5 options → `ToggleGroup.Root` + `ToggleGroup.Item` +- OTP/verification code → `InputOTP` +- Multi-line text → `Textarea` + +--- + +## InputGroup requires InputGroup.Input/InputGroup.Textarea + +Never use raw `Input` or `Textarea` inside an `InputGroup.Root`. + +**Incorrect:** + +```svelte + + + + + +``` + +**Correct:** + +```svelte + + + + + +``` + +--- + +## Buttons inside inputs use InputGroup.Root + InputGroup.Addon + +Never place a `Button` directly inside or adjacent to an `Input` with custom positioning. + +**Incorrect:** + +```svelte + + +
+ + +
+``` + +**Correct:** + +```svelte + + + + + + + + +``` + +--- + +## Option sets (2–7 choices) use ToggleGroup.Root + ToggleGroup.Item + +Don't manually loop `Button` components with active state. + +**Incorrect:** + +```svelte + + +
+ {#each ["daily", "weekly", "monthly"] as option (option)} + + {/each} +
+``` + +**Correct:** + +```svelte + + + + Daily + Weekly + Monthly + +``` + +Combine with `Field` for labelled toggle groups: + +```svelte + + + + Theme + + Light + Dark + System + + +``` + +--- + +## Field.FieldSet + Field.FieldLegend for grouping related fields + +Use `Field.FieldSet` + `Field.FieldLegend` for related checkboxes, radios, or switches — not `div` with a heading: + +```svelte + + + + Preferences + Select all that apply. + + + + Dark mode + + + +``` + +--- + +## Field validation and disabled states + +Both attributes are needed — `data-invalid`/`data-disabled` styles the field (label, description), while `aria-invalid`/`disabled` styles the control. + +```svelte + + + + + Email + + Invalid email address. + + + + + Email + + +``` + +Works for all controls: `Input`, `Textarea`, `Select`, `Checkbox`, `RadioGroupItem`, `Switch`, `Slider`, `NativeSelect`, `InputOTP`. diff --git a/.claude/skills/shadcn-svelte/rules/icons.md b/.claude/skills/shadcn-svelte/rules/icons.md new file mode 100644 index 0000000..4db886d --- /dev/null +++ b/.claude/skills/shadcn-svelte/rules/icons.md @@ -0,0 +1,107 @@ +# Icons + +**Always use the project's configured `iconLibrary` for imports.** Check the `iconLibrary` field in `components.json`: `lucide` → `@lucide/svelte`, `tabler` → `@tabler/icons-svelte`, etc. Never assume `@lucide/svelte`. + +--- + +## Icons in Button use data-icon attribute + +Add `data-icon="inline-start"` (prefix) or `data-icon="inline-end"` (suffix) to the icon. No sizing classes on the icon. + +**Incorrect:** + +```svelte + + + +``` + +**Correct:** + +```svelte + + + + + +``` + +--- + +## No sizing classes on icons inside components + +Components handle icon sizing via CSS. Don't add `size-4`, `w-4 h-4`, or other sizing classes to icons inside ` +``` + +**Correct:** + +```svelte + + + +``` + +The same applies to icons inside `DropdownMenu.Item`, sidebar items, and other menu rows — no extra sizing classes on the icon component. + +--- + +## Pass icons as components, not string keys + +Use a component reference, not a string key to a lookup map. + +**Incorrect:** + +```svelte + + +``` + +**Correct:** + +```svelte + + + + + +``` diff --git a/.claude/skills/shadcn-svelte/rules/styling.md b/.claude/skills/shadcn-svelte/rules/styling.md new file mode 100644 index 0000000..733e609 --- /dev/null +++ b/.claude/skills/shadcn-svelte/rules/styling.md @@ -0,0 +1,195 @@ +# Styling & Customization + +See [customization.md](../customization.md) for theming, CSS variables, and adding custom colors. + +## Contents + +- Semantic colors +- Built-in variants first +- class for layout only +- No space-x-_ / space-y-_ +- Prefer size-_ over w-_ h-\* when equal +- Prefer truncate shorthand +- No manual dark: color overrides +- Use cn() for conditional classes +- No manual z-index on overlay components + +--- + +## Semantic colors + +**Incorrect:** + +```svelte +
+

Secondary text

+
+``` + +**Correct:** + +```svelte +
+

Secondary text

+
+``` + +--- + +## No raw color values for status/state indicators + +For positive, negative, or status indicators, use Badge variants, semantic tokens like `text-destructive`, or define custom CSS variables — don't reach for raw Tailwind colors. + +**Incorrect:** + +```svelte ++20.1% +Active +-3.2% +``` + +**Correct:** + +```svelte + + ++20.1% +Active +-3.2% +``` + +If you need a success/positive color that doesn't exist as a semantic token, use a Badge variant or ask the user about adding a custom CSS variable to the theme (see [customization.md](../customization.md)). + +--- + +## Built-in variants first + +**Incorrect:** + +```svelte + + + +``` + +**Correct:** + +```svelte + + + +``` + +--- + +## class for layout only + +Use `class` for layout (e.g. `max-w-md`, `mx-auto`, `mt-4`), **not** for overriding component colors or typography. To change colors, use semantic tokens, built-in variants, or CSS variables. + +**Incorrect:** + +```svelte + + + + Dashboard + +``` + +**Correct:** + +```svelte + + + + Dashboard + +``` + +To customize a component's appearance, prefer these approaches in order: + +1. **Built-in variants** — `variant="outline"`, `variant="destructive"`, etc. +2. **Semantic color tokens** — `bg-primary`, `text-muted-foreground`. +3. **CSS variables** — define custom colors in the global CSS file (see [customization.md](../customization.md)). + +--- + +## No space-x-_ / space-y-_ + +Use `gap-*` instead. `space-y-4` → `flex flex-col gap-4`. `space-x-2` → `flex gap-2`. + +```svelte + + +
+ + + +
+``` + +--- + +## Prefer size-_ over w-_ h-\* when equal + +`size-10` not `w-10 h-10`. Applies to icons, avatars, skeletons, etc. + +--- + +## Prefer truncate shorthand + +`truncate` not `overflow-hidden text-ellipsis whitespace-nowrap`. + +--- + +## No manual dark: color overrides + +Use semantic tokens — they handle light/dark via CSS variables. `bg-background text-foreground` not `bg-white dark:bg-gray-950`. + +--- + +## Use cn() for conditional classes + +Use the `cn()` utility from the project for conditional or merged class names. Don't write manual ternaries in `class` strings. + +**Incorrect:** + +```svelte + + +
+``` + +**Correct:** + +```svelte + + +
+``` + +--- + +## No manual z-index on overlay components + +`Dialog`, `Sheet`, `Drawer`, `AlertDialog`, `DropdownMenu`, `Popover`, `Tooltip`, `HoverCard` handle their own stacking. Never add `z-50` or `z-[999]`. diff --git a/internal/db/runs.go b/internal/db/runs.go index 95df9be..d2c7b12 100644 --- a/internal/db/runs.go +++ b/internal/db/runs.go @@ -31,6 +31,12 @@ type RunStore interface { // Scenario status tracking CreateScenarioStatus(ctx context.Context, runID uuid.UUID, name string) (uuid.UUID, error) UpdateScenarioPhase(ctx context.Context, id uuid.UUID, phase string) error + // UpdateScenarioIdentity records executor identity mid-run (after detonation), + // touching only the identity columns and leaving status/phase untouched. + UpdateScenarioIdentity(ctx context.Context, id uuid.UUID, executorName, executorType, executionID, simulationID string) error + // UpdateScenarioAssertions persists partial assertion results mid-run, + // touching only the assertions column and leaving status/phase untouched. + UpdateScenarioAssertions(ctx context.Context, id uuid.UUID, assertionsJSON []byte) error CompleteScenarioResult(ctx context.Context, id uuid.UUID, result *ScenarioResult) error IncrementRunCounters(ctx context.Context, id uuid.UUID, successDelta, failDelta int) error @@ -337,6 +343,22 @@ func (s *runStore) UpdateScenarioPhase(ctx context.Context, id uuid.UUID, phase return err } +func (s *runStore) UpdateScenarioIdentity(ctx context.Context, id uuid.UUID, executorName, executorType, executionID, simulationID string) error { + _, err := s.pool.Exec(ctx, + `UPDATE scenario_results SET executor_name = $2, executor_type = $3, execution_id = $4, simulation_id = $5 WHERE id = $1`, + id, executorName, executorType, executionID, simulationID, + ) + return err +} + +func (s *runStore) UpdateScenarioAssertions(ctx context.Context, id uuid.UUID, assertionsJSON []byte) error { + _, err := s.pool.Exec(ctx, + `UPDATE scenario_results SET assertions = $2 WHERE id = $1`, + id, assertionsJSON, + ) + return err +} + func (s *runStore) CompleteScenarioResult(ctx context.Context, id uuid.UUID, result *ScenarioResult) error { _, err := s.pool.Exec(ctx, `UPDATE scenario_results SET diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 18af6fd..c19fe76 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -99,6 +99,10 @@ func (m *Runner) runScenario(scenario *Scenario) (string, float64, error) { executionOutput["simulation_id"] = scenario.Detonator.SimulationId() } + // Surface executor identity now that detonation has resolved execution_id / + // simulation_id, so a still-running row shows what is executing and where. + reportIdentity(scenario, executionId) + // If no assertions, no collector, and not explore mode, just return if !scenario.ExploreMode && len(scenario.Assertions) == 0 && scenario.Collector == nil { return executionId, 0, nil @@ -231,6 +235,8 @@ func (m *Runner) runAssertions(scenario *Scenario, indicators []string, logger * logger.Info("Waiting for assertions") hasDeadline := scenario.Timeout > 0 + matchedSet := make(map[matchers.AlertGeneratedMatcher]bool, len(scenario.Assertions)) + for len(remainingAssertions) > 0 { if hasDeadline && time.Now().After(deadline) { logger.WithFields(logrus.Fields{ @@ -247,7 +253,11 @@ func (m *Runner) runAssertions(scenario *Scenario, indicators []string, logger * if !matched { remainingAssertions <- assertion time.Sleep(m.Interval) + continue } + // Newly matched: persist the updated partial state (write-on-change). + matchedSet[assertion] = true + reportAssertions(scenario, matchedSet) } numRemainingAssertions := len(remainingAssertions) @@ -406,6 +416,50 @@ func reportStatus(scenario *Scenario, phase string) { } } +// reportIdentity emits the executor identity once detonation has resolved. +// Executor name/type are derived the same way results.runSingleScenario does. +func reportIdentity(scenario *Scenario, executionID string) { + if scenario.IdentityCallback == nil { + return + } + identity := ScenarioIdentity{ExecutionID: executionID} + switch { + case scenario.Detonator != nil: + identity.ExecutorType = "detonator" + identity.ExecutorName = scenario.Detonator.String() + identity.SimulationID = scenario.Detonator.SimulationId() + case scenario.Injector != nil: + identity.ExecutorType = "injector" + identity.ExecutorName = scenario.Injector.String() + default: + identity.ExecutorType = "unknown" + identity.ExecutorName = "unknown" + } + scenario.IdentityCallback(scenario.Name, identity) +} + +// reportAssertions emits the current pass/pending state of every assertion. +// Matched assertions carry Passed=true; not-yet-matched ones carry Passed=nil +// (pending) — no terminal failure is recorded until completion. +func reportAssertions(scenario *Scenario, matchedSet map[matchers.AlertGeneratedMatcher]bool) { + if scenario.AssertionsCallback == nil { + return + } + results := make([]AssertionResult, 0, len(scenario.Assertions)) + for _, a := range scenario.Assertions { + r := AssertionResult{ + MatcherType: a.MatcherName(), + AlertName: a.AlertName(), + } + if matchedSet[a] { + passed := true + r.Passed = &passed + } + results = append(results, r) + } + scenario.AssertionsCallback(scenario.Name, results) +} + func (m *Runner) buildIndicatorsList(scenario *Scenario, detonationOutput map[string]string) []string { var indicators []string diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index c6c46c1..60e1587 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -302,6 +302,100 @@ func TestRunnerExploreModeSucceedsWhenAlertsDiscovered(t *testing.T) { assert.Equal(t, "alert-1", scenario.DiscoveredAlerts[0].AlertID) } +// TestRunnerFiresIdentityAndAssertionCallbacks verifies the mid-run hooks that +// power the live scenario detail view: identity is emitted exactly once after +// detonation, and the assertions callback fires once per newly-matched +// assertion, carrying passed=true for matches and nil (pending) for the rest. +func TestRunnerFiresIdentityAndAssertionCallbacks(t *testing.T) { + mockDetonator := &detonatorMocks.MockDetonator{} + mockDetonator.On("Detonate").Return(map[string]string{"execution_id": "exec-123"}, nil) + mockDetonator.On("String").Return("mock-detonator") + mockDetonator.On("SimulationId").Return("sim-456") + mockDetonator.On("PackName").Return("") + mockDetonator.On("SetStatusCallback", mock.AnythingOfType("func(string)")).Return() + + // matcherA matches on the first poll; matcherB only on the second, so the + // assertions callback must fire twice with distinct partial states. + matcherA := &matcherMocks.MockAlertGeneratedMatcher{} + matcherA.On("HasExpectedAlert", []string{"exec-123"}, mock.AnythingOfType("*logrus.Entry")).Return(true, nil) + matcherA.On("MatcherName").Return("Elastic") + matcherA.On("AlertName").Return("alert-a") + matcherA.On("String").Return("alert-a") + matcherA.On("Cleanup", []string{"exec-123"}, mock.AnythingOfType("*logrus.Entry")).Return(nil) + + matcherB := &matcherMocks.MockAlertGeneratedMatcher{} + matcherB.On("HasExpectedAlert", []string{"exec-123"}, mock.AnythingOfType("*logrus.Entry")).Return(false, nil).Once() + matcherB.On("HasExpectedAlert", []string{"exec-123"}, mock.AnythingOfType("*logrus.Entry")).Return(true, nil) + matcherB.On("MatcherName").Return("Elastic") + matcherB.On("AlertName").Return("alert-b") + matcherB.On("String").Return("alert-b") + matcherB.On("Cleanup", []string{"exec-123"}, mock.AnythingOfType("*logrus.Entry")).Return(nil) + + var identities []ScenarioIdentity + var assertionSnapshots [][]AssertionResult + + scenario := &Scenario{ + Name: "callback-scenario", + Detonator: mockDetonator, + Assertions: []matchers.AlertGeneratedMatcher{matcherA, matcherB}, + Timeout: 5 * time.Second, + IdentityCallback: func(_ string, id ScenarioIdentity) { + identities = append(identities, id) + }, + AssertionsCallback: func(_ string, results []AssertionResult) { + snap := make([]AssertionResult, len(results)) + copy(snap, results) + assertionSnapshots = append(assertionSnapshots, snap) + }, + } + + runner := Runner{Scenarios: []*Scenario{scenario}, Interval: 1 * time.Millisecond} + results, err := runner.Run() + assert.NoError(t, err) + assert.Len(t, results, 1) + assert.True(t, results[0].Success) + + // Identity fires exactly once, after detonation, carrying all four fields. + assert.Len(t, identities, 1) + assert.Equal(t, ScenarioIdentity{ + ExecutorName: "mock-detonator", + ExecutorType: "detonator", + ExecutionID: "exec-123", + SimulationID: "sim-456", + }, identities[0]) + + // One callback per newly-matched assertion: A then B → two snapshots. + assert.Len(t, assertionSnapshots, 2) + + first := assertionSnapshots[0] + assertAssertionPassed(t, first, "alert-a", boolPtr(true)) + assertAssertionPassed(t, first, "alert-b", nil) // still pending + + last := assertionSnapshots[len(assertionSnapshots)-1] + assertAssertionPassed(t, last, "alert-a", boolPtr(true)) + assertAssertionPassed(t, last, "alert-b", boolPtr(true)) +} + +func boolPtr(b bool) *bool { return &b } + +func assertAssertionPassed(t *testing.T, results []AssertionResult, alertName string, want *bool) { + t.Helper() + for _, r := range results { + if r.AlertName != alertName { + continue + } + if want == nil { + assert.Nil(t, r.Passed, "%s should be pending", alertName) + } else { + if assert.NotNil(t, r.Passed, "%s should be resolved", alertName) { + assert.Equal(t, *want, *r.Passed, "%s passed state", alertName) + } + } + return + } + t.Fatalf("assertion %q not found in snapshot", alertName) +} + func TestRunnerWithInjector(t *testing.T) { // Mock injector mockInjector := &injectorMocks.MockInjector{} diff --git a/internal/runner/scenario.go b/internal/runner/scenario.go index adc4d58..52aaccd 100644 --- a/internal/runner/scenario.go +++ b/internal/runner/scenario.go @@ -21,8 +21,13 @@ type Scenario struct { Indicators *Indicators Metadata *Metadata StatusCallback func(scenarioName, phase string) - ExploreMode bool // when true, discover all matching alerts instead of asserting specific rules - CleanupAlerts bool // when true in explore mode, close discovered alerts after run + // IdentityCallback fires once after detonation, carrying executor identity. + IdentityCallback func(scenarioName string, identity ScenarioIdentity) + // AssertionsCallback fires when an assertion newly matches, carrying the + // current pass/pending state of every assertion. + AssertionsCallback func(scenarioName string, results []AssertionResult) + ExploreMode bool // when true, discover all matching alerts instead of asserting specific rules + CleanupAlerts bool // when true in explore mode, close discovered alerts after run // Populated by runner after assertion matching completes FailedAssertions []matchers.AlertGeneratedMatcher @@ -35,6 +40,22 @@ type Scenario struct { CollectedDocCount int } +// ScenarioIdentity is the executor identity surfaced mid-run, after detonation. +type ScenarioIdentity struct { + ExecutorName string + ExecutorType string + ExecutionID string + SimulationID string +} + +// AssertionResult is the mid-run state of a single assertion. Passed is nil +// while the assertion is still pending (not yet matched). +type AssertionResult struct { + MatcherType string + AlertName string + Passed *bool +} + // DiscoveredAlert represents an alert found during explore mode. type DiscoveredAlert struct { RuleName string `json:"ruleName"` diff --git a/internal/testutil/fakes/fakes.go b/internal/testutil/fakes/fakes.go index f374d7e..1b6fb2a 100644 --- a/internal/testutil/fakes/fakes.go +++ b/internal/testutil/fakes/fakes.go @@ -228,6 +228,31 @@ func (s *RunStore) UpdateScenarioPhase(_ context.Context, id uuid.UUID, phase st return nil } +func (s *RunStore) UpdateScenarioIdentity(_ context.Context, id uuid.UUID, executorName, executorType, executionID, simulationID string) error { + s.mu.Lock() + defer s.mu.Unlock() + r, ok := s.results[id] + if !ok { + return pgx.ErrNoRows + } + r.ExecutorName = executorName + r.ExecutorType = executorType + r.ExecutionID = executionID + r.SimulationID = simulationID + return nil +} + +func (s *RunStore) UpdateScenarioAssertions(_ context.Context, id uuid.UUID, assertionsJSON []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + r, ok := s.results[id] + if !ok { + return pgx.ErrNoRows + } + r.Assertions = assertionsJSON + return nil +} + func (s *RunStore) CompleteScenarioResult(_ context.Context, id uuid.UUID, result *db.ScenarioResult) error { s.mu.Lock() defer s.mu.Unlock() diff --git a/internal/testutil/fakes/fakes_test.go b/internal/testutil/fakes/fakes_test.go index 76a8b9c..95509c2 100644 --- a/internal/testutil/fakes/fakes_test.go +++ b/internal/testutil/fakes/fakes_test.go @@ -32,6 +32,45 @@ func TestRunStore_ListExpired(t *testing.T) { assert.NotContains(t, ids, oldRunning, "running run must be skipped even when old") } +// UpdateScenarioIdentity and UpdateScenarioAssertions are the mid-run partial +// writes behind the live scenario detail view: each must touch only its own +// columns and leave the lifecycle status/phase untouched. (Production SQL is +// Postgres-bound and verified manually per task 5.3; this pins the contract the +// web wiring relies on.) +func TestRunStore_PartialScenarioUpdates(t *testing.T) { + s := New().Run + ctx := context.Background() + runID := mustCreateRun(t, ctx, s, "running", time.Now()) + + id, err := s.CreateScenarioStatus(ctx, runID, "scn") + require.NoError(t, err) + require.NoError(t, s.UpdateScenarioPhase(ctx, id, "matching")) + + // Identity write: only identity columns change; status/phase preserved. + require.NoError(t, s.UpdateScenarioIdentity(ctx, id, "elastic-detonator", "detonator", "exec-9", "sim-9")) + got, err := s.GetScenarioResult(ctx, id) + require.NoError(t, err) + assert.Equal(t, "running", got.Status, "identity write must not change status") + require.NotNil(t, got.Phase) + assert.Equal(t, "matching", *got.Phase, "identity write must not change phase") + assert.Equal(t, "elastic-detonator", got.ExecutorName) + assert.Equal(t, "detonator", got.ExecutorType) + assert.Equal(t, "exec-9", got.ExecutionID) + assert.Equal(t, "sim-9", got.SimulationID) + assert.Nil(t, got.Assertions, "identity write must not populate assertions") + + // Assertions write: only the assertions column changes; everything else stays. + partial := []byte(`[{"matcherType":"Elastic","alertName":"a","passed":true},{"matcherType":"Elastic","alertName":"b"}]`) + require.NoError(t, s.UpdateScenarioAssertions(ctx, id, partial)) + got, err = s.GetScenarioResult(ctx, id) + require.NoError(t, err) + assert.Equal(t, "running", got.Status, "assertions write must not change status") + require.NotNil(t, got.Phase) + assert.Equal(t, "matching", *got.Phase, "assertions write must not change phase") + assert.Equal(t, "exec-9", got.ExecutionID, "assertions write must not touch identity") + assert.JSONEq(t, string(partial), string(got.Assertions)) +} + func mustCreateRun(t *testing.T, ctx context.Context, s *RunStore, status string, createdAt time.Time) uuid.UUID { t.Helper() id := uuid.New() diff --git a/internal/web/scenario_results.go b/internal/web/scenario_results.go index 098a5e3..4eef163 100644 --- a/internal/web/scenario_results.go +++ b/internal/web/scenario_results.go @@ -6,6 +6,7 @@ import ( "github.com/IBM/simrun/internal/db" "github.com/IBM/simrun/internal/matchers" "github.com/IBM/simrun/internal/results" + "github.com/IBM/simrun/internal/runner" "github.com/google/uuid" log "github.com/sirupsen/logrus" ) @@ -18,6 +19,30 @@ type assertionDTO struct { Passed bool `json:"passed"` } +// partialAssertionDTO is the mid-run counterpart of assertionDTO: a pending +// (not-yet-matched) assertion omits `passed` so the frontend renders it muted +// rather than as a failure. The terminal write uses assertionDTO (passed always +// present) instead. +type partialAssertionDTO struct { + MatcherType string `json:"matcherType"` + AlertName string `json:"alertName"` + Passed *bool `json:"passed,omitempty"` +} + +// buildPartialAssertionsJSON marshals the runner's mid-run assertion state into +// the same wire shape as buildScenarioResultRow, preserving pending vs passed. +func buildPartialAssertionsJSON(results []runner.AssertionResult) ([]byte, error) { + dtos := make([]partialAssertionDTO, 0, len(results)) + for _, r := range results { + dtos = append(dtos, partialAssertionDTO{ + MatcherType: r.MatcherType, + AlertName: r.AlertName, + Passed: r.Passed, + }) + } + return json.Marshal(dtos) +} + // buildScenarioResultRow projects an in-memory scenario result into the // `scenario_results` row shape: marshals assertions/indicators/metadata/ // discovered-alerts and copies scalar fields. Marshaling errors are logged diff --git a/internal/web/scenarios.go b/internal/web/scenarios.go index 54f20dc..da12a50 100644 --- a/internal/web/scenarios.go +++ b/internal/web/scenarios.go @@ -11,6 +11,7 @@ import ( "github.com/IBM/simrun/internal/db" "github.com/IBM/simrun/internal/parser" "github.com/IBM/simrun/internal/results" + "github.com/IBM/simrun/internal/runner" "github.com/google/uuid" log "github.com/sirupsen/logrus" ) @@ -234,6 +235,28 @@ func (s *ScenarioService) Run(ctx context.Context, scenarioID uuid.UUID, opts *R } } } + sc.IdentityCallback = func(scenarioName string, identity runner.ScenarioIdentity) { + if dbID, ok := scenarioDBIDs[scenarioName]; ok { + if err := s.runStore.UpdateScenarioIdentity(context.Background(), dbID, + identity.ExecutorName, identity.ExecutorType, identity.ExecutionID, identity.SimulationID); err != nil { + log.WithField("scenario", scenarioName).WithError(err).Warn("Failed to update scenario identity") + } + } + } + sc.AssertionsCallback = func(scenarioName string, assertions []runner.AssertionResult) { + dbID, ok := scenarioDBIDs[scenarioName] + if !ok { + return + } + assertionsJSON, err := buildPartialAssertionsJSON(assertions) + if err != nil { + log.WithField("scenario", scenarioName).WithError(err).Warn("Failed to marshal partial assertions") + return + } + if err := s.runStore.UpdateScenarioAssertions(context.Background(), dbID, assertionsJSON); err != nil { + log.WithField("scenario", scenarioName).WithError(err).Warn("Failed to update scenario assertions") + } + } } // Create per-run log writer diff --git a/openspec/changes/assessment-retention/.openspec.yaml b/openspec/changes/archive/2026-06-23-assessment-retention/.openspec.yaml similarity index 100% rename from openspec/changes/assessment-retention/.openspec.yaml rename to openspec/changes/archive/2026-06-23-assessment-retention/.openspec.yaml diff --git a/openspec/changes/assessment-retention/design.md b/openspec/changes/archive/2026-06-23-assessment-retention/design.md similarity index 100% rename from openspec/changes/assessment-retention/design.md rename to openspec/changes/archive/2026-06-23-assessment-retention/design.md diff --git a/openspec/changes/assessment-retention/proposal.md b/openspec/changes/archive/2026-06-23-assessment-retention/proposal.md similarity index 100% rename from openspec/changes/assessment-retention/proposal.md rename to openspec/changes/archive/2026-06-23-assessment-retention/proposal.md diff --git a/openspec/changes/assessment-retention/specs/assessment-retention/spec.md b/openspec/changes/archive/2026-06-23-assessment-retention/specs/assessment-retention/spec.md similarity index 100% rename from openspec/changes/assessment-retention/specs/assessment-retention/spec.md rename to openspec/changes/archive/2026-06-23-assessment-retention/specs/assessment-retention/spec.md diff --git a/openspec/changes/assessment-retention/specs/runs/spec.md b/openspec/changes/archive/2026-06-23-assessment-retention/specs/runs/spec.md similarity index 100% rename from openspec/changes/assessment-retention/specs/runs/spec.md rename to openspec/changes/archive/2026-06-23-assessment-retention/specs/runs/spec.md diff --git a/openspec/changes/assessment-retention/tasks.md b/openspec/changes/archive/2026-06-23-assessment-retention/tasks.md similarity index 100% rename from openspec/changes/assessment-retention/tasks.md rename to openspec/changes/archive/2026-06-23-assessment-retention/tasks.md diff --git a/openspec/changes/archive/2026-06-23-live-scenario-detail/.openspec.yaml b/openspec/changes/archive/2026-06-23-live-scenario-detail/.openspec.yaml new file mode 100644 index 0000000..a4ac4d7 --- /dev/null +++ b/openspec/changes/archive/2026-06-23-live-scenario-detail/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-23 diff --git a/openspec/changes/archive/2026-06-23-live-scenario-detail/design.md b/openspec/changes/archive/2026-06-23-live-scenario-detail/design.md new file mode 100644 index 0000000..5efa57e --- /dev/null +++ b/openspec/changes/archive/2026-06-23-live-scenario-detail/design.md @@ -0,0 +1,58 @@ +## Context + +A scenario run is orchestrated in `web/scenarios.go`: a `scenario_results` row is pre-created as `pending` (`CreateScenarioStatus`), a `name → dbID` map is built, and each scenario's `StatusCallback(name, phase)` is wired to `runStore.UpdateScenarioPhase(dbID, phase)`. The runner (`internal/runner/runner.go`) drives detonation → matching → cleanup, calling `reportStatus` at each phase. Only at completion does `RunScenariosParallel`'s callback build the full row and call `CompleteScenarioResult(dbID, row)`. + +Consequently, between `pending` and `completed` the row carries only `status` + `phase`. Yet the runner already holds everything else mid-run: +- Executor identity — `executor_name`/`executor_type`/`simulation_id` are derivable from `scenario.Detonator`/`Injector` with no detonation at all (`String()`, `SimulationId()`); `execution_id` is available the instant detonation returns (`runner.go` ~L95). +- Assertion progress — `runAssertions` (`runner.go:217-259`) resolves expected alerts one-by-one off a `remainingAssertions` channel; only the *failed* remainder is recorded, and only at completion. + +The frontend already polls `GET /api/runs/{runId}` every 5s and renders from `scenario_results`, so any column written earlier is surfaced with no new transport. + +## Goals / Non-Goals + +**Goals:** +- A `running` scenario row exposes executor identity (`executor_name`, `executor_type`, `execution_id`, `simulation_id`) once detonation has occurred. +- A `matching` scenario row exposes per-assertion pass/pending state as the matcher resolves each expected alert. +- Reuse the existing poll path and the existing `name → dbID` callback pattern; no new transport. + +**Non-Goals:** +- No new WebSocket message types or server push (the `assertion_update`/`scenario_started` types in the frontend `types.ts` stay unwired). +- No incremental `discovered_alerts` for explore mode (future work). +- No DB schema migration — target columns already exist on `scenario_results`. +- No change to the terminal completion write or to run-counter logic. + +## Decisions + +**1. Two new optional callbacks on `Scenario`, wired exactly like `StatusCallback`.** +Add `IdentityCallback func(name string, id ScenarioIdentity)` and `AssertionsCallback func(name string, results []AssertionResult)` to `runner.Scenario`. `web/scenarios.go` wires them to new `RunStore` methods keyed by the same `scenarioDBIDs[name]` map. +- *Why:* mirrors the proven phase-callback flow; keeps the runner transport-agnostic and the web layer the single place that knows about `dbID`. +- *Alternatives:* a single generic `ProgressCallback` (rejected — `phase` already owns status transitions; separate concerns read clearer); WebSocket push (rejected — adds plumbing, diverges from the poll model `phase` already uses). + +**2. Fire identity once, right after detonation returns.** +In `runner.go` immediately after `executionId` (and `simulation_id`) are resolved, emit the identity callback carrying all four fields. Executor name/type are computed the same way `results/executor.go` already does. +- *Why:* a single write; `execution_id` is the highest-value "where" field (correlates to the Terraform working dir). Detonation is fast relative to the matching window, so waiting for it costs little. +- *Alternative:* emit name/type at queue time, `execution_id` later (two writes) — rejected as needless chatter. + +**3. Incremental assertions use a tri-state, written on change only.** +After each successful match in the `runAssertions` loop, emit the current assertion set: matched → `passed = true`, not-yet-matched → `passed` omitted (pending). The terminal completion write continues to set `passed = true/false` definitively (a still-unmatched assertion becomes `false` only then). The callback fires only when an assertion newly resolves, not on every poll tick. +- *Why:* avoids rendering an unmatched-but-still-polling assertion as a red failure; the optional `passed?: boolean` field already models pending. Write-on-change keeps UPDATE volume to roughly one per matched assertion. +- *Alternative:* write on every poll iteration (rejected — write amplification for no signal change). + +**4. New DB methods do column-scoped partial UPDATEs.** +`UpdateScenarioIdentity(ctx, id, name, typ, execID, simID)` and `UpdateScenarioAssertions(ctx, id, assertionsJSON)` update only their columns and leave `status='running'`/`phase` untouched. Add to the `RunStore` interface and regenerate mocks. +- *Why:* preserves the lifecycle state machine; each scenario writes only its own row by `dbID`, so parallel scenarios never contend. + +## Risks / Trade-offs + +- **Frontend miscolors pending assertions as failed** → tri-state: only the completion write sets `passed=false`; mid-run unmatched assertions carry `passed` undefined and render muted. The assertion mini-bar gains a third (pending) color. +- **Write amplification from the matcher loop** → fire the assertions callback only on a newly-resolved assertion, not per poll tick; identity is a single write. +- **Failed/aborted detonation** → identity callback may carry an empty `execution_id`; this is acceptable (mirrors today's tolerance for empty `simulation_id`) and the terminal completion write still produces the authoritative row. +- **Callback nil-safety** → both new callbacks are optional and nil-guarded like `reportStatus`, so non-web callers (tests, CLI paths) are unaffected. + +## Migration Plan + +No schema migration. Ship backend + frontend together. Rollback is a plain revert: running rows simply return to `status` + `phase` only, and an older frontend harmlessly ignores the earlier-populated fields (backward compatible). + +## Open Questions + +- Should explore mode surface `discovered_alerts` incrementally the same way? Deferred to a follow-up; this change scopes to assertion-based scenarios + identity. diff --git a/openspec/changes/archive/2026-06-23-live-scenario-detail/proposal.md b/openspec/changes/archive/2026-06-23-live-scenario-detail/proposal.md new file mode 100644 index 0000000..5c5a563 --- /dev/null +++ b/openspec/changes/archive/2026-06-23-live-scenario-detail/proposal.md @@ -0,0 +1,28 @@ +## Why + +While a scenario is running, the only per-scenario data we expose is `status` + `phase`, so the UI can show nothing but a phase badge and the streamed log lines. The executor identity, the resource IDs, and per-assertion match results are all known to the runner mid-run but are flushed to `scenario_results` in a single write at completion — so a running row reads "in progress" with no sense of *what* is executing, *where*, or *how close* detection is to passing. The data already exists in the runner; it just isn't persisted until the end. + +## What Changes + +- Persist executor identity to the `scenario_results` row **right after detonation** (before matching begins): `executor_name`, `executor_type`, `execution_id`, and `simulation_id`. A running row then shows what is executing and where, surfaced through the existing `GET /api/runs/{runId}` poll. +- Flush per-assertion results **incrementally as the matcher resolves them**, rather than only at completion. As each expected alert matches (or the matching window advances), the row's `assertions` reflect current pass/pending state, enabling a live "2/3 matched" view. +- No new WebSocket message types or backend push plumbing — both rely on incremental `UPDATE`s to `scenario_results` picked up by the frontend's existing 5s poll, consistent with how `phase` is surfaced today. +- Frontend renders the now-available executor identity and partial assertion progress on `running` scenario rows (today these are gated to `completed` rows). + +## Capabilities + +### New Capabilities + +_None._ + +### Modified Capabilities + +- `runs`: The **Scenario Result Lifecycle** requirement is extended so that a `scenario_results` row in the `running` state exposes executor identity (after detonation) and incrementally-updated assertion results (during matching), not just `status` + `phase`. + +## Impact + +- **DB layer** (`internal/db/runs.go`): new/extended scenario-result update methods — one to record executor identity post-detonation, one to upsert incremental assertion results during matching. New `RunStore` interface methods + mock regeneration. +- **Runner** (`internal/runner/runner.go`): invoke the post-detonation persistence hook once the executor returns identity, and persist each assertion outcome inside the existing `runAssertions` poll loop as it resolves. +- **Runner↔DB wiring**: extend the per-scenario callback/store currently used for `UpdateScenarioPhase` to carry the new writes. +- **Frontend** (`web/frontend/src/lib/components/ScenarioResult.svelte`, `scenario-tracker`): show executor identity and partial assertion ticks for `running` entries. +- No schema migration expected — the target columns (`executor_name`, `executor_type`, `execution_id`, `simulation_id`, `assertions`) already exist on `scenario_results`; this change writes them earlier. diff --git a/openspec/changes/archive/2026-06-23-live-scenario-detail/specs/runs/spec.md b/openspec/changes/archive/2026-06-23-live-scenario-detail/specs/runs/spec.md new file mode 100644 index 0000000..adf6921 --- /dev/null +++ b/openspec/changes/archive/2026-06-23-live-scenario-detail/specs/runs/spec.md @@ -0,0 +1,40 @@ +## MODIFIED Requirements + +### Requirement: Scenario Result Lifecycle +The system SHALL track each scenario as a row in `scenario_results` with +`status` transitioning `pending` → `running` → `completed`, and `phase` +populated during `running` (e.g., `"warmup"`, `"detonating"`, `"matching"`, +`"collecting"`, `"cleanup"`, `"queued"`). + +The system SHALL populate the row's executor identity — `executor_name`, +`executor_type`, `execution_id`, and `simulation_id` — as soon as detonation +returns these values, while the row is still `running`, rather than only at +completion. When a detonator does not produce a `simulation_id`, that field +SHALL remain empty without blocking the other identity fields. + +While in the `matching` phase, the system SHALL persist per-assertion results +incrementally as the matcher resolves them, so the row's `assertions` reflect +the current passed/pending state before the scenario completes. An assertion +not yet matched SHALL be represented as not-yet-passed (no terminal failure is +recorded until completion). + +These incremental writes SHALL NOT alter the terminal completion write: when a +scenario completes, `status` becomes `completed`, `phase` is cleared, and the +final `is_success`, `assertions`, durations, and `discovered_alerts` are +written as they are today. + +#### Scenario: Phase transitions +- **WHEN** a scenario enters the matching phase +- **THEN** its `scenario_results` row has `status = "running"` and `phase = "matching"` + +#### Scenario: Executor identity exposed during run +- **WHEN** a scenario has detonated and is in the `matching` phase +- **THEN** `GET /api/runs/{runId}` returns that scenario with `status = "running"` and non-empty `executor_name`, `executor_type`, and `execution_id` + +#### Scenario: Assertion progress exposed during matching +- **WHEN** a scenario expecting 3 assertions has matched 2 of them and is still matching +- **THEN** the scenario's `assertions` in `GET /api/runs/{runId}` show 2 passed and 1 not-yet-passed while `status = "running"` + +#### Scenario: Completion write unchanged +- **WHEN** a scenario finishes after its identity and partial assertions were written mid-run +- **THEN** the final row has `status = "completed"`, `phase = null`, and `is_success` plus the full `assertions` and `discovered_alerts` set, with no stale `running`-phase values diff --git a/openspec/changes/archive/2026-06-23-live-scenario-detail/tasks.md b/openspec/changes/archive/2026-06-23-live-scenario-detail/tasks.md new file mode 100644 index 0000000..96dcdb9 --- /dev/null +++ b/openspec/changes/archive/2026-06-23-live-scenario-detail/tasks.md @@ -0,0 +1,29 @@ +## 1. DB layer + +- [x] 1.1 Add `UpdateScenarioIdentity(ctx, id uuid.UUID, executorName, executorType, executionID, simulationID string) error` to the `RunStore` interface in `internal/db/runs.go`, implementing a column-scoped `UPDATE scenario_results SET executor_name=…, executor_type=…, execution_id=…, simulation_id=… WHERE id=$1` that leaves `status`/`phase` untouched. +- [x] 1.2 Add `UpdateScenarioAssertions(ctx, id uuid.UUID, assertionsJSON []byte) error` to `RunStore`, implementing `UPDATE scenario_results SET assertions=$2 WHERE id=$1` (status/phase untouched). +- [x] 1.3 Regenerate mocks (`go generate ./...`) so the `RunStore` mock includes the two new methods. (RunStore is mocked manually in `internal/testutil/fakes/fakes.go`, not via mockery — added both methods there; `go generate ./...` runs clean.) + +## 2. Runner callbacks + +- [x] 2.1 Add `IdentityCallback func(name string, id ScenarioIdentity)` and `AssertionsCallback func(name string, results []AssertionResult)` (with their small value types) to `runner.Scenario` in `internal/runner/scenario.go`, alongside `StatusCallback`. +- [x] 2.2 In `internal/runner/runner.go`, immediately after `executionId`/`simulation_id` are resolved post-detonation, invoke `IdentityCallback` (nil-guarded like `reportStatus`) with executor name/type (derived as in `results/executor.go`), `execution_id`, and `simulation_id`. +- [x] 2.3 In `runAssertions`, after each assertion newly matches, invoke `AssertionsCallback` with the current assertion set: matched → `passed=true`, not-yet-matched → `passed` omitted (pending). Fire only on a state change, not every poll tick. + +## 3. Web wiring + +- [x] 3.1 In `internal/web/scenarios.go`, wire `sc.IdentityCallback` to `runStore.UpdateScenarioIdentity(scenarioDBIDs[name], …)`, mirroring the existing `StatusCallback`→`UpdateScenarioPhase` block (nil/missing-id safe, log-on-error). +- [x] 3.2 Wire `sc.AssertionsCallback` to marshal the partial assertions and call `runStore.UpdateScenarioAssertions(scenarioDBIDs[name], json)`, reusing the same assertion JSON shape produced by `buildScenarioResultRow`/`scenario_results.go`. +- [x] 3.3 Confirm the terminal `CompleteScenarioResult` write still sets the authoritative `is_success`, full `assertions` (with terminal `passed=false` for unmatched), durations, and `discovered_alerts`, overwriting any mid-run partial values. (Unchanged: `CompleteScenarioResult` does `UPDATE … assertions = $11 …`, overwriting the partial column; `buildScenarioResultRow` still emits `assertionDTO` with `passed` always set.) + +## 4. Frontend + +- [x] 4.1 In `web/frontend/src/lib/components/ScenarioResult.svelte`, surface executor identity (executor name, simulation id, matcher) on `running` entries, not only `completed` ones. +- [x] 4.2 Extend the assertion mini-bar to a tri-state: `passed===true` → success, `passed===false` → error, `passed` undefined/null → muted (pending); show the running n/m matched count. +- [x] 4.3 Verify the `scenario-tracker` carries partial `assertions`/identity from the poll for `running` rows (entries currently keep `result` only for completed — adjust so a running entry can expose the partial `ScenarioResult` fields). + +## 5. Verification + +- [x] 5.1 Add/extend Go tests: `UpdateScenarioIdentity` and `UpdateScenarioAssertions` write only their columns and preserve `status='running'` (`TestRunStore_PartialScenarioUpdates` in `fakes_test.go`, pinning the contract the web wiring relies on — the production SQL is Postgres-bound, no DB harness exists); the runner fires `IdentityCallback` once post-detonation and `AssertionsCallback` on each new match (`TestRunnerFiresIdentityAndAssertionCallbacks`). +- [x] 5.2 `go test ./...`, `mise run lint`, and frontend `npm run check` + `npm run build` all pass. (Go tests pass; frontend check = 0 errors; frontend build OK; changed Go packages lint clean = 0 issues. NOTE: `mise run lint` over the whole repo fails on a pre-existing `internal/parser/parser.go` goimports issue — a generated file untouched by this change.) +- [x] 5.3 Manually verify against a running assessment: a `matching` scenario shows executor/IDs and a live "k/n matched" assertion bar that fills in before completion, and the final state matches today's completed view. (Verified by the user against a live Elastic deployment.) diff --git a/openspec/changes/archive/2026-06-23-migrate-shadcn-svelte-style/.openspec.yaml b/openspec/changes/archive/2026-06-23-migrate-shadcn-svelte-style/.openspec.yaml new file mode 100644 index 0000000..a4ac4d7 --- /dev/null +++ b/openspec/changes/archive/2026-06-23-migrate-shadcn-svelte-style/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-23 diff --git a/openspec/changes/archive/2026-06-23-migrate-shadcn-svelte-style/design.md b/openspec/changes/archive/2026-06-23-migrate-shadcn-svelte-style/design.md new file mode 100644 index 0000000..0e11592 --- /dev/null +++ b/openspec/changes/archive/2026-06-23-migrate-shadcn-svelte-style/design.md @@ -0,0 +1,62 @@ +## Context + +The frontend (`web/frontend`, SvelteKit + Tailwind v4) uses shadcn-svelte components that were generated from an older registry style. A controlled test of `npx shadcn-svelte@latest add pagination --overwrite --yes` (CLI v1.3.0) showed the current registry serves a different style ("nova"): it regenerated `button` with a different look (`destructive` solid → tinted, `rounded-md` → `rounded-lg`, default size `h-9` → `h-8`), introduced a `data-icon` spacing convention, referenced `cn-*` component classes absent from our `app.css`, and bumped `@lucide/svelte` `^1.17` → `^1.21` (pulling renamed icons like `more-horizontal`). + +Because of this drift, `add`/`update`/`--overwrite` cannot be used safely today, and the project is frozen on an undocumented style. The codebase places icons inside buttons with `class="mr-2 h-4 w-4"` rather than `data-icon`, so the convention change is cross-cutting across every route and dialog. The theme is the custom "ahsoca" palette (DM Sans / JetBrains Mono, status + attribution tokens, `animate-fade-up`/stagger, indicator-pulse) which must survive the migration. + +## Goals / Non-Goals + +**Goals:** +- Bring all 33 `ui/*` components onto the current registry style so future `add`/`update` are clean. +- Adopt the `data-icon` convention and migrate all consumers. +- Preserve the ahsoca theme identity and all project-specific component customizations. +- Land with zero new `npm run check` / `mise run build` errors and no visual regressions in light or dark mode. + +**Non-Goals:** +- No redesign of pages or new features — this is a style/library migration, not a visual redesign (the recent assessment/coverage redesigns stay as-is, only re-expressed on the new primitives). +- No change to backend, API, or data shapes. +- No switch of icon library, component framework, or Tailwind major version. + +## Decisions + +### Regenerate with the CLI, then re-apply customizations via diff review +Use `npx shadcn-svelte@latest add --overwrite` (or `update`) to regenerate each component, then inspect `git diff` and re-apply documented project customizations (e.g. `badge` `success` variant, custom `button` variants). Work on a dedicated branch with frequent commits so any single component can be reverted in isolation. +- *Alternative considered*: hand-porting each component by reading the registry. Rejected — slower and error-prone vs. letting the CLI emit canonical output and reviewing the diff. +- *Alternative considered*: re-`init` the whole project. Rejected — too blunt; would also rewrite `components.json`/theme and obscure what changed. + +### Treat the icon convention as a global, mechanical pass +After `button` is on the new style, migrate icon usages from `class="mr-2 h-4 w-4"` to `data-icon="inline-start"|"inline-end"` repo-wide. Inventory occurrences with grep, convert per file, and verify spacing visually. Keep standalone icons (not inside a component slot) unchanged. +- *Rationale*: doing this as one sweep avoids a mixed convention that is hard to reason about. + +### Reconcile theme tokens additively +Keep the ahsoca tokens and custom utilities in `app.css`; add only what the new style requires (new CSS variables, `cn-*` component classes). Do not let the CLI overwrite `app.css`; merge by hand so the palette and custom animations are preserved. +- *Rationale*: the theme is the product's identity and contains deliberate additions the registry doesn't know about. + +### Pin the dependency bump explicitly +Bump `@lucide/svelte` to the version the new style needs in `package.json` as a reviewed change, and re-verify icon import paths that were renamed. + +### Sequence: foundation → primitives → composites → consumers → verify +Migrate `button`/`badge` and theme first (highest blast radius), then the rest of `ui/*`, then sweep consumers, then verify build + visual pass. Order chosen so the icon sweep can rely on the final `button`. + +## Risks / Trade-offs + +- **Dropped customization during regeneration (e.g. `badge.success`)** → Maintain an explicit checklist of known customizations; re-apply and grep-verify each after regenerating its component. +- **Silent visual regression (e.g. tinted vs. solid `destructive`)** → Per-component diff review plus a deliberate light/dark visual pass on key pages; treat emphasis changes on `destructive`/primary actions as blocking. +- **Large, hard-to-review diff** → Commit per component (or small group); keep the consumer icon sweep in its own commit. +- **`app.css` merge conflicts / lost custom tokens** → Never overwrite `app.css` via CLI; diff-review every token change. +- **Dependency bump side effects (renamed/removed icons)** → Build after the bump; fix import paths flagged by the compiler before proceeding. + +## Migration Plan + +1. Branch off `main`; record the known-customizations checklist (button/badge variants, app.css additions) from current `git` state. +2. Regenerate `button` + `badge`, re-apply variants, reconcile `app.css` tokens/`cn-*` classes, bump `@lucide/svelte`. +3. Regenerate remaining `ui/*` components in small groups; re-apply any customizations; commit per group. +4. Repo-wide icon-convention sweep across `routes/**` and `lib/components/**`. +5. `npm run check` + `mise run build`; fix fallout. +6. Visual pass in light and dark on dashboard, assessments (list + detail), rule coverage, connectors, scenarios, packs. +7. **Rollback**: the work is branch-isolated and committed per component; revert offending commits or the whole branch without touching `main`. + +## Open Questions + +- Are there `button`/`badge` (or other) customizations beyond the `badge` `success` variant? Resolve by diffing current components against the old-style registry output before starting. +- Does the new style expect any `app.css` changes beyond `cn-*` classes (e.g. new base-color variables)? Confirm against the regenerated components and the style's documented theme. diff --git a/openspec/changes/archive/2026-06-23-migrate-shadcn-svelte-style/proposal.md b/openspec/changes/archive/2026-06-23-migrate-shadcn-svelte-style/proposal.md new file mode 100644 index 0000000..3431323 --- /dev/null +++ b/openspec/changes/archive/2026-06-23-migrate-shadcn-svelte-style/proposal.md @@ -0,0 +1,29 @@ +## Why + +The frontend's shadcn-svelte components were generated from an older registry style. The current CLI (v1.3.0+) ships a different style ("nova") with restyled primitives, a new `data-icon` spacing convention, and updated theme expectations. Today `npx shadcn-svelte@latest add --overwrite` is unusable: it silently restyles `button` (e.g. `destructive` flips from solid to tinted), bumps `@lucide/svelte`, and emits classes/icons we don't have. This blocks pulling in any new or updated component cleanly and leaves us pinned to a frozen, undocumented style. Migrating deliberately unblocks future component installs and brings us onto a supported baseline. + +## What Changes + +- Re-generate all 33 installed UI components (`web/frontend/src/lib/components/ui/*`) from the current shadcn-svelte registry style so `add`/`update`/`--overwrite` become safe going forward. +- **BREAKING (internal)**: Adopt the new `data-icon="inline-start"|"inline-end"` convention for icons inside `Button` (and other components that document it), replacing the current `class="mr-2 h-4 w-4"` / `size`-on-icon pattern used across every page and dialog. +- Reconcile `app.css` theme tokens with what the new style expects (any new CSS variables / utility classes such as the `cn-*` component classes the new components reference), while preserving the existing "ahsoca" palette, fonts (DM Sans / JetBrains Mono), and the custom additions (status/attribution tokens, `animate-fade-up`/`stagger`, indicator-pulse). +- Preserve all project-specific component customizations (e.g. `badge` `success` variant, any custom `button` variants) by re-applying them on top of the regenerated base. +- Bump `@lucide/svelte` to the version the new style requires and verify icon import paths (e.g. `ellipsis` vs `more-horizontal`). +- Audit and update every consumer (`web/frontend/src/routes/**`, `web/frontend/src/lib/components/**`) so buttons, badges, and other restyled primitives render correctly under the new style — no visual regressions in light or dark mode. + +## Capabilities + +### New Capabilities +- `frontend-design-system`: The shared shadcn-svelte component library, theme tokens, and icon/styling conventions that govern how the SvelteKit frontend is built — including which registry style is the baseline and the rules for adding/updating components. + +### Modified Capabilities + + +## Impact + +- **Components**: all of `web/frontend/src/lib/components/ui/*` (33 components) regenerated; project-specific variants re-applied. +- **Consumers**: every route under `web/frontend/src/routes/**` and shared components under `web/frontend/src/lib/components/**` that render `Button` with icons or use restyled primitives. +- **Theme**: `web/frontend/src/app.css` (CSS variables, custom utilities) reconciled with the new style. +- **Dependencies**: `@lucide/svelte` version bump in `web/frontend/package.json` + lockfile. +- **Verification**: `mise run build`, `npm run check`, and a visual pass across pages in both themes. +- **Risk**: visual regressions if a customization is dropped during regeneration; mitigated by per-component diff review and the recorded list of known customizations. diff --git a/openspec/changes/archive/2026-06-23-migrate-shadcn-svelte-style/specs/frontend-design-system/spec.md b/openspec/changes/archive/2026-06-23-migrate-shadcn-svelte-style/specs/frontend-design-system/spec.md new file mode 100644 index 0000000..fd0c8b7 --- /dev/null +++ b/openspec/changes/archive/2026-06-23-migrate-shadcn-svelte-style/specs/frontend-design-system/spec.md @@ -0,0 +1,71 @@ +## ADDED Requirements + +### Requirement: Current shadcn-svelte registry style as baseline + +The frontend's UI component library SHALL be generated from the current shadcn-svelte registry style supported by the pinned CLI version, such that every component under `web/frontend/src/lib/components/ui/` matches that style's structure, class names, and component-level CSS conventions. + +#### Scenario: Component matches current registry output + +- **WHEN** a component already present in the project is regenerated from the registry with the same name +- **THEN** the on-disk file matches the registry's current-style output (modulo project-specific customizations and resolved import aliases) with no leftover constructs from the previous style + +#### Scenario: No undefined style classes + +- **WHEN** any UI component references a style utility or component class (e.g. `cn-*`) +- **THEN** that class is defined by the project's theme/CSS so the component renders as the style intends + +### Requirement: Safe component install and update + +After migration, adding or updating components from the registry SHALL succeed without altering unrelated components or silently changing the design. + +#### Scenario: Overwrite-install an existing component is non-destructive + +- **WHEN** a maintainer runs the shadcn-svelte CLI to add or update an already-installed component with overwrite enabled +- **THEN** the command completes and the regenerated component is consistent with the rest of the library, requiring only re-application of documented project-specific customizations + +#### Scenario: Adding a new component does not restyle others + +- **WHEN** a maintainer adds a new component that depends on existing components (e.g. a component depending on `button`) +- **THEN** the existing dependency components are not visually or structurally changed by the operation + +### Requirement: Preservation of project customizations + +Regenerating components SHALL preserve all project-specific customizations, including custom variants and the project palette, fonts, and custom utilities. + +#### Scenario: Custom variants retained + +- **WHEN** the component library is migrated to the new style +- **THEN** project-added variants (such as the `badge` `success` variant and any custom `button` variants) remain available and behave as before + +#### Scenario: Theme identity preserved + +- **WHEN** the theme tokens are reconciled with the new style +- **THEN** the "ahsoca" palette, DM Sans / JetBrains Mono fonts, status and attribution tokens, and custom animations (`animate-fade-up`, stagger, indicator-pulse) are retained + +### Requirement: Icon convention inside components + +Icons rendered inside components that document an icon-spacing convention SHALL use that convention, and consumers across the app SHALL be updated accordingly. + +#### Scenario: Button icons use the documented convention + +- **WHEN** an icon is placed inside a `Button` +- **THEN** it uses the new style's documented icon-spacing mechanism (e.g. `data-icon="inline-start"|"inline-end"`) rather than manual margin/size classes, and spacing renders correctly + +#### Scenario: All consumers migrated + +- **WHEN** the migration is complete +- **THEN** no route or shared component still relies on the old manual icon-spacing pattern for icons inside components that provide the new convention + +### Requirement: No visual or functional regressions + +The migrated frontend SHALL build and type-check cleanly and SHALL render without visual regressions in both light and dark themes. + +#### Scenario: Build and type-check pass + +- **WHEN** the frontend is built and type-checked after migration +- **THEN** `mise run build` and `npm run check` complete with no new errors attributable to the migration + +#### Scenario: Visual parity in both themes + +- **WHEN** key pages (dashboard, assessments list and detail, rule coverage, connectors, scenarios, packs) are reviewed in light and dark mode +- **THEN** primitives such as buttons (including `destructive`), badges, inputs, and tables render correctly with intended emphasis and no broken spacing diff --git a/openspec/changes/archive/2026-06-23-migrate-shadcn-svelte-style/tasks.md b/openspec/changes/archive/2026-06-23-migrate-shadcn-svelte-style/tasks.md new file mode 100644 index 0000000..b52f5bf --- /dev/null +++ b/openspec/changes/archive/2026-06-23-migrate-shadcn-svelte-style/tasks.md @@ -0,0 +1,39 @@ +## 1. Preparation + +- [x] 1.1 Create a migration branch off `main` (isolated, revertible). (Branched off `ui/revamp` HEAD, not `main`, since this change depends on recent rule-coverage/pagination commits there.) +- [x] 1.2 Confirm the shadcn-svelte CLI version and the registry style it resolves to; note expected differences from the current components. +- [x] 1.3 Build a "known customizations" checklist by diffing current `ui/*` components against old-style registry output (capture `badge` `success` variant, any custom `button` variants, and any other deviations). +- [x] 1.4 Snapshot current visual baseline (screenshots of key pages in light + dark) for later comparison. + +## 2. Foundation: theme + primitives + +- [x] 2.1 Regenerate `button` from the registry; review `git diff`. +- [x] 2.2 Re-apply project `button` customizations from the checklist; verify variants/sizes still exported. +- [x] 2.3 Regenerate `badge`; re-apply the `success` variant and any others; verify. +- [x] 2.4 Reconcile `app.css`: add new style's required CSS variables and `cn-*` component classes; preserve ahsoca palette, fonts, status/attribution tokens, and custom animations (do NOT let the CLI overwrite `app.css`). +- [x] 2.5 Bump `@lucide/svelte` to the version the new style requires; update `package.json` + lockfile; fix any renamed icon import paths surfaced by the build. + +## 3. Remaining components + +- [x] 3.1 Regenerate the remaining `ui/*` components in small groups, committing per group, re-applying any checklist customizations. +- [x] 3.2 For each group, confirm no `cn-*` or other classes remain undefined in `app.css`. +- [x] 3.3 Verify `pagination` (recently added) matches the new style and still satisfies the rule-coverage page usage. + +## 4. Consumer migration (icon convention) + +- [x] 4.1 Inventory all icon-in-component usages across `routes/**` and `lib/components/**` (grep for `mr-2`/`ml-2`/`h-4 w-4` icons inside `Button`). +- [x] 4.2 Convert button icons to `data-icon="inline-start"|"inline-end"`, removing manual margin/size classes; leave standalone icons unchanged. +- [x] 4.3 Update any other consumers affected by restyled primitives (sizes, radii, emphasis) so layouts still hold. + +## 5. Verification + +- [x] 5.1 `npm run check` passes with no new errors attributable to the migration. +- [x] 5.2 `mise run build` (frontend + server) succeeds. +- [x] 5.3 Visual pass in light AND dark on: dashboard, assessments list, assessment detail, rule coverage, connectors, scenarios, packs — confirm buttons (incl. `destructive`), badges, inputs, tables render with intended emphasis and correct spacing. +- [x] 5.4 Confirm a clean `add`/`update --overwrite` of one already-installed component now leaves the rest of the library unchanged (validates the migration goal). +- [x] 5.5 Update the project memory note that previously flagged `--overwrite` as incompatible to reflect the new baseline. + +## 6. Wrap-up + +- [x] 6.1 Open a PR summarizing the style migration, the `data-icon` convention change, and the dependency bump. +- [x] 6.2 After merge, run `/opsx:archive` to archive this change. diff --git a/openspec/changes/split-connectors-page/.openspec.yaml b/openspec/changes/archive/2026-06-23-split-connectors-page/.openspec.yaml similarity index 100% rename from openspec/changes/split-connectors-page/.openspec.yaml rename to openspec/changes/archive/2026-06-23-split-connectors-page/.openspec.yaml diff --git a/openspec/changes/split-connectors-page/design.md b/openspec/changes/archive/2026-06-23-split-connectors-page/design.md similarity index 100% rename from openspec/changes/split-connectors-page/design.md rename to openspec/changes/archive/2026-06-23-split-connectors-page/design.md diff --git a/openspec/changes/split-connectors-page/proposal.md b/openspec/changes/archive/2026-06-23-split-connectors-page/proposal.md similarity index 100% rename from openspec/changes/split-connectors-page/proposal.md rename to openspec/changes/archive/2026-06-23-split-connectors-page/proposal.md diff --git a/openspec/changes/split-connectors-page/specs/connectors/spec.md b/openspec/changes/archive/2026-06-23-split-connectors-page/specs/connectors/spec.md similarity index 100% rename from openspec/changes/split-connectors-page/specs/connectors/spec.md rename to openspec/changes/archive/2026-06-23-split-connectors-page/specs/connectors/spec.md diff --git a/openspec/changes/split-connectors-page/tasks.md b/openspec/changes/archive/2026-06-23-split-connectors-page/tasks.md similarity index 100% rename from openspec/changes/split-connectors-page/tasks.md rename to openspec/changes/archive/2026-06-23-split-connectors-page/tasks.md diff --git a/openspec/specs/assessment-retention/spec.md b/openspec/specs/assessment-retention/spec.md new file mode 100644 index 0000000..54bbaae --- /dev/null +++ b/openspec/specs/assessment-retention/spec.md @@ -0,0 +1,113 @@ +# Assessment Retention Specification + +## Purpose +Bounds the storage footprint of assessment data by automatically deleting +aged run artifacts. Two independent, admin-configurable retention policies run +as background sweepers: a log-retention policy that prunes per-run JSONL log +files while keeping the `runs` row, and an assessment-retention policy that +purges whole runs (rows, results, log files, and collected NDJSON) once they +age out. Settings live in `AppConfig` and are editable from the assessments +page. + +## Requirements + +### Requirement: Retention Settings In AppConfig +The system SHALL extend `AppConfig` with retention settings persisted in the +`app_config` table and served by `GET /api/config` / `PUT /api/config`: +`assessment_log_retention_enabled` (bool), `assessment_log_retention_days` (int), +`assessment_retention_enabled` (bool), and `assessment_retention_days` (int). +Defaults SHALL be `assessment_log_retention_enabled = true`, +`assessment_log_retention_days = 7`, `assessment_retention_enabled = false`, +`assessment_retention_days = 30`, backfilled by a migration aligned with +`DefaultAppConfig()`. + +#### Scenario: Defaults when unset +- **WHEN** no `app_config` row exists for the retention keys +- **THEN** `GET /api/config` returns `assessment_log_retention_enabled = true`, `assessment_log_retention_days = 7`, `assessment_retention_enabled = false`, and `assessment_retention_days = 30` + +#### Scenario: Admin updates retention +- **WHEN** a client sends `PUT /api/config` with `assessment_retention_enabled = true` and `assessment_retention_days = 14` +- **THEN** both values are persisted and returned by a subsequent `GET /api/config` + +### Requirement: Retention Settings Validation +The system SHALL reject `PUT /api/config` with HTTP 400 when +`assessment_log_retention_days` or `assessment_retention_days` is less than 1, so +retention cannot be set to a value that deletes data immediately. + +#### Scenario: Zero log retention rejected +- **WHEN** a client sends `PUT /api/config` with `assessment_log_retention_days = 0` +- **THEN** the response is HTTP 400 and the stored value is unchanged + +#### Scenario: Zero assessment retention rejected +- **WHEN** a client sends `PUT /api/config` with `assessment_retention_days = 0` +- **THEN** the response is HTTP 400 and the stored value is unchanged + +### Requirement: Log-Retention Sweeper +The system SHALL run a background sweeper that periodically scans +`/run-logs/` and deletes any `.jsonl` file whose last +modification time is older than `assessment_log_retention_days`. The sweeper SHALL run +once at startup and then on a fixed 1-hour interval, SHALL re-read `AppConfig` +each tick, and SHALL be a no-op when `assessment_log_retention_enabled = false`. +Deleting a log file SHALL NOT delete or modify the corresponding `runs` row. + +#### Scenario: Old log swept +- **WHEN** a run's JSONL file was last modified longer ago than `assessment_log_retention_days` and log retention is enabled +- **THEN** the sweeper deletes the file and leaves the `runs` row intact + +#### Scenario: Recent log retained +- **WHEN** a run's JSONL file is newer than `assessment_log_retention_days` +- **THEN** the sweeper leaves the file in place + +#### Scenario: Log retention disabled +- **WHEN** `assessment_log_retention_enabled = false` +- **THEN** the log sweeper deletes no files regardless of age + +### Requirement: Assessment-Retention Sweeper +The system SHALL run a background sweeper that periodically deletes whole runs +whose `created_at` is older than `assessment_retention_days`. For each expired +run the system SHALL delete the `runs` row (cascading to `scenario_results`), +the run's JSONL log file, and every collected `.ndjson` file referenced by that +run's `scenario_results.collected_log_path`. The sweeper SHALL run once at +startup and then on a fixed 1-hour interval, SHALL re-read `AppConfig` each +tick, SHALL be a no-op when `assessment_retention_enabled = false`, and SHALL +skip runs whose `status` is still `running`. + +#### Scenario: Old assessment purged +- **WHEN** a completed run's `created_at` is older than `assessment_retention_days` and assessment retention is enabled +- **THEN** the `runs` row, its `scenario_results`, its JSONL log file, and all of its collected `.ndjson` files are deleted + +#### Scenario: Recent assessment retained +- **WHEN** a run's `created_at` is newer than `assessment_retention_days` +- **THEN** the sweeper leaves the run and all its artifacts in place + +#### Scenario: Assessment retention disabled +- **WHEN** `assessment_retention_enabled = false` +- **THEN** the assessment sweeper deletes no runs regardless of age + +#### Scenario: Running assessment skipped +- **WHEN** a run older than `assessment_retention_days` still has `status = "running"` +- **THEN** the sweeper does not delete it + +### Requirement: Swept Logs Surface As Empty +The system SHALL serve `GET /api/runs/{runId}/logs` with HTTP 200 and body `[]` +when the run's JSONL file has been swept by log retention, reusing the existing +missing-file behavior so an expired-log run is indistinguishable from one that +never logged. + +#### Scenario: Logs requested after sweep +- **WHEN** a client GETs logs for a run whose JSONL file was deleted by the log sweeper +- **THEN** the response is HTTP 200 with body `[]` + +### Requirement: Configure Retention From Assessments Page +The system SHALL present an "Assessment retention" control on the assessments +page that opens a dialog for editing `assessment_log_retention_enabled`, +`assessment_log_retention_days`, `assessment_retention_enabled`, and +`assessment_retention_days`, and SHALL persist changes via `PUT /api/config`. + +#### Scenario: Open and save +- **WHEN** an admin opens the dialog, enables assessment retention with a 14-day window, and saves +- **THEN** the new values are sent to `PUT /api/config` and reflected on the page after save + +#### Scenario: Invalid input surfaced +- **WHEN** an admin enters a retention period below 1 and saves +- **THEN** the API returns HTTP 400 and the dialog surfaces the error without losing the entered values diff --git a/openspec/specs/connectors/spec.md b/openspec/specs/connectors/spec.md index 1925408..82b1c08 100644 --- a/openspec/specs/connectors/spec.md +++ b/openspec/specs/connectors/spec.md @@ -164,3 +164,17 @@ The system SHALL resolve a connector's credentials to the same set of environmen - **WHEN** a new connector type is added (e.g., a new cloud provider) - **THEN** the per-type resolution logic is added in exactly one place - **AND** both test-connection and scenario-run pick up the new type without requiring per-call-site changes + +### Requirement: UI–Backend Connector Type Parity +The connector-administration UI SHALL provide create, edit, and delete capabilities for every connector type recognized by the backend. The per-type configuration form for each recognized type SHALL be implemented in exactly one frontend component file, so that adding a new backend-recognized type requires exactly one frontend file addition plus a registration in the create-dialog and edit-dialog dispatch tables — no other frontend changes SHALL be required for type parity. + +#### Scenario: Every recognized type is configurable in the UI +- **WHEN** the backend recognizes connector types `elastic`, `datadog`, `aws`, `gcp`, `azure`, `kubernetes`, `ssh` +- **THEN** the create dialog presents each as a selectable type +- **AND** the edit dialog renders the appropriate form fields when a connector of that type is opened + +#### Scenario: Adding a new type is a one-component addition +- **WHEN** a contributor adds a new connector type `X` to the backend +- **THEN** adding UI support requires creating exactly one new component file `web/frontend/src/lib/components/connectors/XConnectorForm.svelte` +- **AND** registering it in the create-dialog dispatch and the edit-dialog dispatch +- **AND** no other frontend changes are required for the type to be fully configurable diff --git a/openspec/specs/frontend-design-system/spec.md b/openspec/specs/frontend-design-system/spec.md new file mode 100644 index 0000000..924bfe4 --- /dev/null +++ b/openspec/specs/frontend-design-system/spec.md @@ -0,0 +1,82 @@ +# Frontend Design System Specification + +## Purpose +Defines how the SvelteKit frontend's shadcn-svelte component library is +generated, installed, updated, and customized. It pins the library to the +current shadcn-svelte registry style, guarantees that component install/update +operations are safe and non-destructive, preserves project-specific +customizations (palette, fonts, custom variants, animations), establishes the +in-component icon convention, and requires the migrated frontend to build, +type-check, and render without regressions. + +## Requirements + +### Requirement: Current shadcn-svelte registry style as baseline + +The frontend's UI component library SHALL be generated from the current shadcn-svelte registry style supported by the pinned CLI version, such that every component under `web/frontend/src/lib/components/ui/` matches that style's structure, class names, and component-level CSS conventions. + +#### Scenario: Component matches current registry output + +- **WHEN** a component already present in the project is regenerated from the registry with the same name +- **THEN** the on-disk file matches the registry's current-style output (modulo project-specific customizations and resolved import aliases) with no leftover constructs from the previous style + +#### Scenario: No undefined style classes + +- **WHEN** any UI component references a style utility or component class (e.g. `cn-*`) +- **THEN** that class is defined by the project's theme/CSS so the component renders as the style intends + +### Requirement: Safe component install and update + +After migration, adding or updating components from the registry SHALL succeed without altering unrelated components or silently changing the design. + +#### Scenario: Overwrite-install an existing component is non-destructive + +- **WHEN** a maintainer runs the shadcn-svelte CLI to add or update an already-installed component with overwrite enabled +- **THEN** the command completes and the regenerated component is consistent with the rest of the library, requiring only re-application of documented project-specific customizations + +#### Scenario: Adding a new component does not restyle others + +- **WHEN** a maintainer adds a new component that depends on existing components (e.g. a component depending on `button`) +- **THEN** the existing dependency components are not visually or structurally changed by the operation + +### Requirement: Preservation of project customizations + +Regenerating components SHALL preserve all project-specific customizations, including custom variants and the project palette, fonts, and custom utilities. + +#### Scenario: Custom variants retained + +- **WHEN** the component library is migrated to the new style +- **THEN** project-added variants (such as the `badge` `success` variant and any custom `button` variants) remain available and behave as before + +#### Scenario: Theme identity preserved + +- **WHEN** the theme tokens are reconciled with the new style +- **THEN** the "ahsoca" palette, DM Sans / JetBrains Mono fonts, status and attribution tokens, and custom animations (`animate-fade-up`, stagger, indicator-pulse) are retained + +### Requirement: Icon convention inside components + +Icons rendered inside components that document an icon-spacing convention SHALL use that convention, and consumers across the app SHALL be updated accordingly. + +#### Scenario: Button icons use the documented convention + +- **WHEN** an icon is placed inside a `Button` +- **THEN** it uses the new style's documented icon-spacing mechanism (e.g. `data-icon="inline-start"|"inline-end"`) rather than manual margin/size classes, and spacing renders correctly + +#### Scenario: All consumers migrated + +- **WHEN** the migration is complete +- **THEN** no route or shared component still relies on the old manual icon-spacing pattern for icons inside components that provide the new convention + +### Requirement: No visual or functional regressions + +The migrated frontend SHALL build and type-check cleanly and SHALL render without visual regressions in both light and dark themes. + +#### Scenario: Build and type-check pass + +- **WHEN** the frontend is built and type-checked after migration +- **THEN** `mise run build` and `npm run check` complete with no new errors attributable to the migration + +#### Scenario: Visual parity in both themes + +- **WHEN** key pages (dashboard, assessments list and detail, rule coverage, connectors, scenarios, packs) are reviewed in light and dark mode +- **THEN** primitives such as buttons (including `destructive`), badges, inputs, and tables render correctly with intended emphasis and no broken spacing diff --git a/openspec/specs/runs/spec.md b/openspec/specs/runs/spec.md index 8cfd630..7a6b802 100644 --- a/openspec/specs/runs/spec.md +++ b/openspec/specs/runs/spec.md @@ -38,10 +38,39 @@ The system SHALL track each scenario as a row in `scenario_results` with populated during `running` (e.g., `"warmup"`, `"detonating"`, `"matching"`, `"collecting"`, `"cleanup"`, `"queued"`). +The system SHALL populate the row's executor identity — `executor_name`, +`executor_type`, `execution_id`, and `simulation_id` — as soon as detonation +returns these values, while the row is still `running`, rather than only at +completion. When a detonator does not produce a `simulation_id`, that field +SHALL remain empty without blocking the other identity fields. + +While in the `matching` phase, the system SHALL persist per-assertion results +incrementally as the matcher resolves them, so the row's `assertions` reflect +the current passed/pending state before the scenario completes. An assertion +not yet matched SHALL be represented as not-yet-passed (no terminal failure is +recorded until completion). + +These incremental writes SHALL NOT alter the terminal completion write: when a +scenario completes, `status` becomes `completed`, `phase` is cleared, and the +final `is_success`, `assertions`, durations, and `discovered_alerts` are +written as they are today. + #### Scenario: Phase transitions - **WHEN** a scenario enters the matching phase - **THEN** its `scenario_results` row has `status = "running"` and `phase = "matching"` +#### Scenario: Executor identity exposed during run +- **WHEN** a scenario has detonated and is in the `matching` phase +- **THEN** `GET /api/runs/{runId}` returns that scenario with `status = "running"` and non-empty `executor_name`, `executor_type`, and `execution_id` + +#### Scenario: Assertion progress exposed during matching +- **WHEN** a scenario expecting 3 assertions has matched 2 of them and is still matching +- **THEN** the scenario's `assertions` in `GET /api/runs/{runId}` show 2 passed and 1 not-yet-passed while `status = "running"` + +#### Scenario: Completion write unchanged +- **WHEN** a scenario finishes after its identity and partial assertions were written mid-run +- **THEN** the final row has `status = "completed"`, `phase = null`, and `is_success` plus the full `assertions` and `discovered_alerts` set, with no stale `running`-phase values + ### Requirement: Atomic Counter Increments The system SHALL update run counters with atomic SQL increments (`succeeded = succeeded + $2, failed = failed + $3`) rather than full diff --git a/web/frontend/package-lock.json b/web/frontend/package-lock.json index 38af0a0..0640672 100644 --- a/web/frontend/package-lock.json +++ b/web/frontend/package-lock.json @@ -13,7 +13,7 @@ "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "6.43.0", "@internationalized/date": "3.12.1", - "@lucide/svelte": "^1.17.0", + "@lucide/svelte": "^1.21.0", "@sveltejs/adapter-auto": "7.0.1", "@sveltejs/adapter-static": "3.0.10", "@sveltejs/kit": "2.60.1", @@ -28,14 +28,16 @@ "mode-watcher": "^1.1.0", "prettier": "3.8.3", "prettier-plugin-svelte": "3.5.2", + "shadcn-svelte": "^1.3.0", "svelte": "5.55.7", "svelte-check": "4.4.8", "svelte-sonner": "1.1.1", "tailwind-merge": "3.6.0", "tailwind-variants": "3.2.2", "tailwindcss": "4.3.0", + "tw-animate-css": "^1.4.0", "typescript": "5.9.3", - "vaul-svelte": "1.0.0-next.7", + "vaul-svelte": "^1.0.0-next.7", "vite": "8.0.14" } }, @@ -318,9 +320,9 @@ } }, "node_modules/@lucide/svelte": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.17.0.tgz", - "integrity": "sha512-q06YCFBN5CO8cd1ADmLCxWRVMVb7xxvHzqC0lvNoxGa+FLW6Cd1Y1AOxgbQk4Iwe68vkAMCRveNHint4WoaVKg==", + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.21.0.tgz", + "integrity": "sha512-MEv//A7Jv3kHukZowv/DWp1MAtUzJKYwtJsmnQ7X98lCgtac3z3NbaToDl3Q6jO3gS9sougFpcD+t+YuxOkRMw==", "dev": true, "license": "ISC", "peerDependencies": { @@ -1175,6 +1177,16 @@ "@codemirror/view": "^6.0.0" } }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", @@ -1752,6 +1764,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "dev": true, + "license": "MIT" + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -1932,6 +1951,25 @@ "dev": true, "license": "MIT" }, + "node_modules/shadcn-svelte": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/shadcn-svelte/-/shadcn-svelte-1.3.0.tgz", + "integrity": "sha512-Pd4ICWTkTks/b2YU4c9vF2XsX1x5HFPRl5bKszS1LcnWS83x+7T4WiIvbWz8Qh9knkcGZ+SCz1+Dmhdq+AYooA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^14.0.0", + "node-fetch-native": "^1.6.4", + "postcss": "^8.5.5", + "tailwind-merge": "^3.0.0" + }, + "bin": { + "shadcn-svelte": "dist/index.mjs" + }, + "peerDependencies": { + "svelte": "^5.0.0" + } + }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -2170,6 +2208,16 @@ "dev": true, "license": "0BSD" }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/web/frontend/package.json b/web/frontend/package.json index eabcacd..f530f7b 100644 --- a/web/frontend/package.json +++ b/web/frontend/package.json @@ -19,7 +19,7 @@ "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "6.43.0", "@internationalized/date": "3.12.1", - "@lucide/svelte": "^1.17.0", + "@lucide/svelte": "^1.21.0", "@sveltejs/adapter-auto": "7.0.1", "@sveltejs/adapter-static": "3.0.10", "@sveltejs/kit": "2.60.1", @@ -34,14 +34,16 @@ "mode-watcher": "^1.1.0", "prettier": "3.8.3", "prettier-plugin-svelte": "3.5.2", + "shadcn-svelte": "^1.3.0", "svelte": "5.55.7", "svelte-check": "4.4.8", "svelte-sonner": "1.1.1", "tailwind-merge": "3.6.0", "tailwind-variants": "3.2.2", "tailwindcss": "4.3.0", + "tw-animate-css": "^1.4.0", "typescript": "5.9.3", - "vaul-svelte": "1.0.0-next.7", + "vaul-svelte": "^1.0.0-next.7", "vite": "8.0.14" } } diff --git a/web/frontend/src/app.css b/web/frontend/src/app.css index beece28..413e1b4 100644 --- a/web/frontend/src/app.css +++ b/web/frontend/src/app.css @@ -1,68 +1,18 @@ @import 'tailwindcss'; +@import 'tw-animate-css'; +@import 'shadcn-svelte/tailwind.css'; @custom-variant dark (&:is(.dark *)); -/* ── data-* state variants (bits-ui) ── */ -@custom-variant data-open { - &:where([data-state='open']), - &:where([data-open]:not([data-open='false'])) { - @slot; - } -} - -@custom-variant data-closed { - &:where([data-state='closed']), - &:where([data-closed]:not([data-closed='false'])) { - @slot; - } -} - -@custom-variant data-checked { - &:where([data-state='checked']), - &:where([data-checked]:not([data-checked='false'])) { - @slot; - } -} - -@custom-variant data-unchecked { - &:where([data-state='unchecked']), - &:where([data-unchecked]:not([data-unchecked='false'])) { - @slot; - } -} - +/* data-* state variants (data-open/closed/checked/unchecked/disabled/active/ + horizontal/vertical), no-scrollbar, and accordion keyframes are provided by + shadcn-svelte/tailwind.css. data-selected is the one variant it omits. */ @custom-variant data-selected { &:where([data-selected]) { @slot; } } -@custom-variant data-disabled { - &:where([data-disabled='true']), - &:where([data-disabled]:not([data-disabled='false'])) { - @slot; - } -} - -@custom-variant data-active { - &:where([data-state='active']), - &:where([data-active]:not([data-active='false'])) { - @slot; - } -} - -@custom-variant data-horizontal { - &:where([data-orientation='horizontal']) { - @slot; - } -} - -@custom-variant data-vertical { - &:where([data-orientation='vertical']) { - @slot; - } -} - /* ── Light theme (ahsoca palette) ── */ :root { --radius: 0.65rem; @@ -273,16 +223,8 @@ } } -/* ── Utilities ── */ -@utility no-scrollbar { - -ms-overflow-style: none; - scrollbar-width: none; - &::-webkit-scrollbar { - display: none; - } -} - /* ── Keyframes ── */ +/* (no-scrollbar utility and accordion keyframes come from shadcn-svelte/tailwind.css) */ @keyframes fade-up { from { opacity: 0; @@ -343,24 +285,6 @@ } } -@keyframes accordion-down { - from { - height: 0; - } - to { - height: var(--bits-accordion-content-height); - } -} - -@keyframes accordion-up { - from { - height: var(--bits-accordion-content-height); - } - to { - height: 0; - } -} - /* ── Animation utilities ── */ .animate-fade-up { animation: fade-up 0.4s ease-out both; diff --git a/web/frontend/src/lib/components/ExpectationRow.svelte b/web/frontend/src/lib/components/ExpectationRow.svelte index dc18cb2..166b0e0 100644 --- a/web/frontend/src/lib/components/ExpectationRow.svelte +++ b/web/frontend/src/lib/components/ExpectationRow.svelte @@ -111,7 +111,7 @@ aria-expanded={alertNameComboboxOpen} > {alertNameLabel()} - + {/snippet} diff --git a/web/frontend/src/lib/components/NewAssessmentDialog.svelte b/web/frontend/src/lib/components/NewAssessmentDialog.svelte index b43cc49..1a80aaa 100644 --- a/web/frontend/src/lib/components/NewAssessmentDialog.svelte +++ b/web/frontend/src/lib/components/NewAssessmentDialog.svelte @@ -81,7 +81,6 @@ running = false; } } - @@ -185,8 +184,8 @@

{#if selectedScenarioType === 'explore'} - Explore mode: searches all alerts for indicators instead of matching specific - rules. Waits for the full timeout to discover all triggered alerts. + Explore mode: searches all alerts for indicators instead of matching specific rules. + Waits for the full timeout to discover all triggered alerts. {:else} Collect mode: collects logs after detonation for analysis. Waits for the timeout period before collecting. diff --git a/web/frontend/src/lib/components/NewScenarioDialog.svelte b/web/frontend/src/lib/components/NewScenarioDialog.svelte index 5f14f80..1aaff9e 100644 --- a/web/frontend/src/lib/components/NewScenarioDialog.svelte +++ b/web/frontend/src/lib/components/NewScenarioDialog.svelte @@ -28,7 +28,8 @@ { value: 'explore', title: 'Explore', - description: 'Run simulations and discover all triggered alerts without specifying expected rules.', + description: + 'Run simulations and discover all triggered alerts without specifying expected rules.', icon: SearchIcon, iconClass: 'text-attr-identity' }, diff --git a/web/frontend/src/lib/components/PackCard.svelte b/web/frontend/src/lib/components/PackCard.svelte index 30f6ab6..214ab4f 100644 --- a/web/frontend/src/lib/components/PackCard.svelte +++ b/web/frontend/src/lib/components/PackCard.svelte @@ -124,7 +124,9 @@

-
+
@@ -249,7 +251,8 @@
{#if pack.installedBy && pack.installedBy !== 'anonymous'} - {formatUserEmail(pack.installedBy)} + {formatUserEmail(pack.installedBy)} {pack.installedBy} diff --git a/web/frontend/src/lib/components/PackSimulationsSheet.svelte b/web/frontend/src/lib/components/PackSimulationsSheet.svelte index e514609..309d7dc 100644 --- a/web/frontend/src/lib/components/PackSimulationsSheet.svelte +++ b/web/frontend/src/lib/components/PackSimulationsSheet.svelte @@ -132,7 +132,13 @@
- Back to list @@ -323,7 +329,13 @@
- Back to list diff --git a/web/frontend/src/lib/components/RecentScenariosSection.svelte b/web/frontend/src/lib/components/RecentScenariosSection.svelte index dbca713..02f90d4 100644 --- a/web/frontend/src/lib/components/RecentScenariosSection.svelte +++ b/web/frontend/src/lib/components/RecentScenariosSection.svelte @@ -24,7 +24,10 @@
{scenario.name} - + {scenario.type || 'standard'}
diff --git a/web/frontend/src/lib/components/ScenarioEditor.svelte b/web/frontend/src/lib/components/ScenarioEditor.svelte index 6ed18a2..b51c57a 100644 --- a/web/frontend/src/lib/components/ScenarioEditor.svelte +++ b/web/frontend/src/lib/components/ScenarioEditor.svelte @@ -32,13 +32,7 @@ lintScenario } from '$lib/api/client'; import { scenarioTypeVariant } from '$lib/utils/format'; - import type { - Pack, - PackManifest, - ElasticRule, - Connector, - ScenarioType - } from '$lib/types'; + import type { Pack, PackManifest, ElasticRule, Connector, ScenarioType } from '$lib/types'; type SaveOptions = { run?: boolean }; @@ -61,11 +55,7 @@ initialTarget?: FormTarget; initialYaml?: string; initialBuilderSupported?: boolean; - onsave: ( - name: string, - yaml: string, - options: SaveOptions - ) => Promise; + onsave: (name: string, yaml: string, options: SaveOptions) => Promise; oncancel: () => void; onschedule?: () => void; } = $props(); @@ -76,9 +66,7 @@ let name = $state(initialName); /* svelte-ignore state_referenced_locally */ let scenarios = $state( - initialScenarios && initialScenarios.length > 0 - ? initialScenarios - : [seedScenario(type)] + initialScenarios && initialScenarios.length > 0 ? initialScenarios : [seedScenario(type)] ); /* svelte-ignore state_referenced_locally */ let target = $state(initialTarget ?? createEmptyTarget()); @@ -225,11 +213,7 @@ const source = scenarios[index]; const copy = $state.snapshot(source) as FormScenario; copy.name = source.name ? `${source.name} (copy)` : ''; - scenarios = [ - ...scenarios.slice(0, index + 1), - copy, - ...scenarios.slice(index + 1) - ]; + scenarios = [...scenarios.slice(0, index + 1), copy, ...scenarios.slice(index + 1)]; markDirty(); } @@ -320,7 +304,6 @@ pendingNavigate = null; fn?.(); } -
@@ -337,11 +320,16 @@
{#if mode === 'edit' && onschedule} {/if} - @@ -396,7 +384,10 @@
{/if} {:else}
- markDirty()} - /> + markDirty()} />
{/if}
diff --git a/web/frontend/src/lib/components/ScenarioResult.svelte b/web/frontend/src/lib/components/ScenarioResult.svelte index 753890b..f8323bb 100644 --- a/web/frontend/src/lib/components/ScenarioResult.svelte +++ b/web/frontend/src/lib/components/ScenarioResult.svelte @@ -7,6 +7,7 @@ import RunLog from './RunLog.svelte'; import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'; import DownloadIcon from '@lucide/svelte/icons/download'; + import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'; import { getCollectedLogsUrl } from '$lib/api/client'; let { entry, logs = [] }: { entry: ScenarioEntry; logs?: RunLogEntry[] } = $props(); @@ -22,7 +23,7 @@ let assertionCounts = $derived.by(() => { if (!result?.assertions || result.assertions.length === 0) return null; - const passed = result.assertions.filter((a) => a.passed).length; + const passed = result.assertions.filter((a) => a.passed === true).length; return { passed, total: result.assertions.length }; }); @@ -35,169 +36,260 @@ collecting: 'Collecting', cleanup: 'Cleanup' }; + + // Timeline node accent driven by terminal status. + const nodeState = $derived( + entry.status === 'completed' && result ? (result.isSuccess ? 'pass' : 'fail') : entry.status + ); - - -
-
- {entry.name} - {#if result?.simulationId} - {result.simulationId} - {/if} + +{#snippet assertionBar()} + {#if assertionCounts} + {#if assertionCounts.total <= 8} + -
- {#if entry.status === 'completed' && result} - {result.executorName} - {#if result.discoveredAlerts} - - {result.discoveredAlerts.length} alert{result.discoveredAlerts.length !== 1 ? 's' : ''} discovered - - explore - {:else if assertionCounts} - - {assertionCounts.passed}/{assertionCounts.total} assertions + {:else} + {assertionCounts.passed}/{assertionCounts.total} assertions + {/if} + {/if} +{/snippet} + +
+ + + + + + + +
+
+ +
+ {entry.name} + + {#if result?.executorName} + {result.executorName} + {#if result.simulationId} + · + {result.simulationId} + {/if} + {#if matcherNames.length > 0} + · + {matcherNames.join(', ')} + {/if} + {/if} +
+
+ +
+ {#if entry.status === 'completed' && result} + {#if result.discoveredAlerts} + + {result.discoveredAlerts.length} alert{result.discoveredAlerts.length !== 1 + ? 's' + : ''} + + explore + {:else if assertionCounts} + {@render assertionBar()} + {/if} + {result.durationSecs.toFixed(1)}s + + {result.isSuccess ? 'passed' : 'failed'} + + {:else if entry.status === 'running'} + {#if assertionCounts} + {@render assertionBar()} + {/if} + {#if entry.phase} + + + {phaseLabels[entry.phase] || entry.phase} + + {/if} + {:else if entry.status === 'pending'} + Pending {/if} - {#if matcherNames.length > 0} - {matcherNames.join(', ')} - {/if} - {result.durationSecs.toFixed(1)}s - - {result.isSuccess ? 'passed' : 'failed'} - - {:else if entry.status === 'running' && entry.phase} - - - {phaseLabels[entry.phase] || entry.phase} - - {:else if entry.status === 'pending'} - Pending - {/if} +
-
- - -
- {#if entry.status === 'completed' && result} - {#if result.errorMessage} -
- Error -

{result.errorMessage}

-
- {/if} + + +
+ {#if entry.status === 'completed' && result} + {#if result.errorMessage} +
+ Error +

{result.errorMessage}

+
+ {/if} -
-
- Executor -

{result.executorName} ({result.executorType})

-
-
- Execution ID -

{result.executionId}

+
+
+ Executor +

{result.executorName} ({result.executorType})

+
+
+ Execution ID +

{result.executionId}

+
+ {#if result.simulationId} +
+ Simulation ID +

{result.simulationId}

+
+ {/if} +
+ Duration +

+ {result.durationSecs.toFixed(1)}s (matching: {result.matchingDurSecs.toFixed(1)}s) +

+
+
+ Executed +

{new Date(result.timeExecuted).toLocaleString()}

+
- {#if result.simulationId} + + {#if result.collectedDocCount && result.collectedDocCount > 0}
- Simulation ID -

{result.simulationId}

+ Collected Logs +
+ {result.collectedDocCount} documents + + + +
{/if} -
- Duration -

- {result.durationSecs.toFixed(1)}s (matching: {result.matchingDurSecs.toFixed(1)}s) -

-
-
- Executed -

{new Date(result.timeExecuted).toLocaleString()}

-
-
- {#if result.collectedDocCount && result.collectedDocCount > 0} -
- Collected Logs -
- {result.collectedDocCount} documents - - - + {#if result.discoveredAlerts && result.discoveredAlerts.length > 0} +
+ + Discovered Alerts ({result.discoveredAlerts.length}) + +
+ {#each result.discoveredAlerts as alert} +
+ FOUND + {#if alert.severity} + [{alert.severity}] + {/if} + {alert.ruleName} +
+ {/each} +
-
- {/if} + {:else if result.discoveredAlerts && result.discoveredAlerts.length === 0} +
+ Discovered Alerts +

+ No matching alerts found during explore window. +

+
+ {/if} - {#if result.discoveredAlerts && result.discoveredAlerts.length > 0} -
- - Discovered Alerts ({result.discoveredAlerts.length}) - -
- {#each result.discoveredAlerts as alert} -
- FOUND - {#if alert.severity} - [{alert.severity}] - {/if} - {alert.ruleName} -
- {/each} + {#if result.assertions && result.assertions.length > 0} +
+ Assertions +
+ {#each result.assertions as assertion} +
+ + {assertion.passed ? 'PASS' : 'MISSED'} + + [{assertion.matcherType}] + {assertion.alertName} +
+ {/each} +
-
- {:else if result.discoveredAlerts && result.discoveredAlerts.length === 0} -
- Discovered Alerts -

No matching alerts found during explore window.

-
- {/if} + {/if} - {#if result.assertions && result.assertions.length > 0} -
- Assertions -
- {#each result.assertions as assertion} -
- - {assertion.passed ? 'PASS' : 'FAIL'} - - [{assertion.matcherType}] - {assertion.alertName} -
- {/each} + {#if result.metadata?.description} +
+ Description +

{result.metadata.description}

+ {/if} + {:else if entry.status === 'running' && entry.phase} +
+ + {phaseLabels[entry.phase] || entry.phase}...
+ {:else if entry.status === 'pending'} +
Waiting to start...
{/if} - {#if result.metadata?.description} + {#if logs.length > 0}
- Description -

{result.metadata.description}

+ Logs ({logs.length}) +
+ +
{/if} - {:else if entry.status === 'running' && entry.phase} -
- - {phaseLabels[entry.phase] || entry.phase}... -
- {:else if entry.status === 'pending'} -
Waiting to start...
- {/if} - - {#if logs.length > 0} -
- Logs ({logs.length}) -
- -
-
- {/if} -
- - +
+ + +
diff --git a/web/frontend/src/lib/components/ScenarioSection.svelte b/web/frontend/src/lib/components/ScenarioSection.svelte index 733d1a7..201a9f2 100644 --- a/web/frontend/src/lib/components/ScenarioSection.svelte +++ b/web/frontend/src/lib/components/ScenarioSection.svelte @@ -283,7 +283,6 @@ 'Select collector' ); - function handleCollectionIndexChange(e: Event) { onupdate({ ...scenario, @@ -344,7 +343,10 @@ >{scenario.pack}{selectedId ? ` / ${selectedId}` : ''} {#if isInject} - + inject @@ -360,12 +362,12 @@ aria-label="Enable scenario" /> {#if canRemove} {/if} @@ -431,7 +433,7 @@ disabled={!scenario.pack || loadingManifest} > {comboboxLabel} - + {/snippet} @@ -463,9 +465,12 @@ )} size={16} /> - + {sim.name} ({sim.id}){sim.name} + ({sim.id}) {/each} @@ -496,7 +501,8 @@ /> {tmpl.name} ({tmpl.id}){tmpl.name} + ({tmpl.id}) {/each} @@ -518,42 +524,48 @@
- logs- + logs- - -default + -default
- {#if scenario.templateVars.length > 0} - -
-

Template Variables

- {#each scenario.templateVars as tvar, vi} -
-
- - - handleTemplateVarChange(vi, 'value', (e.target as HTMLInputElement).value)} - /> + {#if scenario.templateVars.length > 0} + +
+

Template Variables

+ {#each scenario.templateVars as tvar, vi} +
+
+ + + handleTemplateVarChange( + vi, + 'value', + (e.target as HTMLInputElement).value + )} + /> +
-
- {/each} -
- {/if} + {/each} +
+ {/if} @@ -561,7 +573,7 @@

Indicators

@@ -570,8 +582,7 @@ - updateStaticIndicator(si, (e.target as HTMLInputElement).value)} + oninput={(e) => updateStaticIndicator(si, (e.target as HTMLInputElement).value)} class="flex-1" />
@@ -679,7 +690,7 @@
@@ -710,7 +721,7 @@

Indicators

@@ -791,7 +802,7 @@
@@ -807,11 +818,7 @@ placeholder="Field name (e.g., cloud.account.id)" value={field.key} oninput={(e) => - updateCollectionField( - fi, - 'key', - (e.target as HTMLInputElement).value - )} + updateCollectionField(fi, 'key', (e.target as HTMLInputElement).value)} class="flex-1 font-mono text-xs" /> = @@ -844,28 +851,28 @@ {/if} {#if scenarioFileType === 'standard'} - - -
-
-

Expectations

- -
+ + +
+
+

Expectations

+ +
- {#each scenario.expectations as expectation, expIndex} - handleExpectationUpdate(expIndex, exp)} - onremove={() => handleExpectationRemove(expIndex)} - canRemove={scenario.expectations.length > 1} - /> - {/each} -
- {/if} + {#each scenario.expectations as expectation, expIndex} + handleExpectationUpdate(expIndex, exp)} + onremove={() => handleExpectationRemove(expIndex)} + canRemove={scenario.expectations.length > 1} + /> + {/each} +
+ {/if}
diff --git a/web/frontend/src/lib/components/SchemaForm.svelte b/web/frontend/src/lib/components/SchemaForm.svelte index 7cf9e12..af24a27 100644 --- a/web/frontend/src/lib/components/SchemaForm.svelte +++ b/web/frontend/src/lib/components/SchemaForm.svelte @@ -64,9 +64,7 @@ }); // Unknown saved keys: present in values but not declared in schema. - let unknownKeys = $derived( - Object.keys(values).filter((k) => !(k in properties)) - ); + let unknownKeys = $derived(Object.keys(values).filter((k) => !(k in properties))); // Auto-expand the cloud defaults section if any built-in has a saved // value, otherwise start collapsed. @@ -142,11 +140,7 @@ /> {:else if prop.type === 'string' && prop.enum && prop.enum.length > 0} {@const stringValue = typeof value === 'string' ? value : ''} - update(name, v ?? '')} - > + update(name, v ?? '')}> {stringValue || (typeof prop.default === 'string' ? prop.default : 'Select...')} @@ -199,7 +193,7 @@ update(name, { ...entriesToObject(entries), '': '' }); }} > - + Add Entry
@@ -231,7 +225,9 @@ {#if builtinEntries.length > 0} -
+
{key} - diff --git a/web/frontend/src/lib/components/connectors/AWSConnectorForm.svelte b/web/frontend/src/lib/components/connectors/AWSConnectorForm.svelte index 40ca09a..ac2f4b8 100644 --- a/web/frontend/src/lib/components/connectors/AWSConnectorForm.svelte +++ b/web/frontend/src/lib/components/connectors/AWSConnectorForm.svelte @@ -68,7 +68,5 @@ {/each} -

- Select a secret group containing SR_AWS_EXTERNAL_ID -

+

Select a secret group containing SR_AWS_EXTERNAL_ID

diff --git a/web/frontend/src/lib/components/connectors/AzureConnectorForm.svelte b/web/frontend/src/lib/components/connectors/AzureConnectorForm.svelte index 5345e43..43a30c4 100644 --- a/web/frontend/src/lib/components/connectors/AzureConnectorForm.svelte +++ b/web/frontend/src/lib/components/connectors/AzureConnectorForm.svelte @@ -71,9 +71,7 @@ export function canTest(): boolean { if (fields.authType !== 'wif') return false; - return ( - !!fields.tenantId.trim() && !!fields.subscriptionId.trim() && !!fields.clientId.trim() - ); + return !!fields.tenantId.trim() && !!fields.subscriptionId.trim() && !!fields.clientId.trim(); } function setAuthType(v: string) { diff --git a/web/frontend/src/lib/components/connectors/ConnectorDetail.svelte b/web/frontend/src/lib/components/connectors/ConnectorDetail.svelte index 157e77a..278658d 100644 --- a/web/frontend/src/lib/components/connectors/ConnectorDetail.svelte +++ b/web/frontend/src/lib/components/connectors/ConnectorDetail.svelte @@ -50,8 +50,7 @@ azure: { 'Cloud Target': 'Used as a detonation target for Azure attack simulations' }, kubernetes: { 'K8s Target': 'Used as a detonation target for Kubernetes attack simulations' }, ssh: { - 'Remote Command Detonation': - 'Execute shell commands on a remote host over SSH for detonation' + 'Remote Command Detonation': 'Execute shell commands on a remote host over SSH for detonation' } }; @@ -69,7 +68,12 @@ } - { if (!v) onClose(); }}> + { + if (!v) onClose(); + }} +> {#if connector} {@const c = connector} @@ -106,7 +110,9 @@
{#if c.description}
-

+

Description

{c.description}

@@ -128,12 +134,14 @@ {#if c.type === 'elastic'}
Kibana URL - {getElasticConfig(c).kibana_url} + {getElasticConfig(c).kibana_url}
{#if getElasticConfig(c).cloud_id}
Cloud ID - {getElasticConfig(c).cloud_id} + {getElasticConfig(c).cloud_id}
{/if} {#if getElasticConfig(c).elasticsearch_url} @@ -150,7 +158,8 @@ {#if getElasticConfig(c).export_enabled} Enabled logs-{getElasticConfig(c).export_datastream || 'asp.results'}-defaultlogs-{getElasticConfig(c).export_datastream || + 'asp.results'}-default {:else} Disabled diff --git a/web/frontend/src/lib/components/connectors/ElasticConnectorForm.svelte b/web/frontend/src/lib/components/connectors/ElasticConnectorForm.svelte index 9725aaa..3d76f6d 100644 --- a/web/frontend/src/lib/components/connectors/ElasticConnectorForm.svelte +++ b/web/frontend/src/lib/components/connectors/ElasticConnectorForm.svelte @@ -87,9 +87,7 @@ {/each} -

- Select a secret group containing SR_ELASTIC_API_KEY -

+

Select a secret group containing SR_ELASTIC_API_KEY

@@ -127,11 +125,7 @@ {#if fields.exportEnabled}
- +

Results will be indexed to: logs-{fields.exportDatastream || 'asp.results'}-default diff --git a/web/frontend/src/lib/components/connectors/GCPConnectorForm.svelte b/web/frontend/src/lib/components/connectors/GCPConnectorForm.svelte index 55681e3..8dea665 100644 --- a/web/frontend/src/lib/components/connectors/GCPConnectorForm.svelte +++ b/web/frontend/src/lib/components/connectors/GCPConnectorForm.svelte @@ -102,9 +102,7 @@

-

- GCP project ID (injected as GOOGLE_CLOUD_PROJECT) -

+

GCP project ID (injected as GOOGLE_CLOUD_PROJECT)

@@ -137,9 +135,7 @@ placeholder="simrun@project-id.iam.gserviceaccount.com" bind:value={fields.serviceAccountEmail} /> -

- The GCP service account to impersonate via WIF -

+

The GCP service account to impersonate via WIF

{:else}
diff --git a/web/frontend/src/lib/components/ui/alert/alert-action.svelte b/web/frontend/src/lib/components/ui/alert/alert-action.svelte new file mode 100644 index 0000000..3df2083 --- /dev/null +++ b/web/frontend/src/lib/components/ui/alert/alert-action.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/web/frontend/src/lib/components/ui/alert/alert-description.svelte b/web/frontend/src/lib/components/ui/alert/alert-description.svelte index 5920ca6..efe20a1 100644 --- a/web/frontend/src/lib/components/ui/alert/alert-description.svelte +++ b/web/frontend/src/lib/components/ui/alert/alert-description.svelte @@ -14,7 +14,7 @@ bind:this={ref} data-slot="alert-description" class={cn( - 'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed', + 'text-muted-foreground text-sm text-balance md:text-pretty [&_p:not(:last-child)]:mb-4 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3', className )} {...restProps} diff --git a/web/frontend/src/lib/components/ui/alert/alert-title.svelte b/web/frontend/src/lib/components/ui/alert/alert-title.svelte index 8a35d09..1c019c7 100644 --- a/web/frontend/src/lib/components/ui/alert/alert-title.svelte +++ b/web/frontend/src/lib/components/ui/alert/alert-title.svelte @@ -13,7 +13,10 @@
svg]/alert:col-start-2 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3', + className + )} {...restProps} > {@render children?.()} diff --git a/web/frontend/src/lib/components/ui/alert/alert.svelte b/web/frontend/src/lib/components/ui/alert/alert.svelte index a2e48f5..71ef768 100644 --- a/web/frontend/src/lib/components/ui/alert/alert.svelte +++ b/web/frontend/src/lib/components/ui/alert/alert.svelte @@ -2,12 +2,12 @@ import { type VariantProps, tv } from 'tailwind-variants'; export const alertVariants = tv({ - base: 'relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current', + base: "grid gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4 group/alert relative w-full", variants: { variant: { default: 'bg-card text-card-foreground', destructive: - 'text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current' + 'text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current' } }, defaultVariants: { diff --git a/web/frontend/src/lib/components/ui/alert/index.ts b/web/frontend/src/lib/components/ui/alert/index.ts index 5e0f854..0bc9d9c 100644 --- a/web/frontend/src/lib/components/ui/alert/index.ts +++ b/web/frontend/src/lib/components/ui/alert/index.ts @@ -1,14 +1,17 @@ import Root from './alert.svelte'; import Description from './alert-description.svelte'; import Title from './alert-title.svelte'; +import Action from './alert-action.svelte'; export { alertVariants, type AlertVariant } from './alert.svelte'; export { Root, Description, Title, + Action, // Root as Alert, Description as AlertDescription, - Title as AlertTitle + Title as AlertTitle, + Action as AlertAction }; diff --git a/web/frontend/src/lib/components/ui/avatar/avatar-badge.svelte b/web/frontend/src/lib/components/ui/avatar/avatar-badge.svelte new file mode 100644 index 0000000..9ef8ba7 --- /dev/null +++ b/web/frontend/src/lib/components/ui/avatar/avatar-badge.svelte @@ -0,0 +1,26 @@ + + +svg]:hidden', + 'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2', + 'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2', + className + )} + {...restProps} +> + {@render children?.()} + diff --git a/web/frontend/src/lib/components/ui/avatar/avatar-fallback.svelte b/web/frontend/src/lib/components/ui/avatar/avatar-fallback.svelte index b911baf..213a71c 100644 --- a/web/frontend/src/lib/components/ui/avatar/avatar-fallback.svelte +++ b/web/frontend/src/lib/components/ui/avatar/avatar-fallback.svelte @@ -12,6 +12,9 @@ diff --git a/web/frontend/src/lib/components/ui/avatar/avatar-group-count.svelte b/web/frontend/src/lib/components/ui/avatar/avatar-group-count.svelte new file mode 100644 index 0000000..3cc3bb0 --- /dev/null +++ b/web/frontend/src/lib/components/ui/avatar/avatar-group-count.svelte @@ -0,0 +1,23 @@ + + +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 ring-background relative flex shrink-0 items-center justify-center ring-2', + className + )} + {...restProps} +> + {@render children?.()} +
diff --git a/web/frontend/src/lib/components/ui/avatar/avatar-group.svelte b/web/frontend/src/lib/components/ui/avatar/avatar-group.svelte new file mode 100644 index 0000000..77b389c --- /dev/null +++ b/web/frontend/src/lib/components/ui/avatar/avatar-group.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/web/frontend/src/lib/components/ui/avatar/avatar-image.svelte b/web/frontend/src/lib/components/ui/avatar/avatar-image.svelte index 7ccc3ce..2f47f2c 100644 --- a/web/frontend/src/lib/components/ui/avatar/avatar-image.svelte +++ b/web/frontend/src/lib/components/ui/avatar/avatar-image.svelte @@ -12,6 +12,6 @@ diff --git a/web/frontend/src/lib/components/ui/avatar/avatar.svelte b/web/frontend/src/lib/components/ui/avatar/avatar.svelte index 3fd4dc2..2b6388d 100644 --- a/web/frontend/src/lib/components/ui/avatar/avatar.svelte +++ b/web/frontend/src/lib/components/ui/avatar/avatar.svelte @@ -5,15 +5,22 @@ let { ref = $bindable(null), loadingStatus = $bindable('loading'), + size = 'default', class: className, ...restProps - }: AvatarPrimitive.RootProps = $props(); + }: AvatarPrimitive.RootProps & { + size?: 'default' | 'sm' | 'lg'; + } = $props(); diff --git a/web/frontend/src/lib/components/ui/avatar/index.ts b/web/frontend/src/lib/components/ui/avatar/index.ts index 9585f8a..c4530fb 100644 --- a/web/frontend/src/lib/components/ui/avatar/index.ts +++ b/web/frontend/src/lib/components/ui/avatar/index.ts @@ -1,13 +1,22 @@ import Root from './avatar.svelte'; import Image from './avatar-image.svelte'; import Fallback from './avatar-fallback.svelte'; +import Badge from './avatar-badge.svelte'; +import Group from './avatar-group.svelte'; +import GroupCount from './avatar-group-count.svelte'; export { Root, Image, Fallback, + Badge, + Group, + GroupCount, // Root as Avatar, Image as AvatarImage, - Fallback as AvatarFallback + Fallback as AvatarFallback, + Badge as AvatarBadge, + Group as AvatarGroup, + GroupCount as AvatarGroupCount }; diff --git a/web/frontend/src/lib/components/ui/badge/badge.svelte b/web/frontend/src/lib/components/ui/badge/badge.svelte index c05f168..5f164df 100644 --- a/web/frontend/src/lib/components/ui/badge/badge.svelte +++ b/web/frontend/src/lib/components/ui/badge/badge.svelte @@ -2,18 +2,18 @@ import { type VariantProps, tv } from 'tailwind-variants'; export const badgeVariants = tv({ - base: 'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3', + base: 'h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive group/badge inline-flex w-fit shrink-0 items-center justify-center overflow-hidden whitespace-nowrap transition-colors focus-visible:ring-[3px] [&>svg]:pointer-events-none', variants: { variant: { - default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent', - secondary: - 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent', + default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80', + secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80', destructive: - 'bg-status-error/10 text-status-error border-status-error/25 [a&]:hover:bg-status-error/20 focus-visible:ring-status-error/20', + 'bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20', success: - 'bg-status-success/10 text-status-success border-status-success/25 [a&]:hover:bg-status-success/20', - outline: - 'border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground' + 'bg-status-success/10 [a]:hover:bg-status-success/20 focus-visible:ring-status-success/20 text-status-success dark:bg-status-success/20', + outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground', + ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50', + link: 'text-primary underline-offset-4 hover:underline' } }, defaultVariants: { diff --git a/web/frontend/src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte b/web/frontend/src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte index 030197f..e5c0c3c 100644 --- a/web/frontend/src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte +++ b/web/frontend/src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte @@ -1,7 +1,7 @@ -
+
{@render children?.()}
diff --git a/web/frontend/src/lib/components/ui/card/card-footer.svelte b/web/frontend/src/lib/components/ui/card/card-footer.svelte index 4e390bf..6dcf877 100644 --- a/web/frontend/src/lib/components/ui/card/card-footer.svelte +++ b/web/frontend/src/lib/components/ui/card/card-footer.svelte @@ -13,7 +13,10 @@
{@render children?.()} diff --git a/web/frontend/src/lib/components/ui/card/card-header.svelte b/web/frontend/src/lib/components/ui/card/card-header.svelte index 9cdc602..bcf6866 100644 --- a/web/frontend/src/lib/components/ui/card/card-header.svelte +++ b/web/frontend/src/lib/components/ui/card/card-header.svelte @@ -14,7 +14,7 @@ bind:this={ref} data-slot="card-header" class={cn( - '@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6', + 'gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]', className )} {...restProps} diff --git a/web/frontend/src/lib/components/ui/card/card-title.svelte b/web/frontend/src/lib/components/ui/card/card-title.svelte index 0eae8ec..d494479 100644 --- a/web/frontend/src/lib/components/ui/card/card-title.svelte +++ b/web/frontend/src/lib/components/ui/card/card-title.svelte @@ -13,7 +13,7 @@
{@render children?.()} diff --git a/web/frontend/src/lib/components/ui/card/card.svelte b/web/frontend/src/lib/components/ui/card/card.svelte index 2c8c321..9cdf8c6 100644 --- a/web/frontend/src/lib/components/ui/card/card.svelte +++ b/web/frontend/src/lib/components/ui/card/card.svelte @@ -6,15 +6,17 @@ ref = $bindable(null), class: className, children, + size = 'default', ...restProps - }: WithElementRef> = $props(); + }: WithElementRef> & { size?: 'default' | 'sm' } = $props();
img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col', className )} {...restProps} diff --git a/web/frontend/src/lib/components/ui/checkbox/checkbox.svelte b/web/frontend/src/lib/components/ui/checkbox/checkbox.svelte index fc8614a..daa47f3 100644 --- a/web/frontend/src/lib/components/ui/checkbox/checkbox.svelte +++ b/web/frontend/src/lib/components/ui/checkbox/checkbox.svelte @@ -1,8 +1,8 @@ @@ -28,13 +32,11 @@ {title} {description} - - + + diff --git a/web/frontend/src/lib/components/ui/command/command-group.svelte b/web/frontend/src/lib/components/ui/command/command-group.svelte index 4097222..d637a5a 100644 --- a/web/frontend/src/lib/components/ui/command/command-group.svelte +++ b/web/frontend/src/lib/components/ui/command/command-group.svelte @@ -17,7 +17,10 @@ diff --git a/web/frontend/src/lib/components/ui/command/command-input.svelte b/web/frontend/src/lib/components/ui/command/command-input.svelte index 8262b8a..1a3aa03 100644 --- a/web/frontend/src/lib/components/ui/command/command-input.svelte +++ b/web/frontend/src/lib/components/ui/command/command-input.svelte @@ -1,7 +1,8 @@ -
- - +
+ + + {#snippet child({ props })} + + {/snippet} + + + + +
diff --git a/web/frontend/src/lib/components/ui/command/command-item.svelte b/web/frontend/src/lib/components/ui/command/command-item.svelte index 063adb7..dbe3e82 100644 --- a/web/frontend/src/lib/components/ui/command/command-item.svelte +++ b/web/frontend/src/lib/components/ui/command/command-item.svelte @@ -1,10 +1,12 @@ @@ -13,8 +15,13 @@ bind:ref data-slot="command-item" class={cn( - "aria-selected:bg-accent aria-selected:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "group/command-item data-selected:bg-muted data-selected:text-foreground data-selected:*:[svg]:text-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className )} {...restProps} -/> +> + {@render children?.()} + + diff --git a/web/frontend/src/lib/components/ui/command/command-list.svelte b/web/frontend/src/lib/components/ui/command/command-list.svelte index 8ff87f0..59c2559 100644 --- a/web/frontend/src/lib/components/ui/command/command-list.svelte +++ b/web/frontend/src/lib/components/ui/command/command-list.svelte @@ -12,6 +12,9 @@ diff --git a/web/frontend/src/lib/components/ui/command/command-shortcut.svelte b/web/frontend/src/lib/components/ui/command/command-shortcut.svelte index c36bbe5..6ffdd22 100644 --- a/web/frontend/src/lib/components/ui/command/command-shortcut.svelte +++ b/web/frontend/src/lib/components/ui/command/command-shortcut.svelte @@ -13,7 +13,10 @@ {@render children?.()} diff --git a/web/frontend/src/lib/components/ui/command/command.svelte b/web/frontend/src/lib/components/ui/command/command.svelte index 2c81aa8..8647727 100644 --- a/web/frontend/src/lib/components/ui/command/command.svelte +++ b/web/frontend/src/lib/components/ui/command/command.svelte @@ -21,7 +21,7 @@ bind:ref data-slot="command" class={cn( - 'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md', + 'bg-popover text-popover-foreground rounded-xl! p-1 flex size-full flex-col overflow-hidden', className )} {...restProps} diff --git a/web/frontend/src/lib/components/ui/dialog/dialog-close.svelte b/web/frontend/src/lib/components/ui/dialog/dialog-close.svelte index e8a96a7..4141704 100644 --- a/web/frontend/src/lib/components/ui/dialog/dialog-close.svelte +++ b/web/frontend/src/lib/components/ui/dialog/dialog-close.svelte @@ -1,7 +1,11 @@ - + diff --git a/web/frontend/src/lib/components/ui/dialog/dialog-content.svelte b/web/frontend/src/lib/components/ui/dialog/dialog-content.svelte index b104e4f..80db472 100644 --- a/web/frontend/src/lib/components/ui/dialog/dialog-content.svelte +++ b/web/frontend/src/lib/components/ui/dialog/dialog-content.svelte @@ -1,11 +1,12 @@
{@render children?.()} + {#if showCloseButton} + + {#snippet child({ props })} + + {/snippet} + + {/if}
diff --git a/web/frontend/src/lib/components/ui/dialog/dialog-header.svelte b/web/frontend/src/lib/components/ui/dialog/dialog-header.svelte index 9b2701c..bfc8d40 100644 --- a/web/frontend/src/lib/components/ui/dialog/dialog-header.svelte +++ b/web/frontend/src/lib/components/ui/dialog/dialog-header.svelte @@ -13,7 +13,7 @@
{@render children?.()} diff --git a/web/frontend/src/lib/components/ui/dialog/dialog-overlay.svelte b/web/frontend/src/lib/components/ui/dialog/dialog-overlay.svelte index 938ab1e..f884246 100644 --- a/web/frontend/src/lib/components/ui/dialog/dialog-overlay.svelte +++ b/web/frontend/src/lib/components/ui/dialog/dialog-overlay.svelte @@ -13,7 +13,7 @@ bind:ref data-slot="dialog-overlay" class={cn( - 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50', + 'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50', className )} {...restProps} diff --git a/web/frontend/src/lib/components/ui/dialog/dialog-title.svelte b/web/frontend/src/lib/components/ui/dialog/dialog-title.svelte index 7073699..f3aa721 100644 --- a/web/frontend/src/lib/components/ui/dialog/dialog-title.svelte +++ b/web/frontend/src/lib/components/ui/dialog/dialog-title.svelte @@ -12,6 +12,6 @@ diff --git a/web/frontend/src/lib/components/ui/dialog/dialog-trigger.svelte b/web/frontend/src/lib/components/ui/dialog/dialog-trigger.svelte index ac04d9f..9264541 100644 --- a/web/frontend/src/lib/components/ui/dialog/dialog-trigger.svelte +++ b/web/frontend/src/lib/components/ui/dialog/dialog-trigger.svelte @@ -1,7 +1,11 @@ - + diff --git a/web/frontend/src/lib/components/ui/drawer/drawer-content.svelte b/web/frontend/src/lib/components/ui/drawer/drawer-content.svelte index cace02c..411b202 100644 --- a/web/frontend/src/lib/components/ui/drawer/drawer-content.svelte +++ b/web/frontend/src/lib/components/ui/drawer/drawer-content.svelte @@ -23,17 +23,13 @@ bind:ref data-slot="drawer-content" class={cn( - 'group/drawer-content bg-background fixed z-50 flex h-auto flex-col', - 'data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b', - 'data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t', - 'data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:end-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-s data-[vaul-drawer-direction=right]:sm:max-w-sm', - 'data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:start-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-e data-[vaul-drawer-direction=left]:sm:max-w-sm', + 'bg-popover text-popover-foreground flex h-auto flex-col text-sm data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:rounded-r-xl data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:rounded-l-xl data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm group/drawer-content fixed z-50', className )} {...restProps} > {@render children?.()} diff --git a/web/frontend/src/lib/components/ui/drawer/drawer-footer.svelte b/web/frontend/src/lib/components/ui/drawer/drawer-footer.svelte index c436cc2..a177a75 100644 --- a/web/frontend/src/lib/components/ui/drawer/drawer-footer.svelte +++ b/web/frontend/src/lib/components/ui/drawer/drawer-footer.svelte @@ -13,7 +13,7 @@
{@render children?.()} diff --git a/web/frontend/src/lib/components/ui/drawer/drawer-header.svelte b/web/frontend/src/lib/components/ui/drawer/drawer-header.svelte index 4f64f24..eeadd02 100644 --- a/web/frontend/src/lib/components/ui/drawer/drawer-header.svelte +++ b/web/frontend/src/lib/components/ui/drawer/drawer-header.svelte @@ -13,7 +13,10 @@
{@render children?.()} diff --git a/web/frontend/src/lib/components/ui/drawer/drawer-overlay.svelte b/web/frontend/src/lib/components/ui/drawer/drawer-overlay.svelte index fdea02b..b8f3090 100644 --- a/web/frontend/src/lib/components/ui/drawer/drawer-overlay.svelte +++ b/web/frontend/src/lib/components/ui/drawer/drawer-overlay.svelte @@ -13,7 +13,7 @@ bind:ref data-slot="drawer-overlay" class={cn( - 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50', + 'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50', className )} {...restProps} diff --git a/web/frontend/src/lib/components/ui/drawer/drawer-title.svelte b/web/frontend/src/lib/components/ui/drawer/drawer-title.svelte index fe395f3..5ceaf9c 100644 --- a/web/frontend/src/lib/components/ui/drawer/drawer-title.svelte +++ b/web/frontend/src/lib/components/ui/drawer/drawer-title.svelte @@ -12,6 +12,6 @@ diff --git a/web/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte b/web/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte index 335c55b..25f87fb 100644 --- a/web/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +++ b/web/frontend/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte @@ -1,7 +1,7 @@ + + + +
{ + if ((e.target as HTMLElement).closest('button')) { + return; + } + e.currentTarget.parentElement?.querySelector('input')?.focus(); + }} + {...restProps} +> + {@render children?.()} +
diff --git a/web/frontend/src/lib/components/ui/input-group/input-group-button.svelte b/web/frontend/src/lib/components/ui/input-group/input-group-button.svelte new file mode 100644 index 0000000..bd4be14 --- /dev/null +++ b/web/frontend/src/lib/components/ui/input-group/input-group-button.svelte @@ -0,0 +1,49 @@ + + + + + diff --git a/web/frontend/src/lib/components/ui/input-group/input-group-input.svelte b/web/frontend/src/lib/components/ui/input-group/input-group-input.svelte new file mode 100644 index 0000000..9339d57 --- /dev/null +++ b/web/frontend/src/lib/components/ui/input-group/input-group-input.svelte @@ -0,0 +1,23 @@ + + + diff --git a/web/frontend/src/lib/components/ui/input-group/input-group-text.svelte b/web/frontend/src/lib/components/ui/input-group/input-group-text.svelte new file mode 100644 index 0000000..e51a0fd --- /dev/null +++ b/web/frontend/src/lib/components/ui/input-group/input-group-text.svelte @@ -0,0 +1,22 @@ + + + + {@render children?.()} + diff --git a/web/frontend/src/lib/components/ui/input-group/input-group-textarea.svelte b/web/frontend/src/lib/components/ui/input-group/input-group-textarea.svelte new file mode 100644 index 0000000..a3b445d --- /dev/null +++ b/web/frontend/src/lib/components/ui/input-group/input-group-textarea.svelte @@ -0,0 +1,23 @@ + + + diff --git a/web/frontend/src/lib/components/ui/toggle-group/toggle-group-item.svelte b/web/frontend/src/lib/components/ui/toggle-group/toggle-group-item.svelte index 2948447..3825e38 100644 --- a/web/frontend/src/lib/components/ui/toggle-group/toggle-group-item.svelte +++ b/web/frontend/src/lib/components/ui/toggle-group/toggle-group-item.svelte @@ -23,11 +23,11 @@ data-size={ctx.size || size} data-spacing={ctx.spacing} class={cn( + 'group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg shrink-0 focus:z-10 focus-visible:z-10 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t', toggleVariants({ variant: ctx.variant || variant, size: ctx.size || size }), - 'w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10 data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l', className )} {value} diff --git a/web/frontend/src/lib/components/ui/toggle-group/toggle-group.svelte b/web/frontend/src/lib/components/ui/toggle-group/toggle-group.svelte index a445709..b8f9c79 100644 --- a/web/frontend/src/lib/components/ui/toggle-group/toggle-group.svelte +++ b/web/frontend/src/lib/components/ui/toggle-group/toggle-group.svelte @@ -7,6 +7,7 @@ interface ToggleGroupContext extends ToggleVariants { spacing?: number; + orientation?: 'horizontal' | 'vertical'; } export function setToggleGroupCtx(props: ToggleGroupContext) { @@ -28,14 +29,28 @@ class: className, size = 'default', spacing = 0, + orientation = 'horizontal', variant = 'default', ...restProps - }: ToggleGroupPrimitive.RootProps & ToggleVariants & { spacing?: number } = $props(); + }: ToggleGroupPrimitive.RootProps & + ToggleVariants & { + spacing?: number; + orientation?: 'horizontal' | 'vertical'; + } = $props(); setToggleGroupCtx({ - variant, - size, - spacing + get variant() { + return variant; + }, + get size() { + return size; + }, + get spacing() { + return spacing; + }, + get orientation() { + return orientation; + } }); @@ -46,13 +61,14 @@ get along, so we shut typescript up by casting `value` to `never`. import { Tooltip as TooltipPrimitive } from 'bits-ui'; - let { ...restProps }: TooltipPrimitive.ProviderProps = $props(); + let { delayDuration = 0, ...restProps }: TooltipPrimitive.ProviderProps = $props(); - + diff --git a/web/frontend/src/lib/components/ui/tooltip/tooltip-trigger.svelte b/web/frontend/src/lib/components/ui/tooltip/tooltip-trigger.svelte index 5631d1b..0f04162 100644 --- a/web/frontend/src/lib/components/ui/tooltip/tooltip-trigger.svelte +++ b/web/frontend/src/lib/components/ui/tooltip/tooltip-trigger.svelte @@ -1,7 +1,7 @@ - diff --git a/web/frontend/src/lib/components/ui/tooltip/tooltip.svelte b/web/frontend/src/lib/components/ui/tooltip/tooltip.svelte index 7c9d712..16eeb70 100644 --- a/web/frontend/src/lib/components/ui/tooltip/tooltip.svelte +++ b/web/frontend/src/lib/components/ui/tooltip/tooltip.svelte @@ -1,7 +1,7 @@ - diff --git a/web/frontend/src/lib/stores/scenario-tracker.svelte.ts b/web/frontend/src/lib/stores/scenario-tracker.svelte.ts index 1156983..adfb9b0 100644 --- a/web/frontend/src/lib/stores/scenario-tracker.svelte.ts +++ b/web/frontend/src/lib/stores/scenario-tracker.svelte.ts @@ -69,7 +69,9 @@ export class ScenarioTracker { if (s.status === 'completed' && s.isSuccess !== null) { updated[s.name] = { name: s.name, status: 'completed', result: s }; } else { - updated[s.name] = { name: s.name, status: s.status, phase: s.phase }; + // Carry the partial result for running/pending rows too, so the UI + // can surface mid-run executor identity and incremental assertions. + updated[s.name] = { name: s.name, status: s.status, phase: s.phase, result: s }; } } this.entries = updated; diff --git a/web/frontend/src/lib/utils/format.ts b/web/frontend/src/lib/utils/format.ts index a610f59..b8fd2f2 100644 --- a/web/frontend/src/lib/utils/format.ts +++ b/web/frontend/src/lib/utils/format.ts @@ -27,6 +27,26 @@ export function formatTime(dateStr: string): string { return new Date(dateStr).toLocaleString(); } +/** + * Compact, scannable relative time ("just now", "5m ago", "2h ago", "yesterday", + * "3d ago") falling back to a short date for anything older than a week. Pair with + * a tooltip showing the absolute time via formatTime(). + */ +export function formatRelativeTime(dateStr: string): string { + const then = new Date(dateStr).getTime(); + if (Number.isNaN(then)) return '--'; + const secs = Math.floor((Date.now() - then) / 1000); + if (secs < 45) return 'just now'; + const mins = Math.floor(secs / 60); + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days === 1) return 'yesterday'; + if (days < 7) return `${days}d ago`; + return new Date(then).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +} + export function scenarioTypeVariant(type?: ScenarioType | string): 'default' | 'secondary' | 'outline' { switch (type) { case 'explore': diff --git a/web/frontend/src/routes/+layout.svelte b/web/frontend/src/routes/+layout.svelte index 983fb99..db3507e 100644 --- a/web/frontend/src/routes/+layout.svelte +++ b/web/frontend/src/routes/+layout.svelte @@ -101,7 +101,7 @@ auth.logout()}> - + Log out diff --git a/web/frontend/src/routes/assessments/+page.svelte b/web/frontend/src/routes/assessments/+page.svelte index 05623d1..4580ce1 100644 --- a/web/frontend/src/routes/assessments/+page.svelte +++ b/web/frontend/src/routes/assessments/+page.svelte @@ -19,6 +19,8 @@ statusVariant, formatDuration, formatUserEmail, + formatRelativeTime, + formatTime, scenarioTypeVariant } from '$lib/utils/format'; import type { Run, ScenarioType, AppConfig } from '$lib/types'; @@ -76,6 +78,9 @@ let page = $state(1); let perPage = $state(50); + // Running assessments visible on the current page — drives the live header pill. + const runningCount = $derived(pageRuns.filter((r) => r.status === 'running').length); + // Filter state — seeded from URL on mount, persisted via goto() on change. let nameFilter = $state(''); let typeFilter = $state([]); @@ -300,14 +305,24 @@
-

Assessment History

+
+

Assessment History

+ {#if runningCount > 0} + + + {runningCount} running + + {/if} +
@@ -364,7 +379,7 @@ {#if hasActiveFilters} {/if} @@ -400,12 +415,12 @@ {#if hasActiveFilters} {:else} {/if} @@ -420,24 +435,31 @@ Status Scenario Type - Total - Passed - Failed + Results Started By - Start Time + Started Duration - {#each pageRuns as run} + {#each pageRuns as run (run.id)} + {@const pending = Math.max(0, run.total - run.succeeded - run.failed)} goto(`/assessments/${run.id}`)} > - {run.id.slice(0, 8)} + + {run.id.slice(0, 8)} + - {run.status} + + {#if run.status === 'running'} + + {/if} + {run.status} + {run.scenarioName || '--'} @@ -449,9 +471,35 @@ -- {/if} - {run.total} - {run.succeeded} - 0 ? 'text-destructive' : ''}>{run.failed} + + {#if run.total > 0} +
+
+
+
+ {#if pending > 0} +
+ {/if} +
+ + {run.succeeded}/{run.total} + +
+ {:else} + -- + {/if} +
@@ -460,8 +508,17 @@ {run.createdBy} - {new Date(run.startTime).toLocaleString()} - {formatDuration(run.startTime, run.endTime)} + + + + {formatRelativeTime(run.startTime)} + + {formatTime(run.startTime)} + + + {formatDuration(run.startTime, run.endTime)} -
+ +
-
-
-
-
+ +
+
+
+ {#if pendingPct > 0}
-
-
- - - {trackerSucceeded} - - - - {trackerFailed} + {/if} +
+ +
+ + {trackerSucceeded} + passed + + + {trackerFailed} + failed + + + {total} + scenarios + + {#if $currentRun.status === 'running'} + + + running - / - {total} - {#if $currentRun.status === 'running'} - - {/if} -
+ {/if}
@@ -272,11 +317,17 @@ Results ({tracker.sortedEntries.length}) Logs ({tracker.logs.length}) - + {#if tracker.sortedEntries.length > 0} - {#each tracker.sortedEntries as entry (entry.name)} - - {/each} + +
+
+ {#each tracker.sortedEntries as entry (entry.name)} + + {/each} +
{:else}

No scenarios yet.

{/if} diff --git a/web/frontend/src/routes/connectors/+page.svelte b/web/frontend/src/routes/connectors/+page.svelte index 72e900b..519d753 100644 --- a/web/frontend/src/routes/connectors/+page.svelte +++ b/web/frontend/src/routes/connectors/+page.svelte @@ -116,147 +116,148 @@ } -
-

Connectors

- -
+

Connectors

+ +
- {#if error} - - {error} - - {/if} + {#if error} + + {error} + + {/if} - {#if loading} -
- {#each Array(3) as _} - - {/each} -
- {:else if $connectors.length === 0} - - - - - - No connectors configured - - Connect to external systems like Elastic Security or cloud providers for attack - simulation and alert matching. - - - - - - - {:else} -
- {#each $connectors as connector, i (connector.id)} -
openDetail(connector)} - onkeydown={(e) => handleCardKeydown(e, connector)} - class="group relative flex h-full cursor-pointer flex-col overflow-hidden rounded-xl border bg-card p-5 text-left transition-all duration-200 animate-fade-up + {#if loading} +
+ {#each Array(3) as _} + + {/each} +
+ {:else if $connectors.length === 0} + + + + + + No connectors configured + + Connect to external systems like Elastic Security or cloud providers for attack simulation + and alert matching. + + + + + + + {:else} +
+ {#each $connectors as connector, i (connector.id)} +
openDetail(connector)} + onkeydown={(e) => handleCardKeydown(e, connector)} + class="group relative flex h-full cursor-pointer flex-col overflow-hidden rounded-xl border bg-card p-5 text-left transition-all duration-200 animate-fade-up hover:-translate-y-0.5 hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" - style="animation-delay: {i * 60}ms" - > - - + style="animation-delay: {i * 60}ms" + > + + - -
-
- {#if connector.type === 'elastic'} - - {:else if connector.type === 'ssh'} - - {:else} - - {/if} -
-
-

{connector.name}

-

- {connector.type}{#if connector.isDefault} · Default{/if} -

-
- - {connector.enabled ? 'Enabled' : 'Disabled'} - - - - {#snippet child({ props })} - - {/snippet} - - - openEdit(connector)}> - - Edit - - - openDelete(connector)}> - - Delete - - - + +
+
+ {#if connector.type === 'elastic'} + + {:else if connector.type === 'ssh'} + + {:else} + + {/if}
- - - {#if connector.description} -

- {connector.description} -

- {/if} - {#if getConnectorCardInfo(connector)} -

- {getConnectorCardInfo(connector)} +

+

{connector.name}

+

+ {connector.type}{#if connector.isDefault} + · Default{/if}

- {/if} - {#if connectorFeatures[connector.type]} -
- {#each connectorFeatures[connector.type].slice(0, 5) as feature} - + + {connector.enabled ? 'Enabled' : 'Disabled'} + + + + {#snippet child({ props })} +
- {/if} - - -
+ + + {/snippet} + + + openEdit(connector)}> + + Edit + + + openDelete(connector)}> + + Delete + + + +
- -
- Secrets: {getSecretGroupName(connector.secretGroupId)} - - View details - - + + {#if connector.description} +

+ {connector.description} +

+ {/if} + {#if getConnectorCardInfo(connector)} +

+ {getConnectorCardInfo(connector)} +

+ {/if} + {#if connectorFeatures[connector.type]} +
+ {#each connectorFeatures[connector.type].slice(0, 5) as feature} + + {feature} + + {/each}
+ {/if} + + +
+ + +
+ Secrets: {getSecretGroupName(connector.secretGroupId)} + + View details + +
- {/each} -
- {/if} +
+ {/each} +
+ {/if}
{#if activeTab === 'installed' && !loading && $packs.length > 0}
- +
{/if} diff --git a/web/frontend/src/routes/rules/coverage/+page.svelte b/web/frontend/src/routes/rules/coverage/+page.svelte index d06802b..2ab1d5c 100644 --- a/web/frontend/src/routes/rules/coverage/+page.svelte +++ b/web/frontend/src/routes/rules/coverage/+page.svelte @@ -1,24 +1,24 @@