diff --git a/frontend/src/components/EarthTwin.tsx b/frontend/src/components/EarthTwin.tsx index 181ed95..2f282eb 100644 --- a/frontend/src/components/EarthTwin.tsx +++ b/frontend/src/components/EarthTwin.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useRef, useState, useCallback, useImperativeHandle, forwardRef } from 'react'; import { prefersReducedMotion } from './SatelliteSpotlight/GlowEffect'; import { useUIStore } from '@/store/uiStore'; +import { logEvent } from '@/store/logbookStore'; import { MaterialIcon } from './MaterialIcon'; import { useNavigate } from 'react-router-dom'; import * as Cesium from 'cesium'; @@ -194,6 +195,7 @@ export const EarthTwin = forwardRef((_props, ref) => { }, duration: 1.5, }); + logEvent('TRACKING', 'MEDIUM', 'ISS tracking engaged', 'Camera locked onto the International Space Station.'); }, []); const handleShowDebris = useCallback(() => { @@ -226,6 +228,7 @@ export const EarthTwin = forwardRef((_props, ref) => { }, duration: 1.5, }); + logEvent('CAMERA', 'LOW', 'Camera repositioned', 'Zoomed to regional view: India.'); }, []); const handleToggleSpaceWeather = useCallback(() => { @@ -724,6 +727,12 @@ export const EarthTwin = forwardRef((_props, ref) => { if (pos) { const destination = Cesium.Cartesian3.fromDegrees(pos.lon, pos.lat, pos.alt * 1000 + 2000000); viewer.camera.flyTo({ destination, duration: prefersReducedMotion() ? 0 : 1.5 }); + logEvent( + 'CAMERA', + 'LOW', + 'Camera focused on target', + `Flew to ${obj.name ?? 'Unknown object'} — NORAD ${catalogNumber}` + ); } // SpotlightManager owns actual selection state; this just tells it diff --git a/frontend/src/components/Logbook/LogEntryItem.tsx b/frontend/src/components/Logbook/LogEntryItem.tsx new file mode 100644 index 0000000..ea96dd3 --- /dev/null +++ b/frontend/src/components/Logbook/LogEntryItem.tsx @@ -0,0 +1,96 @@ +import React, { useState } from 'react'; +import { motion } from 'framer-motion'; +import { MaterialIcon } from '@/components/MaterialIcon'; +import type { LogEntry } from '@/types/logbook'; +import { CATEGORY_CONFIG, PRIORITY_CONFIG, formatLogTime } from './logbookConfig'; + +interface LogEntryItemProps { + entry: LogEntry; +} + +export const LogEntryItem: React.FC = ({ entry }) => { + const [expanded, setExpanded] = useState(false); + const cat = CATEGORY_CONFIG[entry.category]; + const pri = PRIORITY_CONFIG[entry.priority]; + const detailEntries = entry.details ? Object.entries(entry.details) : []; + const hasDetails = detailEntries.length > 0; + + return ( + + + + {expanded && hasDetails && ( + + {detailEntries.map(([key, value]) => ( +
+ {key} + {value} +
+ ))} +
+ )} +
+ ); +}; + +export default LogEntryItem; diff --git a/frontend/src/components/Logbook/LogbookPanel.tsx b/frontend/src/components/Logbook/LogbookPanel.tsx new file mode 100644 index 0000000..c587ff5 --- /dev/null +++ b/frontend/src/components/Logbook/LogbookPanel.tsx @@ -0,0 +1,225 @@ +import React, { useMemo, useRef, useState, useEffect, useCallback } from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { MaterialIcon } from '@/components/MaterialIcon'; +import { useLogbookStore } from '@/store/logbookStore'; +import type { LogCategory, LogPriority } from '@/types/logbook'; +import { LogEntryItem } from './LogEntryItem'; +import { CATEGORY_CONFIG, CATEGORY_ORDER, PRIORITY_CONFIG, PRIORITY_ORDER } from './logbookConfig'; + +/** How close (px) to the top the list must be to count as "viewing latest". */ +const AUTO_SCROLL_THRESHOLD = 24; + +export const LogbookPanel: React.FC = () => { + const entries = useLogbookStore((s) => s.entries); + const clearAll = useLogbookStore((s) => s.clearAll); + + const [query, setQuery] = useState(''); + const [activeCategories, setActiveCategories] = useState>(new Set()); + const [activePriorities, setActivePriorities] = useState>(new Set()); + + const listRef = useRef(null); + const [pinnedToTop, setPinnedToTop] = useState(true); + const [newSinceScroll, setNewSinceScroll] = useState(0); + const prevCountRef = useRef(entries.length); + + const toggleCategory = (cat: LogCategory) => { + setActiveCategories((prev) => { + const next = new Set(prev); + if (next.has(cat)) next.delete(cat); + else next.add(cat); + return next; + }); + }; + + const togglePriority = (pri: LogPriority) => { + setActivePriorities((prev) => { + const next = new Set(prev); + if (next.has(pri)) next.delete(pri); + else next.add(pri); + return next; + }); + }; + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + return entries.filter((e) => { + if (activeCategories.size > 0 && !activeCategories.has(e.category)) return false; + if (activePriorities.size > 0 && !activePriorities.has(e.priority)) return false; + if (q && !e.title.toLowerCase().includes(q) && !(e.description ?? '').toLowerCase().includes(q)) { + return false; + } + return true; + }); + }, [entries, activeCategories, activePriorities, query]); + + // Track whether the user is parked at the top (viewing the latest entry) + // so we know whether it's safe to auto-scroll, or whether we'd be + // yanking them away from history they're reviewing. + const handleScroll = useCallback(() => { + const el = listRef.current; + if (!el) return; + const atTop = el.scrollTop <= AUTO_SCROLL_THRESHOLD; + setPinnedToTop(atTop); + if (atTop) setNewSinceScroll(0); + }, []); + + useEffect(() => { + const prevCount = prevCountRef.current; + const grew = entries.length > prevCount; + prevCountRef.current = entries.length; + + if (pinnedToTop && grew) { + listRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); + } + + // Surface a "N new entries" affordance when entries arrive while the + // user is reading history, instead of yanking their scroll position. + setNewSinceScroll((n) => (pinnedToTop ? 0 : grew ? n + (entries.length - prevCount) : n)); + }, [entries.length, pinnedToTop]); + + const jumpToLatest = () => { + listRef.current?.scrollTo({ top: 0, behavior: 'smooth' }); + setNewSinceScroll(0); + setPinnedToTop(true); + }; + + return ( +
+ {/* Search */} +
+ setQuery(e.target.value)} + placeholder="SEARCH LOGBOOK..." + className="w-full bg-surface-container-low border border-border-panel text-[10px] font-technical-data px-2.5 py-2 pl-7 focus:outline-none focus:border-primary-container transition-ui" + /> + +
+ + {/* Category filter chips */} +
+ {CATEGORY_ORDER.map((cat) => { + const cfg = CATEGORY_CONFIG[cat]; + const active = activeCategories.has(cat); + return ( + + ); + })} +
+ + {/* Priority filter chips */} +
+ {PRIORITY_ORDER.map((pri) => { + const cfg = PRIORITY_CONFIG[pri]; + const active = activePriorities.has(pri); + return ( + + ); + })} + {(activeCategories.size > 0 || activePriorities.size > 0 || query) && ( + + )} +
+ + {/* Entry count / clear */} +
+ + {filtered.length} of {entries.length} {entries.length === 1 ? 'ENTRY' : 'ENTRIES'} + + {entries.length > 0 && ( + + )} +
+ + {/* New entries indicator */} + + {newSinceScroll > 0 && ( + + + {newSinceScroll} NEW {newSinceScroll === 1 ? 'ENTRY' : 'ENTRIES'} + + )} + + + {/* Entry list */} +
+ {entries.length === 0 ? ( +
+ +

+ No mission events recorded yet. +

+
+ ) : filtered.length === 0 ? ( +
+ +

+ No entries match the current filters. +

+
+ ) : ( + + {filtered.map((entry) => ( + + ))} + + )} +
+
+ ); +}; + +export default LogbookPanel; diff --git a/frontend/src/components/Logbook/logbookConfig.ts b/frontend/src/components/Logbook/logbookConfig.ts new file mode 100644 index 0000000..894af1a --- /dev/null +++ b/frontend/src/components/Logbook/logbookConfig.ts @@ -0,0 +1,24 @@ +import type { LogCategory, LogPriority } from '@/types/logbook'; + +export const CATEGORY_CONFIG: Record = { + TRACKING: { label: 'Tracking', icon: 'satellite_alt', color: '#00e5ff' }, + CAMERA: { label: 'Camera', icon: 'videocam', color: '#7c3aed' }, + SEARCH: { label: 'Search', icon: 'search', color: '#34C759' }, + ALERTS: { label: 'Alerts', icon: 'crisis_alert', color: '#FF3B30' }, + SYSTEM: { label: 'System', icon: 'memory', color: '#8892A6' }, + MISSION: { label: 'Mission', icon: 'flag', color: '#FF9500' }, +}; + +export const PRIORITY_CONFIG: Record = { + LOW: { label: 'LOW', color: '#8892A6' }, + MEDIUM: { label: 'MEDIUM', color: '#FF9500' }, + HIGH: { label: 'HIGH', color: '#FF3B30' }, + CRITICAL: { label: 'CRITICAL', color: '#FF3B30', pulse: true }, +}; + +export const CATEGORY_ORDER: LogCategory[] = ['TRACKING', 'CAMERA', 'SEARCH', 'ALERTS', 'SYSTEM', 'MISSION']; +export const PRIORITY_ORDER: LogPriority[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']; + +export function formatLogTime(ts: number): string { + return new Date(ts).toISOString().substring(11, 19) + 'Z'; +} diff --git a/frontend/src/components/layouts/MainLayout.tsx b/frontend/src/components/layouts/MainLayout.tsx index db6004c..4889c3d 100644 --- a/frontend/src/components/layouts/MainLayout.tsx +++ b/frontend/src/components/layouts/MainLayout.tsx @@ -1,9 +1,14 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { NavLink, Outlet, useLocation } from 'react-router-dom'; import { motion, AnimatePresence } from 'framer-motion'; import { MaterialIcon } from '@/components/MaterialIcon'; import { useUIStore } from '@/store/uiStore'; import { DynamicBackground } from '@/components/DynamicBackground/DynamicBackground'; +import { LogbookPanel } from '@/components/Logbook/LogbookPanel'; +import { useLogbookStore, logEvent } from '@/store/logbookStore'; + +/** Ensures the mission-init System log fires once per browser tab session. */ +let hasLoggedMissionInit = false; export const MainLayout: React.FC = () => { const { @@ -53,24 +58,48 @@ export const MainLayout: React.FC = () => { const [activeDrawerTab, setActiveDrawerTab] = useState<'STREAM' | 'STATUS' | 'LOGS'>('STREAM'); const [assistantInput, setAssistantInput] = useState(''); - const [assistantLogs, setAssistantLogs] = useState>([ - { time: '14:22:01', msg: 'GYROSCOPE DELTA CALIBRATION COMPLETE', type: 'info' }, - { time: '14:21:45', msg: 'UPLINK ESTABLISHED WITH GROUND STATION XI\'AN', type: 'success' }, - { time: '14:18:22', msg: 'THERMAL SHIELD ATTACHMENT TEMP: -142.2C', type: 'info' } - ]); + const [tleSearch, setTleSearch] = useState(''); + + const logbookEntryCount = useLogbookStore((s) => s.entries.length); + const lastViewedLogCountRef = useRef(0); + const [unreadLogCount, setUnreadLogCount] = useState(0); + const isViewingLogs = rightDrawerOpen && activeDrawerTab === 'LOGS'; + + // Record a System event once, when mission control first comes online. + // Module-scope guard (not a ref) so it survives React StrictMode's + // double-invoke in dev *and* Vite HMR module reloads — a component-local + // ref resets on remount, but this only resets on a genuine full page load. + useEffect(() => { + if (hasLoggedMissionInit) return; + hasLoggedMissionInit = true; + logEvent('SYSTEM', 'LOW', 'Mission control interface initialized', 'Dashboard shell mounted and ready.'); + }, []); + + // Sync the unread badge to the logbook store (an external system) as new + // entries arrive or the Logs tab is opened; see the identical, pre-existing + // pattern in EarthTwin.tsx for setState-in-effect used this way. + useEffect(() => { + if (isViewingLogs) { + lastViewedLogCountRef.current = logbookEntryCount; + } + setUnreadLogCount(isViewingLogs ? 0 : Math.max(0, logbookEntryCount - lastViewedLogCountRef.current)); + }, [isViewingLogs, logbookEntryCount]); const handleSendQuery = (e: React.FormEvent) => { e.preventDefault(); if (!assistantInput.trim()) return; - const time = new Date().toLocaleTimeString([], { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }); - setAssistantLogs(prev => [ - { time, msg: `COMMAND RECEIVED: ${assistantInput.toUpperCase()}`, type: 'command' }, - ...prev - ]); + logEvent('MISSION', 'LOW', 'Command received', assistantInput.trim(), { SOURCE: 'AI Assistant' }); setAssistantInput(''); }; + const handleTleSearchSubmit = (e: React.KeyboardEvent) => { + if (e.key !== 'Enter') return; + const q = tleSearch.trim(); + if (!q) return; + logEvent('SEARCH', 'LOW', 'Command search executed', `Query: "${q}"`); + }; + return (
{/* Dynamic animated background */} @@ -189,6 +218,9 @@ export const MainLayout: React.FC = () => { setTleSearch(e.target.value)} + onKeyDown={handleTleSearchSubmit} placeholder="ID / TLE SEARCH" className="bg-surface-container-low w-full border-b border-primary/30 text-primary font-technical-data text-[12px] pl-8 pr-2 sm:pr-16 py-1 focus:outline-none focus:border-primary-container transition-ui placeholder:text-primary/30" /> @@ -210,9 +242,14 @@ export const MainLayout: React.FC = () => {
)} - {activeDrawerTab === 'LOGS' && ( -
- {assistantLogs.map((log, i) => ( -
- [{log.time}] - - {log.msg} - -
- ))} -
- )} + {activeDrawerTab === 'LOGS' && }
{/* Chat Input */} diff --git a/frontend/src/hooks/useSatelliteSelection.ts b/frontend/src/hooks/useSatelliteSelection.ts index 058b4e0..f0abccb 100644 --- a/frontend/src/hooks/useSatelliteSelection.ts +++ b/frontend/src/hooks/useSatelliteSelection.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import * as Cesium from 'cesium'; import type { CatalogObject } from '@/types/satellite'; import { prefersReducedMotion } from '@/components/SatelliteSpotlight/GlowEffect'; +import { logEvent } from '@/store/logbookStore'; interface UseSatelliteSelectionArgs { viewer: Cesium.Viewer | null; @@ -51,7 +52,12 @@ export function useSatelliteSelection({ (obj: CatalogObject | null) => { setSelectedId(obj?.catalog_number ?? null); setSelectedObject(obj); - setSelectedIsCollisionRisk(obj ? collisionSetRef.current?.has(obj.catalog_number) ?? false : false); + const isCollisionRisk = obj ? collisionSetRef.current?.has(obj.catalog_number) ?? false : false; + setSelectedIsCollisionRisk(isCollisionRisk); + // Selection logging happens centrally in uiStore.setSelectedSatelliteId + // (which this ultimately calls via onSelect), since the satellite list + // page also writes to that same field directly — logging here too + // would double up for globe-driven selections. onSelectRef.current?.(obj?.catalog_number ?? null); }, [collisionSetRef] @@ -103,6 +109,12 @@ export function useSatelliteSelection({ } else { viewer.camera.flyTo({ destination, duration: 1.5 }); } + logEvent( + 'CAMERA', + 'LOW', + 'Camera focused on target', + `Flew to ${obj.name ?? 'Unknown object'} — NORAD ${obj.catalog_number}` + ); } } catch { /* ignore malformed entity data */ diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 9489421..329c92a 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,4 +1,4 @@ -import React, { useRef } from 'react'; +import React, { useRef, useEffect, useMemo } from 'react'; import { motion } from 'framer-motion'; import { EarthTwin, type EarthTwinHandle } from '@/components/EarthTwin'; import { MaterialIcon } from '@/components/MaterialIcon'; @@ -10,6 +10,7 @@ import { useCollisions } from '@/hooks/useApi'; import { useAgentRuns } from '@/hooks/useApi'; import { useWeatherStatus } from '@/hooks/useApi'; import type { Collision, AgentDecision } from '@/services/api'; +import { logEvent } from '@/store/logbookStore'; const riskColor = (level: string) => { switch (level) { @@ -107,9 +108,31 @@ export const Dashboard: React.FC = () => { ] : null; - const conjunctions = collisions.data?.data ?? []; + const conjunctions = useMemo(() => collisions.data?.data ?? [], [collisions.data]); const agentRuns = agents.data?.data ?? []; + // Log newly-detected CRITICAL/HIGH risk conjunctions as Alert events — + // guarded by a ref of already-seen ids so repeated polling doesn't spam + // the logbook with the same conjunction on every refetch. + const loggedCollisionIdsRef = useRef>(new Set()); + useEffect(() => { + for (const conj of conjunctions) { + if ( + (conj.risk_level === 'CRITICAL' || conj.risk_level === 'HIGH') && + !loggedCollisionIdsRef.current.has(conj.id) + ) { + loggedCollisionIdsRef.current.add(conj.id); + logEvent( + 'ALERTS', + conj.risk_level === 'CRITICAL' ? 'CRITICAL' : 'HIGH', + 'Conjunction risk detected', + `${conj.object_a?.name ?? 'Unknown'} vs ${conj.object_b?.name ?? 'Unknown'} — ${(conj.probability * 100).toFixed(2)}% probability`, + { RISK_LEVEL: conj.risk_level, MISS_DISTANCE_M: conj.miss_distance_m.toFixed(0) } + ); + } + } + }, [conjunctions]); + const decisions: Array = agentRuns .flatMap(run => (run.decisions ?? []).map(d => ({ ...d, runName: run.workflow_name })) diff --git a/frontend/src/pages/Satellites.tsx b/frontend/src/pages/Satellites.tsx index f0470c5..75288db 100644 --- a/frontend/src/pages/Satellites.tsx +++ b/frontend/src/pages/Satellites.tsx @@ -3,6 +3,7 @@ import React, { useRef, useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { MaterialIcon } from '@/components/MaterialIcon'; import { useUIStore } from '@/store/uiStore'; +import { logEvent } from '@/store/logbookStore'; import { useCatalogObjects, useCatalogStats, useCatalogSync, useSatelliteTelemetry } from '@/hooks/useApi'; import type { SpaceObject } from '@/services/api'; import { SatelliteComparisonModal } from '@/components/SatelliteComparisonModal'; @@ -59,6 +60,9 @@ export const Satellites: React.FC = () => { searchTimer.current = setTimeout(() => { setDebounced(val); setPage(1); + if (val.trim()) { + logEvent('SEARCH', 'LOW', 'Satellite catalog search', `Query: "${val.trim()}"`); + } }, 400); }; diff --git a/frontend/src/store/logbookStore.ts b/frontend/src/store/logbookStore.ts new file mode 100644 index 0000000..9b11880 --- /dev/null +++ b/frontend/src/store/logbookStore.ts @@ -0,0 +1,53 @@ +import { create } from 'zustand'; +import type { LogEntry, LogEntryInput, LogCategory, LogPriority } from '@/types/logbook'; + +/** Hard cap on retained entries so a long mission session doesn't grow memory unbounded. */ +const MAX_ENTRIES = 300; + +let counter = 0; +/** Collision-resistant id: timestamp isn't unique enough when events fire in the same tick. */ +function nextId(): string { + counter += 1; + return `log_${Date.now()}_${counter}`; +} + +interface LogbookState { + entries: LogEntry[]; + addEntry: (entry: LogEntryInput) => void; + clearAll: () => void; +} + +export const useLogbookStore = create((set) => ({ + entries: [], + addEntry: (entry) => + set((state) => { + const newEntry: LogEntry = { + ...entry, + id: nextId(), + timestamp: Date.now(), + }; + const entries = [newEntry, ...state.entries]; + return { + entries: entries.length > MAX_ENTRIES ? entries.slice(0, MAX_ENTRIES) : entries, + }; + }), + clearAll: () => set({ entries: [] }), +})); + +/** + * Record a mission event from anywhere — components, hooks, Cesium event + * handlers, or other stores — without needing to subscribe to the logbook + * store. This is the primary entry point the rest of the app should use. + * + * @example + * logEvent('TRACKING', 'MEDIUM', 'Satellite locked', 'ISS (ZARYA) — NORAD 25544'); + */ +export function logEvent( + category: LogCategory, + priority: LogPriority, + title: string, + description?: string, + details?: Record +): void { + useLogbookStore.getState().addEntry({ category, priority, title, description, details }); +} diff --git a/frontend/src/store/uiStore.ts b/frontend/src/store/uiStore.ts index 4dd461b..0e425e3 100644 --- a/frontend/src/store/uiStore.ts +++ b/frontend/src/store/uiStore.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { logEvent } from './logbookStore'; interface UIState { sidebarCollapsed: boolean; @@ -32,7 +33,17 @@ export const useUIStore = create((set) => ({ setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }), toggleRightDrawer: () => set((state) => ({ rightDrawerOpen: !state.rightDrawerOpen })), setRightDrawerOpen: (open) => set({ rightDrawerOpen: open }), - setSelectedSatelliteId: (id) => set({ selectedSatelliteId: id }), + setSelectedSatelliteId: (id) => + set((state) => { + // Single source of truth for satellite selection — both the 3D globe + // and the satellite list page funnel here, so log it once, here, + // rather than at every call site (which would double-log the globe + // path, since it also updates this same field). + if (id && id !== state.selectedSatelliteId) { + logEvent('TRACKING', 'LOW', 'Satellite locked', `NORAD ${id}`, { CATALOG_NUMBER: id }); + } + return { selectedSatelliteId: id }; + }), setSelectedSatelliteIds: (ids) => set({ selectedSatelliteIds: ids }), toggleSatelliteSelection: (id) => set((state) => { const isSelected = state.selectedSatelliteIds.includes(id); diff --git a/frontend/src/types/logbook.ts b/frontend/src/types/logbook.ts new file mode 100644 index 0000000..ff0c281 --- /dev/null +++ b/frontend/src/types/logbook.ts @@ -0,0 +1,38 @@ +/** + * Types for the Mission Intelligence & Operations Logbook. + * + * The logbook is the operational audit trail of the mission: every + * significant user interaction or system event (satellite tracking, + * camera movement, searches, alerts, etc.) is recorded here so it can be + * reviewed chronologically, filtered, and inspected after the fact. + */ + +/** High-level grouping used for filtering and iconography in the UI. */ +export type LogCategory = + | 'TRACKING' + | 'CAMERA' + | 'SEARCH' + | 'ALERTS' + | 'SYSTEM' + | 'MISSION'; + +/** Severity of a log entry, used for color coding and sorting. */ +export type LogPriority = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'; + +export interface LogEntry { + /** Unique, monotonically-creatable id (not guaranteed sortable). */ + id: string; + /** Unix ms timestamp, set when the entry is recorded. */ + timestamp: number; + category: LogCategory; + priority: LogPriority; + /** Short, human-readable summary shown in the collapsed row. */ + title: string; + /** Optional one-line elaboration shown under the title. */ + description?: string; + /** Optional key/value pairs shown when the entry is expanded. */ + details?: Record; +} + +/** Payload accepted when recording a new entry — id/timestamp are assigned by the store. */ +export type LogEntryInput = Omit;