From cc6d88f980151634ea1112195fcd4187ee1ae094 Mon Sep 17 00:00:00 2001 From: alvarofraguas Date: Tue, 9 Jun 2026 15:31:58 +0200 Subject: [PATCH 01/57] spec: enroll + config parity with legacy admin Add design doc for SPA parity PR covering: - per-OS flags download (Linux/Mac/Windows/FreeBSD) - certificate viewer/upload/download on enrollment page - assembled osquery.conf view on config page - interval sliders with live readout (replace number inputs) - inline add-scheduled-query and add-option-flag forms - per-section documentation links matching legacy - 2 new osctrl-api endpoints (cert upload, configuration GET) --- .../2026-05-26-enroll-config-parity-design.md | 360 ++++++++++++++++++ 1 file changed, 360 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-26-enroll-config-parity-design.md diff --git a/docs/superpowers/specs/2026-05-26-enroll-config-parity-design.md b/docs/superpowers/specs/2026-05-26-enroll-config-parity-design.md new file mode 100644 index 00000000..66b6ba45 --- /dev/null +++ b/docs/superpowers/specs/2026-05-26-enroll-config-parity-design.md @@ -0,0 +1,360 @@ +# Enroll + Config parity with legacy admin + +## Context + +The new React SPA exposes the per-environment Enrollment page +(`/_app/env/{env}/enroll`) and Configuration page +(`/_app/env/{env}/config`). Both surfaces are functional but missing +controls that the legacy Go-template admin (`cmd/admin`) provides. +Operators migrating to the SPA need those controls or they cannot: + +- View, download, or replace the TLS server certificate that + osquery agents pin against. +- Pull the per-OS flags blob that ships pre-baked + certificate/secret paths for each platform installer. +- Verify the assembled `osquery.conf` document the agent will receive + — the SPA currently exposes only the per-section editors. +- Adjust intervals on a slider with a live readout (the SPA forces + manual numeric typing). +- Add a scheduled query or an osquery flag without hand-editing the + raw JSON in the Monaco editor. + +This PR closes the parity gap. It is intentionally scoped to *parity +with intent*, not new functionality. + +## Goals + +1. SPA Enrollment page can view + copy + download + upload the env + certificate, and can view + copy + per-OS download the flags blob. +2. SPA Configuration page shows an authoritative assembled + `osquery.conf` (server-rendered, not client-stitched). +3. SPA Configuration page exposes inline "Add scheduled query" and + "Add option flag" forms that mutate the existing Monaco buffer + without changing the save flow. +4. SPA `IntervalsCard` uses range sliders with live readouts, matching + the legacy slider UX. +5. `osctrl-api` exposes the two endpoints the SPA needs (cert upload, + assembled configuration) and extends the existing enroll-target + switch for per-OS flags. + +## Non-goals + +- New auth or permission models. Everything new is AdminLevel-gated. +- A JSON-validity pass/fail badge on each editor (Monaco's inline + squigglies cover this). +- Modal wizards. The add-query and add-flag forms live inline at the + bottom of their respective editor cards. +- SPA unit tests for the new sections. The existing EnrollPage and + EnvConfigPage have minimal component coverage; the new sections + match that pattern. Backend gets full table-driven tests. +- A "preview before save" diff for the add-query/add-flag forms. + The user reviews the resulting JSON in the Monaco editor before + clicking Save — same as legacy. + +## Backend changes (osctrl-api) + +### Per-OS flags — extend `EnvEnrollHandler` + +`cmd/api/handlers/environments.go` already handles +`GET /api/v1/environments/{env}/enroll/{target}` for +`secret`, `cert`, `flags`, `enroll.sh`, `enroll.ps1`. Add four cases: + +| target | secret path | cert path | +|---|---|---| +| `flagsLinux` | `/etc/osquery/osctrl-{env}.secret` | `/etc/osquery/osctrl-{env}.crt` | +| `flagsMac` | `/private/var/osquery/osctrl-{env}.secret` | `/private/var/osquery/osctrl-{env}.crt` | +| `flagsWindows` | `C:\Program Files\osquery\osctrl-{env}.secret` | `C:\Program Files\osquery\osctrl-{env}.crt` | +| `flagsFreeBSD` | `/usr/local/etc/osctrl-{env}.secret` | `/usr/local/etc/osctrl-{env}.crt` | + +Each case calls +`environments.GenerateFlags(env, secretPath, certPath, h.OsqueryValues)` +(already exists in `pkg/environments/flags.go`) and returns the result +in the existing `types.ApiDataResponse{Data: ...}` envelope. + +Paths are constants in legacy at `cmd/admin/handlers/templates.go` +lines 968–984. Lift them to `cmd/api/handlers/environments.go` as a +private `osFlagPaths` map so the cases stay compact. Do not extract to +`pkg/environments` — the paths are an installer convention, not a +shared business rule. + +No new route mount; the existing `GET .../enroll/{target}` accepts +the new targets through the switch. + +### Cert upload — new `EnvCertUploadHandler` + +`POST /api/v1/environments/{env}/enroll/cert` + +Request body: +```json +{ "certificate_b64": "" } +``` + +Validation (option **b** from brainstorm — parse-check): + +1. base64-decode `certificate_b64`. Fail → 400 `"invalid base64"`. +2. `pem.Decode` the bytes. Require at least one block of type + `CERTIFICATE`. Fail → 400 `"no CERTIFICATE PEM block found"`. +3. `x509.ParseCertificate` the block's `Bytes`. Fail → 400 + `"invalid x509 certificate"`. +4. Do NOT check expiry, CA chain, or hostname. Lab certs (self-signed, + not-yet-valid, expired) are common in dev and should not be + refused at this layer. + +On success: call `h.Envs.UpdateCertificate(env.UUID, pemString)`. The +stored value is the *original PEM string* (the result of +`pem.EncodeToMemory` of the parsed block — canonicalized, no +surrounding whitespace), not the user's literal base64 input. + +Auth: AdminLevel on the env. Audit log: `h.AuditLog.NewEnvAction(...)` +with action `"upload_cert"`. + +Response on success: 200 `{"message":"certificate updated"}`. + +### Assembled configuration — new `EnvConfigurationHandler` + +`GET /api/v1/environments/{env}/configuration` + +Behavior: + +1. AdminLevel check on the env. +2. Call `h.Envs.RefreshConfiguration(env.UUID)` to ensure the stored + `env.Configuration` reflects the current parts. (This is cheap — + it stitches options/schedule/packs/decorators/atc into one indented + JSON and writes back to the row.) On error, return 500 + `"error assembling configuration"`. +3. Re-fetch the env, return + `{"data": env.Configuration}` (same envelope as the enroll targets, + so the SPA client can reuse the `DataResponse` type). + +Auth: AdminLevel on the env. Audit log: not required — this is a read. + +### Routes (`cmd/api/main.go`) + +Two new lines (per-OS flags ride the existing enroll/{target} GET): + +```go +muxAPI.Handle( + "POST "+_apiPath(apiEnvironmentsPath)+"/{env}/enroll/cert", + handlerAuthCheck(http.HandlerFunc(handlersApi.EnvCertUploadHandler), ...)) +muxAPI.Handle( + "GET "+_apiPath(apiEnvironmentsPath)+"/{env}/configuration", + handlerAuthCheck(http.HandlerFunc(handlersApi.EnvConfigurationHandler), ...)) +``` + +### Tests (`cmd/api/handlers/environments_test.go`) + +Add to the existing table-driven test file: + +**Per-OS flags** — 4 subtests, one per OS. Each: +- Hits the existing enroll handler with the new target. +- Asserts 200 and that the returned `Data` string contains the + platform's expected secret path substring + (`/etc/osquery` for Linux, etc.). + +**Cert upload** — 5 subtests: +| Case | Body | Expected | +|---|---|---| +| valid PEM | base64 of a real test PEM | 200, env.Certificate updated | +| junk base64 | `"!!!"` | 400 | +| valid base64, not PEM | base64 of `"hello"` | 400 | +| valid PEM but wrong type | base64 of a `PRIVATE KEY` block | 400 | +| no auth | omit token | 401 | + +Use `x509.CreateCertificate` with a self-signed template at test setup +to avoid embedding a hardcoded PEM. + +**Configuration** — 1 subtest: +- Pre-seed the env with non-empty options/schedule/packs. +- Hit `GET .../configuration`. +- Assert 200 and that the returned `Data` string is valid JSON + containing all section keys. + +## SPA changes (frontend/) + +### `features/enrollment/EnrollPage.tsx` + +Two new cards added to the main column under existing sections. +Both follow the visual pattern of the existing +`EnrollSecretCard` (heading row + body + action row). + +**CertificateCard**: +- Heading: "Server certificate" +- Body: Monaco editor (`language="plaintext"`, `readOnly=true`, + height ≈ 180px) showing `env.certificate` (empty-state copy if + none configured). +- Actions: `Copy`, `Download` (.crt file named + `osctrl-{env}.crt`), `Upload` (file input → `FileReader.readAsArrayBuffer` + → `btoa` → `uploadEnrollCert(env, b64)` → invalidate + `['env', env]` query). +- Upload errors surface via the existing `Feedback` component below + the actions. + +**FlagsCard**: +- Heading: "Osquery flags" +- Body: Monaco editor (plaintext, readOnly, ≈220px) showing + `env.flags`. +- Actions: `Copy`, then four buttons — `Linux`, `macOS`, `Windows`, + `FreeBSD` — each calling `getEnrollFlagsOS(env, ...)` and + triggering a browser download named `osctrl-{env}.flags`. + +### `features/environments/EnvConfigPage.tsx` + +**Assembled configuration section** (new, between IntervalsCard and +the per-section editors): +- TanStack Query: `['env-configuration', env]`, fetched via + `getEnvironmentConfiguration(env)`. +- Read-only Monaco (`language="json"`, height ≈ 360px). +- Invalidate this query in the `onSuccess` of every save mutation + on the page so the assembled doc refreshes after edits. + +**IntervalsCard slider migration**: +- Replace `` with + ``. +- Live value rendered alongside as `{value} seconds`. +- Three sliders: Configuration, Logging, Query — matching legacy + `conf_range`, `logging_range`, `query_range`. +- Slider styled with the SPA's token system (signal-teal track on + the filled portion). Keyboard a11y: arrow keys = ±1s, PgUp/PgDn = ±60s, + Home/End = clamp to min/max. +- Save button stays. Same mutation, same audit log. +- Reason for `step={1}`: matches legacy, lets operators land on + exact prime-friendly values like 311 if they want. + +**Per-section documentation links** (parity with legacy +`title="Documentation"` icon in each section header): + +Add a small `Docs ↗` link in the heading row of each config section, +opening the upstream osquery docs in a new tab. Reuses the exact URLs +the legacy uses so they stay in sync as upstream evolves: + +| Section | URL | +|---|---| +| Options | https://osquery.readthedocs.io/en/stable/deployment/configuration/#options | +| Schedule | https://osquery.readthedocs.io/en/stable/deployment/configuration/#schedule | +| Packs | https://osquery.readthedocs.io/en/stable/deployment/configuration/#packs | +| ATC | https://osquery.readthedocs.io/en/stable/deployment/configuration/#automatic-table-construction | +| Decorators | https://osquery.readthedocs.io/en/stable/deployment/configuration/#decorator-queries | +| Configuration (assembled) | https://osquery.readthedocs.io/en/stable/deployment/configuration/ | +| Flags (osquery CLI) | https://osquery.readthedocs.io/en/stable/installation/cli-flags/ | + +Implementation: single small `` component in +`features/environments/EnvConfigPage.tsx` (and shared with EnrollPage +for the Flags card via export). Uses `target="_blank" +rel="noopener noreferrer"` and a `lucide-react` `ExternalLink` icon +at 12px. Placed inline next to the section title. + +**Add-scheduled-query inline form** at the bottom of the Schedule +section: +- Fields: `name` (text), `query` (multiline textarea), `interval` + (number, default 3600), `platform` (select: all/linux/darwin/windows/freebsd), + optional `version` (text), optional `description` (text), `snapshot` + (checkbox). +- "Add to schedule" button: + 1. `JSON.parse` the current Monaco draft for schedule. + 2. If parse fails, surface "schedule JSON must be valid first". + 3. Construct entry: + ```js + { query, interval, platform?, version?, description?, snapshot? } + ``` + Omit empty optional fields. + 4. Assign `parsed[name] = entry`. Duplicate names overwrite + (parity with legacy — object semantics). + 5. `JSON.stringify(parsed, null, 2)` and update the draft. + 6. Clear the form. +- User reviews in Monaco, then clicks the existing Save for that + section. No auto-save. + +**Add-option-flag inline form** at the bottom of the Options section: +- Fields: `flag_name` (text), `value` (text). +- "Add to options" button: + 1. `JSON.parse` the current Monaco draft for options. + 2. Parse-failure handling same as above. + 3. `parsed[flag_name.trim()] = value`. Overwrite on dup. + 4. Re-serialize and update draft. + 5. Clear the form. +- Flag name regex: `^[a-zA-Z0-9_]+$` (osquery flag convention). + Validation runs on blur with inline error. + +### `api/enrollment.ts` additions + +```ts +export type FlagsOSTarget = 'flagsLinux' | 'flagsMac' | 'flagsWindows' | 'flagsFreeBSD'; +export function getEnrollFlagsOS(env: string, target: FlagsOSTarget): Promise; +export function uploadEnrollCert(env: string, certificateB64: string): Promise; +``` + +### `api/environments.ts` additions + +```ts +export function getEnvironmentConfiguration(env: string): Promise<{ data: string }>; +``` + +## Permissions and audit + +Cert upload: AdminLevel on the env. Audit log entry. Other reads +(`/configuration`, per-OS flags): AdminLevel on the env (same as +existing enroll-target reads). No audit on reads — matches existing +pattern. + +## Error handling + +Backend: every new handler uses `apiErrorResponse` with the same +shape as siblings — `{"error": "...", "code": "..."}` for failures. +HTTP codes: 400 for bad input, 401 for unauthenticated, 403 for +insufficient perms, 404 for missing env, 500 for DB / file errors. + +Frontend: every new mutation uses the existing `Feedback` component +to render success and error messages. Failed downloads (network / +401) surface via the existing toast system. + +## Verification + +Local Proxmox deployment (192.168.99.118): + +1. `git checkout pr/enroll-config-parity`, push to ubuntu@VM, rebuild + admin + api + frontend containers. +2. Curl smoke: cert upload (valid + each rejection case), per-OS flag + download (each OS), configuration GET. +3. Browser: navigate to `/_app/env/dev/enroll`, exercise cert + download/upload, per-OS flag downloads. +4. Browser: navigate to `/_app/env/dev/config`, exercise sliders + + live readouts, add a scheduled query + save + verify it lands in + Monaco, add an option flag + save + verify, view assembled + configuration after each save and confirm it refreshes. + +## Files modified + +**Backend:** +- `cmd/api/handlers/environments.go` — extend EnvEnrollHandler, + add EnvCertUploadHandler, add EnvConfigurationHandler, add + osFlagPaths constant map. +- `cmd/api/main.go` — register 2 new routes. +- `cmd/api/handlers/environments_test.go` — 10 new subtests. + +**Frontend:** +- `frontend/src/features/enrollment/EnrollPage.tsx` — add + CertificateCard, FlagsCard, DocsLink usage on the Flags card. +- `frontend/src/features/environments/EnvConfigPage.tsx` — add + AssembledConfigSection, swap IntervalsCard inputs to sliders, add + AddQueryForm + AddFlagForm, DocsLink on every section heading. +- `frontend/src/components/atoms/DocsLink.tsx` — new shared atom. +- `frontend/src/api/enrollment.ts` — add 2 functions. +- `frontend/src/api/environments.ts` — add 1 function. + +## Risks + +- **`UpdateCertificate` may have side effects** beyond the DB write + (e.g., bumping a cert version that osquery agents pin against). + Need to read its implementation before merging to confirm it's + safe for online cert rotation. If it isn't, the upload endpoint + must include an explicit warning in the response and/or the SPA + must gate it behind a confirm dialog. +- **`RefreshConfiguration` cost**: assembles + serializes every + call. If the configuration tab gets polled aggressively this is a + per-tab N×assembly cost. Mitigation: SPA only fetches on mount and + on save-success, not on a timer. Document this in code comment. +- **Per-OS flag paths drift** — legacy may evolve installer + conventions. The four constants are duplicated between + `cmd/admin/handlers/templates.go` and the new map. Document the + duplication in a code comment so future changes update both. (No + refactor in this PR — single-responsibility scope.) From d0f4deebe887ee18d4e2ccebe5d94d255a7d862d Mon Sep 17 00:00:00 2001 From: alvarofraguas Date: Tue, 9 Jun 2026 16:56:54 +0200 Subject: [PATCH 02/57] self-rotate token keeps the session alive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user rotates their own API token the new JWT used to invalidate their own session cookie on the very next request (handlerAuthCheck compares the JWT to user.APIToken; the cookie still held the previous JWT). Server now detects self-rotate and re-issues the osctrl_token and osctrl_csrf cookies with the freshly minted token + a new CSRF. The SPA re-primes the in-memory CSRF from the rotated cookie so subsequent mutations don't ship a stale X-CSRF-Token. Other-user rotate is unchanged — the caller's own session is not affected. --- cmd/api/handlers/users_profile.go | 41 +++++++++++++++++++ frontend/src/features/profile/ProfilePage.tsx | 9 +++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/cmd/api/handlers/users_profile.go b/cmd/api/handlers/users_profile.go index bae2ee27..5c9aa083 100644 --- a/cmd/api/handlers/users_profile.go +++ b/cmd/api/handlers/users_profile.go @@ -1,11 +1,14 @@ package handlers import ( + "crypto/rand" + "encoding/hex" "encoding/json" "errors" "fmt" "net/http" "strings" + "time" "github.com/jmpsec/osctrl/pkg/types" "github.com/jmpsec/osctrl/pkg/users" @@ -294,6 +297,44 @@ func (h *HandlersApi) RefreshUserTokenHandler(w http.ResponseWriter, r *http.Req apiErrorResponse(w, "error persisting token", http.StatusInternalServerError, err) return } + // Self-rotate: the JWT in the caller's osctrl_token cookie was just + // invalidated against handlerAuthCheck's APIToken match. Re-issue + // the session cookies with the new token (and a fresh CSRF) so the + // caller stays logged in. For other-user rotate, the caller's own + // session is unaffected — no cookie work needed. + if isSelf { + csrfBytes := make([]byte, 16) + if _, err := rand.Read(csrfBytes); err != nil { + apiErrorResponse(w, "error generating csrf token", http.StatusInternalServerError, err) + return + } + csrfToken := hex.EncodeToString(csrfBytes) + if err := h.Users.UpdateMetadata(strings.Split(r.RemoteAddr, ":")[0], r.UserAgent(), username, csrfToken); err != nil { + apiErrorResponse(w, "error persisting csrf token", http.StatusInternalServerError, err) + return + } + maxAge := int(time.Until(expires).Seconds()) + if maxAge > 0 { + http.SetCookie(w, &http.Cookie{ + Name: "osctrl_token", + Value: token, + Path: "/", + MaxAge: maxAge, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) + http.SetCookie(w, &http.Cookie{ + Name: "osctrl_csrf", + Value: csrfToken, + Path: "/", + MaxAge: maxAge, + HttpOnly: false, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) + } + } h.AuditLog.NewToken(username, strings.Split(r.RemoteAddr, ":")[0]) log.Debug().Msgf("refreshed API token for %s (requested by %s)", username, requester) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, types.TokenResponse{Token: token, Expires: expires}) diff --git a/frontend/src/features/profile/ProfilePage.tsx b/frontend/src/features/profile/ProfilePage.tsx index 21571151..f764b3fd 100644 --- a/frontend/src/features/profile/ProfilePage.tsx +++ b/frontend/src/features/profile/ProfilePage.tsx @@ -8,7 +8,7 @@ import { refreshUserToken, deleteUserToken, } from '$/api/users'; -import { AuthError, ApiError } from '$/api/client'; +import { AuthError, ApiError, primeCsrfFromCookie } from '$/api/client'; import { cn } from '$/lib/cn'; import { formatRelative } from '$/lib/time'; import { toggleTheme, getInitialTheme } from '$/lib/theme'; @@ -114,6 +114,13 @@ export function ProfilePage() { return refreshUserToken(me.username); }, onSuccess: () => { + // Self-rotate: the API just re-issued osctrl_token + osctrl_csrf + // cookies with the freshly minted JWT. Re-prime the in-memory + // CSRF from the new cookie so subsequent X-CSRF-Token headers + // carry the rotated value — otherwise the next mutation + // (e.g. revoke or password change) sends a stale CSRF and the + // server rejects it. + primeCsrfFromCookie(); setTokenErr(null); setTokenMsg('Token rotated. Store it now — it will not be shown again.'); void refetch(); From 4d5f67b60bb6a4478f1911981c2fd4071c36303f Mon Sep 17 00:00:00 2001 From: alvarofraguas Date: Tue, 9 Jun 2026 17:09:27 +0200 Subject: [PATCH 03/57] dashboard: flat fills on stacked 24h activity chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four overlapping linearGradients (info/warning/signal/success at 0.55 → 0.18 opacity) made the queries and carves bands read muddy where they sat under the upper layers. Stacked-area layers represent additive amounts; gradients fight the visual hierarchy because the 'top of layer N' visually blends with 'bottom of layer N+1'. Replace with solid CSS-token fills at fillOpacity=0.65. The top-of-stack outline + gridlines provide all the depth this chart needs. --- .../src/features/dashboard/DashboardPage.tsx | 33 ++++++------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/frontend/src/features/dashboard/DashboardPage.tsx b/frontend/src/features/dashboard/DashboardPage.tsx index bcae0881..deed31f9 100644 --- a/frontend/src/features/dashboard/DashboardPage.tsx +++ b/frontend/src/features/dashboard/DashboardPage.tsx @@ -214,24 +214,11 @@ function TimeSeriesChart({ return ( - - - - - - - - - - - - - - - - - - + {/* Flat fills, not gradients. Stacked layers represent additive + amounts; gradients made overlapping bands read muddy and + inverted the visual hierarchy. Each layer now reads as a + solid color band; the per-series top outline + gridlines + carry depth. */} {/* gridlines */} {[0, 0.25, 0.5, 0.75, 1].map((t) => ( @@ -246,11 +233,11 @@ function TimeSeriesChart({ ))} - {/* Stacked layers, bottom-to-top */} - - - - + {/* Stacked layers, bottom-to-top — flat fills */} + + + + {/* Top-of-stack outline so the chart has a defined edge */} `${i === 0 ? 'M' : 'L'}${(padL + i * stepX).toFixed(1)},${yFor(v).toFixed(1)}`).join(' ')} From 9bfcc5dc93ea6fa0fc427b15183780c93b39ff9b Mon Sep 17 00:00:00 2001 From: alvarofraguas Date: Tue, 9 Jun 2026 17:13:24 +0200 Subject: [PATCH 04/57] dashboard: HeroKpi halo matches the card tone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both Queries (success) and Forensic Carves (info) cards rendered the same signal-teal corner halo because the halo color was hardcoded to --halo-r/g/b regardless of the tone prop. The toneText badge said one thing, the halo glow said another — that's the 'looks strange' you'd notice without being able to name it. Drive the halo from a per-tone rgba map matching the existing --{tone}-r/g/b token components so each card glows in its own color. --- frontend/src/features/dashboard/DashboardPage.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/frontend/src/features/dashboard/DashboardPage.tsx b/frontend/src/features/dashboard/DashboardPage.tsx index deed31f9..19f64800 100644 --- a/frontend/src/features/dashboard/DashboardPage.tsx +++ b/frontend/src/features/dashboard/DashboardPage.tsx @@ -397,6 +397,17 @@ function HeroKpi({ warning: 'bg-[color:var(--warning)]/10 text-[color:var(--warning)] border-[color:var(--warning)]/25', danger: 'bg-[color:var(--danger)]/10 text-[color:var(--danger)] border-[color:var(--danger)]/25', }; + // Drive the corner halo from the card's semantic tone so each KPI + // glows in its own color (queries-success → teal-green, carves-info → + // blue, etc). Previously every HeroKpi had the same signal-teal halo + // regardless of tone, which made warning/info cards "feel off" + // because the badge color and the halo color disagreed. + const toneHalo: Record = { + success: 'rgba(var(--success-r), var(--success-g), var(--success-b), 0.22)', + info: 'rgba(var(--info-r), var(--info-g), var(--info-b), 0.22)', + warning: 'rgba(var(--warning-r), var(--warning-g), var(--warning-b), 0.22)', + danger: 'rgba(var(--danger-r), var(--danger-g), var(--danger-b), 0.22)', + }; return (
{label}
{description}
From e8ba7325719471dc17061dec4b551228ab0baae3 Mon Sep 17 00:00:00 2001 From: alvarofraguas Date: Tue, 9 Jun 2026 17:18:44 +0200 Subject: [PATCH 05/57] apiFetch: re-prime CSRF from cookie if in-memory is null A stale in-memory CSRF (e.g. after a soft navigation that lost the login response body, or a race during token rotation) used to make every subsequent mutation 403 with 'csrf token missing or invalid' until the user logged out and back in. On any mutating request (POST/PUT/PATCH/DELETE) where csrfTokenInMemory is null, fall through to primeCsrfFromCookie() before deciding whether to attach X-CSRF-Token. The cookie is set on every login and on every self-rotate, so as long as the browser holds a fresh osctrl_csrf, the next mutation will carry the header. --- frontend/src/api/client.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index ca7297c2..0aa6df1e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -91,9 +91,20 @@ export async function apiFetch( headers.set('Accept', 'application/json'); } - const csrf = getCsrfToken(); - if (MUTATING_VERBS.has(method) && csrf) { - headers.set('X-CSRF-Token', csrf); + if (MUTATING_VERBS.has(method)) { + // Belt-and-braces: if the in-memory CSRF is null (we never primed + // from the cookie this boot, or a stale clear happened) but the + // browser still holds the osctrl_csrf cookie, re-prime now so + // mutating requests don't ship without the header. Without this, + // a single dropped prime turns every mutation into "csrf token + // missing or invalid" until the user logs out and back in. + if (!getCsrfToken()) { + primeCsrfFromCookie(); + } + const csrf = getCsrfToken(); + if (csrf) { + headers.set('X-CSRF-Token', csrf); + } } const res = await fetch(path, { From 2382201787739f3263ce0e6b968cf896828e0747 Mon Sep 17 00:00:00 2001 From: alvarofraguas Date: Tue, 9 Jun 2026 17:24:34 +0200 Subject: [PATCH 06/57] dashboard: separate config from query in the time-series palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 24h stacked chart used --signal (teal #2bc4be) for queries and --success (mint #4ade80) for config. Both colors live in the green-teal quadrant; at flat 0.65 opacity over the dark surface they read as nearly the same color and the layers blurred into each other. Give config a chart-local violet (#a78bfa) — opposite teal on the wheel, distinct from --info (blue) and --warning (amber). The color override stays inside the TimeSeriesChart component; semantic --success elsewhere is unaffected. --- frontend/src/features/dashboard/DashboardPage.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/frontend/src/features/dashboard/DashboardPage.tsx b/frontend/src/features/dashboard/DashboardPage.tsx index 19f64800..5a5819d3 100644 --- a/frontend/src/features/dashboard/DashboardPage.tsx +++ b/frontend/src/features/dashboard/DashboardPage.tsx @@ -233,16 +233,22 @@ function TimeSeriesChart({ ))} - {/* Stacked layers, bottom-to-top — flat fills */} + {/* Stacked layers, bottom-to-top — flat fills. + Config uses a chart-local violet instead of --success so it + reads distinctly from --signal (queries). Both --signal and + --success sit in the green-teal corner of the wheel and at + flat 0.65 opacity they merged visually. The category color + here is chart-only and does NOT propagate to other places + where "config" might have semantic meaning. */} - + {/* Top-of-stack outline so the chart has a defined edge */} `${i === 0 ? 'M' : 'L'}${(padL + i * stepX).toFixed(1)},${yFor(v).toFixed(1)}`).join(' ')} fill="none" - stroke="var(--success)" + stroke="#a78bfa" strokeWidth="1.5" strokeLinejoin="round" /> @@ -268,7 +274,7 @@ function TimeSeriesChart({ {/* Legend */} - + Config Query From fc1ef7893f1a1be48c56497f2ba302ada8fbeb7b Mon Sep 17 00:00:00 2001 From: alvarofraguas Date: Tue, 9 Jun 2026 18:15:23 +0200 Subject: [PATCH 07/57] query detail: surface targets on API + SPA (parity with legacy) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The query detail page lost the Targets table that legacy admin shows under the SQL — operators couldn't see what scope the query was launched against (platform/uuid/hostname/tag). The API returned 'target' as an empty string because the detail is stored in the separate query_targets table. QueryShowHandler now also calls Queries.GetTargets(name) and includes a 'targets' array on the response. The SPA renders them as compact type:value chips below the SQL block. Best-effort: a targets-fetch error doesn't fail the whole request. --- cmd/api/handlers/queries.go | 26 +++++++++++++- frontend/src/api/types.ts | 11 ++++++ .../src/features/queries/QueryDetailPage.tsx | 34 +++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/cmd/api/handlers/queries.go b/cmd/api/handlers/queries.go index bf52bda2..6ff97680 100644 --- a/cmd/api/handlers/queries.go +++ b/cmd/api/handlers/queries.go @@ -75,10 +75,34 @@ func (h *HandlersApi) QueryShowHandler(w http.ResponseWriter, r *http.Request) { } return } + // Targets — the creation-time scope (platform/uuid/hostname/tag rows + // stored in the query_targets table). The legacy admin shows them + // in a small Type/Value table on the query detail page; surface + // them here so the SPA can render the same. Best-effort: a fetch + // error doesn't fail the request — we still return the query. + type queryTarget struct { + Type string `json:"type"` + Value string `json:"value"` + } + targets := []queryTarget{} + if rows, terr := h.Queries.GetTargets(name); terr == nil { + for _, t := range rows { + targets = append(targets, queryTarget{Type: t.Type, Value: t.Value}) + } + } else { + log.Debug().Err(terr).Msgf("query targets fetch failed for %s", name) + } + resp := struct { + queries.DistributedQuery + Targets []queryTarget `json:"targets"` + }{ + DistributedQuery: query, + Targets: targets, + } // Serialize and serve JSON log.Debug().Msgf("Returned query %s", name) h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) - utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, query) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, resp) } // QueriesRunHandler - POST Handler to run a query diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index c981da67..e4a3d69b 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -150,6 +150,17 @@ export interface DistributedQuery { extra_data: string; expiration: string; target: string; + /** + * Targets the query was launched against — populated by + * GET /queries/{env}/{name}; list endpoints leave it out so it may + * be undefined depending on the call site. + */ + targets?: QueryTargetRow[]; +} + +export interface QueryTargetRow { + type: string; + value: string; } export interface QueriesPagedResponse { diff --git a/frontend/src/features/queries/QueryDetailPage.tsx b/frontend/src/features/queries/QueryDetailPage.tsx index 652253a9..f5d56439 100644 --- a/frontend/src/features/queries/QueryDetailPage.tsx +++ b/frontend/src/features/queries/QueryDetailPage.tsx @@ -169,6 +169,40 @@ export function QueryDetailPage() {
)} + + {/* Targets — creation-time scope (platform / uuid / hostname / + tag rows). Matches the legacy admin's Targets table on the + query detail page so operators can answer "why didn't host + X run this." Empty list rendered as a quiet hint. */} + {query && query.targets !== undefined && ( +
+
+ Targets +
+ {query.targets.length === 0 ? ( +
+ No targets recorded. +
+ ) : ( +
+ {query.targets.map((t, i) => ( + + {t.type}: + {t.value} + + ))} +
+ )} +
+ )} {/* ── Results section header ── */} From d848cf26b3aa167630980d6214d0476e143cf234 Mon Sep 17 00:00:00 2001 From: alvarofraguas Date: Tue, 9 Jun 2026 18:16:20 +0200 Subject: [PATCH 08/57] isAuthenticated: re-prime CSRF from cookie before answering false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TanStack Router's appRoute beforeLoad guard called isAuthenticated() on every navigation. When csrfTokenInMemory transiently went null (token rotation race, soft nav after a code path that called setCsrfToken(null)) the guard threw a redirect to /login even though the osctrl_csrf cookie was still valid. Selecting an env from the EnvSwitcher hit this most often because it forces a route change. Mirror the apiFetch fallback: when memory is empty, prime from the cookie before deciding. Same security posture — we only read from our own origin's cookie. --- frontend/src/api/client.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 0aa6df1e..e4c8cba4 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -14,6 +14,15 @@ export function getCsrfToken(): string | null { } export function isAuthenticated(): boolean { + // Mirror the apiFetch fallback: if memory is empty but the browser + // still holds the osctrl_csrf cookie, prime from it before answering + // false. Without this, every soft navigation through a route with a + // beforeLoad auth guard could bounce a logged-in user to /login the + // moment csrfTokenInMemory is reset (e.g., a brief race during token + // rotation, or any code path that called setCsrfToken(null)). + if (csrfTokenInMemory === null) { + primeCsrfFromCookie(); + } return csrfTokenInMemory !== null; } From 715bf264c35d9e7b2801f180b44f10d468759140 Mon Sep 17 00:00:00 2001 From: alvarofraguas Date: Tue, 9 Jun 2026 18:43:18 +0200 Subject: [PATCH 09/57] query results table parity: Created + linked Node + Status + Refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the legacy admin's missing pieces to the SPA query log view: - Created column (relative time, full timestamp on hover) - Node column shows hostname when known, linked to /nodes/{uuid}; falls back to a UUID prefix when the nodes lookup hasn't landed - Status column maps osquery distributed result codes 0/1/2 to ok/error/other badges - Refresh button that invalidates both the query meta and results queries — mirrors the legacy 'Refresh table' control Nodes lookup uses a 60s-cached listNodes call so result-table renders don't hit the API per row. --- .../src/features/queries/QueryDetailPage.tsx | 192 +++++++++++++----- 1 file changed, 144 insertions(+), 48 deletions(-) diff --git a/frontend/src/features/queries/QueryDetailPage.tsx b/frontend/src/features/queries/QueryDetailPage.tsx index f5d56439..c7dea65a 100644 --- a/frontend/src/features/queries/QueryDetailPage.tsx +++ b/frontend/src/features/queries/QueryDetailPage.tsx @@ -1,6 +1,7 @@ import { useParams, useNavigate, useSearch, Link } from '@tanstack/react-router'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { getQuery, listQueryResults, getQueryResultsCSVUrl } from '$/api/queries'; +import { listNodes } from '$/api/nodes'; import { AuthError } from '$/api/client'; import { formatRelative } from '$/lib/time'; import { cn } from '$/lib/cn'; @@ -19,6 +20,18 @@ function QueryStatusBadge({ q }: { q: { active: boolean; completed: boolean; exp return ; } +// osquery distributed result `status` codes: +// 0 → ok (query ran, returned rows) +// 1 → error (query failed on the agent — usually SQL syntax / table not present) +// 2 → other (osquery has used this for transient state in past versions) +// Anything else gets a neutral "code N" rendering so we don't silently hide it. +function StatusBadge({ code }: { code: number }) { + if (code === 0) return ; + if (code === 1) return ; + if (code === 2) return ; + return ; +} + function Badge({ variant, label, @@ -68,6 +81,8 @@ export function QueryDetailPage() { refetchInterval: 15_000, }); + const qc = useQueryClient(); + // Paginated query results const { data: resultsData, @@ -82,6 +97,22 @@ export function QueryDetailPage() { enabled: !!query, }); + // Nodes lookup map — used to turn the raw uuid on each result row into a + // human-friendly hostname. Cached for 60s so result-page renders don't + // hammer the nodes endpoint; staler-than-staleTime gets a quiet refetch + // on next mount. The nodes query is keyed by env so a small fleet (this + // is the dev scope, not multi-tenant) pulls the whole list once. + const { data: nodesData } = useQuery({ + queryKey: ['query-results-nodes-lookup', env], + queryFn: () => listNodes({ env, pageSize: 500 }), + staleTime: 60_000, + enabled: !!query, + }); + const uuidToHostname = new Map(); + for (const n of nodesData?.items ?? []) { + uuidToHostname.set(n.uuid, n.hostname || n.localname || n.uuid); + } + // Redirect to login on 401 if ((metaError && metaErr instanceof AuthError) || (resultsError && resultsErr instanceof AuthError)) { void navigate({ to: '/login' }); @@ -100,7 +131,13 @@ export function QueryDetailPage() { // `{name,version,arch}` per row). Typing as `unknown` keeps us honest; the // render path below stringifies anything non-primitive instead of letting // React throw error #31 when it sees an object child. - const rows: Array<{ id: number; uuid: string; cols: Record }> = []; + const rows: Array<{ + id: number; + uuid: string; + createdAt: string; + status: number; + cols: Record; + }> = []; const colSet = new Set(); for (const item of items) { @@ -111,7 +148,13 @@ export function QueryDetailPage() { cols = { data: item.data }; } for (const k of Object.keys(cols)) colSet.add(k); - rows.push({ id: item.id, uuid: item.uuid, cols }); + rows.push({ + id: item.id, + uuid: item.uuid, + createdAt: item.created_at, + status: item.status, + cols, + }); } const colHeaders = Array.from(colSet).sort(); @@ -215,21 +258,44 @@ export function QueryDetailPage() { )} - {totalItems > 0 && ( - + {/* Refresh button — mirrors the legacy admin's "Refresh table" control. + The page also polls every 15s via TanStack Query's refetchInterval, + but an explicit button gives operators the same control they had + in legacy when watching a long-running distributed query land. */} + + {totalItems > 0 && ( + + Download CSV + + )} + {/* ── Results table ── */} @@ -238,11 +304,17 @@ export function QueryDetailPage() { {colHeaders.length > 0 && ( + + Created + - Node UUID + Node {colHeaders.map((col) => ( ))} + + Status + )} @@ -261,13 +339,13 @@ export function QueryDetailPage() { {/* Loading skeleton */} {resultsLoading && Array.from({ length: 5 }).map((_, i) => ( - + ))} {/* Error state */} {resultsError && !resultsLoading && ( - + @@ -300,40 +378,58 @@ export function QueryDetailPage() { {/* Data rows */} {!resultsLoading && !resultsError && - rows.map((row) => ( - - - {row.uuid.slice(0, 8)} - - - {colHeaders.map((col) => { - // osquery cells can be nested objects (e.g. uptime's - // `{days, hours, minutes, seconds}`), not just strings — - // the typed cast on JSON.parse lies. Coerce non-primitive - // values to a compact JSON string so React renders them - // safely instead of throwing minified error #31. - const raw = row.cols[col]; - const display = - raw == null - ? '—' - : typeof raw === 'object' - ? JSON.stringify(raw) - : String(raw); - return ( - { + const hostname = uuidToHostname.get(row.uuid); + return ( + + + {formatRelative(row.createdAt)} + + + - {display} - - ); - })} - - ))} + {hostname ?? `${row.uuid.slice(0, 8)}…`} + + + {colHeaders.map((col) => { + // osquery cells can be nested objects (e.g. uptime's + // `{days, hours, minutes, seconds}`), not just strings — + // the typed cast on JSON.parse lies. Coerce non-primitive + // values to a compact JSON string so React renders them + // safely instead of throwing minified error #31. + const raw = row.cols[col]; + const display = + raw == null + ? '—' + : typeof raw === 'object' + ? JSON.stringify(raw) + : String(raw); + return ( + + {display} + + ); + })} + + + + + ); + })} From 5f47118e8c21c5e9c8b1d4221a87965285e93c34 Mon Sep 17 00:00:00 2001 From: alvarofraguas Date: Tue, 9 Jun 2026 18:51:30 +0200 Subject: [PATCH 10/57] dashboard: HeroKpi gradient background replaces halo blob MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The corner halo was an absolute-positioned 32x32 disc that read as a distinct shape — fine, but you noticed it looked image-like rather than feeling like a real surface tint. Swap for a 135deg diagonal linear-gradient from the tone color (at 0.22 alpha) into --bg-1 by 65% so the value text and delta chip still sit on a clean reading surface. Drop the old halo
since the gradient now carries the tone cue. --- .../src/features/dashboard/DashboardPage.tsx | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/frontend/src/features/dashboard/DashboardPage.tsx b/frontend/src/features/dashboard/DashboardPage.tsx index 5a5819d3..315a0c86 100644 --- a/frontend/src/features/dashboard/DashboardPage.tsx +++ b/frontend/src/features/dashboard/DashboardPage.tsx @@ -403,30 +403,32 @@ function HeroKpi({ warning: 'bg-[color:var(--warning)]/10 text-[color:var(--warning)] border-[color:var(--warning)]/25', danger: 'bg-[color:var(--danger)]/10 text-[color:var(--danger)] border-[color:var(--danger)]/25', }; - // Drive the corner halo from the card's semantic tone so each KPI - // glows in its own color (queries-success → teal-green, carves-info → - // blue, etc). Previously every HeroKpi had the same signal-teal halo - // regardless of tone, which made warning/info cards "feel off" - // because the badge color and the halo color disagreed. - const toneHalo: Record = { - success: 'rgba(var(--success-r), var(--success-g), var(--success-b), 0.22)', - info: 'rgba(var(--info-r), var(--info-g), var(--info-b), 0.22)', - warning: 'rgba(var(--warning-r), var(--warning-g), var(--warning-b), 0.22)', - danger: 'rgba(var(--danger-r), var(--danger-g), var(--danger-b), 0.22)', + // Diagonal-sweep gradient background — replaces the previous + // absolute-positioned halo blob. linear-gradient(135deg) pours from + // the top-left in the card's semantic tone, fading into the regular + // --bg-1 surface around 65% so the right half stays a clean reading + // surface for the value + delta chip. Stop at 65% (not 100%) because + // the value text sits in the lower-right quadrant and needs the flat + // surface for contrast. + const toneGradient: Record = { + success: + 'linear-gradient(135deg, rgba(var(--success-r), var(--success-g), var(--success-b), 0.22) 0%, var(--bg-1) 65%)', + info: + 'linear-gradient(135deg, rgba(var(--info-r), var(--info-g), var(--info-b), 0.22) 0%, var(--bg-1) 65%)', + warning: + 'linear-gradient(135deg, rgba(var(--warning-r), var(--warning-g), var(--warning-b), 0.22) 0%, var(--bg-1) 65%)', + danger: + 'linear-gradient(135deg, rgba(var(--danger-r), var(--danger-g), var(--danger-b), 0.22) 0%, var(--bg-1) 65%)', }; return (
-
{label}
{description}
From d0f3c683bd7fd6b9370a739fb61056eab3bdc364 Mon Sep 17 00:00:00 2001 From: alvarofraguas Date: Tue, 9 Jun 2026 20:32:55 +0200 Subject: [PATCH 11/57] dashboard: flip HeroKpi gradient direction so tone sits top-right MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously 135deg poured the tone into the top-left, putting color under the title and description. Flipping to 225deg keeps the value column (left half) clean and tints the upper-right corner instead — closer to the original halo's intent. --- .../src/features/dashboard/DashboardPage.tsx | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/frontend/src/features/dashboard/DashboardPage.tsx b/frontend/src/features/dashboard/DashboardPage.tsx index 315a0c86..2b28f5df 100644 --- a/frontend/src/features/dashboard/DashboardPage.tsx +++ b/frontend/src/features/dashboard/DashboardPage.tsx @@ -404,21 +404,19 @@ function HeroKpi({ danger: 'bg-[color:var(--danger)]/10 text-[color:var(--danger)] border-[color:var(--danger)]/25', }; // Diagonal-sweep gradient background — replaces the previous - // absolute-positioned halo blob. linear-gradient(135deg) pours from - // the top-left in the card's semantic tone, fading into the regular - // --bg-1 surface around 65% so the right half stays a clean reading - // surface for the value + delta chip. Stop at 65% (not 100%) because - // the value text sits in the lower-right quadrant and needs the flat - // surface for contrast. + // absolute-positioned halo blob. linear-gradient(225deg) pours from + // the top-right in the card's semantic tone, fading into the regular + // --bg-1 surface around 65% so the left half (label + description + + // value text) sits on a clean reading surface for contrast. const toneGradient: Record = { success: - 'linear-gradient(135deg, rgba(var(--success-r), var(--success-g), var(--success-b), 0.22) 0%, var(--bg-1) 65%)', + 'linear-gradient(225deg, rgba(var(--success-r), var(--success-g), var(--success-b), 0.22) 0%, var(--bg-1) 65%)', info: - 'linear-gradient(135deg, rgba(var(--info-r), var(--info-g), var(--info-b), 0.22) 0%, var(--bg-1) 65%)', + 'linear-gradient(225deg, rgba(var(--info-r), var(--info-g), var(--info-b), 0.22) 0%, var(--bg-1) 65%)', warning: - 'linear-gradient(135deg, rgba(var(--warning-r), var(--warning-g), var(--warning-b), 0.22) 0%, var(--bg-1) 65%)', + 'linear-gradient(225deg, rgba(var(--warning-r), var(--warning-g), var(--warning-b), 0.22) 0%, var(--bg-1) 65%)', danger: - 'linear-gradient(135deg, rgba(var(--danger-r), var(--danger-g), var(--danger-b), 0.22) 0%, var(--bg-1) 65%)', + 'linear-gradient(225deg, rgba(var(--danger-r), var(--danger-g), var(--danger-b), 0.22) 0%, var(--bg-1) 65%)', }; return (
Date: Tue, 9 Jun 2026 21:09:48 +0200 Subject: [PATCH 12/57] login: Trace Map background + theme toggle Three-layer Trace Map backdrop on the login screen: - Tiled circuit-board SVG (mask-image, theme-driven color) that drifts diagonally over 25s - Soft teal spotlight disc that translates around the viewport on a 12s loop with mix-blend-mode:screen so it brightens the circuit underneath - Static brand halo radial behind the card Both animations respect prefers-reduced-motion. Adds a sun/moon theme toggle button (fixed top-right, pre-auth) so operators landing on a system in the wrong theme can flip without logging in. Persists choice via the existing localStorage theme helper. The circuit.svg asset is the legacy admin's login circuit pattern (from cmd/admin/static/img/), now color-tokenised via CSS mask so dark and light themes share the same file. --- frontend/public/img/circuit.svg | 1 + frontend/public/img/osctrl-mark.svg | 39 ++++++++ frontend/src/features/login/LoginPage.tsx | 77 +++++++++++++-- .../src/features/login/login-trace-map.css | 97 +++++++++++++++++++ 4 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 frontend/public/img/circuit.svg create mode 100644 frontend/public/img/osctrl-mark.svg create mode 100644 frontend/src/features/login/login-trace-map.css diff --git a/frontend/public/img/circuit.svg b/frontend/public/img/circuit.svg new file mode 100644 index 00000000..e235dda4 --- /dev/null +++ b/frontend/public/img/circuit.svg @@ -0,0 +1 @@ + diff --git a/frontend/public/img/osctrl-mark.svg b/frontend/public/img/osctrl-mark.svg new file mode 100644 index 00000000..2f377f79 --- /dev/null +++ b/frontend/public/img/osctrl-mark.svg @@ -0,0 +1,39 @@ + + + + +Created by potrace 1.16, written by Peter Selinger 2001-2019 + + + + + + + + diff --git a/frontend/src/features/login/LoginPage.tsx b/frontend/src/features/login/LoginPage.tsx index a6e929d6..1722b2c1 100644 --- a/frontend/src/features/login/LoginPage.tsx +++ b/frontend/src/features/login/LoginPage.tsx @@ -10,6 +10,9 @@ import { Button } from '$/components/atoms/Button'; import { Input } from '$/components/atoms/Input'; import { Label } from '$/components/atoms/Label'; import { login, listAuthMethods } from '$/api/client'; +import { toggleTheme, getInitialTheme } from '$/lib/theme'; +import type { Theme } from '$/lib/design-tokens'; +import './login-trace-map.css'; const loginSchema = z.object({ username: z.string().min(1, 'Username is required'), @@ -20,8 +23,13 @@ type LoginFormValues = z.infer; export function LoginPage() { const [serverError, setServerError] = useState(null); + const [theme, setTheme] = useState(() => getInitialTheme()); const router = useRouter(); + function onToggleTheme() { + setTheme(toggleTheme()); + } + const { data: authMethods } = useQuery({ queryKey: ['auth-methods'], queryFn: () => listAuthMethods(), @@ -55,15 +63,72 @@ export function LoginPage() { return (
+ {/* ── Trace Map background — three layered elements ── + 1. Drifting circuit pattern (var(--circuit-url)) — repeats + diagonally over 25s, picking up the brand teal at low alpha. + 2. Scanning spotlight — a bright teal disc that translates + across the viewport on a 12s loop, brightening the + traces underneath as it passes. + 3. Static brand halo — radial gradient anchored behind the + card so the eye stays on the form. + All three layers respect prefers-reduced-motion (see + login-trace-map.css below — animations get killed). + z-index: 0 = circuit + spotlight, 1 = halo, 5 = card. */} +
+
+
+ + {/* Theme toggle — fixed top-right; available pre-auth so operators + who land on a system in the wrong theme can flip without + logging in. Hugs the same visual language as the rest of the + SPA (border + bg-2 hover + teal focus ring). */} + +
Date: Tue, 9 Jun 2026 21:13:08 +0200 Subject: [PATCH 13/57] =?UTF-8?q?login:=20fix=20Trace=20Map=20visibility?= =?UTF-8?q?=20=E2=80=94=20strip=20baked-in=20fill-opacity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The circuit SVG carried fill-opacity=0.06 from the original asset. When used as a CSS mask, that alpha multiplied with the background color's alpha, producing a near-invisible pattern (~0.6% effective opacity instead of the preview's ~6%). Strip the fill-opacity so the SVG renders as a clean solid mask; move the actual opacity control into background-color alpha values that match the preview HTML's perceived density. --- frontend/public/img/circuit.svg | 2 +- frontend/src/features/login/login-trace-map.css | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/frontend/public/img/circuit.svg b/frontend/public/img/circuit.svg index e235dda4..02217542 100644 --- a/frontend/public/img/circuit.svg +++ b/frontend/public/img/circuit.svg @@ -1 +1 @@ - + diff --git a/frontend/src/features/login/login-trace-map.css b/frontend/src/features/login/login-trace-map.css index 25d097f6..58d4295a 100644 --- a/frontend/src/features/login/login-trace-map.css +++ b/frontend/src/features/login/login-trace-map.css @@ -24,7 +24,13 @@ * the actual painting; .login-trace-circuit handles the drift * animation on top of it. */ .login-trace-circuit { - background-color: rgba(var(--halo-r), var(--halo-g), var(--halo-b), 0.5); + /* The mask SVG is now a solid black silhouette of the circuit. We + * paint background-color at the alpha we actually want to see, the + * mask cuts away the negative space, and the result is a teal + * circuit pattern at exactly the chosen opacity. Earlier the SVG + * carried fill-opacity=0.06 which compounded with the mask and + * produced a far fainter result than the preview HTML. */ + background-color: rgba(var(--halo-r), var(--halo-g), var(--halo-b), 0.14); -webkit-mask-image: url(/img/circuit.svg); mask-image: url(/img/circuit.svg); -webkit-mask-repeat: repeat; @@ -34,7 +40,7 @@ animation: login-trace-drift 25s linear infinite; } [data-theme='light'] .login-trace-circuit { - background-color: rgba(var(--halo-r), var(--halo-g), var(--halo-b), 0.85); + background-color: rgba(var(--halo-r), var(--halo-g), var(--halo-b), 0.32); } .login-trace-spotlight { From 0719ba2017394b3330b1e05b001e5449a23dbb92 Mon Sep 17 00:00:00 2001 From: alvarofraguas Date: Tue, 9 Jun 2026 21:14:41 +0200 Subject: [PATCH 14/57] login: use original osctrl PNG logo + match favicon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch the login screen logo from the React SVG to the original PNG from cmd/admin/static/img/logo.png — the artwork legacy operators already recognise (tower, broadcast arcs, trapezoidal cabin with light-blue windows, mast and base). Inverted via the 'invert' utility in dark mode so the near-black outline reads as light against the dark card; left untouched in light mode where it pops naturally. Replace the SPA's favicon.svg pointer in index.html with favicon.png sourced from the same original artwork. --- frontend/index.html | 2 +- frontend/public/favicon.png | Bin 0 -> 29379 bytes frontend/public/img/osctrl-logo.png | Bin 0 -> 31195 bytes frontend/src/features/login/LoginPage.tsx | 17 +++++++++++++++-- 4 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 frontend/public/favicon.png create mode 100644 frontend/public/img/osctrl-logo.png diff --git a/frontend/index.html b/frontend/index.html index 0d913ecb..9169c797 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - + osctrl diff --git a/frontend/public/favicon.png b/frontend/public/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..fc69cc17722f9186676e083faf139d7d755b8386 GIT binary patch literal 29379 zcmeFYbyQs2(l6S$yL;mfjk~+MOR&b>Ex5Y{cXxLQ9yDlx1P>Ad1b0ceWbgf*v(LNd zefNE1y!+o_jHcI|^{ZditXWlatual# z*cOP)_RoBHy7^^u)^Pu@a{TQHFFynFCE{SLck5W7p`w5%?f$)RK)>Up|Cz=XQD6Uk zx9MD}&{prt(w+36NJG#4Sd{N`=FC%I{;l$tIJlv$J}7^1biF{zg#y;pZ4(UqjFJU0V<1 zH6O*@)!v)_^BJ9|hXeDI8NHj_pVReY)AHaevtPw!;a1 z?}`KKa$vORzH;}Ai!8ug1evJOq^F5hRGusE0Ml@=crmplD}=PUNP5OVkg zRS*h9Sd*FJy-G9=*%5uIwYogtJ53D*p-5u4NxlrGxFhN`UG<5wG=2R;u1~reAKO0Z z8=QRX0ECsL8?e>2FZ&)8}R{oLphf-KLtb)SZ@z9F1iPlJNU7t&LZbv2Ve#CMX7R&oN20%S0P%I@p<~GWK@u zbjPj%(#2Q{x6%3eqD!~9JL{?q+(S&+hK^$?PP0h>=O3o zAJns_kgX&Ev^V1|8}iASz(p;U<}Yk5x%FRkCHUFE%S=wgs?WA>*}~EpQ@7(iKCeQr zzP}!2xVVanB}z=zIdADo&zfLx`=lYhxrw{zb_y3}m{aEExeE)>bu`EGyCntFUh?)Q zM20IocjJloc4ECFva^&;NG7Cd*~3mIMn-Q~RIhAFAcrHQOhWR@AeKP&l(oHz+HP{+W~XFM8w3%BzvrRo4n1!ugl3YSNo-y~5;%OHT1GOU0E;xy z3N{-yS{d1CC!QvpB-rRUmF11)`Uvj8=g>yhFY(mhqYsW78O3+KUhg%f2=|Mv&qa2m z*T;;V-d$`hYY)-lxH|88N==RZ)#!_=vxRU52isTIgFm)t0#m_dsd+L&&=q4%0fnj5 zQ^u$K5p1RcEY^vnT_q;$A6K-v)>5YfQe}A2boukWwWu@Mj3n^sB|vJb>}hC<#@{@? zB!yDg-!C7ZSN;A4+lw-ld)LEzYL6f&>iF~yX^=bjq z(C@ER2cn#9yU%(%P(8d16X-GRwl(I5N3LMsZ&zcu5d7R;7BYTw!R{ILZrkGf=;3@2 z-T)hb?#&T6y2bx~PmT|tg|Z>>6!NRIC=GL@W9D$1Rj-)<%g(Z1~|VD@CroC zN7DeaXm_Z?T;fxgwg{nElG-cFz=b0qw(mYMU)-?1pmxuMijS2fxGMlky+bOS3q8`@ zm2C*H;d>LUt3$0rDpv7Gk-ZM7U|cJp3I|2EqN!5YqBojn)F2vBCGB>4lJSB2X@}=^ z*`hgLAx1|&IgjbO9o^7XY35I07n3yR%Ym8=TYfB@f2t?84W<<0j@F8$)(7t|)?%oi zes7l+Lh#ZRl|B9>I^azwI;eNSg83ZLzr8bW*O!JqF!WCD1$AXgT3C2|Cy#4H!XSZkU_!0}iNq%r`v%URDMc&FAJ1!d zEC9gwIqIDgcQeSfI8Ax8*+U;q6~CEQEyR>S?njAMx_|oT(M%4v1Ee^Jr&MCsBt32d z^iVOV1uEJEluu2cCrV6OZPyhqpnlMZX5pbmNBzjwyHLdu%et@SfcDyj{V@#Q*?fp= zst%?Bgp0#EE3r}0()L0rw4U6w%0MHIMDbcop<oZJ;U{0f4kj$NNYazo z#li;?Yh0NF>0#R@2qbA`UDKKE?I(ug^sPdd(ZfM~IpOr_OF z$<;Q(TIvZR`K`EiQg2+r_-hb0!wxi7>La@$=tglomP&C={s+?N9G;>mXLos2CM;(A z^@l5p%pW-6l1(q$tkMsQh=;h)DTWUqo6FnJ3}jkC6`m-Nvou1j+V2YA1!Q^7$A6#^ zQwsG;souiV6qJR>l#G-}rHTM#&c0cJ2b|~n3XzO!klq+glZ8P0g{#Pa!wLf6&P;?g z0`0yy8)|hI4oG02uA1^YV&c+VqJEy6{dN!@CzJd`w!usqO4df+$hrzK9f-te9n7;< z&pDC(%Wwqe+b)VM1$?5z-Fr!yDx`Nh7^bxWP#5r#ACi2s?1N>n5!b;eBk7N_dWn=*lH+|o`rwAsaI2G7RtwJBu1gdLM}8F(n#lAfa&*?GK# z03)afuz@@A)Upr}`wm+6=+!zD^yO@uToj98eHR8$VRoj=LES?d3p9fzShCwFvX<*q zD3Pix2@p*T5|S8*x}e^STvf-Q9RhLdof&AJi0a+{Mv*H6%yD`0RI?(rGw^Ca2$-PRU;)6WO# ztBVjQ2VKkWqmr(bmspv{_|AMFdraVtli9LT6aE78DRTH?bouTc-Jk)l=(s5)wwe$@ zLH81tjkrQ#%I*0tbZ40yB4`gR(HK1U1r+`7_+QH|maNR6uW+Tjihu#4DlD|jyN-0H zl(2Nag78J*Kc<;Oz%C55=-Gpj$#H;Rqq(pZR^o~{jQ9j3PNgQL&l%dkZpp<|SDJ8? zU_pEz?raVN()qK(JWs8+N<@JZ8g#?S-?VO(0QW+dZIje1@;gV>9;I$ay{2eUU+>bS>} z)H;8cZWoo0hz~Z+B?2oQT8un)J>R-=Qj{dXS2~H|<*-8P+r@B^4p3f)ut%GA8dIt{ ztn7iJ3vSYL-%@6a;4P71hym`B#>BkVaF?K`eCC;;M3*KGu9lZ-1l^^c zY|>{J9J#1-4L1rtvhkPs?Lb6LI;iXO&ag=8q}`n|6!I^t4Sz3^a2_aImcw66|`)cQh!dN2&6Q-kM);HGx31 z&Ps_oY6B0baRGc8<=`JfiG}2XC0D)AAW)PDWEWjQWyt$^mLx{BF1hdCk?E&%We5Q> zU$R9hjFAzApxP%U!jp;5UpU8kx8WHhkVh$iOq-YqA&$|d#58Is)CX(r;niuE zy*RD?6TqX30&(f#{eD&mkh0n|<`-mgX$N5t;knzC+mJ4fAg)DI9U^``le0T|JHT~% z4!VptTQzKRq^88l3!)Bt_e&=ZC1UcYg!kw}UjD7ypGM%3kbflc!I#eGMD41d%$u9t z?@%$qa8g4ksE5Rx)Z{t(m{H!s%>||*uh!m#H`b6f5Ljk)jHqd{CeZ!bZf`Gvow3%W zM82AxqWa8KFU3zS?@0YX@53G4gJt$`gB3E^npW|SzgJ6oKQ~P?f!3=sGXe+Fwo&2> z4-XZkw|g8VH!iP$KTVD@4lt82thWLsT*F63i?r9L?8~4%EIgNpOgNsTCatNGP2sj( z(A_$AQulKuzN7%!UQ zBSB|~MhaTi#pzvLm4*DKgYhY}DU37p9e%isjQfkgEUCaI;DPqC8i2wos)AKc@(t!K zoO1r%5SUi%4Td^Z$a^y_lwXZIB2bKPaw*OdO~w|8l#E7?gJ!wq?9plrPZJBe=4!l|A-UTc&>RKXRmp ze-n*OwH2{o{Z)1I;ym{4cDew!? zw28v{{NBlZqH}JgRs`C>sdI_f9}7g~RPTLhxo(lJDPD7&m=1CvA=ZB)#pQ{_KWTs* zJ33w-6u3-whbT@}csOy_LbzvObA_%EM&8ctIGS0-wPP4eQ5uCZ4Ps+)cyIm0EXQ+G zBowhWA3{cPjlQKi7O`C~ZA8Q%^@C>YXrm~1*?r^s$2WxmRf}Ugh(W=k+L&;s)CcRhhOIB+u)AS(cKDL zEM%3s?7dLtO&VncKQoLXTVzl0ykJ5pzpEE;5`kn;3Tb4lh#}Z})?FA%*k&coiSL2y z_Yv-hhS^Z54pS_TFQncO8(N10Dnux{Dw3C#UlK{-Xsbc z-<;@Yy5-dDZUE`G@h2+hI2H@&DDG4gY}#TjfC<&v0Pqp&-(z&lOn%n0!z zes2OjUqBikWf6ylq#H0~=_H6>{1cN89*1n~I(82|Qadq@Y)$n!geDu0gM!_D`Vo^8 z0a08qUJf?gz!|kVvO*#kjvk!uAVneRh z_i9%YTC2q@hSq0dDMg;rLR-T^oB$sVki2-#P8Tmf7{lI29x1UASTSW{JReE@CfakSumjM9-|7!0{n~^+|gu?df_L9uAo{nr>uoNw&f)&b}Q}a z@1(>qA}Z9PDoObe^(_S@;!^E$+|!%tUfIH;x4jspB^amDVhKl$K&?WW$XaQ)<9iuq zkAq9kM9i_lwxk52VB#}MGn);bISTv63bhYVwg3^st`MbE)) z=8&}RRs`hGBJkR926>pCS8pUNfBgnX>#+kkL=v1zYy!3;#17dv?X~JczoF}>34lOW z3-US;UWH=#akm;tN~>}~PWE!^n*6mO>j4dH)hFJUpzoZ!JUpZoV$Y%!VUn zY|Q2A`cV(6@xD!CJ_|7TS-B(xrv~G4b2W?JdmGo4Oq{{u#L;@#tg|N>dc?Tytwvj> z7A;K>6mLwh0khV64|n|uik1mAr01NOD_dGFLMgJ6V_1kxF>IB=fo66V7Ks8N-BD8r z(1{skqPN|dn9=U|W{sBZGv3|~&yTkFH5W<`rP_f@ad@TbEC_{{Vy$uYF3oqMv}p9Z z&A&)5i5Y?v#GZnFl3<^BK7}E#k%nBDoNqHuMuXU9;4i{kXmG|*orgH!N}PO?ZXnf` zYAY(kJ6u|eelq?bMoDN;Dt@qHg?mnUuGA)%`V2iyVU0}1C0p>Otys0-Q7QvHXxY6p zUxRgOE#uZZXJ+&uT2|}c3rEOGMX$>>lGprjy6j7=z zMj%oxp$HQ${vE*}L9++qgdb&y&5594CcRi91ng({J>z(eq;5SI_xzKkI$5SvZPChN zWth)2r#qZfEqc)BFo9#=svQy>mXZee2i2EyWEDTFnafMLYtw&ogaRAaI$Pb#dMOdT z58(&5hHX)X(jeN955i*6ceMH;@G%%DEn~m~i_1NmCDiFX@?zu4Sx1JUQf!s@6J*0H zG-X@mcRCp-%izwX-m=GaTUT&epLsP?tj5zirGYi+Co8QpAur;>%f#QJ8pXfE#FT{{ ztjPXQ?g7L?fKQM7MObJYhSw)juX>d%3|8ImX!z>91 zZIdd|M~Hif%gt7gQ_qmOnif>Ckq~6o(#H1PU~aNC`<&O01g8Z4zAJbTynjQKfRz z-oueB<)GzP-ZzEuJz^HXDKA@$j)v;gOHVsZkPP=0tk?-^^K?fS6spoa&k|h=T8F%j z=%{sgs0<4UM<;r4K1Om;DJAD~*jLU8JnRX9a5X7M&GZnxof0Hc5p#5Ee)J;I2wuJ5 zipa?5%H;9G&W4nV?);5$x*>j9VqmnVngQlMMTeMPOLy5K`fgfX7P}l#(=PHzodRnd z^fkJF5M2wgr&vfC4ZMi1#B#ng`N=>QZ7>3TWT3|D4XG%y@L6kvlSHKMNIq)~H2r6ij9($WWbj2b zTL*1f&EW(-9eu@F3hN3t&kW`W(PT80_&SnlUc-eIp?zsT&Qhaf2%pVM80bzVwsBZ^ z`Gt=CuCTYN_&ds^px#^E=>CnB_+B$1-NW^GZS&lz0@ z^$8e|ebM-V>%AvXFwi}Mlu9O^#YtN{S5Y zs?gr)4#>F@)oKRaHtaA3@U*#QjC7QNO89{g&eH<^AZM#N<#;QJfI{y6l4@YKQcwBI zdruv`fF486#4-A^rORs(CM`WeZwe#zNVRGPjA z;K4CTPorYwH6n=O$Z0H6;5XFjt2wcwT8r7`{h#+=O>{_pp}M}EADqJZjh#8i2;K2m?#WI@WxX^wnqT)9?B_i)d}Si zP7U_rqb;CUXy@F@19|HTiI*9djOr_c5NPL@om7zf$Ph7K+T(nMXFK-hz`)LgmkD(G zvRK{Wv16T%gCg8X*I8}6O^r0A1IdxK9)WdP0S_apKEF`ShH2_dqZF#0G~)w-&Fg1`g@VGHq%pTs`s28wHTYm>>W6L45UMr~qQeQE`( zEtLE48SOA!m`JWCSK{7gC2-QvEr9XtE($`fs&)NA|3zqNrs?bTR3t;i^_!YvN;~x97Fp5>gu2FM=A-uY~)!cwB5qm#QVl zqP+yq`IxM3)~au$&V#+m1AeNvjS;0G+pL?p*pJ5A7|iMSxI9%A!7f=`_$G=Q%YOIf zOU=5dXJ3fEp1jgo35f3Lug&2263wgHvA%F>z zQzfl8ly5^QquQ3C6hme7zJqKYQPWwrsli|1OAw!nig(b?glACWGN(arys)uAtai@5 zZS=WQY7;(5EdZW}|LcCg-MhqU9Fkw_aM6K-g;CX`KnVYZ4;82fl{Hi(4l0Mg%A&oG zRc%7QK~g4KG{mmwmfzTVD~SntAMHgy9rp7L_b$ov>c{Z22(*2}a+lJvtMlW%Wqa0((nF42zWDkuYv;maY zBEeHI-$)@`?nd4ba()VtJks$|HHheR`jGSR4sHW9Ry|JDNdun;Z?B`N4XksZ{KUFj zIbL|>{?>^Lm@h9_P0L&x-?OSHE*nitJB>P=?B#d!f?2QK>z)eCQ?9zN{|U~^R-K*B zrTTq|^f z6v6N?4wi4CG9^fus1<4po}^#iW5^|5VhI2}Xgn;dvxg+*?)5yJPDnOTAO+mr>FGwI z1k@ilT9P1=bZ^OpwLW_B@X06e-$bB03Xl7DCvtJfJGgnqTN5Xnuk+VI`Z_S#+X#J`>+V5)Rk zm~ZfeOPEZ5!Jw`+fB0hgT?pbsn3vk0e_6D@km@&gLp#^O|;q|hreOBKzFLk~s zDZ?zM)GTj=1UXeYwN_{Jrpg!eDdi#~W&X#VLqP4VE4~y5lQ6`Goh1S@SNr>$VE{MnN+zta*-OI;l+tu@51c{1cIWnUFb_cc5 zQDs@UJN38gKVfquZ}UX$JTmQe8&tj-TP7{+gj@Nf3|nDOP^7hoTxl>KmYB>6jY^u7 z0P!KQowpI0a`}+Cl-?b8DEMe;vyA`pgOuteQE3@+ew>ckhq8_4GAO5EWcd0-FGe|; zx^?%UkG{l&*>Fwb zxhlL3$Vvna3R@!(+4$x3%zF1#qEwU6zDGT#H4?}#8Bx#G$o6J`2IA?_USXRr`E!|L zo|Kw+?0k4Y6*0t*b(!Jpc(kKrt;uo)UTBffh)rgF8kqaI!1kT54Xz|n3F73StEzP9 z8iw2sI7f*Tknf0Txch{kF*$gg-$VKh4>0Jt1f?~}jOTumRA1E2=Es4gmNzt(dP-YY z`H-u{%^zPJpL8%*^EIE+swS`>=Hb@kR`TNEXGB9Nt#7Q)rdy-gl9kE%O#Q6keAO0h zyH5AU`>hspN6BCFO_NbE=lmnP3ZUg0qkxlVZ#F&ysYy6y&W{yvmxD1yljh+vBImtM zV>tDaAa{vkH4b$jLL3zlCmnZ95o37*raS z$4u&P$e7XaKB*h@HTwd0!&1k7m2e!l6s)C`m0va>}`k zRz5wUy*}_!-NP}g_#=)7&n9I;!7n)TmXNP$dILWxMDN{2Nc z4JwT19}k}iH3@W@ivKZRKjja9hb1Xq%LdAY1VSUz3szsW+@ zx^el;h2V-9`Au!AGxi8HYjpCh<0_(y$`9>JSOtbY@>t1tS+;#ra@F_X?W3`!!NSm->c+ZQ1&PKwf0tL8Z>{B)zCp7gv@wjBTCe$uRw(ppoKA2 zp9_S1c7N7kJ1t0T<|yE~|~>igvRL@|TNhFgwq22r0gP?ZdFwS4#^;ohYY=&J4sfz4mgr>Zd%wWGZT z=f*bscWXziFEpfqGe6^MhZ9z0_Sq)O)&tV~|^j0ru#(^N)&&KaJJTj?!%!?%sLVl3tme8~@z_+R)eH`8;aE%~2L3vSdw8{OlH9Ij!?#Ok1yY^AgmV=?R z?F@)iH zA*m94N68HO;V^4=Oi);(t*Ai(sKgPLNwHr4ajrtE%(%%LLyFbeB6k37@l#A?q8fnh zTfIacRwVBdL_-lIK77s304{^-F@0Ju1k610qY!2Xve~|bi8~J8;)G#C^u;H@o6JYC z@TB*)v4Xh&UEP~3Idmx+SGBh2|`R)c#3T`g}Ttvm8s5=?2}LFkqw6upzg~R)IBni zVj0zH2G}ohR{{9%gjXoWyo!Jn!C4#Eb>h%%o=J&4b5L!*S@~ z`Tkjnx-^`Ra7%_RjGGy>bI+)V^%KEO3IE@Y-T^w z)-cTP$Z3r=l3k20lv$sqH{Gt@bQxG^w&JX7Zje?_E0a`K^FtA}4l*%dwY4>Bx?oCH zc~BZ80ZbOh_<|ZV@<`e2KON0;M+^5u{fS zp>b&aNtTpR5QY%`tOx+0joQ4vHms|p$ZziCz+!6QWCmvOc5r@utr!3h6!CU8HMax1 z1I@rzHjY9RXFdHCKpP7o3LP#bkdm_m*xE+c*A=YctE_46YiG`9K_McHDCo`qO5gx? zHwAh-*gLxMdkazg!OQ=8{d<~~0{92S-A;%?S4kBp;p7SiaXOobQM`_XD6HMxo%vZ=y}Z0wyf|2#T&-By`1tr(LF}yT?98tSW;Y*4cT;a> zM>opf6n}C^g5AtrZJgb0oE(9_IZe%+JlusSC|>2jzsO&u=2G6TlYbfbJ^q8<&E0}k z?sdTVx}H}6D?13p%M4;?X6Ix5yZ)FOV(GX{;>Ra z9T_<#)qm*x4x^QggYzF6ztMk3TA2R>=j`EX{|CmxoE2;jc6fEf?UkABU+}-*GXICh zzpUrCJ@a2!V@=H3Id;B&kCn-en+h2YQCvzJM{y#3kW<2a5Gj0&G zg(VLcGbf0{lGzk&Y01pR$!*GI$qwe@;N<)pm7JrSyQ!l&_&3!nIg8CJ4=*2xlg*S5 z%xr3IVb08HVaCO5#%spM%wcM3VadT}4(2j7{~LvhtIcaAnA-o{tKU=>uT)@mHV_9J zCkHc^8Q9|08%uL$K6W;CW^OQ;jg6awjhDxi?+>cq`@k=zDknt2&I0=9jH zgAj$1sX0(Z*VV5H~Si3mp?~;wk>;`KSBir{?RJUmhN7ru3#~%*Ajf)=hp)KW1oR^f95FtzdGY(4gQ^gAT~Z`5GON;LleZs&(6)y z^*ZF?f6c8ww;U+Q`nwhX*OCQ)H!CG2{=WxB@OLxgm;0Tw8XnHh_BLSGe+|}uCC~p0 z?r-{kCF=i4{qL|pwI!UKeO}Am+FixV@xL|yZ-9R?DB766mX_0h=lb6ve}?66C)TUa zf6l$0gs%rH>pxD`zf$FQHT_@w`zr;y<6w!H%yte_pRo-XvIezOPU1ux1J}l7BqE10E!L0$;BXoMrXg z003O--yaA$b*l5%NjP^oB`LUFXeeYt{t-m<1^@sEkdqYC^jyxB);BjzP@3i%X zl@p%aE<+a?9>HN;Ul&?Di0SKi^jLJB;_gj2h zdUW^Whw~g)5Q6X~%t18}$N-bMVA*EK4D^u_3{`9xfGQUX039`)Fu`0G_Nz(bSC0H$ zbold|*~#K4XApG$!BN3JqLhU?{T%1g`4>@|4QJ-v2gr!#tccliQmZfA7J6d5#Nt5* zZxboQ3RM{>bCkb|V~N94=|Ru|N#mDA@W2FuW90booc_4m4-tOMNE8%b(RmY~0C1x9 zOi^SJ!$np;34Y$2>&dCc{ZqaGT9j>82!A-h*3`$5VhsQMFmt`q6a@eL@M5|V^Z9_h z1PG8KJV81AktaZeSIGC<7~s8#)2Xb>W$-ojITMsK{s7rEV*VHOF&EQJTx>0&vlR+P z2E;OU`?U{vy54|5leOEgZ~7h|*;SCPYjjXq7}LZ1u%(vXj5>opH>SNhrl#%N4Y}Bi z9vk-L$}{RpAOS@Xm4dw3Jm6-i^Rne(lm^{&@vh{se#>OB)g+q+vk9LD5hjd=d2}(^ zneKCA@8<*!y?kHvVbB0=r|_XsX5hs-pPx!%ewX>{8as{v{6fAoC%iMB8A<%!_G`iA z$0&ei61e)~TYCn*h{LIP5HgApkI5Hq{Or@m7pq13&M;2@bk_9l_pI4Pre9FqhXGT~ zQMX@V*c7q4O}Qb*+r`wdDZZ+%9K=cwy2<~_Dy`Fy46qW?15Hh}&5NaW~{Epq$k(s>I5yfyts)^}7AYG1H zY>|_AEV#D1kRDMiA8un<{Zn0z-}QI3=Z{;zNKpHq#BjpI?B81ny}zm3I(q1(77KC^ zh#D(KnNp@*(O^PyHo}{b>}5P&DBRV=L{=JhF~U{=t73<{v#YJRG%;xkD^QDRV86z? z*mlkX3o6S-Pl7Z55JG7Y zor>Vl0uroNeU1bPpAx1?0U?&cmZUuBVkp}jA}P3h*)duC*|Tc_W$~^@yeR19Lga<; zEa2SbCx@8&`C=p)1RWs}kHb-P!yI4JS-$U4AeND zg3+N-+I~y|Jz)qPd7h#uIyKd)x(^1>vJ=bBUjgZ?T7w{W+_-wJ1awJ+-&H;C%^72+SE=7^LW14x2*gI_mb~@*^aMZ( zGSzFh(n$pCqPMH@5f6uiEMlMJF{BP37wDMk;UW(YxDca@i!Qp4<&#e}8X5d_d$icb z>^{^l56J`*G}`apK!?5-o7?8I_?n9<)4O9Af_{(XW_#=@Nb79dhjhDcRIwC{?M}Uo zbbKL!h)_7RoxF}MSU_Cd^x)0PRuCU0r7Sg;5ytTs^r?2WYRqFr{(YkNuhmB_2GD40 z4)AS&l3j8~SGP5Jff*31P62cQ7`!f#>GUR;TO?Ipf+#$yec&CG4r9tjOx z(ZyoguQ|?XyylRJ4OlD;C^v_Kl|}E{#Do;y92D&Smz0j^O^t-Yui5cU*k*Y zs>OmQypbbJDF{(l`no5!38u?myGwHxr~8H|C-D(|xulxr3jh86u8K)!`hi#ERsfzm zTt$vU-Cax|?wg}36FOQ5vCsDKH}Ze!gY z%(07=PmVX{!Imk8yF&?IvDZJgi!$pz`^IXlnNnw+s&qFP4Z2bI)Mk~k5 zT54S5#j5?vYAFNF$oSb1S=Vb!Q)bT2jpWjjAAvfEmxA&k@Qck!N)`USPKd@CqIsl3 z61McveVJ+-AX6oTSi9`a>t*+BEC)SqIz*5C_{7BBrZLj7#h!xD@{R;tEOhh?Zx)Ac z8*U$k_)UZrk8a5xH&`hD4fPa;q>2}x1lc_>*wY~)hH`k%H$M(Ty0te(A47B6oqecx&4 z^weeWpolz-a>uDCZqL5_@Qd;wNdL}UyFdh2;u0Kl-2J)7i#2~M5b$ES|L_y8(d*2( zv))h4xWF{?qhl}K1Za9@i+Icf{Doj5V^?uP2u)l`Rn8?%11`705!uCMN$_FH#kGct zX)%o4&jSjqaEknZBl_pjf)_rsw+w61Qj~A6j=ooI%S{jYt~nmAb~usU_TFQ@PmB5C zb3y%Mt>Y}jaPm76f*`>8xn=e|H8SAWtncBa{$#-A+^oy%6icU_IV;uq_wED|f&78P zmfiPbrKv9dZpS}m73IFnc|b+j1eaIAUNJ?nV_sNe&F1J2QAVk2Nbi2ym6U^khnRI^ zcC(FAV90`EPDjvF8(_EpDJZTtz}hbK+O4kze_elZ`SJMz`gco!A0$|>J0lbg*v62o zs$dc-#LTp-Zz@*Bgx-SW6Nfi`yN@GyaebP8u<3dx)M@uIySy>(O%gTV&{c=Bx@Qk#O_QnM=Lgk8cQV>t)##Y*5) zp#SXC$YPJnVOhq`haAUCWTw@G#p@hwI_#;TR)iMTpxyzECVBTY!*oX~VnlA}&g{*` zc{RcJZ)K)*r&H`2hM?{ONenr`k=tNJ121V^r4z`>pc{swqc}?+rbRK=_wtmCX`BXVE7{mvr(epC0#;*@6eVx zQsnu@)&sYCRod)Ax~L%T=($Pz#mSGw2ybRSCXCoy62Ue+6JXA$(@U&V8*(i1&ueH> zpS}4<+zA-5;#pd6cyjNPZ87`6%dGS`}ycoAe1e;?)i?7vxHH;A5B@( z!_&9F>f$z8Ln~ykj4n5Hu&}NE+T(cD9Ymrq3KK5ah6rl0n%yA_yj)B-eW6}44rk8c zS$X}vjnSAlOX7w9piXqQqo_8v#S``-1%T!fChh>ygH#57(EEl%Pt72-0x`*R>q0Q` zRm*&$P3OVDet1OW*Ios$-|dfDVMeJXQ2rDp554dh z`{;f@LR8pB@RVhcC0jD9HjcH)uXVngQ(_IQ6}#;V#3)ShZQhMYc*RBNSvT1ayZBT4 z7_rhOnKVO#K(l%H*X=K|{Wuk)U??e0PIjI)*z{4`#@QLY|LVl)tH^T&ndr?I(O>7O zvyCV7V;2KZXzoTj%Qg7r(XrAIi~@H{y>9I!-mc*kJ5Ar?(zc0TfB7;i++T6C;IT!s z?lE?Pa&w$gY*MTmW-g#q?<#N}cYq{In<|uIjlox=3&-?&i-}|=bkp*dLDBF0lf|zP z1?R1Dzw?)Xhs-m+{LMC^j!l2cbk;Y*i~vk(f-Z54Hg<17;aV^)o5VIfHsbwQ!83Bm z-qzi>lvwFTpR4lnZ*Wbr&{>+8!=X?phZwJ6ztn3#+Loj8l{ctL`Yz84d>6Hn7FiU` zv@jv5vrXR$KqH67dVqv&(GzrjS#i19&$!Q*Ny2{KRS8&bwr(!KOq5Dire?EpE}Y>y z^xQ_626!_~4b9}K?A#2WJz|-q4|)Y2tl^WA-ah9S9Y#P`5PHM1AC{+ecy1@-wC%b_ zR8i#@SJtbWzCL%|b8pLOHMn_uyOP7CweGVKt<-&B?pTK$=44BsGH1%T+h?g8*!2XEE;c`E z$9?aHHt*`CZDL8A+pX z8t`P$ADt-j08Oi$cVRRca9Dmoo z3quN6;-&%zLB1ZcYYBwLJG`j3edmyy!k1!GWyg2@PXfBPOVoun|Eq(uj*H^!!Z;n$ z-3SPXu)q@1C82b8hjgc;^wKRLAj=OV1Yzk~V5J0PMU*8)x+ItGc*pm@`ONO!&(57Q z=brOC-#d&om3BaU?RL?}s2(NUe zzU*iJbcEC9v6%Yhs_RR_zC-+y+%aZ)_YEm+kA`@)-rbfeG4Q@Jdp@jLMeIguvb*w7<0EFA;ev`QhF z_qgokb1rf-ygWSX_WTOTBgfYobx>}SQ zrGuwaj4hdt4Y4ci6?v}uO3!e7Ig68!O!m}2b>lW?5N|@wBVze&TxEBsLF9~RohyZt z;Ngc1OQ!iCasnL2UL}H~$uLgJzSu8EB;Sif*255umK6U+t7cE4^fhIeA>T4A#J}Sm z250~EkCRMDQlv<(Ig2)h&$fHZpZA=q12bFnvPO9$FLdGe&gH~!(R zRwHQjN;WJ!B%{U^XqCsrk0d$1FJ#UI$!z~ST}OuTC*aO6H8u4z`*_ru#kA5*VeuH82Yzj^SGm-)NA*w@Jmb@IXEYev@a{aQ-Nkep2uZ zGS3mnu0Wa03nV;rXcF`rGy&*d3sl*9{c~L+6+D~dGUOYnI_>8!n8jhH5Uv!unf51m z6VGPbu#HfC{>-n925h-W2Eam4zDbR5K+xKXq1xmX8bOnNYZ_%hqs;y2>&|ul4&2$M zu2WHK^%uc$g|P9$`Sb4EYjzuf7Vo7L)2`6xkfyoxMV$0~=m|~;E3jbjB=`F~_D9^X zm+CewinxG_yhdXrfYNkt{=#14pxN#&RJq355X9IQjKCnDEY1d&dPRawVy9ib-6po6 z$<&n0p?lm~Z_(C_(G3aGBU5TVZ_{WCV_5HfzV%6~;?4Q~fQdlopg6~(I(4h8owl)o zkbtrs!;lG@=HMh;W4GDwt3JrQt%qru%>Ko-`bg^K}*QK>1-~Z6Z8tOqWeLejfM-zN&8|+ zm;9dM2=8?mxIQCD7-%L6mH)ay#03V$+D5$sQC5qYfMQEs zBAXbK(cj$$AnGR5w=6*ms3izfH=Yh89nzMb5ucQ~_u(#~L{tm=R$qWvjcfIC z0evs#)qV7m__Z*}Gf4x;!8A**spy1mQa0Z^#+dGw$4PmwaUH0x{6)b1?C(?v9jGJ+ zxwUY)%N4)#%=D%2OCxZ`aY0-!27$x>+4J5=i;+J?^^+tNOEaC7 zMj9K1!h_sSkyQcFNaw1TLPByvC!Fvbf}MP_trHHM^?qz!5UmO)_3iPt$it{b?AW{>heZlY4Ki3e(mr(3LGKLIBgPGnRFiZIi9^i zByieO)LL{^*5}qnOr1LBTS4ka<)o#OACBoFV;c3) zS~Ci4iA$H~Z+wophy+wI?yj4uBtqBuUE~<52K)v4OYXZK5+zyMyb3^-cB`)CWoACT~Pm(4my3( z-S%L%gcgKK<1>kQU<~_0G5jpstM2h5#_hCQ0dKshLuDB1EEM4(vLpu&b386Aa4A3Q@Mm?5BXY=`-uzU`~$|&9aH;N~FIqZ97+OCkW zbNbesGS1jCo_8vZ&~x8K3}uw4mqqKNCra3e;qKFZukhPn39`kJF2Mx5V)7Wt$B0vV zFXEdZd0F=gL~){{*=+mGGirb5h+49=z#{Z;rVhg!>8s0+WJeQ3_mYZ$7!R_%HClhf z1+)`CML(9l)DqV$r}P~x`*od%3c)7FASNSQgVGwS8ga^8tOvQsa_*u;)0j*mNa$=f zm*kz%B09^1M>>M^7{wCz&(7tVQIR2?d%U&XXXjA74c9`*7W5CP(UIUrr#DL0wEK{e znB?_NWX{Q3$|D}`M=B#Vzt5cZQZW)MUH^pc*73W)w_bQ)mJ5C8f&M6Aq3>O=Xp1@4 z>(Cu_K*OrwZ;PB1HBp0$Ryr?uHhSvFwAK#z$!u+X{;N8PJiQMK4*U|?xbmUYL#ec~ z>ZI?4e$M$bx~QOD`1dql?Emm4)^ z`SWif_&<*vh8rR;au7>4p&P9oD|z#wO>#9gtN+G)R!`{||6NJ?6j(M{c)}Mk2f@S# z*6pMz^_~3}?`O!mqb`<0x~fi`o6P?7IP$stE(%arqzHq)rF{9=Y8v*f&Tcv0IY^N* z86nsYTp35sK7R=M?#)u)F242Q-P;wT{IzpwFK@QZGEODE=Rl_$Fc7s%xg?LqiNtE( zY3vjzn5#(*kV?A|yAe+n4W#LM;}Hu&T9p))@zi#>(!eZRzF)k@9+yvZc$EPa$6%2T zrT!9n;JSpaH*F#2^MbclO*}KpYYe*)wA;C-y}iE?a{z>*Z`Yh_Lds7l-i>l3Z#1Dx zGzy*T<71-TG&V+sL>NJ(JdgNHV1ujfu->l6;d>$9E$Uf&vj!3xj`NJH_=Iz{R}O9v z(I3$)kX<1RldRF;pskM`r^!ePVu%xs3>>!7S)B1At2?LH1@iU!<}G%0z=f6qA-ja% zCHZ3LR3!EEzh55Bd#9jBgRY&g@ZX90z)(-5QK3IO(f`OSFKI!VHTsW| z5=L@a!`?NM&;G=2L`H}sx>@4-{-)V0A|3I-YQ4CNHn-_I4Fz@GLl$c@IPEUdU)h@7 zYxn1k70HCT`A-S(#m;H!bR%=H=h{$uLWng9tDO2m8u>&)cvyi`1FC+veN zr_V(mes9C#jWoOxxcM1M22*C(8OBfA3opEdK?z;$GEvGLzjDq(ZhjU80|Vi0a$Bo% zFPBzdw-~YLGu6U8@8cRK4x=2*2&~Lg0(j%oE^06C7Tfsuej`Zi$>vh2RaQPD>xWl& zy-sB}m+Q}$V=vObGe*gUqX|gwN59gMmBsKB8mN{IXG-NwTj(1IPNFnl`;%G2;M!u@tVAZ3t%=?{h3%u5lEMZ8t6esXp&Wj z?%XRmR=|c@N~MNwZe0`4=&tLQ!hg26r>)HfZywo{S@NE}j;9s0N_Q0hvlI5cNDw^` zhVGXAxP}SnDd^(%qaox?c!pW%0KaK>K#0zCl_js;%(zb5lQ0K zl0Q*O=|lFWZ)>s*7^E?PDP`)cq7 z?0vjAyhh@Oy7f_}$2FcW@)N{$=ek&M1OEa~Z7*^=i!*t)^)A05!g5!EC#Lnn>xINb z#KxyxxWwf#CiBfmB>E7Tpa!3n{&VDiJbi7RfTOK4wR~|aedl0tc0{D5p55+=U#d&~ z0$Lx?`o2E{-l$k4<~68Mk61C2(zK)lQqcggn<}-hv=o*ban`TWapeBFF>;1<9G$^j zlX+(J26m7_SZ`J`;LSXkrF6f3J~VHFo3){$~RF4FD$~^N$5EOD%%gq}%lD zEbMGFXUGq_<695PUMwsHHte9`o zgPa{FffSKUW}Z1;gf>m#gCW|gPIOW;9Dq?kB|26l7Gzm7AWV$cT;P^JZ^r!#s%NtB zw>o9_cmwG>{<|yui+tcMkM-h3xK49VOdp%^QZqx=r#Ghkfy{fq?+MhuZnvND(`W#< znxaE)h9POF;Tk$7t7RUme=+za~-dH%+Jd!j!iDkkOzlff=F^DgH{MH-Mv)@HY{mzKbnfLdhlJ5P}J!&CBA&xrm~HsgBh1+f&Lrp2ziBDV_9t|QBq zn(e=5Iw)csY#GHqg?U-w^tENmiNl9in?D9-yv|CL{ew<9N%!{09^qnP{%N(NTK$$f zKJe!7y93$B%<(hg{}kmioLO(4cEB#qzGgH02^`;qzI2jIkHrov8J^R7KxDZebdb;q zc678p-^6ZUsjAwt;K?fru%RA=ILuCpN(E6U>efITSQxfUX|2DUo!yA7eZ>|GyCCAg z8Q%{l7GDT@x+!S-^Xx2QV*=@Ovru$6I3Rz1dw=Chc&=={}tZ&WnURv5J}K%&LQ21B5`uD#Y;4_cP=h6DZ|re6}M z=%H9m{l0GGJB$_E46NydzF8`i@MpY%M&L0W%>OpH?kn36x4od6w+#+{9cPG@KLav@ zysqi${@_=g_%B5W2uAx*r{ve~J=6LcXtfs>M92N(!?~=}f>rz4^={q;7ng%MJ-~A~ znZpvx+}H*^RfTJw&+WC0J%piq66Eg|zZ~tG58*4)zZa>0S`R$%nyJvZ-QRD$G4UW< z>^~j*3t;Xe8O7(V7@!Gw+Zu;i$BZ}W6 zV_<~JRO9OM>h2wK_~qK&jRV-9l}sd-iH`IEVT+&Z;gm(4!Bq&HT$ZWM%^Sjw;`ApTScXZXJqjmIDn8__M2jB4PqZ6A zDFMRKn!I!Ecim9zBoSXg2ZXb>;bEL?j@>k=84=C3_$nbcb+cqM;iKpvr$X<{i-Y(Kb^fX0!0c~63b^3gR)7WqI z3*w9xE0rk<;zOS#A~oY3)`oovDB&q46s-ZgPUqTi)%*g%jNgX=xYBkMVU#Okyx8*G zxUK(e+edLv_o*C(8E%gr-%+%A%%y&O9D##u8-LE|?6Mbmgkddz3)}J@=CHdx_m)lk zS?;uG(DUwhk(A^hTcR+aZ77?$x~R&XNs#{sN|sg0<69vi`4WEWtJ>6_YYP9%_{Z-= zi-d~GEU%Bo#4`HoWa|kz!KL=BFEbM{71oA6iK{T+4mRyUu4oIr*chsAge4$p7mX|S z8|;(>**L4&m7UZpx(#*>3>!H`bObm)Q@JM7Rb=@p6f3p_Amo3hynt{*kzu#i!<6;G zP(TDKXzMac-EUcH>iy38gxm2SKjlUb5;>DlBzRQeH}G@Ls~kiSyb|Aoz%WPbeg0QH z%;q>Is&Jg$jjX@>aHc9TDcVuttzr5_M^(=dxqiD1?b;WT{UF|ZLQwAPm1-b9GtKv` zlIKzN30QUWcBtKR};41CEgo zY+KdyidLDB85s?b5pSQ?Yj&a-F%0LR4dlD*LLas|5DOy#T)C;@xIff44ieU5%9gX$ zyQm7welI>AZsO19fs|rS-2UXBhj>cM?)#oupkZtMT;)Gb%;fzXh1#6c$%dOmUXD0v zbW^LN7W~Q59kUW&0&*$fIJB+L5a5^xVXT?b%8d85$ejKtj_h%scs7!3t~OeK&TM~F zp{ap|iKv3kl)Nf^R%0h%&Z6L`7w2d+DhpSW|HSpK6P98qj0vfHLGS_)$1Yrfu-?rM zm5tynz|hk3`5c~H=^BR>jfh(}G8(Y(?ul1jw{$7vBv94z+totGmF*DVhTvfLscD!p zR6jdeLzc}h>rs*?W?M42n2;@3H+V1s{y7L`{wA5@f=hpoGDg3Z0;m=0cV+?fY0|&t zQ%!o++f4}pYNJ?J^JRlSEoVR6;H1fAS!!Hag1JKPw`CgD^{H{NWP;O`szGc;+ArQY z0*=*BO)kn}b#69Q%CZ$Of$B!HGP9$eqEs%Rf^1<0d%jJn+=eV{RqInL-_Ac=#tL?G zWpXkA6$!NpfN;R&)_(C=&cw5QQyp^PN#OthwpurKI~iIPHF9vK_5hfKr)E0uBB^P@ z&((PZ{tE?V`x_OIXH*i31j*)+9U) z1RQ?xFb`w0%j8+6EGu|h4iJrE2AQX4z?po`jgF%lVb^xL1 z$!Eb(2n(aS`*3j;lIy}rtjOzI@LOAP>{@pU9j*R)QIx-d*nmu^obo7i+P=&8fY@If z{WcP$tERjv(N=Yqxpi5|pM){o2elu=Vl}R``ZGv%bV^63uy8nzVN>nr3cNIY>6ADG zF_)Hc?%Q+{VnN0{Q%BVoz`MP>_`3~QD-2(HaZ`2NHX#uW81U6&q_fPIVP9ryT#8dp z`+bI?M$dP+6rh2Y+77#?uRu@RJN&KMf0#Hax>d8LuPJ0R$uhmj>vPk*gG2Cze}3ja zhxME@{%P!3@9!S^iV~Kh*`AfSFLRbrfNpj;ZjnnjR^Lk^e}VoMS9cucO)W=%b@&Cj zlbH!Dbzuauu`q=TmJY&HS*l0Zjj4MMt&6dm#I>U|EA%t1*4>E`3D zDJa-ai-RmG-a>U)cLVkZ|6HZp(*1=gc#!)oZ2GW@(!Ors(HzhJc z0rgm~bN6VdvKijEFNxlsVs$FUczeJKA^Xn&7SMp0m$M}4aXcknlPg#>;_Z`P%*dGS z|4_q*C);qG8aw-Mt14%XK^`8&(HLl=|8R)}hMF9am4Q9hR|;|D>&X?6%ektO>->{L znFOFW6C104Z`9-Op%hb)Zx}^iSoVf4UY`NGAmDDp!96j68UI^w_l5ART;je2k8#+{ zC58J;ppYI9M~>$C;4ee11jB_RsJ6;zKn32AIdzG21{Ezo5+=b*yrxm>9y(r`eKt#l z(NI$vyCtEVL->KA#HG{Dgz$Qdt&figsN%tzL~EVhTa{I=W>eP{i_`+@!Vvsspo{fk zuKFR4qjvBqDfG)VA8!o=Xs~s`Q2^M(?;zZ^c3Ss)QUic`J*058#KBO(;F!4<5OKMI zRw-VdgV{eKxC3AjwYD?IeBT!!Ul1M2D4Rul&Z`cVK^F;BP*&qDg>HphD$$G9WeeFc=0Z;Ivo zhfU#ja8^YV*pqKh!s|(tvKnMf0+?+pDwH)RBjH6$>CV~Bnx_=}Tv3sK{$(4sRe=A4 zbwZvBAvM@NG(&|WVotY$XNUR!1Eo~8n%7L*>A>z|n4cE!HNNzeRBwHbuFA(0ql;PG z#yjM6B9pNhy~5P_5`R|~RWzL&DZd&Cc?#u^@_$p?Jjzbhl~^TY)yoTmmOEI8 zQ5uYnKGWqPqNERUYNC~Kb_94mEH>#hGfIP*SsxzJkC@Gh0!#k+b=m-BZEDQ*jhC9Z zrB*4Uojz2bPw^B>M~%D>9AISA@!ukNcA_c<0dM^W_PAV)_+`eX{i<8epmTKYva$nF$PY`>KCa{8|RmXvvyn zN(^!3pnSwmn`Rr}!5rG!1Dmyr95G(@wW7PS|0aDL3>^e(0$zM-He0`_)}ag;fOY`5 zN;zfHNukT8WAxAoZU<~W_bcER0T!6BY^EV}O_x$xb6qR0h6y1v{t=|A8_3eAT8?NX zM=uxtj&?57tzDOGq|;`vDCYs(mu2l8x#8nKTmO)sBRPN<;k(TPnC)nC!Qw zuRe8rsRv%Xe&@l9U)1p{eIQFk_^2Ll9zJnnE~)}e zo=jKv45C`>jD+G`mgDa}72=uct-x{S0w$V64W>k0;J<`3>C=?M`_|xI21-Anxz?Uu zKOcEm>+Ovmn=5lK5U?Vfba{-VJOGjxC$<^&MAex36C88Kmrw1K6`o7eN&!Gx)!i$2 z*-7+RG8qQWL*&cORjf!bKQOzNg3ppw=C}_(6)v{HmNYQSWFmlT0ZWNt!pyDgr3J7s zVSM6?WHQeY`PxH$4Iu3jhN8TRy=m7Dm)E2~dE`Z}MQdPe_`Z^mHVQk+6_@otee)bZ zd{qEvs*Ra5R#xu*3l{=cH(3L09kJs6*;!MQEgDA#80s-{;KcIow`G2>Xzf8_L?4q3 z26i`O|mXxKiPE3R5kJn5ufuQ0euqwtY5WQv}5(krBEO25=_j<6MY{5v*X^ z-hC4BO$i{M+cMzw+4iEwN?iLtsBy_Sn`}cc^|G0yM&4X61L;Ylu+M;S)@Z`Is&IEv zwPh0Z&Rq^+^=#a+VAbe*%UJ zy%St1zPB@Dv2NHh2^s=s%hEB?4rZxa$F>ZWuCSMaA3R?lSmFeeLgq9p>G&-ZKaE4p zB&d~PrdYmwqyrwUXLUy9p%hM!4>5W(w#HZ_zhOmHx-L1pgvv%;<%(wv9ro@~wPTEj zQNA4TAb|ozhY~h+hx0&a_w9~5J(7kgy>she$`t-O35`DYAHq@5Gi?lKn6B?3ny=C< z`%#(G5w>8yr2U$B-~(MxLQFJ-Ryv2jj4W6i>rxK<40CmI`(I3vVgK%v2W9D>RXrv^KgHlRrkaF&Fs;j_U-k$8V0p7tee#W{pImd z@S1`x#16jr72P780BD~fq15%=rRG%S9Y$C=oEUS*j0IHPQ8aA)^{FY?z$st*-MdN};tV?Wm-34F~Vv>Ot_}lp| zOkFi{rqKYmx}c}{GF4b3EwygZoN2l|68jTOMyUTjnai3oE?=qFGy!2~^*TE#Pbo+i zBY?6uk1;svnFYhiW_ouZuscLw&G=rJHRbJ7YU0DHA0Of?x2-Eog)h33v{_kSh4(Zwy4Y8kgOinCOB+)K=+s?reeAdy--aP2|v_?X)1P zBi5x*6neZ!dZL%CFdfEvm{7the`IoHzul+F+bTv$YDHp;q3~+gtWL>HYqgQ*_$wuW zE;>&9igUS9x-wsDGoaT1#;J_Jz_R`E)3#Xu9j>pR|08^b^s-N6R*F#E!^3}%P0!nt a2W(Rru~h-Mxx~L+Xlg3j$}mOSnEwHdzP5Y- literal 0 HcmV?d00001 diff --git a/frontend/public/img/osctrl-logo.png b/frontend/public/img/osctrl-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..b384e439a4b5ea4c58d7cb2bca9db929dd3b2d9c GIT binary patch literal 31195 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 {/* Wordmark */}
- + {/* Original osctrl tower mark from cmd/admin/static/img/logo.png — + the same artwork legacy operators recognise. Inverted in + dark mode so the near-black outline reads as light against + the dark card; left untouched in light mode where the + outline + light-blue cabin windows already pop. */} + osctrl logo
osctrl
From e889dca4d8362c6dac968b9c00195c72ec3155a4 Mon Sep 17 00:00:00 2001 From: alvarofraguas Date: Tue, 9 Jun 2026 21:15:11 +0200 Subject: [PATCH 15/57] login: light-theme spotlight uses multiply blend (was invisible) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mix-blend-mode: screen on the spotlight disc works in dark theme because there's plenty of room to brighten near-black. On light theme the same blend mode tries to brighten a near-white surface and produces no visible delta — the spotlight effectively disappears. Override blend mode + opacity for light theme: multiply darkens what the disc passes over (teal-tinted shadow), bumped alpha so the motion is perceptible. Animation timings and the disc geometry stay the same; only the painting behavior changes per theme. --- frontend/src/features/login/login-trace-map.css | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/frontend/src/features/login/login-trace-map.css b/frontend/src/features/login/login-trace-map.css index 58d4295a..c947dcaf 100644 --- a/frontend/src/features/login/login-trace-map.css +++ b/frontend/src/features/login/login-trace-map.css @@ -58,10 +58,27 @@ transparent 80% ); filter: blur(8px); + /* Dark theme: screen blends the teal disc as a brightening highlight + * against --bg-0 (#07090c). Visible because there's plenty of room + * to brighten. */ mix-blend-mode: screen; pointer-events: none; animation: login-trace-scan 12s ease-in-out infinite; } +/* Light theme: screen on a near-white surface produces no visible + * brightening — the disc effectively disappears. Switch to multiply + * so the disc darkens what it passes over (teal-tinted shadow effect) + * and bump opacity so the motion reads at all. */ +[data-theme='light'] .login-trace-spotlight { + background: radial-gradient( + circle at center, + rgba(var(--halo-r), var(--halo-g), var(--halo-b), 0.65) 0%, + rgba(var(--halo-r), var(--halo-g), var(--halo-b), 0.32) 25%, + rgba(var(--halo-r), var(--halo-g), var(--halo-b), 0.12) 55%, + transparent 80% + ); + mix-blend-mode: multiply; +} @keyframes login-trace-drift { from { From c495f0aa431471f95d010b088760ff5d30aeb53d Mon Sep 17 00:00:00 2001 From: alvarofraguas Date: Tue, 9 Jun 2026 21:19:09 +0200 Subject: [PATCH 16/57] logo: swap SVG for original PNG everywhere, theme-aware via CSS The legacy osctrl tower mark (cmd/admin/static/img/logo.png) is what operators already recognise. Replace the in-tree SVG drawing in the Logo atom with an tag pointing at the same PNG that the login screen uses, and add a single .osctrl-logo CSS rule in base.css that inverts the image on data-theme=dark. LoginPage now uses the .osctrl-logo class as well so all surfaces share the rule; no per-callsite invert conditionals. --- frontend/src/components/atoms/Logo.tsx | 28 ++++++++++++----------- frontend/src/features/login/LoginPage.tsx | 12 ++++------ frontend/src/styles/base.css | 12 ++++++++++ 3 files changed, 31 insertions(+), 21 deletions(-) diff --git a/frontend/src/components/atoms/Logo.tsx b/frontend/src/components/atoms/Logo.tsx index 0ee1e12b..1f5c8f4d 100644 --- a/frontend/src/components/atoms/Logo.tsx +++ b/frontend/src/components/atoms/Logo.tsx @@ -6,23 +6,25 @@ interface LogoProps { decorative?: boolean; } +/** + * Original osctrl tower mark from cmd/admin/static/img/logo.png — the + * artwork legacy operators already recognise. Rendered as a plain + * because the PNG carries the brand asset (tower + arcs + cabin + * windows). Inverted via a Tailwind `invert` utility in dark mode so + * the near-black outline reads against the dark chrome; left as-is in + * light mode where the outline already pops. The `decorative` flag + * controls aria semantics, matching the previous SVG component's API + * so this swap doesn't require any caller changes. + */ export function Logo({ size = 32, className, decorative = false }: LogoProps) { return ( - - - - - - - - + className={cn('osctrl-logo', className)} + /> ); } diff --git a/frontend/src/features/login/LoginPage.tsx b/frontend/src/features/login/LoginPage.tsx index 09469751..9099cab0 100644 --- a/frontend/src/features/login/LoginPage.tsx +++ b/frontend/src/features/login/LoginPage.tsx @@ -136,19 +136,15 @@ export function LoginPage() { {/* Wordmark */}
{/* Original osctrl tower mark from cmd/admin/static/img/logo.png — - the same artwork legacy operators recognise. Inverted in - dark mode so the near-black outline reads as light against - the dark card; left untouched in light mode where the - outline + light-blue cabin windows already pop. */} + the same artwork legacy operators recognise. The .osctrl-logo + class (base.css) handles the dark-mode invert so this stays + in lockstep with every other use of the mark in the app. */} osctrl logo
osctrl diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index 62c15873..71bfbd15 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -30,6 +30,18 @@ body { backdrop-filter: blur(10px); } +/* Original osctrl PNG mark — the artwork ships with a near-black outline + * and light-blue cabin windows that read naturally on a light background. + * Inverted on dark so the outline becomes light against --bg-1. Applied + * via the Logo component's `.osctrl-logo` class so every consumer + * (SideNav, dev gallery, etc.) inherits theme-awareness for free. */ +.osctrl-logo { + display: block; +} +[data-theme="dark"] .osctrl-logo { + filter: invert(1); +} + /* --- Status pip pulse (live indicator) --- */ @keyframes pip-pulse { 0% { transform: scale(0.8); opacity: 0.7; } From 2644abcb2577dc33bd3716ef3ec3c07c9be82562 Mon Sep 17 00:00:00 2001 From: alvarofraguas Date: Tue, 9 Jun 2026 21:33:02 +0200 Subject: [PATCH 17/57] sidenav: circuit-pattern background (B from preview) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tile the legacy login screen's circuit-trace SVG behind the SideNav rail so the brand carries from the login screen into the in-app shell without competing with data on the main content area. The existing subtle teal sheen at the rail's top is preserved by stacking a linear-gradient on top of the SVG via background-image's comma-separated layer syntax. Per-theme rules tune the SVG's fill-opacity so dark and light read at similar perceived density. Pattern is static — operators stare at the rail constantly and motion would be a distraction. --- frontend/src/components/chrome/SideNav.tsx | 34 ++++++++++++---------- frontend/src/styles/base.css | 27 +++++++++++++++++ 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/chrome/SideNav.tsx b/frontend/src/components/chrome/SideNav.tsx index 2d745e7e..100d24c1 100644 --- a/frontend/src/components/chrome/SideNav.tsx +++ b/frontend/src/components/chrome/SideNav.tsx @@ -142,22 +142,26 @@ export function SideNav() { return (
diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index c4089861..8b21e400 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -64,7 +64,7 @@ body { [data-theme="light"] .sidenav-circuit { background-image: linear-gradient(180deg, rgba(var(--halo-r), var(--halo-g), var(--halo-b), 0.06) 0%, transparent 280px), - url("data:image/svg+xml;utf8,"); + url("data:image/svg+xml;utf8,"); background-size: auto, 200px 200px; background-repeat: no-repeat, repeat; } From 92ff4e592df97a19a4718cf5ff0d2e098140cb8a Mon Sep 17 00:00:00 2001 From: alvarofraguas Date: Tue, 9 Jun 2026 21:45:16 +0200 Subject: [PATCH 19/57] dashboard: palette row replaces the in-chart legend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-SVG legend (Config / Query / Carve / Enroll circles+text at the top of the chart) is now redundant with the always-visible palette row above the chart — same swatch + label info, but each swatch is a working color picker. Remove the SVG legend entirely and drop the chart's top padding from 30 to 10 since there's no legend strip to reserve space for. The palette row also drops the collapse/expand toggle; it's a permanent control like the day-range toggle next to it. --- .../src/features/dashboard/DashboardPage.tsx | 108 ++++++------------ 1 file changed, 37 insertions(+), 71 deletions(-) diff --git a/frontend/src/features/dashboard/DashboardPage.tsx b/frontend/src/features/dashboard/DashboardPage.tsx index be6d3113..1aba2510 100644 --- a/frontend/src/features/dashboard/DashboardPage.tsx +++ b/frontend/src/features/dashboard/DashboardPage.tsx @@ -241,7 +241,10 @@ function TimeSeriesChart({ const H = 200; const padL = 40; const padR = 10; - const padT = 30; + // padT used to reserve 30px for the now-removed in-chart legend. + // Tighten so the chart uses the freed space; the palette/legend + // row above the SVG is rendered by DashboardPage. + const padT = 10; const padB = 30; const innerW = W - padL - padR; const innerH = H - padT - padB; @@ -335,18 +338,9 @@ function TimeSeriesChart({ ); })} - {/* Legend — swatches drive from the same palette as the layers - so a remap immediately updates both. */} - - - Config - - Query - - Carve - - Enroll - + {/* In-chart SVG legend removed — the always-visible palette row + rendered above the chart (DashboardPage) now serves as the + legend AND the per-category color picker in one control. */} ); } @@ -1097,7 +1091,6 @@ export function DashboardPage() { }); const [palette, setPaletteEntry, resetPalette] = useChartPalette(); - const [paletteOpen, setPaletteOpen] = useState(false); const is401 = isError && error instanceof AuthError; @@ -1353,72 +1346,45 @@ export function DashboardPage() { > 24 hours - {/* Palette toggle — opens an inline disclosure with 4 - color inputs for the chart's stacked categories. The - state is per-browser; localStorage so it survives a - reload. */} +
+
+ {/* Palette row — always visible. Doubles as the chart's + legend (swatch + label per category) and as the per-user + color picker. Each swatch is a native + bound to localStorage; a Reset link returns to defaults. */} +
+
+ {(['config', 'query', 'carve', 'enroll'] as ChartCategory[]).map((key) => ( + + ))}
- {paletteOpen && ( -
-
- - Chart colors - - {(['config', 'query', 'carve', 'enroll'] as ChartCategory[]).map((key) => ( - - ))} - -
-
- )}
Date: Tue, 9 Jun 2026 21:56:53 +0200 Subject: [PATCH 20/57] wordmark: use Bai Jamjuree font from the legacy admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Load Bai Jamjuree (weights 500/700) from Google Fonts in index.html with preconnect + display=swap. Add a small .font-wordmark utility in base.css that uses it (falls back to Space Grotesk → sans-serif). Apply only to the two wordmark surfaces — the login card header and the SideNav rail — so the rest of the SPA's type stack (Inter / Space Grotesk / IBM Plex Mono) is unchanged. The osctrl name now reads in the same typeface operators see on the legacy admin. --- frontend/index.html | 7 +++++++ frontend/src/components/chrome/SideNav.tsx | 2 +- frontend/src/features/login/LoginPage.tsx | 2 +- frontend/src/styles/base.css | 9 +++++++++ 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 9169c797..345a78d1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,6 +5,13 @@ + + + + osctrl diff --git a/frontend/src/components/chrome/SideNav.tsx b/frontend/src/components/chrome/SideNav.tsx index 100d24c1..b285c18c 100644 --- a/frontend/src/components/chrome/SideNav.tsx +++ b/frontend/src/components/chrome/SideNav.tsx @@ -157,7 +157,7 @@ export function SideNav() { login card because the sidenav rail is narrower (~240px). */}
-
+
osctrl
diff --git a/frontend/src/features/login/LoginPage.tsx b/frontend/src/features/login/LoginPage.tsx index 9099cab0..efa1fd9d 100644 --- a/frontend/src/features/login/LoginPage.tsx +++ b/frontend/src/features/login/LoginPage.tsx @@ -146,7 +146,7 @@ export function LoginPage() { height={48} className="osctrl-logo" /> -
+
osctrl

diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index 8b21e400..6dd33a51 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -15,6 +15,15 @@ body { letter-spacing: -0.015em; } +/* Wordmark font — Bai Jamjuree, the typeface the legacy osctrl admin + * used for its body + brand. We apply it only to the "osctrl" wordmark + * (login card + SideNav header) to honor the original visual identity + * without changing the rest of the SPA's type system. */ +.font-wordmark { + font-family: 'Bai Jamjuree', 'Space Grotesk', sans-serif; + letter-spacing: -0.01em; +} + .font-mono-tabular { font-family: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-variant-numeric: tabular-nums; From 41ad1effb6630cbc1d59f91b6c4079763c9bbf84 Mon Sep 17 00:00:00 2001 From: alvarofraguas Date: Tue, 9 Jun 2026 21:57:26 +0200 Subject: [PATCH 21/57] logo: preserve cabin-window color in dark mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit filter: invert(1) flipped lightness correctly (dark outline → light) but sent the light-blue cabin windows to a muddy orange because the hue inverts too. Chain hue-rotate(180deg) after the invert so the hue shift the inversion caused is cancelled — windows stay in the cool/blue family, matching what operators see on the light theme. --- frontend/src/styles/base.css | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index 6dd33a51..eed26998 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -47,8 +47,15 @@ body { .osctrl-logo { display: block; } +/* Dark theme recolor: invert(1) flips the lightness so the dark + * outline becomes light against --bg-1, but it would also send the + * light-blue cabin windows to a muddy orange (#a8c5e8 → #573a17). + * Chaining hue-rotate(180deg) cancels the hue shift the invert + * caused, so the windows stay in the cool/blue family while still + * being light enough to read on dark. The result is the dark-mode + * equivalent of what operators see on the light theme. */ [data-theme="dark"] .osctrl-logo { - filter: invert(1); + filter: invert(1) hue-rotate(180deg); } /* SideNav circuit background — the legacy login screen's circuit-trace From b25a8c0c35b7039a60bdb6cddcc8a7d1efbe6cc3 Mon Sep 17 00:00:00 2001 From: alvarofraguas Date: Tue, 9 Jun 2026 21:57:54 +0200 Subject: [PATCH 22/57] =?UTF-8?q?sidenav:=20tone=20down=20the=20light-them?= =?UTF-8?q?e=20rail=20circuit=20(0.18=20=E2=86=92=200.10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/styles/base.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index eed26998..ec49dc89 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -80,7 +80,7 @@ body { [data-theme="light"] .sidenav-circuit { background-image: linear-gradient(180deg, rgba(var(--halo-r), var(--halo-g), var(--halo-b), 0.06) 0%, transparent 280px), - url("data:image/svg+xml;utf8,"); + url("data:image/svg+xml;utf8,"); background-size: auto, 200px 200px; background-repeat: no-repeat, repeat; } From fa9f67c83adabfc130649fc9d009b8f295c06bde Mon Sep 17 00:00:00 2001 From: alvarofraguas Date: Tue, 9 Jun 2026 22:03:17 +0200 Subject: [PATCH 23/57] logo: fix dark-mode cabin-window color (minifier dropped invert arg) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous invert(1) hue-rotate(180deg) filter was being silently mangled by Tailwind v4's CSS minifier — it dropped to invert() hue-rotate(180deg), which is invalid CSS and the browser threw the whole declaration away. So the dark-mode logo was rendering with no filter at all (dark outline on dark card, no recolor). Use invert(100%) instead — the percentage form survives minification. Now the cabin windows keep their cool/blue tone in dark mode, matching the light theme. --- frontend/src/styles/base.css | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index ec49dc89..d8160713 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -47,15 +47,19 @@ body { .osctrl-logo { display: block; } -/* Dark theme recolor: invert(1) flips the lightness so the dark +/* Dark theme recolor: invert(100%) flips the lightness so the dark * outline becomes light against --bg-1, but it would also send the * light-blue cabin windows to a muddy orange (#a8c5e8 → #573a17). * Chaining hue-rotate(180deg) cancels the hue shift the invert * caused, so the windows stay in the cool/blue family while still - * being light enough to read on dark. The result is the dark-mode - * equivalent of what operators see on the light theme. */ + * being light enough to read on dark. + * + * Note: written as invert(100%) (not invert(1)) because Tailwind v4's + * CSS minifier collapsed `invert(1)` to bare `invert()` when chained + * with hue-rotate — that's invalid CSS and the whole filter rule got + * dropped silently. The percentage form survives minification. */ [data-theme="dark"] .osctrl-logo { - filter: invert(1) hue-rotate(180deg); + filter: invert(100%) hue-rotate(180deg); } /* SideNav circuit background — the legacy login screen's circuit-trace From 016273225949c7da77a0bd48387f6a5426856606 Mon Sep 17 00:00:00 2001 From: alvarofraguas Date: Tue, 9 Jun 2026 22:07:30 +0200 Subject: [PATCH 24/57] =?UTF-8?q?logo:=20switch=20invert(100%)=20=E2=86=92?= =?UTF-8?q?=20invert(99%)=20to=20defeat=20CSS=20minifier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tailwind v4's optimizer collapses invert(100%) to bare invert(), which is invalid CSS and gets dropped — the dark-mode logo recolor was silently a no-op. invert(99%) is visually identical but the optimizer keeps the argument because it's no longer the default. --- frontend/src/styles/base.css | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index d8160713..984bfe47 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -47,19 +47,20 @@ body { .osctrl-logo { display: block; } -/* Dark theme recolor: invert(100%) flips the lightness so the dark +/* Dark theme recolor: invert() flips the lightness so the dark * outline becomes light against --bg-1, but it would also send the * light-blue cabin windows to a muddy orange (#a8c5e8 → #573a17). * Chaining hue-rotate(180deg) cancels the hue shift the invert * caused, so the windows stay in the cool/blue family while still * being light enough to read on dark. * - * Note: written as invert(100%) (not invert(1)) because Tailwind v4's - * CSS minifier collapsed `invert(1)` to bare `invert()` when chained - * with hue-rotate — that's invalid CSS and the whole filter rule got - * dropped silently. The percentage form survives minification. */ + * Note: Tailwind v4's CSS optimizer collapses both `invert(1)` and + * `invert(100%)` to bare `invert()` when chained with hue-rotate, + * and `invert()` with no argument is invalid CSS — the whole filter + * gets dropped silently. Using 99% is functionally identical to 100% + * but is non-default enough that the optimizer keeps the argument. */ [data-theme="dark"] .osctrl-logo { - filter: invert(100%) hue-rotate(180deg); + filter: invert(99%) hue-rotate(180deg); } /* SideNav circuit background — the legacy login screen's circuit-trace From 9ecede2f46ed4821b5c1b0f160a45295635982b5 Mon Sep 17 00:00:00 2001 From: alvarofraguas Date: Tue, 9 Jun 2026 22:30:21 +0200 Subject: [PATCH 25/57] logo: ship dark-mode PNG variant + tone down dark rail circuit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-variant approach replaces the filter:invert() hack that Tailwind v4's optimizer kept silently breaking. osctrl-logo-dark.png is the manually recolored mark — light-grey outline, brand-info blue cabin windows — and base.css switches between the two PNGs based on data-theme. Pixel-perfect control over both variants, no minifier games to lose. Also drops the dark-theme SideNav rail circuit from 14% to 8% alpha; the previous value was reading too dense against the deep --bg-0. --- frontend/public/img/osctrl-logo-dark.png | Bin 0 -> 28128 bytes frontend/src/components/atoms/Logo.tsx | 48 +++++++++++++++------ frontend/src/features/login/LoginPage.tsx | 32 +++++++++----- frontend/src/styles/base.css | 50 ++++++++++++---------- 4 files changed, 84 insertions(+), 46 deletions(-) create mode 100644 frontend/public/img/osctrl-logo-dark.png diff --git a/frontend/public/img/osctrl-logo-dark.png b/frontend/public/img/osctrl-logo-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..ac534b25dd097ca39c1397eaedfb167350bd5672 GIT binary patch literal 28128 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 QgGdl1IW^e==_i5z1yuxOHvj+t literal 0 HcmV?d00001 diff --git a/frontend/src/components/atoms/Logo.tsx b/frontend/src/components/atoms/Logo.tsx index 1f5c8f4d..816b3561 100644 --- a/frontend/src/components/atoms/Logo.tsx +++ b/frontend/src/components/atoms/Logo.tsx @@ -8,23 +8,43 @@ interface LogoProps { /** * Original osctrl tower mark from cmd/admin/static/img/logo.png — the - * artwork legacy operators already recognise. Rendered as a plain - * because the PNG carries the brand asset (tower + arcs + cabin - * windows). Inverted via a Tailwind `invert` utility in dark mode so - * the near-black outline reads against the dark chrome; left as-is in - * light mode where the outline already pops. The `decorative` flag - * controls aria semantics, matching the previous SVG component's API - * so this swap doesn't require any caller changes. + * artwork legacy operators already recognise. Two PNG variants exist: + * + * /img/osctrl-logo.png — original (dark outline, light-blue + * cabin windows). Used in light theme. + * /img/osctrl-logo-dark.png — manually recolored (light outline, + * brand-info blue cabin windows). Used + * in dark theme. + * + * The .osctrl-logo CSS rule in base.css shows one or the other based + * on data-theme, no JS state needed. This replaces the previous + * filter:invert() hack which Tailwind's minifier kept breaking. */ export function Logo({ size = 32, className, decorative = false }: LogoProps) { return ( - {decorative + role={decorative ? undefined : 'img'} + aria-label={decorative ? undefined : 'osctrl logo'} + aria-hidden={decorative ? true : undefined} + style={{ display: 'inline-block', width: size, height: size }} + > + + + ); } diff --git a/frontend/src/features/login/LoginPage.tsx b/frontend/src/features/login/LoginPage.tsx index efa1fd9d..c714c7a3 100644 --- a/frontend/src/features/login/LoginPage.tsx +++ b/frontend/src/features/login/LoginPage.tsx @@ -135,17 +135,29 @@ export function LoginPage() { > {/* Wordmark */}

- {/* Original osctrl tower mark from cmd/admin/static/img/logo.png — - the same artwork legacy operators recognise. The .osctrl-logo - class (base.css) handles the dark-mode invert so this stays - in lockstep with every other use of the mark in the app. */} - osctrl logo + role="img" + aria-label="osctrl logo" + style={{ display: 'inline-block', width: 48, height: 48 }} + > + + +
osctrl
diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index 984bfe47..5ae915a0 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -39,28 +39,34 @@ body { backdrop-filter: blur(10px); } -/* Original osctrl PNG mark — the artwork ships with a near-black outline - * and light-blue cabin windows that read naturally on a light background. - * Inverted on dark so the outline becomes light against --bg-1. Applied - * via the Logo component's `.osctrl-logo` class so every consumer - * (SideNav, dev gallery, etc.) inherits theme-awareness for free. */ -.osctrl-logo { - display: block; -} -/* Dark theme recolor: invert() flips the lightness so the dark - * outline becomes light against --bg-1, but it would also send the - * light-blue cabin windows to a muddy orange (#a8c5e8 → #573a17). - * Chaining hue-rotate(180deg) cancels the hue shift the invert - * caused, so the windows stay in the cool/blue family while still - * being light enough to read on dark. +/* Original osctrl PNG mark — two variants ship as separate PNGs: * - * Note: Tailwind v4's CSS optimizer collapses both `invert(1)` and - * `invert(100%)` to bare `invert()` when chained with hue-rotate, - * and `invert()` with no argument is invalid CSS — the whole filter - * gets dropped silently. Using 99% is functionally identical to 100% - * but is non-default enough that the optimizer keeps the argument. */ -[data-theme="dark"] .osctrl-logo { - filter: invert(99%) hue-rotate(180deg); + * osctrl-logo.png — original, dark outline + light-blue cabin + * windows. Used on the light theme where the + * dark outline reads naturally on white. + * osctrl-logo-dark.png — manually recolored, light outline + bright + * brand-info cabin windows. Used on the dark + * theme. + * + * The Logo component renders both s; CSS picks which one shows + * based on data-theme. We avoid CSS filter recoloring because + * Tailwind v4's optimizer silently broke our filter:invert() chain + * by dropping the argument, and this two-PNG approach gives pixel- + * perfect control over both variants. */ +.osctrl-logo-light, +.osctrl-logo-dark { + display: none !important; +} +[data-theme="light"] .osctrl-logo .osctrl-logo-light { + display: block !important; +} +[data-theme="dark"] .osctrl-logo .osctrl-logo-dark { + display: block !important; +} +/* No data-theme set yet (first paint before theme-bootstrap.js runs) — + * default to the light variant so the page isn't logo-less briefly. */ +:root:not([data-theme="dark"]) .osctrl-logo .osctrl-logo-light { + display: block !important; } /* SideNav circuit background — the legacy login screen's circuit-trace @@ -78,7 +84,7 @@ body { [data-theme="dark"] .sidenav-circuit { background-image: linear-gradient(180deg, rgba(var(--halo-r), var(--halo-g), var(--halo-b), 0.04) 0%, transparent 280px), - url("data:image/svg+xml;utf8,"); + url("data:image/svg+xml;utf8,"); background-size: auto, 200px 200px; background-repeat: no-repeat, repeat; } From f90276dd0a2a6716bef95446048bce1b7ca67d7e Mon Sep 17 00:00:00 2001 From: alvarofraguas Date: Tue, 9 Jun 2026 22:35:00 +0200 Subject: [PATCH 26/57] wordmark: drop Bai Jamjuree, keep Space Grotesk @ 700 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mockup the user shared (Heading 1 / 28px / Bold 700) shows the intended wordmark in a plain sans-serif (Inter family). Bai Jamjuree was the wrong call — operators want the SPA wordmark to stay in the display stack the rest of the brand uses. Reverts the Google Fonts import in index.html and rewrites .font-wordmark to use Space Grotesk @ 700 with the same letter-spacing as .font-display. --- frontend/index.html | 7 ------- frontend/src/styles/base.css | 14 ++++++++------ 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 345a78d1..9169c797 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,13 +5,6 @@ - - - - osctrl diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index 5ae915a0..f29948bf 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -15,13 +15,15 @@ body { letter-spacing: -0.015em; } -/* Wordmark font — Bai Jamjuree, the typeface the legacy osctrl admin - * used for its body + brand. We apply it only to the "osctrl" wordmark - * (login card + SideNav header) to honor the original visual identity - * without changing the rest of the SPA's type system. */ +/* Wordmark font — reuse the SPA's display stack (Space Grotesk → + * system sans) so the "osctrl" wordmark inherits the same typeface + * as the rest of the brand display copy. Kept as its own class so + * future tweaks (weight, tracking, etc.) for the wordmark don't + * leak into other .font-display usages. */ .font-wordmark { - font-family: 'Bai Jamjuree', 'Space Grotesk', sans-serif; - letter-spacing: -0.01em; + font-family: 'Space Grotesk', sans-serif; + font-weight: 700; + letter-spacing: -0.015em; } .font-mono-tabular { From a729d10b4131ade6a2820307a375a7e2c1038d05 Mon Sep 17 00:00:00 2001 From: alvarofraguas Date: Tue, 9 Jun 2026 22:35:39 +0200 Subject: [PATCH 27/57] =?UTF-8?q?wordmark:=20restore=20Bai=20Jamjuree=20?= =?UTF-8?q?=E2=80=94=20user=20confirmed=20it's=20the=20right=20font?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-add the Google Fonts for Bai Jamjuree and point .font-wordmark back at it. Previous deploy was supposed to do this but the browser-rendered text never picked it up (likely a stale asset cache). Trying again with a fresh bundle. --- frontend/index.html | 7 +++++++ frontend/src/styles/base.css | 12 +++++------- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 9169c797..1e21f8eb 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,6 +5,13 @@ + + + + osctrl diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index f29948bf..0a43ab47 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -15,15 +15,13 @@ body { letter-spacing: -0.015em; } -/* Wordmark font — reuse the SPA's display stack (Space Grotesk → - * system sans) so the "osctrl" wordmark inherits the same typeface - * as the rest of the brand display copy. Kept as its own class so - * future tweaks (weight, tracking, etc.) for the wordmark don't - * leak into other .font-display usages. */ +/* Wordmark font — Bai Jamjuree at 700, loaded from Google Fonts in + * index.html. The wordmark is the only surface that uses it; the rest + * of the SPA stays on Inter + Space Grotesk + IBM Plex Mono. */ .font-wordmark { - font-family: 'Space Grotesk', sans-serif; + font-family: 'Bai Jamjuree', 'Space Grotesk', sans-serif; font-weight: 700; - letter-spacing: -0.015em; + letter-spacing: -0.01em; } .font-mono-tabular { From cda253e2a2a2040160e3577ea96f0ab999ee4de4 Mon Sep 17 00:00:00 2001 From: alvarofraguas Date: Tue, 9 Jun 2026 22:43:57 +0200 Subject: [PATCH 28/57] wordmark: self-host Bai Jamjuree to bypass ad-blockers Google Fonts was blocked by the user's browser/network, so the wordmark fell through to Space Grotesk and never rendered as intended. Bundle the 3 Latin-subset weights (500/600/700) directly in /fonts/ and replace the with local @font-face rules. ~36KB total, no third-party CDN, no possible blocker to defeat. --- frontend/index.html | 7 ----- frontend/public/fonts/bai-jamjuree-500.woff2 | Bin 0 -> 11824 bytes frontend/public/fonts/bai-jamjuree-600.woff2 | Bin 0 -> 11880 bytes frontend/public/fonts/bai-jamjuree-700.woff2 | Bin 0 -> 11668 bytes frontend/src/styles/base.css | 28 +++++++++++++++++++ 5 files changed, 28 insertions(+), 7 deletions(-) create mode 100644 frontend/public/fonts/bai-jamjuree-500.woff2 create mode 100644 frontend/public/fonts/bai-jamjuree-600.woff2 create mode 100644 frontend/public/fonts/bai-jamjuree-700.woff2 diff --git a/frontend/index.html b/frontend/index.html index 1e21f8eb..9169c797 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,13 +5,6 @@ - - - - osctrl diff --git a/frontend/public/fonts/bai-jamjuree-500.woff2 b/frontend/public/fonts/bai-jamjuree-500.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..0779eba6d0192803067ef246e03f302f0772ad0b GIT binary patch literal 11824 zcmV-0F3-_-Pew8T0RR9104^{94gdfE0BjTh04>)50RR9100000000000000000000 z0000QdKRb63TgB_Wvn?lQF!tgKCM2?#gX9SyrM=9DLlmon%a6j_&GEEPb#4ba|L@ji-(E z4?S%wqN0Ks&~Ej}MLm~_2@2|6f+x>&M`&`2R-XU={hr@kYoGhxH%yaQDI$_+;-%oB zF^ColBr+i}I+aqy&(HJQ{Bz%X@Zb@WCgKAT5t}J0x-tqjF>)nVu{mqet&4V*xX9H1 z8+DmH|NVZjU;8}7J$Xp3&7-I#hD0QhsFrD>q5S#P{SJ?t_OCOqPzBmzlAsa=Z_pG= z0)uK6asx zJM{M7Elb)wvZmM=*6buwxYU|6f(B#p5;R z5EJ#SNs0EPq@o*aPF09isv__K2>5)&K-q%mNZD54Bv})v(QE+eKvR}CrBF$eQ!d(# z{dQY!--O(6(REwxS${9n-UkgGi>Q}+xRK`H@c-92_GbNe86v}$h@8YHClirjOKSaA z|6y#pVBOayO^~$yU$X#r0bGH>*>%CZ_7>)ycMuF8!58As9}*}DVw3~PQwk|p4yjZP zu^I&#Z4Ass7a^Bjfn0M9V%G)t+B-M|T+-iK__C)=84z;NQda}2lVz52kRSj%XJ8mu z;?)rXsQ^EZiGAR`+8Rid@}2O^&?jA4Z3-U-6oDb@G5E~^1atgr%u^>-D7b)+SpdO; zG^>D2#l@2&O&XJUeHfe)B7laH4Dz7PLsr)l$e~&YIb9u)+jSd~Z3OfS1`h)@1Q&m1 zBnw4AZ`B9A`H6~K{7^|6{1s@dLtdoa29%M3M%3x__nxHea?lZRt|yP0>QoW z(I*HTPaw!UgzyGE*RB_^o^^f0dR$uqCy(w%pTk&z#J_a@hJ0rEcY>pKu6>RmfbwB~&pwEE$} zkG!(C0rpgfLxNK5t(oX%$^|K34@Nz!AXe=Tm>W{m^m? z@|M`5zHHbOIGIEVE#?V}jJ6`i*T?!%b(`$DMoZ*91MsxqtStLh`{H3A%eX)W8DybH zImjjyKsfDkcDLZpw@A3aGJDNeV^-AaZ1sqIdHm<%uk zgL6J$0FFtF7{nk3i9FX}5e6Wi^9qeT4CY)&@IDFg6AgQe&T6Fb*G>jnjciwCBi zh#Xn9w1SRpprCFB20JR~os-Ln8vZp^))IVJf)Jqd(jSvE*r;kzO2;{zre-!i)RX46 z9RnBair59I69Jc}ky7K18WMFOyO+F#5KMC@z^7ISPi`#49IJ^HFYyUmYsii}W%{IG z6xM4FnI>XBql-{#wWq3YpJHcT@HA_&(N?VvI^?h;jycZe1dIxSLxPKjLOgtk0|_%y zLQVpa_c@^{R>F&)3WPv{1x6=u;erw&2!@6ZB1K{7dEh*~#1To5M9e5v4DZMQwvV%{_ zo?7IPkt@_iD#A}U>8DD^wn)7s0nbavl`EIjfPgc;IcD z4<7j7!(lMeLm0u42sSWKz(GI=2_)b`0x{enfjcB{hZIaW!WASqaEw{TlkhT8s7w-` zCJWjW;bE#!nl9vK2!&?B&?00u3WiozAD=85B3O2avU3U9&^>B2#-5E#H%0e}1j&~<4;nkKCu-g7$(iHJ8WvtWmVK_~ zYa9<5r$BoPWxrS2RZDs!ajE_EoDU4g2)m)s&{nrZix}KA3ngc#fGoy5*J#R_NNC(y z7K;h#YuhJ`=R0RPPIS}6z|UkcJmW<@@_$G4Vek3mc%+Fd>a%8||7o+m-#nEr@w+(TX) z$z7Nw?U>-o_gbA#GIE+ivYXFlPL!~HkLSJ0qEP3a>1T_ff;jmq#+D;ydAk6J+H<~i zO7DIw+eR>zH?Q5zoCK*6AzO()WOWmLXyiUyn)GaOQ}giNUJYp8$vE=*8r3LeKlZCt z>vm8fge>0DBh;Neme2cXiy#1F7{n7}b?8NA<5l)V&fnln&PUrElpmo~l@s!;ZwX+7 z4FTBDKnYLyfW@-m3v9^1h79iDf(H@!a0SmIH9>{YB%wB0C`=VzrVH8(q0%fIwFs$= z!ci+b4_t7A6cjk1#^XS^bOaDK#(;TsAp&F$OeDa3;qY)G;0A0I4(@{;BzU9)aS;hZ z5NdVdk^93f7aXzPVsnkwph#Yn#BRcf74^hA@)szafmyvqTeZT-8_V1UpYI_G>Cj%rhD=aM7_3l#!FH9=>?nFt%2QKJTzdG@M>A;J^(iJAy(Og|&CxfF zfwR^Vs9g^n8&5Li?#FU+_}={e?^F+kvVZmO!+-+yW;s}1YJq`8Yhe{+Rf7=#2lhb0 zDSiAB+0jl)bOR zkgOLC8A0&ib#Sew8-4ArF?>vGB(Ksu*^3qXJ_$j|l?Y`uL0=gz2VN$4V(Y63I69L| zF%1(a6$%b=r2v-X#0S%mcCxFZ+7)=4sx>e?A5k&b53ukq4rPL<_WI&AqtYCcTttQD z0|w~*<^a#@{mm}A_ySJmXFNM*k1sRQPJz2d;{WYp0PA&w@Az_(kY6ImgF#Hb5e^BW z=Br0kFu)~zl|T{3I^cJnKIygfLZ9^6Ae_M2xaS=b+Le~mN?J|#pbc~^{Zye;EKn>` zij>mdIl&ESK@6H5S|zQ=Gk|wEYqFSjre!Z$M@L*Q(>?}X0kCTT|IbhN(D~B<{P(ea zBLKhNZ6DjduYE>)TDv@vy-a$U_@eE_5kLSB0IzEg3m}iO(k$}O>y-yt?B+c-IcKNK z9((S*-S*hwh871kYqj16_uO&U7S9m4cpL~ha^lQ|oClhcCold21qr6pL%0YAk$T#1 zmjfO->Wg0DB}igqmMX0Q{x3hkK!XioQLa*zYF0zlYtX34D0}^5p9e13?4qkKx#nLl zeb#QQQyw~`!&iU0Z=pp#cww8578vKWm6lm$D;5SO3W<$_PfA2g!ksHOG6E_-)V%r9 zxJ4^Wh){Y_0@R4sTOYCdiV-JK3X^{N%aJKdwq*H=6)04y$ZaL+3{z{k5h{%2r_BHU zWB1kxCmmzc<~Rhk->w1p7mz=JKftwj0B-!Afwd0wF+c%mgErED z@N&kxRiwMJT)Nc;wrz~CDolRpD)j-XEPdmUd;nSux8xMH`9(`>jPV}X`t}l`n?#r5 z2KdyG;dwRgB@{9+#1+wz$2>_&#wpAFszL+`_7O0C6ny1kWeV65gi->XFcgw7!pV%0 zv4vjVkXYJ#B$MO_M~Rt;=U~|yCK)h7qT872_zPn@<`HrGi^XGxV)SQ+nnB!QMuT?h ztex6=iNV}yl%~;YI{lK8eOwsAkU#@;+k)|E=IhWTF)1zSB*WV87fwiHo{Oon~0KbT1$ax`eQn`TQWc1YpHCMePd-D?`X#!6B^ zQ&W_rVfGZ|HZX2f|p%S_hHTS!-e2E1oaABz~TAa#anIzV-K09PWao<11Dy?f?K4JcW zR-{Q~0{^Y?(|2rr+1c4$S||F1yvs$poMNs7Cr?nQQ<@O3_CL+a75V3~ZvUBI;9gz$M29Bg=d`1LWL$dGH82JH zI46CrdQZ<60F6{nPeWak4vLf^kYke939j3VeZF&dpbItXGWL=DJF6F6%I97UKa@}$ zaF|Q0M(8a{R)wXO9L^%cTUKV0V_A-DA9MC9-0Us^dgp+l)Z0W4d4ZA-G37w#ee}iG z06>OjSYA(<@ZQ0Dnx3mqw(TrD*41M6*mh^^JQA2*pCwJ z6KPZ7gD5R=-cz1n>!fV`f1aPD1Pt@h{k&y(C!=q~-J!NLActyYZbUT~RE_AQVDQ0y zO0(=%>iuc%KA=3u(0a1Lmh){rGB>JuQDpg~UGZyP!xI3tutt87CZVhv)*1|kv0bji zY=53UPw<-wYRjIqG-Cd8f0HKA(aSytqW7qHwc}{t_^(y%sgM)DE_??@Q#1-Ayb!gk z?(iChKj8LsaEscrYxOe_#qoB%c)C_ne{s3$T`mYe&4F?$vrHmQ}7=8npnqeRd?ZBu`h~53-M3D=oFwf*Xs6 z0?Jwxx=3EFq(!;Yb@1wfT1Ri?6E7vnIn$Qwms%KF)R!}%xh3U1pRWZNE#_0w#sRDN}`pZDZ`nr$ndOXM;no5 zfwj*m?Mie(B|LZDD9bFL@;FBag^y?5OqQxq5DWG`<5)}uzYDtc9QKtVY^0E z**wR^!o+rGrW_I$4H zEgz`Iv}63R4+D0i9I^9Kgg|z$)%%?09d)yQ6;U+o>?0A?2Y-n}R6u@wR6~Q0Jh%(& zpEFECYoCn#5Q-zBQUCnLu!TPt>;&MNF39-zdoStOJj)~r&U`W6jnIE!MD0|UC!y)7 zXm1MsW+#epqjfiUFi=!(%PE!v4T>6Hbgk57gE-HQ=d<-MVF#IWM_t;oRIzdyNxNI= z<=q^aw4-v__dz!G<(w;PrIB*UK;UwhS%$*N9MzwKCSX>lDA5F1PE{|TBfCu;!c@Zk z#pyo*lvcRfecmHDxLykDP(8K~f|tdMs_T7UR508q?&1u}07dE7$QDKUe#ER0ULea} zU%$Y8_&(A-jtKMY^B5YWadZ89i0X*Jo}nfpfz|kJT?|TeWE7Oh7S6N*FPK{1kTFkPjsNpn++}-c*$Nvo8Tz-8EjyZT zzEzj;uv?En|8X?ulCrf$dKetMVGx(739#c38**;z%9jkr=@Du!Z0rl^$=+Sw*FDE% z&D=vCK{jK^e}wzFH;q^Qk*DKhTr*8~ZwmSs>sB-u*UtJ7?kHa2P}2XTNLtgrGVLn8 z9DMHISw8I~7rTGhK;sw7!=BOl55c0*hVU<_wx^T%~fvt0VFE?h%S!)0f{1PYb&CAmi^GIYax2Y-gQj{V(K}b&H|c$&0W_3{x#V4M<6qT+W#dSng7qmaO1c4Us{o=qxU7+2=Mk{3I1@Izu>DD z{&7ZuY5B1_XAAtOy`4>Dj~tg=B$^jZy-|5&WZc4E$+tNFJwDiUiKuDMPtgIBWc^+A zw{Ei7d;>;Hyl8_73Gi#B$5j;A$zm#_Dz7fy_+Q8R^@IV|J_IV{$vFfin8aL0!hf{~& zedXkkI3-7uXS_X?m^yMsavcaRe3Ux=D<;LLIq)7iQJgy0rpbevAnP`JjbP9S<(LEjjCGvyY)3&OV6p z`|j*n#YP;VVIncJZ@6|~kO3c&Q(o7`=rhs&Yp1=zM$j`xV7!(hk}UNEg*IaBqa}6= zg4;hDWNgX3zKE7g{4%#iG}u94AN{*kNc>Obg8pfVW;g62aC*>_Kd5R`H@h3prs$JV z_oitiIId62?nUn<_r6_76prjoP8aU6FWNlN-!N89`RP*Z5S1*Fbs|-y{zoM^0t6SB z^hzOGi8n?vKmHM^D6~b($@`Um^@6W>r4T76Cd>J+kLPS$(?6M8WWvycJgUE03#`&; z4fC-_R zE!W8kmWM||?|=Vs`Jo<;m@qu92}=Hy|MMp{ksCLTFiwoLPFqn(Zt4H(rUwPo8anB< zhG%nZ)0xxhGY-~pg|2BlCf5!;eewOm#M(^EjYc-*OB=z@wfB>K$@l9V_>`+vb1N zf`Gdnfsw&}#dTdRo1GK@79FD}9$KNncsamdDia4g4|Ps})jZT%dWA+XhB5d>z(2t~ z8~<6Gu1eeEe}j2sHi8U#nMlMd#9Q|SrSD1;vR4EJv`A42ipvDE!+0sEkhzeE1~)t- z96;N}i1>b?P1)9=Xe@g)~ar~_h8OaG`?9Z8SXXl5FM=U~5< zl^4SGJnb{a6?Dt0G>O`;(X|_#nh3Ppxcy#JA)L<1^ucl7Ul-22YqMVNu{dP@ErP3GDqqSrlSe6@D8oOkj=B3HC+)IYEn_+DVj8&7Ee@}yp$Vz>9iwiE$ z20JnEp5ixlUp67(S4&VL4wDg6vv`{lhaH4{+Bl^G-u0H#WA(L)4GSPeqp9E?MNB2u z>?P%X$xzG6*_j3d!i2cDjf3&Q6xw+L6Pjz z)H8|n*ci=j)%Ck7pW|n-wKs_CXMnTBxtXD9#fI9 z1G6@aoD#$>Fsx}(CL#hEZYat`mFI~NHHn;WVnE$w;PVXvfr!r+2_TgDkpbZ!9*Z?Y z{{Y>0n+877`8)GdEyWZyz;iPMZFK#?{M<<7ix60O@B_qq4P#?=eg5h68?SxJ2ead% z!w{nu?H^FYHQ?a4cqW==Xqii3l1V0gY(lwK)Enkh>)BMcpE-OyT{VuMPR4atns%7Zgj4XD%RTLa!TUSh z{OXup>pXuYZ1FV0-s`>V((H*K{MTXS5>eU{EZ#2t>jh;;Yf^Wm32fXX$E=i2B(^W&N3E&@hm9$Ry$sW7<{Lx z_-CP)!ek;cRUJ!jbwfU{ir7}Q0SDpS(r4u;&l3^--?Xv-EP5#TkxHNI_gmkIvGEe3g_ZP ze*MXcr1Z|7o(&JDb_s2WTPb&xe6b7|Tb@k@GUAKHdhrc{-oEPT-|*xe@X^hNN6b+> ziQ9f;PNLIjzS~kL8_;NBdW`lArj4y_$87TJe-icLd1^=xP9`SAR%PPZiwnz+k5617 z?+_z#0$f67O0gglR-G?$sYv{KJ@aEGFzeTd5%4i}$BxHF=A63d%o!tRe}KU0ceQ() z@;>!kWk*HbCv4X59ZB7@dsf^Qi%-Ymd%@#BANbJNd)MB(8V7z__*tKSWd4yl+@?K5 z9ns_nt`wocY$=8?nCg0wTSeg4E7*%RT1tU;r;VFr6n3E z=3~?ExL6AEV{r{dVqxBM2@|mxcIA5RSq2jA?+11CFPd%*YPM)vxBsr|yp?C_JZ}R9 z;Z~c&ihE2ygw>xv-KqMZ_pCYbMW^%Ryc0I?Hk{Z2#?qmnyWkFoJZ?`o^hrm8d+8wh zmU!g$|I}yi`)N5=FStvV?B*Kr57Arqd60&;Xg7I;sm7Izr7S3g{ew}#QnlhA3;zjZ z1t&6^W4ZUS{q}L7jo1~*Z-2eZI~+$%@_DAbCr2#BiWv(A5_Zz> zQkV}KFk38mlf*{gcCb`tXe;v;&TM49Wq;}(MF*!5aNvJrMQce|B#9sY;dqEyU6O3NZfDMs$Je(DuvRVa%-*QYgPCo{C$v2K zx7(7MKQ_FG34y`5(AQzY-8Q=w!>y5?ySfaWxAHnVAM5rYNPE4L*P3cG@~-NMSW(0r_SVM1yK&dc`o&MKqEy|G=yCKYKHQF!>)MNUp z<=Az8r`~UOdMrQlF6DK%p418RE^O`QUD|25zvs)2_HIER+$ZR6?|8EZ{Bb+G>qr)) zR*GbNF<&4O@C|&xNb#OZ=o!nFjB5s@%3?l0K^ZpIPt~zM?+1p2zwhSz5x!r3fql=m zy-cum^S3GUm%nt-%RjO|0{b)7v8U{3?4%{i8HhH#XJj3QETePN*>RSV$R0j%*=K=g z3T0gTBSczz{^1dPBmW}+e~x~F!T4R5LhvOJJP$*s!r+K+75!(%HRJfFicCeOMv+_K zkj{TbQ49pPw$7R7_TSdHH^298&olF`{I0uk0PMdG?mh@Eyv=Ss!UDmSHlC2pCH(ar z9eeQCd?V#x&G+i|j!ooiq3JTHlHiTPmV_kC`U5#yac zN_yu=3v1b$bKMR zJ9EniclUclz@Np+SR7QDYJ5?7G;wmeu%3S53R9uLNIpEMyg-4zf}v;k4r}3F`fM!v zbYI+(RF_6eck_Jy)A5+yy)+ERge|bm?K5GIMG0%hV|2 zN4~Pml-HKVDKdV<)nk=uV9(oe_!y&mb}wsW!o&1%_x)P}?&*fhH|8$&P{WG@OTtTN z3|hP_CLD8&y)RL4Bz;H9l*%1UrYGmUx?;}kq|;*)Eg80LNJYaozhnN*io#j z(9!VGW`6U&@V?N#$c#7ZBb9>%cV5Zf_)Z3VvdgZR zzx>L}t^~c8Upjx;AAaUuYaj@I_>S1lYIlG@U*7r5&ZkR9 zR$@n10r$1iw6Zj{mgAJbH?jNUZ41nCx?(-T*qm>~-v9;gkG>?mV129e>w;mBjE*3! zO5=ZgH@3F*ydlhh)KD311B`-K6NW(+d~>{Te7NA0f9H+Zdz;&RgxkZXlH^%rGYz+4 z>%L+(OHiPZ&Pz1{%k*KtnI@Ei4Y!-Q+*w-{Imr7Kf64=WTI7Cm{Cxx74(#+|Tc{^< z;ge4f41OZH-HpwJuSvvb(E-8WyDhqCx!6c1=^#CnDTLwP6(1BsYkO)sdwcHE+&ENR zNIy~Cx~1}Da&&~gN1N^bRE7MxaR9kFSR&icJpV@^cCbV&5zc-nc@nFl zl=P17-qt@)KA@dEtmccG(JP7p}gcMnf`&pisMs$ zgdX^=v#0Iy(bWndX*)M=zHp?Vvr1Eb5h}$}R7u!7i+Q$E6fACvM4-H!^8Ri*9VWw` zagQ(Tuv>A`9^&8IW$O8Iur7MC$K3t+K*&u5ZT6T~nfb@>)m@{I^(o(K>FKvqP>k@| zu?u+KGeRq$?tQYOf4!xhod6e(EJAzGs*w7e4OJhh90?5xe;heEH*NXa+)Flz0K=N?*L*@3hUNA2xxAF>~@U@4x$e9YVhS1j%BNI{Rz`}hMQDJylU z>Gx+qFXmE}RvYOU$bb|3{Oak+sc*Cc)ZM@b&6dYzJR5e#stag-fWhb?qVM3cie) z&@ru92{RV(d_3So@J>5RyYbSn(;tLGbRYDcg4NIsC~YxC;TCbtT#5jk()wH+Py}>8~Mt!LG!ZyUljJ z6D}{3Y}Y$MXSPK}5qk2#VBM?Q0_JYB-Q20aMvoWl$`Et6*{*knks*kpUTUx! z4b0tUySdZ+N`DRG3wA|j?l#-?PRa;QXg?!18&~%Q(Cu!4#dzWg1K02fMMG$W7LbLe zS|QBcX1lple~qCQs+C~wHrw@1G?}|#A9ywsY>9h5@n0M@y7K{V9->h7=7;b6& zfFryc?YddHXc6@;>;rH=86}i7^Az}J=47G8#-e^U=z#?DSOjc1KSwN*jtOIyD@@U}llYi2*2jHVtiU-getzwN7%a zpM}^cpQK=!XP}<6-sSJ`DLRY1t z@)5u4X{wUeFV3l>%_N#Ml4KN3j9jyb#_hSr(bBpnj;=Ektt1&)D3jLF8ZFoGXZ|lt zt-VUuvO=7gV2$cY>G+!y0 zVh2q|?F&stU76$=U51fc&??eScZUa1cWRZb)pws$Ere{HUI$=TB%FnKq$2?$S*}$9 zXsH&F?p`dP4e4%}QOo*_qACoz{~_Huw5KU;P>r?xWEO9^X57H;q8^G{Ng~HsD)gpn!>c!hX?}xH6Cd=MlU#a0|PE7fkQpm0KN>%Y>24+u_1=<-iA1X zmDPlQLxyNM8#1YcDE`<7K2)cfp$q~C4p*pF`CQeR2ryh#m5a-&R;fTK;+aIVD3{El zT7`PGiWT$MPq8B9>Q&29tTuG-tZLAL1qiz3Bhl$p390%LTa^Vwjk>%>1L@=Se8_z< zVi~>BN2aATRXXla#iCZ!rlpzYn9E~4BJ+HyGA2SN=u-_xhew*?g~O#)N#&}{R|Omw zvW8V)i7kp)79uWEyxzg0Qef04?cePKGr!KgozAuWUyuR}PUW(T3bZP;%Y0tcig^2< zMzKq-xXOo?FF)5@cf(Eo0t5z!Ai+xL+;Ur~yB64Oj~==&vF%%!aQEC-=E23hT}SEE zQ|zU?qgSCuwPtbRStYPbG_>lsm0^-3GctLoPJ>4E`h|f0Qo;bUR87*1Fv@5nwHRZs zbgyK{lx2o&ImQ}qf^l-?8DO9;#|(1HX=|({CLz60=w8RWm4t~%#6oODAr9gq9^xYh zB#4mBhlnEf(Wgivjboe$IdCVp17QQD_0F1xhe@P24z9uS_0oqR&i2_a+BxSPxUBuB zWSi|yIEjIZi^r7IbL{Vt{6`|H*$!E(k2rTsaWgC%~Rz;2+G7Ob9C0Ob?Bj|UOH2~ zPLR#f;e~{xPT(}Jz+&q3RPj$&Z!P2g1asGHG@MhdH!$}nw|jSP%=Y_5i^(6<;JmQg z&Q+S7bT`%yWefSU$aBn}3!h{DaCA4z_IZH${IJ&lF3)zGstc-A`K(+$cL@D`u5PMV e6+PQ??%K4q(o$dz>gHCa>wb>6DR4+)jhow z+YAmy1wt4YTv|YZz;Ljmy^7`Rt5x`EgGSNO+HQG9cj|eBX+#@pJ_N`9j&Dzt*ZRS( zy9B=E3~+}P(e6#E-{1dFXa0R7IqqucR<~EMp|h!Qlv9%tkt2sezdlv^8n8@JR;l9ux8|4qmHTUe-weyl?6Qh*Q_Bia zuO@|^s!A%An;zg^;w*Eglm9#Uuz+gMN;CqH)u^>2+=7*>gj`6sKrVv)j6vv%1o1g@ zqyA-LS=;-G4IxNaf)x=fBA@N~Qni3^D+rK9O5buBCjIwgOs)OLtj!V*A%bjY|1%@N zS^!Lk!A<%E^VN5lAAUeEJOoRK6)T91U`VJWNQy#8v0_N6a!92XNUJuO3obw|xeS>w z0h#m(?yDbg2ryNIweXR9@gQ(qo!yuM(ltey3g9CE)IS7Bqs9Bu0!dfqb|d z=g69Zm=N+PuYs(}uOP>BJ;+49m*FBy=OpkS1}X++CIt4t9c~%`e5b$Idr$Y9zcJ9k z&?-6z{aTvxiGHgldia-|$-y>@e@s*?=TL>%tIXPskNFxc!xV<&DnpV~dfGkL1apCGBT2S+A zp3S-0HdT`XW?;y4;cy6g-U+lp`HQ@$7$t~^e8{UjfqQVnyv)IqYG-pIM{^*%vZWXG zkRFiXth}W|3+BUs-W{$ot*s#SsY*%mx_wtuBN<6bOu`cA-~;ZaBY}0HBH{>pLiD+s z9u@809}*|9|NQAt0LeI2XMW^6zUGTQ?a_RWN4!t412)0hoaJ8Zxel_OZgwpqD_rCp zr-c$i;(_1;?uH{LVo^6=;SdMd+pe~^+WnTBva$K*0Za~y=^tDz`b{9AH+rTAx@8_5 zv!?!0TIWIjjMwV-D;O5)GWEyN&mzEcVJ)E+0Ju=@i@=x4d#9?%*BZ&$>wE>|-NW0& zCP69c%r6T?K}2PRRBU{WHk>E{Y~=PEq$x+St!;t@A>3w3NZR~jlkWVsq9ZS=FP%0a zk;Qz#IYzLgnxptO5tj)g7@>K^Fo!wJDGrcBCPvjlk-*zpOk61QwG3Zz5nn3rU4f`d z)h|^Rq5>ek_-*tM;K2hN4lxK9=HiDk+*`TLX*&uyFc~ZqP{aBt-{ z2jnA%D?(-Yc4+4q2R;F{`W8rBSQgdhYV2w_|dEJG&K zS}%D3Os7ivc1)Z2T4rP&NyB|OgnKKuIjG1B4a{u^01lK0Kn${wUCLXKYr$l_BIZl^ zP=Y6Cwp66d7;;MA+mvqtmvC5(>4@%_%!I|22p6_zvK3=$Y1#!=LZyiC;!+H@H`EVT z*AHs{fGlzWJXqL50QXV^bcUe7VzBPJOv3~|COqAE?{`!T9`)-?2YU~ot9OawaXE@h z71MQ3#*PT#Voeo?vI46-n~-7XKI80S+458;{V}Cemd68&6N!rb zal$ohy-l{+?XVF?9CgfbqfWp`5!m=R)au~kL9~=kRX~S;z*k zI9DOL3BkQqiw93MULVZmZ8}{u1k)2Lvc)KbE&7A?q)A7YA&Wq^q7{n~fl|>^CIaQ6 zud zj|Hf8_hma1f0SvV=>=B->>veGJ90M^>>=DF&N@fEd%%}sC?kEZ@?Y%89=A5%qb%j~?})qr3xR2=P84pw`pEr2{joOrdQb_J2S zVp)vrZF%{you1+wX~1-K&-R8)(LFGKd8T;PWYp%PhQDof5GH3(PEQaO0-@_IWu6=S zChM`yM(J+ zZZ0b;#k8_J%$Lk=FaLehv+o8DU?JOw(A!7v-{~C;F@Zv1zDG8*;gr63|E9*^Ef{b} zERJN!1X8z3w}sM}iL;Y8xWKqd`HUz_FEz4TBqgl6F z;vMD2PIVkKmvAGP3ooMNE=$_PS(B~2k@uDjB3vzzO~~vQP1tnj7tPxH`gwBjyBQW^ z6tsEb+clX8cTPzCu9+k!pXbK7yA&D2E***mtJ_g=_kGuIPm9Y4q*?oIL3A6A!F=lU zqMo_#{}I#nZBc0J;KU!76W80p31G^UOEhCMIGCY6KkZ?b`|QvSS>}dlGjVgHw3qoL zs}HSMS*rVm-)L*v-=B|_Ah)xh^@4fwBjqNtt#Ze;vT0-N8L!R_h;`cMVCULtE43}C zlZJ5X1M8F}+aA;&k&#E?JSP+5*Axyym7rNlV7~Dj$ z%S1v_oK^<`svR8z zU`{>@rwf>eg~Y~5ZsOw-9LF~xwg~m8PxQrJx#7H{HW)Hrt9p4-?v$RteKYfhkJ4ww zhAVeb>NGr9eG!aVK}8jaU{>O}7WUKns%cRT$-3xAaVId*InU#u)bt}?{!BNqS z*Wk%T77M>uGOACi?_A+tL<}k#7mBS3hGiH|kt+N4On79l4F@I)0k|Uz6R}Q1F2JK# zuY2+t)eI#upfB_L32H;f$>GZw4VSqt%m;srXb!GQ8Z<13_c)FMZ zKb0)1xQBIR{o1Qp();MWSlCaDy`C3?H$|u^856yTFhqNtD|(O<#BlEdqa0}xMDgZG z1!3satxvz12F!x#Dyo@&at42syL`5p3;5Y>S>Fp$Fz+3tX;Lv-3=3*a~06&*|d~dJ7u8~ffaj>={{-G)W zw1>7^jnE(f`r;FyCPLUS9}WqT3*&YZ8u)W(u;HfNA^#VP73)!-_ai?E!U>#(OIGtK zW6F#YQ*z3Ia;E~QYa+2|k!Vn?D;E6!Z&XXMsu&-y0$$Inh|mlhrR z3>Y$E#vDaVLdJ>>TXyU@aOK9G2QLThamWKd`GdDWGXx72C0dMF$x@}skSSZSQf10j zs#2$3gGMd(d*^_A&fDUGD=wPw-gCcuq20LqM!fW=*X~+u&@Z0Z?pKR+IAx_}R@sJ$ zhK|I*!p0+@OAntZVONh!%$u%vK zJ=*D$tGqPr9#wgerlrJ^*Zbm7HZ@<$Q7WaK(qyWYU5Tki((|*yKZH#{{xk%Jn+?x) zuw)9@5Cl>HtuP$KL4=*|T5_(9POgwh+EXa=&<^^a9ux6P!6Nb`1LhfeK|^=!_2XL{ z@PMdkMGjT8VP{h_6K4ostIbGg)~U*>tqx)^bB+WW5T(3=>iQym9|Xi9sB)A$418WS z@gaDRnj+FPrb^WaHO0Vl0$WVETUyi2iF94E$wSOEF`5?cc||qH+N&A!uaVF%vATw} zIH4mfbO+4z=ggLeBsgwK3zw4BA$9BjCbY*VJNJIIcQkPr%i2fM=}1LBr*9%XGQqn` zOS6~z>v<5WBBypBsa-(#iLeE+4er?e(=F!8vQ!!ogfrausCb=k32&F90H?eXGNt6(~AR5Gh(dKp~zyfxJhob&CKNzn}r40 zps6@CtT@J4Wi#_qJe3_IoO&UJ4rd&wj_82ZsXbG*Y1;9WBh~$|F6)`#{O_JWoK;s^ zw7eD!T{gQ6I%y@nC0;!#%Xs1~MMm&$6td8&p1q0fiV-1>ZxD2pd9ih=PD}cNb6vZy zEK=`IHfjeof?#`S>NGvmQAt-xylY80gbvI(-N6LSEZ507X}+M+f)2uwMKt>=2d{g% zl7XWNy4j~%CF`r4fh$fq<27rhWkKhelrbCXp6Kx#)S<5RBrfOxJYR z0L9O<2di9jVyHKlEfpBMnry)a%jrt)1jxccd+aS^oHj#i^@p~lg@_v}#X-AYLdf|C za~Yo}K1=gX2JqMMTU zwm$0bb-TxAF>M|dM2)q?sA`{$v{q>sntB}K+OE*917JflCQY!9o2EK)t?;X7*fS%k z{IXEU4m3B1(1`lmMQF&+J=F2tb6(mGxr9bb()1Ooj0jk>$b1~m@Sf?CdY(3~VXRFW zYW%GRIB;Z4VCYKR?b)nbs{<{~rD#>*-E2|^yTskW@1wNG{d7%y>$uq6e8zoaR32f^ zS`)wpdjus1OXT>kT3;8;Su)ctnU1SRb9Y-bshTtrF`rhIlUt{$ zjntmE8Wk4I@%dWjRf#k)#6{B5NR2Ivc$d2Y)>{e(vPj(R^09U197z{N7E+}+dK@!C zwuvm&0P&$M)i47dIA9Jj%N~gef)&qLeZCW!c=b4P6k`KaVb9fGW5yca-_;?B7+YkX z&{MdY4&bvGOu815iiS|Ak2HIC?@eS9O=BfyE_vFnn|wQ^m*-OtKxux@m`ynddt|!+ z0Ci-c=#d*{y5iEwDs_)FP_bApI{*WKp;V14UmG1wOG_^1<^%y-b+%F0A$pSl;|Yv% zzU-xcAco6o$dM*x~!|Ibei59b~YnXW>o=A8)-Bq)$xz z+>_`D6;)_eEx3j~s)4IYyPL>x4u9bP~*JfcWF~Rzr-nsnKmBdG6mr58%qe$A(aOG(%CoWWuOI<1+xe$AEgLlmYpH+H)c5hPNGtMs_TYM8eElSIZ zGE!l7e>K?hnM?rK$DT_@kqC@PR4GzdK@=~QjRyIJvdS7>f z8x^8O5bFJDRKK3l&9XWB=~aoeRug(Y`JA2|9MzAC{XY4YPcLiJCv-@_)QgK~JmQM2 zx+pllv2JU|cfmAbrq%#ozJ78o-Gd^?`Ql2CIWgYWN}m9j<6rj}zop+b3pc`~?(QM) ztm9X=-=<7@JrkG((E4E@;bjIVcf{kTV#*2r9<0-jQ)eROl~MII= zI;qu6*+~?0sj52rxcxqdHKLh-tzir4);6b5`vmBta9D~dsSeL5RR*li&dCj%*j_ps zvA#V-=GPEtzLyv2-E%Nnv%b*LeW2s}j|DSqUiTB;(YW#HFMuCs(JT(>b}y?S&DS6N z&NkP%+Xc7JT)97Bp!q>w*jAYPP(^X-rMZP0fq(1wf&;H73KBz2@QgBJ8dtySDcdBh zsai-aW>Zh;91+Lv#*I2G9D{%Ix$!`GmY5mz`xbxM!Ai}##nT$3Bp^=#U|KI z$#}}AY$U40$GifC*ZfS~M5yC*T8jq3??WR|zcc*Yn&fM;&)LTv2@m z#f{kA4n6Lgsa1;9vc7a^rWpVFQ(dZ$+oninwPfE`0W3J<+PxW-G?)}1pEuVnHtWmo zd-k8?Q^<}BW+1l~kZ~4CN~8&0U0{P|g(pg+gN@ML)c8&DD7`_dbQgF=5_~0|<=YiLNUQ_z zC^b)b%KmcQRbOAXqNX&u4KLPR5B8X&@+$ZRQxF3;)J0{o5A&k^d&4_xj(M)+2L%$sc z?ZuayRs)XgpjPthRjpJq-vLgg#KX`LecI_k2LE3|%$u|zeNW#ERtN-w!9vs-s_FMy z*-XSd&iUsFL+%a6v|(58U|)g?&Y+~i^(P3!y~w~vtDyj=L-Cp7+UT0L)NnfFh)&kV zGn2!-Z4ky?Lsj8ZvA9|68}+xn;g@lJOvy&|TZ<45N6`N6h+<07Sh_b2@B676r-3$x zp8`Q^i{$UB_sj#8A(1ewp4SRvgji)!=STZR)v@2$DE}e*9OreTZ~zDrUPP~`CQA?*73Ll|7Hs4<(dAWmv9^++req;Bs)wV>>4W83+l~D{El~L)7_5n(RRRda- zxRoTJJl@?t5*O*bvB^p#PmXXlE5kW!sA_yV7Q?TV@YKJK`nVjyTBtaK$Pr9mIug-k zH7o|3u|2o_APIIpmy2CxuI9J~BJ5{u-K;N~1`^e3xTY1yq7n_lFwh{`#L+}`G8Qoi zd1{k93=?b&(n=D7ujHU_Y1$_I7&O!s&v@;mWdt8aN8g?=(10T7M2b35EIv}33a8-~7l&jHZ66fn6ySls5)vLa>7g$f+0s8K^r_>Fw~Val>ng0sqcuJ4Y%@3Z zJ(&NV#0}_|YX+}%bObSW7&7|Ky!lp9rb{Bb9BD^%5mIgmyel$OO@>2Rh$EO5^g=;hm1SEiXk`A{|s~?1btXPDzatW5yH9k()6a0$lZ5MI@(JP;V0`I3dn0MWyG5 zRD!R1gfuSW2+bI-mWf8PG8-`sz31{P*~{PxWkr5B7iAp#Q?2`C&LE}#Q9okdV!@_P zm<&td{X`~LAS*q>b?yZ&xJ>B!k}!y%vTrqY zM3(5(tJ4)3hU?QJc?7mlL%hx0t-nO;46+_;Di9EYhg?qoawHc!cjnv!xd(oruMvjj z{9q|1UJx*lCbnx>%YI>)t2sJh_F1?zBe+o8c~8^+nYIH}3s3!k!OP2q8@B{%<#o@z zN2b|oDcv}4%Qi#&&i%P1z9j|5|GoQ{&iTvOdiEXuW%S>!-PdRL!;a^&=_Ba|eYY%R z=5xvykLLp_5@x%o8rJuFX*IhlQ6FgN9$%l?s}hCVGWP(MX3<@XYLCbfjEs~HXAi!8 z`Y0f?IYkh+xc+T%NoW>%oiSSzq-^jcA&>+r=lAoCauuI@NTrkS5N=CtOxamrSgWz> zUJ+OyxJCQKraT;_652iMv^7g!95;gFsfm6?; zb=BJp;7v-=T{U6-VMdFFxlC1K0ajuW(mWKDielKVUDkppE=qJ51ivg2aG!QKYV!VU ze6b1S5f}BoSJ8&rC6&S(>yrIfAI0VS8GbgpTOw=}aM znZlFh83;p;r|`%i7SBwyt#JwEah2*oWelsPE)mR=DH$hOCkPf8T-I(uN-g_|!+^{Z zHK8&~AO`0nM|h5sw3QMt$rbYm(Irk97ubi{CPPLlPa+(>CRLMJE#|UJmDUd$71_Mp zL?|<2hicKq<{gJXrBNs4NMd7P=Gvq%I0?HiOaNKL>09tz)~CDCMoX?2?TN!ii&7;w zsD+&xQPbJ~Iq!Mg0vM$C8SBZd1{j7h4371lEKqhG&)4a=Mt_>kyhYKmb+ex~za!ck zgE346D~ww4HpOq?4z{v8XDj!K|C5AXfjhD4+%;(XYEW>V*`$nhTDD5I5gpX6g^nv< z41_;yv1#pyl&P>9G%66^rOig4I__q|g->%FdJ>@QJO?c4?SF3qJtHy}Vk(V9gQk!^Mzf+CRu6~Y=ys6>u+M!Kt!GnBGXJ2XpMi#2j~r3^`49ZG?4sX{fl?xlPtVI-ofj*9c*ksWL^ zdtpRe5QBk=2@BIsmTX&5-bDaUEX&K@Bwb$Dc}?Cmfc0sApH2^ME-%nKY1Gv%E>_NP zJz%ijmC?HOl&$lcl@zy23RvzI2((rQYU0rJiK+)IvQ*A|%a-OF&7rR&`BQaZ735TW ztBRVEe|476-s8_!$Mam!Ij*_ps|#388PMt8AdlY{OrogCf|);w=JTLH*RmBe zS&eA2C&1_T1QJH0*@T&n2D>20-?eNu3igHww>uPexq@N0J4m%+EFHUZZ81I?a0H^kRhVyeQf=9cUz@^W-T6~=*|8k`almAh zZpoih@K<4H(NT!sOTny#u`?c1N*M zyd`!EdP{WQ8|jV9wSxVR)wVx30dKbclz0FAn)~0b8dx?z&wC5%s|N>%mQBabK39YV zSk63maIt;X*=!;B=UOwB+MM-|_Ahf^-Z)0d2fRm*Wgj5;RRYU}i3cWHljZ*$*5{#zrjn5S*f(%)cG+!l=~SvNzmFH1LS z^9*it$|x37wr>J|MqbME5!b7#{EAY_;EY*y*l6f&3xM+5w;$%l4wy8%vZBj zC6Dzji5lrkMvi?)s7BfmqmPB$g>Z4JI#M}v7jLaqlQZmGwMGW@H2DYw17F^ zGpS+6ADoeWDzQ@&Fyp$=kH^jQ;jwoR@-tKB2ogB%jl|r7{wHrqGgfs4Z|m(M*jELm z{DG%(swa}qL}bSNw@Ffap0E!coTpoF-nEL}Zg=WsgF?n6$I9p%1j8!SpJd7S+1Z$v zEn*v5=})ZPx-6NXkL`^5&7JhiZtRCzPVvNZtIM{5U8mlnCtY1`e6@dXXTNZOMfVOL zxVCleq2X&RDKp9wf_2A7%VemmCfnjl$DK-NI^hER!7IIX{cX(FWwEf|n_6bxZ0Zof zSi=`=s`qb+=Krf?GWUD7ykjUwoc(>?G@2f=uz|mg`rTpC+J6+(`qqtwj#sKR4|Eu9 zPI9Qgm9Y>Xs7(KOjb$N7{cfDT>HsC?b{pC+{+z-z~sp`sWMwIei$Fju@UdVKI0ddc23uE@?M(@;~qw zqn*QM#Cd9LVhRjhrb>c<*xYsfyfE=bww9JQpUv^9?(^WQ?{Z)ZPcHm3&}B^Jra`@G z5cT+Zp7hIq8oopnok$=5cj6rY-fiDs>G;{F?gy+z07jo8l1uKa`PrN_r_3+i z4ubjFoYYe=I{qgE=4W$KPl2j#4;yS!i6`V}Sveo(XLHh=(qB^tvvMxX&*r3_NFnGVeMQ?atGSy^`Iwxv!xq3p0>_Qe3hvp zHo$Opg=V&yZDtQ;*R)twSa0;Q8$21=EMz# z;Fg91Aa>S~wYWU4GN^5fo0M#ro2Cq!^Wa>a7pobXKiI6k)Rcue0H^&I#zh8g81G-0 zdU{CrLU+-UE~$eNE)k2vqK!Rj#em^BghTfi=+^s@Xl@UC94;dcj(;TDe zKkIF(V(d#-mCY!}-V^_&!Wi|&>C#!$&?1BXVvV6V5U8iWR}yVV7FMZ1 zgL8@oJd}t}^9heqveO-Hb-QjydyeER?y-LML4PTa(Pk4Y?yValuH`6F-a+9$g*b%D z$YY8WKe}*eb$NbM-_n-jEj#xQbZF`h-KTruq0HF#jWF{Xj2MdF6#!|_FJ~U{+slDZ)j%#Mi>AD{x`+$XCvXqM*{K^@i%_4mr&<)6Zr(>rB;XQ zX8{6oE4C>;Je~6o7-T5{fLMAm<*u}Z4iOOR94_+j7l(chV)#R-tb6CVsa?SqEFIgL z|3rI47l~enu37JPKkAG}H!t8rm%jkA%0WLHAhJ8%uHq5qG;D=x)rK+_pH!fR*ln7D zJl#;|MJ+}dua0PZ!hn(fFWM{qUxq8;Pfk=N@tp6CG_w$| zd|noz&T2CMGs%uqaUIeCm>Zhz^di1=?0^OG$BMwnPfH*{D2ZkYav|)${b)t5~H2_8cw&O9&JV%hL!DG}RgOOg46nNkU zx=fZ{R-$d-hw5kQzHZ~sT^iAYb#416L6%Vvk{TUE4!gqQSR6h(s0{YBF z35H=5_Vb;mKpsZGVK@W|s0SDB;HLxu{%XgdL0b#AFEGGF0UWBa0Js-?ZX%*GIT6G2 zWFpSnT0fzkNaH7MBAtRG<$W^1HMJJ1;?5>tBUg>$nbuB{H4QE6&t|0>g=|C$6vj`s zVj;4XD^aILzI;|<66C9~y0l6aD0ZybCawqSep(<}*PTj5u~ebfRj4mG zqBCXTMaN%&P!taz5JL+yTDHuZ;>uKPm}36_bHBzugDpD+?A>rvq1zVOYaa)n@1<{-=fc$;cNMu; z&bOeaDP96f_1^p?DwG>GU7$)cObSw^hDNnuAwq?@uU5SVb;5&y2$4a9DA5|lXwsrp zv-R5S7wd~S@e<6EC`r3cT{nI5gTz3 z7x9o5(v}XReu)<8@vGmWO#7#Xb%Dt|G zpa4O!c@P`{C4l~h-QO%U6|Z50D4dqII+*pqYC;n$7JkM|7;=>(C@FFoPzoyTIJai>L`GRJm;3jw zmr~ko19NkfN`;%}C$Wa-@_WT*<_qiE>Xr|1d?=DFf9(zl{J**E|HSi0%YOrkjx4?m+Ij2XS&EwD2Gxz_P)1A(oyuHiDP|S2PTa`@Jq7P$L2K zzrZLW_n-(#r~yw%K)mnnKp6D(;yrSQz#0yO+9VtZU?j>nW5g!{AcBbx7k)T(*Alpa zB`5$9L8^>`EXI(^lq}gmy;+$##0V42KnHnWFG0393J^z~0(n(#kRQtdQX(qa@MzRuS1I#@Mh zZ|$hcT07D!Q40ZOFoPyxs$lh$vmDh}rqY!XAO;?>;F-#pU!fniDx`cVi;3a5(L(Zg zK_q{{7pGaec7DzG&-=T)$fMlJwOmT=7%|*Ao1@vED!?|_NUJf~(ps4LnUh%hhGiHq zM*Q)lBQ3Eq7(wgG352C^!V)~DgosH-IGC|F+Q>r_BuDUn@Q34}N$mE|ZuFaE`b|H` zFZV595wOE~I!O~9_FnJu7O(e8v|jA;lzKE8b=Y2TLz`;@c%OHR4OUy~1V=g4ygB38W&@Yfb0ZL?dTN8~wM*wIh;Y_5E z@hYEOPUN6(g*EGUCuy7(EfoE$l0^XpJ zbi_*6Ih>}#IF2rB)!S)Uh?hMVj0^&;90N+Pr?k)XwXG}p2^5PR8?EB1SOrB6k0W(Y z@e8rUxpUSPB9)*c^rVF(CLOT9eb=t!`h@gi2GXxPk*ZQ{uY-;`=e!GQ)oWmP31&o+ zP-76QAjLvtponTHcrip@@WKgLupps291MwMSQH~9Fj^-@GRMkvmWa}2YAK6el=|pL zWq=`0+rT7}rdG+7M<8DzjUppdj|9^g%~ZxPjj>E?e5}v}Q;1AujA=}4daT?Gvxv-Y z&BBDL7`B&T2N~&DEajY9h@BBGJ+A~j_zfhOw@-^er9J3vA0{fJ>*6^yI`wUUrS!U~ zXt`p+5#dIF8iX5NP@-i#qr{%Zy=d&VrRosMK%??gs%VEP;avO3zEO!eXVRc-L@AEG zx+#DtndrAQJp`Ah>Rxo^&~jI~DUf2wh#xJkUqQqiq?cyAV;0Nxa9zAN1gun7EU#h! z8Q7?nQ2nU!!0t(j^iqA|My637o(Mpps=95-l7_yMNYRn4S7xfD5oltvU4@b;WI$JI zS*JQWH%z4Hs9Gsh(p!i0vD98Gk5GSBZ#An5N(zQ;MUvH8#RlDuFe_%$w)(?2ee}c7 z366?7sM?l~-wiuyVnR}p!5RrB+e(!Qq6SsGRiUHM=2>M3epW!@-8R&j8*b3 zD){VQ$D$`vFJeek6(1`n#AP$fYcg(yNX z(x^ZQDv>58aT26#2%f>vzK~$?dLD(A=@6bs^XyV?Z4XsVC{`!|Ve-DtgLX<#+s%t; zGs?IdK5LRkUc(Q|1Q9N_&J9cNSL?71-59mWO2^NUL%N-KFlr4)ZLD&QB-EZm&R-1< z5;LDA8A>i7d>(A;(oX1lnCmUoCN=c<_tr{L&@I(lT!ZA$x4Z|d(phpq`U9c5qZ z*I8^M<Z*=HT&|wE_SjR3}DokSNuz(H=SVTr2V;Eo#eXq)d& z0xV`hmNL#VW>m$rs+q=Krge+ z4o2ID>-KCmcda9$y#mNPS>eAVx&E8IXt~NV!$Z;sir|=mCkSaPeN5<%#Zk!cSeShkvSMW-|*S zDUq&N+G8qDy^BTiDaJ~lUR0?82}_=czzCs^`s7@-PslOE`z;I1isedYV-dpy5h^#| zB8x4t)G}B_w=L;P7~4cD@7TglTkH!(>k*ekK1gn!_(>(QzSw&iC!}ZVp2~~24ySrxqNo-?NA>W@lOFB15nH!B?G=* z7sxF}x`H6RGK7Ex;;$B%V&xx`^p}YJlz*%qrba_LPw0?f2CR*7a-pcxr#r}ke0gh( zAu#u|&ou{@0zqv0iKn3SXAw+CZnWxdgkzRU=B_m{mcUL=+c_;%;?^xMKr-;9MBAY@ zx4BzDn;p&X16={W*zzJv2moe8SEQg82DvB#jKv72Am3oEFX)hKlxyWnq8)_-3q76~ z1}YoZm$?a*>4nKcR$tFS0UvfIz!knt?I0>404%I-fA$tQv$Phk3hWL_j-iL_mzJwfZD63cPDq3D-%Pv;J?4REp<(!*Bd9h#(0u5x7^)`Eq*B z!0}u(XXbk1Z&eKe>#7@SN`vPAf1_AV1hXpVub%8=6o^KMc2IHNobH}Sah*OQ)r$c) z0YG;F_`fjMpX*-(@b5$8UI6%du(7Q1bmOwdbBS3yY7UOj(DKjIaQ9SvBxt{Jaxc3B!-lXf|eI=KJ@%? z3<3q|C|ra{QKBX2#H_O}&N%L@SL*!1qPIT!8Nenm(Kg-Pd|8Wy$!zj;E=D@ndh=?HrwtX5sCm! zNKAsIp`@bb$5%T#az-8S+6xiv5tDc^V#P@mW}NQ2vg)au9(w6(h=B$fEK{0v8Tui^aN;4c9E2lxTjUW&k`@c^|I z@Hqf50KgsbrAy2?OECn}qU81^!IaT-QJZS9manw;H3QF!0oP!E^x2z1RNX5{MkpYv zBLZS6PxQ#uSM7FXnXAwSp^YF*Z$+!P^}}N=p#Taj1S?4-B zZXErB(K(UwTE8ZxDpmR5w^2-E9-z{cun`)~aCMp~`Q@k&>@l-}FdDpNVg-yF7pd11 z2^bz~1N|Efo{uW&itya_2V)o|Z5p@z6WxRtFLsLT%5{0tS~;9}Q$nzfzWfCgIc>gu z@V{dGv#6A`iTzCj-Oif!ffT}Rp{Nik@%+TlN+iC*a^zcpawCQj{u~+#t=AEAjR-29 z-TnQR^?&9P&(T{`-VqgzZg*Pr$j6&cSJS+RR9yu^_PcIu&mqpI<&Pu6ZBeU^{gayM!%uMxo8nffwFwoe&+5_W?-c`=WWqfPO0R>6Pzzz zMLI)WxC$eZln&sKX((D>9zkQy-B?1Y^PMAGE>i*)BnuY{n^tjzF;Esn^eeqga^kis zX<)}S@|szF+T62VG(JDS^%oVQloGNjE5k{Jw4|&(MNg+ykl7Ss3^gg#-L*uJf*W9k z)O-PGI+wg4?2cnJ*Raxq{m~RzaY@2Q!ele1)tM`|L9Xahf}~y1xUj+iNCo#_W@-43 zq%TGZ*kE1BLWfHwmoR8xfa&OS@^&)5H=C*(f{xC+QmcmvN=k$n%GDn3Qoy3~KSpc2 zX??t_OIK8=(`@nWfn}vIpb#0atFc-oM4dJ?`5YQK!UQkzt!SKOJ3a>v5syfB5I}ve zL;xvk(b&^5^ktF)BL^&WK|j|p*8}v#lq)1zkY%1z9}ZbwnsPlJr-@phq1W7)VOjBA z>NvR65^-ifOptmf1=ijmJ} zR19U58f`<~5vN7A(v#Zcf?`SBYvq#wQWmTE3sDM`B`ifs3XvREha~N`|`a?{*qcBei7>fxMT@biCNfEV(4U2Ngy|CpdUb%ShEZ=} z3Jo!1!9YMJpycjdDhyV)ayzSbM5AbQ#2KhVnWK*++kBa->Jj?gHKyVfAFSAk`YN?RHq{*BH+El0bID=sZ zP=rutef3R}8adt&)_lL`JC$T_V_8|fJH}daBj;;`tzoq~DJYc0Q5n68g?d|~Dgpu4 zB?%XYz>ywdC~9vYJzp&)W#Xk4-Vu0en_&Rua&d_r2v!-<5M$wZkDb4YI%7WJZR@ zw$_$f%PBYpG933}53)PZcrO4P>xJWnV>UG2R%GikR9~>|#HVCW$iuNp4L1j-to-5M zgLo4}4iyW&`X17nFZkr{%B{Q0ScN@*y3kYST!LJ!$Ce1{zmsrB$L!sTyNT1=wNC6vp$3=r&A-_#f>vrlla-K<{Yl9IzWXgr%>e@6EN9ur=oF#{Rj5ixY{M z8PZEf?-3Y3;u+XSNq?M%4^dHtEN;GRmd9fXlfSlKCLJ&$Vwn#13shFsah1Z#3v!k9 zDx_XEI;b=T!3=7_66(KD9Fmr>({OM z;o*o}q1Rom)~m(O-UMueh+SSe(Dl}ME8evFH?frtz8!!2=(RVg?={PhPwoxJ!)GVs z7l0(07GE>h9&gV|fR9l_3b}1Y-)2P8kPhL=6mZ|^b)&TMV^vB$KmYl-VOlTiACwtl zK$1)lw1mW5f}|xSKut1uU4T=l9;slU77=l3XmvD>LGpa!W-Y(JTdHgTKYj@*Yd%cB z-k9i$qc{#50fS0hU6oQ@Bf@fJ|jwQ;1X+$;z18h$XHfcS%^AmD|b{ zk-yYA3lkZI>T3k<{D*Pvx0UAp4&Y_mh))ae#aHch$K7XF#V7FJ9!H;VDdT#U@y(Yc z`z7tnDx08P$Zc`5gr{)_K`%Ph2;{={4&_?7y9`h(+F4h*m?=DQk? zE$UU^xT!)@XDrZC9Cll)t!bq2pbgsJ|5=E(hFumdEHne3O zpZ<550?R4=`gKr^8XFBGu%R--@ZgB-nx&WY_KdbhrGP82c*`~YiY_kCDM#q9ll$qU z(h`n(=|z7>W~W826f1QZF-`m}EEbzt<+lLX%D3k(GPgp>Qc)q1K1%bmntQ&* zOQ;JN`P9=S0G4U?g=RHkU$|PQ)A~5VHi=hO?euarGQve-3fZ9ix@26_@+Fx!z-#zo z-MGmE4)Uc6Sjlj(8X4V8HRl-^%%Pl2ZX(BE+z`RAZsqy;p}qAHhdze zwtaskxuZD>olUl1W-I%;HVG^_!Wo!iCFS=r{F3H(aEo?5924ODQo9uG-2FwWYUwY9 zXwU91Q*2O_1?9KPe;&h1I#+9Ldy8qb3!r^E#0;mYU9$bI2TcK{F*eMnT_M>>&(mB! z*I4#C&9&DfuU1aL$;7QJc5`)~l-*&k5-Bi8G~X{Zz|bXT8|4*9f|Z`{s)e9@or7O^ z$1#(dODisnzl8<`ifdX!L3^Ksg9lGldaCs$k#?B;9@otOh@^r3&uF8qCRp=Nnw+66 zqo`wQu0qC1M_>~w)M*+ok(WBegzP0OeBAGrR#`!iduuvOjXf>;@%&qbMn!4X(u^O^ zaT_&qTA9LMYj=P_l(VINS;_t4io$o%xkc9DhyOxo>TLr#gVv98i}fkuu|;i5loC&b zr`IdH+5A?C3B#DlNn0{-3SOY6z-T_U2_McFR(?O;89ek zzquL_5f+u03AW<|CSx8Pi~ z4V5ZNoO18au2W0Hq5+WvT>bmv*V(a;l0EDD=Kh(Tybb%zf}^*+%>>phD#ClQN^_^T zxe{T|oGw6~N3nuzRk{cdUQ@eL@!$)<8(Fz~!weqM?CmAeTkEOps#0A%4lz$E?DSP>o!~uGSSSu? zf9)mmg{pNnnUlk9lOWIulC5%*W17`riAojf-XFv?h1eD5>h!un))Pz`C4<_XrR8lW z()Y1IqOvmIqd(EmH9FsX{V@UB4zUv8Ix55zH}WgV)%q_|Gz=AyaWGiMP}|hPCmAk- zU>Qxy7l-~ML9XPB8$(Q;zw;0c!qG7VnvF)l&~Xs0lT|rOgh=;b8+?xh5oasGi>NL( z&mr1KdY5b~hZ*(jH6?hJx(60?%BV7lv!83w)&#jCm%K<(?e_6BHh!o=U3G*-8(?ZV zcNo<2`Bd=Fc2v6h#rADMfHOgFor-Cv&^b%f(y>ZD+Aq=Sg?xcp#70XagUa6@FTIh$ z2HL9YMOgxaS|SwdYs5aZEsvPnn4OV1fR8Y6+={T0GAftU^U5!Xst&zf6h2shE zqGof^{ygThzl48?Y^yW{7wlP)5=;Qktgc4r&(6zWvNGmZldmOgSZs>8CJd(GKShWai8EXvz6|{+``HF1Y>Ly5;xR0qf!ygP`}5fCU-v zN>xfksbU2_FV4(sIzpLtLS6Uc5oQi5Uy(7H_%4CFt91Jp|IDcGes}PP3cUM-k#;8* zv0=+_=OG*QXGTKKclU5rHietXYZR1KJlMm1aw|C_;eQ9PZ9P4Jx{I1hFCQW^dc9cu zaWrt^JBOIj(97+Gt9WB>Pf8H4Q(w@%n z+>gYSd83-U`rc{q!IvI)%K_*L#Y?#Y9cb~m9qO0_yO3B-A*xU)Vh_q5;zDefm>OjF zJ>S@2QUZ>=9Pl_;j?LsQK}>+x5IICOUN9^G1h?D*>PEUvsE%Iq2cD(Sh+JmMx6tVPT$uhdSvBg9 z>vE`6bIuQ1jj7?t)KavWPDNX!3c1CsRswTwpEUPXl@j}e!Kl&J)~Qs6x&n`KDv(}w zQSyZ~4ZFJLbs!yn5FZO()(ouTnY$_!;OK?4&+i-Tcw7u#S$F~ewfi>2(mf4+i^Sq@ zNb8=6`>)g|uDEmZegCJDPyO#7zec}We=UQ>)5qeSamSP+<`|UmoLyXnP447!oic=e zj%0)WM<0AU_BOca8TKFL9p!v$qV=e-X!sS|%wD8>e8xp8n_${yDOhNSf7w2#L6n^as6i_?OQI1_J2zt>`T^ z_ko~I?Z?Kl@&B}~dmuZ@(fBj_2k+@G#x5R44kNc0xxjwAA3@&N?+~)Ei__3$MWeuJ z51G;!lU}ZJW~Sw@DIQI6*~8{E@X&N4W>zBL*W{E}{L^C}F8gpAOuRNdaesO_rqyAH z8Co4ifl=5397M0d_F&hbIs6#^7FU;oClB;DkIt^*ecGdw;>~pc>Tt=M!9UjEdCK;w z6*nhO?$~~PV%yr-<4u+hJ-)R3s}$p_MA+QiY*Jb6trlkCBd?Z*|4`1)QfK2!_bDBE z%XO#UZNF)-H<(ZJ+VZanTbRoUbthI8mK4G=`iF&;*oCCdxaW%ie#+HBf~mft_J*=b zNZ$O&Hi;---`m_owm5AydAQTht|<--$>Hb%FQ<9^=1XZf0r{_{_t1F-?-r3tDvBZ( z-+FXU2M+p%Lj1icONytLH1!sm8(MR*eeNyKka0OBc&QPr z>+Eb}wsp?y`#Im=xVK@iuhVx8_5|NqlGA6&FAxhxwY}!yx~0k zd6pZ`1JueY#dyZT^uN8kfKpK}SXdY{;90sb?V)#$ za=8$tZESU}OfC?ll{$9+`7bekQ1my}~QKT5J9pteXIE#EQIRBSK|5 z1HpEHs>PFMY><2Xu$XOSbzlQ4)wQh30X9w>Mw}Y|S8%<$>}&Ii4O)epwsuAtZ}rBR zo5s0TBYeTC0ITw*7*#o|5_I4<1gd?*GZ1$D*bUnIT=kOaFIdQ7?!88fKU>)t?vE`& zuUvC0M{~37Z`u~`!Tjl{c)+VN@gHl13*VZt3&5k^VI>v-tojZKd^zX1+0bZR*nGz- zvA|XfX0mfL`uYMbMx?X$#$32FVQmU`F3tP+=B1vCJ()^Oy|Tj=nGKEBg;^zLT^UQ-6uxtV#}GH+FR7w4S`Z70lX|bdh}O{pFg^8M#c zeqtt=t4x_W+_cSa2M)ebX=QACI*z&%TRTIZ8Oy_|-4)-g&;#2ed}+y5g1qf+(c2IS z+~t&62YriLRUbKh<_#ApZB+PIgtq!V2q5tNW6AXbnfRMXNc;-Gmji`I@xnid`1^Vmv$OUoy4@&m!d1*hPI|) z0p#jiS>wg-@ue>!orTQ6-13lD4Uz*9Zt=6~!M%Eh34%P+p#G6wDSDgeSJ3kIA2<612QbApW{;bxmvnfF#Qe7HG8N(p#;=IPfv2t!>3bQWc$6N6Im z|9@+OEwvAXyYr={lhs%@M(T*jbwaB~b%j3febCoPbTt-HO@yymM3e|{EnV%u41EY- zGErg|qRT8qnG+odn~ecY!i`}^pNj>)sq0NV%4;Vbo-3?|)m{!3Z10^-@Zs9k(}d-8V6X0L6y`)1^^F`|)VB_z zjPhBIlEbJBdzKFev;gcEZKi+C8wX??^_dZr6>{s!IcO*Wq?BPYY`2RL9@K7T&W78< zo~muzXhijgmk2jqI#W{6E72sU=e4zwL>bUSFbS713@b1J^RNK%)~lBQ7bDz&TaWKlh zu<15nF;PSq9vc&D$Lp z`;h8<8jX~~@8wO8aj;IV0DkGDmeU>d{1otMZ&tf?V3&xHO7FRNM3hH+PFORH!@fQ`0V06n3ZYOj(X$FMMgW?{lZa3Zb0vft<3@>`g%U)4|sWQp* zMeD)ws@L>-!)B+PR^^*>a$DjPc+(wTchCNQo>pLMNG%klA_12>VvtYsM4~^(z3eu{;R67swK~%P literal 0 HcmV?d00001 diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index 0a43ab47..e09c6e55 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -1,6 +1,34 @@ @import "tailwindcss"; @import "./tokens.css"; +/* Bai Jamjuree — wordmark font, self-hosted from /fonts/. + * Originally loaded from Google Fonts, but Google's font CDN is + * blocked by some ad blockers and corporate proxies. Self-hosting + * makes the font part of the SPA's own asset bundle so it always + * loads. Only the Latin subset at weights 500/600/700 ships — + * everything we need for the wordmark, ~36KB total. */ +@font-face { + font-family: 'Bai Jamjuree'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('/fonts/bai-jamjuree-500.woff2') format('woff2'); +} +@font-face { + font-family: 'Bai Jamjuree'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url('/fonts/bai-jamjuree-600.woff2') format('woff2'); +} +@font-face { + font-family: 'Bai Jamjuree'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('/fonts/bai-jamjuree-700.woff2') format('woff2'); +} + html, body { background: var(--bg-0); From a4a770ee9c5ec866522f92566ab931d9b78d102e Mon Sep 17 00:00:00 2001 From: alvarofraguas Date: Tue, 9 Jun 2026 22:48:23 +0200 Subject: [PATCH 29/57] api: per-OS flags target (spec item #1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend EnvEnrollHandler switch with flagsLinux / flagsMac / flagsWindows / flagsFreeBSD. Each substitutes the canonical install paths for that OS into env.Flags's __SECRET_FILE__ and __CERT_FILE__ placeholders — same substitution legacy admin's download path performs via generateFlags. SPA FlagsCard can now serve the same per-OS downloads operators expect from the legacy UI. --- cmd/api/handlers/environments.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/cmd/api/handlers/environments.go b/cmd/api/handlers/environments.go index 3f4eee0a..22c25514 100644 --- a/cmd/api/handlers/environments.go +++ b/cmd/api/handlers/environments.go @@ -243,6 +243,14 @@ func (h *HandlersApi) EnvEnrollHandler(w http.ResponseWriter, r *http.Request) { returnData = env.Certificate case settings.DownloadFlags: returnData = env.Flags + case settings.DownloadFlagsLinux: + returnData = substitutePlatformPaths(env.Flags, env.Name, "/etc/osquery", "/") + case settings.DownloadFlagsMac: + returnData = substitutePlatformPaths(env.Flags, env.Name, "/private/var/osquery", "/") + case settings.DownloadFlagsWin: + returnData = substitutePlatformPaths(env.Flags, env.Name, "C:\\Program Files\\osquery", "\\") + case settings.DownloadFlagsFreeBSD: + returnData = substitutePlatformPaths(env.Flags, env.Name, "/usr/local/etc", "/") case environments.EnrollShell: returnData, err = environments.QuickAddOneLinerShell((env.Certificate != ""), env) if err != nil { @@ -657,3 +665,19 @@ func (h *HandlersApi) EnvActionsHandler(w http.ResponseWriter, r *http.Request) h.AuditLog.EnvAction(ctx[ctxUser], e.Action+" - "+e.Name, strings.Split(r.RemoteAddr, ":")[0], auditlog.NoEnvironment) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, types.ApiGenericResponse{Message: msgReturn}) } + +// substitutePlatformPaths fills the __SECRET_FILE__ / __CERT_FILE__ placeholders +// in the env.Flags template with the canonical install paths for a given OS. +// This is the same substitution legacy admin's download path performs (see +// cmd/admin/handlers/utils.go generateFlags); centralising it here keeps the +// API's per-OS flag downloads producing the exact bytes operators expect to +// drop into /etc/osquery/osctrl-{env}.flags (or the platform equivalent). +// +// sep is the path separator the OS uses ("/" for everything except Windows). +func substitutePlatformPaths(flags, envName, dir, sep string) string { + secretPath := dir + sep + "osctrl-" + envName + ".secret" + certPath := dir + sep + "osctrl-" + envName + ".crt" + out := strings.Replace(flags, "__SECRET_FILE__", secretPath, 1) + out = strings.Replace(out, "__CERT_FILE__", certPath, 1) + return out +} From 3227adb83c44624d77925756e8915076e4ec0868 Mon Sep 17 00:00:00 2001 From: alvarofraguas Date: Tue, 9 Jun 2026 22:54:42 +0200 Subject: [PATCH 30/57] api: cert upload endpoint (spec item #2) POST /api/v1/environments/{env}/enroll/cert Body: { "certificate_b64": "" } Decodes base64, parses the PEM CERTIFICATE block, and validates the DER via x509.ParseCertificate before persisting via UpdateCertificate. Legacy admin's equivalent path skipped validation; we add it so a typo'd paste fails with a 400 instead of breaking enrollment downloads later. Audit-logged as 'upload certificate for environment '. --- cmd/api/handlers/environments.go | 71 ++++++++++++++++++++++++++++++++ cmd/api/main.go | 3 ++ 2 files changed, 74 insertions(+) diff --git a/cmd/api/handlers/environments.go b/cmd/api/handlers/environments.go index 22c25514..d5753504 100644 --- a/cmd/api/handlers/environments.go +++ b/cmd/api/handlers/environments.go @@ -1,7 +1,10 @@ package handlers import ( + "crypto/x509" + "encoding/base64" "encoding/json" + "encoding/pem" "fmt" "net/http" "strings" @@ -666,6 +669,74 @@ func (h *HandlersApi) EnvActionsHandler(w http.ResponseWriter, r *http.Request) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, types.ApiGenericResponse{Message: msgReturn}) } +// envCertUploadRequest is the body shape for EnvCertUploadHandler. The PEM +// is sent base64-encoded so the upload survives clients that mangle raw +// newlines (curl --data, browser fetch with a plain string body, etc.). +type envCertUploadRequest struct { + CertificateB64 string `json:"certificate_b64"` +} + +// EnvCertUploadHandler - POST handler to upload the enrollment certificate +// for an environment. Body: { "certificate_b64": "" }. The PEM +// must parse as one or more CERTIFICATE blocks and the leaf must be a real +// x509 cert — we don't accept "looks like base64 of something." Legacy +// admin's equivalent path skipped this validation; the SPA target gets it +// so a typo'd paste fails fast instead of breaking enrollment downloads. +func (h *HandlersApi) EnvCertUploadHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + envVar := r.PathValue("env") + if envVar == "" { + apiErrorResponse(w, "error getting environment", http.StatusBadRequest, nil) + return + } + env, err := h.Envs.Get(envVar) + if err != nil { + if err.Error() == "record not found" { + apiErrorResponse(w, "environment not found", http.StatusNotFound, err) + } else { + apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, err) + } + return + } + ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) + if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + h.denyEnv(w, r, ctx, env.ID, "permission check failed") + return + } + var req envCertUploadRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + apiErrorResponse(w, "error parsing POST body", http.StatusBadRequest, err) + return + } + if req.CertificateB64 == "" { + apiErrorResponse(w, "empty certificate", http.StatusBadRequest, nil) + return + } + pemBytes, err := base64.StdEncoding.DecodeString(req.CertificateB64) + if err != nil { + apiErrorResponse(w, "error decoding certificate", http.StatusBadRequest, err) + return + } + block, _ := pem.Decode(pemBytes) + if block == nil || block.Type != "CERTIFICATE" { + apiErrorResponse(w, "invalid PEM: no CERTIFICATE block", http.StatusBadRequest, nil) + return + } + if _, err := x509.ParseCertificate(block.Bytes); err != nil { + apiErrorResponse(w, "invalid x509 certificate", http.StatusBadRequest, err) + return + } + if err := h.Envs.UpdateCertificate(env.UUID, string(pemBytes)); err != nil { + apiErrorResponse(w, "error saving certificate", http.StatusInternalServerError, err) + return + } + log.Debug().Msgf("Certificate updated for environment %s", env.Name) + h.AuditLog.EnvAction(ctx[ctxUser], "upload certificate for environment "+env.Name, strings.Split(r.RemoteAddr, ":")[0], env.ID) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, types.ApiGenericResponse{Message: "certificate uploaded successfully"}) +} + // substitutePlatformPaths fills the __SECRET_FILE__ / __CERT_FILE__ placeholders // in the env.Flags template with the canonical install paths for a given OS. // This is the same substitution legacy admin's download path performs (see diff --git a/cmd/api/main.go b/cmd/api/main.go index 421295ae..b28c81d4 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -637,6 +637,9 @@ func osctrlAPIService() { muxAPI.Handle( "GET "+_apiPath(apiEnvironmentsPath)+"/{env}/enroll/{target}", handlerAuthCheck(http.HandlerFunc(handlersApi.EnvEnrollHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "POST "+_apiPath(apiEnvironmentsPath)+"/{env}/enroll/cert", + handlerAuthCheck(http.HandlerFunc(handlersApi.EnvCertUploadHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) muxAPI.Handle( "POST "+_apiPath(apiEnvironmentsPath)+"/{env}/enroll/{action}", handlerAuthCheck(http.HandlerFunc(handlersApi.EnvEnrollActionsHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) From ecf8c267cd6e095b70d20ad76ae20346be6b5255 Mon Sep 17 00:00:00 2001 From: alvarofraguas Date: Tue, 9 Jun 2026 22:57:19 +0200 Subject: [PATCH 31/57] api: assembled configuration endpoint (spec item #3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/v1/environments/{env}/configuration Calls RefreshConfiguration to recompose options + schedule + packs + decorators + ATC into the canonical osquery configuration blob, then re-reads the row to return the freshly written JSON. Same composition path the TLS endpoint serves to agents — operators can preview what their fleet will actually receive without grepping the DB. --- cmd/api/handlers/environments.go | 41 ++++++++++++++++++++++++++++++++ cmd/api/main.go | 3 +++ 2 files changed, 44 insertions(+) diff --git a/cmd/api/handlers/environments.go b/cmd/api/handlers/environments.go index d5753504..88de8c7f 100644 --- a/cmd/api/handlers/environments.go +++ b/cmd/api/handlers/environments.go @@ -669,6 +669,47 @@ func (h *HandlersApi) EnvActionsHandler(w http.ResponseWriter, r *http.Request) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, types.ApiGenericResponse{Message: msgReturn}) } +// EnvConfigurationHandler - GET handler returning the assembled osquery +// configuration JSON for an environment. Calls RefreshConfiguration so the +// returned blob reflects the latest options / schedule / packs / decorators / +// ATC parts, then re-reads the row. Same composition path the TLS endpoint +// uses to serve agents — useful for operators previewing what the agents +// will actually receive. +func (h *HandlersApi) EnvConfigurationHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + envVar := r.PathValue("env") + if envVar == "" { + apiErrorResponse(w, "error getting environment", http.StatusBadRequest, nil) + return + } + env, err := h.Envs.Get(envVar) + if err != nil { + if err.Error() == "record not found" { + apiErrorResponse(w, "environment not found", http.StatusNotFound, err) + } else { + apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, err) + } + return + } + ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) + if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + h.denyEnv(w, r, ctx, env.ID, "permission check failed") + return + } + if err := h.Envs.RefreshConfiguration(env.UUID); err != nil { + apiErrorResponse(w, "error refreshing configuration", http.StatusInternalServerError, err) + return + } + refreshed, err := h.Envs.Get(env.UUID) + if err != nil { + apiErrorResponse(w, "error reading configuration", http.StatusInternalServerError, err) + return + } + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, types.ApiDataResponse{Data: refreshed.Configuration}) +} + // envCertUploadRequest is the body shape for EnvCertUploadHandler. The PEM // is sent base64-encoded so the upload survives clients that mangle raw // newlines (curl --data, browser fetch with a plain string body, etc.). diff --git a/cmd/api/main.go b/cmd/api/main.go index b28c81d4..50cfa548 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -637,6 +637,9 @@ func osctrlAPIService() { muxAPI.Handle( "GET "+_apiPath(apiEnvironmentsPath)+"/{env}/enroll/{target}", handlerAuthCheck(http.HandlerFunc(handlersApi.EnvEnrollHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "GET "+_apiPath(apiEnvironmentsPath)+"/{env}/configuration", + handlerAuthCheck(http.HandlerFunc(handlersApi.EnvConfigurationHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) muxAPI.Handle( "POST "+_apiPath(apiEnvironmentsPath)+"/{env}/enroll/cert", handlerAuthCheck(http.HandlerFunc(handlersApi.EnvCertUploadHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) From 8ab1dd4b04c2beeabec7dbef7bd0284ee1d60106 Mon Sep 17 00:00:00 2001 From: alvarofraguas Date: Tue, 9 Jun 2026 22:58:00 +0200 Subject: [PATCH 32/57] api: scope assembled-config route to avoid /map/{target} clash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go 1.22 ServeMux flagged GET /{env}/configuration as ambiguous with the existing GET /map/{target} (both match /environments/map/configuration). Moved the new route to GET /{env}/configuration/assembled — three segments after /environments/, no overlap. --- cmd/api/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/api/main.go b/cmd/api/main.go index 50cfa548..bd58f462 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -638,7 +638,7 @@ func osctrlAPIService() { "GET "+_apiPath(apiEnvironmentsPath)+"/{env}/enroll/{target}", handlerAuthCheck(http.HandlerFunc(handlersApi.EnvEnrollHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) muxAPI.Handle( - "GET "+_apiPath(apiEnvironmentsPath)+"/{env}/configuration", + "GET "+_apiPath(apiEnvironmentsPath)+"/{env}/configuration/assembled", handlerAuthCheck(http.HandlerFunc(handlersApi.EnvConfigurationHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) muxAPI.Handle( "POST "+_apiPath(apiEnvironmentsPath)+"/{env}/enroll/cert", From 6c4d34722ac4b7de65a422fb3ac01d00b2944b50 Mon Sep 17 00:00:00 2001 From: alvarofraguas Date: Tue, 9 Jun 2026 23:02:09 +0200 Subject: [PATCH 33/57] spa: CertificateCard on Enrollment page (spec item #4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-rail card under the two LifecycleCards on /env/{env}/enroll: - live preview of the env's enrollment certificate (first PEM line + last 60 chars + last PEM line — enough to fingerprint at a glance without flooding the rail with 30+ lines of base64) - Copy / Download "osctrl-.crt" buttons over the preview - Replace section: paste full PEM or pick a file (.crt/.pem/.cer), Upload posts to POST /enroll/cert with base64-encoded body. The server's PEM + x509 validation messages surface verbatim inline. API client gains uploadCertificate() and EnrollTarget picks up the 4 new per-OS flag targets (flagsLinux/flagsMac/flagsWindows/flagsFreeBSD) that #1 added to the backend — FlagsCard (item #5) will consume those. --- frontend/src/api/enrollment.ts | 25 +- .../features/enrollment/CertificateCard.tsx | 229 ++++++++++++++++++ .../src/features/enrollment/EnrollPage.tsx | 2 + 3 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 frontend/src/features/enrollment/CertificateCard.tsx diff --git a/frontend/src/api/enrollment.ts b/frontend/src/api/enrollment.ts index 6de0bf93..5bf14b94 100644 --- a/frontend/src/api/enrollment.ts +++ b/frontend/src/api/enrollment.ts @@ -22,7 +22,11 @@ import { apiFetch } from './client'; export type EnrollTarget = | 'secret' // raw enroll secret (string) | 'cert' // env certificate PEM - | 'flags' // raw osquery flags file content + | 'flags' // raw osquery flags file content (template — paths unsubstituted) + | 'flagsLinux' // flags file with __SECRET_FILE__/__CERT_FILE__ → /etc/osquery/... paths + | 'flagsMac' // flags file with paths → /private/var/osquery/... + | 'flagsWindows' // flags file with paths → C:\Program Files\osquery\... + | 'flagsFreeBSD' // flags file with paths → /usr/local/etc/... | 'enroll.sh' // bash one-liner installer | 'enroll.ps1'; // powershell one-liner installer @@ -114,3 +118,22 @@ export function removeAction( }, ); } + +// --------------------------------------------------------------------------- +// POST /environments/{env}/enroll/cert — upload a replacement enrollment cert. +// The API validates: base64 → PEM CERTIFICATE block → x509.ParseCertificate. +// Caller passes the raw PEM as a string; we base64-encode here so JSON-body +// quoting and newline handling stays clean (raw PEM strings break otherwise). +// --------------------------------------------------------------------------- +export function uploadCertificate(env: string, pem: string): Promise { + // btoa handles ASCII; a PEM is ASCII (base64 + dashes + newlines), so it's safe. + const b64 = btoa(pem); + return apiFetch( + `/api/v1/environments/${encodeURIComponent(env)}/enroll/cert`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ certificate_b64: b64 }), + }, + ); +} diff --git a/frontend/src/features/enrollment/CertificateCard.tsx b/frontend/src/features/enrollment/CertificateCard.tsx new file mode 100644 index 00000000..e65d9dc7 --- /dev/null +++ b/frontend/src/features/enrollment/CertificateCard.tsx @@ -0,0 +1,229 @@ +import { useRef, useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { getEnrollData, uploadCertificate } from '$/api/enrollment'; +import { ApiError } from '$/api/client'; +import { Button } from '$/components/atoms/Button'; +import { Skeleton } from '$/components/data/Skeleton'; +import { cn } from '$/lib/cn'; + +/** + * CertificateCard — view, copy, download, and replace the env's enrollment + * certificate. Slots into the EnrollPage right rail beside the two + * LifecycleCards. The card always displays a short fingerprint preview + * (first + last PEM lines) so operators can sanity-check at a glance + * without copy/pasting the whole thing. + * + * Upload path: paste full PEM into the textarea, click Upload. The API + * does the PEM + x509 validation server-side — we surface its rejection + * verbatim under the textarea on 400, and re-fetch the cert on 200. + * + * Notes on layout: matches the LifecycleCard chrome exactly (rounded-xl, + * border, p-4, h2 heading) so the rail stays visually coherent. + */ +export function CertificateCard({ env }: { env: string }) { + const qc = useQueryClient(); + const [paste, setPaste] = useState(''); + const [feedback, setFeedback] = useState<{ kind: 'success' | 'error'; msg: string } | null>( + null, + ); + const [copied, setCopied] = useState(false); + const fileInputRef = useRef(null); + + const certQuery = useQuery({ + queryKey: ['enrollment', env, 'cert'], + queryFn: () => getEnrollData(env, 'cert'), + staleTime: 60_000, + }); + + const uploadMut = useMutation({ + mutationFn: (pem: string) => uploadCertificate(env, pem), + onSuccess: (res) => { + setFeedback({ kind: 'success', msg: res.message }); + setPaste(''); + void qc.invalidateQueries({ queryKey: ['enrollment', env, 'cert'] }); + }, + onError: (err) => + setFeedback({ + kind: 'error', + msg: err instanceof ApiError ? err.message : 'Upload failed', + }), + }); + + function handleUpload() { + const trimmed = paste.trim(); + if (!trimmed) { + setFeedback({ kind: 'error', msg: 'Empty certificate' }); + return; + } + setFeedback(null); + uploadMut.mutate(trimmed); + } + + async function handleFile(file: File) { + try { + const text = await file.text(); + setPaste(text); + setFeedback(null); + } catch { + setFeedback({ kind: 'error', msg: 'Could not read file' }); + } + } + + async function handleCopy() { + if (!certQuery.data?.data) return; + try { + await navigator.clipboard.writeText(certQuery.data.data); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + setFeedback({ kind: 'error', msg: 'Clipboard blocked by browser' }); + } + } + + function handleDownload() { + if (!certQuery.data?.data) return; + const blob = new Blob([certQuery.data.data], { type: 'application/x-pem-file' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `osctrl-${env}.crt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + + // Build a one-glance preview: first PEM line + last 60 chars of body. + const preview = (() => { + const pem = certQuery.data?.data ?? ''; + if (!pem) return null; + const lines = pem.trim().split('\n'); + if (lines.length < 3) return pem; + const body = lines.slice(1, -1).join(''); + const tail = body.slice(-60); + return `${lines[0]}\n…${tail}\n${lines[lines.length - 1]}`; + })(); + + return ( +
+
+

+ Certificate +

+ + ⓘ + +
+ + {/* Preview */} +
+        {certQuery.isLoading ? (
+          
+        ) : certQuery.isError ? (
+          
+            {(certQuery.error as Error | undefined)?.message ?? 'Failed to load'}
+          
+        ) : (
+          preview ?? No certificate set
+        )}
+      
+ +
+ + +
+ + {/* Replace */} +
+
+ + Replace certificate + + { + const f = ev.target.files?.[0]; + if (f) void handleFile(f); + ev.target.value = ''; + }} + /> + +
+