diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 2f59d251..28821433 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -4,6 +4,35 @@ Owlette is a cloud-connected Windows process management and remote deployment sy **Version**: 2.12.3 | **License**: FSL-1.1-Apache-2.0 +--- + +## Critical Guardrails (non-negotiable — read first) + +**Files you must not touch:** +- `firestore.rules` — don't modify without explicit request +- `.tokens.enc` / credential files — never read, log, or commit +- `owlette_installer.iss` — only modify if you understand the full build pipeline (see `.claude/skills/build-system.md`) + +**Agent landmines:** +- **Never import `firebase_admin`** — we use a custom REST client +- **Never log OAuth tokens** — not even in debug, not even partially +- **Never modify the `firebase` section** of `config.json` during remote config updates — breaks agent registration +- **Never use blocking operations** in the 10-second main service loop — stalls all monitoring +- **Never spawn reconnection logic** outside `ConnectionManager` — it has circuit breaker and backoff + +**Web landmines:** +- **Never call Firestore directly from components** — use hooks in `web/hooks/` +- **Never hardcode colors** — use CSS variables / Tailwind theme tokens +- **Never add icon libraries** beyond `lucide-react` + +**Workflow:** +- **Don't push to `main` directly** — all work through `dev`, then PR +- **Don't create new `docs/*.md` files** without being asked +- **Don't install new npm/pip packages** without confirming first +- **Don't modify `.claude/hooks/` or `.claude/settings.json`** without explicit request + +--- + ## In-Flight Major Initiative: roost (project distribution v2) A multi-quarter rewrite of project distribution into a content-addressed sync platform (Cloudflare R2, immutable manifests, atomic deploy, rollback). Branded as "roost" (always lowercase). Plan + tasks live at `dev/active/project-distribution-v2/`. Memory: `project_roost.md`. @@ -67,6 +96,10 @@ Version files: `/VERSION`, `agent/VERSION`, `web/package.json`, `firestore.rules **Lint as you go — don't let errors accumulate.** After editing any web file, run `npx eslint ` on that file (or `npm run lint` for a broader change) and fix every error and warning you introduced before moving on. Never commit new lint errors, and never rationalise them as "pre-existing" if your edit touched the same file. The repo has historical lint debt — your job is to not add to it, and to clean up any issues in lines you modified. Same principle for TypeScript: if `tsc` / IDE diagnostics flag your change, fix it before the next edit, not at commit time. +**E2E verification (two layers).** The `playwright e2e` GitHub Action ([.github/workflows/e2e.yml](../.github/workflows/e2e.yml)) gates pushes to `dev`/`main` that touch `web/**`, `firestore.rules`, or `firebase.json`. +- **Proactive (preferred):** before pushing such changes, run `/preflight` — it runs lint, typecheck, unit tests, and the local e2e suite (the exact mirror of CI, ~45s steady-state). Fix reds locally; don't ship them to a branch that auto-deploys. +- **Reactive (safety net):** after a `git push` to `dev`/`main` in e2e scope, the `post-push-e2e.mjs` hook reminds you to watch the triggered run with `gh run watch --exit-status` (run it in the background). On failure: `gh run view --log-failed`, diagnose, and **propose** a fix — never auto-fix-and-repush (`dev` auto-deploys, `main` is protected). + --- ## Agent Authentication (Device Code Pairing) @@ -99,98 +132,23 @@ Agents authenticate via a device code flow — no browser login on the target ma **Failover load balancer**: `owlette.app` is fronted by a Cloudflare LB (Railway primary, Vercel standby) defined as Terraform in `infra/cloudflare/`. Health probe is `/api/health`. Apply workflow, token scope, and the origin-hostname gotchas: `.claude/skills/cf-load-balancing.md`. -**IMPORTANT: Always version up AND update the changelog BEFORE building the installer.** Bump with `node scripts/sync-versions.js X.Y.Z` and commit BEFORE running `build_installer_full.bat` — the installer bakes the version into the exe filename and binary. - -**IMPORTANT: `docs/changelog.md` MUST be updated before every installer build.** Add a new `## [X.Y.Z] - YYYY-MM-DD` section summarising all changes since the last release. Never build or upload an installer without a matching changelog entry. +**IMPORTANT — installer release order (do not reorder):** bump the version (`node scripts/sync-versions.js X.Y.Z`) **and** add the `## [X.Y.Z] - YYYY-MM-DD` entry to `docs/changelog.md`, then commit — *before* building. `build_installer_full.bat` bakes the version into the exe filename and binary, and an installer must never ship without a matching changelog entry. -**Agent Installer Release** (build + upload to Firebase): -```bash -# 1. Update changelog, bump version, commit, push -# Edit docs/changelog.md → add [X.Y.Z] section -node scripts/sync-versions.js X.Y.Z -git add -A && git commit -m "chore: bump version to X.Y.Z" && git push origin dev - -# 2. Build installer (~5 min, non-interactive) -# build_installer_full.bat ends with `pause` and has `pause` on every error -# branch, so it MUST be run with stdin redirected from NUL or it will hang -# the harness forever. Invoke by FULL PATH (cmd /c won't reliably cd via -# PowerShell quote-stripping) and capture the log explicitly. Run in the -# background — exit code 0 means the .exe is built; check the log on failure. -# -# powershell (foreground/background): -# cmd /c "C:\Users\admin\Documents\Git\Owlette\agent\build_installer_full.bat < NUL > C:\Users\admin\AppData\Local\Temp\installer-build.log 2>&1" -# -# bash: -# cd c:/Users/admin/Documents/Git/Owlette/agent && cmd //c "build_installer_full.bat" < /dev/null > /tmp/installer-build.log 2>&1 -# # (if //c gets mangled by Git Bash, fall back to the powershell cmd /c form above) -# -# DO NOT use `cd agent && powershell -Command "& './build_installer_full.bat'"` — -# the trailing pause will hang non-interactive shells indefinitely. -# Output: agent/build/installer_output/Owlette-Installer-vX.Y.Z.exe - -# 3. Compute checksum -sha256sum agent/build/installer_output/Owlette-Installer-vX.Y.Z.exe - -# 4. Upload via API (3-step: request URL → upload binary → finalize) -# Endpoint is `/api/installer/upload` (api-sprint route — old `/api/admin/installer/upload` was removed). -# Auth: api key with `installer=*:write` scope (superadmin-only at minting). `x-api-key` or `Authorization: Bearer owk_…` both work. -# Idempotency-Key REQUIRED on both POST and PUT — the route is wrapped in `withIdempotency(..., { requireKey: true })`. -API_KEY=$(grep OWLETTE_API_KEY .claude/.env.local | cut -d= -f2) -BASE_URL="https://dev.owlette.app" # or https://owlette.app for prod - -# Step 1: Get signed upload URL -curl -s -X POST "$BASE_URL/api/installer/upload" \ - -H "Content-Type: application/json" \ - -H "x-api-key: $API_KEY" \ - -H "Idempotency-Key: installer-upload-X.Y.Z-$(date +%s)" \ - -d '{"version":"X.Y.Z","fileName":"Owlette-Installer-vX.Y.Z.exe","releaseNotes":"...","setAsLatest":true}' -# → returns uploadUrl, uploadId, storagePath, expiresAt (15-min window) - -# Step 2: Upload binary to the signed GCS URL (no Idempotency-Key here — it's a direct GCS PUT) -curl -X PUT "$UPLOAD_URL" -H "Content-Type: application/octet-stream" \ - --data-binary @agent/build/installer_output/Owlette-Installer-vX.Y.Z.exe - -# Step 3: Finalize (verifies file in storage, computes/checks checksum, writes installer_metadata, sets as latest) -curl -s -X PUT "$BASE_URL/api/installer/upload" \ - -H "Content-Type: application/json" \ - -H "x-api-key: $API_KEY" \ - -H "Idempotency-Key: installer-finalize-X.Y.Z-$(date +%s)" \ - -d '{"uploadId":"","checksum_sha256":""}' -# checksum_sha256 is optional — server computes it if omitted, but providing it gets a 412 `checksum_mismatch` on corruption. -``` +**Full release recipe** — the non-interactive build invocation (the `pause`-hang gotcha) plus the 3-step signed-URL upload → finalize API flow — lives in `.claude/skills/build-system.md` → "Agent Installer Release". That skill auto-activates on installer/release/version work. --- -## Don'ts / Guardrails - -### Files You Must Not Touch -- `web/components/ui/*` — auto-generated by shadcn/ui -- `firestore.rules` — don't modify without explicit request -- `.tokens.enc` / credential files — never read, log, or commit -- `owlette_installer.iss` — only modify if you understand the full build pipeline - -### Agent Landmines -- **Never import `firebase_admin`** — we use a custom REST client -- **Never log OAuth tokens** — not even in debug, not even partially -- **Never modify the `firebase` section** of `config.json` during remote config updates — breaks agent registration -- **Never use blocking operations** in the 10-second main service loop — stalls all monitoring -- **Never spawn reconnection logic** outside `ConnectionManager` — it has circuit breaker and backoff +## Conventions & Review Discipline ### UI Copy Style - **All user-facing copy is lowercase** — page titles, buttons, dialog headings, labels, descriptions, tooltips, placeholder text, empty-state copy, toasts. Match the voice of the rest of the UI. - Exceptions (keep normal casing): proper nouns/product names in external contexts, acronyms (`LLM`, `API`, `URL`, `GPU`, `OAuth`), code identifiers, machine IDs / site IDs / user-entered strings, and legal/compliance text where casing is load-bearing. - When adding new copy, default to lowercase. When editing existing strings, match the surrounding casing — don't mix sentence case into a lowercase screen or vice versa. -### Web Landmines -- **Never call Firestore directly from components** — use hooks in `web/hooks/` -- **Never hardcode colors** — use CSS variables / Tailwind theme tokens -- **Never add icon libraries** beyond `lucide-react` - -### General -- **Don't push to `main` directly** — all work through `dev`, then PR -- **Don't create new `docs/*.md` files** without being asked -- **Don't install new npm/pip packages** without confirming first -- **Don't modify `.claude/hooks/` or `.claude/settings.json`** without explicit request +### Design System (shadcn/ui) +- `web/components/ui/*` are shadcn primitives **copied into the repo — we own them.** Editing them for theming, variants, hover/focus states, and standardization is the *intended* shadcn workflow (they're scaffolding you customize, not auto-generated black boxes — they've been hand-tuned before). +- Caveats when editing: changes are **app-wide**, so verify broadly; re-running `npx shadcn add ` **overwrites** that file, so port upstream fixes by hand; prefer CSS-variable tokens (`web/app/globals.css`) over hardcoded values. +- **`button.tsx` variants are the single source of truth for button styling.** Standardize there — don't sprinkle per-instance `hover:*`/`bg-*` overrides on individual ` + + +

{tooltip}

+
+ + + setValue(false)} + variant="destructive" + /> + + ); +} diff --git a/web/app/cortex/components/CortexChatView.tsx b/web/app/cortex/components/CortexChatView.tsx index 31fadfee..e841f80c 100644 --- a/web/app/cortex/components/CortexChatView.tsx +++ b/web/app/cortex/components/CortexChatView.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'; import { useAuth } from '@/contexts/AuthContext'; import { useSites, useMachines } from '@/hooks/useFirestore'; import { useOwletteChat, type ChatConversation } from '@/hooks/useCortex'; +import { useCortexSidebarPrefs } from '@/hooks/useCortexSidebarPrefs'; import { PageHeader } from '@/components/PageHeader'; import { AccountSettingsDialog } from '@/components/AccountSettingsDialog'; import { Button } from '@/components/ui/button'; @@ -17,6 +18,7 @@ import { ChatWindow } from './ChatWindow'; import { ChatInput } from './ChatInput'; import { MachineSelector, SITE_TARGET_ID } from './MachineSelector'; import { CortexPowerToggle } from './CortexPowerToggle'; +import { CortexApprovalToggle } from './CortexApprovalToggle'; import { LoadingWord } from '@/components/LoadingWord'; function timeAgo(date: Date): string { @@ -65,7 +67,7 @@ interface CortexChatViewProps { export function CortexChatView({ initialChatId }: CortexChatViewProps) { const router = useRouter(); - const { user, userSites, isSuperadmin, loading: authLoading, lastSiteId, lastMachineIds, updateLastSite, updateLastMachine } = useAuth(); + const { user, userSites, isSuperadmin, isSiteAdmin, loading: authLoading, lastSiteId, lastMachineIds, updateLastSite, updateLastMachine } = useAuth(); const { sites, loading: sitesLoading } = useSites(user?.uid, userSites, isSuperadmin); const [currentSiteId, setCurrentSiteId] = useState(''); @@ -76,8 +78,8 @@ export function CortexChatView({ initialChatId }: CortexChatViewProps) { const [errorDismissed, setErrorDismissed] = useState(false); const [searchOpen, setSearchOpen] = useState(false); const [categorizingAll, setCategorizingAll] = useState(false); - const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); - const [sidebarOpen, setSidebarOpen] = useState(true); + // Sidebar expand/collapse state persists per-device to Firestore. + const { sidebarOpen, setSidebarOpen, collapsedGroups, setCollapsedGroups } = useCortexSidebarPrefs(); const { machines } = useMachines(currentSiteId); @@ -112,7 +114,11 @@ export function CortexChatView({ initialChatId }: CortexChatViewProps) { const selectedMachine = !isSiteMode ? machines.find((m) => m.machineId === selectedMachineId) : null; const suppressNextChatRouteRef = useRef(false); const skipNextLandingResetRef = useRef(false); - const pendingNewChatFromRouteIdRef = useRef(null); + // Set when we intentionally start a new chat (or delete the routed chat) while + // the URL still points at the old chat: the persistent component would briefly + // see initialChatId(old) !== activeChatId(new) and wrongly reload the old chat, + // stealing selection from the just-created one. One-shot skip of that load. + const suppressNextLoadRef = useRef(false); const previousChatIdRef = useRef(null); const previousInitialChatIdRef = useRef(initialChatId); @@ -132,6 +138,12 @@ export function CortexChatView({ initialChatId }: CortexChatViewProps) { const loadChat = chat.loadChat; useEffect(() => { + // Skip the load that a just-started new chat (or a deletion) would otherwise + // trigger from the stale URL before navigation commits. + if (suppressNextLoadRef.current) { + suppressNextLoadRef.current = false; + return; + } if (!initialChatId || initialChatId === activeChatId) return; void loadChat(initialChatId); }, [initialChatId, activeChatId, loadChat]); @@ -151,19 +163,15 @@ export function CortexChatView({ initialChatId }: CortexChatViewProps) { } }, [activeChatId, initialChatId, router]); + // Landing transition: when the URL goes from a routed chat back to /cortex + // (browser back, or a deletion), start a fresh chat. Skipped when an explicit + // handler (handleNewChat / handleDeleteChat) already started one. With the + // persistent layout the component is not remounted, so this fires on the + // initialChatId prop change rather than on mount. useEffect(() => { const previousInitialChatId = previousInitialChatIdRef.current; previousInitialChatIdRef.current = initialChatId; - const pendingNewChatFromRouteId = pendingNewChatFromRouteIdRef.current; - if (!initialChatId && pendingNewChatFromRouteId) { - if (activeChatId !== pendingNewChatFromRouteId) { - pendingNewChatFromRouteIdRef.current = null; - router.replace(`/cortex/${encodeURIComponent(activeChatId)}`); - } - return; - } - if (initialChatId || !previousInitialChatId) return; if (skipNextLandingResetRef.current) { skipNextLandingResetRef.current = false; @@ -172,7 +180,7 @@ export function CortexChatView({ initialChatId }: CortexChatViewProps) { suppressNextChatRouteRef.current = true; chat.startNewChat(); - }, [activeChatId, chat, initialChatId, router]); + }, [chat, initialChatId]); // Reset error dismissed state when a new error arrives useEffect(() => { @@ -184,8 +192,13 @@ export function CortexChatView({ initialChatId }: CortexChatViewProps) { const handleNewChat = useCallback((overrides?: { machineId?: string; machineName?: string }) => { if (initialChatId) { - pendingNewChatFromRouteIdRef.current = initialChatId; + // Navigate back to the landing URL but keep it there until the chat is + // persisted (handleChatPersisted replaces to /cortex/{id}). suppress stops + // the URL-sync effect from pushing the unsaved id; skipNextLandingReset + // stops the landing effect from starting a *second* new chat. suppressNextChatRouteRef.current = true; + skipNextLandingResetRef.current = true; + suppressNextLoadRef.current = true; router.push('/cortex'); } @@ -194,14 +207,27 @@ export function CortexChatView({ initialChatId }: CortexChatViewProps) { }, [chat, initialChatId, router]); const handleConversationClick = useCallback((conversationId: string) => { + // Expand the selected conversation's category group if the user had it + // collapsed, so the row it lives in is actually visible after selecting. + const convo = conversationsRef.current.find((c) => c.id === conversationId); + if (convo && convo.title !== 'new conversation') { + const label = convo.category || 'General'; + setCollapsedGroups((prev) => { + if (!prev.has(label)) return prev; + const next = new Set(prev); + next.delete(label); + return next; + }); + } router.push(`/cortex/${encodeURIComponent(conversationId)}`); - }, [router]); + }, [router, setCollapsedGroups]); const handleDeleteChat = useCallback((conversationId: string) => { const deletedRouteChat = conversationId === initialChatId; if (deletedRouteChat) { suppressNextChatRouteRef.current = true; skipNextLandingResetRef.current = true; + suppressNextLoadRef.current = true; } void chat.deleteChat(conversationId); @@ -228,11 +254,44 @@ export function CortexChatView({ initialChatId }: CortexChatViewProps) { return () => observer.disconnect(); }, [hasMoreConversations, loadingMore, loadMoreConversations]); + // Latest conversations, readable from event handlers without re-subscribing. + const conversationsRef = useRef(chat.conversations); + conversationsRef.current = chat.conversations; + + // Scroll the active conversation row into view whenever the active chat changes + // (selecting a conversation, starting a new one), so the highlighted row is + // never left scrolled out of sight. No state writes here — purely a DOM nudge. + useEffect(() => { + if (!chat.chatId) return; + const raf = requestAnimationFrame(() => { + sidebarScrollRef.current + ?.querySelector('[data-active-conversation="true"]') + ?.scrollIntoView({ block: 'nearest' }); + }); + return () => cancelAnimationFrame(raf); + }, [chat.chatId]); + // Skip "new conversation" entries — the API requires a title or first message to categorize const uncategorizedIds = chat.conversations .filter((c) => !c.category && c.title !== 'new conversation') .map((c) => c.id); + // Drive the collapse-all/expand-all toggle off the *actual* set of visible + // group labels so the icon/label and the action never disagree (e.g. one + // section expanded while the rest are collapsed). + const visibleGroupLabels = groupConversationsByCategory( + chat.conversations.filter((c) => c.title !== 'new conversation'), + ).map((g) => g.label); + const allGroupsCollapsed = + visibleGroupLabels.length > 0 && visibleGroupLabels.every((l) => collapsedGroups.has(l)); + + // Which category the active conversation lives in — used to flag a collapsed + // section that contains the current chat, so the user knows where it is. + const activeConvo = chat.conversations.find((c) => c.id === chat.chatId); + const activeCategoryLabel = activeConvo && activeConvo.title !== 'new conversation' + ? (activeConvo.category || 'General') + : null; + const categorizeAll = async () => { if (categorizingAll || uncategorizedIds.length === 0) return; setCategorizingAll(true); @@ -397,17 +456,14 @@ export function CortexChatView({ initialChatId }: CortexChatViewProps) { -

{collapsedGroups.size > 0 ? 'expand all' : 'collapse all'}

+

{allGroupsCollapsed ? 'expand all' : 'collapse all'}

)} @@ -478,6 +534,9 @@ export function CortexChatView({ initialChatId }: CortexChatViewProps) { chat.conversations.filter((c) => c.title !== 'new conversation') ).map((group) => { const isCollapsed = collapsedGroups.has(group.label); + // Highlight the header of whichever group holds the active + // conversation — collapsed (where the row is hidden) or expanded. + const containsActive = group.label === activeCategoryLabel; return (
{!isCollapsed && group.conversations.map((convo) => ( @@ -593,11 +658,14 @@ export function CortexChatView({ initialChatId }: CortexChatViewProps) { )} - {!isSiteMode && selectedMachine && ( -
+
+ {currentSiteId && isSiteAdmin(currentSiteId) && ( + + )} + {!isSiteMode && selectedMachine && ( -
- )} + )} +
{/* Messages */} @@ -609,6 +677,8 @@ export function CortexChatView({ initialChatId }: CortexChatViewProps) { isLoading={chat.isLoading} hasApiKey={hasApiKey} onOpenSettings={() => setAccountSettingsOpen(true)} + onToolApproval={(id, approved) => chat.addToolApprovalResponse({ id, approved })} + approvalTargetLabel={isSiteMode ? 'all machines' : selectedMachineId} /> )} @@ -734,7 +804,7 @@ function ConversationItem({ setConfirming(false); }} aria-label={`cancel delete ${conversation.title}`} - className="p-1 rounded hover:bg-secondary transition-colors cursor-pointer" + className="p-1 rounded hover:bg-accent transition-colors cursor-pointer" > @@ -768,7 +838,7 @@ function ConversationItem({ setEditing(false); }} aria-label={`save rename ${conversation.title}`} - className="p-1 rounded hover:bg-secondary transition-colors cursor-pointer" + className="p-1 rounded hover:bg-accent transition-colors cursor-pointer" > @@ -783,7 +853,7 @@ function ConversationItem({ setEditing(false); }} aria-label={`cancel rename ${conversation.title}`} - className="p-1 rounded hover:bg-secondary transition-colors cursor-pointer" + className="p-1 rounded hover:bg-accent transition-colors cursor-pointer" > @@ -793,30 +863,41 @@ function ConversationItem({ return (
- {conversation.source === 'autonomous' ? ( - - ) : ( - - )} -
-
-

{conversation.title}

- {conversation.source === 'autonomous' && ( - - auto - - )} + {/* The open-conversation control is a real
+
diff --git a/web/app/cortex/components/SynapticIndicator.tsx b/web/app/cortex/components/SynapticIndicator.tsx index a15513ce..dbdc7191 100644 --- a/web/app/cortex/components/SynapticIndicator.tsx +++ b/web/app/cortex/components/SynapticIndicator.tsx @@ -1,104 +1,94 @@ 'use client'; /** - * Synaptic firing indicator — a triad of neurons (big, medium, small) - * exchanging signals bidirectionally along shared axons. Each neuron - * bobs gently; the whole cluster drifts. + * Synaptic firing indicator — a hexagonal neuron lattice. Six nodes sit at the + * vertices of a pointy-top hexagon; a faint web of perimeter + diagonal axons + * connects them. Cyan signal dots flow around the ring in sequence (a travelling + * wave) while the nodes fire in a staggered pulse — lively, but composed. */ export function SynapticIndicator() { - const A = { x: 5, y: 18, r: 3.2 }; // big - const B = { x: 19, y: 15, r: 2.3 }; // medium - const C = { x: 13, y: 4, r: 1.6 }; // small + const R = 8; + const C = 12; - const cycle = 380; - const d = `${cycle}ms`; + // Pointy-top hexagon vertices, starting at the top and going clockwise. + const verts = [-90, -30, 30, 90, 150, 210].map((deg) => { + const a = (deg * Math.PI) / 180; + return { x: +(C + R * Math.cos(a)).toFixed(2), y: +(C + R * Math.sin(a)).toFixed(2) }; + }); - // 6 pulses — every edge fires in both directions, staggered - const pulses = [ - { from: A, to: B, delay: 0 }, - { from: B, to: C, delay: 60 }, - { from: C, to: A, delay: 120 }, - { from: B, to: A, delay: 190 }, - { from: C, to: B, delay: 250 }, - { from: A, to: C, delay: 310 }, - ]; + // Closed perimeter path the signal dots travel along. + const perimeter = `M ${verts.map((v) => `${v.x} ${v.y}`).join(' L ')} Z`; + // Long axons across the centre, for the neural-web texture. + const diagonals = [[0, 3], [1, 4], [2, 5]].map( + ([a, b]) => `M ${verts[a].x} ${verts[a].y} L ${verts[b].x} ${verts[b].y}`, + ); - // Per-neuron gentle bob (subtle Y oscillation, varied phase) - const bobs = [ - { values: '0 0; 0 -0.5; 0 0.3; 0 0', dur: '1.6s' }, - { values: '0 0; 0 0.4; 0 -0.4; 0 0', dur: '1.3s' }, - { values: '0 0; 0 -0.3; 0 0.5; 0 0', dur: '1.9s' }, - ]; - const nodes = [A, B, C]; + const loop = 2100; // ms for one full lap around the hexagon + const nodeCycle = 1260; // ms for the node firing wave + const signals = [0, 1, 2].map((i) => (i * loop) / 3); // three dots, evenly chasing return ( - - - {/* Whole-cluster drift */} - - - {/* Axons */} - {[ - [A, B], - [B, C], - [C, A], - ].map(([p, q], i) => ( - + + {/* Axon lattice */} + + + {diagonals.map((d, i) => ( + ))} + - {/* Neurons — each bobs independently */} - {nodes.map((n, i) => ( - - - - + {/* Static fallback for reduced motion — a calm, dimly-lit hexagon. */} + + {verts.map((v, i) => ( + ))} + - {/* Bidirectional pulses */} - {pulses.map((p, i) => ( - - - - - ))} + {/* Animated layer — suppressed entirely when the user prefers reduced motion. */} + + {/* Neurons — fire in a staggered wave around the ring */} + + {verts.map((v, i) => ( + + + + + ))} + + + {/* Signals — cyan dots flowing between neurons, sequenced */} + + {signals.map((delay, i) => ( + + + + + ))} + ); diff --git a/web/app/cortex/components/ToolCallCard.tsx b/web/app/cortex/components/ToolCallCard.tsx index f95189cc..73de3237 100644 --- a/web/app/cortex/components/ToolCallCard.tsx +++ b/web/app/cortex/components/ToolCallCard.tsx @@ -1,8 +1,9 @@ 'use client'; import React, { useState } from 'react'; -import { ChevronDown, ChevronRight, Wrench, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react'; +import { ChevronDown, ChevronRight, Wrench, CheckCircle2, AlertCircle, Loader2, ShieldAlert, Ban, Check } from 'lucide-react'; import { getToolByName } from '@/lib/mcp-tools'; +import { Button } from '@/components/ui/button'; import { CopyButton } from './CopyButton'; interface ToolCallCardProps { @@ -10,14 +11,36 @@ interface ToolCallCardProps { args: Record; result?: unknown; isLoading?: boolean; + /** + * Tier-3 approval (human-in-the-loop). `requested` shows approve/deny + * controls; `denied` shows the declined state. Absent for tier-1/2 tools + * and for already-executed tier-3 calls. + */ + approvalState?: 'requested' | 'denied'; + /** Where the tool will run, e.g. a machine name or "all machines". */ + approvalTargetLabel?: string; + onApprove?: () => void; + onDeny?: () => void; } -export function ToolCallCard({ toolName, args, result, isLoading }: ToolCallCardProps) { +export function ToolCallCard({ + toolName, + args, + result, + isLoading, + approvalState, + approvalTargetLabel, + onApprove, + onDeny, +}: ToolCallCardProps) { const [expanded, setExpanded] = useState(false); + const [submitting, setSubmitting] = useState(false); const toolDef = getToolByName(toolName); const hasError = result != null && typeof result === 'object' && !!(result as Record).error; const tierLabel = toolDef ? `Tier ${toolDef.tier}` : ''; + const awaitingApproval = approvalState === 'requested'; + const denied = approvalState === 'denied'; // Inline preview for screenshot captures: prefer the uploaded Firebase URL, // fall back to inline base64 JPEG if the upload failed but the capture succeeded. @@ -31,20 +54,30 @@ export function ToolCallCard({ toolName, args, result, isLoading }: ToolCallCard } } + const statusIcon = awaitingApproval ? ( + + ) : isLoading ? ( + + ) : denied ? ( + + ) : hasError ? ( + + ) : ( + + ); + return ( -
+
{/* Header */} + {/* Approval banner — privileged tier-3 action needs explicit go-ahead. + The payload stays collapsed (expand the card header to inspect the + input) so it isn't duplicated here and under the expanded view. */} + {awaitingApproval && ( +
+

+ cortex wants to run the privileged {toolName} tool + {approvalTargetLabel ? <> on {approvalTargetLabel} : null}. approve to continue, or expand to inspect the input. +

+
+ + +
+
+ )} + {/* Inline screenshot preview (always visible when available) */} {screenshotSrc && (
- arguments + input
@@ -119,7 +190,7 @@ export function ToolCallCard({ toolName, args, result, isLoading }: ToolCallCard
- result + output
diff --git a/web/app/cortex/layout.tsx b/web/app/cortex/layout.tsx new file mode 100644 index 00000000..244b1275 --- /dev/null +++ b/web/app/cortex/layout.tsx @@ -0,0 +1,29 @@ +'use client'; + +/** + * Persistent Cortex shell. + * + * `/cortex` and `/cortex/[chatId]` are separate route segments. Without a shared + * layout, navigating between them remounts the page subtree and wipes all of + * CortexChatView's local UI state (sidebar collapse, category collapse, the + * optimistic "new conversation" row). Hoisting the view into this layout keeps a + * single instance alive across those navigations; the active chat id is derived + * from the pathname and handed down as `initialChatId`. The page files render + * nothing — they exist only so the routes resolve. + */ + +import { usePathname } from 'next/navigation'; +import { CortexChatView } from './components/CortexChatView'; + +export default function CortexLayout({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const match = pathname?.match(/^\/cortex\/(.+)$/); + const initialChatId = match ? decodeURIComponent(match[1]) : undefined; + + return ( + <> + {children} + + + ); +} diff --git a/web/app/cortex/page.tsx b/web/app/cortex/page.tsx index 590c58a8..04c7ac0a 100644 --- a/web/app/cortex/page.tsx +++ b/web/app/cortex/page.tsx @@ -1,7 +1,6 @@ -'use client'; - -import { CortexChatView } from './components/CortexChatView'; - +// The Cortex view is rendered by the persistent layout (app/cortex/layout.tsx), +// which derives the active chat id from the pathname. This page exists only so +// the /cortex route resolves. export default function CortexPage() { - return ; + return null; } diff --git a/web/app/dashboard/components/AddMachineButton.tsx b/web/app/dashboard/components/AddMachineButton.tsx index f0b2935c..39fd9f26 100644 --- a/web/app/dashboard/components/AddMachineButton.tsx +++ b/web/app/dashboard/components/AddMachineButton.tsx @@ -134,7 +134,7 @@ export function AddMachineButton({ currentSiteId, currentSiteName }: AddMachineB variant="ghost" size="sm" onClick={() => setOpen(true)} - className="text-muted-foreground hover:bg-secondary hover:text-foreground cursor-pointer group" + className="text-muted-foreground cursor-pointer group" > @@ -216,7 +216,7 @@ export function AddMachineButton({ currentSiteId, currentSiteName }: AddMachineB disabled={isLoadingVersion || !downloadUrl} onClick={() => downloadUrl && window.open(downloadUrl, '_blank')} aria-label="download owlette agent" - className="text-muted-foreground hover:text-foreground hover:bg-secondary cursor-pointer p-1.5" + className="text-muted-foreground cursor-pointer p-1.5" > {isLoadingVersion ? : } @@ -240,7 +240,7 @@ export function AddMachineButton({ currentSiteId, currentSiteName }: AddMachineB } }} aria-label="copy owlette agent download link" - className="text-muted-foreground hover:text-foreground hover:bg-secondary cursor-pointer p-1.5" + className="text-muted-foreground cursor-pointer p-1.5" > @@ -314,7 +314,7 @@ export function AddMachineButton({ currentSiteId, currentSiteName }: AddMachineB size="sm" onClick={() => copyToClipboard(generatedPhrase, 'Phrase')} aria-label="copy pairing phrase" - className="border-border text-foreground hover:bg-secondary cursor-pointer shrink-0" + className="border-border text-foreground cursor-pointer shrink-0" > @@ -336,7 +336,7 @@ export function AddMachineButton({ currentSiteId, currentSiteName }: AddMachineB 'Command' )} aria-label="copy silent install command" - className="border-border text-foreground hover:bg-secondary cursor-pointer shrink-0" + className="border-border text-foreground cursor-pointer shrink-0" > @@ -351,7 +351,7 @@ export function AddMachineButton({ currentSiteId, currentSiteName }: AddMachineB variant="ghost" size="sm" onClick={() => { setGenerateSuccess(false); setGeneratedPhrase(''); handleGenerate(); }} - className="text-muted-foreground hover:text-foreground hover:bg-secondary cursor-pointer h-7 px-2 text-xs" + className="text-muted-foreground cursor-pointer h-7 px-2 text-xs" > regenerate diff --git a/web/app/dashboard/components/MachineCardView.tsx b/web/app/dashboard/components/MachineCardView.tsx index 054132dd..b3499f58 100644 --- a/web/app/dashboard/components/MachineCardView.tsx +++ b/web/app/dashboard/components/MachineCardView.tsx @@ -28,7 +28,7 @@ import { SparklineChart } from '@/components/charts'; import { ChevronDown, ChevronUp, Pencil, Square, Plus, Clock, AlertTriangle, X, RotateCcw, RotateCw, Settings2, BellOff } from 'lucide-react'; import { useAuth } from '@/contexts/AuthContext'; import { formatTemperature, getTemperatureColorClass } from '@/lib/temperatureUtils'; -import { getUsageColorClass, getUsageRingClass } from '@/lib/usageColorUtils'; +import { getUsageColorClass } from '@/lib/usageColorUtils'; import { formatHeartbeatTime, formatMachineLocalClock, formatTimezoneShortName, getDisplayTimezone } from '@/lib/timeUtils'; import { formatThroughput } from '@/lib/networkUtils'; import { DISK_IO_COLORS, formatDiskIO } from '@/lib/diskIOUtils'; @@ -225,8 +225,8 @@ function MachineCard({ ); return ( - - + +
@@ -344,7 +344,7 @@ function MachineCard({ {!statsExpanded && ( - @@ -852,7 +837,7 @@ function MachineCard({ size="sm" onClick={() => onRestartProcess(process.id, process.name)} aria-label={`restart ${process.name}`} - className="bg-card border border-border text-foreground disabled:cursor-not-allowed disabled:opacity-50 p-2" + className="bg-card border border-border/50 text-foreground disabled:cursor-not-allowed disabled:opacity-50 p-2" disabled={process.status !== 'RUNNING' && process.status !== 'LAUNCHING' && process.status !== 'STALLED'} > @@ -869,7 +854,7 @@ function MachineCard({ size="sm" onClick={() => onKillProcess(process.id, process.name)} aria-label={`kill ${process.name}`} - className="bg-card border border-border text-red-400 hover:bg-red-950/50 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-50 p-2" + className="bg-card border border-border/50 text-red-400 hover:bg-red-950/50 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-50 p-2" disabled={process.status !== 'RUNNING' && process.status !== 'LAUNCHING' && process.status !== 'STALLED'} > @@ -881,7 +866,6 @@ function MachineCard({
-
))}
{/* add process Button */} @@ -890,7 +874,7 @@ function MachineCard({ variant="ghost" size="sm" onClick={onCreateProcess} - className="bg-card border border-border text-accent-cyan hover:bg-accent-cyan/15 hover:text-accent-cyan" + className="bg-card border border-border/50 text-accent-cyan hover:bg-accent-cyan/15 hover:text-accent-cyan" > add process @@ -903,12 +887,12 @@ function MachineCard({ {/* add process button for machines with no processes */} {(!machine.processes || machine.processes.length === 0) && ( -
+
@@ -957,7 +981,7 @@ export default function DashboardPage() { variant="ghost" size="sm" onClick={() => handleViewChange('card')} - className={`cursor-pointer ${viewType === 'card' ? 'bg-secondary text-accent-cyan' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'}`} + className={`cursor-pointer ${viewType === 'card' ? 'bg-secondary text-accent-cyan' : 'text-muted-foreground'}`} > @@ -973,7 +997,7 @@ export default function DashboardPage() { size="sm" onClick={() => handleViewChange('list')} data-testid="view-toggle-list" - className={`cursor-pointer ${viewType === 'list' ? 'bg-secondary text-accent-cyan' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'}`} + className={`cursor-pointer ${viewType === 'list' ? 'bg-secondary text-accent-cyan' : 'text-muted-foreground'}`} > @@ -1027,7 +1051,7 @@ export default function DashboardPage() { {/* List View — only rendered when active */} {viewType === 'list' && ( -
+
{/* Expand/Collapse All + View Toggle */} -
+
@@ -271,7 +271,7 @@ export default function DemoPage() { size="sm" onClick={() => setViewType('card')} aria-label="card view" - className={`cursor-pointer ${viewType === 'card' ? 'bg-secondary text-accent-cyan' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'}`} + className={`cursor-pointer ${viewType === 'card' ? 'bg-secondary text-accent-cyan' : 'text-muted-foreground'}`} > @@ -287,7 +287,7 @@ export default function DemoPage() { size="sm" onClick={() => setViewType('list')} aria-label="list view" - className={`cursor-pointer ${viewType === 'list' ? 'bg-secondary text-accent-cyan' : 'text-muted-foreground hover:bg-secondary hover:text-foreground'}`} + className={`cursor-pointer ${viewType === 'list' ? 'bg-secondary text-accent-cyan' : 'text-muted-foreground'}`} > @@ -329,7 +329,7 @@ export default function DemoPage() { {/* List View */} {viewType === 'list' && ( -
+
diff --git a/web/app/globals.css b/web/app/globals.css index 59fcb27b..7bfd1be8 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -47,6 +47,8 @@ --color-popover: var(--popover); --color-card-foreground: var(--card-foreground); --color-card: var(--card); + --color-card-sunken: var(--card-sunken); + --color-card-header: var(--card-header); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); @@ -62,10 +64,21 @@ } :root { + /* Tells the browser to render native controls (date pickers + their calendar + icon, selects, scrollbars) in the light palette. Overridden under .dark below. + This is what themes the native popup app-wide. */ + color-scheme: light; --radius: 0.375rem; --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); --card: oklch(1 0 0); + --card-sunken: oklch(0.96 0 0); + /* Header surface: a slight step darker than --card-sunken (midway toward + --background) so machine headers read as distinct from their content. + Derived from themed tokens via color-mix and defined only here — it + re-resolves per theme at use time, so no .dark override is needed (and + therefore can't fall back to a light value the way a duplicated token can). */ + --card-header: color-mix(in oklch, var(--card-sunken) 30%, var(--background)); --card-foreground: oklch(0.145 0 0); --popover: oklch(1 0 0); --popover-foreground: oklch(0.145 0 0); @@ -97,9 +110,13 @@ } .dark { + /* Dark native controls: makes the calendar popup dark and + renders its calendar-picker icon light (visible on dark fields) — everywhere. */ + color-scheme: dark; --background: oklch(0.145 0.03 250); --foreground: oklch(0.985 0.01 250); --card: oklch(0.23 0.04 250); + --card-sunken: oklch(0.19 0.04 250); --card-foreground: oklch(0.985 0.01 250); --popover: oklch(0.205 0.04 250); --popover-foreground: oklch(0.985 0.01 250); @@ -145,6 +162,26 @@ --sidebar-ring: oklch(0.65 0.25 250); } +/* Native temporal inputs (date / datetime-local / time / month / week) read + `color-scheme` for BOTH the calendar popup and the picker icon. Set it directly + on the element — not only via inheritance from — so it also + applies when the input is rendered in a portal (Radix dialogs mount at ). + Unlayered so it outranks Tailwind's base layer. Theme-aware via the .dark scope. */ +input[type="date"], +input[type="datetime-local"], +input[type="time"], +input[type="month"], +input[type="week"] { + color-scheme: light; +} +.dark input[type="date"], +.dark input[type="datetime-local"], +.dark input[type="time"], +.dark input[type="month"], +.dark input[type="week"] { + color-scheme: dark; +} + @layer base { * { @apply border-border; diff --git a/web/app/login/page.tsx b/web/app/login/page.tsx index c912aff3..cab4dd3d 100644 --- a/web/app/login/page.tsx +++ b/web/app/login/page.tsx @@ -255,7 +255,7 @@ function LoginForm() { -
+
machine id {log.machineId} @@ -302,8 +366,27 @@ export default function LogsPage() { const [filterDatePreset, setFilterDatePreset] = useState('all'); const [filterDateFrom, setFilterDateFrom] = useState(''); const [filterDateTo, setFilterDateTo] = useState(''); + // Clear-logs dialog date window — independent of the page's view filters. + const [clearFrom, setClearFrom] = useState(undefined); + const [clearTo, setClearTo] = useState(undefined); const [showFilters, setShowFilters] = useState(false); + // Free-text search. `searchQuery` mirrors the input; `searchTerm` is the + // debounced, normalised value the filter actually runs against. `searchActive` + // toggles the collapsed button ↔ expanded field. + const [searchQuery, setSearchQuery] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [searchActive, setSearchActive] = useState(false); + const [searchCollapsedW, setSearchCollapsedW] = useState(); + const searchInputRef = useRef(null); + const searchWrapperRef = useRef(null); + const searchBtnRef = useRef(null); + // Full filtered scope loaded on demand while searching (see effect below). + const [searchPool, setSearchPool] = useState(null); + const [searchPoolLoading, setSearchPoolLoading] = useState(false); + const [searchPoolTruncated, setSearchPoolTruncated] = useState(false); + const isSearching = searchTerm.length > 0; + // Clear logs confirmation dialog const [showClearDialog, setShowClearDialog] = useState(false); const [screenshotModalUrl, setScreenshotModalUrl] = useState(null); @@ -335,15 +418,67 @@ export default function LogsPage() { }); }, []); - const allExpanded = logs.length > 0 && expandedLogIds.size === logs.length; + // Debounce the search input so typing doesn't re-filter/re-render on every + // keystroke once a large batch is loaded. + useEffect(() => { + const id = setTimeout(() => setSearchTerm(searchQuery.trim().toLowerCase()), 150); + return () => clearTimeout(id); + }, [searchQuery]); + + // Focus the field as it expands. + useEffect(() => { + if (searchActive) searchInputRef.current?.focus(); + }, [searchActive]); + + // Measure the collapsed button's natural width so expand/collapse can animate + // between real pixel widths — CSS can't transition to/from `auto`. Measured + // after paint while the wrapper is hugging content, so the value is exact and + // there's no layout shift. + useEffect(() => { + if (searchBtnRef.current) setSearchCollapsedW(searchBtnRef.current.offsetWidth); + }, []); + + // Collapse back to a button on outside click — but only when empty, so an + // active search is never silently hidden (e.g. clicking a log row to expand + // it while filtering). + useEffect(() => { + if (!searchActive) return; + const onMouseDown = (e: MouseEvent) => { + if (searchWrapperRef.current?.contains(e.target as Node)) return; + if (!searchQuery) setSearchActive(false); + }; + document.addEventListener('mousedown', onMouseDown); + return () => document.removeEventListener('mousedown', onMouseDown); + }, [searchActive, searchQuery]); + + // Client-side substring filter. Firestore has no full-text query, so we match + // in JS against the search pool (the full set matching the active server-side + // filters, loaded on demand) — falling back to the on-screen logs until it + // arrives. Matches the formatted action label, raw action, machine, process, + // level, and details. + const filteredLogs = useMemo(() => { + if (!searchTerm) return logs; + const source = searchPool ?? logs; + return source.filter(log => + formatAction(log.action).toLowerCase().includes(searchTerm) || + log.action.toLowerCase().includes(searchTerm) || + log.machineName?.toLowerCase().includes(searchTerm) || + log.machineId?.toLowerCase().includes(searchTerm) || + log.processName?.toLowerCase().includes(searchTerm) || + log.details?.toLowerCase().includes(searchTerm) || + log.level.toLowerCase().includes(searchTerm) + ); + }, [logs, searchPool, searchTerm]); + + const allExpanded = filteredLogs.length > 0 && filteredLogs.every(l => expandedLogIds.has(l.id)); const toggleAllExpanded = useCallback(() => { if (allExpanded) { setExpandedLogIds(new Set()); } else { - setExpandedLogIds(new Set(logs.map(l => l.id))); + setExpandedLogIds(new Set(filteredLogs.map(l => l.id))); } - }, [allExpanded, logs]); + }, [allExpanded, filteredLogs]); // Redirect if not logged in useEffect(() => { @@ -376,66 +511,16 @@ export default function LogsPage() { setLogsLoading(true); setExpandedLogIds(new Set()); - // Compute date range from preset - const dateRange = getDateRange(filterDatePreset, filterDateFrom, filterDateTo); - - // Check if any non-date filters are active (date filters use orderBy-compatible where clauses) - const hasNonDateFilters = filterAction !== 'all' || filterMachine !== 'all' || filterLevel !== 'all'; - - // Build query with filters const logsRef = collection(db, 'sites', currentSiteId, 'logs'); - let q: Query; - - // Always use orderBy('timestamp', 'desc') — date range filters are compatible with it. - // Only skip orderBy when non-date filters are active without a composite index. - if (hasNonDateFilters) { - q = query(logsRef, limit(LOGS_PER_PAGE + 1)); - } else { - q = query(logsRef, orderBy('timestamp', 'desc'), limit(LOGS_PER_PAGE + 1)); - } - - // Apply filters - if (filterAction !== 'all') { - q = query(q, where('action', '==', filterAction)); - } - if (filterMachine !== 'all') { - q = query(q, where('machineId', '==', filterMachine)); - } - if (filterLevel !== 'all') { - q = query(q, where('level', '==', filterLevel)); - } - // Date range filters — applied client-side when non-date filters are active (no composite index), - // or via Firestore where clauses when only date filters are active - if (!hasNonDateFilters) { - if (dateRange.from) { - q = query(q, where('timestamp', '>=', Timestamp.fromDate(dateRange.from))); - } - if (dateRange.to) { - q = query(q, where('timestamp', '<=', Timestamp.fromDate(dateRange.to))); - } - } + const q = buildLogsQuery( + logsRef, + { action: filterAction, machine: filterMachine, level: filterLevel, datePreset: filterDatePreset, dateFrom: filterDateFrom, dateTo: filterDateTo }, + LOGS_PER_PAGE + 1 + ); // Set up real-time listener const unsubscribe = onSnapshot(q, (snapshot) => { - let docsData = snapshot.docs.map(doc => ({ - id: doc.id, - ...doc.data() - } as LogEvent)); - - // Sort client-side by timestamp if non-date filters are active - if (hasNonDateFilters) { - docsData.sort((a, b) => b.timestamp.toMillis() - a.timestamp.toMillis()); - } - - // Apply date range client-side when non-date filters are active - if (hasNonDateFilters && (dateRange.from || dateRange.to)) { - docsData = docsData.filter(log => { - const ts = log.timestamp.toMillis(); - if (dateRange.from && ts < dateRange.from.getTime()) return false; - if (dateRange.to && ts > dateRange.to.getTime()) return false; - return true; - }); - } + const docsData = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() } as LogEvent)); // Check if there are more pages const hasMoreData = docsData.length > LOGS_PER_PAGE; @@ -443,7 +528,6 @@ export default function LogsPage() { // Remove the extra document used for pagination check const displayLogs = hasMoreData ? docsData.slice(0, LOGS_PER_PAGE) : docsData; - setLogs(displayLogs); // Set pagination marker for infinite scroll @@ -461,6 +545,49 @@ export default function LogsPage() { return () => unsubscribe(); }, [currentSiteId, filterAction, filterMachine, filterLevel, filterDatePreset, filterDateFrom, filterDateTo]); + // While searching, load the full set of logs matching the current server-side + // filters (capped) so search spans the whole scope, not just the visible 50. + // Re-runs when the filters change, not on every keystroke (the text filters + // the pool client-side in `filteredLogs`). + useEffect(() => { + if (!isSearching || !currentSiteId || !db) { + setSearchPool(null); + setSearchPoolTruncated(false); + setSearchPoolLoading(false); + return; + } + + let cancelled = false; + setSearchPoolLoading(true); + + (async () => { + try { + const logsRef = collection(db, 'sites', currentSiteId, 'logs'); + const q = buildLogsQuery( + logsRef, + { action: filterAction, machine: filterMachine, level: filterLevel, datePreset: filterDatePreset, dateFrom: filterDateFrom, dateTo: filterDateTo }, + SEARCH_POOL_CAP + 1 + ); + const snapshot = await getDocs(q); + if (cancelled) return; + + const docsData = snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() } as LogEvent)); + setSearchPoolTruncated(docsData.length > SEARCH_POOL_CAP); + setSearchPool(docsData.slice(0, SEARCH_POOL_CAP)); + } catch (error) { + if (!cancelled) { + console.error('Error loading search pool:', error); + setSearchPool(null); + } + } finally { + if (!cancelled) setSearchPoolLoading(false); + } + })(); + + return () => { cancelled = true; }; + // `isSearching` (not `searchTerm`) so we don't refetch on every keystroke. + }, [isSearching, currentSiteId, filterAction, filterMachine, filterLevel, filterDatePreset, filterDateFrom, filterDateTo]); + // Infinite scroll — load more logs const loadMore = useCallback(async () => { if (!currentSiteId || !db || !lastDoc || !hasMore || isFetchingMore) return; @@ -468,15 +595,16 @@ export default function LogsPage() { setIsFetchingMore(true); try { - const dateRange = getDateRange(filterDatePreset, filterDateFrom, filterDateTo); + // Same query as the initial page (identical ordering + filters), advanced + // past the last loaded doc — so page N+1 continues exactly where page N + // left off. Reusing buildLogsQuery keeps the two from drifting apart. const logsRef = collection(db, 'sites', currentSiteId, 'logs'); - let q = query(logsRef, orderBy('timestamp', 'desc'), startAfter(lastDoc), limit(LOGS_PER_PAGE + 1)); - - if (filterAction !== 'all') q = query(q, where('action', '==', filterAction)); - if (filterMachine !== 'all') q = query(q, where('machineId', '==', filterMachine)); - if (filterLevel !== 'all') q = query(q, where('level', '==', filterLevel)); - if (dateRange.from) q = query(q, where('timestamp', '>=', Timestamp.fromDate(dateRange.from))); - if (dateRange.to) q = query(q, where('timestamp', '<=', Timestamp.fromDate(dateRange.to))); + const baseQuery = buildLogsQuery( + logsRef, + { action: filterAction, machine: filterMachine, level: filterLevel, datePreset: filterDatePreset, dateFrom: filterDateFrom, dateTo: filterDateTo }, + LOGS_PER_PAGE + 1 + ); + const q = query(baseQuery, startAfter(lastDoc)); const snapshot = await getDocs(q); const docsData = snapshot.docs.map(doc => ({ @@ -503,7 +631,9 @@ export default function LogsPage() { // IntersectionObserver for infinite scroll sentinel useEffect(() => { const sentinel = sentinelRef.current; - if (!sentinel) return; + // Pause infinite scroll while searching: a short filtered list keeps the + // sentinel on-screen, which would otherwise auto-load every remaining page. + if (!sentinel || searchTerm) return; const observer = new IntersectionObserver( (entries) => { @@ -516,7 +646,7 @@ export default function LogsPage() { observer.observe(sentinel); return () => observer.disconnect(); - }, [hasMore, isFetchingMore, loadMore]); + }, [hasMore, isFetchingMore, loadMore, searchTerm]); const resetFilters = () => { setFilterAction('all'); @@ -533,10 +663,29 @@ export default function LogsPage() { setIsClearing(true); try { + // Date window comes from the clear dialog's own from/to date pickers. + // Resolve the bounds in the SAME timezone the logs are displayed in (not + // browser-local) so clearing "May 25" deletes May 25 as the operator sees + // it — otherwise a cross-timezone admin over-/under-deletes at the day + // boundary. Mirrors the display resolution used to render each row. + const clearTz = getDisplayTimezone( + userPreferences.timeDisplayMode || 'machine', + userPreferences.timezone, + undefined, + siteTimezone, + ); + const since = clearFrom + ? zonedTimeToUtcMs(clearFrom.getFullYear(), clearFrom.getMonth(), clearFrom.getDate(), 0, 0, 0, 0, clearTz) + : undefined; + const until = clearTo + ? zonedTimeToUtcMs(clearTo.getFullYear(), clearTo.getMonth(), clearTo.getDate(), 23, 59, 59, 999, clearTz) + : undefined; const hasFilters = filterAction !== 'all' || filterMachine !== 'all' || - filterLevel !== 'all'; + filterLevel !== 'all' || + since !== undefined || + until !== undefined; const idempotencySuffix = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' ? crypto.randomUUID() @@ -551,6 +700,8 @@ export default function LogsPage() { ...(filterAction !== 'all' ? { action: filterAction } : {}), ...(filterMachine !== 'all' ? { machineId: filterMachine } : {}), ...(filterLevel !== 'all' ? { level: filterLevel } : {}), + ...(since !== undefined ? { since } : {}), + ...(until !== undefined ? { until } : {}), ...(!hasFilters ? { all: true } : {}), }), }); @@ -571,9 +722,14 @@ export default function LogsPage() { } }; - // Get unique machines for filter + // Get unique machines for filter — drawn from the full loaded set (not the + // search-filtered view) so the dropdown doesn't collapse as you type. const uniqueMachines = Array.from(new Set(logs.map(log => log.machineId))); + // Header stats reflect the currently shown (search-filtered) logs. + const warningCount = filteredLogs.filter(l => l.level === 'warning').length; + const errorCount = filteredLogs.filter(l => l.level === 'error').length; + if (loading || sitesLoading) { return (
@@ -635,12 +791,12 @@ export default function LogsPage() {
-
0 ? 'bg-accent-cyan/10 text-accent-cyan' : 'bg-muted text-muted-foreground'}`}> +
0 ? 'bg-accent-cyan/10 text-accent-cyan' : 'bg-muted text-muted-foreground'}`}>
- {logs.length} + {filteredLogs.length}

events

@@ -649,12 +805,12 @@ export default function LogsPage() {
-
l.level === 'warning').length > 0 ? 'bg-yellow-500/10 text-yellow-400' : 'bg-muted text-muted-foreground'}`}> +
0 ? 'bg-yellow-500/10 text-yellow-400' : 'bg-muted text-muted-foreground'}`}>
- l.level === 'warning').length > 0 ? 'text-yellow-400' : 'text-foreground'}`}>{logs.filter(l => l.level === 'warning').length} + 0 ? 'text-yellow-400' : 'text-foreground'}`}>{warningCount}

warnings

@@ -663,12 +819,12 @@ export default function LogsPage() {
-
l.level === 'error').length > 0 ? 'bg-red-500/10 text-red-400' : 'bg-muted text-muted-foreground'}`}> +
0 ? 'bg-red-500/10 text-red-400' : 'bg-muted text-muted-foreground'}`}>
- l.level === 'error').length > 0 ? 'text-red-400' : 'text-foreground'}`}>{logs.filter(l => l.level === 'error').length} + 0 ? 'text-red-400' : 'text-foreground'}`}>{errorCount}

errors

@@ -677,7 +833,7 @@ export default function LogsPage() {
- {logs.length > 0 && ( + {filteredLogs.length > 0 && ( +
+ + setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + setSearchQuery(''); + setSearchActive(false); + } + }} + tabIndex={searchActive ? 0 : -1} + data-testid="logs-search" + className="h-9 w-full pl-9 pr-9 bg-muted border-border" + /> + {searchQuery && ( + + )} +
+
- {/* Filters */} - {showFilters && ( + {/* Filters — animated expand/collapse via Radix Collapsible, reusing the + shared collapsible-down/up keyframes in globals.css */} + +
@@ -801,41 +1015,62 @@ export default function LogsPage() { {filterDatePreset === 'custom' && (
- - setFilterDateFrom(e.target.value)} - className="bg-muted border-border" + + setFilterDateFrom(d ? toYMD(d) : '')} + placeholder="start date" />
- - setFilterDateTo(e.target.value)} - className="bg-muted border-border" + + setFilterDateTo(d ? toYMD(d) : '')} + placeholder="end date" />
)} + + + + {/* Search scope notice — only when the matching scope exceeds the cap */} + {isSearching && searchPoolTruncated && ( +

+ searching the most recent {SEARCH_POOL_CAP.toLocaleString()} logs in scope — add a date or machine filter to reach older entries. +

)} {/* Logs List */} - + + {!logsLoading && filteredLogs.length > 0 && ( +
+ + level + time + event + machine + process + details +
+ )}
{logsLoading ? (
loading logs...
- ) : logs.length === 0 ? ( + ) : filteredLogs.length === 0 ? (
- no logs found for this site + {isSearching + ? (searchPoolLoading && !searchPool + ? 'searching…' + : `no events match "${searchQuery.trim()}"`) + : 'no logs found for this site'}
) : ( - logs.map((log) => ( + filteredLogs.map((log) => ( - {/* Infinite scroll sentinel */} -
+ {/* Infinite scroll sentinel — disabled while searching (see observer effect) */} + {!searchTerm &&
} {isFetchingMore && (
loading more... @@ -887,18 +1122,54 @@ export default function LogsPage() { {/* Clear Logs Confirmation Dialog */} { + setShowClearDialog(o); + if (!o) { + setClearFrom(undefined); + setClearTo(undefined); + } + }} title="clear event logs" - description={ - filterAction !== 'all' || filterMachine !== 'all' || filterLevel !== 'all' - ? `this will permanently delete all logs matching the current filters.\n\nfilters active:\n${filterAction !== 'all' ? `• action: ${ACTION_TYPES.find(t => t.value === filterAction)?.label}\n` : ''}${filterMachine !== 'all' ? `• machine: ${filterMachine}\n` : ''}${filterLevel !== 'all' ? `• level: ${filterLevel}\n` : ''}\nthis action cannot be undone.` - : `this will permanently delete ALL event logs for this site (across all machines).\n\nthis action cannot be undone.` - } + description={(() => { + const scope: string[] = []; + if (filterAction !== 'all') scope.push(`• action: ${ACTION_TYPES.find(t => t.value === filterAction)?.label}`); + if (filterMachine !== 'all') scope.push(`• machine: ${filterMachine}`); + if (filterLevel !== 'all') scope.push(`• level: ${filterLevel}`); + if (clearFrom) scope.push(`• from: ${clearFrom.toLocaleDateString()}`); + if (clearTo) scope.push(`• to: ${clearTo.toLocaleDateString()}`); + const searchNote = searchTerm + ? `\n\nnote: the search box does NOT limit deletion — only the scope below applies.` + : ''; + return scope.length > 0 + ? `this will permanently delete logs matching this scope:\n${scope.join('\n')}${searchNote}\n\nthis action cannot be undone.` + : `with no date range or view filters set, this will permanently delete ALL event logs for this site (across all machines).${searchNote}\n\nthis action cannot be undone.`; + })()} confirmText="clear logs" cancelText="cancel" onConfirm={handleClearLogs} variant="destructive" - /> + > +
+
+ + (clearTo ? d > clearTo : false)} + /> +
+
+ + (clearFrom ? d < clearFrom : false)} + /> +
+
+
); } diff --git a/web/app/register/page.tsx b/web/app/register/page.tsx index 2158451f..b48fc2d4 100644 --- a/web/app/register/page.tsx +++ b/web/app/register/page.tsx @@ -209,7 +209,7 @@ export default function RegisterPage() {
diff --git a/web/components/AccountSettingsDialog.tsx b/web/components/AccountSettingsDialog.tsx index 58cfa48e..5b4f38d3 100644 --- a/web/components/AccountSettingsDialog.tsx +++ b/web/components/AccountSettingsDialog.tsx @@ -17,6 +17,12 @@ import Link from 'next/link'; import { toast } from 'sonner'; import { PasskeyManager } from '@/components/PasskeyManager'; import { getBrowserTimezone } from '@/lib/timeUtils'; +import { + SCOPE_PRESETS, + SCOPE_PRESET_KEYS, + SCOPE_PRESET_DESCRIPTIONS, + type ApiKeyScopePreset, +} from '@/lib/apiKeyTypes'; import { TimezoneSelect } from '@/components/TimezoneSelect'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; @@ -112,6 +118,7 @@ export function AccountSettingsDialog({ open, onOpenChange, initialSection }: Ac const [apiKeys, setApiKeys] = useState([]); const [apiKeysLoading] = useState(false); const [newKeyName, setNewKeyName] = useState(''); + const [keyScopePreset, setKeyScopePreset] = useState('publisher'); const [createdKey, setCreatedKey] = useState(null); const [creatingKey, setCreatingKey] = useState(false); const [revokingKeyId, setRevokingKeyId] = useState(null); @@ -184,7 +191,7 @@ export function AccountSettingsDialog({ open, onOpenChange, initialSection }: Ac .catch(() => {}); // Load API keys - fetch('/api/account/api-keys') + fetch('/api/keys') .then((res) => res.json()) .then((data) => { if (data.success) setApiKeys(data.keys || []); @@ -212,6 +219,7 @@ export function AccountSettingsDialog({ open, onOpenChange, initialSection }: Ac setShowLlmKey(false); setApiKeys([]); setNewKeyName(''); + setKeyScopePreset('publisher'); setCreatedKey(null); setCreatingKey(false); setActiveSection('profile'); @@ -1133,41 +1141,74 @@ export function AccountSettingsDialog({ open, onOpenChange, initialSection }: Ac {/* Create new key */}
-
-
- - setNewKeyName(e.target.value)} - className="border-border bg-background text-white" - disabled={creatingKey} - /> -
+
+ + setNewKeyName(e.target.value)} + className="border-border bg-background text-white" + disabled={creatingKey} + /> +
+
+ + +

{SCOPE_PRESET_DESCRIPTIONS[keyScopePreset]}

+
+
+

+ need custom scopes?{' '} + onOpenChange(false)} + className="text-accent-cyan hover:underline" + > + manage api keys + +

@@ -1209,14 +1250,15 @@ export function AccountSettingsDialog({ open, onOpenChange, initialSection }: Ac onClick={async () => { setRevokingKeyId(k.id); try { - const res = await fetch(`/api/account/api-keys/${encodeURIComponent(k.id)}`, { + const res = await fetch(`/api/keys/${encodeURIComponent(k.id)}`, { method: 'DELETE', }); if (res.ok) { setApiKeys((prev) => prev.filter((key) => key.id !== k.id)); toast.success('API key revoked'); } else { - toast.error('Failed to revoke key'); + const data = await res.json().catch(() => ({})); + toast.error(data.detail || data.error || 'Failed to revoke key'); } } catch { toast.error('Failed to revoke key'); diff --git a/web/components/ConfirmDialog.tsx b/web/components/ConfirmDialog.tsx index c399d1e2..5eb56952 100644 --- a/web/components/ConfirmDialog.tsx +++ b/web/components/ConfirmDialog.tsx @@ -20,6 +20,8 @@ interface ConfirmDialogProps { cancelText?: string; onConfirm: () => void; variant?: 'default' | 'destructive'; + /** Optional extra content rendered between the description and the buttons. */ + children?: React.ReactNode; } export default function ConfirmDialog({ @@ -31,6 +33,7 @@ export default function ConfirmDialog({ cancelText = 'Cancel', onConfirm, variant = 'default', + children, }: ConfirmDialogProps) { const handleConfirm = () => { onConfirm(); @@ -46,8 +49,9 @@ export default function ConfirmDialog({ {description} + {children} - diff --git a/web/components/ManageSitesDialog.tsx b/web/components/ManageSitesDialog.tsx index e90441cd..121e46e0 100644 --- a/web/components/ManageSitesDialog.tsx +++ b/web/components/ManageSitesDialog.tsx @@ -43,9 +43,9 @@ export function ManageSitesDialog({ onDeleteSite, onCreateSite, }: ManageSitesDialogProps) { - // When admin, fetch all users so we can display the owner of foreign sites. - // Lazily resolve owner UIDs → emails for sites not owned by the current admin. - const { users: allUsers } = useUserManagement(); + // When superadmin, fetch all users so we can display the owner of foreign sites. + // Lazily resolve owner UIDs to emails for sites not owned by the current admin. + const { users: allUsers } = useUserManagement(Boolean(isSuperadmin)); const ownerEmailByUid = React.useMemo(() => { if (!isSuperadmin) return new Map(); const map = new Map(); diff --git a/web/components/PasskeyManager.tsx b/web/components/PasskeyManager.tsx index eb968bfd..3d4e3c91 100644 --- a/web/components/PasskeyManager.tsx +++ b/web/components/PasskeyManager.tsx @@ -242,7 +242,7 @@ export function PasskeyManager({ userId, compact = false }: PasskeyManagerProps) diff --git a/web/components/charts/DisplayLayoutPanel.tsx b/web/components/charts/DisplayLayoutPanel.tsx index 904ed73f..f0f4ceae 100644 --- a/web/components/charts/DisplayLayoutPanel.tsx +++ b/web/components/charts/DisplayLayoutPanel.tsx @@ -1091,8 +1091,8 @@ export function DisplayLayoutPanel({ } return ( -
-
+
+
{/* Single header row: machine title, tabs, write actions, close. diff --git a/web/components/charts/MetricsDetailPanel.tsx b/web/components/charts/MetricsDetailPanel.tsx index 932ab323..303eed74 100644 --- a/web/components/charts/MetricsDetailPanel.tsx +++ b/web/components/charts/MetricsDetailPanel.tsx @@ -1049,7 +1049,7 @@ export function MetricsDetailPanel({ }, [availableMetrics, effectiveMetrics, selectedNics, nicNames, networkMode, selectedDisks, diskNames, driveOrder, selectedGpus, gpuNames, effectiveDiskIO, diskIOMode, gpuDisplayLabel]); return ( - + {/* Title row */}
@@ -1240,7 +1240,7 @@ export function MetricsDetailPanel({
{/* Chart Area */} -
+
{error ? (
{error}
@@ -1253,6 +1253,14 @@ export function MetricsDetailPanel({ data appears as the agent collects metrics.
+ ) : !activeLines.some((line) => !line.hidden) ? ( +
+
+ no metrics selected. +
+ toggle a metric above to view its chart. +
+
) : ( @@ -1359,7 +1367,7 @@ export function MetricsDetailPanel({ cards align with the chart's plot area (the "0" on the x-axis). */} {chartData.length > 0 && hasSelection && (
setHoveredKey(null)} > @@ -1396,36 +1404,37 @@ export function MetricsDetailPanel({ return (
setHoveredKey(key)} > - {/* Metric label — Thermometer icon for temp entries + {/* Metric label — left. Thermometer icon for temp entries (cpuTemp/gpuTemp and per-GPU _temp), ArrowUp/ArrowDown for NIC TX/RX. Both disambiguate siblings that share the same base label (CPU usage vs CPU temp, Ethernet TX vs Ethernet RX). */} -
+
{label} {showThermometer && } {direction === 'tx' && } {direction === 'rx' && }
-
-
-
avg
-
+ {/* Stats — enclosed section floated right, ordered min / avg / max. */} +
+
+
min
+
{fmtMin}
+
+
+
avg
+
{fmtAvg} {isNetwork && ({formatThroughput(avgThroughput)})}
-
-
max
-
{fmtMax}
-
-
-
min
-
{fmtMin}
+
+
max
+
{fmtMax}
diff --git a/web/components/ui/button.tsx b/web/components/ui/button.tsx index bd1b1061..a2dd71b7 100644 --- a/web/components/ui/button.tsx +++ b/web/components/ui/button.tsx @@ -11,13 +11,13 @@ const buttonVariants = cva( variant: { default: "bg-primary text-primary-foreground hover:bg-primary/90", destructive: - "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 dark:hover:bg-destructive/80", outline: - "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input", + "border bg-background shadow-xs hover:bg-secondary hover:text-secondary-foreground dark:bg-input/30 dark:border-input dark:hover:bg-secondary dark:hover:text-secondary-foreground", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: - "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + "hover:bg-secondary hover:text-secondary-foreground", link: "text-primary underline-offset-4 hover:underline", }, size: { diff --git a/web/components/ui/calendar.tsx b/web/components/ui/calendar.tsx new file mode 100644 index 00000000..3d50d9ae --- /dev/null +++ b/web/components/ui/calendar.tsx @@ -0,0 +1,220 @@ +"use client" + +import * as React from "react" +import { + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, +} from "lucide-react" +import { + DayPicker, + getDefaultClassNames, + type DayButton, +} from "react-day-picker" + +import { cn } from "@/lib/utils" +import { Button, buttonVariants } from "@/components/ui/button" + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = "label", + buttonVariant = "ghost", + formatters, + components, + ...props +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps["variant"] +}) { + const defaultClassNames = getDefaultClassNames() + + return ( + svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className + )} + captionLayout={captionLayout} + formatters={{ + formatMonthDropdown: (date) => + date.toLocaleString("default", { month: "short" }), + ...formatters, + }} + classNames={{ + root: cn("w-fit", defaultClassNames.root), + months: cn( + "relative flex flex-col gap-4 md:flex-row", + defaultClassNames.months + ), + month: cn("flex w-full flex-col gap-4", defaultClassNames.month), + nav: cn( + "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", + defaultClassNames.nav + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) p-0 select-none aria-disabled:opacity-50", + defaultClassNames.button_previous + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "size-(--cell-size) p-0 select-none aria-disabled:opacity-50", + defaultClassNames.button_next + ), + month_caption: cn( + "flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)", + defaultClassNames.month_caption + ), + dropdowns: cn( + "flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium", + defaultClassNames.dropdowns + ), + dropdown_root: cn( + "relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50", + defaultClassNames.dropdown_root + ), + dropdown: cn( + "absolute inset-0 bg-popover opacity-0", + defaultClassNames.dropdown + ), + caption_label: cn( + "font-medium select-none", + captionLayout === "label" + ? "text-sm" + : "flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground", + defaultClassNames.caption_label + ), + month_grid: "w-full border-collapse", + weekdays: cn("flex", defaultClassNames.weekdays), + weekday: cn( + "flex-1 rounded-md text-[0.8rem] font-normal text-muted-foreground select-none", + defaultClassNames.weekday + ), + week: cn("mt-2 flex w-full", defaultClassNames.week), + week_number_header: cn( + "w-(--cell-size) select-none", + defaultClassNames.week_number_header + ), + week_number: cn( + "text-[0.8rem] text-muted-foreground select-none", + defaultClassNames.week_number + ), + day: cn( + "group/day relative aspect-square h-full w-full p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-md", + props.showWeekNumber + ? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md" + : "[&:first-child[data-selected=true]_button]:rounded-l-md", + defaultClassNames.day + ), + range_start: cn( + "rounded-l-md bg-accent", + defaultClassNames.range_start + ), + range_middle: cn("rounded-none", defaultClassNames.range_middle), + range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end), + today: cn( + "rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none", + defaultClassNames.today + ), + outside: cn( + "text-muted-foreground aria-selected:text-muted-foreground", + defaultClassNames.outside + ), + disabled: cn( + "text-muted-foreground opacity-50", + defaultClassNames.disabled + ), + hidden: cn("invisible", defaultClassNames.hidden), + ...classNames, + }} + components={{ + Root: ({ className, rootRef, ...props }) => { + return ( +
+ ) + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + + ) + } + + if (orientation === "right") { + return ( + + ) + } + + return ( + + ) + }, + DayButton: CalendarDayButton, + WeekNumber: ({ children, ...props }) => { + return ( +
+ ) + }, + ...components, + }} + {...props} + /> + ) +} + +function CalendarDayButton({ + className, + day, + modifiers, + ...props +}: React.ComponentProps) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( + + + + { + onChange(date) + setOpen(false) + }} + disabled={disabled} + autoFocus + /> + + + + ) +} diff --git a/web/content/docs/changelog.mdx b/web/content/docs/changelog.mdx index a0ccead2..c5e74dd9 100644 --- a/web/content/docs/changelog.mdx +++ b/web/content/docs/changelog.mdx @@ -5,6 +5,46 @@ description: "All notable changes to owlette are documented here. The format is --- +## [Unreleased] + +> Web/dashboard changes shipped to prod since 2.12.3. No version bump — the +> version number tracks the agent, and this batch contains no agent changes. + +### added + +- **Cortex tier-3 tool-approval gate.** Tier-3 tool calls now require explicit in-chat approval. `/api/cortex` migrated to the UIMessage protocol (`convertToModelMessages`); the per-site approval flag is default-on and forces the server-side path. Includes Cortex sidebar/UX fixes and a persistent chat layout. +- **Scoped full-text search on the logs page**, plus an animated filters panel. +- **Date-scoped log clearing.** Clear-logs deletion accepts a `since`/`until` range, with the date window computed in the display timezone. +- **Themed date picker** (shadcn calendar + popover input) and dark-mode theming of native form controls via `color-scheme`. +- **Site admins can remove machines on their assigned sites** (previously superadmin-only). +- **Admin user management: last-seen column and deleted-user visibility.** +- **Any authenticated user can create API keys from account settings** — the account dialog was wrongly pointed at the superadmin-only `/api/account/api-keys`; it now uses the user-scoped `/api/keys` with an explicit scope-preset selector. +- **`GET /api/users/deletions`** is now documented in the OpenAPI spec; new Firestore composite indexes back the log filter combinations and the deletions audit feed. +- **CLI / SDK OIDC trusted-publishing workflows** for `@owlette/cli` and the SDKs. + +### fixed + +- **Dashboard "Missing or insufficient permissions" error (Sentry OWLETTE-WEB-3R).** `useUserManagement` opened a realtime listener over the whole `users` collection (superadmin-only per `firestore.rules`) for *every* user, via `ManageSitesDialog` mounted on the dashboard/roosts/logs/deployments pages — so every non-superadmin tripped a permission-denied on load. The listener is now gated to superadmins (client-side; rules unchanged). +- **Quieted expected `permission-denied` noise** on the site-availability check (`CreateSiteDialog`) and Cortex chat-URL load, and **blocked agent ID tokens from minting user API keys** (`POST /api/keys`). +- **Logs are ordered by timestamp across all filter combinations.** +- **roost:** config-only republish honors content-addressed CAS on the no-op branch; a restated deploy config is applied on same-version republish. +- **Cortex conversation rows are a11y-accessible** (no nested buttons). +- **Dark-mode button hover repaired** and standardized on the secondary rollover. +- **Metrics panel** persistence restored (gated on a non-empty selection; no auto-restore on load); empty metrics-slide gap removed; inline sparklines read hourly `metrics_history` buckets; disk r/w throughput right-aligned in the machine list. + +### changed + +- **Logs page refactor** — themed date pickers, date-scoped clear, aligned-column table. +- **Sunken machine card/list surfaces** with section enclosures; metrics/displays panels darkened with brighter content. +- **CLI pre-publish hardening** (6-wave review): request timeouts, idempotency-key surfacing on unconfirmed failures; `owlette key` removed (key management is dashboard-only). +- Removed the dead `MachineListView` wrapper and redundant per-instance button hover overrides. + +### infrastructure / docs + +- `/preflight` pre-push gate + `post-push-e2e` watch hook; build-system skill expanded with the installer-release + version-bump flow. +- CI: bumped checkout/setup-node/setup-java/cache to Node 24 action majors; py-sdk publish now runs pytest first. +- Layperson video-tutorial series + Playwright video-capture harness. + ## [2.12.3] - 2026-05-19 ### fixed diff --git a/web/e2e/specs/access-control/user-mgmt.spec.ts b/web/e2e/specs/access-control/user-mgmt.spec.ts index e1c2555b..8466dbf5 100644 --- a/web/e2e/specs/access-control/user-mgmt.spec.ts +++ b/web/e2e/specs/access-control/user-mgmt.spec.ts @@ -56,7 +56,7 @@ test.describe('/admin/users — role badges', () => { const row = page.getByRole('row', { name: /super@e2e\.test/ }); await expect(row.getByText('superadmin', { exact: true })).toBeVisible(); await expect(row.getByText('all sites')).toBeVisible(); - await expect(row.getByText('You', { exact: true })).toBeVisible(); + await expect(row.getByText('you', { exact: true })).toBeVisible(); }); test('admin row shows admin badge + assigned site pill', async ({ page }) => { diff --git a/web/e2e/specs/roosts/empty-roost-state.spec.ts b/web/e2e/specs/roosts/empty-roost-state.spec.ts index ef38f134..daae60ac 100644 --- a/web/e2e/specs/roosts/empty-roost-state.spec.ts +++ b/web/e2e/specs/roosts/empty-roost-state.spec.ts @@ -22,10 +22,7 @@ const ROOST_ID = 'rst_test_empty_001'; const ROOST_NAME = 'empty-roost'; function isKnownPageChromeNoise(message: string): boolean { - return ( - message.includes('Error fetching users: FirebaseError') || - message === '[Error] An error occurred' - ); + return message === '[Error] An error occurred'; } async function cleanup() { diff --git a/web/e2e/specs/visual/button-hover.spec.ts b/web/e2e/specs/visual/button-hover.spec.ts new file mode 100644 index 00000000..93137e78 --- /dev/null +++ b/web/e2e/specs/visual/button-hover.spec.ts @@ -0,0 +1,43 @@ +import { test, expect, type Locator } from '@playwright/test'; +import { roleState } from '../../helpers/roles'; +import { seedLogEvents } from '../../helpers/coverageSeed'; + +// Guards the button hover standard: outline buttons must visibly change their +// background on hover (the base shadcn `hover:bg-accent`). Hover went "dead" on +// the logs toolbar when individual buttons overrode it with the near-invisible +// `hover:bg-muted` — this catches that regression class. The assertion is +// theme-agnostic: it only requires that hover changes the background, which +// holds in both the light and dark token sets. +test.describe('button hover states', () => { + test.use(roleState('admin')); + + const bgColor = (loc: Locator) => + loc.evaluate((el) => getComputedStyle(el).backgroundColor); + + test('outline toolbar buttons change background on hover', async ({ page }) => { + await seedLogEvents('site-A'); + await page.goto('/logs'); + await expect(page.getByRole('heading', { name: /^logs$/i })).toBeVisible(); + + const buttons = [ + page.getByRole('button', { name: /search logs/i }), + page.getByRole('button', { name: /show filters/i }), + ]; + + for (const button of buttons) { + await expect(button).toBeVisible(); + const rest = await bgColor(button); + + await button.hover(); + await page.waitForTimeout(250); // let the hover transition settle + + const hovered = await bgColor(button); + expect(hovered, 'hover background should differ from the resting state').not.toBe(rest); + expect(hovered, 'hover background should not be transparent').not.toBe('rgba(0, 0, 0, 0)'); + + // Reset so the next button is measured from its true resting state. + await page.mouse.move(0, 0); + await page.waitForTimeout(250); + } + }); +}); diff --git a/web/e2e/videos/README.md b/web/e2e/videos/README.md new file mode 100644 index 00000000..fe3bd9df --- /dev/null +++ b/web/e2e/videos/README.md @@ -0,0 +1,83 @@ +# Tutorial web-capture harness + +Drives the dashboard at 1080p against the seeded demo fleet and records one `.webm` +per scene — the web-footage half of the tutorial pipeline (the other halves are +ElevenLabs voiceover and pywinauto native capture; see `dev/video-tutorials/`). + +This is a **sibling of the screenshots harness** (`../screenshots/`). It reuses the +same emulator boot, `global-setup` (role fixtures), `webServer`, and — crucially — the +same deterministic demo data (`../screenshots/fixtures.ts`: a 10-machine AV/signage +fleet, Cortex chats, roost rollouts, schedule presets). + +> **Status:** this harness currently ships **one worked example scene** — episode 3 +> (dashboard tour), beats b01–b04 (`dashboard-tour.video.ts`). The scenario→episode +> table below is the **target map**; the remaining scenes are built by copying the +> example. + +## Run + +```bash +cd web +npm run videos # the implemented example scene(s) +npm run videos -- --grep "dashboard" # one scene (grep matches the test title) +npm run videos:debug # headed + inspector, to tune selectors/pacing +``` + +Prereqs are identical to the E2E suite (JDK 21, firebase-tools 13, chromium installed). +Output: `web/e2e/.output/videos/.webm`. + +## How a scene works + +```ts +test('episode N — title', async ({ browser }) => { + const ctx = await seedScreenshotFixtures('dashboard-mixed-states'); // pick a scenario + try { + await getAdminDb().collection('users').doc(TEST_USERS.admin.uid) + .set({ lastSiteId: ctx.siteId }, { merge: true }); // auto-select the site + await recordScene(browser, 'NN-slug', { baseURL: E2E_BASE_URL, + storageState: roleState('admin').storageState }, async (page) => { + await openForCapture(page, '/dashboard'); // goto + settle + await narrate(page, 'b01 ...', 6); // dwell ~6s for the beat + await clickWithCursor(page, page.getByTestId('view-toggle-list')); + await narrate(page, 'b02 ...', 8); + }); + } finally { + await ctx.cleanup(); + } +}); +``` + +- File names end in `.video.ts` (the config's `testMatch`). +- Each scene records its OWN context (`recordScene`) so the `.webm` is named after the + episode, not Playwright's auto hash. +- `narrate(page, beat, seconds)` is a dwell sized to that beat's voiceover length — it's + what keeps the screen on a frame long enough to lay the MP3 underneath. +- `installFakeCursor` (called by `recordScene`) draws a visible pointer + click ripple; + headless Chromium has no OS cursor, so without it clicks look like nothing happened. + +## Why `recordVideo`, not OBS, for web + +Built-in `recordVideo` at an explicit 1920×1080 (not the downscaled 800×800 default) is +turnkey and repeatable — no human in the loop, regenerates whenever the UI changes. The +frame rate is screencast-variable (fine for UI demos). If you ever want buttery 60fps +for a hero moment, run `npm run videos:debug` (headed) and capture that window in OBS +instead; the scene code is identical. + +## Determinism + +Inherited from the screenshots harness: fixed clock (`FIXED_NOW_MS`), disabled CSS +animations, seeded PRNG sparklines, fixed machine ids. See `../screenshots/README.md`. + +## Target scenario → episode map + +_Only the dashboard-tour example (ep3, b01–b04) is implemented today; the rest is the build plan._ + +| Scenario (fixtures.ts) | Episode | +|---|---| +| `dashboard-mixed-states` | 1 (b-roll), 3 (dashboard), 7 (remote actions) | +| `monitor-single-machine` | 6 (machine health) | +| `control-process-restarting` | 4 (keep alive), 13 (logs) | +| `automate-schedule-editor` | 5 (schedule), 11 (alerts) | +| `deploy-roost-rolling` | 9 (deploy), 10 (roost) | +| `diagnose-cortex-chat` | 12 (cortex) | +| `display-layout-editor` | optional display add-on | diff --git a/web/e2e/videos/dashboard-tour.video.ts b/web/e2e/videos/dashboard-tour.video.ts new file mode 100644 index 00000000..092589f8 --- /dev/null +++ b/web/e2e/videos/dashboard-tour.video.ts @@ -0,0 +1,72 @@ +/** + * Scene — episode 3, "the dashboard, end to end". + * + * Reference implementation of a web-capture scene — and the ONLY scene implemented so + * far. It covers episode 3 beats b01–b04: orient on the fleet, read a card, open a + * metric detail panel, flip to list view. Beats b05–b06 (expand/collapse-all + nav + * tour) and the other episodes' scenes are still to be built by copying this pattern. + * The `narrate()` dwell after each action is sized to roughly match that beat's + * voiceover so the MP3 drops straight underneath in the editor. + * + * Reuses the screenshots harness verbatim: the `dashboard-mixed-states` fixture (10 + * seeded machines) and the admin role storageState. Selectors are the same ones the + * screenshot specs use (machine-card, view-toggle-list, machine-row, the "cpu" tile). + * + * Run: cd web && npm run videos -- --grep "dashboard" + * Out: web/e2e/.output/videos/03-dashboard-tour.webm + */ + +import { test, expect } from '@playwright/test'; +import { roleState } from '../helpers/roles'; +import { getAdminDb, E2E_BASE_URL } from '../helpers/emulator'; +import { TEST_USERS } from '../helpers/seed'; +import { seedScreenshotFixtures } from '../screenshots/fixtures'; +import { + recordScene, + openForCapture, + narrate, + clickWithCursor, + highlight, +} from './video-helpers'; + +test('episode 3 — dashboard tour', async ({ browser }) => { + const ctx = await seedScreenshotFixtures('dashboard-mixed-states'); + try { + // Auto-select the seeded site on load (admin is also on the baseline site-A). + await getAdminDb() + .collection('users') + .doc(TEST_USERS.admin.uid) + .set({ lastSiteId: ctx.siteId }, { merge: true }); + + await recordScene( + browser, + '03-dashboard-tour', + { baseURL: E2E_BASE_URL, storageState: roleState('admin').storageState }, + async (page) => { + // [b01] orientation — the fleet at a glance + await openForCapture(page, '/dashboard'); + await expect(page.getByTestId('machine-card')).toHaveCount(10); + await narrate(page, 'b01 orientation', 6); + + // [b02] card anatomy — draw the eye to a single card + const focusCard = page + .getByTestId('machine-card') + .filter({ hasText: 'media-server-stage' }); + await highlight(page, focusCard); + await narrate(page, 'b02 card anatomy', 8); + + // [b03] open the metrics detail panel via the CPU tile + await clickWithCursor(page, focusCard.getByText('cpu', { exact: true }).first()); + await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' })); + await narrate(page, 'b03 metric panel', 9); + + // [b04] flip to the dense list view + await clickWithCursor(page, page.getByTestId('view-toggle-list')); + await expect(page.getByTestId('machine-row').first()).toBeVisible(); + await narrate(page, 'b04 list view', 6); + }, + ); + } finally { + await ctx.cleanup(); + } +}); diff --git a/web/e2e/videos/video-helpers.ts b/web/e2e/videos/video-helpers.ts new file mode 100644 index 00000000..0b9211a8 --- /dev/null +++ b/web/e2e/videos/video-helpers.ts @@ -0,0 +1,185 @@ +/** + * Video-capture helpers for the tutorial pipeline. + * + * The screenshots harness froze a single frame; here we capture motion, so we add: + * - a fake on-screen cursor (headless Chromium has no OS pointer, so recorded video + * would otherwise show clicks happening with no visible mouse), + * - human-paced movement / typing so the footage is watchable, + * - `narrate()` dwell gaps so each beat lingers long enough to lay its MP3 underneath. + * + * Determinism is inherited from the screenshots harness: fixed clock + disabled + * animations (see ../screenshots/docs-helpers.ts), seeded fixture data + * (../screenshots/fixtures.ts). + * + * Scenes drive their own browser context via `recordScene()` so each .webm is named + * after the episode/scene rather than Playwright's auto hash. + */ + +import { mkdir } from 'node:fs/promises'; +import path from 'node:path'; +import type { Browser, Locator, Page } from '@playwright/test'; +import { disableAnimations } from '../screenshots/docs-helpers'; +import { FIXED_NOW_MS } from '../screenshots/fixtures'; + +/** Clean, named .webm output lands here. */ +export const VIDEO_OUT_DIR = path.resolve(__dirname, '..', '.output', 'videos'); +/** Raw per-context recordings (auto-named) land here before being copied out. */ +const VIDEO_RAW_DIR = path.resolve(__dirname, '..', '.output', 'videos-raw'); + +const VIEWPORT = { width: 1920, height: 1080 } as const; + +export interface RecordSceneOptions { + /** App origin, e.g. http://127.0.0.1:3100. */ + baseURL: string; + /** Path to a role storageState fixture (use roleState('admin').storageState). */ + storageState: string; +} + +/** + * Run `scene` inside a fresh recorded context and save the result to + * `e2e/.output/videos/{sceneName}.webm`. A fixed clock is installed before the scene + * runs so relative timestamps match the seeded fixture data. + */ +export async function recordScene( + browser: Browser, + sceneName: string, + opts: RecordSceneOptions, + scene: (page: Page) => Promise, +): Promise { + await mkdir(VIDEO_RAW_DIR, { recursive: true }); + await mkdir(VIDEO_OUT_DIR, { recursive: true }); + + const context = await browser.newContext({ + baseURL: opts.baseURL, + storageState: opts.storageState, + viewport: VIEWPORT, + deviceScaleFactor: 1, + recordVideo: { dir: VIDEO_RAW_DIR, size: VIEWPORT }, + }); + const page = await context.newPage(); + await installFakeCursor(page); + await page.clock.install({ time: FIXED_NOW_MS }); + + await scene(page); + + const video = page.video(); + await context.close(); // finalizes the recording + const target = path.join(VIDEO_OUT_DIR, `${sceneName}.webm`); + if (video) await video.saveAs(target); + return target; +} + +/** + * Inject a visible cursor that follows Playwright's mouse events, plus a click ripple. + * Runs on every navigation (addInitScript) so it survives page transitions. + */ +export async function installFakeCursor(page: Page): Promise { + await page.addInitScript(() => { + const CURSOR_ID = '__owl_cursor__'; + const mount = (): void => { + if (!document.body || document.getElementById(CURSOR_ID)) return; + const cursor = document.createElement('div'); + cursor.id = CURSOR_ID; + cursor.style.cssText = [ + 'position:fixed', 'left:0', 'top:0', 'z-index:2147483647', + 'width:20px', 'height:20px', 'margin:-2px 0 0 -2px', 'pointer-events:none', + 'filter:drop-shadow(0 1px 2px rgba(0,0,0,0.4))', + ].join(';'); + cursor.innerHTML = + '' + + ''; + document.body.appendChild(cursor); + + window.addEventListener( + 'mousemove', + (e) => { + cursor.style.left = `${e.clientX}px`; + cursor.style.top = `${e.clientY}px`; + }, + true, + ); + window.addEventListener( + 'mousedown', + (e) => { + const ripple = document.createElement('div'); + ripple.style.cssText = [ + 'position:fixed', `left:${e.clientX - 14}px`, `top:${e.clientY - 14}px`, + 'width:28px', 'height:28px', 'border-radius:50%', 'pointer-events:none', + 'z-index:2147483646', 'border:2px solid rgba(99,102,241,0.95)', + ].join(';'); + document.body.appendChild(ripple); + ripple + .animate( + [ + { transform: 'scale(0.3)', opacity: 1 }, + { transform: 'scale(1.8)', opacity: 0 }, + ], + { duration: 450, easing: 'ease-out' }, + ) + .addEventListener('finish', () => ripple.remove()); + }, + true, + ); + }; + if (document.body) mount(); + else window.addEventListener('DOMContentLoaded', mount); + }); +} + +/** Open the dashboard (or any path) and quiet the page for capture. */ +export async function openForCapture(page: Page, urlPath: string): Promise { + await page.goto(urlPath, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(1200); // let Firestore listeners hydrate the fixture data + await disableAnimations(page); + await page.clock.setFixedTime(FIXED_NOW_MS); +} + +/** + * Dwell on the current frame for `seconds`, long enough to lay this beat's narration + * MP3 underneath in the editor. The label is logged so you can match capture to beat. + */ +export async function narrate(page: Page, beat: string, seconds: number): Promise { + console.log(` [vo] ${beat} (~${seconds}s)`); + await page.waitForTimeout(Math.round(seconds * 1000)); +} + +/** Glide the cursor to an element's center (visible movement, not a teleport). */ +export async function moveCursorTo(page: Page, locator: Locator): Promise { + await locator.scrollIntoViewIfNeeded(); + const box = await locator.boundingBox(); + if (!box) throw new Error('moveCursorTo: target has no bounding box (not visible?)'); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 24 }); +} + +/** Move to an element, pause a beat, then click it. */ +export async function clickWithCursor(page: Page, locator: Locator): Promise { + await moveCursorTo(page, locator); + await page.waitForTimeout(250); + await locator.click(); +} + +/** Type into a field one character at a time so the keystrokes read on screen. */ +export async function typewrite( + page: Page, + locator: Locator, + text: string, + perCharMs = 55, +): Promise { + await clickWithCursor(page, locator); + await locator.pressSequentially(text, { delay: perCharMs }); +} + +/** Briefly outline an element to draw the eye (auto-clears). */ +export async function highlight(page: Page, locator: Locator, ms = 1400): Promise { + await moveCursorTo(page, locator); + await locator.evaluate((el: SVGElement | HTMLElement, dur: number) => { + const prevOutline = el.style.outline; + const prevOffset = el.style.outlineOffset; + el.style.outline = '3px solid rgba(99,102,241,0.95)'; + el.style.outlineOffset = '3px'; + window.setTimeout(() => { + el.style.outline = prevOutline; + el.style.outlineOffset = prevOffset; + }, dur); + }, ms); +} diff --git a/web/hooks/useCortex.ts b/web/hooks/useCortex.ts index 413ad311..1627aa6f 100644 --- a/web/hooks/useCortex.ts +++ b/web/hooks/useCortex.ts @@ -10,7 +10,7 @@ 'use client'; import { useChat as useAIChat } from '@ai-sdk/react'; -import { DefaultChatTransport } from 'ai'; +import { DefaultChatTransport, lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { collection, @@ -71,6 +71,11 @@ export function useOwletteChat({ siteId, machineId, machineName, onChatPersisted const [chatLoadError, setChatLoadError] = useState(null); const loadChatRequestRef = useRef(0); const isMountedRef = useRef(true); + // The current unpersisted "new conversation" row. Held in a ref so a Firestore + // snapshot refire (which rebuilds `conversations` from persisted docs) doesn't + // erase the optimistic entry before the first message is saved. Cleared once + // its doc shows up in a snapshot, or when it's discarded/deleted. + const draftConvoRef = useRef(null); useEffect(() => { isMountedRef.current = true; @@ -106,41 +111,13 @@ export function useOwletteChat({ siteId, machineId, machineName, onChatPersisted () => new DefaultChatTransport({ api: '/api/cortex', + // Send the full UIMessages (text + file + tool/approval parts). The + // server reconstructs ModelMessages via `convertToModelMessages`, which + // is what lets a tier-3 approval round-trip carry the pending tool call + // and the approve/deny decision back so streamText can resume. prepareSendMessagesRequest: ({ messages }) => ({ body: { - messages: messages.map((m) => { - const hasFiles = m.parts.some((p) => p.type === 'file'); - - if (!hasFiles) { - // Text-only message — send as plain string for backwards compat - return { - role: m.role, - content: m.parts - .filter((p): p is { type: 'text'; text: string } => p.type === 'text') - .map((p) => p.text) - .join('') || '', - }; - } - - // Multimodal message — send as AI SDK content block array - const content: Array> = []; - for (const p of m.parts) { - if (p.type === 'text') { - content.push({ type: 'text', text: (p as { text: string }).text }); - } else if (p.type === 'file') { - const fp = p as FileUIPart; - if (fp.mediaType?.startsWith('image/')) { - // AI SDK ImagePart format: { type: 'image', image: url, mediaType } - content.push({ - type: 'image', - image: fp.url, - mediaType: fp.mediaType, - }); - } - } - } - return { role: m.role, content }; - }), + messages, siteId: siteIdRef.current, machineId: machineIdRef.current, machineName: machineNameRef.current, @@ -154,6 +131,10 @@ export function useOwletteChat({ siteId, machineId, machineName, onChatPersisted const chat = useAIChat({ id: chatId, transport, + // Once the user has answered every pending tier-3 approval on the last + // assistant message, automatically re-send so the SDK resumes (executes + // approved tools, feeds denials back to the model). + sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses, onFinish: async () => { // Persist conversation metadata + messages after assistant response if (user && db) { @@ -255,6 +236,15 @@ export function useOwletteChat({ siteId, machineId, machineName, onChatPersisted return true; }); deduped.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()); + // Keep the unpersisted draft pinned until its doc lands in a snapshot. + const draft = draftConvoRef.current; + if (draft) { + if (seen.has(draft.id)) { + draftConvoRef.current = null; // persisted now — the real doc supersedes it + } else { + deduped.unshift(draft); + } + } setConversations(deduped); setLoadingConversations(false); } @@ -336,20 +326,25 @@ export function useOwletteChat({ siteId, machineId, machineName, onChatPersisted const effectiveMachineName = overrides?.machineName ?? machineNameRef.current; const isSiteMode = effectiveMachineId === SITE_TARGET_ID; - // Add optimistic entry to sidebar, removing any previous empty "new conversation" entries + // Add optimistic entry to the sidebar. Track it by id (in a ref) so the + // snapshot listener can preserve it; drop only the *previous* draft by id + // (not every "new conversation"-titled row, which could be a real chat). + const previousDraftId = draftConvoRef.current?.id; + const draft: ChatConversation = { + id: newId, + title: 'new conversation', + siteId: siteIdRef.current, + targetType: isSiteMode ? 'site' : 'machine', + targetMachineId: isSiteMode ? null : effectiveMachineId, + machineName: isSiteMode ? 'All Machines' : effectiveMachineName, + source: 'user', + createdAt: new Date(), + updatedAt: new Date(), + }; + draftConvoRef.current = draft; setConversations((prev) => [ - { - id: newId, - title: 'new conversation', - siteId: siteIdRef.current, - targetType: isSiteMode ? 'site' : 'machine', - targetMachineId: isSiteMode ? null : effectiveMachineId, - machineName: isSiteMode ? 'All Machines' : effectiveMachineName, - source: 'user', - createdAt: new Date(), - updatedAt: new Date(), - }, - ...prev.filter((c) => c.title !== 'new conversation'), + draft, + ...prev.filter((c) => c.id !== newId && c.id !== previousDraftId), ]); }, [chat]); @@ -479,7 +474,14 @@ export function useOwletteChat({ siteId, machineId, machineName, onChatPersisted } } catch (error) { if (!isMountedRef.current || requestId !== loadChatRequestRef.current) return; - console.error('Failed to load chat messages:', error); + // permission-denied is expected when the URL points at a chat the user + // doesn't own (or an autonomous chat for a site they can't access) — + // firestore.rules correctly denies it. Surface as not_found without + // logging noise; only log genuinely unexpected failures. + const code = (error as { code?: string } | null)?.code; + if (code !== 'permission-denied') { + console.error('Failed to load chat messages:', error); + } setChatLoadError('not_found'); chat.setMessages([]); } @@ -499,6 +501,9 @@ export function useOwletteChat({ siteId, machineId, machineName, onChatPersisted // Remove from local list immediately (handles both persisted and optimistic entries) setConversations((prev) => prev.filter((c) => c.id !== conversationId)); + if (draftConvoRef.current?.id === conversationId) { + draftConvoRef.current = null; + } if (conversationId === chatId) { if (isEmptyNew) { @@ -632,6 +637,11 @@ export function useOwletteChat({ siteId, machineId, machineName, onChatPersisted stop: chat.stop, status: chat.status, + // Tier-3 tool approval (human-in-the-loop). Pass the approvalId from a + // tool part in `approval-requested` state. `sendAutomaticallyWhen` resumes + // the stream once every pending approval on the message is answered. + addToolApprovalResponse: chat.addToolApprovalResponse, + // Input management input: inputValue, setInput: setInputValue, diff --git a/web/hooks/useCortexApprovalSetting.ts b/web/hooks/useCortexApprovalSetting.ts new file mode 100644 index 00000000..bd1e8de7 --- /dev/null +++ b/web/hooks/useCortexApprovalSetting.ts @@ -0,0 +1,34 @@ +'use client'; + +/** + * Subscribe to a site's tier-3 Cortex approval policy + * (`sites/{siteId}/settings/cortex.requireTier3Approval`). + * + * Defaults to `true` (gate on) when the doc/field is absent or unreadable, + * mirroring the server-side `getCortexRequireTier3Approval` default — so the + * UI shows the safe state until (and if) the snapshot says otherwise. + */ + +import { useEffect, useState } from 'react'; +import { doc, onSnapshot } from 'firebase/firestore'; +import { db } from '@/lib/firebase'; + +export function useCortexApprovalSetting(siteId: string) { + const [requireApproval, setRequireApproval] = useState(true); + + useEffect(() => { + if (!siteId || !db) return; + const ref = doc(db, 'sites', siteId, 'settings', 'cortex'); + const unsub = onSnapshot( + ref, + (snap) => setRequireApproval(snap.data()?.requireTier3Approval !== false), + (error) => { + console.error('Failed to read cortex approval setting:', error); + setRequireApproval(true); + }, + ); + return () => unsub(); + }, [siteId]); + + return { requireApproval }; +} diff --git a/web/hooks/useCortexSidebarPrefs.ts b/web/hooks/useCortexSidebarPrefs.ts new file mode 100644 index 00000000..25f753ee --- /dev/null +++ b/web/hooks/useCortexSidebarPrefs.ts @@ -0,0 +1,134 @@ +'use client'; + +/** + * Per-device persistence for the Cortex sidebar's expand/collapse state: + * - `sidebarOpen` — the whole sidebar panel open/collapsed + * - `collapsedGroups` — which category sections are collapsed + * + * Stored on the existing per-device prefs doc (`users/{uid}/devicePrefs/global`, + * the same doc `useDevicePrefs` uses) under `cortexSidebarOpen` / + * `cortexCollapsedGroups`. Hydrated once on mount, then local state is the + * source of truth and writes are debounced. The setters mirror `useState` + * (accept a value or an updater) so they're drop-in replacements. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { doc, getDoc, setDoc } from 'firebase/firestore'; +import { useAuth } from '@/contexts/AuthContext'; +import { db } from '@/lib/firebase'; + +const DEBOUNCE_MS = 400; + +type SetState = (value: T | ((prev: T) => T)) => void; + +export interface CortexSidebarPrefs { + sidebarOpen: boolean; + setSidebarOpen: SetState; + collapsedGroups: Set; + setCollapsedGroups: SetState>; +} + +export function useCortexSidebarPrefs(): CortexSidebarPrefs { + const { user } = useAuth(); + const uid = user?.uid ?? null; + + const [sidebarOpen, setSidebarOpenState] = useState(true); + const [collapsedGroups, setCollapsedGroupsState] = useState>(new Set()); + + // Mirror refs let the setters read current state without being re-created. + // Updated in effects (writing refs during render is disallowed). + const sidebarOpenRef = useRef(sidebarOpen); + const collapsedRef = useRef(collapsedGroups); + const uidRef = useRef(uid); + useEffect(() => { sidebarOpenRef.current = sidebarOpen; }, [sidebarOpen]); + useEffect(() => { collapsedRef.current = collapsedGroups; }, [collapsedGroups]); + useEffect(() => { uidRef.current = uid; }, [uid]); + + const pendingRef = useRef>({}); + const timerRef = useRef | null>(null); + + const flush = useCallback(() => { + const currentUid = uidRef.current; + const updates = pendingRef.current; + pendingRef.current = {}; + if (!db || !currentUid || Object.keys(updates).length === 0) return; + setDoc(doc(db, 'users', currentUid, 'devicePrefs', 'global'), updates, { merge: true }).catch( + (err) => console.error('Failed to persist cortex sidebar prefs:', err), + ); + }, []); + + const schedulePersist = useCallback( + (patch: Record) => { + if (!db || !uidRef.current) return; + Object.assign(pendingRef.current, patch); + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + timerRef.current = null; + flush(); + }, DEBOUNCE_MS); + }, + [flush], + ); + + // Hydrate once from Firestore. setState lives in the async callback (not the + // synchronous effect body), so it doesn't trip the cascading-render lint rule. + useEffect(() => { + if (!db || !uid) return; + let cancelled = false; + getDoc(doc(db, 'users', uid, 'devicePrefs', 'global')) + .then((snap) => { + if (cancelled || !snap.exists()) return; + const data = snap.data() as { + cortexSidebarOpen?: unknown; + cortexCollapsedGroups?: unknown; + }; + if (typeof data.cortexSidebarOpen === 'boolean') { + setSidebarOpenState(data.cortexSidebarOpen); + } + if (Array.isArray(data.cortexCollapsedGroups)) { + setCollapsedGroupsState(new Set(data.cortexCollapsedGroups as string[])); + } + }) + .catch((err) => console.error('Failed to read cortex sidebar prefs:', err)); + return () => { + cancelled = true; + }; + }, [uid]); + + // Flush any pending write on unmount. + useEffect(() => { + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + flush(); + } + }; + }, [flush]); + + const setSidebarOpen = useCallback>( + (value) => { + const next = typeof value === 'function' + ? (value as (p: boolean) => boolean)(sidebarOpenRef.current) + : value; + sidebarOpenRef.current = next; + setSidebarOpenState(next); + schedulePersist({ cortexSidebarOpen: next }); + }, + [schedulePersist], + ); + + const setCollapsedGroups = useCallback>>( + (value) => { + const next = typeof value === 'function' + ? (value as (p: Set) => Set)(collapsedRef.current) + : value; + collapsedRef.current = next; + setCollapsedGroupsState(next); + schedulePersist({ cortexCollapsedGroups: Array.from(next) }); + }, + [schedulePersist], + ); + + return { sidebarOpen, setSidebarOpen, collapsedGroups, setCollapsedGroups }; +} diff --git a/web/hooks/useHistoricalMetrics.ts b/web/hooks/useHistoricalMetrics.ts index f41dd925..33473511 100644 --- a/web/hooks/useHistoricalMetrics.ts +++ b/web/hooks/useHistoricalMetrics.ts @@ -16,6 +16,12 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { collection, getDocs, query, where, documentId, orderBy } from 'firebase/firestore'; import { db } from '@/lib/firebase'; import { useDemoContext } from '@/contexts/DemoContext'; +import { + formatHourBucketId, + formatDayBucketId, + DAY_BUCKET_ID_RE, + HOUR_BUCKET_ID_RE, +} from '@/lib/metricsHistoryBuckets'; import type { TimeRange } from '@/components/charts'; /** @@ -120,20 +126,10 @@ function getStartDate(range: TimeRange): Date { } } -const DAY_BUCKET_ID_RE = /^\d{4}-\d{2}-\d{2}$/; -const HOUR_BUCKET_ID_RE = /^\d{4}-\d{2}-\d{2}-\d{2}$/; const FIRESTORE_IN_LIMIT = 30; const MAX_FETCHED_SAMPLES = 5000; const MAX_DAY_BUCKET_IN_QUERIES = 24; -function formatDayBucketId(date: Date): string { - return date.toISOString().split('T')[0]; -} - -function formatHourBucketId(date: Date): string { - return date.toISOString().slice(0, 13).replace('T', '-'); -} - function chunkArray(items: T[], chunkSize: number): T[][] { const chunks: T[][] = []; for (let i = 0; i < items.length; i += chunkSize) { diff --git a/web/hooks/useSparklineData.ts b/web/hooks/useSparklineData.ts index 62ea720e..1a1b828e 100644 --- a/web/hooks/useSparklineData.ts +++ b/web/hooks/useSparklineData.ts @@ -4,28 +4,127 @@ * useSparklineData Hook * * Provides real-time sparkline data for a specific metric type. - * Uses Firestore snapshot listener for live updates. + * Uses Firestore snapshot listeners for live updates. * - * Returns the last 60 samples (1 hour at 1-min resolution) for - * displaying inline sparklines in machine cards. + * Returns the last 60 samples (~1 hour at 1-min resolution) for displaying + * inline sparklines in machine cards. + * + * Bucket shapes (mirrors useHistoricalMetrics): + * - The cloud function writes hourly UTC buckets: metrics_history/{YYYY-MM-DD-HH}. + * - Legacy data and the e2e fixtures use a daily bucket: metrics_history/{YYYY-MM-DD}. + * We subscribe to the current + previous hour buckets (so the window stays full + * across the top of the hour) plus today's daily bucket, then merge and keep the + * most recent 60 samples. Listeners re-subscribe at each hour boundary so the + * data doesn't freeze on a stale bucket when a tab stays open. */ import { useState, useEffect } from 'react'; -import { doc, onSnapshot } from 'firebase/firestore'; +import { collection, query, where, documentId, onSnapshot, type Firestore } from 'firebase/firestore'; import { db } from '@/lib/firebase'; import { useDemoContext } from '@/contexts/DemoContext'; +import { formatHourBucketId, formatDayBucketId } from '@/lib/metricsHistoryBuckets'; import type { SparklineDataPoint, MetricColor } from '@/components/charts'; type SparklineMetricType = 'cpu' | 'memory' | 'disk' | 'gpu'; // Map metric type to abbreviated key in Firestore -const metricKeyMap: Record = { +const metricKeyMap: Record = { cpu: 'c', memory: 'm', disk: 'd', gpu: 'g', }; +const HOUR_MS = 60 * 60 * 1000; +const MAX_SAMPLES = 60; + +/** Raw sample as stored in a metrics_history bucket (abbreviated keys). */ +interface RawSample { + t: number; + c?: number; + m?: number; + d?: number; + g?: number; +} + +function currentHourEpoch(): number { + return Math.floor(Date.now() / HOUR_MS); +} + +function msUntilNextHour(): number { + return HOUR_MS - (Date.now() % HOUR_MS); +} + +/** + * Re-derive an hour epoch at every UTC hour boundary so subscriptions that + * close over a bucket id get torn down and recreated for the new hour. Inert + * (no timer) when `active` is false. + */ +function useHourEpoch(active: boolean): number { + const [epoch, setEpoch] = useState(() => currentHourEpoch()); + useEffect(() => { + if (!active) return; + // +2s buffer so we're safely inside the new hour before recomputing ids. + const timer = setTimeout(() => setEpoch(currentHourEpoch()), msUntilNextHour() + 2000); + return () => clearTimeout(timer); + }, [active, epoch]); + return epoch; +} + +/** + * Subscribe to the metrics_history buckets that can hold the last hour of + * samples — current + previous hourly buckets plus today's legacy daily bucket + * — merge them (deduped by timestamp), and deliver the most recent 60 samples + * sorted ascending by time. Returns an unsubscribe. + * + * A single `documentId() in [...]` query listener covers all three buckets + * (one listener per machine, not three), mirroring how useHistoricalMetrics + * reads this collection. Only the current-hour doc actually changes minute to + * minute, so steady-state update traffic is unchanged. + */ +function subscribeLastHourSamples( + database: Firestore, + siteId: string, + machineId: string, + onSamples: (samples: RawSample[]) => void, +): () => void { + const now = new Date(); + const bucketIds = [ + formatHourBucketId(new Date(now.getTime() - HOUR_MS)), // previous hour + formatHourBucketId(now), // current hour + formatDayBucketId(now), // legacy / e2e daily + ]; + + const historyRef = collection(database, 'sites', siteId, 'machines', machineId, 'metrics_history'); + const bucketsQuery = query(historyRef, where(documentId(), 'in', bucketIds)); + + return onSnapshot( + bucketsQuery, + (snapshot) => { + // Dedupe by timestamp (a sample maps to exactly one bucket in practice; + // this is defensive against daily/hourly overlap). Query results iterate + // in documentId order, so the daily bucket ("YYYY-MM-DD") is visited + // before the hourly ones ("YYYY-MM-DD-HH") — hourly wins any tie. Then + // sort and keep the last 60. + const byTime = new Map(); + snapshot.forEach((docSnap) => { + const samples = (docSnap.data()?.samples ?? []) as RawSample[]; + for (const s of samples) { + if (s && typeof s.t === 'number') byTime.set(s.t, s); + } + }); + const merged = Array.from(byTime.values()) + .sort((a, b) => a.t - b.t) + .slice(-MAX_SAMPLES); + onSamples(merged); + }, + (error) => { + console.error('Error listening to sparkline data:', error); + onSamples([]); + }, + ); +} + interface UseSparklineDataResult { data: SparklineDataPoint[]; loading: boolean; @@ -54,57 +153,22 @@ export function useSparklineData( }>({ data: [], loadedKey: null }); const currentKey = db && siteId && machineId ? `${siteId}/${machineId}/${metricType}` : null; + const hourEpoch = useHourEpoch(currentKey !== null); useEffect(() => { if (!currentKey || !db || !siteId || !machineId) return; - // Get today's bucket ID - const bucketId = new Date().toISOString().split('T')[0]; - - // Listen to today's metrics history bucket - const docRef = doc( - db, - 'sites', - siteId, - 'machines', - machineId, - 'metrics_history', - bucketId - ); - - const unsubscribe = onSnapshot( - docRef, - (snapshot) => { - if (!snapshot.exists()) { - setState({ data: [], loadedKey: currentKey }); - return; - } - - const docData = snapshot.data(); - const samples = docData?.samples || []; - - // Get the value key for this metric type - const valueKey = metricKeyMap[metricType]; - - // Extract the last 60 samples (1 hour of data) - const recentSamples = samples - .slice(-60) - .map((s: Record) => ({ - t: s.t, - v: s[valueKey] ?? 0, - })) - .filter((s: SparklineDataPoint) => s.v !== undefined && s.v !== null); - - setState({ data: recentSamples, loadedKey: currentKey }); - }, - (error) => { - console.error('Error listening to sparkline data:', error); - setState({ data: [], loadedKey: currentKey }); - } - ); + const valueKey = metricKeyMap[metricType]; + const unsubscribe = subscribeLastHourSamples(db, siteId, machineId, (samples) => { + const data = samples + .map((s) => ({ t: s.t, v: s[valueKey] ?? 0 })) + .filter((s) => s.v !== undefined && s.v !== null); + setState({ data, loadedKey: currentKey }); + }); return () => unsubscribe(); - }, [currentKey, siteId, machineId, metricType]); + // hourEpoch re-subscribes the listeners at each hour boundary. + }, [currentKey, siteId, machineId, metricType, hourEpoch]); const matched = currentKey !== null && state.loadedKey === currentKey; const data = matched ? state.data : EMPTY_SPARKLINE; @@ -149,6 +213,7 @@ export function useAllSparklineData( }>({ cpu: [], memory: [], disk: [], gpu: [], loadedKey: null }); const currentKey = !demo && db && siteId && machineId ? `${siteId}/${machineId}` : null; + const hourEpoch = useHourEpoch(currentKey !== null); useEffect(() => { // Demo mode is handled entirely at render (see below) — the synthesized @@ -156,58 +221,26 @@ export function useAllSparklineData( if (demo) return; if (!currentKey || !db || !siteId || !machineId) return; - // Get today's bucket ID - const bucketId = new Date().toISOString().split('T')[0]; - - // Listen to today's metrics history bucket - const docRef = doc( - db, - 'sites', - siteId, - 'machines', - machineId, - 'metrics_history', - bucketId - ); - - const unsubscribe = onSnapshot( - docRef, - (snapshot) => { - if (!snapshot.exists()) { - setState({ cpu: [], memory: [], disk: [], gpu: [], loadedKey: currentKey }); - return; - } - - const docData = snapshot.data(); - const samples = docData?.samples || []; - - // Get last 60 samples — single pass extracting all metrics - const recentSamples = samples.slice(-60); - const cpu: SparklineDataPoint[] = []; - const memory: SparklineDataPoint[] = []; - const disk: SparklineDataPoint[] = []; - const gpu: SparklineDataPoint[] = []; - - for (const s of recentSamples) { - const t = s.t; - cpu.push({ t, v: s.c ?? 0 }); - memory.push({ t, v: s.m ?? 0 }); - disk.push({ t, v: s.d ?? 0 }); - if (s.g > 0) gpu.push({ t, v: s.g }); - } - - // Single setState — one re-render instead of five - setState({ cpu, memory, disk, gpu, loadedKey: currentKey }); - }, - (error) => { - console.error('Error listening to sparkline data:', error); - // Mark loaded even on error so the spinner clears. - setState({ cpu: [], memory: [], disk: [], gpu: [], loadedKey: currentKey }); + const unsubscribe = subscribeLastHourSamples(db, siteId, machineId, (samples) => { + const cpu: SparklineDataPoint[] = []; + const memory: SparklineDataPoint[] = []; + const disk: SparklineDataPoint[] = []; + const gpu: SparklineDataPoint[] = []; + + for (const s of samples) { + cpu.push({ t: s.t, v: s.c ?? 0 }); + memory.push({ t: s.t, v: s.m ?? 0 }); + disk.push({ t: s.t, v: s.d ?? 0 }); + if ((s.g ?? 0) > 0) gpu.push({ t: s.t, v: s.g as number }); } - ); + + // Single setState — one re-render instead of five + setState({ cpu, memory, disk, gpu, loadedKey: currentKey }); + }); return () => unsubscribe(); - }, [currentKey, siteId, machineId, demo]); + // hourEpoch re-subscribes the listeners at each hour boundary. + }, [currentKey, siteId, machineId, demo, hourEpoch]); if (demo && machineId) return { ...demo.getSparklineData(machineId) }; // Surface only data that matches the currently-requested key. If db isn't diff --git a/web/hooks/useUserManagement.ts b/web/hooks/useUserManagement.ts index 293007f2..da135aaa 100644 --- a/web/hooks/useUserManagement.ts +++ b/web/hooks/useUserManagement.ts @@ -20,6 +20,8 @@ export interface UserData { sites?: string[]; createdAt: Timestamp; displayName?: string; + deletedAt?: number; + deletedBy?: string; } /** @@ -33,16 +35,24 @@ export interface UserData { * - Sort and filter users * * Usage: - * const { users, loading, error, updateUserRole } = useUserManagement(); + * const { users, loading, error, updateUserRole } = useUserManagement(isSuperadmin); */ -export function useUserManagement() { +const EMPTY_USERS: UserData[] = []; + +export function useUserManagement(enabled: boolean) { const [users, setUsers] = useState([]); - const [loading, setLoading] = useState(!!db); - const [error, setError] = useState(db ? null : 'Firebase is not configured'); + const [loading, setLoading] = useState(enabled && !!db); + const [error, setError] = useState( + enabled && !db ? 'Firebase is not configured' : null + ); + + const exposedUsers = enabled ? users : EMPTY_USERS; + const exposedLoading = enabled ? loading : false; + const exposedError = enabled && !db ? 'Firebase is not configured' : enabled ? error : null; // Fetch all users with real-time updates useEffect(() => { - if (!db) return; + if (!enabled || !db) return; // No try/catch: `collection()`/`query()` only throw for invalid path or // query shape (both literals here), and onSnapshot surfaces runtime @@ -76,7 +86,7 @@ export function useUserManagement() { ); return () => unsubscribe(); - }, []); + }, [enabled]); /** * Update a user's role @@ -116,17 +126,19 @@ export function useUserManagement() { * - members: standard users with site-level access */ const getUserCounts = useCallback(() => { - const superadmins = users.filter((u) => u.role === 'superadmin').length; - const admins = users.filter((u) => u.role === 'admin').length; - const members = users.filter((u) => u.role === 'member').length; + const active = exposedUsers.filter((u) => u.deletedAt == null); + const superadmins = active.filter((u) => u.role === 'superadmin').length; + const admins = active.filter((u) => u.role === 'admin').length; + const members = active.filter((u) => u.role === 'member').length; return { - total: users.length, + total: active.length, superadmins, admins, members, + deleted: exposedUsers.length - active.length, }; - }, [users]); + }, [exposedUsers]); /** * Assign a site to a user @@ -205,9 +217,9 @@ export function useUserManagement() { ); return { - users, - loading, - error, + users: exposedUsers, + loading: exposedLoading, + error: exposedError, updateUserRole, getUserCounts, assignSiteToUser, diff --git a/web/lib/actions/clearLogs.server.ts b/web/lib/actions/clearLogs.server.ts index b85a2ea7..f66bc1d4 100644 --- a/web/lib/actions/clearLogs.server.ts +++ b/web/lib/actions/clearLogs.server.ts @@ -15,7 +15,13 @@ * firestore path: `sites/{siteId}/logs/*` */ -import type { Firestore, Query } from 'firebase-admin/firestore'; +import { Timestamp } from 'firebase-admin/firestore'; +import type { + CollectionReference, + Firestore, + Query, + QueryDocumentSnapshot, +} from 'firebase-admin/firestore'; import { getAdminDb } from '@/lib/firebase-admin'; import logger from '@/lib/logger'; @@ -36,6 +42,10 @@ export interface ClearLogsInput { machineId?: string; /** Match the `level` field exactly. Omit for all levels. */ level?: string; + /** Inclusive lower timestamp bound (epoch ms). Omit for no lower bound. */ + sinceMs?: number; + /** Inclusive upper timestamp bound (epoch ms). Omit for no upper bound. */ + untilMs?: number; } export interface ClearLogsResult { @@ -53,6 +63,10 @@ export class ClearLogsValidationError extends Error { } } +// Hard cap iterations defensively — a site shouldn't have unbounded logs, but a +// runaway query is the kind of thing we'd rather surface than hang on. +const MAX_ITERATIONS = 1000; // 1000 * 500 = 500k entries — well above any realistic site + export async function clearLogs( ctx: ClearLogsContext, input: ClearLogsInput = {}, @@ -77,50 +91,121 @@ export async function clearLogs( ); } } + for (const field of ['sinceMs', 'untilMs'] as const) { + const v = input[field]; + if (v !== undefined && (typeof v !== 'number' || !Number.isFinite(v) || v < 0)) { + throw new ClearLogsValidationError( + field, + `${field} must be a non-negative epoch-ms number when provided`, + ); + } + } + if (input.sinceMs !== undefined && input.untilMs !== undefined && input.sinceMs > input.untilMs) { + throw new ClearLogsValidationError('sinceMs', 'sinceMs must be <= untilMs'); + } const db = ctx.db ?? getAdminDb(); const logsCol = db.collection('sites').doc(ctx.siteId).collection('logs'); - // Build query with the same filters the UI applies. + // Two index-free strategies: + // - No date window: equality filters server-side + batch-delete loop (every + // fetched doc matches, so re-querying from the front terminates). This is + // the unchanged legacy path. + // - Date window: constrain by timestamp range only (single-field index, same + // as the GET handler) and cursor-paginate, applying action/machine/level in + // memory. Avoids the composite indexes an equality+range query would need. + const deletedCount = + input.sinceMs !== undefined || input.untilMs !== undefined + ? await clearByTimestampWindow(db, logsCol, input) + : await clearByEqualityFilters(db, logsCol, input); + + if (deletedCount > 0) { + logger.info(`clearLogs: deleted ${deletedCount} entries from sites/${ctx.siteId}/logs`, { + context: 'clearLogs', + data: { siteId: ctx.siteId, filters: input, deletedCount }, + }); + } + + return { + siteId: ctx.siteId, + deletedCount, + filters: input, + }; +} + +/** + * No date window: equality filters applied server-side, deleted in batches of + * 500. Every fetched doc matches all filters, so deleting and re-querying from + * the front always makes progress and terminates. + */ +async function clearByEqualityFilters( + db: Firestore, + logsCol: CollectionReference, + input: ClearLogsInput, +): Promise { let q: Query = logsCol; if (input.action !== undefined) q = q.where('action', '==', input.action); if (input.machineId !== undefined) q = q.where('machineId', '==', input.machineId); if (input.level !== undefined) q = q.where('level', '==', input.level); - // Loop: fetch a batch-sized chunk, delete it, repeat until empty. Firestore - // doesn't have a streaming-delete primitive; iterating in chunks of 500 - // matches the batch limit and the legacy hook's pattern. Each iteration is - // committed individually so a failure mid-flight leaves the *committed* - // chunks already deleted (idempotent retry will pick up the remainder). let deletedCount = 0; - // Hard cap iterations defensively — a site shouldn't have unbounded logs, - // but a runaway query is the kind of thing we'd rather surface than hang on. - const MAX_ITERATIONS = 1000; // 1000 * 500 = 500k log entries — well above any realistic site for (let iter = 0; iter < MAX_ITERATIONS; iter++) { const snap = await q.limit(FIRESTORE_BATCH_LIMIT).get(); if (snap.empty) break; const batch = db.batch(); - for (const doc of snap.docs) { - batch.delete(doc.ref); - } + for (const doc of snap.docs) batch.delete(doc.ref); await batch.commit(); deletedCount += snap.size; - // If we got fewer than the limit, no more to fetch — short-circuit. if (snap.size < FIRESTORE_BATCH_LIMIT) break; } + return deletedCount; +} - if (deletedCount > 0) { - logger.info(`clearLogs: deleted ${deletedCount} entries from sites/${ctx.siteId}/logs`, { - context: 'clearLogs', - data: { siteId: ctx.siteId, filters: input, deletedCount }, - }); - } +/** + * Date window: order by timestamp + apply the range server-side (single-field + * index), cursor-paginate, and match action/machine/level in memory so we never + * need composite indexes. The cursor advances past non-matching docs, so the + * loop terminates even when most rows in the window don't match the filters. + */ +async function clearByTimestampWindow( + db: Firestore, + logsCol: CollectionReference, + input: ClearLogsInput, +): Promise { + let cursor: QueryDocumentSnapshot | null = null; + let deletedCount = 0; + for (let iter = 0; iter < MAX_ITERATIONS; iter++) { + let q: Query = logsCol.orderBy('timestamp', 'desc'); + if (input.sinceMs !== undefined) { + q = q.where('timestamp', '>=', Timestamp.fromMillis(input.sinceMs)); + } + if (input.untilMs !== undefined) { + q = q.where('timestamp', '<=', Timestamp.fromMillis(input.untilMs)); + } + q = q.limit(FIRESTORE_BATCH_LIMIT); + if (cursor) q = q.startAfter(cursor); - return { - siteId: ctx.siteId, - deletedCount, - filters: input, - }; + const snap = await q.get(); + if (snap.empty) break; + cursor = snap.docs[snap.docs.length - 1]; + + const batch = db.batch(); + let matched = 0; + for (const doc of snap.docs) { + const d = doc.data(); + if (input.action !== undefined && d.action !== input.action) continue; + if (input.machineId !== undefined && d.machineId !== input.machineId) continue; + if (input.level !== undefined && d.level !== input.level) continue; + batch.delete(doc.ref); + matched++; + } + if (matched > 0) { + await batch.commit(); + deletedCount += matched; + } + if (snap.size < FIRESTORE_BATCH_LIMIT) break; + } + return deletedCount; } diff --git a/web/lib/actions/removeMachine.server.ts b/web/lib/actions/removeMachine.server.ts index bd007418..cef72df9 100644 --- a/web/lib/actions/removeMachine.server.ts +++ b/web/lib/actions/removeMachine.server.ts @@ -12,8 +12,9 @@ * `web/hooks/useMachineOperations.ts` exactly. The hook will be deleted in * a follow-up wave once the route-side action is the only writer. * - * Capability: `MACHINE_REMOVE` — superadmin only per the role matrix in - * `web/lib/capabilities.ts`. Site admins cannot remove machines. + * Capability: `MACHINE_REMOVE` — site-scoped per the role matrix in + * `web/lib/capabilities.ts`. Site admins can remove machines on their assigned + * sites; superadmins on any site. * * Atomicity: the main doc + config delete run in a Firestore batch; the * two command-map docs are deleted as best-effort follow-ups since they diff --git a/web/lib/actions/setCortexRequireTier3Approval.server.ts b/web/lib/actions/setCortexRequireTier3Approval.server.ts new file mode 100644 index 00000000..5c3d9d41 --- /dev/null +++ b/web/lib/actions/setCortexRequireTier3Approval.server.ts @@ -0,0 +1,73 @@ +/** + * Action core: toggle the per-site `requireTier3Approval` Cortex policy. + * + * Writes `sites/{siteId}/settings/cortex.requireTier3Approval`. When `true` + * (the default), privileged tier-3 tool calls (run_powershell, execute_script, + * reboot_machine, etc.) pause for explicit in-chat approval before they run, + * and single-machine admin chats are routed through the server-side LLM path + * so the AI SDK approval gate can fire. When `false`, local Cortex is allowed + * and the gate does not apply. + * + * Read side: `getCortexRequireTier3Approval` in `lib/cortex-utils.server.ts`. + */ +import { getAdminDb } from '@/lib/firebase-admin'; +import { FieldValue } from 'firebase-admin/firestore'; +import { emitMutation } from '@/lib/auditLogClient'; +import logger from '@/lib/logger'; +import { ActionInputError, type ActionContext } from './createProcess.server'; + +export interface SetCortexRequireTier3ApprovalInput { + requireTier3Approval: boolean; +} + +export interface SetCortexRequireTier3ApprovalResult { + siteId: string; + requireTier3Approval: boolean; +} + +export async function setCortexRequireTier3Approval( + ctx: ActionContext, + input: SetCortexRequireTier3ApprovalInput, +): Promise { + if (typeof input.requireTier3Approval !== 'boolean') { + throw new ActionInputError( + 400, + 'invalid_require_tier3_approval', + 'Field `requireTier3Approval` must be a boolean.', + ); + } + + const db = getAdminDb(); + await db + .collection('sites') + .doc(ctx.siteId) + .collection('settings') + .doc('cortex') + .set( + { + requireTier3Approval: input.requireTier3Approval, + updatedAt: FieldValue.serverTimestamp(), + }, + { merge: true }, + ); + + emitMutation({ + kind: 'site_mutated', + siteId: ctx.siteId, + actor: ctx.auditActor, + targetId: ctx.siteId, + attributes: { + verb: 'set_cortex_require_tier3_approval', + endpoint: 'cortex-settings', + method: 'PATCH', + requireTier3Approval: input.requireTier3Approval, + }, + }); + + logger.info( + `Cortex tier-3 approval ${input.requireTier3Approval ? 'required' : 'disabled'} on site ${ctx.siteId}`, + { context: 'actions/setCortexRequireTier3Approval' }, + ); + + return { siteId: ctx.siteId, requireTier3Approval: input.requireTier3Approval }; +} diff --git a/web/lib/apiAuth.server.ts b/web/lib/apiAuth.server.ts index aa990e87..33ce2f02 100644 --- a/web/lib/apiAuth.server.ts +++ b/web/lib/apiAuth.server.ts @@ -110,7 +110,8 @@ export async function requireSession(request: NextRequest): Promise { } export async function requireSessionOrIdToken( - request: NextRequest + request: NextRequest, + options: { rejectAgentTokens?: boolean } = {} ): Promise { try { return await requireSession(request); @@ -126,8 +127,16 @@ export async function requireSessionOrIdToken( try { const adminAuth = getAdminAuth(); const decoded = await adminAuth.verifyIdToken(bearer); + // Agents authenticate via custom tokens (role='agent') for site/machine + // operations and must never mint user-scoped API keys. Opt-in per call so + // existing callers are unaffected; session callers are always human, so + // this only applies to the ID-token branch. + if (options.rejectAgentTokens && decoded.role === 'agent') { + throw new ApiAuthError(403, 'Forbidden: agent credentials cannot create api keys'); + } return decoded.uid; - } catch { + } catch (e) { + if (e instanceof ApiAuthError) throw e; // preserve the 403 above throw new ApiAuthError(401, 'Unauthorized: Invalid ID token'); } } diff --git a/web/lib/apiKeyTypes.ts b/web/lib/apiKeyTypes.ts index ded8beed..f1a42238 100644 --- a/web/lib/apiKeyTypes.ts +++ b/web/lib/apiKeyTypes.ts @@ -98,6 +98,22 @@ export const SCOPE_PRESETS: Record = { admin: wildcardScopes(['read', 'write', 'deploy', 'rollback', 'admin']), }; +/** Ordered preset keys for scope pickers (excludes the synthetic "custom" option). */ +export const SCOPE_PRESET_KEYS: readonly ApiKeyScopePreset[] = [ + 'readonly', + 'publisher', + 'operator', + 'admin', +]; + +/** Human-readable descriptions of each preset, shared across every scope picker. */ +export const SCOPE_PRESET_DESCRIPTIONS: Record = { + readonly: 'read access to roosts, sites, machines, and cortex chats — no mutations', + publisher: 'read + write — can upload chunks, publish versions, and use cortex chats', + operator: 'read, write, deploy, rollback — full day-to-day operations', + admin: 'full access including admin permissions', +}; + export const DEFAULT_TTL_DAYS = 90; export const MAX_TTL_DAYS = 365; export const ROTATION_GRACE_MS = 24 * 60 * 60 * 1000; diff --git a/web/lib/capabilities.ts b/web/lib/capabilities.ts index 47cdea6f..f6f88c4b 100644 --- a/web/lib/capabilities.ts +++ b/web/lib/capabilities.ts @@ -53,6 +53,9 @@ const SITE_ADMIN_CAPABILITIES: readonly Capability[] = [ ...MEMBER_CAPABILITIES, Capability.MACHINE_EXEC_COMMAND, Capability.MACHINE_CONFIG_WRITE, + // Site-scoped (see SITE_SCOPED_CAPABILITIES): admins can remove machines on their + // OWN assigned sites; superadmins on any site. + Capability.MACHINE_REMOVE, Capability.DEPLOYMENT_MANAGE, Capability.DISTRIBUTION_MANAGE, Capability.UNINSTALL_TRIGGER, diff --git a/web/lib/cortex-utils.server.ts b/web/lib/cortex-utils.server.ts index 8d0188a3..49df0c90 100644 --- a/web/lib/cortex-utils.server.ts +++ b/web/lib/cortex-utils.server.ts @@ -52,6 +52,13 @@ function stripReservedExistingCommandKeys(params: Record): Reco export interface BuildExecutableToolsOptions { userId?: string; userRole?: string | null; + /** + * Whether tier-3 tools require in-chat approval. Defaults to true. When the + * per-site flag (`getCortexRequireTier3Approval`) is off, tier-3 tools + * auto-run on the server-side / site-wide paths too — not just local Cortex — + * so the approval toggle is honored consistently everywhere. + */ + requireTier3Approval?: boolean; } type ProcessToolResult = Record; @@ -304,6 +311,41 @@ export async function isCortexEnabled( return machineDoc.data()?.cortexEnabled !== false; } +/** + * Whether tier-3 (privileged) Cortex tool calls require explicit in-chat + * approval before they execute, for the given site. + * + * Stored at `sites/{siteId}/settings/cortex.requireTier3Approval`. Defaults to + * `true` when the doc or field is absent so the safety gate is on by default — + * an admin must deliberately opt out per site. + * + * When this is `true`, single-machine admin chats are forced through the + * server-side LLM path (skipping local Cortex) so the AI SDK's `needsApproval` + * gate can fire — see the routing decision in `runCortexStream` / + * `app/api/cortex/route.ts`. When `false`, local Cortex is allowed and the + * gate does not apply (the agent runs tools locally; approval is not enforced). + */ +export async function getCortexRequireTier3Approval( + db: FirebaseFirestore.Firestore, + siteId: string, +): Promise { + try { + const settingsDoc = await db + .collection('sites') + .doc(siteId) + .collection('settings') + .doc('cortex') + .get(); + + if (!settingsDoc.exists) return true; + + return settingsDoc.data()?.requireTier3Approval !== false; + } catch { + // Fail safe: if we can't read the setting, keep the gate on. + return true; + } +} + /** * Get all online machines for a site. */ @@ -1076,6 +1118,15 @@ export function buildExecutableTools( const toolConfig: any = { description: def.description, inputSchema: jsonSchema(def.parameters as Record), + // Tier-3 tools (run_powershell, execute_script, reboot_machine, etc.) + // pause for explicit in-chat approval before `execute` runs. The AI SDK + // emits a `tool-approval-request` part instead of calling `execute`; the + // client surfaces approve/deny and resumes the stream once answered. + // Tier 1/2 keep auto-running. This is a chat-only guardrail — autonomous + // Cortex uses a separate `buildAutonomousTools` (no human to approve), so + // it is intentionally unaffected. Gated by the per-site approval flag so + // turning approval off disables the gate on every path, not just local. + needsApproval: def.tier >= 3 && options.requireTier3Approval !== false, execute: async (params: unknown) => { // Server-side tools run directly on the web server (no agent relay) if (SERVER_SIDE_TOOLS.has(toolName)) { @@ -1127,8 +1178,35 @@ export function buildExecutableTools( // For capture_screenshot: inject the image as a vision content block // so the LLM can see and analyze the screenshot, not just get a URL string if (toolName === 'capture_screenshot') { + type ScreenshotBlock = { type: 'text'; text: string } | { type: 'image-url'; url: string }; toolConfig.toModelOutput = ({ output }: { output: unknown }) => { const result = output as Record | null; + + // Site-wide mode aggregates per-machine results as { machines: [...] }. + // Project each machine's screenshot URL as its own image block so the + // model sees all of them — a single top-level `url` only exists in + // single-machine mode. + const machines = Array.isArray(result?.machines) + ? (result!.machines as Array>) + : null; + if (machines) { + const blocks: ScreenshotBlock[] = []; + for (const m of machines) { + const mid = (m.machine as string) || 'machine'; + const murl = m.url as string | undefined; + if (murl) { + blocks.push({ type: 'text' as const, text: `${mid}:` }); + blocks.push({ type: 'image-url' as const, url: murl }); + } else { + const note = (m.message as string) || (m.error as string) || 'no screenshot'; + blocks.push({ type: 'text' as const, text: `${mid}: ${note}` }); + } + } + if (blocks.length > 0) { + return { type: 'content' as const, value: blocks }; + } + } + const url = result?.url as string | undefined; const message = (result?.message as string) || (result?.error as string) || 'Screenshot captured'; diff --git a/web/lib/cortexStream.server.ts b/web/lib/cortexStream.server.ts index f120335f..5fc42b8e 100644 --- a/web/lib/cortexStream.server.ts +++ b/web/lib/cortexStream.server.ts @@ -29,6 +29,7 @@ import { isMachineOnline, isCortexEnabled, getOnlineMachines, + getCortexRequireTier3Approval, buildExecutableTools, } from '@/lib/cortex-utils.server'; @@ -130,7 +131,16 @@ export async function runCortexStream( // Non-admins are forced through the server-side LLM path so the tier cap // (tier 1, read-only) is actually enforced. The local Cortex path runs // tools inside the agent and does not yet honor a per-user tier cap. - const cortexLocal = access.isSiteAdmin && maxToolTier >= 3 + // + // Additionally, when tier-3 approval is required for the site (the default), + // admins are kept on the server-side path so the AI SDK `needsApproval` gate + // can pause privileged tool calls — the local path runs tools inside the + // agent where the web server can't gate them. See the §6 decision in the PR. + const localPathAllowed = + access.isSiteAdmin && + maxToolTier >= 3 && + !(await getCortexRequireTier3Approval(db, siteId)); + const cortexLocal = localPathAllowed ? await isCortexLocal(db, siteId, machineId) : false; @@ -397,9 +407,10 @@ async function runServerSideLLM( maxToolTier: ToolTier, userRole: string | null, ): Promise { - const [llmConfig, processes] = await Promise.all([ + const [llmConfig, processes, requireTier3Approval] = await Promise.all([ resolveLlmConfig(db, userId, siteId), fetchProcessSummaries(db, siteId, machineId), + getCortexRequireTier3Approval(db, siteId), ]); const toolDefs = getToolsByTier(maxToolTier); @@ -411,7 +422,7 @@ async function runServerSideLLM( toolDefs, false, [], - { userId, userRole }, + { userId, userRole, requireTier3Approval }, ); const model = createModel(llmConfig); @@ -454,7 +465,10 @@ async function runSiteWideMode( onlineMachines: string[], userRole: string | null, ): Promise { - const llmConfig = await resolveLlmConfig(db, userId, siteId); + const [llmConfig, requireTier3Approval] = await Promise.all([ + resolveLlmConfig(db, userId, siteId), + getCortexRequireTier3Approval(db, siteId), + ]); const toolDefs = getToolsByTier(maxToolTier); const executableTools = buildExecutableTools( db, @@ -464,7 +478,7 @@ async function runSiteWideMode( toolDefs, true, onlineMachines, - { userId, userRole }, + { userId, userRole, requireTier3Approval }, ); const model = createModel(llmConfig); diff --git a/web/lib/metricsHistoryBuckets.ts b/web/lib/metricsHistoryBuckets.ts new file mode 100644 index 00000000..fbb27ccc --- /dev/null +++ b/web/lib/metricsHistoryBuckets.ts @@ -0,0 +1,33 @@ +/** + * metrics_history bucket-id contract (single source of truth) + * + * Time-series metrics live in `sites/{siteId}/machines/{machineId}/metrics_history/{bucketId}`. + * There are two bucket shapes, both keyed off the sample's UTC time: + * - hourly: `YYYY-MM-DD-HH` — written by the cloud function for all current data + * - daily: `YYYY-MM-DD` — legacy buckets + the e2e screenshot fixtures + * + * The writer is `functions/src/metricsHistory.ts` (`hourlyBucketId` / `dailyBucketId`); + * these formatters MUST stay byte-for-byte identical to it. Every reader + * (useSparklineData, useHistoricalMetrics) imports from here so the contract + * can't drift per-file again — drift between writer and one reader is exactly + * what blanked the inline sparklines once before. + * + * `toISOString()` is always UTC and starts `YYYY-MM-DDTHH:...`, so these are + * timezone-independent and match the writer regardless of server locale. + */ + +/** `YYYY-MM-DD-HH` (hourly UTC bucket). Mirrors metricsHistory.ts hourlyBucketId. */ +export function formatHourBucketId(date: Date): string { + return date.toISOString().slice(0, 13).replace('T', '-'); +} + +/** `YYYY-MM-DD` (legacy daily bucket / e2e fixture). Mirrors metricsHistory.ts dailyBucketId. */ +export function formatDayBucketId(date: Date): string { + return date.toISOString().split('T')[0]; +} + +/** Matches a daily bucket doc id (`YYYY-MM-DD`). */ +export const DAY_BUCKET_ID_RE = /^\d{4}-\d{2}-\d{2}$/; + +/** Matches an hourly bucket doc id (`YYYY-MM-DD-HH`). */ +export const HOUR_BUCKET_ID_RE = /^\d{4}-\d{2}-\d{2}-\d{2}$/; diff --git a/web/lib/resendClient.server.ts b/web/lib/resendClient.server.ts index 87934e1a..cd654708 100644 --- a/web/lib/resendClient.server.ts +++ b/web/lib/resendClient.server.ts @@ -18,5 +18,15 @@ export const isProduction = process.env.NODE_ENV === 'production' && !process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN?.includes('dev'); -export const FROM_EMAIL = process.env.RESEND_FROM_EMAIL || 'onboarding@resend.dev'; +// Friendly display name shown in recipients' inboxes (the part before the address). +// External-facing, so the product name keeps its normal casing. +const FROM_NAME = 'Owlette'; + +const RESEND_FROM_ADDRESS = process.env.RESEND_FROM_EMAIL || 'onboarding@resend.dev'; + +// Resend accepts an RFC 5322 "Name " string. If the configured value already +// carries a display name, respect it verbatim; otherwise prepend the friendly name. +export const FROM_EMAIL = RESEND_FROM_ADDRESS.includes('<') + ? RESEND_FROM_ADDRESS + : `${FROM_NAME} <${RESEND_FROM_ADDRESS}>`; export const ENV_LABEL = isProduction ? 'PRODUCTION' : 'DEVELOPMENT'; diff --git a/web/lib/timeUtils.ts b/web/lib/timeUtils.ts index 5d8d8274..3ec70b22 100644 --- a/web/lib/timeUtils.ts +++ b/web/lib/timeUtils.ts @@ -223,6 +223,59 @@ export function getDisplayTimezone( } } +/** + * Convert a wall-clock time (calendar components) in a given IANA timezone to + * the corresponding UTC epoch milliseconds. + * + * Uses the standard offset-correction trick: interpret the components as if + * they were UTC, see what wall-clock that instant renders as in the target + * zone, and subtract the difference. Accurate except within the ~1-hour DST + * fold, which is irrelevant for the day-boundary bounds this is used for. + * + * Needed because `new Date(y, m, d, …)` builds the instant in the *browser's* + * timezone — wrong when the surface (e.g. logs) shows and operates on times in + * the site/display timezone instead. + */ +export function zonedTimeToUtcMs( + year: number, + monthIndex: number, + day: number, + hour: number, + minute: number, + second: number, + millisecond: number, + timeZone: string, +): number { + const asUtc = Date.UTC(year, monthIndex, day, hour, minute, second, millisecond); + try { + const dtf = new Intl.DateTimeFormat('en-US', { + timeZone, + hourCycle: 'h23', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + const parts = dtf.formatToParts(new Date(asUtc)); + const get = (type: string) => Number(parts.find((p) => p.type === type)?.value); + const tzAsUtc = Date.UTC( + get('year'), + get('month') - 1, + get('day'), + get('hour'), + get('minute'), + get('second'), + millisecond, + ); + return asUtc - (tzAsUtc - asUtc); + } catch { + // Invalid timezone — fall back to treating the components as UTC. + return asUtc; + } +} + /** * Resolve the timezone AND the source label that explains where it came * from, in a single call. Used by surfaces that want to render a diff --git a/web/lib/usageColorUtils.ts b/web/lib/usageColorUtils.ts index 1cb22ae4..bb3b5f24 100644 --- a/web/lib/usageColorUtils.ts +++ b/web/lib/usageColorUtils.ts @@ -50,24 +50,3 @@ export function getUsageColor(percent: number): string { return 'rgb(239, 68, 68)'; // red-500 } } - -/** - * Get the ring/hover color class based on usage percentage - * For use in hover states - * - * @param percent - Usage percentage (0-100) - * @returns Tailwind CSS ring color class - */ -export function getUsageRingClass(percent: number): string { - if (percent < 30) { - return 'hover:ring-emerald-500/30'; - } else if (percent < 50) { - return 'hover:ring-violet-500/30'; - } else if (percent < 70) { - return 'hover:ring-sky-500/30'; - } else if (percent < 85) { - return 'hover:ring-amber-500/30'; - } else { - return 'hover:ring-red-500/30'; - } -} diff --git a/web/openapi.yaml b/web/openapi.yaml index c1ba51f6..7c2eeed8 100644 --- a/web/openapi.yaml +++ b/web/openapi.yaml @@ -614,8 +614,10 @@ paths: summary: Clear site logs description: | Deletes log entries from `sites/{siteId}/logs`. Optional filters - match the current logs view: action, machineId, and level. Whole-site - clearing requires explicit `all: true`. API-key callers must hold + match the current logs view: action, machineId, level, and a + `since`/`until` timestamp window. Whole-site clearing (no filters) + requires explicit `all: true`. Note: full-text search is client-side + and does NOT scope deletion. API-key callers must hold `site=:admin`; session callers need the site-log management capability. security: @@ -641,6 +643,12 @@ paths: level: type: string enum: [debug, info, warning, error, critical] + since: + type: string + description: ISO 8601 or unix-ms inclusive lower timestamp bound. Scopes deletion to a time window. + until: + type: string + description: ISO 8601 or unix-ms inclusive upper timestamp bound. responses: '200': description: Logs cleared @@ -5745,6 +5753,53 @@ paths: '401': { description: Unauthorized } '403': { description: Forbidden (not superadmin) } + /api/users/deletions: + get: + tags: [Users] + summary: list user-deletion audit events (superadmin only) + description: | + Lists user-deletion events from the platform audit log, newest first. + Covers both self-service deletions (`USER_SELF_DELETE`) and + superadmin-initiated deletions (`USER_DELETE`); both land in + `global/audit_log/entries`. Requires `user=*:read` scope + (superadmin-only at minting) or a session/id-token from a superadmin + user. + security: + - apiKey: [] + - bearerApiKey: [] + - firebaseIdToken: [] + parameters: + - name: limit + in: query + required: false + description: max events to return (clamped 1..200) + schema: { type: integer, minimum: 1, maximum: 200, default: 50 } + responses: + '200': + description: Deletion events (newest first) + content: + application/json: + schema: + type: object + required: [deletions] + properties: + deletions: + type: array + items: + type: object + required: [id] + properties: + id: { type: string } + uid: { type: string, nullable: true } + actorUid: { type: string, nullable: true } + capability: { type: string } + outcome: { type: string } + timestamp: { type: string, format: date-time, nullable: true } + denyReason: { type: string, nullable: true } + counts: { type: object, nullable: true, additionalProperties: true } + '401': { description: Unauthorized } + '403': { description: Forbidden (not superadmin) } + /api/users/bootstrap: post: tags: [Users] diff --git a/web/package-lock.json b/web/package-lock.json index 2e6e6a24..8b1f8c95 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -39,7 +39,7 @@ "ai": "^6.0.134", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "date-fns": "^4.1.0", + "date-fns": "^4.3.0", "firebase": "^12.5.0", "firebase-admin": "^13.5.0", "fumadocs-core": "^16.8.12", @@ -55,6 +55,7 @@ "otplib": "^12.0.1", "qrcode": "^1.5.4", "react": "19.2.0", + "react-day-picker": "^10.0.1", "react-dom": "19.2.0", "react-firebase-hooks": "^5.1.1", "react-markdown": "^10.1.0", @@ -1673,6 +1674,12 @@ "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", "license": "Apache-2.0" }, + "node_modules/@date-fns/tz": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz", + "integrity": "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==", + "license": "MIT" + }, "node_modules/@emnapi/core": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.6.0.tgz", @@ -12024,9 +12031,9 @@ } }, "node_modules/date-fns": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", - "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.3.0.tgz", + "integrity": "sha512-OYcL+3N/jyWbYdFGqoMAhytDgxP9pbYPUUiRCOgn4Fewaadk9l/Wam4Avciiyp2BgkpfQyBV9B+ehnVJych+eQ==", "license": "MIT", "funding": { "type": "github", @@ -20149,6 +20156,32 @@ "node": ">=0.10.0" } }, + "node_modules/react-day-picker": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-10.0.1.tgz", + "integrity": "sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "date-fns": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "@types/react": ">=16.8.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-dom": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", diff --git a/web/package.json b/web/package.json index 865e7e1a..24c19f0c 100644 --- a/web/package.json +++ b/web/package.json @@ -23,7 +23,9 @@ "e2e:ui": "npm run e2e:build && cd .. && firebase emulators:exec --only auth,firestore,storage --project demo-playwright-e2e \"cd web && npx playwright test --ui\"", "e2e:install": "playwright install chromium", "screenshots": "npm run e2e:build && cd .. && firebase emulators:exec --only auth,firestore,storage --project demo-playwright-e2e \"cd web && npx playwright test --config=playwright.screenshots.config.ts\"", - "screenshots:debug": "npm run e2e:build && cd .. && firebase emulators:exec --only auth,firestore,storage --project demo-playwright-e2e \"cd web && npx playwright test --config=playwright.screenshots.config.ts --headed --debug\"" + "screenshots:debug": "npm run e2e:build && cd .. && firebase emulators:exec --only auth,firestore,storage --project demo-playwright-e2e \"cd web && npx playwright test --config=playwright.screenshots.config.ts --headed --debug\"", + "videos": "npm run e2e:build && cd .. && firebase emulators:exec --only auth,firestore,storage --project demo-playwright-e2e \"cd web && npx playwright test --config=playwright.videos.config.ts\"", + "videos:debug": "npm run e2e:build && cd .. && firebase emulators:exec --only auth,firestore,storage --project demo-playwright-e2e \"cd web && npx playwright test --config=playwright.videos.config.ts --headed --debug\"" }, "dependencies": { "@ai-sdk/anthropic": "^3.0.63", @@ -56,7 +58,7 @@ "ai": "^6.0.134", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "date-fns": "^4.1.0", + "date-fns": "^4.3.0", "firebase": "^12.5.0", "firebase-admin": "^13.5.0", "fumadocs-core": "^16.8.12", @@ -72,6 +74,7 @@ "otplib": "^12.0.1", "qrcode": "^1.5.4", "react": "19.2.0", + "react-day-picker": "^10.0.1", "react-dom": "19.2.0", "react-firebase-hooks": "^5.1.1", "react-markdown": "^10.1.0", diff --git a/web/playwright.videos.config.ts b/web/playwright.videos.config.ts new file mode 100644 index 00000000..bf78def8 --- /dev/null +++ b/web/playwright.videos.config.ts @@ -0,0 +1,97 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Playwright config for the tutorial VIDEO-capture pipeline. + * + * Sibling of `playwright.screenshots.config.ts` — same emulator boot, global-setup, + * webServer block, and seeded demo fleet (so machine names / metrics read like a real + * operation). The differences: + * - testDir is `./e2e/videos`, matching only `*.video.ts` files + * - 1920×1080 viewport so footage drops straight into a 1080p timeline + * - serial, retries:0 (deterministic capture, loud failures) + * + * Each scene file creates its OWN browser context with `recordVideo` (via + * `recordScene()` in `e2e/videos/video-helpers.ts`) so it can name the output file + * after the episode/scene. The clean .webm files land in `e2e/.output/videos/`. + * + * Triggered explicitly by `npm run videos`; never in CI. + */ + +const PORT = Number(process.env.E2E_PORT) || 3100; +const BASE_URL = `http://127.0.0.1:${PORT}`; +const AUTH_EMULATOR_HOST = process.env.FIREBASE_AUTH_EMULATOR_HOST || '127.0.0.1:9099'; +const FIRESTORE_EMULATOR_HOST = process.env.FIRESTORE_EMULATOR_HOST || '127.0.0.1:8080'; +const STORAGE_EMULATOR_HOST = + process.env.FIREBASE_STORAGE_EMULATOR_HOST || '127.0.0.1:9199'; +const NEXT_DIST_DIR = process.env.OWLETTE_NEXT_DIST_DIR || '.next-e2e'; +const OUTPUT_DIR = process.env.E2E_VIDEOS_OUTPUT_DIR || './e2e/.output/videos-results'; + +export default defineConfig({ + testDir: './e2e/videos', + testMatch: /\.video\.ts$/, + outputDir: OUTPUT_DIR, + fullyParallel: false, + forbidOnly: false, + retries: 0, + workers: 1, + // Generous timeout — scenes deliberately dwell (narration gaps) and can run minutes. + timeout: 5 * 60_000, + reporter: [['list']], + + globalSetup: require.resolve('./e2e/global-setup'), + globalTeardown: require.resolve('./e2e/global-teardown'), + + expect: { timeout: 10_000 }, + + use: { + baseURL: BASE_URL, + trace: 'retain-on-failure', + screenshot: 'off', + // Scenes record video via their own context (recordScene); the per-test fixture + // video stays off so we don't get duplicate downscaled captures. + video: 'off', + actionTimeout: 15_000, + navigationTimeout: 20_000, + viewport: { width: 1920, height: 1080 }, + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'], viewport: { width: 1920, height: 1080 } }, + }, + ], + + webServer: { + command: `node scripts/e2e-next-server.mjs --port ${PORT} --hostname 127.0.0.1`, + url: BASE_URL, + reuseExistingServer: false, + timeout: 60_000, + stdout: 'pipe', + stderr: 'pipe', + env: { + NEXT_PUBLIC_USE_FIREBASE_EMULATOR: 'true', + NEXT_PUBLIC_FIREBASE_PROJECT_ID: 'demo-playwright-e2e', + NEXT_PUBLIC_FIREBASE_API_KEY: 'demo-api-key', + NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: 'demo-playwright-e2e.firebaseapp.com', + NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET: 'demo-playwright-e2e.firebasestorage.app', + NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: '000000000000', + NEXT_PUBLIC_FIREBASE_APP_ID: 'demo-app-id', + NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_HOST: AUTH_EMULATOR_HOST, + NEXT_PUBLIC_FIRESTORE_EMULATOR_HOST: FIRESTORE_EMULATOR_HOST, + NEXT_PUBLIC_FIREBASE_STORAGE_EMULATOR_HOST: STORAGE_EMULATOR_HOST, + FIREBASE_AUTH_EMULATOR_HOST: AUTH_EMULATOR_HOST, + FIRESTORE_EMULATOR_HOST, + FIREBASE_STORAGE_EMULATOR_HOST: STORAGE_EMULATOR_HOST, + FIREBASE_PROJECT_ID: 'demo-playwright-e2e', + OWLETTE_NEXT_DIST_DIR: NEXT_DIST_DIR, + SESSION_SECRET: 'demo-session-secret-for-emulator-playwright-tests-32chars', + MFA_ENCRYPTION_KEY: 'demo-mfa-encryption-secret-for-playwright-only', + NEXT_PUBLIC_SENTRY_DSN: '', + UPSTASH_REDIS_REST_URL: '', + UPSTASH_REDIS_REST_TOKEN: '', + E2E_DISABLE_RATE_LIMIT: 'true', + OWLETTE_E2E: '1', + }, + }, +});
+
+ {children} +
+