From fdee10ccbf36c28788afa74970fe22aeae074126 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 2 Apr 2026 17:53:11 -0400 Subject: [PATCH 01/66] feat(merge): add showMergeTabsDialog state to ui-store Co-Authored-By: Claude Opus 4.6 (1M context) --- src/stores/ui-store.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/stores/ui-store.ts b/src/stores/ui-store.ts index 3b1cf323d..1d02f5db1 100644 --- a/src/stores/ui-store.ts +++ b/src/stores/ui-store.ts @@ -172,6 +172,7 @@ interface UiState { showSettingsDialog: boolean; showEvidenceBundleDialog: boolean; showGuidRegistryDialog: boolean; + showMergeTabsDialog: boolean; showFileAssociationPrompt: boolean; logListFontSize: number; logDetailsFontSize: number; @@ -217,6 +218,7 @@ interface UiState { setShowSettingsDialog: (show: boolean) => void; setShowEvidenceBundleDialog: (show: boolean) => void; setShowGuidRegistryDialog: (show: boolean) => void; + setShowMergeTabsDialog: (show: boolean) => void; setShowFileAssociationPrompt: (show: boolean) => void; setLogListFontSize: (fontSize: number) => void; increaseLogListFontSize: () => void; @@ -314,6 +316,7 @@ export const useUiStore = create()( showSettingsDialog: false, showEvidenceBundleDialog: false, showGuidRegistryDialog: false, + showMergeTabsDialog: false, showFileAssociationPrompt: false, logListFontSize: DEFAULT_LOG_LIST_FONT_SIZE, logDetailsFontSize: DEFAULT_LOG_DETAILS_FONT_SIZE, @@ -427,6 +430,7 @@ export const useUiStore = create()( setShowSettingsDialog: (show) => set({ showSettingsDialog: show }), setShowEvidenceBundleDialog: (show) => set({ showEvidenceBundleDialog: show }), setShowGuidRegistryDialog: (show) => set({ showGuidRegistryDialog: show }), + setShowMergeTabsDialog: (show) => set({ showMergeTabsDialog: show }), setShowFileAssociationPrompt: (show) => set({ showFileAssociationPrompt: show }), setLogListFontSize: (fontSize) => set({ logListFontSize: clampLogListFontSize(fontSize) }), From ed18e0c1c6898a33c86dc79e9d4bb3b70b603acf Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 2 Apr 2026 17:53:14 -0400 Subject: [PATCH 02/66] =?UTF-8?q?feat(merge):=20add=20pure=20merge=20logic?= =?UTF-8?q?=20=E2=80=94=20sorting,=20colors,=20correlation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/merge-entries.ts | 113 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 src/lib/merge-entries.ts diff --git a/src/lib/merge-entries.ts b/src/lib/merge-entries.ts new file mode 100644 index 000000000..5a7079479 --- /dev/null +++ b/src/lib/merge-entries.ts @@ -0,0 +1,113 @@ +import type { LogEntry } from "../types/log"; + +export const MERGE_FILE_COLORS = [ + "#2563eb", "#dc2626", "#16a34a", "#9333ea", + "#ea580c", "#0891b2", "#c026d3", "#854d0e", +]; + +export interface MergedTabState { + sourceFilePaths: string[]; + colorAssignments: Record; + fileVisibility: Record; + mergedEntries: LogEntry[]; + cacheKey: string; +} + +export interface CorrelatedEntry { + entry: LogEntry; + deltaMs: number; + fileColor: string; +} + +export function assignFileColors( + filePaths: string[] +): Record { + const assignments: Record = {}; + for (let i = 0; i < filePaths.length; i++) { + assignments[filePaths[i]] = MERGE_FILE_COLORS[i % MERGE_FILE_COLORS.length]; + } + return assignments; +} + +export function buildMergeCacheKey( + filePaths: string[], + entryCounts: Record +): string { + return filePaths + .map((fp) => `${fp}:${entryCounts[fp] ?? 0}`) + .sort() + .join("|"); +} + +export function mergeEntries( + entriesByFile: Record +): LogEntry[] { + const allTimestamped: LogEntry[] = []; + + for (const entries of Object.values(entriesByFile)) { + for (const entry of entries) { + if (entry.timestamp != null) { + allTimestamped.push(entry); + } + } + } + + allTimestamped.sort((a, b) => { + if (a.timestamp !== b.timestamp) return a.timestamp! - b.timestamp!; + const fileCmp = a.filePath.localeCompare(b.filePath); + if (fileCmp !== 0) return fileCmp; + return a.lineNumber - b.lineNumber; + }); + + return allTimestamped; +} + +export function filterByVisibility( + entries: LogEntry[], + visibility: Record +): LogEntry[] { + return entries.filter((e) => visibility[e.filePath] !== false); +} + +export function countEntriesByFile( + entries: LogEntry[] +): Record { + const counts: Record = {}; + for (const entry of entries) { + counts[entry.filePath] = (counts[entry.filePath] ?? 0) + 1; + } + return counts; +} + +export function findCorrelatedEntries( + entries: LogEntry[], + targetEntry: LogEntry, + windowMs: number, + colorAssignments: Record +): CorrelatedEntry[] { + if (targetEntry.timestamp == null) return []; + + const targetTs = targetEntry.timestamp; + const results: CorrelatedEntry[] = []; + + for (const entry of entries) { + if (entry.filePath === targetEntry.filePath) continue; + if (entry.timestamp == null) continue; + + const delta = entry.timestamp - targetTs; + if (Math.abs(delta) <= windowMs) { + results.push({ + entry, + deltaMs: delta, + fileColor: colorAssignments[entry.filePath] ?? "#888", + }); + } + } + + results.sort((a, b) => Math.abs(a.deltaMs) - Math.abs(b.deltaMs)); + return results; +} + +export function fileBaseName(filePath: string): string { + return filePath.split(/[\\/]/).pop() ?? filePath; +} From 08a27b64ef251ed81beea4be82a90c588cdff4e6 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 2 Apr 2026 17:55:17 -0400 Subject: [PATCH 03/66] feat(merge): add MergedTabState, merge/correlation actions to log store Co-Authored-By: Claude Opus 4.6 (1M context) --- src/stores/log-store.ts | 148 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 146 insertions(+), 2 deletions(-) diff --git a/src/stores/log-store.ts b/src/stores/log-store.ts index f792e1e3e..cc0502b00 100644 --- a/src/stores/log-store.ts +++ b/src/stores/log-store.ts @@ -17,6 +17,17 @@ import { } from "../lib/column-config"; import { formatLogEntryTimestamp } from "../lib/date-time-format"; import { buildGuidNameMap, mergeGuidNameMap } from "../lib/guid-name-map"; +import { + type MergedTabState, + type CorrelatedEntry, + assignFileColors, + buildMergeCacheKey, + mergeEntries, + filterByVisibility, + findCorrelatedEntries, +} from "../lib/merge-entries"; + +export type { MergedTabState, CorrelatedEntry }; /** * Snapshot of parsed file state — cached in memory so tab switches @@ -89,7 +100,7 @@ export interface ParserSelectionDisplay { dateOrderLabel: string | null; } -export type SourceOpenMode = "single-file" | "aggregate-folder" | null; +export type SourceOpenMode = "single-file" | "aggregate-folder" | "merged" | null; const UNGROUPED_TOOLBAR_GROUP_ID = "ungrouped"; @@ -539,6 +550,10 @@ interface LogState { folderLoadCompletedFiles: number | null; /** GUID→app name map built from "Get policies" log entries. */ guidNameMap: Record; + mergedTabState: MergedTabState | null; + correlationWindowMs: number; + autoCorrelate: boolean; + correlatedEntries: CorrelatedEntry[]; /** Pending scroll target set by deployment workspace — consumed by LogListView after load. */ pendingScrollTarget: { filePath: string; lineNumber: number } | null; @@ -583,6 +598,13 @@ interface LogState { currentFile: string; } | null) => void; setPendingScrollTarget: (target: { filePath: string; lineNumber: number } | null) => void; + createMergedTab: (sourceFilePaths: string[]) => void; + closeMergedTab: () => void; + setFileVisibility: (filePath: string, visible: boolean) => void; + setAllFileVisibility: (visible: boolean) => void; + setCorrelationWindowMs: (ms: number) => void; + setAutoCorrelate: (enabled: boolean) => void; + updateCorrelation: () => void; } /** Debounced version of recomputeAndSetMatches for keystroke-driven updates. */ @@ -703,6 +725,10 @@ export const useLogStore = create((set, get) => ({ folderLoadCompletedFiles: null, activeColumns: DEFAULT_COLUMNS, guidNameMap: {}, + mergedTabState: null, + correlationWindowMs: 1000, + autoCorrelate: true, + correlatedEntries: [], pendingScrollTarget: null, hasActiveSource: () => { @@ -757,7 +783,10 @@ export const useLogStore = create((set, get) => ({ }); recomputeAndSetMatches(); }, - selectEntry: (id) => set({ selectedId: id }), + selectEntry: (id) => { + set({ selectedId: id }); + setTimeout(() => useLogStore.getState().updateCorrelation(), 0); + }, togglePause: () => set((state) => ({ isPaused: !state.isPaused })), setLoading: (loading) => set({ isLoading: loading }), setFormatDetected: (format) => set({ formatDetected: format }), @@ -841,6 +870,8 @@ export const useLogStore = create((set, get) => ({ activeColumns: DEFAULT_COLUMNS, byteOffset: 0, guidNameMap: {}, + mergedTabState: null, + correlatedEntries: [], findMatchIds: [], findCurrentIndex: -1, findRegexError: null, @@ -870,6 +901,8 @@ export const useLogStore = create((set, get) => ({ }, byteOffset: 0, guidNameMap: {}, + mergedTabState: null, + correlatedEntries: [], findMatchIds: [], findCurrentIndex: -1, findRegexError: null, @@ -892,4 +925,115 @@ export const useLogStore = create((set, get) => ({ } ), setPendingScrollTarget: (target) => set({ pendingScrollTarget: target }), + + createMergedTab: (sourceFilePaths) => { + const entriesByFile: Record = {}; + const entryCounts: Record = {}; + + for (const fp of sourceFilePaths) { + const snapshot = getCachedTabSnapshot(fp); + if (snapshot) { + entriesByFile[fp] = snapshot.entries; + entryCounts[fp] = snapshot.entries.length; + } + } + + const validPaths = Object.keys(entriesByFile); + if (validPaths.length < 2) return; + + const colorAssignments = assignFileColors(validPaths); + const fileVisibility: Record = {}; + for (const fp of validPaths) { + fileVisibility[fp] = true; + } + + const merged = mergeEntries(entriesByFile); + const cacheKey = buildMergeCacheKey(validPaths, entryCounts); + + set({ + mergedTabState: { + sourceFilePaths: validPaths, + colorAssignments, + fileVisibility, + mergedEntries: merged, + cacheKey, + }, + entries: filterByVisibility(merged, fileVisibility), + sourceOpenMode: "merged" as SourceOpenMode, + selectedId: null, + correlatedEntries: [], + }); + }, + + closeMergedTab: () => { + set({ + mergedTabState: null, + entries: [], + sourceOpenMode: null, + selectedId: null, + correlatedEntries: [], + }); + }, + + setFileVisibility: (filePath, visible) => { + set((state) => { + if (!state.mergedTabState) return {}; + const fileVisibility = { + ...state.mergedTabState.fileVisibility, + [filePath]: visible, + }; + return { + mergedTabState: { ...state.mergedTabState, fileVisibility }, + entries: filterByVisibility(state.mergedTabState.mergedEntries, fileVisibility), + selectedId: null, + correlatedEntries: [], + }; + }); + recomputeAndSetMatches(); + }, + + setAllFileVisibility: (visible) => { + set((state) => { + if (!state.mergedTabState) return {}; + const fileVisibility: Record = {}; + for (const fp of state.mergedTabState.sourceFilePaths) { + fileVisibility[fp] = visible; + } + return { + mergedTabState: { ...state.mergedTabState, fileVisibility }, + entries: visible ? state.mergedTabState.mergedEntries : [], + selectedId: null, + correlatedEntries: [], + }; + }); + recomputeAndSetMatches(); + }, + + setCorrelationWindowMs: (ms) => set({ correlationWindowMs: ms }), + + setAutoCorrelate: (enabled) => set({ autoCorrelate: enabled }), + + updateCorrelation: () => { + const state = useLogStore.getState(); + if (!state.mergedTabState || !state.autoCorrelate || state.selectedId == null) { + if (state.correlatedEntries.length > 0) { + useLogStore.setState({ correlatedEntries: [] }); + } + return; + } + + const selectedEntry = state.entries.find((e) => e.id === state.selectedId); + if (!selectedEntry) { + useLogStore.setState({ correlatedEntries: [] }); + return; + } + + const correlated = findCorrelatedEntries( + state.mergedTabState.mergedEntries, + selectedEntry, + state.correlationWindowMs, + state.mergedTabState.colorAssignments + ); + useLogStore.setState({ correlatedEntries: correlated }); + }, })); From 4cae36622d22b50c4ef0f4485a180aec335bc908 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 2 Apr 2026 17:57:12 -0400 Subject: [PATCH 04/66] feat(merge): create MergeLegendBar with file toggles and correlation controls Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/log-view/MergeLegendBar.tsx | 166 +++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 src/components/log-view/MergeLegendBar.tsx diff --git a/src/components/log-view/MergeLegendBar.tsx b/src/components/log-view/MergeLegendBar.tsx new file mode 100644 index 000000000..91bbffc99 --- /dev/null +++ b/src/components/log-view/MergeLegendBar.tsx @@ -0,0 +1,166 @@ +import { tokens } from "@fluentui/react-components"; +import { useLogStore } from "../../stores/log-store"; +import { fileBaseName } from "../../lib/merge-entries"; +import { LOG_UI_FONT_FAMILY } from "../../lib/log-accessibility"; + +const CORRELATION_WINDOWS = [ + { label: "100ms", value: 100 }, + { label: "500ms", value: 500 }, + { label: "1s", value: 1000 }, + { label: "5s", value: 5000 }, + { label: "10s", value: 10000 }, +]; + +export function MergeLegendBar() { + const mergedTabState = useLogStore((s) => s.mergedTabState); + const correlationWindowMs = useLogStore((s) => s.correlationWindowMs); + const autoCorrelate = useLogStore((s) => s.autoCorrelate); + const setFileVisibility = useLogStore((s) => s.setFileVisibility); + const setAllFileVisibility = useLogStore((s) => s.setAllFileVisibility); + const setCorrelationWindowMs = useLogStore((s) => s.setCorrelationWindowMs); + const setAutoCorrelate = useLogStore((s) => s.setAutoCorrelate); + const entries = useLogStore((s) => s.entries); + + if (!mergedTabState) return null; + + const fileCounts: Record = {}; + for (const entry of mergedTabState.mergedEntries) { + fileCounts[entry.filePath] = (fileCounts[entry.filePath] ?? 0) + 1; + } + + return ( +
+ {mergedTabState.sourceFilePaths.map((fp) => { + const color = mergedTabState.colorAssignments[fp] ?? "#888"; + const visible = mergedTabState.fileVisibility[fp] !== false; + const count = fileCounts[fp] ?? 0; + + return ( + + ); + })} + +
+ + + + +
+ + + Correlate: + + + + +
+ {entries.length} merged +
+
+ ); +} From b1670ea34f2883cafd59a45a56b59a4b1f90ad55 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 2 Apr 2026 17:57:41 -0400 Subject: [PATCH 05/66] feat(merge): create MergeTabsDialog component Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/dialogs/MergeTabsDialog.tsx | 179 +++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 src/components/dialogs/MergeTabsDialog.tsx diff --git a/src/components/dialogs/MergeTabsDialog.tsx b/src/components/dialogs/MergeTabsDialog.tsx new file mode 100644 index 000000000..7f8991713 --- /dev/null +++ b/src/components/dialogs/MergeTabsDialog.tsx @@ -0,0 +1,179 @@ +import { useMemo, useState } from "react"; +import { + Button, + Checkbox, + Dialog, + DialogActions, + DialogBody, + DialogContent, + DialogSurface, + DialogTitle, + tokens, +} from "@fluentui/react-components"; +import { WarningRegular } from "@fluentui/react-icons"; +import { LOG_MONOSPACE_FONT_FAMILY } from "../../lib/log-accessibility"; +import { getCachedTabSnapshot } from "../../stores/log-store"; +import { useUiStore } from "../../stores/ui-store"; +import { formatLogEntryTimestamp } from "../../lib/date-time-format"; + +interface MergeTabsDialogProps { + isOpen: boolean; + onClose: () => void; + onMerge: (filePaths: string[]) => void; +} + +interface TabInfo { + filePath: string; + fileName: string; + entryCount: number; + hasTimestamps: boolean; + timeRange: string | null; +} + +function getTabInfo(filePath: string): TabInfo { + const snapshot = getCachedTabSnapshot(filePath); + const fileName = filePath.split(/[\\/]/).pop() ?? filePath; + if (!snapshot) { + return { filePath, fileName, entryCount: 0, hasTimestamps: false, timeRange: null }; + } + + const timestamped = snapshot.entries.filter((e) => e.timestamp != null); + const hasTimestamps = timestamped.length > 0; + + let timeRange: string | null = null; + if (hasTimestamps) { + const first = formatLogEntryTimestamp(timestamped[0]); + const last = formatLogEntryTimestamp(timestamped[timestamped.length - 1]); + if (first && last) { + timeRange = `${first} — ${last}`; + } + } + + return { + filePath, + fileName, + entryCount: snapshot.entries.length, + hasTimestamps, + timeRange, + }; +} + +export function MergeTabsDialog({ isOpen, onClose, onMerge }: MergeTabsDialogProps) { + const openTabs = useUiStore((s) => s.openTabs); + const [selected, setSelected] = useState>(new Set()); + + const tabInfos = useMemo(() => { + return openTabs.map((tab) => getTabInfo(tab.filePath)); + }, [openTabs]); + + const toggleFile = (filePath: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(filePath)) next.delete(filePath); + else next.add(filePath); + return next; + }); + }; + + const selectAll = () => { + const eligible = tabInfos.filter((t) => t.hasTimestamps).map((t) => t.filePath); + setSelected(new Set(eligible)); + }; + + const selectNone = () => setSelected(new Set()); + + const canMerge = selected.size >= 2; + + const handleMerge = () => { + if (!canMerge) return; + onMerge(Array.from(selected)); + setSelected(new Set()); + onClose(); + }; + + const handleClose = () => { + setSelected(new Set()); + onClose(); + }; + + return ( + { if (!data.open) handleClose(); }}> + + + Merge Tabs into Timeline + +
+ Select 2 or more tabs to merge into a unified time-sorted view. + Files without timestamps cannot be merged. +
+ +
+ + +
+ +
+ {tabInfos.map((tab) => ( +
+ toggleFile(tab.filePath)} + disabled={!tab.hasTimestamps} + /> +
+
+ {tab.fileName} + {!tab.hasTimestamps && ( + + )} +
+
+ {tab.entryCount} entries + {tab.timeRange && ` | ${tab.timeRange}`} + {!tab.hasTimestamps && " | No timestamps — cannot merge"} +
+
+
+ ))} +
+
+ + + + +
+
+
+ ); +} From dfdcd125ca8fd05dc2550367b7cafbf379503c64 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 2 Apr 2026 17:57:43 -0400 Subject: [PATCH 06/66] feat(merge): add Merge Tabs button to toolbar Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/layout/Toolbar.tsx | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/components/layout/Toolbar.tsx b/src/components/layout/Toolbar.tsx index 7252efd31..95b96e8f7 100644 --- a/src/components/layout/Toolbar.tsx +++ b/src/components/layout/Toolbar.tsx @@ -768,12 +768,17 @@ export function Toolbar() { const activeView = useUiStore((s) => s.activeView); const setActiveView = useUiStore((s) => s.setActiveView); const currentPlatform = useUiStore((s) => s.currentPlatform); + const activeWorkspace = useUiStore((s) => s.activeWorkspace); + const openTabs = useUiStore((s) => s.openTabs); + const setShowMergeTabsDialog = useUiStore((s) => s.setShowMergeTabsDialog); const enabledWorkspaces = useUiStore((s) => s.enabledWorkspaces); const availableWorkspaces = useMemo( () => getAvailableWorkspaces(currentPlatform, enabledWorkspaces), [currentPlatform, enabledWorkspaces] ); + const canMergeTabs = activeWorkspace === "log" && openTabs.length >= 2; + const { commandState, openSourceFileDialog, @@ -957,6 +962,25 @@ export function Toolbar() { }} /> + {canMergeTabs && ( + + )} + - -
- -
- {tabInfos.map((tab) => ( -
- toggleFile(tab.filePath)} - disabled={!tab.hasTimestamps} - /> -
-
- {tab.fileName} - {!tab.hasTimestamps && ( - - )} -
-
- {tab.entryCount} entries - {tab.timeRange && ` | ${tab.timeRange}`} - {!tab.hasTimestamps && " | No timestamps — cannot merge"} -
-
-
- ))} -
+

+ Select two or more log files to merge into a single chronological timeline. +

+ {mergeable.length < 2 ? ( +

+ Open at least two log files to use this feature. +

+ ) : ( +
+ {mergeable.map((tab) => { + const label = tab.filePath.split(/[\\/]/).pop() || tab.filePath; + return ( + togglePath(tab.filePath)} + label={label} + /> + ); + })} +
+ )} - - + diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index d6dcd0d4a..a36603739 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -18,6 +18,7 @@ import { FileAssociationPromptDialog } from "../dialogs/FileAssociationPromptDia import { CollectDiagnosticsDialog } from "../dialogs/CollectDiagnosticsDialog"; import { CollectionCompleteDialog } from "../dialogs/CollectionCompleteDialog"; import { UpdateDialog } from "../dialogs/UpdateDialog"; +import { MergeTabsDialog } from "../dialogs/MergeTabsDialog"; import { IntuneDashboard } from "../intune/IntuneDashboard"; import { NewIntuneWorkspace } from "../intune/NewIntuneWorkspace"; import { DsregcmdWorkspace } from "../dsregcmd/DsregcmdWorkspace"; @@ -101,6 +102,9 @@ export function AppShell() { const setShowCollectDiagnosticsDialog = useUiStore((s) => s.setShowCollectDiagnosticsDialog); const showUpdateDialog = useUiStore((s) => s.showUpdateDialog); const setShowUpdateDialog = useUiStore((s) => s.setShowUpdateDialog); + const showMergeTabsDialog = useUiStore((s) => s.showMergeTabsDialog); + const setShowMergeTabsDialog = useUiStore((s) => s.setShowMergeTabsDialog); + const createMergedTab = useLogStore((s) => s.createMergedTab); useCollectionProgressListener(); useParseProgressListener(); @@ -585,6 +589,11 @@ export function AppShell() { result={collectionResult} onClose={() => setCollectionResult(null)} /> + setShowMergeTabsDialog(false)} + onMerge={(filePaths) => createMergedTab(filePaths)} + /> { From 8a114b396a08ac3221ec33bd6105e5cdf719daab Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 2 Apr 2026 17:59:36 -0400 Subject: [PATCH 08/66] feat(merge): show merged tab indicator in tab strip Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/layout/TabStrip.tsx | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/components/layout/TabStrip.tsx b/src/components/layout/TabStrip.tsx index 72b5de196..6d2a7d0f5 100644 --- a/src/components/layout/TabStrip.tsx +++ b/src/components/layout/TabStrip.tsx @@ -1,5 +1,6 @@ import { type CSSProperties, type KeyboardEvent, type MouseEvent as ReactMouseEvent, useCallback, useEffect, useRef, useState } from "react"; import { tokens } from "@fluentui/react-components"; +import { useLogStore } from "../../stores/log-store"; import { useUiStore } from "../../stores/ui-store"; /** Minimum width a tab can shrink to before being pushed to overflow */ @@ -13,6 +14,10 @@ export function TabStrip() { const switchTab = useUiStore((s) => s.switchTab); const closeTab = useUiStore((s) => s.closeTab); + const sourceOpenMode = useLogStore((s) => s.sourceOpenMode); + const mergedTabState = useLogStore((s) => s.mergedTabState); + const closeMergedTab = useLogStore((s) => s.closeMergedTab); + const [hoveredTabIndex, setHoveredTabIndex] = useState(null); const [overflowOpen, setOverflowOpen] = useState(false); const [visibleCount, setVisibleCount] = useState(openTabs.length); @@ -78,9 +83,13 @@ export function TabStrip() { const handleCloseTab = useCallback( (e: ReactMouseEvent, index: number) => { e.stopPropagation(); + if (sourceOpenMode === "merged" && index === activeTabIndex) { + closeMergedTab(); + return; + } closeTab(index); }, - [closeTab] + [closeTab, sourceOpenMode, activeTabIndex, closeMergedTab] ); const handleToggleOverflow = useCallback( @@ -132,9 +141,13 @@ export function TabStrip() { const handleOverflowClose = useCallback( (e: ReactMouseEvent, index: number) => { e.stopPropagation(); + if (sourceOpenMode === "merged" && index === activeTabIndex) { + closeMergedTab(); + return; + } closeTab(index); }, - [closeTab] + [closeTab, sourceOpenMode, activeTabIndex, closeMergedTab] ); if (openTabs.length === 0) { @@ -174,7 +187,15 @@ export function TabStrip() { onMouseEnter={() => setHoveredTabIndex(index)} onMouseLeave={() => setHoveredTabIndex(null)} > - {tab.fileName} + + {sourceOpenMode === "merged" && index === activeTabIndex && mergedTabState ? ( + + Merged ({mergedTabState.sourceFilePaths.length} files) + + ) : ( + tab.fileName + )} +
+ {mergedTabState && } +
{ if (id !== selectedId) { suppressScrollRef.current = true; } selectEntry(id); }} onContextMenu={showContextMenu} onErrorCodeClick={handleErrorCodeClick} + mergeFileColor={mergedTabState?.colorAssignments[entry.filePath] ?? null} + isCorrelated={correlatedIdSet.has(entry.id)} + correlationColor={mergedTabState?.colorAssignments[entry.filePath] ?? null} />
); diff --git a/src/components/log-view/LogRow.tsx b/src/components/log-view/LogRow.tsx index ccb00c497..edd1eeb55 100644 --- a/src/components/log-view/LogRow.tsx +++ b/src/components/log-view/LogRow.tsx @@ -20,6 +20,9 @@ interface LogRowProps { onClick: (id: number) => void; onContextMenu: (entry: LogEntry, event: React.MouseEvent) => void; onErrorCodeClick?: (span: ErrorCodeSpan) => void; + mergeFileColor?: string | null; + isCorrelated?: boolean; + correlationColor?: string | null; } /** Subtle tint for find-match rows (not the active selection). */ @@ -234,6 +237,9 @@ export const LogRow = memo(function LogRow({ onClick, onContextMenu, onErrorCodeClick, + mergeFileColor, + isCorrelated, + correlationColor, }: LogRowProps) { const style = getRowStyle(entry, isSelected, isFindMatch, severityPalette); @@ -255,7 +261,12 @@ export const LogRow = memo(function LogRow({ lineHeight: `${rowLineHeight}px`, whiteSpace: "nowrap", transition: "filter 80ms linear", - boxShadow: `inset 3px 0 0 ${isSelected ? tokens.colorNeutralForegroundOnBrand : "transparent"}`, + boxShadow: mergeFileColor + ? `inset 3px 0 0 ${mergeFileColor}` + : `inset 3px 0 0 ${isSelected ? tokens.colorNeutralForegroundOnBrand : "transparent"}`, + ...(isCorrelated && correlationColor && !isSelected ? { + backgroundImage: `linear-gradient(${correlationColor}30, ${correlationColor}30)`, + } : {}), }} onClick={() => onClick(entry.id)} onContextMenu={(e) => onContextMenu(entry, e)} From 39029c112a1096bd78854c24dfad2efb713b0052 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 2 Apr 2026 17:59:56 -0400 Subject: [PATCH 10/66] feat(merge): add Merge into Timeline button to folder sidebar Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/layout/FileSidebar.tsx | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/components/layout/FileSidebar.tsx b/src/components/layout/FileSidebar.tsx index b9d1b34ba..e4ff449a1 100644 --- a/src/components/layout/FileSidebar.tsx +++ b/src/components/layout/FileSidebar.tsx @@ -330,6 +330,7 @@ function LogSidebar() { const isLoading = useLogStore((s) => s.isLoading); const knownSources = useLogStore((s) => s.knownSources); const sourceStatus = useLogStore((s) => s.sourceStatus); + const createMergedTab = useLogStore((s) => s.createMergedTab); const clearFilter = useFilterStore((s) => s.clearFilter); const [pendingPath, setPendingPath] = useState(null); @@ -554,6 +555,31 @@ function LogSidebar() { : "Select a file to begin viewing log entries." } /> + {files.length >= 2 && ( +
+ +
+ )} {files.length === 0 ? ( ) : ( From f3781fd216edce230049746e9b943a34c4d2cbca Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 2 Apr 2026 17:59:59 -0400 Subject: [PATCH 11/66] feat(merge): add correlated entries section to InfoPane Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/log-view/InfoPane.tsx | 72 ++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/src/components/log-view/InfoPane.tsx b/src/components/log-view/InfoPane.tsx index 9cb1653d5..313d6da07 100644 --- a/src/components/log-view/InfoPane.tsx +++ b/src/components/log-view/InfoPane.tsx @@ -13,6 +13,7 @@ import { import { getCategoryColor } from "../../lib/error-categories"; import { resolveGuidsInMessage } from "../../lib/guid-name-map"; import { AppWorkloadScriptDetail } from "./AppWorkloadScriptDetail"; +import { fileBaseName } from "../../lib/merge-entries"; export function InfoPane() { const entries = useLogStore((state) => state.entries); @@ -26,6 +27,9 @@ export function InfoPane() { ); const guidNameMap = useLogStore((state) => state.guidNameMap); + const mergedTabState = useLogStore((state) => state.mergedTabState); + const correlatedEntries = useLogStore((state) => state.correlatedEntries); + const selectEntry = useLogStore((state) => state.selectEntry); const parserDisplay = getParserSelectionDisplay(parserSelection); const detailLineHeight = getLogDetailsLineHeight(logDetailsFontSize); @@ -219,6 +223,74 @@ export function InfoPane() { ); })()} + {mergedTabState && correlatedEntries.length > 0 && ( +
+
+ Correlated Entries ({correlatedEntries.length}) +
+
+ {correlatedEntries.slice(0, 20).map((corr) => ( +
selectEntry(corr.entry.id)} + style={{ + display: "flex", + alignItems: "center", + gap: "6px", + padding: "2px 4px", + borderRadius: "3px", + cursor: "pointer", + borderLeft: `3px solid ${corr.fileColor}`, + }} + > + + {corr.deltaMs >= 0 ? "+" : ""}{corr.deltaMs}ms + + + {fileBaseName(corr.entry.filePath)} + + + {corr.entry.message.slice(0, 100)} + +
+ ))} + {correlatedEntries.length > 20 && ( +
+ +{correlatedEntries.length - 20} more +
+ )} +
+
+ )}
Date: Thu, 2 Apr 2026 18:00:51 -0400 Subject: [PATCH 12/66] docs: add unified timeline spec and implementation plan Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-04-02-unified-timeline.md | 1298 +++++++++++++++++ .../2026-04-02-unified-timeline-design.md | 185 +++ 2 files changed, 1483 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-02-unified-timeline.md create mode 100644 docs/superpowers/specs/2026-04-02-unified-timeline-design.md diff --git a/docs/superpowers/plans/2026-04-02-unified-timeline.md b/docs/superpowers/plans/2026-04-02-unified-timeline.md new file mode 100644 index 000000000..30a8ed19a --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-unified-timeline.md @@ -0,0 +1,1298 @@ +# Multi-File Unified Timeline Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Merge entries from multiple open log files into a single time-sorted view with per-file color coding, visibility toggles, and cross-file timestamp correlation. + +**Architecture:** Client-side only — no backend changes. A new `MergedTabState` in the log store holds the merged entry array, color assignments, and visibility toggles. Merge is triggered from a toolbar dialog or folder sidebar button and creates a virtual tab. A `MergeLegendBar` component provides file toggles and correlation controls. The `LogRow` component gains a file-color left border in merged mode, and the `InfoPane` gains a correlated entries section. + +**Tech Stack:** React 19, Zustand, TanStack Virtual (existing), Fluent UI v9 tokens (existing) + +--- + +## File Structure + +| File | Action | Responsibility | +|------|--------|----------------| +| `src/lib/merge-entries.ts` | Create | Pure merge logic: k-way merge, color assignment, correlation search | +| `src/stores/log-store.ts` | Modify | Add `MergedTabState`, merge/correlation actions, cache invalidation | +| `src/components/dialogs/MergeTabsDialog.tsx` | Create | Dialog for selecting tabs to merge | +| `src/components/log-view/MergeLegendBar.tsx` | Create | File chips with toggles, correlation controls | +| `src/components/log-view/LogRow.tsx` | Modify | File-color left border, correlation highlight | +| `src/components/log-view/LogListView.tsx` | Modify | Pass merged state props to LogRow, render legend bar | +| `src/components/log-view/InfoPane.tsx` | Modify | Correlated entries section | +| `src/components/layout/Toolbar.tsx` | Modify | "Merge Tabs..." button | +| `src/components/layout/TabStrip.tsx` | Modify | Merged tab icon and label | +| `src/components/layout/FileSidebar.tsx` | Modify | "Merge into Timeline" button | +| `src/components/layout/AppShell.tsx` | Modify | Render MergeTabsDialog | +| `src/stores/ui-store.ts` | Modify | Add `showMergeTabsDialog` state | + +--- + +### Task 1: Pure Merge Logic + +**Files:** +- Create: `src/lib/merge-entries.ts` + +This module contains all merge logic with no React dependency — pure functions that are easy to test. + +- [ ] **Step 1: Create the merge-entries module with types and color palette** + +```typescript +// src/lib/merge-entries.ts +import type { LogEntry } from "../types/log"; + +export const MERGE_FILE_COLORS = [ + "#2563eb", "#dc2626", "#16a34a", "#9333ea", + "#ea580c", "#0891b2", "#c026d3", "#854d0e", +]; + +export interface MergedTabState { + sourceFilePaths: string[]; + colorAssignments: Record; + fileVisibility: Record; + mergedEntries: LogEntry[]; + cacheKey: string; +} + +export interface CorrelatedEntry { + entry: LogEntry; + deltaMs: number; + fileColor: string; +} + +/** + * Assign a color to each file path, cycling through the palette. + */ +export function assignFileColors( + filePaths: string[] +): Record { + const assignments: Record = {}; + for (let i = 0; i < filePaths.length; i++) { + assignments[filePaths[i]] = MERGE_FILE_COLORS[i % MERGE_FILE_COLORS.length]; + } + return assignments; +} + +/** + * Build a cache key from source file paths and their entry counts. + */ +export function buildMergeCacheKey( + filePaths: string[], + entryCounts: Record +): string { + return filePaths + .map((fp) => `${fp}:${entryCounts[fp] ?? 0}`) + .sort() + .join("|"); +} + +/** + * Merge entries from multiple files into a single time-sorted array. + * Only includes entries with timestamps (strict mode). + * Sort: timestamp → filename → line number. + */ +export function mergeEntries( + entriesByFile: Record +): LogEntry[] { + const allTimestamped: LogEntry[] = []; + + for (const entries of Object.values(entriesByFile)) { + for (const entry of entries) { + if (entry.timestamp != null) { + allTimestamped.push(entry); + } + } + } + + allTimestamped.sort((a, b) => { + // Primary: timestamp + if (a.timestamp !== b.timestamp) return a.timestamp! - b.timestamp!; + // Secondary: filename + const fileCmp = a.filePath.localeCompare(b.filePath); + if (fileCmp !== 0) return fileCmp; + // Tertiary: line number + return a.lineNumber - b.lineNumber; + }); + + return allTimestamped; +} + +/** + * Apply file visibility filter to merged entries. + * Does not re-sort — just filters. + */ +export function filterByVisibility( + entries: LogEntry[], + visibility: Record +): LogEntry[] { + return entries.filter((e) => visibility[e.filePath] !== false); +} + +/** + * Count entries per file path. + */ +export function countEntriesByFile( + entries: LogEntry[] +): Record { + const counts: Record = {}; + for (const entry of entries) { + counts[entry.filePath] = (counts[entry.filePath] ?? 0) + 1; + } + return counts; +} + +/** + * Find entries from other files within a time window of a target timestamp. + */ +export function findCorrelatedEntries( + entries: LogEntry[], + targetEntry: LogEntry, + windowMs: number, + colorAssignments: Record +): CorrelatedEntry[] { + if (targetEntry.timestamp == null) return []; + + const targetTs = targetEntry.timestamp; + const results: CorrelatedEntry[] = []; + + for (const entry of entries) { + if (entry.filePath === targetEntry.filePath) continue; + if (entry.timestamp == null) continue; + + const delta = entry.timestamp - targetTs; + if (Math.abs(delta) <= windowMs) { + results.push({ + entry, + deltaMs: delta, + fileColor: colorAssignments[entry.filePath] ?? "#888", + }); + } + } + + results.sort((a, b) => Math.abs(a.deltaMs) - Math.abs(b.deltaMs)); + return results; +} + +/** + * Get the basename of a file path. + */ +export function fileBaseName(filePath: string): string { + return filePath.split(/[\\/]/).pop() ?? filePath; +} +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +Run: `npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/merge-entries.ts +git commit -m "feat(merge): add pure merge logic — sorting, colors, correlation" +``` + +--- + +### Task 2: Store Changes — MergedTabState and Actions + +**Files:** +- Modify: `src/stores/log-store.ts` + +Add the merged tab state, correlation state, and all actions to the log store. + +- [ ] **Step 1: Add imports at the top of log-store.ts** + +Add after the existing `guid-name-map` import: + +```typescript +import { + type MergedTabState, + type CorrelatedEntry, + assignFileColors, + buildMergeCacheKey, + mergeEntries, + filterByVisibility, + countEntriesByFile, + findCorrelatedEntries, +} from "../lib/merge-entries"; +``` + +- [ ] **Step 2: Export MergedTabState from log-store** + +Add re-export after imports: + +```typescript +export type { MergedTabState, CorrelatedEntry }; +``` + +- [ ] **Step 3: Add fields to the LogState interface** + +Add after the `guidNameMap` field (~line 542): + +```typescript + /** Merged tab state when viewing a multi-file merged timeline. */ + mergedTabState: MergedTabState | null; + /** Correlation time window in milliseconds. */ + correlationWindowMs: number; + /** Whether correlation runs automatically on selection change. */ + autoCorrelate: boolean; + /** Entries from other files within the correlation window of the selected entry. */ + correlatedEntries: CorrelatedEntry[]; +``` + +- [ ] **Step 4: Add action signatures to LogState interface** + +Add after the existing `setPendingScrollTarget` action: + +```typescript + createMergedTab: (sourceFilePaths: string[]) => void; + closeMergedTab: () => void; + setFileVisibility: (filePath: string, visible: boolean) => void; + setAllFileVisibility: (visible: boolean) => void; + setCorrelationWindowMs: (ms: number) => void; + setAutoCorrelate: (enabled: boolean) => void; + updateCorrelation: () => void; +``` + +- [ ] **Step 5: Add default values in the store creation** + +Add after `guidNameMap: {},`: + +```typescript + mergedTabState: null, + correlationWindowMs: 1000, + autoCorrelate: true, + correlatedEntries: [], +``` + +- [ ] **Step 6: Implement createMergedTab action** + +Add after the `setPendingScrollTarget` action implementation: + +```typescript + createMergedTab: (sourceFilePaths) => { + // Collect entries from tab cache for each source file + const entriesByFile: Record = {}; + const entryCounts: Record = {}; + + for (const fp of sourceFilePaths) { + const snapshot = getCachedTabSnapshot(fp); + if (snapshot) { + entriesByFile[fp] = snapshot.entries; + entryCounts[fp] = snapshot.entries.length; + } + } + + const validPaths = Object.keys(entriesByFile); + if (validPaths.length < 2) return; + + const colorAssignments = assignFileColors(validPaths); + const fileVisibility: Record = {}; + for (const fp of validPaths) { + fileVisibility[fp] = true; + } + + const merged = mergeEntries(entriesByFile); + const cacheKey = buildMergeCacheKey(validPaths, entryCounts); + + set({ + mergedTabState: { + sourceFilePaths: validPaths, + colorAssignments, + fileVisibility, + mergedEntries: merged, + cacheKey, + }, + entries: filterByVisibility(merged, fileVisibility), + sourceOpenMode: "merged" as SourceOpenMode, + selectedId: null, + correlatedEntries: [], + }); + }, +``` + +- [ ] **Step 7: Implement closeMergedTab action** + +```typescript + closeMergedTab: () => { + set({ + mergedTabState: null, + entries: [], + sourceOpenMode: null, + selectedId: null, + correlatedEntries: [], + }); + }, +``` + +- [ ] **Step 8: Implement file visibility actions** + +```typescript + setFileVisibility: (filePath, visible) => { + set((state) => { + if (!state.mergedTabState) return {}; + const fileVisibility = { + ...state.mergedTabState.fileVisibility, + [filePath]: visible, + }; + return { + mergedTabState: { ...state.mergedTabState, fileVisibility }, + entries: filterByVisibility(state.mergedTabState.mergedEntries, fileVisibility), + selectedId: null, + correlatedEntries: [], + }; + }); + recomputeAndSetMatches(); + }, + + setAllFileVisibility: (visible) => { + set((state) => { + if (!state.mergedTabState) return {}; + const fileVisibility: Record = {}; + for (const fp of state.mergedTabState.sourceFilePaths) { + fileVisibility[fp] = visible; + } + return { + mergedTabState: { ...state.mergedTabState, fileVisibility }, + entries: visible + ? state.mergedTabState.mergedEntries + : [], + selectedId: null, + correlatedEntries: [], + }; + }); + recomputeAndSetMatches(); + }, +``` + +- [ ] **Step 9: Implement correlation actions** + +```typescript + setCorrelationWindowMs: (ms) => set({ correlationWindowMs: ms }), + + setAutoCorrelate: (enabled) => set({ autoCorrelate: enabled }), + + updateCorrelation: () => { + const state = useLogStore.getState(); + if (!state.mergedTabState || !state.autoCorrelate || state.selectedId == null) { + if (state.correlatedEntries.length > 0) { + set({ correlatedEntries: [] }); + } + return; + } + + const selectedEntry = state.entries.find((e) => e.id === state.selectedId); + if (!selectedEntry) { + set({ correlatedEntries: [] }); + return; + } + + const correlated = findCorrelatedEntries( + state.mergedTabState.mergedEntries, + selectedEntry, + state.correlationWindowMs, + state.mergedTabState.colorAssignments + ); + set({ correlatedEntries: correlated }); + }, +``` + +- [ ] **Step 10: Update selectEntry to trigger correlation** + +Find the existing `selectEntry` action and modify it: + +```typescript + selectEntry: (id) => { + set({ selectedId: id }); + // Trigger correlation update in merged mode + setTimeout(() => useLogStore.getState().updateCorrelation(), 0); + }, +``` + +- [ ] **Step 11: Update SourceOpenMode type** + +Change line 92: + +```typescript +export type SourceOpenMode = "single-file" | "aggregate-folder" | "merged" | null; +``` + +- [ ] **Step 12: Add mergedTabState to clearActiveFile and clear actions** + +In `clearActiveFile`, add `mergedTabState: null, correlatedEntries: [],` to the set object. + +In `clear`, add `mergedTabState: null, correlatedEntries: [],` to the set object. + +- [ ] **Step 13: Verify TypeScript compiles** + +Run: `npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 14: Commit** + +```bash +git add src/stores/log-store.ts +git commit -m "feat(merge): add MergedTabState, merge/correlation actions to log store" +``` + +--- + +### Task 3: UI Store — Merge Dialog Toggle + +**Files:** +- Modify: `src/stores/ui-store.ts` + +- [ ] **Step 1: Add showMergeTabsDialog state** + +Add to the `UiState` interface, near `showGuidRegistryDialog`: + +```typescript + showMergeTabsDialog: boolean; + setShowMergeTabsDialog: (show: boolean) => void; +``` + +Add default value `showMergeTabsDialog: false,` in the store creation. + +Add setter: `setShowMergeTabsDialog: (show) => set({ showMergeTabsDialog: show }),` + +- [ ] **Step 2: Commit** + +```bash +git add src/stores/ui-store.ts +git commit -m "feat(merge): add showMergeTabsDialog state to ui-store" +``` + +--- + +### Task 4: Merge Tabs Dialog + +**Files:** +- Create: `src/components/dialogs/MergeTabsDialog.tsx` + +- [ ] **Step 1: Create the dialog component** + +```typescript +// src/components/dialogs/MergeTabsDialog.tsx +import { useMemo, useState } from "react"; +import { + Button, + Checkbox, + Dialog, + DialogActions, + DialogBody, + DialogContent, + DialogSurface, + DialogTitle, + tokens, +} from "@fluentui/react-components"; +import { WarningRegular } from "@fluentui/react-icons"; +import { LOG_MONOSPACE_FONT_FAMILY } from "../../lib/log-accessibility"; +import { getCachedTabSnapshot } from "../../stores/log-store"; +import { useUiStore } from "../../stores/ui-store"; +import { formatLogEntryTimestamp } from "../../lib/date-time-format"; + +interface MergeTabsDialogProps { + isOpen: boolean; + onClose: () => void; + onMerge: (filePaths: string[]) => void; +} + +interface TabInfo { + filePath: string; + fileName: string; + entryCount: number; + hasTimestamps: boolean; + timeRange: string | null; +} + +function getTabInfo(filePath: string): TabInfo { + const snapshot = getCachedTabSnapshot(filePath); + const fileName = filePath.split(/[\\/]/).pop() ?? filePath; + if (!snapshot) { + return { filePath, fileName, entryCount: 0, hasTimestamps: false, timeRange: null }; + } + + const timestamped = snapshot.entries.filter((e) => e.timestamp != null); + const hasTimestamps = timestamped.length > 0; + + let timeRange: string | null = null; + if (hasTimestamps) { + const first = formatLogEntryTimestamp(timestamped[0]); + const last = formatLogEntryTimestamp(timestamped[timestamped.length - 1]); + if (first && last) { + timeRange = `${first} — ${last}`; + } + } + + return { + filePath, + fileName, + entryCount: snapshot.entries.length, + hasTimestamps, + timeRange, + }; +} + +export function MergeTabsDialog({ isOpen, onClose, onMerge }: MergeTabsDialogProps) { + const openTabs = useUiStore((s) => s.openTabs); + const [selected, setSelected] = useState>(new Set()); + + const tabInfos = useMemo(() => { + return openTabs.map((tab) => getTabInfo(tab.filePath)); + }, [openTabs]); + + const toggleFile = (filePath: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(filePath)) next.delete(filePath); + else next.add(filePath); + return next; + }); + }; + + const selectAll = () => { + const eligible = tabInfos.filter((t) => t.hasTimestamps).map((t) => t.filePath); + setSelected(new Set(eligible)); + }; + + const selectNone = () => setSelected(new Set()); + + const canMerge = selected.size >= 2; + + const handleMerge = () => { + if (!canMerge) return; + onMerge(Array.from(selected)); + setSelected(new Set()); + onClose(); + }; + + const handleClose = () => { + setSelected(new Set()); + onClose(); + }; + + return ( + { if (!data.open) handleClose(); }}> + + + Merge Tabs into Timeline + +
+ Select 2 or more tabs to merge into a unified time-sorted view. + Files without timestamps cannot be merged. +
+ +
+ + +
+ +
+ {tabInfos.map((tab) => ( +
+ toggleFile(tab.filePath)} + disabled={!tab.hasTimestamps} + /> +
+
+ {tab.fileName} + {!tab.hasTimestamps && ( + + )} +
+
+ {tab.entryCount} entries + {tab.timeRange && ` | ${tab.timeRange}`} + {!tab.hasTimestamps && " | No timestamps — cannot merge"} +
+
+
+ ))} +
+
+ + + + +
+
+
+ ); +} +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +Run: `npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 3: Commit** + +```bash +git add src/components/dialogs/MergeTabsDialog.tsx +git commit -m "feat(merge): create MergeTabsDialog component" +``` + +--- + +### Task 5: Merge Legend Bar + +**Files:** +- Create: `src/components/log-view/MergeLegendBar.tsx` + +- [ ] **Step 1: Create the legend bar component** + +```typescript +// src/components/log-view/MergeLegendBar.tsx +import { tokens } from "@fluentui/react-components"; +import { useLogStore, type MergedTabState } from "../../stores/log-store"; +import { fileBaseName } from "../../lib/merge-entries"; +import { LOG_UI_FONT_FAMILY } from "../../lib/log-accessibility"; + +const CORRELATION_WINDOWS = [ + { label: "100ms", value: 100 }, + { label: "500ms", value: 500 }, + { label: "1s", value: 1000 }, + { label: "5s", value: 5000 }, + { label: "10s", value: 10000 }, +]; + +export function MergeLegendBar() { + const mergedTabState = useLogStore((s) => s.mergedTabState); + const correlationWindowMs = useLogStore((s) => s.correlationWindowMs); + const autoCorrelate = useLogStore((s) => s.autoCorrelate); + const setFileVisibility = useLogStore((s) => s.setFileVisibility); + const setAllFileVisibility = useLogStore((s) => s.setAllFileVisibility); + const setCorrelationWindowMs = useLogStore((s) => s.setCorrelationWindowMs); + const setAutoCorrelate = useLogStore((s) => s.setAutoCorrelate); + const entries = useLogStore((s) => s.entries); + + if (!mergedTabState) return null; + + // Count visible entries per file + const fileCounts: Record = {}; + for (const entry of mergedTabState.mergedEntries) { + fileCounts[entry.filePath] = (fileCounts[entry.filePath] ?? 0) + 1; + } + + return ( +
+ {mergedTabState.sourceFilePaths.map((fp) => { + const color = mergedTabState.colorAssignments[fp] ?? "#888"; + const visible = mergedTabState.fileVisibility[fp] !== false; + const count = fileCounts[fp] ?? 0; + + return ( + + ); + })} + +
+ + + + +
+ + + Correlate: + + + + +
+ {entries.length} merged +
+
+ ); +} + +const smallBtnStyle: React.CSSProperties = { + fontSize: "10px", + padding: "2px 6px", + border: `1px solid var(--colorNeutralStroke2)`, + borderRadius: "3px", + backgroundColor: "var(--colorNeutralBackground1)", + color: "var(--colorNeutralForeground1)", + cursor: "pointer", +}; +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +Run: `npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 3: Commit** + +```bash +git add src/components/log-view/MergeLegendBar.tsx +git commit -m "feat(merge): create MergeLegendBar with file toggles and correlation controls" +``` + +--- + +### Task 6: LogRow — File Color Border and Correlation Highlight + +**Files:** +- Modify: `src/components/log-view/LogRow.tsx` +- Modify: `src/components/log-view/LogListView.tsx` + +- [ ] **Step 1: Add merge-related props to LogRow** + +In `LogRow.tsx`, add to `LogRowProps` interface: + +```typescript + /** Hex color for file-based left border in merged views. Null when not in merged mode. */ + mergeFileColor?: string | null; + /** Whether this entry is a correlation match in merged view. */ + isCorrelated?: boolean; + /** Color to use for correlation highlight tint. */ + correlationColor?: string | null; +``` + +- [ ] **Step 2: Apply merge color border in LogRow rendering** + +In the `
` that renders the row (the one with `boxShadow: inset 3px 0 0 ...`), update the boxShadow logic: + +```typescript +boxShadow: mergeFileColor + ? `inset 3px 0 0 ${mergeFileColor}` + : `inset 3px 0 0 ${isSelected ? tokens.colorNeutralForegroundOnBrand : "transparent"}`, +``` + +For correlation highlight, add to the style object: + +```typescript +...(isCorrelated && correlationColor && !isSelected ? { + backgroundImage: `linear-gradient(${correlationColor}30, ${correlationColor}30)`, +} : {}), +``` + +- [ ] **Step 3: Pass merge props from LogListView** + +In `LogListView.tsx`, add store selectors: + +```typescript +const mergedTabState = useLogStore((s) => s.mergedTabState); +const correlatedEntries = useLogStore((s) => s.correlatedEntries); +``` + +Create a correlation ID set: + +```typescript +const correlatedIdSet = useMemo( + () => new Set(correlatedEntries.map((c) => c.entry.id)), + [correlatedEntries] +); +``` + +In the `` render, add the new props: + +```typescript +mergeFileColor={mergedTabState?.colorAssignments[entry.filePath] ?? null} +isCorrelated={correlatedIdSet.has(entry.id)} +correlationColor={mergedTabState?.colorAssignments[entry.filePath] ?? null} +``` + +- [ ] **Step 4: Render MergeLegendBar in LogListView** + +Import and render the legend bar above the virtualized list: + +```typescript +import { MergeLegendBar } from "./MergeLegendBar"; +``` + +Add before the virtualizer `
`: + +```tsx +{mergedTabState && } +``` + +- [ ] **Step 5: Verify TypeScript compiles** + +Run: `npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 6: Commit** + +```bash +git add src/components/log-view/LogRow.tsx src/components/log-view/LogListView.tsx +git commit -m "feat(merge): add file color borders, correlation highlights, and legend bar to log view" +``` + +--- + +### Task 7: Toolbar — Merge Button + +**Files:** +- Modify: `src/components/layout/Toolbar.tsx` + +- [ ] **Step 1: Add "Merge Tabs..." button to the toolbar** + +In the `Toolbar` component, after the existing toolbar buttons (near the Highlight input area), add a "Merge Tabs..." button. This button should be visible only when the active workspace is "log" and there are 2+ open tabs: + +```typescript +const openTabs = useUiStore((s) => s.openTabs); +const setShowMergeTabsDialog = useUiStore((s) => s.setShowMergeTabsDialog); +const canMergeTabs = activeWorkspace === "log" && openTabs.length >= 2; +``` + +Render the button in the toolbar (after the Highlight input, before the Details/Info toggles): + +```tsx +{canMergeTabs && ( + +)} +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +Run: `npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 3: Commit** + +```bash +git add src/components/layout/Toolbar.tsx +git commit -m "feat(merge): add Merge Tabs button to toolbar" +``` + +--- + +### Task 8: AppShell — Wire Dialog and Merge Action + +**Files:** +- Modify: `src/components/layout/AppShell.tsx` + +- [ ] **Step 1: Import and render the MergeTabsDialog** + +Add import: + +```typescript +import { MergeTabsDialog } from "../dialogs/MergeTabsDialog"; +``` + +Add store selectors: + +```typescript +const showMergeTabsDialog = useUiStore((s) => s.showMergeTabsDialog); +const setShowMergeTabsDialog = useUiStore((s) => s.setShowMergeTabsDialog); +const createMergedTab = useLogStore((s) => s.createMergedTab); +``` + +Add the dialog render near the other dialogs: + +```tsx + setShowMergeTabsDialog(false)} + onMerge={(filePaths) => createMergedTab(filePaths)} +/> +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +Run: `npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 3: Commit** + +```bash +git add src/components/layout/AppShell.tsx +git commit -m "feat(merge): wire MergeTabsDialog into AppShell" +``` + +--- + +### Task 9: TabStrip — Merged Tab Display + +**Files:** +- Modify: `src/components/layout/TabStrip.tsx` + +- [ ] **Step 1: Display merged tab differently** + +In `TabStrip.tsx`, read the merged state: + +```typescript +const sourceOpenMode = useLogStore((s) => s.sourceOpenMode); +const mergedTabState = useLogStore((s) => s.mergedTabState); +``` + +When `sourceOpenMode === "merged"` and the active tab is selected, show a merged indicator. Add a visual cue to the active tab — prepend a merge icon or change the tab label. The simplest approach is to show a special tab label when in merged mode: + +In the tab rendering, when the active tab matches and `sourceOpenMode === "merged"`: + +```tsx +{sourceOpenMode === "merged" && index === activeTabIndex && mergedTabState ? ( + + Merged ({mergedTabState.sourceFilePaths.length} files) + +) : ( + {tab.fileName} +)} +``` + +Also handle closing the merged tab — when the merged tab's close button is clicked, call `closeMergedTab()` instead of the normal tab close: + +```typescript +const closeMergedTab = useLogStore((s) => s.closeMergedTab); +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +Run: `npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 3: Commit** + +```bash +git add src/components/layout/TabStrip.tsx +git commit -m "feat(merge): show merged tab indicator in tab strip" +``` + +--- + +### Task 10: InfoPane — Correlated Entries Section + +**Files:** +- Modify: `src/components/log-view/InfoPane.tsx` + +- [ ] **Step 1: Add correlated entries display** + +Import: + +```typescript +import { useLogStore, type CorrelatedEntry } from "../../stores/log-store"; +import { fileBaseName } from "../../lib/merge-entries"; +``` + +Add store selectors inside the `InfoPane` component: + +```typescript +const mergedTabState = useLogStore((state) => state.mergedTabState); +const correlatedEntries = useLogStore((state) => state.correlatedEntries); +const selectEntry = useLogStore((state) => state.selectEntry); +``` + +Add a correlated entries section after the `AppWorkloadScriptDetail` and before the raw message, only when in merged mode and correlations exist: + +```tsx +{mergedTabState && correlatedEntries.length > 0 && ( +
+
+ Correlated Entries ({correlatedEntries.length}) +
+
+ {correlatedEntries.slice(0, 20).map((corr) => ( +
selectEntry(corr.entry.id)} + style={{ + display: "flex", + alignItems: "center", + gap: "6px", + padding: "2px 4px", + borderRadius: "3px", + cursor: "pointer", + borderLeft: `3px solid ${corr.fileColor}`, + }} + > + + {corr.deltaMs >= 0 ? "+" : ""}{corr.deltaMs}ms + + + {fileBaseName(corr.entry.filePath)} + + + {corr.entry.message.slice(0, 100)} + +
+ ))} + {correlatedEntries.length > 20 && ( +
+ +{correlatedEntries.length - 20} more +
+ )} +
+
+)} +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +Run: `npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 3: Commit** + +```bash +git add src/components/log-view/InfoPane.tsx +git commit -m "feat(merge): add correlated entries section to InfoPane" +``` + +--- + +### Task 11: FileSidebar — Folder Merge Button + +**Files:** +- Modify: `src/components/layout/FileSidebar.tsx` + +- [ ] **Step 1: Add "Merge into Timeline" button** + +Read `FileSidebar.tsx` first, then add a "Merge into Timeline" button at the top of the file list when multiple files are shown. Import the merge action: + +```typescript +const createMergedTab = useLogStore((s) => s.createMergedTab); +const sourceEntries = useLogStore((s) => s.sourceEntries); +``` + +Add a button above the file list when `sourceEntries.filter(e => !e.isDir).length >= 2`: + +```tsx +{fileEntries.length >= 2 && ( + +)} +``` + +Note: This button only works when the files are already parsed and cached. If they haven't been opened yet, the button should first open/parse them before merging. Check how the sidebar currently handles file selection to match the pattern. If files aren't cached, the button should be disabled with a tooltip "Open files first to merge." + +- [ ] **Step 2: Verify TypeScript compiles** + +Run: `npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 3: Commit** + +```bash +git add src/components/layout/FileSidebar.tsx +git commit -m "feat(merge): add Merge into Timeline button to folder sidebar" +``` + +--- + +### Task 12: Final Integration and Verification + +**Files:** All modified files + +- [ ] **Step 1: Run TypeScript check** + +Run: `npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 2: Run Rust tests** + +Run: `cd src-tauri && cargo test` +Expected: All tests pass (no Rust changes, but verify nothing broke) + +- [ ] **Step 3: Run clippy** + +Run: `cd src-tauri && cargo clippy -- -D warnings` +Expected: No warnings + +- [ ] **Step 4: Manual smoke test** + +1. Open 2+ log files as separate tabs +2. Click "Merge Tabs..." — verify dialog shows all tabs with entry counts +3. Select 2+ tabs and click Merge — verify merged timeline appears +4. Verify color-coded left borders on each row +5. Verify legend bar shows file chips with toggle buttons +6. Toggle a file off — verify its entries disappear +7. Click "All" / "None" — verify bulk toggle works +8. Select an entry — verify correlated entries section appears in InfoPane +9. Change correlation window — verify the number of correlated entries changes +10. Click a correlated entry — verify it jumps to that entry +11. Close the merged tab — verify original tabs are unaffected + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "feat(merge): final integration and cleanup" +``` diff --git a/docs/superpowers/specs/2026-04-02-unified-timeline-design.md b/docs/superpowers/specs/2026-04-02-unified-timeline-design.md new file mode 100644 index 000000000..801348270 --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-unified-timeline-design.md @@ -0,0 +1,185 @@ +# Multi-File Unified Timeline + +## Overview + +Merge entries from multiple open log files into a single time-sorted view. Two entry points: manual tab selection and optional folder merge. The merged view lives in a virtual tab with per-file color coding, toggle visibility, a lightweight cache, and cross-file timestamp correlation. + +## Entry Points + +### Manual Merge + +A "Merge Tabs..." button in the toolbar (visible when 2+ file tabs are open). Opens a dialog listing all open tabs with checkboxes. Select 2+ and click "Merge." Creates a virtual merged tab. + +Files without parseable timestamps are shown with a warning icon and excluded from selection. The dialog shows each file's entry count and time range to help the user decide. + +### Folder Merge + +When a folder is loaded and the file sidebar lists multiple files, a "Merge into Timeline" button appears at the top of the sidebar. Clicking it merges all timestamped files into a virtual merged tab. Files without timestamps are excluded with a toast notification listing the skipped files. + +## Merged Tab + +### Identity + +- Tab title: "Merged: file1 + file2 + ..." (truncated to fit, full list in tooltip) +- Tab icon: a distinct merge/layers icon to differentiate from file tabs +- Tab type: `"merged"` (new `SourceOpenMode` variant) + +### Lifecycle + +- Original file tabs remain open and independent +- Closing the merged tab does not affect originals +- Closing an original file tab removes its entries from the merged view and triggers a re-merge +- If only 1 source tab remains, the merged tab auto-closes with a toast: "Merged view closed — only one source file remains" + +### Sorting + +- Primary: `timestamp` epoch (ascending, descending toggle) +- Secondary: filename alphabetical +- Tertiary: line number within file +- Entries without timestamps are excluded (strict mode) + +## Visual Differentiation + +### Color Stripes + +Each source file is assigned a color from an 8-color palette: + +``` +["#2563eb", "#dc2626", "#16a34a", "#9333ea", "#ea580c", "#0891b2", "#c026d3", "#854d0e"] +``` + +Colors cycle if more than 8 files. Applied as a 3px left border on each log row, replacing the default alternating row background for merged views. + +### Source Column + +An optional "Source" column showing the truncated filename (last path segment). Enabled by default in merged views. Can be toggled via column settings. + +### Legend Bar + +A horizontal bar below the toolbar, visible only in merged tabs. Contains: + +- One chip per source file: colored dot + truncated filename + entry count +- Each chip is a toggle button — click to show/hide that file's entries +- "All" / "None" toggle buttons on the right +- Compact: single row, horizontally scrollable if many files + +Toggling a file off filters its entries from the view without re-sorting the remaining entries (just a filter mask on the merged array). + +## Merge Cache + +### Structure + +```typescript +interface MergedTabState { + sourceFilePaths: string[]; + colorAssignments: Record; // filePath → hex color + fileVisibility: Record; // filePath → visible toggle + mergedEntries: LogEntry[]; // cached sorted merge result + cacheKey: string; // hash of source paths + entry counts +} +``` + +### Invalidation + +The cache invalidates (triggers re-merge) when: +- A source tab closes (entries removed) +- A source tab receives new entries via tail (`appendEntries`) +- File visibility toggles do NOT invalidate the cache — they apply a filter mask on top of the cached `mergedEntries` + +### Performance + +- Merge uses a k-way merge (already implemented as `compareMergedLogEntries` in log-store) rather than concat + sort +- For 10 files x 50K entries each = 500K entries, the k-way merge is O(n log k) and should complete in under 500ms +- The virtualized list handles rendering — only ~50 rows in the DOM at any time + +## Cross-File Timestamp Correlation ("Jump to Same Timestamp") + +### Trigger + +When the user selects an entry in the merged view, a "Correlate" action appears in the detail pane (or via keyboard shortcut). This highlights all entries from OTHER source files within a configurable time window of the selected entry's timestamp. + +### Behavior + +1. User selects entry at timestamp T from file A +2. System finds entries from files B, C, ... where `abs(entry.timestamp - T) <= windowMs` +3. Default window: 1000ms (1 second) +4. Matching entries get a subtle highlight (background tint matching their file's color, at 20% opacity) +5. The detail pane shows a "Correlated entries" section listing the matches grouped by file, with timestamps showing the delta from T (e.g., "+200ms", "-50ms") +6. Arrow keys in the correlated list jump between correlated entries + +### Configuration + +- Time window adjustable: 100ms, 500ms, 1s (default), 5s, 10s +- Dropdown in the legend bar or detail pane +- Auto-correlation toggle: when enabled, correlation runs on every selection change. When disabled, requires explicit "Correlate" click. Default: enabled. + +## Store Changes + +### log-store.ts + +Add to `LogState`: + +```typescript +mergedTabState: MergedTabState | null; +correlationWindow: number; // ms, default 1000 +autoCorrelate: boolean; // default true +correlatedEntryIds: Set; // IDs of entries within correlation window +``` + +Actions: + +```typescript +createMergedTab: (sourceFilePaths: string[]) => void; +closeMergedTab: () => void; +setFileVisibility: (filePath: string, visible: boolean) => void; +setAllFileVisibility: (visible: boolean) => void; +setCorrelationWindow: (ms: number) => void; +setAutoCorrelate: (enabled: boolean) => void; +correlateFromEntry: (entryId: number) => void; +``` + +### TabEntrySnapshot + +Add `sourceOpenMode: "merged"` variant. Merged tabs use `MergedTabState` instead of a file path for their cache key. + +## UI Changes + +### Toolbar + +- "Merge Tabs..." button — visible when 2+ file tabs are open, hidden otherwise + +### Tab Strip + +- Merged tabs show a merge icon and the combined filename label +- Tooltip shows full list of source files + +### Legend Bar (new component) + +- `src/components/log-view/MergeLegendBar.tsx` +- Renders between toolbar and log list, only for merged tabs +- File chips with color dots, toggle checkboxes, entry counts +- Correlation window dropdown +- Auto-correlate toggle + +### LogRow + +- In merged view, left border color comes from the file's assigned color instead of severity +- Correlated entries get a background highlight tint + +### InfoPane + +- When auto-correlate is on and an entry is selected, shows a "Correlated Entries" section below the main detail +- Groups correlated entries by source file +- Shows timestamp delta from the selected entry +- Click to jump to that entry in the merged list + +## No Backend Changes + +All merge logic is client-side. Entries already exist in the tab cache (`tabEntryCache`). The merge reads from cached snapshots and produces a new sorted array. + +## Out of Scope + +- Merging entries from different workspaces (e.g., Intune + log viewer) +- Saving merged views to disk (that's the Session Save/Restore feature) +- Diff between files (that's the Log Diff feature) +- Merging files with incompatible timestamp formats (excluded with warning) From 4e139a12f017e55d2322b7fd6316ad1d98161ead Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 2 Apr 2026 18:14:10 -0400 Subject: [PATCH 13/66] docs: add session save/restore and log diff specs Co-Authored-By: Claude Opus 4.6 (1M context) --- .../specs/2026-04-02-log-diff-design.md | 159 +++++++++++++++ .../2026-04-02-session-save-restore-design.md | 181 ++++++++++++++++++ 2 files changed, 340 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-02-log-diff-design.md create mode 100644 docs/superpowers/specs/2026-04-02-session-save-restore-design.md diff --git a/docs/superpowers/specs/2026-04-02-log-diff-design.md b/docs/superpowers/specs/2026-04-02-log-diff-design.md new file mode 100644 index 000000000..514286911 --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-log-diff-design.md @@ -0,0 +1,159 @@ +# Log Diff + +## Overview + +Compare two log sources (two files or two time ranges within one file) and show which entries are unique to each source vs. common to both. Uses fuzzy pattern matching to normalize GUIDs, timestamps, and numbers so "same event, different instance" lines are recognized as matches. Display in side-by-side split or unified inline view, both virtualized. + +## Modes + +### Two-File Diff +Select two open tabs. Compare all entries from each file. + +### Time-Range Diff +Select one open tab. Pick two time ranges by start/end timestamp. Compare entries within each range. + +A mode toggle in the diff config dialog and the diff view itself switches between them. + +## Entry Point + +"Diff Tabs..." button in the toolbar, next to "Merge Tabs...", visible when 2+ tabs are open. + +Opens `DiffConfigDialog`: +- Mode toggle: "Two Files" / "Time Range" +- **Two Files mode**: Two dropdowns to select Tab A and Tab B +- **Time Range mode**: One dropdown to select the file, then two pairs of timestamp pickers (or entry selectors) for Range A and Range B +- "Compare" button launches the diff +- Creates a virtual diff tab (similar to merged tab pattern) + +## Matching Algorithm + +### Normalization + +Each log line's message is normalized before comparison: + +1. Replace GUIDs (`[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-...-[0-9a-fA-F]{12}`) → `{GUID}` +2. Replace timestamps (ISO 8601 patterns, common date formats) → `{TS}` +3. Replace numeric sequences of 5+ digits → `{NUM}` +4. Lowercase the result +5. Trim whitespace + +### Pattern Key + +The "identity" of a log line for matching purposes: + +```typescript +type PatternKey = string; // hash of (normalizedMessage, component, severity) +``` + +Two lines from different sources with the same pattern key are considered "the same pattern." + +### Classification + +For each source (A and B): +1. Build a `Map` of all entries +2. Keys present in both maps → **Common** (matched) +3. Keys only in A → **Only A** (green) +4. Keys only in B → **Only B** (red) + +### Stats + +Displayed in the diff view header: +- `N common patterns` +- `M only in A (filename)` +- `K only in B (filename)` + +## Display + +### Side-by-Side (default) + +Two virtualized columns, each showing entries from their respective source. + +- Common entries: neutral background, aligned by timestamp when both have timestamps, otherwise by sequence +- Only-in-A entries: green-tinted background in left column, empty space in right column +- Only-in-B entries: empty space in left column, red-tinted background in right column +- Synchronized scrolling: scrolling one side scrolls the other +- Each row shows: severity dot, timestamp, message (truncated) + +### Unified Inline + +Single virtualized list showing all entries interleaved by timestamp. + +- Common entries: neutral background, no marker +- Only-in-A entries: green left border + "A" badge +- Only-in-B entries: red left border + "B" badge +- Each row shows: source badge (A/B/both), severity dot, timestamp, message + +### Toggle + +Button in the diff view header: "Side-by-Side" / "Unified". Persisted per diff session (not globally). + +## Diff View Tab + +- Tab title: "Diff: fileA vs fileB" (truncated, full in tooltip) +- Tab icon: distinct diff icon +- Virtual tab — closing doesn't affect source tabs +- Source tabs remain open and independent +- Selecting an entry in the diff view shows its full detail in the InfoPane + +## Store Changes + +### log-store.ts + +```typescript +interface DiffState { + mode: "two-file" | "time-range"; + sourceA: DiffSource; + sourceB: DiffSource; + displayMode: "side-by-side" | "unified"; + entriesA: LogEntry[]; // Source A entries (filtered by range if time-range mode) + entriesB: LogEntry[]; // Source B entries + commonKeys: Set; // Pattern keys found in both + onlyAKeys: Set; // Pattern keys only in A + onlyBKeys: Set; // Pattern keys only in B + entryClassification: Map; // entry.id → class + stats: { common: number; onlyA: number; onlyB: number }; +} + +interface DiffSource { + filePath: string; + label: string; + startTime?: number; // epoch ms, for time-range mode + endTime?: number; // epoch ms, for time-range mode +} +``` + +Actions: + +```typescript +createDiff: (sourceA: DiffSource, sourceB: DiffSource) => void; +closeDiff: () => void; +setDiffDisplayMode: (mode: "side-by-side" | "unified") => void; +``` + +## New Files + +| File | Responsibility | +|------|----------------| +| `src/lib/diff-entries.ts` | Normalization, pattern key generation, classification logic | +| `src/components/dialogs/DiffConfigDialog.tsx` | Mode selection, source pickers, time range pickers | +| `src/components/log-view/DiffView.tsx` | Side-by-side and unified diff rendering | +| `src/components/log-view/DiffHeader.tsx` | Stats bar, display mode toggle, source labels | + +## No Backend Changes + +Normalization and matching are pure client-side functions. No new Rust commands needed. + +## Performance + +- Normalization is O(n) per entry — regex replacements on message strings +- Pattern key hashing is O(n) total +- Classification is O(n) via two Map lookups +- For 50K entries per source: normalization + classification should complete in under 200ms +- Both display modes use TanStack Virtual — only visible rows in the DOM + +## Out of Scope + +- Saving diff results to file +- Three-way diff +- Line-level diff within a single log message (character-level comparison) +- Diff across workspaces (e.g., Intune events vs. log entries) diff --git a/docs/superpowers/specs/2026-04-02-session-save-restore-design.md b/docs/superpowers/specs/2026-04-02-session-save-restore-design.md new file mode 100644 index 000000000..2ed9d78bf --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-session-save-restore-design.md @@ -0,0 +1,181 @@ +# Session Save/Restore + +## Overview + +Save the current workspace state (open files, scroll positions, filters, merged tabs, workspace context) to a `.cmtrace` JSON file. Double-click or "Open Session..." to restore the full workspace. Recent sessions submenu for quick access. + +## File Format + +```json +{ + "version": 1, + "savedAt": "2026-04-02T12:00:00Z", + "workspace": "log", + "tabs": [ + { + "filePath": "/path/to/AppWorkload.log", + "fileHash": "sha256:abc123...", + "fileSize": 524288, + "selectedId": 42, + "scrollPosition": 1500, + "activeColumns": ["severity", "dateTime", "message", "component"] + } + ], + "activeTabIndex": 0, + "mergedTabState": { + "sourceFilePaths": ["/path/a.log", "/path/b.log"], + "fileVisibility": { "/path/a.log": true, "/path/b.log": true }, + "correlationWindowMs": 1000, + "autoCorrelate": true + }, + "filters": { + "clauses": [], + "findQuery": "error", + "findCaseSensitive": false, + "findUseRegex": false, + "highlightText": "timeout" + }, + "workspaceState": { + "type": "log" + } +} +``` + +For non-log workspaces: + +```json +{ + "workspaceState": { + "type": "intune", + "sourceFile": "/path/to/folder", + "activeTab": "timeline", + "filterEventType": "All", + "filterStatus": "All", + "timelineViewMode": "list" + } +} +``` + +```json +{ + "workspaceState": { + "type": "dsregcmd", + "sourcePath": "/path/to/dsregcmd.txt" + } +} +``` + +The `version` field allows forward-compatible migration. Unknown fields are ignored on restore. + +## Save Flow + +1. User clicks File → Save Session... (Ctrl+Shift+S) or native menu +2. System save dialog opens with `.cmtrace` extension filter +3. Frontend collects state from all stores: + - `log-store`: open file paths, entries metadata, selected IDs, merged state + - `ui-store`: active tab index, active workspace, active columns + - `filter-store`: filter clauses + - `intune-store` / `dsregcmd-store`: workspace-specific state if active +4. For each open file, calls backend `compute_file_hash(path)` to get SHA-256 hash + file size +5. Writes JSON to chosen path +6. Adds path to recent sessions list in ui-store + +## Restore Flow + +1. User clicks File → Open Session..., selects from Recent Sessions, or double-clicks `.cmtrace` file +2. Frontend reads and validates JSON (check `version` field) +3. For each tab entry: + - Check if file exists at `filePath` + - If exists: compare hash/size with saved values + - If hash differs: show warning "File has changed since session was saved" with option to continue or skip + - If missing: show warning "File not found: /path/to/file.log" with option to locate or skip +4. Parse each valid file using the existing `parse_files_batch` command +5. Restore tab order, active tab index +6. For each tab: restore selected entry ID, scroll position (via `pendingScrollTarget` pattern) +7. Restore active columns per tab +8. Restore filters and find query +9. If merged tab state exists: call `createMergedTab` with the saved source paths, restore visibility and correlation settings +10. Switch to the saved workspace and restore workspace-specific state + +## Recent Sessions + +- Stored in ui-store persisted preferences: `recentSessions: string[]` (last 5 file paths) +- "Recent Sessions" submenu in the File menu lists saved paths +- Each entry shows the filename (basename) with full path as tooltip +- Clicking opens that session file +- Entries pointing to deleted `.cmtrace` files are pruned when the submenu is built +- New saves push to the front, duplicates are moved to front, oldest dropped when exceeding 5 + +## File Association + +### Windows (NSIS/MSI installer) +- Register `.cmtrace` file extension during install +- Associate with `cmtrace-open.exe` with `--session` argument +- Icon: app icon with a small session overlay + +### macOS (Info.plist) +- Register `com.cmtraceopen.session` document type for `.cmtrace` extension +- App receives the file path via Tauri's file association handler + +### Fallback prompt +- On first save, if association isn't registered, prompt via existing `FileAssociationPromptDialog` pattern +- User can dismiss or register + +### Command line +- `cmtrace-open session.cmtrace` — opens the session +- `cmtrace-open --session path/to/session.cmtrace` — explicit flag + +## Backend + +One new Rust command: + +```rust +#[tauri::command] +pub fn compute_file_hash(path: String) -> Result { + // SHA-256 hash of file contents + file size +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileHashResult { + pub hash: String, // "sha256:" + pub size_bytes: u64, +} +``` + +Everything else is frontend-only — collecting and restoring store state. + +## Menu Changes + +### Native menu (`menu.rs`) +- Add: `MENU_ID_FILE_SAVE_SESSION` → action `"save_session"` (Ctrl+Shift+S) +- Add: `MENU_ID_FILE_OPEN_SESSION` → action `"open_session"` +- Add: "Recent Sessions" submenu (dynamic, built from persisted list) + +### Frontend (`use-app-menu.ts`) +- Handle `"save_session"` → trigger save flow +- Handle `"open_session"` → open file dialog filtered to `.cmtrace` +- Handle `"open_recent_session"` → restore from path + +## Store Changes + +### ui-store +- Add `recentSessions: string[]` (persisted, max 5) +- Add `addRecentSession(path: string): void` +- Add `clearRecentSessions(): void` + +## New Files + +| File | Responsibility | +|------|----------------| +| `src/lib/session.ts` | Session file format types, serialize/deserialize, validation | +| `src/lib/session-save.ts` | Collect state from all stores, call hash command, write file | +| `src/lib/session-restore.ts` | Read file, validate, warn about changes, restore state to stores | +| `src-tauri/src/commands/file_hash.rs` | SHA-256 hash command | + +## Out of Scope + +- Auto-save on exit (future enhancement) +- Embedding log data in the session file +- Saving UI preferences (theme, font size) — these stay global +- Session file encryption From 8ccf9249892b62ab79a0a3f71d6345fa92a928c9 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 2 Apr 2026 18:15:46 -0400 Subject: [PATCH 14/66] feat(session): add recentSessions state to ui-store Co-Authored-By: Claude Opus 4.6 (1M context) --- src/stores/ui-store.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/stores/ui-store.ts b/src/stores/ui-store.ts index 1d02f5db1..c5576e64c 100644 --- a/src/stores/ui-store.ts +++ b/src/stores/ui-store.ts @@ -201,6 +201,7 @@ interface UiState { defaultShowInfoPane: boolean; confirmTabClose: boolean; showUpdateDialog: boolean; + recentSessions: string[]; setActiveWorkspace: (workspace: WorkspaceId) => void; setCurrentPlatform: (platform: PlatformId) => void; @@ -260,6 +261,8 @@ interface UiState { setCollectionResult: (result: CollectionResult | null) => void; setShowCollectDiagnosticsDialog: (show: boolean) => void; setShowUpdateDialog: (show: boolean) => void; + addRecentSession: (path: string) => void; + clearRecentSessions: () => void; } const DEFAULT_WORKSPACE: WorkspaceId = "log"; @@ -339,6 +342,7 @@ export const useUiStore = create()( collectionResult: null, showCollectDiagnosticsDialog: false, showUpdateDialog: false, + recentSessions: [], setCurrentPlatform: (platform) => set({ currentPlatform: platform }), setEnabledWorkspaces: (workspaces) => { @@ -602,6 +606,12 @@ export const useUiStore = create()( setCollectionResult: (result) => set({ collectionResult: result }), setShowCollectDiagnosticsDialog: (show) => set({ showCollectDiagnosticsDialog: show }), setShowUpdateDialog: (show) => set({ showUpdateDialog: show }), + addRecentSession: (path) => + set((state) => { + const filtered = state.recentSessions.filter((p) => p !== path); + return { recentSessions: [path, ...filtered].slice(0, 5) }; + }), + clearRecentSessions: () => set({ recentSessions: [] }), }), { name: "cmtraceopen-ui-preferences", From b02fe6c8ea554aba4c8764baa99f13956646e03d Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 2 Apr 2026 18:15:55 -0400 Subject: [PATCH 15/66] feat(session): add compute_file_hash Rust command Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/commands/file_ops.rs | 32 ++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 1 + 4 files changed, 35 insertions(+) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d27d45051..256078724 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -536,6 +536,7 @@ dependencies = [ "regex", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-clipboard-manager", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index daa3076e3..0f0e93189 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -46,6 +46,7 @@ rayon = "1.10" font-kit = "0.14" evtx = { version = "0.8", optional = true } glob = "0.3" +sha2 = "0.10" [target.'cfg(target_os = "macos")'.dependencies] plist = { version = "1", optional = true } diff --git a/src-tauri/src/commands/file_ops.rs b/src-tauri/src/commands/file_ops.rs index 183470fb7..6f0c05a34 100644 --- a/src-tauri/src/commands/file_ops.rs +++ b/src-tauri/src/commands/file_ops.rs @@ -434,6 +434,38 @@ fn compare_folder_entries(left: &FolderEntry, right: &FolderEntry) -> Ordering { } } +#[derive(Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileHashResult { + pub hash: String, + pub size_bytes: u64, +} + +#[tauri::command] +pub fn compute_file_hash(path: String) -> Result { + use sha2::{Sha256, Digest}; + use std::io::Read; + + let mut file = std::fs::File::open(&path) + .map_err(crate::error::AppError::Io)?; + + let metadata = file.metadata() + .map_err(crate::error::AppError::Io)?; + let size_bytes = metadata.len(); + + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 8192]; + loop { + let bytes_read = file.read(&mut buffer) + .map_err(crate::error::AppError::Io)?; + if bytes_read == 0 { break; } + hasher.update(&buffer[..bytes_read]); + } + + let hash = format!("sha256:{:x}", hasher.finalize()); + Ok(FileHashResult { hash, size_bytes }) +} + fn compare_aggregate_entries( left: &LogEntry, right: &LogEntry, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d069fbfdd..94f7220ca 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -68,6 +68,7 @@ pub fn run() { commands::file_ops::inspect_path_kind, commands::file_ops::write_text_output_file, commands::file_ops::get_initial_file_paths, + commands::file_ops::compute_file_hash, commands::bundle_ops::inspect_evidence_bundle, commands::bundle_ops::inspect_evidence_artifact, commands::known_sources::get_known_log_sources, From bdf565d7813408aaed95eef158f462048ecccf6b Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 2 Apr 2026 18:16:34 -0400 Subject: [PATCH 16/66] feat(diff): add normalization, pattern key, and classification logic Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/diff-entries.ts | 174 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 src/lib/diff-entries.ts diff --git a/src/lib/diff-entries.ts b/src/lib/diff-entries.ts new file mode 100644 index 000000000..cc6fff121 --- /dev/null +++ b/src/lib/diff-entries.ts @@ -0,0 +1,174 @@ +import type { LogEntry } from "../types/log"; + +// ── Types ───────────────────────────────────────────────────────────── + +export interface DiffSource { + filePath: string; + label: string; + startTime?: number; // epoch ms, for time-range mode + endTime?: number; // epoch ms, for time-range mode +} + +export interface DiffState { + mode: "two-file" | "time-range"; + sourceA: DiffSource; + sourceB: DiffSource; + displayMode: "side-by-side" | "unified"; + entriesA: LogEntry[]; + entriesB: LogEntry[]; + commonKeys: Set; + onlyAKeys: Set; + onlyBKeys: Set; + entryClassification: Map; + stats: DiffStats; +} + +export interface DiffStats { + common: number; + onlyA: number; + onlyB: number; +} + +export type EntryClassification = "common" | "only-a" | "only-b"; + +// ── Normalization ───────────────────────────────────────────────────── + +const GUID_RE = + /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g; +const ISO_TS_RE = + /\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?/g; +const COMMON_TS_RE = + /\d{1,2}\/\d{1,2}\/\d{4}\s+\d{1,2}:\d{2}:\d{2}\s*(?:AM|PM)?/gi; +const LONG_NUM_RE = /\b\d{5,}\b/g; + +/** + * Normalize a log message for fuzzy comparison. + * Replaces GUIDs, timestamps, and long numbers with placeholders, + * then lowercases and trims. + */ +export function normalizeMessage(message: string): string { + return message + .replace(GUID_RE, "{GUID}") + .replace(ISO_TS_RE, "{TS}") + .replace(COMMON_TS_RE, "{TS}") + .replace(LONG_NUM_RE, "{NUM}") + .toLowerCase() + .trim(); +} + +/** + * Generate a pattern key for a log entry. + * Combines normalized message, component, and severity into a single string. + */ +export function patternKey(entry: LogEntry): string { + const normalizedMsg = normalizeMessage(entry.message); + const component = (entry.component ?? "").toLowerCase(); + const severity = entry.severity.toLowerCase(); + return `${severity}|${component}|${normalizedMsg}`; +} + +// ── Classification ──────────────────────────────────────────────────── + +/** + * Build a map of pattern key → entry IDs for a list of entries. + */ +function buildPatternMap(entries: LogEntry[]): Map { + const map = new Map(); + for (const entry of entries) { + const key = patternKey(entry); + const existing = map.get(key); + if (existing) { + existing.push(entry.id); + } else { + map.set(key, [entry.id]); + } + } + return map; +} + +/** + * Classify entries from two sources into common, only-A, and only-B. + */ +export function classifyEntries( + entriesA: LogEntry[], + entriesB: LogEntry[], +): { + commonKeys: Set; + onlyAKeys: Set; + onlyBKeys: Set; + entryClassification: Map; + stats: DiffStats; +} { + const mapA = buildPatternMap(entriesA); + const mapB = buildPatternMap(entriesB); + + const commonKeys = new Set(); + const onlyAKeys = new Set(); + const onlyBKeys = new Set(); + + // Classify keys from A + for (const key of mapA.keys()) { + if (mapB.has(key)) { + commonKeys.add(key); + } else { + onlyAKeys.add(key); + } + } + + // Find keys only in B + for (const key of mapB.keys()) { + if (!mapA.has(key)) { + onlyBKeys.add(key); + } + } + + // Build per-entry classification + const entryClassification = new Map(); + + for (const [key, ids] of mapA) { + const cls: EntryClassification = commonKeys.has(key) ? "common" : "only-a"; + for (const id of ids) { + entryClassification.set(id, cls); + } + } + + for (const [key, ids] of mapB) { + const cls: EntryClassification = commonKeys.has(key) ? "common" : "only-b"; + for (const id of ids) { + entryClassification.set(id, cls); + } + } + + return { + commonKeys, + onlyAKeys, + onlyBKeys, + entryClassification, + stats: { + common: commonKeys.size, + onlyA: onlyAKeys.size, + onlyB: onlyBKeys.size, + }, + }; +} + +/** + * Filter entries by time range (for time-range diff mode). + */ +export function filterByTimeRange( + entries: LogEntry[], + startTime: number, + endTime: number, +): LogEntry[] { + return entries.filter( + (e) => + e.timestamp != null && e.timestamp >= startTime && e.timestamp <= endTime, + ); +} + +/** + * Get the basename of a file path. + */ +export function diffFileBaseName(filePath: string): string { + return filePath.split(/[\\/]/).pop() ?? filePath; +} From c38c6bc0f2febd0e8dad4e5e0132e086c1aa2d43 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 2 Apr 2026 18:16:54 -0400 Subject: [PATCH 17/66] feat(session): add session types, save, and restore logic Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/session-restore.ts | 104 ++++++++++++++++++++++++++++++++++++ src/lib/session-save.ts | 106 +++++++++++++++++++++++++++++++++++++ src/lib/session.ts | 89 +++++++++++++++++++++++++++++++ 3 files changed, 299 insertions(+) create mode 100644 src/lib/session-restore.ts create mode 100644 src/lib/session-save.ts create mode 100644 src/lib/session.ts diff --git a/src/lib/session-restore.ts b/src/lib/session-restore.ts new file mode 100644 index 000000000..d4c68612b --- /dev/null +++ b/src/lib/session-restore.ts @@ -0,0 +1,104 @@ +import { invoke } from "@tauri-apps/api/core"; +import { open } from "@tauri-apps/plugin-dialog"; +import { readTextFile } from "@tauri-apps/plugin-fs"; +import { useLogStore } from "../stores/log-store"; +import { useUiStore } from "../stores/ui-store"; +import { validateSession, type FileChangeWarning } from "./session"; + +interface FileHashResult { + hash: string; + sizeBytes: number; +} + +export async function openSessionDialog(): Promise { + const filePath = await open({ + title: "Open Session", + filters: [{ name: "CMTrace Session", extensions: ["cmtrace"] }], + multiple: false, + }); + + if (!filePath || Array.isArray(filePath)) return null; + return restoreSession(filePath); +} + +export async function restoreSession(sessionPath: string): Promise { + const content = await readTextFile(sessionPath); + const data = JSON.parse(content); + const session = validateSession(data); + + if (!session) { + console.error("[session] invalid session file", { sessionPath }); + return null; + } + + // Check file integrity + const warnings: FileChangeWarning[] = []; + const validTabs: typeof session.tabs = []; + + for (const tab of session.tabs) { + try { + const result = await invoke("compute_file_hash", { path: tab.filePath }); + if (tab.fileHash && result.hash !== tab.fileHash) { + warnings.push({ + filePath: tab.filePath, + issue: "changed", + savedHash: tab.fileHash, + savedSize: tab.fileSize, + currentHash: result.hash, + currentSize: result.sizeBytes, + }); + } + validTabs.push(tab); + } catch { + warnings.push({ + filePath: tab.filePath, + issue: "missing", + savedHash: tab.fileHash, + savedSize: tab.fileSize, + }); + } + } + + if (warnings.length > 0) { + const missing = warnings.filter((w) => w.issue === "missing"); + const changed = warnings.filter((w) => w.issue === "changed"); + const parts: string[] = []; + if (missing.length > 0) { + parts.push(`${missing.length} file(s) not found: ${missing.map((w) => w.filePath.split(/[\\/]/).pop()).join(", ")}`); + } + if (changed.length > 0) { + parts.push(`${changed.length} file(s) changed since session was saved`); + } + console.warn("[session] file integrity warnings:", parts.join("; "), warnings); + } + + if (validTabs.length === 0) { + console.error("[session] no valid files to restore"); + return null; + } + + // Clear current state + useLogStore.getState().clear(); + + // Set workspace + const uiStore = useUiStore.getState(); + if (session.workspace) { + uiStore.setActiveWorkspace(session.workspace as Parameters[0]); + } + + // Restore filters + const logStore = useLogStore.getState(); + if (session.filters) { + logStore.setHighlightText(session.filters.highlightText || ""); + logStore.setFindQuery(session.filters.findQuery || ""); + logStore.setFindCaseSensitive(session.filters.findCaseSensitive ?? false); + logStore.setFindUseRegex(session.filters.findUseRegex ?? false); + } + + // Add to recent sessions + uiStore.addRecentSession(sessionPath); + + // Return the list of file paths to open — the caller handles parsing + // via the existing file open flow + return validTabs.map((t) => t.filePath).join("\n"); +} diff --git a/src/lib/session-save.ts b/src/lib/session-save.ts new file mode 100644 index 000000000..2f7597a93 --- /dev/null +++ b/src/lib/session-save.ts @@ -0,0 +1,106 @@ +import { invoke } from "@tauri-apps/api/core"; +import { save } from "@tauri-apps/plugin-dialog"; +import { writeTextFile } from "@tauri-apps/plugin-fs"; +import { useLogStore, getCachedTabSnapshot } from "../stores/log-store"; +import { useUiStore } from "../stores/ui-store"; +import { useFilterStore } from "../stores/filter-store"; +import { useIntuneStore } from "../stores/intune-store"; +import type { SessionFile, SessionTab } from "./session"; + +interface FileHashResult { + hash: string; + sizeBytes: number; +} + +export async function saveSession(): Promise { + const logState = useLogStore.getState(); + const uiState = useUiStore.getState(); + const filterState = useFilterStore.getState(); + + const openTabs = uiState.openTabs; + if (openTabs.length === 0 && uiState.activeWorkspace === "log") { + return null; + } + + // Build tab entries with file hashes + const tabs: SessionTab[] = []; + for (const tab of openTabs) { + let hash = ""; + let size = 0; + try { + const result = await invoke("compute_file_hash", { path: tab.filePath }); + hash = result.hash; + size = result.sizeBytes; + } catch { + // File might not exist or be inaccessible — save without hash + } + + const snapshot = getCachedTabSnapshot(tab.filePath); + tabs.push({ + filePath: tab.filePath, + fileHash: hash, + fileSize: size, + selectedId: logState.selectedId, + scrollPosition: null, + activeColumns: snapshot?.activeColumns ?? [], + }); + } + + // Build workspace state + let workspaceState: SessionFile["workspaceState"] = { type: "log" }; + if (uiState.activeWorkspace === "intune") { + const intuneState = useIntuneStore.getState(); + workspaceState = { + type: "intune", + sourceFile: intuneState.sourceFile, + activeTab: intuneState.activeTab, + filterEventType: intuneState.filterEventType, + filterStatus: intuneState.filterStatus, + timelineViewMode: intuneState.timelineViewMode, + }; + } else if (uiState.activeWorkspace === "dsregcmd") { + workspaceState = { + type: "dsregcmd", + sourcePath: null, + }; + } + + const session: SessionFile = { + version: 1, + savedAt: new Date().toISOString(), + workspace: uiState.activeWorkspace, + tabs, + activeTabIndex: uiState.activeTabIndex, + mergedTabState: logState.mergedTabState + ? { + sourceFilePaths: logState.mergedTabState.sourceFilePaths, + fileVisibility: logState.mergedTabState.fileVisibility, + correlationWindowMs: logState.correlationWindowMs, + autoCorrelate: logState.autoCorrelate, + } + : null, + filters: { + clauses: filterState.clauses ?? [], + findQuery: logState.findQuery, + findCaseSensitive: logState.findCaseSensitive, + findUseRegex: logState.findUseRegex, + highlightText: logState.highlightText, + }, + workspaceState, + }; + + const filePath = await save({ + title: "Save Session", + filters: [{ name: "CMTrace Session", extensions: ["cmtrace"] }], + defaultPath: "session.cmtrace", + }); + + if (!filePath) return null; + + await writeTextFile(filePath, JSON.stringify(session, null, 2)); + + // Add to recent sessions + useUiStore.getState().addRecentSession(filePath); + + return filePath; +} diff --git a/src/lib/session.ts b/src/lib/session.ts new file mode 100644 index 000000000..c7eeccfff --- /dev/null +++ b/src/lib/session.ts @@ -0,0 +1,89 @@ +export interface SessionFile { + version: number; + savedAt: string; + workspace: string; + tabs: SessionTab[]; + activeTabIndex: number; + mergedTabState: SessionMergedState | null; + filters: SessionFilters; + workspaceState: SessionWorkspaceState; +} + +export interface SessionTab { + filePath: string; + fileHash: string; + fileSize: number; + selectedId: number | null; + scrollPosition: number | null; + activeColumns: string[]; +} + +export interface SessionMergedState { + sourceFilePaths: string[]; + fileVisibility: Record; + correlationWindowMs: number; + autoCorrelate: boolean; +} + +export interface SessionFilters { + clauses: unknown[]; + findQuery: string; + findCaseSensitive: boolean; + findUseRegex: boolean; + highlightText: string; +} + +export type SessionWorkspaceState = + | { type: "log" } + | { + type: "intune"; + sourceFile: string | null; + activeTab: string; + filterEventType: string; + filterStatus: string; + timelineViewMode: string; + } + | { + type: "dsregcmd"; + sourcePath: string | null; + } + | { type: string }; + +const CURRENT_VERSION = 1; + +export function createEmptySession(): SessionFile { + return { + version: CURRENT_VERSION, + savedAt: new Date().toISOString(), + workspace: "log", + tabs: [], + activeTabIndex: 0, + mergedTabState: null, + filters: { + clauses: [], + findQuery: "", + findCaseSensitive: false, + findUseRegex: false, + highlightText: "", + }, + workspaceState: { type: "log" }, + }; +} + +export function validateSession(data: unknown): SessionFile | null { + if (typeof data !== "object" || data === null) return null; + const obj = data as Record; + if (typeof obj.version !== "number") return null; + if (obj.version > CURRENT_VERSION) return null; + if (!Array.isArray(obj.tabs)) return null; + return obj as unknown as SessionFile; +} + +export interface FileChangeWarning { + filePath: string; + issue: "missing" | "changed"; + savedHash: string; + savedSize: number; + currentHash?: string; + currentSize?: number; +} From 214230034da8661fd3f2a5b56e36f233dcbe25d0 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 2 Apr 2026 18:18:08 -0400 Subject: [PATCH 18/66] feat(diff): add DiffState and diff actions to log store Co-Authored-By: Claude Opus 4.6 (1M context) --- src/stores/log-store.ts | 70 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/src/stores/log-store.ts b/src/stores/log-store.ts index cc0502b00..3e0ca1ba2 100644 --- a/src/stores/log-store.ts +++ b/src/stores/log-store.ts @@ -26,8 +26,15 @@ import { filterByVisibility, findCorrelatedEntries, } from "../lib/merge-entries"; +import { + type DiffState, + type DiffSource, + classifyEntries, + filterByTimeRange, +} from "../lib/diff-entries"; export type { MergedTabState, CorrelatedEntry }; +export type { DiffState, DiffSource }; /** * Snapshot of parsed file state — cached in memory so tab switches @@ -100,7 +107,7 @@ export interface ParserSelectionDisplay { dateOrderLabel: string | null; } -export type SourceOpenMode = "single-file" | "aggregate-folder" | "merged" | null; +export type SourceOpenMode = "single-file" | "aggregate-folder" | "merged" | "diff" | null; const UNGROUPED_TOOLBAR_GROUP_ID = "ungrouped"; @@ -554,6 +561,7 @@ interface LogState { correlationWindowMs: number; autoCorrelate: boolean; correlatedEntries: CorrelatedEntry[]; + diffState: DiffState | null; /** Pending scroll target set by deployment workspace — consumed by LogListView after load. */ pendingScrollTarget: { filePath: string; lineNumber: number } | null; @@ -605,6 +613,9 @@ interface LogState { setCorrelationWindowMs: (ms: number) => void; setAutoCorrelate: (enabled: boolean) => void; updateCorrelation: () => void; + createDiff: (sourceA: DiffSource, sourceB: DiffSource) => void; + closeDiff: () => void; + setDiffDisplayMode: (mode: "side-by-side" | "unified") => void; } /** Debounced version of recomputeAndSetMatches for keystroke-driven updates. */ @@ -729,6 +740,7 @@ export const useLogStore = create((set, get) => ({ correlationWindowMs: 1000, autoCorrelate: true, correlatedEntries: [], + diffState: null, pendingScrollTarget: null, hasActiveSource: () => { @@ -872,6 +884,7 @@ export const useLogStore = create((set, get) => ({ guidNameMap: {}, mergedTabState: null, correlatedEntries: [], + diffState: null, findMatchIds: [], findCurrentIndex: -1, findRegexError: null, @@ -903,6 +916,7 @@ export const useLogStore = create((set, get) => ({ guidNameMap: {}, mergedTabState: null, correlatedEntries: [], + diffState: null, findMatchIds: [], findCurrentIndex: -1, findRegexError: null, @@ -1036,4 +1050,58 @@ export const useLogStore = create((set, get) => ({ ); useLogStore.setState({ correlatedEntries: correlated }); }, + + createDiff: (sourceA, sourceB) => { + // Get entries from cache + const snapshotA = getCachedTabSnapshot(sourceA.filePath); + const snapshotB = getCachedTabSnapshot(sourceB.filePath); + if (!snapshotA || !snapshotB) return; + + let entriesA = snapshotA.entries; + let entriesB = snapshotB.entries; + + // Apply time range filter if specified + if (sourceA.startTime != null && sourceA.endTime != null) { + entriesA = filterByTimeRange(entriesA, sourceA.startTime, sourceA.endTime); + } + if (sourceB.startTime != null && sourceB.endTime != null) { + entriesB = filterByTimeRange(entriesB, sourceB.startTime, sourceB.endTime); + } + + const { commonKeys, onlyAKeys, onlyBKeys, entryClassification, stats } = + classifyEntries(entriesA, entriesB); + + set({ + diffState: { + mode: sourceA.filePath === sourceB.filePath ? "time-range" : "two-file", + sourceA, + sourceB, + displayMode: "side-by-side", + entriesA, + entriesB, + commonKeys, + onlyAKeys, + onlyBKeys, + entryClassification, + stats, + }, + sourceOpenMode: "diff" as SourceOpenMode, + selectedId: null, + }); + }, + + closeDiff: () => { + set({ + diffState: null, + sourceOpenMode: null, + selectedId: null, + }); + }, + + setDiffDisplayMode: (mode) => { + set((state) => { + if (!state.diffState) return {}; + return { diffState: { ...state.diffState, displayMode: mode } }; + }); + }, })); From 9c9901caa49ef6e5d76c59b7b7abdac5d324e003 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 2 Apr 2026 18:19:15 -0400 Subject: [PATCH 19/66] feat(session): add Save/Open Session menu items and handlers Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/menu.rs | 34 +++++++++++++++++++++++++++++++++- src/hooks/use-app-menu.ts | 10 ++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 0206f271a..c123c8e87 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -10,6 +10,8 @@ pub const MENU_EVENT_APP_ACTION: &str = "app-menu-action"; pub const MENU_ID_FILE_OPEN_LOG_FILE: &str = "file.open_log_file"; pub const MENU_ID_FILE_OPEN_LOG_FOLDER: &str = "file.open_log_folder"; +pub const MENU_ID_FILE_SAVE_SESSION: &str = "file.save_session"; +pub const MENU_ID_FILE_OPEN_SESSION: &str = "file.open_session"; pub const MENU_ID_FILE_QUIT: &str = "file.quit"; pub const MENU_ID_EDIT_FIND: &str = "edit.find"; @@ -48,6 +50,20 @@ pub fn build_app_menu(app: &AppHandle) -> tauri::Result> true, None::<&str>, )?; + let save_session = MenuItem::with_id( + app, + MENU_ID_FILE_SAVE_SESSION, + "Save Session...", + true, + Some("Shift+CmdOrCtrl+S"), + )?; + let open_session = MenuItem::with_id( + app, + MENU_ID_FILE_OPEN_SESSION, + "Open Session...", + true, + None::<&str>, + )?; let quit = MenuItem::with_id(app, MENU_ID_FILE_QUIT, "Exit", true, None::<&str>)?; let known_sources = build_known_sources_submenu(app)?; @@ -124,7 +140,7 @@ pub fn build_app_menu(app: &AppHandle) -> tauri::Result> app, "File", true, - &[&open_log_file, &open_log_folder, &known_sources, &quit], + &[&open_log_file, &open_log_folder, &save_session, &open_session, &known_sources, &quit], )?; let edit_menu = Submenu::with_items(app, "Edit", true, &[&find, &filter])?; #[cfg(all(target_os = "windows", feature = "collector"))] @@ -351,6 +367,22 @@ fn payload_for_menu_id(menu_id: &str) -> Option { trigger: "menu".to_string(), source_id: None, }, + MENU_ID_FILE_SAVE_SESSION => AppMenuActionPayload { + version: 1, + menu_id: MENU_ID_FILE_SAVE_SESSION.to_string(), + action: "save_session".to_string(), + category: "file".to_string(), + trigger: "menu".to_string(), + source_id: None, + }, + MENU_ID_FILE_OPEN_SESSION => AppMenuActionPayload { + version: 1, + menu_id: MENU_ID_FILE_OPEN_SESSION.to_string(), + action: "open_session".to_string(), + category: "file".to_string(), + trigger: "menu".to_string(), + source_id: None, + }, MENU_ID_EDIT_FIND => AppMenuActionPayload { version: 1, menu_id: MENU_ID_EDIT_FIND.to_string(), diff --git a/src/hooks/use-app-menu.ts b/src/hooks/use-app-menu.ts index 1fb8e754c..8c7d2ccef 100644 --- a/src/hooks/use-app-menu.ts +++ b/src/hooks/use-app-menu.ts @@ -88,6 +88,16 @@ export function useAppMenu() { case "check_for_updates": useUiStore.getState().setShowUpdateDialog(true); return; + case "save_session": { + const { saveSession } = await import("../lib/session-save"); + await saveSession(); + return; + } + case "open_session": { + const { openSessionDialog } = await import("../lib/session-restore"); + await openSessionDialog(); + return; + } case "open_known_source": { if (payload.source_id) { await openKnownSourceCatalogAction({ From 66e57622b27df1690359cf2e0fac9a8a70b2bbc6 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 2 Apr 2026 18:20:14 -0400 Subject: [PATCH 20/66] feat(diff): add DiffConfigDialog, toolbar button, and ui-store state Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/dialogs/DiffConfigDialog.tsx | 116 ++++++++++++++++++++ src/components/layout/Toolbar.tsx | 19 ++++ src/stores/ui-store.ts | 4 + 3 files changed, 139 insertions(+) create mode 100644 src/components/dialogs/DiffConfigDialog.tsx diff --git a/src/components/dialogs/DiffConfigDialog.tsx b/src/components/dialogs/DiffConfigDialog.tsx new file mode 100644 index 000000000..c25a77f4e --- /dev/null +++ b/src/components/dialogs/DiffConfigDialog.tsx @@ -0,0 +1,116 @@ +import { useMemo, useState } from "react"; +import { + Button, + Dialog, + DialogActions, + DialogBody, + DialogContent, + DialogSurface, + DialogTitle, + tokens, +} from "@fluentui/react-components"; +import { LOG_MONOSPACE_FONT_FAMILY } from "../../lib/log-accessibility"; +import { getCachedTabSnapshot } from "../../stores/log-store"; +import { useUiStore } from "../../stores/ui-store"; +import type { DiffSource } from "../../lib/diff-entries"; + +interface DiffConfigDialogProps { + isOpen: boolean; + onClose: () => void; + onCompare: (sourceA: DiffSource, sourceB: DiffSource) => void; +} + +export function DiffConfigDialog({ isOpen, onClose, onCompare }: DiffConfigDialogProps) { + const openTabs = useUiStore((s) => s.openTabs); + const [fileA, setFileA] = useState(""); + const [fileB, setFileB] = useState(""); + + const tabOptions = useMemo(() => { + return openTabs.map((tab) => { + const snapshot = getCachedTabSnapshot(tab.filePath); + const count = snapshot?.entries.length ?? 0; + return { filePath: tab.filePath, fileName: tab.fileName, entryCount: count }; + }); + }, [openTabs]); + + const canCompare = fileA !== "" && fileB !== "" && fileA !== fileB; + + const handleCompare = () => { + if (!canCompare) return; + const nameA = fileA.split(/[\\/]/).pop() ?? fileA; + const nameB = fileB.split(/[\\/]/).pop() ?? fileB; + onCompare( + { filePath: fileA, label: nameA }, + { filePath: fileB, label: nameB } + ); + onClose(); + }; + + const handleClose = () => { + setFileA(""); + setFileB(""); + onClose(); + }; + + const selectStyle: React.CSSProperties = { + flex: 1, + padding: "6px 8px", + fontSize: "12px", + border: `1px solid ${tokens.colorNeutralStroke2}`, + borderRadius: "4px", + backgroundColor: tokens.colorNeutralBackground1, + color: tokens.colorNeutralForeground1, + fontFamily: LOG_MONOSPACE_FONT_FAMILY, + }; + + return ( + { if (!data.open) handleClose(); }}> + + + Compare Log Files + +
+ Select two open tabs to compare. Lines unique to each file will be highlighted. +
+ +
+
+
+ Source A +
+ +
+ +
+
+ Source B +
+ +
+
+
+ + + + +
+
+
+ ); +} diff --git a/src/components/layout/Toolbar.tsx b/src/components/layout/Toolbar.tsx index 95b96e8f7..47c9c4c76 100644 --- a/src/components/layout/Toolbar.tsx +++ b/src/components/layout/Toolbar.tsx @@ -771,6 +771,7 @@ export function Toolbar() { const activeWorkspace = useUiStore((s) => s.activeWorkspace); const openTabs = useUiStore((s) => s.openTabs); const setShowMergeTabsDialog = useUiStore((s) => s.setShowMergeTabsDialog); + const setShowDiffConfigDialog = useUiStore((s) => s.setShowDiffConfigDialog); const enabledWorkspaces = useUiStore((s) => s.enabledWorkspaces); const availableWorkspaces = useMemo( () => getAvailableWorkspaces(currentPlatform, enabledWorkspaces), @@ -980,6 +981,24 @@ export function Toolbar() { Merge Tabs... )} + {canMergeTabs && ( + + )} diff --git a/src/stores/ui-store.ts b/src/stores/ui-store.ts index c5576e64c..48aeeb11d 100644 --- a/src/stores/ui-store.ts +++ b/src/stores/ui-store.ts @@ -173,6 +173,7 @@ interface UiState { showEvidenceBundleDialog: boolean; showGuidRegistryDialog: boolean; showMergeTabsDialog: boolean; + showDiffConfigDialog: boolean; showFileAssociationPrompt: boolean; logListFontSize: number; logDetailsFontSize: number; @@ -220,6 +221,7 @@ interface UiState { setShowEvidenceBundleDialog: (show: boolean) => void; setShowGuidRegistryDialog: (show: boolean) => void; setShowMergeTabsDialog: (show: boolean) => void; + setShowDiffConfigDialog: (show: boolean) => void; setShowFileAssociationPrompt: (show: boolean) => void; setLogListFontSize: (fontSize: number) => void; increaseLogListFontSize: () => void; @@ -320,6 +322,7 @@ export const useUiStore = create()( showEvidenceBundleDialog: false, showGuidRegistryDialog: false, showMergeTabsDialog: false, + showDiffConfigDialog: false, showFileAssociationPrompt: false, logListFontSize: DEFAULT_LOG_LIST_FONT_SIZE, logDetailsFontSize: DEFAULT_LOG_DETAILS_FONT_SIZE, @@ -435,6 +438,7 @@ export const useUiStore = create()( setShowEvidenceBundleDialog: (show) => set({ showEvidenceBundleDialog: show }), setShowGuidRegistryDialog: (show) => set({ showGuidRegistryDialog: show }), setShowMergeTabsDialog: (show) => set({ showMergeTabsDialog: show }), + setShowDiffConfigDialog: (show) => set({ showDiffConfigDialog: show }), setShowFileAssociationPrompt: (show) => set({ showFileAssociationPrompt: show }), setLogListFontSize: (fontSize) => set({ logListFontSize: clampLogListFontSize(fontSize) }), From fabbbadd02573835ac546e7b21931714647f2072 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 2 Apr 2026 18:21:19 -0400 Subject: [PATCH 21/66] feat(diff): create DiffView with side-by-side and unified modes Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/log-view/DiffHeader.tsx | 101 ++++++++++ src/components/log-view/DiffView.tsx | 255 +++++++++++++++++++++++++ 2 files changed, 356 insertions(+) create mode 100644 src/components/log-view/DiffHeader.tsx create mode 100644 src/components/log-view/DiffView.tsx diff --git a/src/components/log-view/DiffHeader.tsx b/src/components/log-view/DiffHeader.tsx new file mode 100644 index 000000000..6f2efb745 --- /dev/null +++ b/src/components/log-view/DiffHeader.tsx @@ -0,0 +1,101 @@ +import { tokens } from "@fluentui/react-components"; +import { useLogStore } from "../../stores/log-store"; +import { diffFileBaseName } from "../../lib/diff-entries"; +import { LOG_UI_FONT_FAMILY } from "../../lib/log-accessibility"; + +export function DiffHeader() { + const diffState = useLogStore((s) => s.diffState); + const setDiffDisplayMode = useLogStore((s) => s.setDiffDisplayMode); + const closeDiff = useLogStore((s) => s.closeDiff); + + if (!diffState) return null; + + const { stats, displayMode, sourceA, sourceB } = diffState; + + return ( +
+ + Diff: {diffFileBaseName(sourceA.filePath)} vs {diffFileBaseName(sourceB.filePath)} + + +
+ + + {stats.common} common + + + {stats.onlyA} only A + + + {stats.onlyB} only B + + +
+ +
+ + +
+ + +
+ ); +} diff --git a/src/components/log-view/DiffView.tsx b/src/components/log-view/DiffView.tsx new file mode 100644 index 000000000..7fcd10680 --- /dev/null +++ b/src/components/log-view/DiffView.tsx @@ -0,0 +1,255 @@ +import { useMemo, useRef } from "react"; +import { tokens } from "@fluentui/react-components"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useLogStore } from "../../stores/log-store"; +import { LOG_MONOSPACE_FONT_FAMILY, LOG_UI_FONT_FAMILY, getLogListMetrics } from "../../lib/log-accessibility"; +import { formatDisplayDateTime } from "../../lib/date-time-format"; +import { diffFileBaseName } from "../../lib/diff-entries"; +import { useUiStore } from "../../stores/ui-store"; +import { DiffHeader } from "./DiffHeader"; +import type { LogEntry } from "../../types/log"; +import type { EntryClassification } from "../../lib/diff-entries"; + +const CLASS_COLORS: Record = { + common: { bg: "transparent", border: "transparent" }, + "only-a": { bg: tokens.colorPaletteGreenBackground1, border: tokens.colorPaletteGreenForeground1 }, + "only-b": { bg: tokens.colorPaletteRedBackground1, border: tokens.colorPaletteRedForeground1 }, +}; + +export function DiffView() { + const diffState = useLogStore((s) => s.diffState); + const selectEntry = useLogStore((s) => s.selectEntry); + const selectedId = useLogStore((s) => s.selectedId); + const logListFontSize = useUiStore((s) => s.logListFontSize); + const metrics = useMemo(() => getLogListMetrics(logListFontSize), [logListFontSize]); + + if (!diffState) return null; + + return ( +
+ + {diffState.displayMode === "side-by-side" ? ( + + ) : ( + + )} +
+ ); +} + +function SideBySideView({ + diffState, + metrics, + selectedId, + onSelect, +}: { + diffState: NonNullable["diffState"]>; + metrics: ReturnType; + selectedId: number | null; + onSelect: (id: number | null) => void; +}) { + const parentRefA = useRef(null); + const parentRefB = useRef(null); + const rowHeight = metrics.rowHeight; + + const virtualizerA = useVirtualizer({ + count: diffState.entriesA.length, + getScrollElement: () => parentRefA.current, + estimateSize: () => rowHeight, + overscan: 10, + }); + + const virtualizerB = useVirtualizer({ + count: diffState.entriesB.length, + getScrollElement: () => parentRefB.current, + estimateSize: () => rowHeight, + overscan: 10, + }); + + return ( +
+
+
+ A: {diffFileBaseName(diffState.sourceA.filePath)} ({diffState.entriesA.length}) +
+
+
+ {virtualizerA.getVirtualItems().map((row) => { + const entry = diffState.entriesA[row.index]; + const cls = diffState.entryClassification.get(entry.id) ?? "common"; + return ( +
+ +
+ ); + })} +
+
+
+ +
+
+ B: {diffFileBaseName(diffState.sourceB.filePath)} ({diffState.entriesB.length}) +
+
+
+ {virtualizerB.getVirtualItems().map((row) => { + const entry = diffState.entriesB[row.index]; + const cls = diffState.entryClassification.get(entry.id) ?? "common"; + return ( +
+ +
+ ); + })} +
+
+
+
+ ); +} + +function UnifiedView({ + diffState, + metrics, + selectedId, + onSelect, +}: { + diffState: NonNullable["diffState"]>; + metrics: ReturnType; + selectedId: number | null; + onSelect: (id: number | null) => void; +}) { + const parentRef = useRef(null); + const rowHeight = metrics.rowHeight; + + const unifiedEntries = useMemo(() => { + const all = [ + ...diffState.entriesA.map((e) => ({ entry: e, source: "a" as const })), + ...diffState.entriesB.map((e) => ({ entry: e, source: "b" as const })), + ]; + all.sort((x, y) => { + if (x.entry.timestamp != null && y.entry.timestamp != null) { + if (x.entry.timestamp !== y.entry.timestamp) return x.entry.timestamp - y.entry.timestamp; + } + return x.entry.lineNumber - y.entry.lineNumber; + }); + return all; + }, [diffState.entriesA, diffState.entriesB]); + + const virtualizer = useVirtualizer({ + count: unifiedEntries.length, + getScrollElement: () => parentRef.current, + estimateSize: () => rowHeight, + overscan: 10, + }); + + return ( +
+
+ {virtualizer.getVirtualItems().map((row) => { + const { entry, source } = unifiedEntries[row.index]; + const cls = diffState.entryClassification.get(entry.id) ?? "common"; + return ( +
+ +
+ ); + })} +
+
+ ); +} + +function DiffRow({ + entry, + classification, + isSelected, + fontSize, + onSelect, + sourceBadge, +}: { + entry: LogEntry; + classification: EntryClassification; + isSelected: boolean; + fontSize: number; + onSelect: (id: number | null) => void; + sourceBadge?: string; +}) { + const colors = CLASS_COLORS[classification]; + const monoFont = Math.max(fontSize - 1, 10); + + return ( +
onSelect(isSelected ? null : entry.id)} + style={{ + display: "flex", + alignItems: "center", + gap: "6px", + padding: "2px 8px", + fontSize: `${fontSize}px`, + backgroundColor: isSelected ? tokens.colorBrandBackground : colors.bg, + color: isSelected ? tokens.colorNeutralForegroundOnBrand : tokens.colorNeutralForeground1, + borderLeft: `3px solid ${colors.border}`, + borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, + cursor: "pointer", + height: "100%", + boxSizing: "border-box", + }} + > + {sourceBadge && ( + + {sourceBadge} + + )} + + {formatDisplayDateTime(entry.timestampDisplay ?? entry.timestamp) ?? "\u2014"} + + + {entry.message} + +
+ ); +} From 918fc15f32a08f1ce6d975cf9b47b50753770559 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 2 Apr 2026 18:23:32 -0400 Subject: [PATCH 22/66] feat: wire DiffConfigDialog into AppShell, render DiffView, update changelog Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 3 +++ src/components/layout/AppShell.tsx | 13 ++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5acce1d3..9fc12eb73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ All notable changes to this project will be documented in this file. - **Jump to Line**: Context menu action to jump to a specific line number in the log. - **Reveal in File Manager**: Context menu action to open the source file's location in Finder/Explorer. - **Quick Filter**: Context menu action to instantly filter by the selected row's severity or component. +- **Multi-file unified timeline**: Merge entries from multiple open log files into a single time-sorted view. Two entry points: "Merge Tabs..." button in the toolbar and "Merge into Timeline" button in the folder sidebar. Color-coded left borders distinguish source files. A legend bar provides per-file toggle visibility, correlation time window, and auto-correlate controls. Cross-file timestamp correlation highlights entries from other files within a configurable time window and shows them in the InfoPane with delta timestamps. +- **Session save/restore**: Save the current workspace state (open files, scroll positions, filters, merged tabs, workspace context) to a `.cmtrace` JSON file via File > Save Session (Ctrl+Shift+S). Restore via File > Open Session or Recent Sessions submenu. Files are integrity-checked with SHA-256 hashes — warns if files have changed or are missing since the session was saved. New `compute_file_hash` Rust backend command. +- **Log diff**: Compare two open log files side-by-side or in unified inline view. Fuzzy pattern matching normalizes GUIDs, timestamps, and long numbers so "same event, different instance" lines are recognized as matches. Stats bar shows common patterns vs. lines unique to each file. "Diff Tabs..." button in the toolbar opens a config dialog for source selection. ### Fixed diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index a36603739..ebdb420dd 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -6,6 +6,7 @@ import { TabStrip } from "./TabStrip"; import { StatusBar } from "./StatusBar"; import { FileSidebar, FILE_SIDEBAR_RECOMMENDED_WIDTH } from "./FileSidebar"; import { LogListView } from "../log-view/LogListView"; +import { DiffView } from "../log-view/DiffView"; import { InfoPane } from "../log-view/InfoPane"; import { FindBar } from "./FindBar"; import { FilterDialog } from "../dialogs/FilterDialog"; @@ -19,6 +20,7 @@ import { CollectDiagnosticsDialog } from "../dialogs/CollectDiagnosticsDialog"; import { CollectionCompleteDialog } from "../dialogs/CollectionCompleteDialog"; import { UpdateDialog } from "../dialogs/UpdateDialog"; import { MergeTabsDialog } from "../dialogs/MergeTabsDialog"; +import { DiffConfigDialog } from "../dialogs/DiffConfigDialog"; import { IntuneDashboard } from "../intune/IntuneDashboard"; import { NewIntuneWorkspace } from "../intune/NewIntuneWorkspace"; import { DsregcmdWorkspace } from "../dsregcmd/DsregcmdWorkspace"; @@ -105,6 +107,10 @@ export function AppShell() { const showMergeTabsDialog = useUiStore((s) => s.showMergeTabsDialog); const setShowMergeTabsDialog = useUiStore((s) => s.setShowMergeTabsDialog); const createMergedTab = useLogStore((s) => s.createMergedTab); + const showDiffConfigDialog = useUiStore((s) => s.showDiffConfigDialog); + const setShowDiffConfigDialog = useUiStore((s) => s.setShowDiffConfigDialog); + const createDiff = useLogStore((s) => s.createDiff); + const sourceOpenMode = useLogStore((s) => s.sourceOpenMode); useCollectionProgressListener(); useParseProgressListener(); @@ -304,7 +310,7 @@ export function AppShell() { position: "relative", }} > - + {sourceOpenMode === "diff" ? : } {/* Folder loading overlay with progress bar */} {folderLoadProgress !== null && ( @@ -594,6 +600,11 @@ export function AppShell() { onClose={() => setShowMergeTabsDialog(false)} onMerge={(filePaths) => createMergedTab(filePaths)} /> + setShowDiffConfigDialog(false)} + onCompare={(sourceA, sourceB) => createDiff(sourceA, sourceB)} + /> { From b4f7894bf36c72c4bfef9fb2703ed436d512bb54 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 2 Apr 2026 18:32:56 -0400 Subject: [PATCH 23/66] =?UTF-8?q?fix:=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20scroll=20sync,=20session=20restore,=20validation,?= =?UTF-8?q?=20performance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/log-view/DiffView.tsx | 19 +++++++++++-- src/components/log-view/MergeLegendBar.tsx | 4 +-- src/lib/merge-entries.ts | 32 +++++++++++++++------- src/lib/session-restore.ts | 21 +++++++++++--- src/lib/session-save.ts | 5 ++-- src/lib/session.ts | 7 +++++ 6 files changed, 68 insertions(+), 20 deletions(-) diff --git a/src/components/log-view/DiffView.tsx b/src/components/log-view/DiffView.tsx index 7fcd10680..0fb7f2da7 100644 --- a/src/components/log-view/DiffView.tsx +++ b/src/components/log-view/DiffView.tsx @@ -60,8 +60,23 @@ function SideBySideView({ }) { const parentRefA = useRef(null); const parentRefB = useRef(null); + const isSyncingRef = useRef(false); const rowHeight = metrics.rowHeight; + const handleScrollA = () => { + if (isSyncingRef.current || !parentRefA.current || !parentRefB.current) return; + isSyncingRef.current = true; + parentRefB.current.scrollTop = parentRefA.current.scrollTop; + requestAnimationFrame(() => { isSyncingRef.current = false; }); + }; + + const handleScrollB = () => { + if (isSyncingRef.current || !parentRefA.current || !parentRefB.current) return; + isSyncingRef.current = true; + parentRefA.current.scrollTop = parentRefB.current.scrollTop; + requestAnimationFrame(() => { isSyncingRef.current = false; }); + }; + const virtualizerA = useVirtualizer({ count: diffState.entriesA.length, getScrollElement: () => parentRefA.current, @@ -82,7 +97,7 @@ function SideBySideView({
A: {diffFileBaseName(diffState.sourceA.filePath)} ({diffState.entriesA.length})
-
+
{virtualizerA.getVirtualItems().map((row) => { const entry = diffState.entriesA[row.index]; @@ -106,7 +121,7 @@ function SideBySideView({
B: {diffFileBaseName(diffState.sourceB.filePath)} ({diffState.entriesB.length})
-
+
{virtualizerB.getVirtualItems().map((row) => { const entry = diffState.entriesB[row.index]; diff --git a/src/components/log-view/MergeLegendBar.tsx b/src/components/log-view/MergeLegendBar.tsx index 91bbffc99..b408ba7be 100644 --- a/src/components/log-view/MergeLegendBar.tsx +++ b/src/components/log-view/MergeLegendBar.tsx @@ -19,7 +19,7 @@ export function MergeLegendBar() { const setAllFileVisibility = useLogStore((s) => s.setAllFileVisibility); const setCorrelationWindowMs = useLogStore((s) => s.setCorrelationWindowMs); const setAutoCorrelate = useLogStore((s) => s.setAutoCorrelate); - const entries = useLogStore((s) => s.entries); + const visibleEntryCount = useLogStore((s) => s.entries.length); if (!mergedTabState) return null; @@ -159,7 +159,7 @@ export function MergeLegendBar() {
- {entries.length} merged + {visibleEntryCount} merged
); diff --git a/src/lib/merge-entries.ts b/src/lib/merge-entries.ts index 5a7079479..c73260e8e 100644 --- a/src/lib/merge-entries.ts +++ b/src/lib/merge-entries.ts @@ -90,18 +90,30 @@ export function findCorrelatedEntries( const targetTs = targetEntry.timestamp; const results: CorrelatedEntry[] = []; - for (const entry of entries) { - if (entry.filePath === targetEntry.filePath) continue; + // Binary search for window start + const windowStart = targetTs - windowMs; + const windowEnd = targetTs + windowMs; + let lo = 0; + let hi = entries.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if ((entries[mid].timestamp ?? 0) < windowStart) lo = mid + 1; + else hi = mid; + } + + // Scan from window start to window end + for (let i = lo; i < entries.length; i++) { + const entry = entries[i]; if (entry.timestamp == null) continue; + if (entry.timestamp > windowEnd) break; + if (entry.filePath === targetEntry.filePath) continue; + if (entry.id === targetEntry.id) continue; - const delta = entry.timestamp - targetTs; - if (Math.abs(delta) <= windowMs) { - results.push({ - entry, - deltaMs: delta, - fileColor: colorAssignments[entry.filePath] ?? "#888", - }); - } + results.push({ + entry, + deltaMs: entry.timestamp - targetTs, + fileColor: colorAssignments[entry.filePath] ?? "#888", + }); } results.sort((a, b) => Math.abs(a.deltaMs) - Math.abs(b.deltaMs)); diff --git a/src/lib/session-restore.ts b/src/lib/session-restore.ts index d4c68612b..0c4503c12 100644 --- a/src/lib/session-restore.ts +++ b/src/lib/session-restore.ts @@ -23,7 +23,13 @@ export async function openSessionDialog(): Promise { export async function restoreSession(sessionPath: string): Promise { const content = await readTextFile(sessionPath); - const data = JSON.parse(content); + let data: unknown; + try { + data = JSON.parse(content); + } catch { + console.error("[session] invalid JSON in session file", { sessionPath }); + return null; + } const session = validateSession(data); if (!session) { @@ -98,7 +104,14 @@ export async function restoreSession(sessionPath: string): Promise t.filePath).join("\n"); + // Parse the valid files using the existing file-loading flow + const filePaths = validTabs.map((t) => t.filePath); + try { + const { loadFilesAsLogSource } = await import("./log-source"); + await loadFilesAsLogSource(filePaths); + } catch (error) { + console.error("[session] failed to parse files during restore", error); + } + + return sessionPath; } diff --git a/src/lib/session-save.ts b/src/lib/session-save.ts index 2f7597a93..496305e69 100644 --- a/src/lib/session-save.ts +++ b/src/lib/session-save.ts @@ -24,7 +24,8 @@ export async function saveSession(): Promise { // Build tab entries with file hashes const tabs: SessionTab[] = []; - for (const tab of openTabs) { + for (let i = 0; i < openTabs.length; i++) { + const tab = openTabs[i]; let hash = ""; let size = 0; try { @@ -40,7 +41,7 @@ export async function saveSession(): Promise { filePath: tab.filePath, fileHash: hash, fileSize: size, - selectedId: logState.selectedId, + selectedId: i === uiState.activeTabIndex ? logState.selectedId : null, scrollPosition: null, activeColumns: snapshot?.activeColumns ?? [], }); diff --git a/src/lib/session.ts b/src/lib/session.ts index c7eeccfff..d46f466a7 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -76,6 +76,13 @@ export function validateSession(data: unknown): SessionFile | null { if (typeof obj.version !== "number") return null; if (obj.version > CURRENT_VERSION) return null; if (!Array.isArray(obj.tabs)) return null; + // Validate each tab has required fields + for (const tab of obj.tabs) { + if (typeof tab !== "object" || tab === null) return null; + const t = tab as Record; + if (typeof t.filePath !== "string") return null; + } + if (typeof obj.workspace !== "string") return null; return obj as unknown as SessionFile; } From f48719f17e79a474ca8a43fb1580f0b92a582384 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 2 Apr 2026 18:36:21 -0400 Subject: [PATCH 24/66] =?UTF-8?q?fix:=20address=20Copilot=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20accessibility,=20UTF-8,=20GUID=20casing,=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/settings.local.json | 4 +- .claude/worktrees/agent-a03c04a2 | 1 + .claude/worktrees/agent-a439ef87 | 1 + .claude/worktrees/agent-afdd3183 | 1 + Logs/AppWorkload 2.log | 460 +++ Logs/AppWorkload-20260401-160729.log | 3253 +++++++++++++++++ Logs/AppWorkload-20260401-190042.log | 1518 ++++++++ scripts/Launch-CMTraceOpen.sh | 2 +- src-tauri/src/commands/intune.rs | 6 +- src/components/dialogs/GuidRegistryDialog.tsx | 4 + src/components/intune/EventActivityView.tsx | 2 +- src/components/layout/AppShell.tsx | 5 + .../log-view/AppWorkloadScriptDetail.tsx | 13 +- src/stores/ui-store.ts | 6 + 14 files changed, 5268 insertions(+), 8 deletions(-) create mode 160000 .claude/worktrees/agent-a03c04a2 create mode 160000 .claude/worktrees/agent-a439ef87 create mode 160000 .claude/worktrees/agent-afdd3183 create mode 100644 Logs/AppWorkload 2.log create mode 100644 Logs/AppWorkload-20260401-160729.log create mode 100644 Logs/AppWorkload-20260401-190042.log diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 939a41e37..59cb48198 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -24,5 +24,7 @@ "Bash(powershell.exe -NoProfile -Command \"Set-Location ''C:\\\\GitHub\\\\cmtraceopen''; git add src-tauri/src/intune/ime_parser.rs src-tauri/src/models/log_entry.rs src-tauri/src/parser/panther.rs src-tauri/src/parser/burn.rs src-tauri/src/parser/cbs.rs src-tauri/src/parser/ccm.rs src-tauri/src/parser/dhcp.rs src-tauri/src/parser/dism.rs src-tauri/src/parser/intune_macos.rs src-tauri/src/parser/plain.rs src-tauri/src/parser/psadt.rs src-tauri/src/parser/reporting_events.rs src-tauri/src/parser/simple.rs src-tauri/src/parser/timestamped.rs src-tauri/src/parser/msi.rs src-tauri/src/commands/deployment.rs src/components/log-view/InfoPane.tsx src/lib/column-config.ts src/types/log.ts; git status --short 2>&1\")", "WebFetch(domain:github.com)" ] - } + }, + "spinnerTipsEnabled": false, + "outputStyle": "Explanatory" } diff --git a/.claude/worktrees/agent-a03c04a2 b/.claude/worktrees/agent-a03c04a2 new file mode 160000 index 000000000..3e64ee531 --- /dev/null +++ b/.claude/worktrees/agent-a03c04a2 @@ -0,0 +1 @@ +Subproject commit 3e64ee5318b091b825c3aace1615eac41ce4f922 diff --git a/.claude/worktrees/agent-a439ef87 b/.claude/worktrees/agent-a439ef87 new file mode 160000 index 000000000..183cf6124 --- /dev/null +++ b/.claude/worktrees/agent-a439ef87 @@ -0,0 +1 @@ +Subproject commit 183cf6124bcc97673a2433bfb104d085f0dfd68d diff --git a/.claude/worktrees/agent-afdd3183 b/.claude/worktrees/agent-afdd3183 new file mode 160000 index 000000000..b1f0d0a62 --- /dev/null +++ b/.claude/worktrees/agent-afdd3183 @@ -0,0 +1 @@ +Subproject commit b1f0d0a62e0e0be3cb1523d4e40cbe3e88f3ea2f diff --git a/Logs/AppWorkload 2.log b/Logs/AppWorkload 2.log new file mode 100644 index 000000000..830f3d6e0 --- /dev/null +++ b/Logs/AppWorkload 2.log @@ -0,0 +1,460 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Logs/AppWorkload-20260401-160729.log b/Logs/AppWorkload-20260401-160729.log new file mode 100644 index 000000000..c5bf5d620 --- /dev/null +++ b/Logs/AppWorkload-20260401-160729.log @@ -0,0 +1,3253 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Logs/AppWorkload-20260401-190042.log b/Logs/AppWorkload-20260401-190042.log new file mode 100644 index 000000000..79655636b --- /dev/null +++ b/Logs/AppWorkload-20260401-190042.log @@ -0,0 +1,1518 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/Launch-CMTraceOpen.sh b/scripts/Launch-CMTraceOpen.sh index 6a01ebf1b..a193a6377 100644 --- a/scripts/Launch-CMTraceOpen.sh +++ b/scripts/Launch-CMTraceOpen.sh @@ -157,7 +157,7 @@ while [ "$#" -gt 0 ]; do shift done -script_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +script_root="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" app_root="$(cd "${script_root}/.." && pwd)" node_modules_path="${app_root}/node_modules" diff --git a/src-tauri/src/commands/intune.rs b/src-tauri/src/commands/intune.rs index fb93a4bab..f053702cf 100644 --- a/src-tauri/src/commands/intune.rs +++ b/src-tauri/src/commands/intune.rs @@ -158,7 +158,9 @@ fn analyze_intune_logs_blocking( all_events.extend(processed_file.events); all_downloads.extend(processed_file.downloads); coverage.push(processed_file.coverage); - all_policy_metadata.extend(processed_file.policy_metadata); + all_policy_metadata.extend( + processed_file.policy_metadata.into_iter().map(|(k, v)| (k.to_lowercase(), v)) + ); } // Enrich event and download names using the global GUID registry @@ -210,7 +212,7 @@ fn analyze_intune_logs_blocking( .as_deref() .or(event.guid.as_deref()); if let Some(guid) = lookup_guid { - if let Some(policy) = all_policy_metadata.get(guid) { + if let Some(policy) = all_policy_metadata.get(&guid.to_lowercase()) { // Find the first script-type detection rule with a body if let Some(rule) = policy .detection_rules diff --git a/src/components/dialogs/GuidRegistryDialog.tsx b/src/components/dialogs/GuidRegistryDialog.tsx index b72a1f2f1..ee2aa537e 100644 --- a/src/components/dialogs/GuidRegistryDialog.tsx +++ b/src/components/dialogs/GuidRegistryDialog.tsx @@ -154,7 +154,11 @@ function GuidRow({ guid, entry }: { guid: string; entry: GuidRegistryEntry }) { { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleCopyGuid(); } }} + tabIndex={0} + role="button" title="Click to copy GUID" + aria-label={`Copy GUID ${guid}`} > {entry.name} diff --git a/src/components/intune/EventActivityView.tsx b/src/components/intune/EventActivityView.tsx index 137233353..119c1a8ec 100644 --- a/src/components/intune/EventActivityView.tsx +++ b/src/components/intune/EventActivityView.tsx @@ -67,7 +67,7 @@ function extractAppNameFromDetail( if (name.length === 0) continue; // If it's a GUID, try to resolve it via the registry if (GUID_PATTERN.test(name)) { - const entry = registry?.[name.toLowerCase()]; + const entry = registry?.[name.toLowerCase()] ?? registry?.[name]; if (entry) return entry.name; continue; } diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index ebdb420dd..b1a82f4dc 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -156,6 +156,11 @@ export function AppShell() { return () => { window.removeEventListener("mousemove", onMouseMove); window.removeEventListener("mouseup", onMouseUp); + if (infoPaneResizeRef.current) { + infoPaneResizeRef.current = null; + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + } }; }, [setInfoPaneHeight]); diff --git a/src/components/log-view/AppWorkloadScriptDetail.tsx b/src/components/log-view/AppWorkloadScriptDetail.tsx index c9b405fb0..f6830312d 100644 --- a/src/components/log-view/AppWorkloadScriptDetail.tsx +++ b/src/components/log-view/AppWorkloadScriptDetail.tsx @@ -33,7 +33,12 @@ type ParsedDetail = function decodeBase64(encoded: string): string | null { try { - return atob(encoded); + const binaryStr = atob(encoded); + const bytes = new Uint8Array(binaryStr.length); + for (let i = 0; i < binaryStr.length; i++) { + bytes[i] = binaryStr.charCodeAt(i); + } + return new TextDecoder("utf-8").decode(bytes); } catch { return null; } @@ -80,8 +85,10 @@ function parseGetPolicies(message: string): PolicyEntry[] | null { return arr.map((item: unknown) => { const obj = item as Record; + const rawId = obj.Id; + if (typeof rawId !== "string" || rawId.length === 0) return null; const entry: PolicyEntry = { - id: String(obj.Id ?? ""), + id: String(rawId), name: String(obj.Name ?? "Unknown"), intent: typeof obj.Intent === "number" ? obj.Intent : undefined, targetType: typeof obj.TargetType === "number" ? obj.TargetType : undefined, @@ -120,7 +127,7 @@ function parseGetPolicies(message: string): PolicyEntry[] | null { } return entry; - }); + }).filter((entry): entry is PolicyEntry => entry !== null); } function parseSideCarDetail(message: string): SideCarDetail | null { diff --git a/src/stores/ui-store.ts b/src/stores/ui-store.ts index 48aeeb11d..fa3780987 100644 --- a/src/stores/ui-store.ts +++ b/src/stores/ui-store.ts @@ -485,6 +485,9 @@ export const useUiStore = create()( !state.showAboutDialog && !state.showSettingsDialog && !state.showEvidenceBundleDialog && + !state.showGuidRegistryDialog && + !state.showMergeTabsDialog && + !state.showDiffConfigDialog && !state.showFileAssociationPrompt && !state.showCollectDiagnosticsDialog && !state.showUpdateDialog @@ -501,6 +504,9 @@ export const useUiStore = create()( showAboutDialog: false, showSettingsDialog: false, showEvidenceBundleDialog: false, + showGuidRegistryDialog: false, + showMergeTabsDialog: false, + showDiffConfigDialog: false, showFileAssociationPrompt: false, showCollectDiagnosticsDialog: false, showUpdateDialog: false, From c2b7adebbd6161e5f55c39e79a0124678edbd015 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 2 Apr 2026 18:54:03 -0400 Subject: [PATCH 25/66] Add Graph API GUID resolution (Windows) Introduce end-to-end Microsoft Graph integration to resolve Intune app GUIDs. Backend: new src-tauri/src/graph_api.rs implements WAM-based Windows auth, token caching, batch/single Graph requests, paginated app fetches and an in-memory GUID cache; adds fetch_all_apps and resolve_guids utilities. Frontend: new GraphApiTab settings UI to enable/sign-in/pre-populate cache, a startup hook to auto-connect and populate cache, status bar indicator for Graph status, and enhancements to the GUID registry dialog (tabs, filtering, publisher/type columns). Add lib/graph-registry.ts to convert GraphAppInfo to GuidRegistry entries and update types (GuidCategory, publisher). Also improve event_tracker to extract PolicyId from JSON payloads. Wire startup import in main.tsx and add ui-store state/handlers for graphApiEnabled and graphApiStatus. --- src-tauri/src/graph_api.rs | 584 ++++++++++++++++++ src-tauri/src/intune/event_tracker.rs | 16 +- src/components/dialogs/GuidRegistryDialog.tsx | 165 ++++- .../dialogs/settings/GraphApiTab.tsx | 449 ++++++++++++++ src/components/layout/StatusBar.tsx | 67 +- src/hooks/use-graph-api-startup.ts | 40 ++ src/lib/graph-registry.ts | 25 + src/main.tsx | 2 + src/stores/ui-store.ts | 20 + src/types/intune.ts | 4 + 10 files changed, 1324 insertions(+), 48 deletions(-) create mode 100644 src-tauri/src/graph_api.rs create mode 100644 src/components/dialogs/settings/GraphApiTab.tsx create mode 100644 src/hooks/use-graph-api-startup.ts create mode 100644 src/lib/graph-registry.ts diff --git a/src-tauri/src/graph_api.rs b/src-tauri/src/graph_api.rs new file mode 100644 index 000000000..e92efccb8 --- /dev/null +++ b/src-tauri/src/graph_api.rs @@ -0,0 +1,584 @@ +//! Microsoft Graph API integration for GUID resolution. +//! +//! Uses WAM (Web Account Manager) for silent token acquisition on Entra-joined +//! devices. This module is Windows-only and gated behind user opt-in. + +use std::collections::HashMap; +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; + +use crate::error::AppError; + +// ── Public types ──────────────────────────────────────────────────────────── + +/// Status of the Graph API connection, returned to the frontend. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GraphAuthStatus { + pub is_authenticated: bool, + pub user_principal_name: Option, + pub tenant_id: Option, + pub error: Option, +} + +/// A resolved app from Graph API. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GraphAppInfo { + pub id: String, + pub display_name: String, + pub publisher: Option, + pub odata_type: Option, +} + +/// Batch resolution result. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GraphResolutionResult { + pub resolved: HashMap, + pub not_found: Vec, + pub errors: Vec, +} + +// ── Token cache ───────────────────────────────────────────────────────────── + +#[derive(Debug, Default)] +pub struct GraphAuthState { + access_token: Mutex>, + guid_cache: Mutex>, +} + +#[derive(Debug, Clone)] +struct CachedToken { + token: String, + user_principal_name: Option, + tenant_id: Option, + expires_at: std::time::Instant, +} + +impl GraphAuthState { + pub fn new() -> Self { + Self::default() + } + + fn get_valid_token(&self) -> Option { + let guard = self.access_token.lock().unwrap(); + guard.as_ref().and_then(|t| { + if t.expires_at > std::time::Instant::now() { + Some(t.clone()) + } else { + None + } + }) + } + + fn set_token(&self, token: CachedToken) { + *self.access_token.lock().unwrap() = Some(token); + } + + fn clear_token(&self) { + *self.access_token.lock().unwrap() = None; + } + + fn get_cached_app(&self, guid: &str) -> Option { + self.guid_cache.lock().unwrap().get(guid).cloned() + } + + fn cache_apps(&self, apps: &HashMap) { + let mut cache = self.guid_cache.lock().unwrap(); + for (k, v) in apps { + cache.insert(k.clone(), v.clone()); + } + } +} + +// ── WAM token acquisition (Windows only) ──────────────────────────────────── + +/// Well-known Microsoft Graph PowerShell client ID (public client, no app reg needed). +const GRAPH_POWERSHELL_CLIENT_ID: &str = "14d82eec-204b-4c2f-b7e8-296a70dab67e"; +const GRAPH_RESOURCE: &str = "https://graph.microsoft.com"; + +#[cfg(target_os = "windows")] +mod wam { + use super::*; + + use windows::core::{factory, HSTRING}; + use windows::Foundation::IAsyncOperation; + use windows::Security::Authentication::Web::Core::{ + WebAuthenticationCoreManager, WebTokenRequest, WebTokenRequestResult, + WebTokenRequestStatus, + }; + use windows::Win32::Foundation::HWND; + use windows::Win32::System::WinRT::IWebAuthenticationCoreManagerInterop; + + /// Acquire a token via WAM using the Win32 interop path. + /// + /// Desktop (Win32) apps don't have a CoreWindow, so we must use + /// `IWebAuthenticationCoreManagerInterop::RequestTokenForWindowAsync` + /// with an explicit HWND instead of the UWP `RequestTokenAsync`. + pub fn acquire_token(hwnd_raw: isize) -> Result { + let hwnd = HWND(hwnd_raw as *mut _); + + // Provider lookup doesn't need a window + let authority = HSTRING::from("organizations"); + let provider = WebAuthenticationCoreManager::FindAccountProviderWithAuthorityAsync( + &HSTRING::from("https://login.microsoft.com"), + &authority, + ) + .map_err(|e| AppError::Internal(format!("WAM provider lookup failed: {e}")))? + .get() + .map_err(|e| AppError::Internal(format!("WAM provider await failed: {e}")))?; + + // WAM v1 resource model: pass empty scope, set resource via properties + let scope = HSTRING::from(""); + let client_id = HSTRING::from(GRAPH_POWERSHELL_CLIENT_ID); + let request = WebTokenRequest::Create(&provider, &scope, &client_id) + .map_err(|e| AppError::Internal(format!("WAM request creation failed: {e}")))?; + + request.Properties() + .map_err(|e| AppError::Internal(format!("WAM properties failed: {e}")))? + .Insert(&HSTRING::from("resource"), &HSTRING::from(GRAPH_RESOURCE)) + .map_err(|e| AppError::Internal(format!("WAM set resource failed: {e}")))?; + + // Use the COM interop interface to pass our HWND + let interop: IWebAuthenticationCoreManagerInterop = + factory::() + .map_err(|e| AppError::Internal(format!("WAM interop factory failed: {e}")))?; + + let operation: IAsyncOperation = unsafe { + interop.RequestTokenForWindowAsync(hwnd, &request) + } + .map_err(|e| AppError::Internal(format!("WAM token request failed: {e}")))?; + + let result = operation + .get() + .map_err(|e| AppError::Internal(format!("WAM token await failed: {e}")))?; + + let status = result + .ResponseStatus() + .map_err(|e| AppError::Internal(format!("WAM status check failed: {e}")))?; + + match status { + WebTokenRequestStatus::Success => { + let responses = result + .ResponseData() + .map_err(|e| AppError::Internal(format!("WAM response data: {e}")))?; + let response = responses + .GetAt(0) + .map_err(|e| AppError::Internal(format!("WAM response index: {e}")))?; + + let token = response + .Token() + .map_err(|e| AppError::Internal(format!("WAM token extract: {e}")))? + .to_string(); + + if token.is_empty() { + return Err(AppError::Internal( + "WAM returned Success but the access token is empty. \ + Ensure the resource property is set correctly.".into() + )); + } + + let upn = response + .WebAccount() + .ok() + .and_then(|acct| acct.UserName().ok()) + .map(|s| s.to_string()); + + let tenant = response + .Properties() + .ok() + .and_then(|props| props.Lookup(&HSTRING::from("TenantId")).ok()) + .map(|s| s.to_string()); + + Ok(CachedToken { + token, + user_principal_name: upn, + tenant_id: tenant, + expires_at: std::time::Instant::now() + + std::time::Duration::from_secs(50 * 60), + }) + } + WebTokenRequestStatus::UserCancel => { + Err(AppError::Internal("Authentication was cancelled by user.".into())) + } + WebTokenRequestStatus::UserInteractionRequired => { + Err(AppError::Internal( + "Interactive authentication required. Please sign in to Windows with your Entra ID account first.".into() + )) + } + _ => { + let error_msg = result + .ResponseError() + .ok() + .and_then(|e| e.ErrorMessage().ok()) + .map(|s| s.to_string()) + .unwrap_or_else(|| "Unknown WAM error".to_string()); + Err(AppError::Internal(format!("WAM authentication failed: {error_msg}"))) + } + } + } +} + +#[cfg(not(target_os = "windows"))] +mod wam { + use super::*; + + pub fn acquire_token(_hwnd_raw: isize) -> Result { + Err(AppError::PlatformUnsupported( + "Graph API authentication via WAM is only available on Windows.".into(), + )) + } +} + +// ── Graph API calls ───────────────────────────────────────────────────────── + +const GRAPH_BETA_BASE: &str = "https://graph.microsoft.com/beta"; + +/// Helper: parse a ureq response body as JSON. +fn read_json(response: ureq::Response) -> Result { + let body = response + .into_string() + .map_err(|e| AppError::Internal(format!("Failed to read response body: {e}")))?; + serde_json::from_str(&body) + .map_err(|e| AppError::Internal(format!("Failed to parse JSON: {e}"))) +} + +/// Helper: extract a GraphAppInfo from a JSON object. +fn parse_app_json(item: &serde_json::Value) -> Option { + let id = item.get("id").and_then(|v| v.as_str())?; + let name = item.get("displayName").and_then(|v| v.as_str())?; + Some(GraphAppInfo { + id: id.to_lowercase(), + display_name: name.to_string(), + publisher: item.get("publisher").and_then(|v| v.as_str()).map(String::from), + odata_type: item.get("@odata.type").and_then(|v| v.as_str()).map(String::from), + }) +} + +fn make_agent() -> ureq::Agent { + ureq::AgentBuilder::new() + .timeout_read(std::time::Duration::from_secs(30)) + .timeout_write(std::time::Duration::from_secs(10)) + .build() +} + +/// Authenticate with Graph API via WAM. Returns current auth status. +/// `hwnd_raw` is the native window handle for the WAM dialog. +pub fn authenticate(state: &GraphAuthState, hwnd_raw: isize) -> Result { + if let Some(cached) = state.get_valid_token() { + return Ok(GraphAuthStatus { + is_authenticated: true, + user_principal_name: cached.user_principal_name, + tenant_id: cached.tenant_id, + error: None, + }); + } + + match wam::acquire_token(hwnd_raw) { + Ok(token) => { + let status = GraphAuthStatus { + is_authenticated: true, + user_principal_name: token.user_principal_name.clone(), + tenant_id: token.tenant_id.clone(), + error: None, + }; + state.set_token(token); + Ok(status) + } + Err(e) => { + state.clear_token(); + Ok(GraphAuthStatus { + is_authenticated: false, + user_principal_name: None, + tenant_id: None, + error: Some(e.to_string()), + }) + } + } +} + +/// Get current auth status without triggering a new auth flow. +pub fn get_auth_status(state: &GraphAuthState) -> GraphAuthStatus { + match state.get_valid_token() { + Some(cached) => GraphAuthStatus { + is_authenticated: true, + user_principal_name: cached.user_principal_name, + tenant_id: cached.tenant_id, + error: None, + }, + None => GraphAuthStatus { + is_authenticated: false, + user_principal_name: None, + tenant_id: None, + error: None, + }, + } +} + +/// Sign out — clear cached token and GUID cache. +pub fn sign_out(state: &GraphAuthState) { + state.clear_token(); + *state.guid_cache.lock().unwrap() = HashMap::new(); +} + +/// Resolve a batch of GUIDs to app display names via Graph API. +pub fn resolve_guids( + state: &GraphAuthState, + guids: &[String], +) -> Result { + let token = state + .get_valid_token() + .ok_or_else(|| AppError::Internal("Not authenticated. Please sign in first.".into()))?; + + let mut resolved: HashMap = HashMap::new(); + let mut to_fetch: Vec = Vec::new(); + + for guid in guids { + let normalized = guid.to_lowercase(); + if let Some(cached) = state.get_cached_app(&normalized) { + resolved.insert(normalized, cached); + } else { + to_fetch.push(normalized); + } + } + + if to_fetch.is_empty() { + return Ok(GraphResolutionResult { + resolved, + not_found: vec![], + errors: vec![], + }); + } + + let mut not_found = Vec::new(); + let mut errors = Vec::new(); + + // Graph $batch supports max 20 requests per batch + for chunk in to_fetch.chunks(20) { + match fetch_apps_batch(&token.token, chunk) { + Ok(batch_result) => { + for (guid, info) in &batch_result.resolved { + resolved.insert(guid.clone(), info.clone()); + } + not_found.extend(batch_result.not_found); + errors.extend(batch_result.errors); + } + Err(e) => { + errors.push(format!("Batch request failed: {e}")); + for guid in chunk { + match fetch_single_app(&token.token, guid) { + Ok(Some(info)) => { + resolved.insert(guid.clone(), info); + } + Ok(None) => not_found.push(guid.clone()), + Err(e) => errors.push(format!("{guid}: {e}")), + } + } + } + } + } + + state.cache_apps(&resolved); + + Ok(GraphResolutionResult { + resolved, + not_found, + errors, + }) +} + +/// Fetch all Intune apps, scripts, and remediations for pre-populating the cache. +pub fn fetch_all_apps(state: &GraphAuthState) -> Result, AppError> { + let token = state + .get_valid_token() + .ok_or_else(|| AppError::Internal("Not authenticated. Please sign in first.".into()))?; + + let mut all: Vec = Vec::new(); + + // Win32/LOB/Store apps + all.extend(fetch_paginated( + &token.token, + &format!("{GRAPH_BETA_BASE}/deviceAppManagement/mobileApps?$select=id,displayName,publisher"), + None, + )?); + + // Proactive Remediations (Health Scripts) + match fetch_paginated( + &token.token, + &format!("{GRAPH_BETA_BASE}/deviceManagement/deviceHealthScripts?$select=id,displayName,publisher"), + Some("#microsoft.graph.deviceHealthScript"), + ) { + Ok(items) => all.extend(items), + Err(e) => log::warn!("event=graph_skip_health_scripts error=\"{e}\""), + } + + // Platform scripts (PowerShell scripts deployed via Intune) + match fetch_paginated( + &token.token, + &format!("{GRAPH_BETA_BASE}/deviceManagement/deviceManagementScripts?$select=id,displayName"), + Some("#microsoft.graph.deviceManagementScript"), + ) { + Ok(items) => all.extend(items), + Err(e) => log::warn!("event=graph_skip_device_scripts error=\"{e}\""), + } + + // Shell scripts (macOS) + match fetch_paginated( + &token.token, + &format!("{GRAPH_BETA_BASE}/deviceManagement/deviceShellScripts?$select=id,displayName"), + Some("#microsoft.graph.deviceShellScript"), + ) { + Ok(items) => all.extend(items), + Err(e) => log::warn!("event=graph_skip_shell_scripts error=\"{e}\""), + } + + let cache_map: HashMap = all + .iter() + .map(|a| (a.id.clone(), a.clone())) + .collect(); + state.cache_apps(&cache_map); + + Ok(all) +} + +/// Fetch all items from a paginated Graph API endpoint. +/// `default_type` is used when the response items don't include `@odata.type`. +fn fetch_paginated( + token: &str, + initial_url: &str, + default_type: Option<&str>, +) -> Result, AppError> { + let agent = make_agent(); + let mut items: Vec = Vec::new(); + let mut next_url: Option = Some(initial_url.to_string()); + + while let Some(url) = next_url.take() { + let response = agent + .get(&url) + .set("Authorization", &format!("Bearer {token}")) + .set("ConsistencyLevel", "eventual") + .call() + .map_err(|e| { + if let ureq::Error::Status(code, resp) = e { + let body = resp.into_string().unwrap_or_default(); + log::warn!("Graph API HTTP {code} for {url}: {body}"); + AppError::Internal(format!("Graph API HTTP {code}: {body}")) + } else { + AppError::Internal(format!("Graph API request failed: {e}")) + } + })?; + + let body = read_json(response)?; + + if let Some(value) = body.get("value").and_then(|v| v.as_array()) { + for item in value { + if let Some(mut app) = parse_app_json(item) { + // Apply default type if the item doesn't have one + if app.odata_type.is_none() { + app.odata_type = default_type.map(String::from); + } + items.push(app); + } + } + } + + next_url = body + .get("@odata.nextLink") + .and_then(|v| v.as_str()) + .map(String::from); + } + + Ok(items) +} + +// ── Internal helpers ──────────────────────────────────────────────────────── + +fn fetch_apps_batch( + token: &str, + guids: &[String], +) -> Result { + let requests: Vec = guids + .iter() + .enumerate() + .map(|(i, guid)| { + serde_json::json!({ + "id": i.to_string(), + "method": "GET", + "url": format!("/deviceAppManagement/mobileApps/{guid}?$select=id,displayName,publisher") + }) + }) + .collect(); + + let batch_body = serde_json::json!({ "requests": requests }); + let body_str = serde_json::to_string(&batch_body) + .map_err(|e| AppError::Internal(format!("JSON serialize failed: {e}")))?; + + let agent = make_agent(); + let response = agent + .post(&format!("{GRAPH_BETA_BASE}/$batch")) + .set("Authorization", &format!("Bearer {token}")) + .set("Content-Type", "application/json") + .send_string(&body_str) + .map_err(|e| AppError::Internal(format!("Graph batch request failed: {e}")))?; + + let body = read_json(response)?; + + let mut resolved = HashMap::new(); + let mut not_found = Vec::new(); + let mut errors = Vec::new(); + + if let Some(responses) = body.get("responses").and_then(|v| v.as_array()) { + for resp in responses { + let id_str = resp.get("id").and_then(|v| v.as_str()).unwrap_or("0"); + let idx: usize = id_str.parse().unwrap_or(0); + let status = resp.get("status").and_then(|v| v.as_u64()).unwrap_or(0); + let guid = guids.get(idx).cloned().unwrap_or_default(); + + if status == 200 { + if let Some(resp_body) = resp.get("body") { + if let Some(app) = parse_app_json(resp_body) { + resolved.insert(app.id.clone(), app); + } + } + } else if status == 404 { + not_found.push(guid); + } else { + let msg = resp + .get("body") + .and_then(|b| b.get("error")) + .and_then(|e| e.get("message")) + .and_then(|m| m.as_str()) + .unwrap_or("Unknown error"); + errors.push(format!("{guid}: HTTP {status} - {msg}")); + } + } + } + + Ok(GraphResolutionResult { + resolved, + not_found, + errors, + }) +} + +fn fetch_single_app(token: &str, guid: &str) -> Result, AppError> { + let agent = make_agent(); + let url = format!( + "{GRAPH_BETA_BASE}/deviceAppManagement/mobileApps/{guid}?$select=id,displayName,publisher" + ); + + match agent + .get(&url) + .set("Authorization", &format!("Bearer {token}")) + .call() + { + Ok(response) => { + let body = read_json(response)?; + Ok(parse_app_json(&body)) + } + Err(ureq::Error::Status(404, _)) => Ok(None), + Err(e) => Err(AppError::Internal(format!("Graph request failed: {e}"))), + } +} diff --git a/src-tauri/src/intune/event_tracker.rs b/src-tauri/src/intune/event_tracker.rs index 266f4d82d..ac7ac43ce 100644 --- a/src-tauri/src/intune/event_tracker.rs +++ b/src-tauri/src/intune/event_tracker.rs @@ -1002,7 +1002,9 @@ fn extract_guid(msg: &str) -> Option { .and_then(|cap| cap.get(1)) .map(|value| value.as_str().to_string()) }) - // 3. Generic first GUID fallback + // 3. PolicyId from JSON payloads (HealthScripts, script results) + .or_else(|| extract_policy_id(msg)) + // 4. Generic first GUID fallback .or_else(|| { guid_re() .captures(msg) @@ -1011,6 +1013,18 @@ fn extract_guid(msg: &str) -> Option { }) } +/// Extract PolicyId from JSON payloads in HealthScripts/script result messages. +/// Handles lines like: `"PolicyId":"79880037-a3c4-489a-a7e6-a6a705b52b78"` +fn extract_policy_id(msg: &str) -> Option { + static RE: OnceLock = OnceLock::new(); + let re = RE.get_or_init(|| { + Regex::new(r#"(?i)"PolicyId"\s*:\s*\\?"([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\\?""#).unwrap() + }); + re.captures(msg) + .and_then(|cap| cap.get(1)) + .map(|m| m.as_str().to_string()) +} + fn extract_error_code(msg: &str) -> Option { error_code_re() .captures(msg) diff --git a/src/components/dialogs/GuidRegistryDialog.tsx b/src/components/dialogs/GuidRegistryDialog.tsx index ee2aa537e..a4987fdb6 100644 --- a/src/components/dialogs/GuidRegistryDialog.tsx +++ b/src/components/dialogs/GuidRegistryDialog.tsx @@ -14,7 +14,7 @@ import { SearchRegular } from "@fluentui/react-icons"; import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { LOG_MONOSPACE_FONT_FAMILY } from "../../lib/log-accessibility"; import { useIntuneStore } from "../../stores/intune-store"; -import type { GuidRegistryEntry } from "../../types/intune"; +import type { GuidCategory, GuidRegistryEntry } from "../../types/intune"; const SOURCE_LABELS: Record = { ApplicationName: { label: "AppName", color: tokens.colorPaletteGreenForeground1 }, @@ -22,62 +22,145 @@ const SOURCE_LABELS: Record = { SetUpFilePath: { label: "FilePath", color: tokens.colorNeutralForeground3 }, }; +type TabId = "all" | "apps" | "scripts" | "remediations"; + +interface TabDef { + id: TabId; + label: string; + filter: (category: GuidCategory | undefined) => boolean; +} + +const TABS: TabDef[] = [ + { id: "all", label: "All", filter: () => true }, + { id: "apps", label: "Apps", filter: (c) => !c || c === "app" || c === "unknown" }, + { id: "scripts", label: "Scripts", filter: (c) => c === "script" }, + { id: "remediations", label: "Remediations", filter: (c) => c === "remediation" }, +]; + interface GuidRegistryDialogProps { isOpen: boolean; onClose: () => void; } +interface RowEntry extends GuidRegistryEntry { + guid: string; +} + export function GuidRegistryDialog({ isOpen, onClose }: GuidRegistryDialogProps) { const guidRegistry = useIntuneStore((s) => s.guidRegistry); const [filter, setFilter] = useState(""); + const [activeTab, setActiveTab] = useState("all"); - const entries = useMemo(() => { - const all = Object.entries(guidRegistry).map(([guid, entry]) => ({ + const allEntries = useMemo(() => { + const all: RowEntry[] = Object.entries(guidRegistry).map(([guid, entry]) => ({ guid, ...entry, })); all.sort((a, b) => a.name.localeCompare(b.name)); + return all; + }, [guidRegistry]); + + const tabCounts = useMemo(() => { + const counts: Record = { all: allEntries.length, apps: 0, scripts: 0, remediations: 0 }; + for (const entry of allEntries) { + const tab = TABS.find((t) => t.id !== "all" && t.filter(entry.category)); + if (tab) counts[tab.id]++; + } + return counts; + }, [allEntries]); + + const filteredEntries = useMemo(() => { + const tabDef = TABS.find((t) => t.id === activeTab) ?? TABS[0]; + let entries = allEntries.filter((e) => tabDef.filter(e.category)); - if (!filter.trim()) return all; - const needle = filter.toLowerCase(); - return all.filter( - (e) => - e.name.toLowerCase().includes(needle) || - e.guid.toLowerCase().includes(needle) - ); - }, [guidRegistry, filter]); + if (filter.trim()) { + const needle = filter.toLowerCase(); + entries = entries.filter( + (e) => + e.name.toLowerCase().includes(needle) || + e.guid.toLowerCase().includes(needle) || + (e.publisher?.toLowerCase().includes(needle) ?? false) + ); + } - const totalCount = Object.keys(guidRegistry).length; + return entries; + }, [allEntries, activeTab, filter]); return ( { if (!data.open) onClose(); }}> - + GUID Registry + {/* Tab bar */} +
+ {TABS.map((tab) => { + const count = tabCounts[tab.id]; + if (tab.id !== "all" && count === 0) return null; + return ( + + ); + })} +
+ + {/* Search */}
} - placeholder="Filter by name or GUID..." + placeholder="Filter by name, GUID, or publisher..." value={filter} onChange={(_, data) => setFilter(data.value)} style={{ flex: 1 }} /> - - {entries.length === totalCount - ? `${totalCount} entries` - : `${entries.length} / ${totalCount}`} + + {filteredEntries.length === tabCounts[activeTab] + ? `${filteredEntries.length} entries` + : `${filteredEntries.length} / ${tabCounts[activeTab]}`}
- {totalCount === 0 ? ( + {allEntries.length === 0 ? ( +
+ No GUID registry data available. Run an Intune analysis or enable Graph API in Settings. +
+ ) : filteredEntries.length === 0 ? (
- No GUID registry data available. Run an Intune analysis first. + No matches for “{filter}”
) : (
- App Name + Name GUID + {activeTab === "all" && Type} + Publisher Source - {entries.map((entry) => ( - + {filteredEntries.map((entry) => ( + ))} @@ -141,12 +226,20 @@ const tdStyle: React.CSSProperties = { verticalAlign: "middle", }; -function GuidRow({ guid, entry }: { guid: string; entry: GuidRegistryEntry }) { +const CATEGORY_LABELS: Record = { + app: { label: "App", color: tokens.colorBrandForeground1 }, + script: { label: "Script", color: tokens.colorPaletteMarigoldForeground1 }, + remediation: { label: "Remediation", color: tokens.colorPaletteTealForeground2 }, + unknown: { label: "-", color: tokens.colorNeutralForeground3 }, +}; + +function GuidRow({ entry, showType }: { entry: RowEntry; showType: boolean }) { const sourceInfo = SOURCE_LABELS[entry.source] ?? { label: entry.source, color: tokens.colorNeutralForeground3 }; + const categoryInfo = CATEGORY_LABELS[entry.category ?? "unknown"] ?? CATEGORY_LABELS.unknown; const handleCopyGuid = async () => { try { - await writeText(guid); + await writeText(entry.guid); } catch { /* ignore */ } }; @@ -164,16 +257,20 @@ function GuidRow({ guid, entry }: { guid: string; entry: GuidRegistryEntry }) { {entry.name} - {guid} + {entry.guid} + + {showType && ( + + + {categoryInfo.label} + + + )} + + {entry.publisher ?? ""} - + {sourceInfo.label} diff --git a/src/components/dialogs/settings/GraphApiTab.tsx b/src/components/dialogs/settings/GraphApiTab.tsx new file mode 100644 index 000000000..86b92419f --- /dev/null +++ b/src/components/dialogs/settings/GraphApiTab.tsx @@ -0,0 +1,449 @@ +import { useState, useEffect, useCallback } from "react"; +import { tokens } from "@fluentui/react-components"; +import { useUiStore } from "../../../stores/ui-store"; +import { + graphAuthenticate, + graphGetAuthStatus, + graphSignOut, + graphFetchAllApps, + type GraphAuthStatus, +} from "../../../lib/commands"; +import { useIntuneStore } from "../../../stores/intune-store"; +import { buildGraphRegistryEntries } from "../../../lib/graph-registry"; + +export function GraphApiTab() { + const graphApiEnabled = useUiStore((state) => state.graphApiEnabled); + const setGraphApiEnabled = useUiStore((state) => state.setGraphApiEnabled); + const currentPlatform = useUiStore((state) => state.currentPlatform); + + const [authStatus, setAuthStatus] = useState(null); + const [loading, setLoading] = useState(false); + const [cacheLoading, setCacheLoading] = useState(false); + const [cachedAppCount, setCachedAppCount] = useState(null); + const [cacheError, setCacheError] = useState(null); + const [showConfirmEnable, setShowConfirmEnable] = useState(false); + + const refreshStatus = useCallback(async () => { + if (!graphApiEnabled) return; + try { + const status = await graphGetAuthStatus(); + setAuthStatus(status); + } catch { + // Command may not exist on non-Windows + } + }, [graphApiEnabled]); + + useEffect(() => { + refreshStatus(); + }, [refreshStatus]); + + const handleToggle = (checked: boolean) => { + if (checked) { + setShowConfirmEnable(true); + } else { + setGraphApiEnabled(false); + setAuthStatus(null); + } + }; + + const confirmEnable = () => { + setGraphApiEnabled(true); + setShowConfirmEnable(false); + }; + + const handleSignIn = async () => { + setLoading(true); + try { + const status = await graphAuthenticate(); + setAuthStatus(status); + } catch (e) { + setAuthStatus({ + isAuthenticated: false, + userPrincipalName: null, + tenantId: null, + error: e instanceof Error ? e.message : String(e), + }); + } finally { + setLoading(false); + } + }; + + const handleSignOut = async () => { + try { + await graphSignOut(); + setAuthStatus(null); + setCachedAppCount(null); + } catch { + // ignore + } + }; + + const handlePrePopulateCache = async () => { + setCacheLoading(true); + setCacheError(null); + setCachedAppCount(null); + try { + const apps = await graphFetchAllApps(); + setCachedAppCount(apps.length); + + if (apps.length > 0) { + useIntuneStore.getState().mergeGuidRegistry(buildGraphRegistryEntries(apps)); + } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + setCacheError(msg); + } finally { + setCacheLoading(false); + } + }; + + if (currentPlatform !== "windows") { + return ( +
+ Graph API integration is only available on Windows (Entra-joined devices). +
+ ); + } + + return ( +
+
+ Optionally connect to Microsoft Graph to resolve Intune app GUIDs to + display names. This feature is off by default. +
+ + {/* Warning banner - always visible */} +
+
+ Before you enable this feature: +
+
    +
  • + This connects CMTrace Open to Microsoft Graph API using your Windows + sign-in session (WAM). +
  • +
  • + It sends read-only requests to your Intune tenant to resolve app + GUIDs. +
  • +
  • + Even with read-only permissions, your organization may have policies + governing API access.{" "} + + Validate with your security team before enabling in production. + +
  • +
  • + Uses the Microsoft Graph PowerShell public client ID — no app + registration required. +
  • +
  • + Requires DeviceManagementApps.Read.All delegated + permission (admin consent may be needed on first use). +
  • +
+
+ + {/* Enable toggle */} +
+ + + {/* Confirmation dialog when enabling */} + {showConfirmEnable && ( +
+
+ Confirm: Enable Graph API connection +
+
+ You are about to enable network calls to Microsoft Graph API. + CMTrace Open will authenticate using your current Windows session + and make read-only API calls to your Intune tenant. No data is + sent to third parties. +
+
+ + +
+
+ )} + + {/* Auth status & sign-in (only when enabled) */} + {graphApiEnabled && ( +
+
+ Connection Status +
+ + {authStatus?.isAuthenticated ? ( +
+
+ + Connected +
+ {authStatus.userPrincipalName && ( +
+ Signed in as: {authStatus.userPrincipalName} +
+ )} + {authStatus.tenantId && ( +
+ Tenant: {authStatus.tenantId} +
+ )} +
+ + +
+ {cachedAppCount != null && ( +
0 + ? tokens.colorPaletteGreenForeground1 + : tokens.colorNeutralForeground3, + }} + > + {cachedAppCount > 0 + ? `Cached ${cachedAppCount} app${cachedAppCount !== 1 ? "s" : ""} from Intune. GUIDs will be resolved automatically during log analysis.` + : "No apps returned from Graph API. Check permissions."} +
+ )} + {cacheError && ( +
+ {cacheError} +
+ )} +
+ ) : ( +
+
+ + Not connected +
+ {authStatus?.error && ( +
+ {authStatus.error} +
+ )} + +
+ )} +
+ )} +
+
+ ); +} diff --git a/src/components/layout/StatusBar.tsx b/src/components/layout/StatusBar.tsx index 28a0ad141..fe3b0e7f6 100644 --- a/src/components/layout/StatusBar.tsx +++ b/src/components/layout/StatusBar.tsx @@ -62,6 +62,8 @@ export function StatusBar() { const openTabs = useUiStore((s) => s.openTabs); const activeTabIndex = useUiStore((s) => s.activeTabIndex); + const graphApiStatus = useUiStore((s) => s.graphApiStatus); + const intuneAnalysisState = useIntuneStore((s) => s.analysisState); const intuneSummary = useIntuneStore((s) => s.summary); const intuneSourceContext = useIntuneStore((s) => s.sourceContext); @@ -381,19 +383,58 @@ export function StatusBar() { {leftStatusText}
- - {rightStatusText} - +
+ {graphApiStatus !== "idle" && ( + + + {graphApiStatus === "connecting" + ? "Graph API: Connecting..." + : graphApiStatus === "connected" + ? "Graph API: Connected" + : "Graph API: Error"} + + )} + + {rightStatusText} + +
); } diff --git a/src/hooks/use-graph-api-startup.ts b/src/hooks/use-graph-api-startup.ts new file mode 100644 index 000000000..87eda32aa --- /dev/null +++ b/src/hooks/use-graph-api-startup.ts @@ -0,0 +1,40 @@ +import { useUiStore } from "../stores/ui-store"; +import { useIntuneStore } from "../stores/intune-store"; +import { buildGraphRegistryEntries } from "../lib/graph-registry"; +import { + graphAuthenticate, + graphFetchAllApps, +} from "../lib/commands"; + +async function connectAndPopulate() { + try { + useUiStore.getState().setGraphApiStatus("connecting"); + + const status = await graphAuthenticate(); + if (!status.isAuthenticated) { + useUiStore.getState().setGraphApiStatus("error"); + return; + } + + const apps = await graphFetchAllApps(); + if (apps.length > 0) { + useIntuneStore.getState().mergeGuidRegistry(buildGraphRegistryEntries(apps)); + } + + useUiStore.getState().setGraphApiStatus("connected"); + } catch { + useUiStore.getState().setGraphApiStatus("error"); + } +} + +function tryStart() { + const { graphApiEnabled, currentPlatform } = useUiStore.getState(); + if (!graphApiEnabled || currentPlatform !== "windows") return; + connectAndPopulate(); +} + +if (useUiStore.persist.hasHydrated()) { + tryStart(); +} else { + useUiStore.persist.onFinishHydration(tryStart); +} diff --git a/src/lib/graph-registry.ts b/src/lib/graph-registry.ts new file mode 100644 index 000000000..059693813 --- /dev/null +++ b/src/lib/graph-registry.ts @@ -0,0 +1,25 @@ +import type { GraphAppInfo } from "./commands"; +import type { GuidCategory, GuidRegistryEntry } from "../types/intune"; + +function categorizeOdataType(odataType: string | null): GuidCategory { + if (!odataType) return "unknown"; + const t = odataType.toLowerCase(); + if (t.includes("healthscript")) return "remediation"; + if (t.includes("managementscript") || t.includes("shellscript")) return "script"; + return "app"; +} + +export function buildGraphRegistryEntries( + apps: GraphAppInfo[] +): Record { + const entries: Record = {}; + for (const app of apps) { + entries[app.id] = { + name: app.displayName, + source: "GraphApi", + category: categorizeOdataType(app.odataType), + publisher: app.publisher ?? undefined, + }; + } + return entries; +} diff --git a/src/main.tsx b/src/main.tsx index 25cbe6231..d3c14e492 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,6 +7,8 @@ import { getThemeById } from "./lib/themes"; import { useUiStore } from "./stores/ui-store"; import { initializeDateTimeFormatting, refreshDateTimeFormatting } from "./lib/date-time-format"; import { getCurrentWindow } from "@tauri-apps/api/window"; +// Register Graph API auto-connect (runs after persist hydration) +import "./hooks/use-graph-api-startup"; const RootWrapper = import.meta.env.DEV ? React.Fragment : React.StrictMode; diff --git a/src/stores/ui-store.ts b/src/stores/ui-store.ts index fa3780987..a569ba0ae 100644 --- a/src/stores/ui-store.ts +++ b/src/stores/ui-store.ts @@ -202,7 +202,12 @@ interface UiState { defaultShowInfoPane: boolean; confirmTabClose: boolean; showUpdateDialog: boolean; +<<<<<<< Updated upstream recentSessions: string[]; +======= + graphApiEnabled: boolean; + graphApiStatus: "idle" | "connecting" | "connected" | "error"; +>>>>>>> Stashed changes setActiveWorkspace: (workspace: WorkspaceId) => void; setCurrentPlatform: (platform: PlatformId) => void; @@ -263,8 +268,13 @@ interface UiState { setCollectionResult: (result: CollectionResult | null) => void; setShowCollectDiagnosticsDialog: (show: boolean) => void; setShowUpdateDialog: (show: boolean) => void; +<<<<<<< Updated upstream addRecentSession: (path: string) => void; clearRecentSessions: () => void; +======= + setGraphApiEnabled: (enabled: boolean) => void; + setGraphApiStatus: (status: "idle" | "connecting" | "connected" | "error") => void; +>>>>>>> Stashed changes } const DEFAULT_WORKSPACE: WorkspaceId = "log"; @@ -345,7 +355,12 @@ export const useUiStore = create()( collectionResult: null, showCollectDiagnosticsDialog: false, showUpdateDialog: false, +<<<<<<< Updated upstream recentSessions: [], +======= + graphApiEnabled: false, + graphApiStatus: "idle", +>>>>>>> Stashed changes setCurrentPlatform: (platform) => set({ currentPlatform: platform }), setEnabledWorkspaces: (workspaces) => { @@ -616,12 +631,17 @@ export const useUiStore = create()( setCollectionResult: (result) => set({ collectionResult: result }), setShowCollectDiagnosticsDialog: (show) => set({ showCollectDiagnosticsDialog: show }), setShowUpdateDialog: (show) => set({ showUpdateDialog: show }), +<<<<<<< Updated upstream addRecentSession: (path) => set((state) => { const filtered = state.recentSessions.filter((p) => p !== path); return { recentSessions: [path, ...filtered].slice(0, 5) }; }), clearRecentSessions: () => set({ recentSessions: [] }), +======= + setGraphApiEnabled: (enabled) => set({ graphApiEnabled: enabled }), + setGraphApiStatus: (status) => set({ graphApiStatus: status }), +>>>>>>> Stashed changes }), { name: "cmtraceopen-ui-preferences", diff --git a/src/types/intune.ts b/src/types/intune.ts index 89cf37227..f36c9a789 100644 --- a/src/types/intune.ts +++ b/src/types/intune.ts @@ -268,9 +268,13 @@ export interface IntuneAnalysisState { export type GuidNameSource = "SetUpFilePath" | "NameField" | "ApplicationName"; +export type GuidCategory = "app" | "script" | "remediation" | "unknown"; + export interface GuidRegistryEntry { name: string; source: GuidNameSource; + category?: GuidCategory; + publisher?: string; } export interface IntuneAnalysisResult { From 2c5cbe6e6d5d65924a3ec227eccee17f02a54efc Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 2 Apr 2026 15:55:24 -0400 Subject: [PATCH 26/66] feat: add Microsoft Graph API integration for GUID resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add opt-in Graph API support to resolve Intune app GUIDs to display names using the device's existing Entra ID session via WAM (Web Account Manager). No app registration required — uses the Microsoft Graph PowerShell public client ID. - WAM authentication with HWND interop for Win32 desktop apps - Graph API client with batch resolution ($batch endpoint, 20 per request) - Pre-populate cache button fetches all tenant apps in one call - GraphApi source variant (highest confidence) in GuidRegistry - Settings tab with opt-in toggle (off by default), consent warnings, and connection status display - Automatic enrichment during Intune log analysis when enabled Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/settings.local.json | 3 +- CHANGELOG.md | 3 +- src-tauri/Cargo.toml | 15 ++++- src-tauri/src/commands/graph_api.rs | 60 ++++++++++++++++++ src-tauri/src/commands/intune.rs | 63 ++++++++++++++++++- src-tauri/src/commands/mod.rs | 2 + src-tauri/src/graph_api.rs | 35 +++++++++++ src-tauri/src/intune/event_tracker.rs | 2 +- src-tauri/src/intune/guid_registry.rs | 17 ++++- src-tauri/src/lib.rs | 19 ++++++ src/components/dialogs/GuidRegistryDialog.tsx | 1 + src/components/dialogs/SettingsDialog.tsx | 6 +- .../dialogs/settings/GraphApiTab.tsx | 11 ++++ src/components/layout/Toolbar.tsx | 1 + src/lib/commands.ts | 45 ++++++++++++- src/stores/intune-store.ts | 4 ++ src/stores/ui-store.ts | 17 +++++ src/types/intune.ts | 2 +- 18 files changed, 295 insertions(+), 11 deletions(-) create mode 100644 src-tauri/src/commands/graph_api.rs diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 59cb48198..2f996bac7 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -22,7 +22,8 @@ "Bash(grep -r \"setupact\\\\|setuperr\" /c/GitHub/cmtraceopen/src-tauri --include=*.rs)", "Bash(powershell -Command \"cd ''C:\\\\GitHub\\\\cmtraceopen\\\\src-tauri''; cargo test panther 2>&1\")", "Bash(powershell.exe -NoProfile -Command \"Set-Location ''C:\\\\GitHub\\\\cmtraceopen''; git add src-tauri/src/intune/ime_parser.rs src-tauri/src/models/log_entry.rs src-tauri/src/parser/panther.rs src-tauri/src/parser/burn.rs src-tauri/src/parser/cbs.rs src-tauri/src/parser/ccm.rs src-tauri/src/parser/dhcp.rs src-tauri/src/parser/dism.rs src-tauri/src/parser/intune_macos.rs src-tauri/src/parser/plain.rs src-tauri/src/parser/psadt.rs src-tauri/src/parser/reporting_events.rs src-tauri/src/parser/simple.rs src-tauri/src/parser/timestamped.rs src-tauri/src/parser/msi.rs src-tauri/src/commands/deployment.rs src/components/log-view/InfoPane.tsx src/lib/column-config.ts src/types/log.ts; git status --short 2>&1\")", - "WebFetch(domain:github.com)" + "WebFetch(domain:github.com)", + "Bash(powershell.exe:*)" ] }, "spinnerTipsEnabled": false, diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fc12eb73..4733ec8cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,8 @@ All notable changes to this project will be documented in this file. - **Event Log workspace** (Windows, feature-gated): Parse `.evtx` files and query live Windows Event Log channels. Supports file-based EVTX parsing with channel grouping, severity filtering, and correlation linking. Frontend workspace with channel sidebar, severity badges, and detail pane. - **AppWorkload enrichment**: Parse "Get policies" JSON payloads in the log viewer to build GUID-to-app-name mappings. InfoPane shows resolved app names when log messages contain GUIDs, structured policy metadata cards, and decoded base64 PowerShell detection scripts via a lightweight syntax-highlighted code viewer. - **Activity view**: New "Activity" toggle in the Intune timeline tab groups events by app into collapsible cards. Each card shows worst status, event count, duration, and event type badges. Expanded rows display parsed structured fields (intent, detection, applicability, reboot, GRS expired, enforcement) as colored tags with inline GUID resolution and word-wrapped detail messages. -- **GUID Registry dialog**: New Tools menu item showing a searchable table of all GUID-to-app-name mappings from the Intune analysis, with source confidence ranking (ApplicationName > Name > SetUpFilePath) and click-to-copy. +- **GUID Registry dialog**: New Tools menu item showing a searchable table of all GUID-to-app-name mappings from the Intune analysis, with source confidence ranking (GraphApi > ApplicationName > Name > SetUpFilePath) and click-to-copy. +- **Microsoft Graph API integration** (Windows, opt-in): Resolve Intune app GUIDs to display names via Microsoft Graph API. Authenticates silently using WAM (Web Account Manager) with the device's existing Entra ID session — no app registration required. Gated behind Settings > Graph API toggle (off by default) with consent warnings. Pre-populate cache button fetches all tenant apps in one call. Graph-resolved names appear in the GUID Registry with a purple "Graph API" label and are used as the highest-confidence source during Intune log analysis. - **SideCarScriptDetectionManager events**: Extract PowerShell script detection lifecycle events (start, complete, exit code, process ID) as standalone PowerShellScript events in the Intune timeline. - **Resizable InfoPane**: Drag handle between the log list and detail pane allows resizing (min 80px, max 70% viewport). - **Jump to Line**: Context menu action to jump to a specific line number in the log. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0f0e93189..59290bfd0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -20,7 +20,7 @@ full = ["collector", "deployment", "dsregcmd", "event-log", "intune-diagnostics" event-log = ["dep:evtx"] collector = [] deployment = [] -dsregcmd = ["dep:ureq", "intune-diagnostics"] +dsregcmd = ["intune-diagnostics"] intune-diagnostics = ["dep:evtx"] macos-diag = ["dep:plist"] @@ -53,8 +53,17 @@ plist = { version = "1", optional = true } [target.'cfg(target_os = "windows")'.dependencies] winreg = "0.52" -windows = { version = "0.58", features = ["Win32_Foundation", "Win32_System_EventLog", "Win32_Security"] } -ureq = { version = "2", optional = true } +windows = { version = "0.58", features = [ + "Win32_Foundation", + "Win32_System_EventLog", + "Win32_Security", + "Security_Authentication_Web_Core", + "Security_Credentials", + "Foundation", + "Foundation_Collections", + "Win32_System_WinRT", +] } +ureq = "2" [dev-dependencies] criterion = "0.5" diff --git a/src-tauri/src/commands/graph_api.rs b/src-tauri/src/commands/graph_api.rs new file mode 100644 index 000000000..8a729ecae --- /dev/null +++ b/src-tauri/src/commands/graph_api.rs @@ -0,0 +1,60 @@ +use tauri::Manager; + +use crate::error::{AppError, CmdResult}; +use crate::graph_api::{self, GraphAppInfo, GraphAuthState, GraphAuthStatus, GraphResolutionResult}; + +/// Get the HWND of the main Tauri window for WAM dialog parenting. +fn get_main_hwnd(app: &tauri::AppHandle) -> Result { + let window = app + .get_webview_window("main") + .ok_or_else(|| AppError::Internal("No main window found".into()))?; + + #[cfg(target_os = "windows")] + { + let hwnd = window.hwnd() + .map_err(|e| AppError::Internal(format!("Failed to get HWND: {e}")))?; + Ok(hwnd.0 as isize) + } + + #[cfg(not(target_os = "windows"))] + { + let _ = window; + Ok(0) + } +} + +#[tauri::command] +pub fn graph_authenticate( + app: tauri::AppHandle, + state: tauri::State<'_, GraphAuthState>, +) -> CmdResult { + let hwnd = get_main_hwnd(&app)?; + graph_api::authenticate(&state, hwnd) +} + +#[tauri::command] +pub fn graph_get_auth_status( + state: tauri::State<'_, GraphAuthState>, +) -> GraphAuthStatus { + graph_api::get_auth_status(&state) +} + +#[tauri::command] +pub fn graph_sign_out(state: tauri::State<'_, GraphAuthState>) { + graph_api::sign_out(&state); +} + +#[tauri::command] +pub fn graph_resolve_guids( + guids: Vec, + state: tauri::State<'_, GraphAuthState>, +) -> CmdResult { + graph_api::resolve_guids(&state, &guids) +} + +#[tauri::command] +pub fn graph_fetch_all_apps( + state: tauri::State<'_, GraphAuthState>, +) -> CmdResult> { + graph_api::fetch_all_apps(&state) +} diff --git a/src-tauri/src/commands/intune.rs b/src-tauri/src/commands/intune.rs index f053702cf..6c009b752 100644 --- a/src-tauri/src/commands/intune.rs +++ b/src-tauri/src/commands/intune.rs @@ -64,19 +64,60 @@ pub async fn analyze_intune_logs( path: String, request_id: String, include_live_event_logs: bool, + graph_api_enabled: bool, app: AppHandle, + #[cfg(target_os = "windows")] graph_state: tauri::State<'_, crate::graph_api::GraphAuthState>, ) -> Result { + // Attempt Graph API enrichment before spawning the blocking task. + // We capture the resolved map here (on the async side) so the blocking + // task doesn't need Send-unfriendly state references. + #[cfg(target_os = "windows")] + let graph_resolved = if graph_api_enabled { + try_graph_prefetch(&graph_state) + } else { + None + }; + #[cfg(not(target_os = "windows"))] + let graph_resolved: Option> = None; + Ok(async_runtime::spawn_blocking(move || { - analyze_intune_logs_blocking(path, request_id, include_live_event_logs, app) + analyze_intune_logs_blocking(path, request_id, include_live_event_logs, graph_resolved, app) }) .await .map_err(|error| crate::error::AppError::Internal(format!("Intune analysis task failed: {}", error)))??) } +/// Pre-fetch all Intune apps from Graph API and return a guid→name map. +/// Returns None if auth isn't active or the call fails (non-blocking fallback). +#[cfg(target_os = "windows")] +fn try_graph_prefetch( + state: &crate::graph_api::GraphAuthState, +) -> Option> { + match crate::graph_api::fetch_all_apps(state) { + Ok(apps) => { + let map: HashMap = apps + .into_iter() + .map(|a| (a.id, a.display_name)) + .collect(); + if map.is_empty() { + None + } else { + log::info!("event=graph_api_prefetch apps={}", map.len()); + Some(map) + } + } + Err(e) => { + log::warn!("event=graph_api_prefetch_failed error=\"{e}\""); + None + } + } +} + fn analyze_intune_logs_blocking( path: String, request_id: String, include_live_event_logs: bool, + graph_resolved: Option>, app: AppHandle, ) -> Result { let analysis_started = Instant::now(); @@ -147,6 +188,26 @@ fn analyze_intune_logs_blocking( for processed_file in &processed_files { guid_registry.merge(&processed_file.guid_registry); } + // Enrich the GUID registry with Graph API data (highest confidence source). + // This fills in any GUIDs that weren't resolved from log lines. + if let Some(ref graph_map) = graph_resolved { + let mut graph_enriched = 0u32; + for (guid, name) in graph_map { + let normalized = guid.to_lowercase(); + guid_registry.insert( + normalized, + name.clone(), + crate::intune::guid_registry::GuidNameSource::GraphApi, + ); + graph_enriched += 1; + } + log::info!( + "event=graph_api_enrichment entries_added={} total_registry={}", + graph_enriched, + guid_registry.len() + ); + } + let guid_registry_map = guid_registry.to_serializable(); let mut all_events = Vec::new(); diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 2093f8ad0..dd2fcada0 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -17,6 +17,8 @@ pub mod intune_bundle; #[cfg(feature = "intune-diagnostics")] pub mod intune_diagnostics; pub mod app_config; +#[cfg(target_os = "windows")] +pub mod graph_api; pub mod known_sources; #[cfg(feature = "macos-diag")] pub mod macos_diag; diff --git a/src-tauri/src/graph_api.rs b/src-tauri/src/graph_api.rs index e92efccb8..dc54098d5 100644 --- a/src-tauri/src/graph_api.rs +++ b/src-tauri/src/graph_api.rs @@ -389,12 +389,17 @@ pub fn resolve_guids( }) } +<<<<<<< HEAD /// Fetch all Intune apps, scripts, and remediations for pre-populating the cache. +======= +/// Fetch all Intune apps at once (for pre-populating the cache). +>>>>>>> d065202 (feat: add Microsoft Graph API integration for GUID resolution) pub fn fetch_all_apps(state: &GraphAuthState) -> Result, AppError> { let token = state .get_valid_token() .ok_or_else(|| AppError::Internal("Not authenticated. Please sign in first.".into()))?; +<<<<<<< HEAD let mut all: Vec = Vec::new(); // Win32/LOB/Store apps @@ -453,17 +458,32 @@ fn fetch_paginated( let agent = make_agent(); let mut items: Vec = Vec::new(); let mut next_url: Option = Some(initial_url.to_string()); +======= + let mut all_apps: Vec = Vec::new(); + let mut next_url: Option = Some(format!( + "{GRAPH_BETA_BASE}/deviceAppManagement/mobileApps?$select=id,displayName,publisher" + )); + + let agent = make_agent(); +>>>>>>> d065202 (feat: add Microsoft Graph API integration for GUID resolution) while let Some(url) = next_url.take() { let response = agent .get(&url) +<<<<<<< HEAD .set("Authorization", &format!("Bearer {token}")) +======= + .set("Authorization", &format!("Bearer {}", token.token)) +>>>>>>> d065202 (feat: add Microsoft Graph API integration for GUID resolution) .set("ConsistencyLevel", "eventual") .call() .map_err(|e| { if let ureq::Error::Status(code, resp) = e { let body = resp.into_string().unwrap_or_default(); +<<<<<<< HEAD log::warn!("Graph API HTTP {code} for {url}: {body}"); +======= +>>>>>>> d065202 (feat: add Microsoft Graph API integration for GUID resolution) AppError::Internal(format!("Graph API HTTP {code}: {body}")) } else { AppError::Internal(format!("Graph API request failed: {e}")) @@ -474,12 +494,17 @@ fn fetch_paginated( if let Some(value) = body.get("value").and_then(|v| v.as_array()) { for item in value { +<<<<<<< HEAD if let Some(mut app) = parse_app_json(item) { // Apply default type if the item doesn't have one if app.odata_type.is_none() { app.odata_type = default_type.map(String::from); } items.push(app); +======= + if let Some(app) = parse_app_json(item) { + all_apps.push(app); +>>>>>>> d065202 (feat: add Microsoft Graph API integration for GUID resolution) } } } @@ -490,7 +515,17 @@ fn fetch_paginated( .map(String::from); } +<<<<<<< HEAD Ok(items) +======= + let cache_map: HashMap = all_apps + .iter() + .map(|a| (a.id.clone(), a.clone())) + .collect(); + state.cache_apps(&cache_map); + + Ok(all_apps) +>>>>>>> d065202 (feat: add Microsoft Graph API integration for GUID resolution) } // ── Internal helpers ──────────────────────────────────────────────────────── diff --git a/src-tauri/src/intune/event_tracker.rs b/src-tauri/src/intune/event_tracker.rs index ac7ac43ce..4fe25f20d 100644 --- a/src-tauri/src/intune/event_tracker.rs +++ b/src-tauri/src/intune/event_tracker.rs @@ -613,7 +613,7 @@ fn extract_appworkload_event( end_time: None, duration_secs: None, error_code, - detail: build_detail(msg), + detail: msg.to_string(), source_file: source_file.to_string(), line_number: line.line_number, start_time_epoch: None, diff --git a/src-tauri/src/intune/guid_registry.rs b/src-tauri/src/intune/guid_registry.rs index ee797e946..0af56c650 100644 --- a/src-tauri/src/intune/guid_registry.rs +++ b/src-tauri/src/intune/guid_registry.rs @@ -64,8 +64,10 @@ pub enum GuidNameSource { SetUpFilePath = 0, /// `"Name"` JSON field NameField = 1, - /// `"ApplicationName"` JSON field — highest confidence + /// `"ApplicationName"` JSON field ApplicationName = 2, + /// Microsoft Graph API — highest confidence (canonical display name) + GraphApi = 3, } /// A resolved identity for a GUID observed in IME logs. @@ -173,6 +175,19 @@ impl GuidRegistry { self.entries.is_empty() } + /// Insert a GUID→name entry from an external source (e.g. Graph API). + pub fn insert(&mut self, guid: String, name: String, source: GuidNameSource) { + self.insert_if_dominated(guid, name, source); + } + + /// Collect all GUIDs that have no resolved name. + pub fn unresolved_guids_from<'a>(&self, guids: impl Iterator) -> Vec { + guids + .filter(|g| !self.entries.contains_key(*g)) + .map(|g| g.to_string()) + .collect() + } + /// Iterate over all `(guid, entry)` pairs in the registry. pub fn iter(&self) -> impl Iterator { self.entries.iter() diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 94f7220ca..d76e90bc4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,8 @@ mod commands; pub mod dsregcmd; pub mod error; pub mod error_db; +#[cfg(target_os = "windows")] +pub mod graph_api; pub mod intune; #[cfg(feature = "event-log")] pub mod event_log; @@ -18,6 +20,10 @@ mod state; mod watcher; use state::app_state::AppState; +use tauri::Manager; + +#[cfg(target_os = "windows")] +use graph_api::GraphAuthState; /// Returns all non-flag CLI arguments as potential file paths. /// @@ -53,6 +59,9 @@ pub fn run() { menu::handle_menu_event(app_handle, event.id().as_ref()); }); + #[cfg(target_os = "windows")] + app.manage(GraphAuthState::new()); + Ok(()) }) .manage(AppState::new(initial_file_paths)) @@ -119,6 +128,16 @@ pub fn run() { event_log::commands::evtx_enumerate_channels, #[cfg(feature = "event-log")] event_log::commands::evtx_query_channels, + #[cfg(target_os = "windows")] + commands::graph_api::graph_authenticate, + #[cfg(target_os = "windows")] + commands::graph_api::graph_get_auth_status, + #[cfg(target_os = "windows")] + commands::graph_api::graph_sign_out, + #[cfg(target_os = "windows")] + commands::graph_api::graph_resolve_guids, + #[cfg(target_os = "windows")] + commands::graph_api::graph_fetch_all_apps, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/components/dialogs/GuidRegistryDialog.tsx b/src/components/dialogs/GuidRegistryDialog.tsx index a4987fdb6..37af2d136 100644 --- a/src/components/dialogs/GuidRegistryDialog.tsx +++ b/src/components/dialogs/GuidRegistryDialog.tsx @@ -20,6 +20,7 @@ const SOURCE_LABELS: Record = { ApplicationName: { label: "AppName", color: tokens.colorPaletteGreenForeground1 }, NameField: { label: "Name", color: tokens.colorBrandForeground1 }, SetUpFilePath: { label: "FilePath", color: tokens.colorNeutralForeground3 }, + GraphApi: { label: "Graph API", color: tokens.colorPalettePurpleForeground2 }, }; type TabId = "all" | "apps" | "scripts" | "remediations"; diff --git a/src/components/dialogs/SettingsDialog.tsx b/src/components/dialogs/SettingsDialog.tsx index 5b05d1ae6..554e15ad1 100644 --- a/src/components/dialogs/SettingsDialog.tsx +++ b/src/components/dialogs/SettingsDialog.tsx @@ -6,8 +6,9 @@ import { ColumnsTab } from "./settings/ColumnsTab"; import { BehaviorTab } from "./settings/BehaviorTab"; import { UpdatesTab } from "./settings/UpdatesTab"; import { FileAssociationsTab } from "./settings/FileAssociationsTab"; +import { GraphApiTab } from "./settings/GraphApiTab"; -type SettingsTabId = "appearance" | "columns" | "behavior" | "updates" | "file-associations"; +type SettingsTabId = "appearance" | "columns" | "behavior" | "updates" | "file-associations" | "graph-api"; interface TabDef { id: SettingsTabId; @@ -21,6 +22,7 @@ const TABS: TabDef[] = [ { id: "behavior", label: "Behavior" }, { id: "updates", label: "Updates" }, { id: "file-associations", label: "File Associations", windowsOnly: true }, + { id: "graph-api", label: "Graph API", windowsOnly: true }, ]; interface SettingsDialogProps { @@ -94,6 +96,8 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { return ; case "file-associations": return ; + case "graph-api": + return ; } }; diff --git a/src/components/dialogs/settings/GraphApiTab.tsx b/src/components/dialogs/settings/GraphApiTab.tsx index 86b92419f..5458f4fa4 100644 --- a/src/components/dialogs/settings/GraphApiTab.tsx +++ b/src/components/dialogs/settings/GraphApiTab.tsx @@ -9,7 +9,10 @@ import { type GraphAuthStatus, } from "../../../lib/commands"; import { useIntuneStore } from "../../../stores/intune-store"; +<<<<<<< HEAD import { buildGraphRegistryEntries } from "../../../lib/graph-registry"; +======= +>>>>>>> d065202 (feat: add Microsoft Graph API integration for GUID resolution) export function GraphApiTab() { const graphApiEnabled = useUiStore((state) => state.graphApiEnabled); @@ -87,7 +90,15 @@ export function GraphApiTab() { setCachedAppCount(apps.length); if (apps.length > 0) { +<<<<<<< HEAD useIntuneStore.getState().mergeGuidRegistry(buildGraphRegistryEntries(apps)); +======= + const entries: Record = {}; + for (const app of apps) { + entries[app.id] = { name: app.displayName, source: "GraphApi" }; + } + useIntuneStore.getState().mergeGuidRegistry(entries); +>>>>>>> d065202 (feat: add Microsoft Graph API integration for GUID resolution) } } catch (e) { const msg = e instanceof Error ? e.message : String(e); diff --git a/src/components/layout/Toolbar.tsx b/src/components/layout/Toolbar.tsx index 47c9c4c76..f45664931 100644 --- a/src/components/layout/Toolbar.tsx +++ b/src/components/layout/Toolbar.tsx @@ -369,6 +369,7 @@ export function useAppActions(): AppActionHandlers { const result = await analyzeIntuneLogs(getLogSourcePath(source), requestId, { includeLiveEventLogs: shouldIncludeLiveEventLogs(source), + graphApiEnabled: useUiStore.getState().graphApiEnabled, }); startTransition(() => { diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 130fb5f7d..0548c8e9c 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -180,12 +180,13 @@ export async function resumeTail(path: string): Promise { export async function analyzeIntuneLogs( path: string, requestId: string, - options?: AnalyzeIntuneLogsOptions + options?: AnalyzeIntuneLogsOptions & { graphApiEnabled?: boolean } ): Promise { return invokeCommand("analyze_intune_logs", { path, requestId, includeLiveEventLogs: options?.includeLiveEventLogs ?? false, + graphApiEnabled: options?.graphApiEnabled ?? false, }); } @@ -283,6 +284,48 @@ export async function collectDiagnostics( }); } +// --- Graph API (Windows only, opt-in) --- + +export interface GraphAuthStatus { + isAuthenticated: boolean; + userPrincipalName: string | null; + tenantId: string | null; + error: string | null; +} + +export interface GraphAppInfo { + id: string; + displayName: string; + publisher: string | null; + odataType: string | null; +} + +export interface GraphResolutionResult { + resolved: Record; + notFound: string[]; + errors: string[]; +} + +export async function graphAuthenticate(): Promise { + return invokeCommand("graph_authenticate"); +} + +export async function graphGetAuthStatus(): Promise { + return invokeCommand("graph_get_auth_status"); +} + +export async function graphSignOut(): Promise { + return invokeCommand("graph_sign_out"); +} + +export async function graphResolveGuids(guids: string[]): Promise { + return invokeCommand("graph_resolve_guids", { guids }); +} + +export async function graphFetchAllApps(): Promise { + return invokeCommand("graph_fetch_all_apps"); +} + // --- macOS Diagnostics --- import type { diff --git a/src/stores/intune-store.ts b/src/stores/intune-store.ts index d7196f8e5..4d5243e70 100644 --- a/src/stores/intune-store.ts +++ b/src/stores/intune-store.ts @@ -218,6 +218,7 @@ interface IntuneState { setFilterStatus: (status: IntuneStatus | "All") => void; setEventLogFilterChannel: (channel: EventLogChannel | "All") => void; setEventLogFilterSeverity: (severity: EventLogSeverity | "All") => void; + mergeGuidRegistry: (entries: Record) => void; selectEventLogEntry: (id: number | null) => void; setActiveTab: (tab: IntuneWorkspaceTab) => void; setTimelineViewMode: (mode: IntuneTimelineViewMode) => void; @@ -456,6 +457,9 @@ export const useIntuneStore = create((set) => ({ setFilterStatus: (status) => set({ filterStatus: status }), setEventLogFilterChannel: (channel) => set({ eventLogFilterChannel: channel }), setEventLogFilterSeverity: (severity) => set({ eventLogFilterSeverity: severity }), + mergeGuidRegistry: (entries) => set((state) => ({ + guidRegistry: { ...state.guidRegistry, ...entries }, + })), selectEventLogEntry: (id) => set({ selectedEventLogEntryId: id }), setActiveTab: (tab) => set({ activeTab: tab }), setTimelineViewMode: (mode) => set({ timelineViewMode: mode }), diff --git a/src/stores/ui-store.ts b/src/stores/ui-store.ts index a569ba0ae..0a4f3b7c4 100644 --- a/src/stores/ui-store.ts +++ b/src/stores/ui-store.ts @@ -202,12 +202,16 @@ interface UiState { defaultShowInfoPane: boolean; confirmTabClose: boolean; showUpdateDialog: boolean; +<<<<<<< HEAD <<<<<<< Updated upstream recentSessions: string[]; ======= graphApiEnabled: boolean; graphApiStatus: "idle" | "connecting" | "connected" | "error"; >>>>>>> Stashed changes +======= + graphApiEnabled: boolean; +>>>>>>> d065202 (feat: add Microsoft Graph API integration for GUID resolution) setActiveWorkspace: (workspace: WorkspaceId) => void; setCurrentPlatform: (platform: PlatformId) => void; @@ -268,6 +272,7 @@ interface UiState { setCollectionResult: (result: CollectionResult | null) => void; setShowCollectDiagnosticsDialog: (show: boolean) => void; setShowUpdateDialog: (show: boolean) => void; +<<<<<<< HEAD <<<<<<< Updated upstream addRecentSession: (path: string) => void; clearRecentSessions: () => void; @@ -275,6 +280,9 @@ interface UiState { setGraphApiEnabled: (enabled: boolean) => void; setGraphApiStatus: (status: "idle" | "connecting" | "connected" | "error") => void; >>>>>>> Stashed changes +======= + setGraphApiEnabled: (enabled: boolean) => void; +>>>>>>> d065202 (feat: add Microsoft Graph API integration for GUID resolution) } const DEFAULT_WORKSPACE: WorkspaceId = "log"; @@ -355,12 +363,16 @@ export const useUiStore = create()( collectionResult: null, showCollectDiagnosticsDialog: false, showUpdateDialog: false, +<<<<<<< HEAD <<<<<<< Updated upstream recentSessions: [], ======= graphApiEnabled: false, graphApiStatus: "idle", >>>>>>> Stashed changes +======= + graphApiEnabled: false, +>>>>>>> d065202 (feat: add Microsoft Graph API integration for GUID resolution) setCurrentPlatform: (platform) => set({ currentPlatform: platform }), setEnabledWorkspaces: (workspaces) => { @@ -631,6 +643,7 @@ export const useUiStore = create()( setCollectionResult: (result) => set({ collectionResult: result }), setShowCollectDiagnosticsDialog: (show) => set({ showCollectDiagnosticsDialog: show }), setShowUpdateDialog: (show) => set({ showUpdateDialog: show }), +<<<<<<< HEAD <<<<<<< Updated upstream addRecentSession: (path) => set((state) => { @@ -642,6 +655,9 @@ export const useUiStore = create()( setGraphApiEnabled: (enabled) => set({ graphApiEnabled: enabled }), setGraphApiStatus: (status) => set({ graphApiStatus: status }), >>>>>>> Stashed changes +======= + setGraphApiEnabled: (enabled) => set({ graphApiEnabled: enabled }), +>>>>>>> d065202 (feat: add Microsoft Graph API integration for GUID resolution) }), { name: "cmtraceopen-ui-preferences", @@ -656,6 +672,7 @@ export const useUiStore = create()( autoUpdateEnabled: state.autoUpdateEnabled, defaultShowInfoPane: state.defaultShowInfoPane, confirmTabClose: state.confirmTabClose, + graphApiEnabled: state.graphApiEnabled, }), merge: (persistedState, currentState) => { const raw = persistedState as Partial & { diff --git a/src/types/intune.ts b/src/types/intune.ts index f36c9a789..2f3772ad9 100644 --- a/src/types/intune.ts +++ b/src/types/intune.ts @@ -266,7 +266,7 @@ export interface IntuneAnalysisState { progress: IntuneAnalysisProgress | null; } -export type GuidNameSource = "SetUpFilePath" | "NameField" | "ApplicationName"; +export type GuidNameSource = "SetUpFilePath" | "NameField" | "ApplicationName" | "GraphApi"; export type GuidCategory = "app" | "script" | "remediation" | "unknown"; From a9eacf4c891a5ee5fcf12c9b9cb72e2c844e5d7a Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 2 Apr 2026 19:11:45 -0400 Subject: [PATCH 27/66] fix: resolve merge conflicts with Graph API integration, fix CI - Fix analyze_intune_logs to use app.state() instead of cfg-gated parameter (Tauri generate_handler! can't handle #[cfg] on parameters) - Gate Manager import to windows-only in both lib.rs and intune.rs - Resolve merge conflicts in ui-store.ts (recentSessions + graphApi state) - Resolve merge conflicts in GraphApiTab.tsx (keep buildGraphRegistryEntries) - Fix GuidRegistryDialog aria-label to use entry.guid Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/commands/intune.rs | 6 +++- src-tauri/src/lib.rs | 3 +- src/components/dialogs/GuidRegistryDialog.tsx | 2 +- .../dialogs/settings/GraphApiTab.tsx | 11 -------- src/stores/ui-store.ts | 28 ------------------- 5 files changed, 8 insertions(+), 42 deletions(-) diff --git a/src-tauri/src/commands/intune.rs b/src-tauri/src/commands/intune.rs index 6c009b752..5cc19934e 100644 --- a/src-tauri/src/commands/intune.rs +++ b/src-tauri/src/commands/intune.rs @@ -9,6 +9,8 @@ use std::time::Instant; use rayon::prelude::*; use serde::Serialize; use tauri::{async_runtime, AppHandle, Emitter}; +#[cfg(target_os = "windows")] +use tauri::Manager; use crate::intune::download_stats; use crate::intune::event_tracker; @@ -66,19 +68,21 @@ pub async fn analyze_intune_logs( include_live_event_logs: bool, graph_api_enabled: bool, app: AppHandle, - #[cfg(target_os = "windows")] graph_state: tauri::State<'_, crate::graph_api::GraphAuthState>, ) -> Result { // Attempt Graph API enrichment before spawning the blocking task. // We capture the resolved map here (on the async side) so the blocking // task doesn't need Send-unfriendly state references. #[cfg(target_os = "windows")] let graph_resolved = if graph_api_enabled { + let graph_state = app.state::(); try_graph_prefetch(&graph_state) } else { None }; #[cfg(not(target_os = "windows"))] let graph_resolved: Option> = None; + #[cfg(not(target_os = "windows"))] + let _ = graph_api_enabled; Ok(async_runtime::spawn_blocking(move || { analyze_intune_logs_blocking(path, request_id, include_live_event_logs, graph_resolved, app) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d76e90bc4..bf00928be 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -20,8 +20,9 @@ mod state; mod watcher; use state::app_state::AppState; -use tauri::Manager; +#[cfg(target_os = "windows")] +use tauri::Manager; #[cfg(target_os = "windows")] use graph_api::GraphAuthState; diff --git a/src/components/dialogs/GuidRegistryDialog.tsx b/src/components/dialogs/GuidRegistryDialog.tsx index 37af2d136..bf1649e48 100644 --- a/src/components/dialogs/GuidRegistryDialog.tsx +++ b/src/components/dialogs/GuidRegistryDialog.tsx @@ -252,7 +252,7 @@ function GuidRow({ entry, showType }: { entry: RowEntry; showType: boolean }) { tabIndex={0} role="button" title="Click to copy GUID" - aria-label={`Copy GUID ${guid}`} + aria-label={`Copy GUID ${entry.guid}`} > {entry.name} diff --git a/src/components/dialogs/settings/GraphApiTab.tsx b/src/components/dialogs/settings/GraphApiTab.tsx index 5458f4fa4..86b92419f 100644 --- a/src/components/dialogs/settings/GraphApiTab.tsx +++ b/src/components/dialogs/settings/GraphApiTab.tsx @@ -9,10 +9,7 @@ import { type GraphAuthStatus, } from "../../../lib/commands"; import { useIntuneStore } from "../../../stores/intune-store"; -<<<<<<< HEAD import { buildGraphRegistryEntries } from "../../../lib/graph-registry"; -======= ->>>>>>> d065202 (feat: add Microsoft Graph API integration for GUID resolution) export function GraphApiTab() { const graphApiEnabled = useUiStore((state) => state.graphApiEnabled); @@ -90,15 +87,7 @@ export function GraphApiTab() { setCachedAppCount(apps.length); if (apps.length > 0) { -<<<<<<< HEAD useIntuneStore.getState().mergeGuidRegistry(buildGraphRegistryEntries(apps)); -======= - const entries: Record = {}; - for (const app of apps) { - entries[app.id] = { name: app.displayName, source: "GraphApi" }; - } - useIntuneStore.getState().mergeGuidRegistry(entries); ->>>>>>> d065202 (feat: add Microsoft Graph API integration for GUID resolution) } } catch (e) { const msg = e instanceof Error ? e.message : String(e); diff --git a/src/stores/ui-store.ts b/src/stores/ui-store.ts index 0a4f3b7c4..41fc57a80 100644 --- a/src/stores/ui-store.ts +++ b/src/stores/ui-store.ts @@ -202,16 +202,9 @@ interface UiState { defaultShowInfoPane: boolean; confirmTabClose: boolean; showUpdateDialog: boolean; -<<<<<<< HEAD -<<<<<<< Updated upstream recentSessions: string[]; -======= graphApiEnabled: boolean; graphApiStatus: "idle" | "connecting" | "connected" | "error"; ->>>>>>> Stashed changes -======= - graphApiEnabled: boolean; ->>>>>>> d065202 (feat: add Microsoft Graph API integration for GUID resolution) setActiveWorkspace: (workspace: WorkspaceId) => void; setCurrentPlatform: (platform: PlatformId) => void; @@ -272,17 +265,10 @@ interface UiState { setCollectionResult: (result: CollectionResult | null) => void; setShowCollectDiagnosticsDialog: (show: boolean) => void; setShowUpdateDialog: (show: boolean) => void; -<<<<<<< HEAD -<<<<<<< Updated upstream addRecentSession: (path: string) => void; clearRecentSessions: () => void; -======= setGraphApiEnabled: (enabled: boolean) => void; setGraphApiStatus: (status: "idle" | "connecting" | "connected" | "error") => void; ->>>>>>> Stashed changes -======= - setGraphApiEnabled: (enabled: boolean) => void; ->>>>>>> d065202 (feat: add Microsoft Graph API integration for GUID resolution) } const DEFAULT_WORKSPACE: WorkspaceId = "log"; @@ -363,16 +349,9 @@ export const useUiStore = create()( collectionResult: null, showCollectDiagnosticsDialog: false, showUpdateDialog: false, -<<<<<<< HEAD -<<<<<<< Updated upstream recentSessions: [], -======= graphApiEnabled: false, graphApiStatus: "idle", ->>>>>>> Stashed changes -======= - graphApiEnabled: false, ->>>>>>> d065202 (feat: add Microsoft Graph API integration for GUID resolution) setCurrentPlatform: (platform) => set({ currentPlatform: platform }), setEnabledWorkspaces: (workspaces) => { @@ -643,21 +622,14 @@ export const useUiStore = create()( setCollectionResult: (result) => set({ collectionResult: result }), setShowCollectDiagnosticsDialog: (show) => set({ showCollectDiagnosticsDialog: show }), setShowUpdateDialog: (show) => set({ showUpdateDialog: show }), -<<<<<<< HEAD -<<<<<<< Updated upstream addRecentSession: (path) => set((state) => { const filtered = state.recentSessions.filter((p) => p !== path); return { recentSessions: [path, ...filtered].slice(0, 5) }; }), clearRecentSessions: () => set({ recentSessions: [] }), -======= setGraphApiEnabled: (enabled) => set({ graphApiEnabled: enabled }), setGraphApiStatus: (status) => set({ graphApiStatus: status }), ->>>>>>> Stashed changes -======= - setGraphApiEnabled: (enabled) => set({ graphApiEnabled: enabled }), ->>>>>>> d065202 (feat: add Microsoft Graph API integration for GUID resolution) }), { name: "cmtraceopen-ui-preferences", From dba1c04b178a3a37f3d1b301661dcc9352d8b906 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 2 Apr 2026 19:14:59 -0400 Subject: [PATCH 28/66] =?UTF-8?q?fix:=20address=20PR=20#82=20review=20?= =?UTF-8?q?=E2=80=94=20ID=20collisions,=20persist=20sessions,=20correlatio?= =?UTF-8?q?n=20refresh,=20diff=20close?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/settings.local.json | 31 ------------------------- .gitignore | 3 +++ src/components/layout/TabStrip.tsx | 7 +++++- src/components/log-view/LogListView.tsx | 9 +++---- src/lib/merge-entries.ts | 5 ++++ src/lib/session-restore.ts | 21 ++++++++++------- src/stores/log-store.ts | 15 ++++++++++-- src/stores/ui-store.ts | 1 + 8 files changed, 45 insertions(+), 47 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 2f996bac7..000000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(grep -rn \"const analyzeDsregcmdSource\\\\|function analyzeDsregcmdSource\" /c/Users/AdamGell/Documents/GitHub/cmtraceopen/src --include=*.ts --include=*.tsx)", - "Bash(find /c/Users/AdamGell/Documents/GitHub/cmtraceopen -type f -name *evidence* -o -name *intune-profile*)", - "Bash(cargo check:*)", - "Bash(npx tsc:*)", - "Bash(cargo test:*)", - "Bash(git pull:*)", - "Bash(git add:*)", - "Bash(git commit:*)", - "Bash(git push:*)", - "Bash(gh run:*)", - "Bash(ls -la /Users/Adam.Gell/repo/cmtraceopen/src-tauri/src/parser/*.rs)", - "WebSearch", - "Bash(gh issue:*)", - "Bash(ls -1 /Users/Adam.Gell/repo/cmtraceopen/*.md)", - "Bash(gh pr:*)", - "Bash(git checkout:*)", - "Bash(git stash:*)", - "Bash(xargs grep:*)", - "Bash(grep -r \"setupact\\\\|setuperr\" /c/GitHub/cmtraceopen/src-tauri --include=*.rs)", - "Bash(powershell -Command \"cd ''C:\\\\GitHub\\\\cmtraceopen\\\\src-tauri''; cargo test panther 2>&1\")", - "Bash(powershell.exe -NoProfile -Command \"Set-Location ''C:\\\\GitHub\\\\cmtraceopen''; git add src-tauri/src/intune/ime_parser.rs src-tauri/src/models/log_entry.rs src-tauri/src/parser/panther.rs src-tauri/src/parser/burn.rs src-tauri/src/parser/cbs.rs src-tauri/src/parser/ccm.rs src-tauri/src/parser/dhcp.rs src-tauri/src/parser/dism.rs src-tauri/src/parser/intune_macos.rs src-tauri/src/parser/plain.rs src-tauri/src/parser/psadt.rs src-tauri/src/parser/reporting_events.rs src-tauri/src/parser/simple.rs src-tauri/src/parser/timestamped.rs src-tauri/src/parser/msi.rs src-tauri/src/commands/deployment.rs src/components/log-view/InfoPane.tsx src/lib/column-config.ts src/types/log.ts; git status --short 2>&1\")", - "WebFetch(domain:github.com)", - "Bash(powershell.exe:*)" - ] - }, - "spinnerTipsEnabled": false, - "outputStyle": "Explanatory" -} diff --git a/.gitignore b/.gitignore index a4a59358f..dc5e1edc7 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ Thumbs.db #logcollection loginventory/ + +# Claude Code local settings +.claude/settings.local.json diff --git a/src/components/layout/TabStrip.tsx b/src/components/layout/TabStrip.tsx index 6d2a7d0f5..a45914845 100644 --- a/src/components/layout/TabStrip.tsx +++ b/src/components/layout/TabStrip.tsx @@ -17,6 +17,7 @@ export function TabStrip() { const sourceOpenMode = useLogStore((s) => s.sourceOpenMode); const mergedTabState = useLogStore((s) => s.mergedTabState); const closeMergedTab = useLogStore((s) => s.closeMergedTab); + const closeDiff = useLogStore((s) => s.closeDiff); const [hoveredTabIndex, setHoveredTabIndex] = useState(null); const [overflowOpen, setOverflowOpen] = useState(false); @@ -145,9 +146,13 @@ export function TabStrip() { closeMergedTab(); return; } + if (sourceOpenMode === "diff" && index === activeTabIndex) { + closeDiff(); + return; + } closeTab(index); }, - [closeTab, sourceOpenMode, activeTabIndex, closeMergedTab] + [closeTab, sourceOpenMode, activeTabIndex, closeMergedTab, closeDiff] ); if (openTabs.length === 0) { diff --git a/src/components/log-view/LogListView.tsx b/src/components/log-view/LogListView.tsx index 85f9b57b6..84d9ec974 100644 --- a/src/components/log-view/LogListView.tsx +++ b/src/components/log-view/LogListView.tsx @@ -39,6 +39,7 @@ export function LogListView() { const findMatchIds = useLogStore((s) => s.findMatchIds); const showDetails = useUiStore((s) => s.showDetails); + const sourceOpenMode = useLogStore((s) => s.sourceOpenMode); const mergedTabState = useLogStore((s) => s.mergedTabState); const correlatedEntries = useLogStore((s) => s.correlatedEntries); @@ -342,7 +343,7 @@ export function LogListView() { ))}
- {mergedTabState && } + {sourceOpenMode === "merged" && mergedTabState && }
{ if (id !== selectedId) { suppressScrollRef.current = true; } selectEntry(id); }} onContextMenu={showContextMenu} onErrorCodeClick={handleErrorCodeClick} - mergeFileColor={mergedTabState?.colorAssignments[entry.filePath] ?? null} - isCorrelated={correlatedIdSet.has(entry.id)} - correlationColor={mergedTabState?.colorAssignments[entry.filePath] ?? null} + mergeFileColor={sourceOpenMode === "merged" ? mergedTabState?.colorAssignments[entry.filePath] ?? null : null} + isCorrelated={sourceOpenMode === "merged" && correlatedIdSet.has(entry.id)} + correlationColor={sourceOpenMode === "merged" ? mergedTabState?.colorAssignments[entry.filePath] ?? null : null} />
); diff --git a/src/lib/merge-entries.ts b/src/lib/merge-entries.ts index c73260e8e..5ef4e7cee 100644 --- a/src/lib/merge-entries.ts +++ b/src/lib/merge-entries.ts @@ -59,6 +59,11 @@ export function mergeEntries( return a.lineNumber - b.lineNumber; }); + // Reassign IDs to be globally unique across merged files + for (let i = 0; i < allTimestamped.length; i++) { + allTimestamped[i] = { ...allTimestamped[i], id: i }; + } + return allTimestamped; } diff --git a/src/lib/session-restore.ts b/src/lib/session-restore.ts index 0c4503c12..84ad1f9e1 100644 --- a/src/lib/session-restore.ts +++ b/src/lib/session-restore.ts @@ -92,15 +92,6 @@ export async function restoreSession(sessionPath: string): Promise[0]); } - // Restore filters - const logStore = useLogStore.getState(); - if (session.filters) { - logStore.setHighlightText(session.filters.highlightText || ""); - logStore.setFindQuery(session.filters.findQuery || ""); - logStore.setFindCaseSensitive(session.filters.findCaseSensitive ?? false); - logStore.setFindUseRegex(session.filters.findUseRegex ?? false); - } - // Add to recent sessions uiStore.addRecentSession(sessionPath); @@ -111,6 +102,18 @@ export async function restoreSession(sessionPath: string): Promise((set, get) => ({ recomputeAndSetMatches(); }, - setCorrelationWindowMs: (ms) => set({ correlationWindowMs: ms }), + setCorrelationWindowMs: (ms) => { + set({ correlationWindowMs: ms }); + setTimeout(() => useLogStore.getState().updateCorrelation(), 0); + }, - setAutoCorrelate: (enabled) => set({ autoCorrelate: enabled }), + setAutoCorrelate: (enabled) => { + set({ autoCorrelate: enabled }); + setTimeout(() => useLogStore.getState().updateCorrelation(), 0); + }, updateCorrelation: () => { const state = useLogStore.getState(); @@ -1068,6 +1074,11 @@ export const useLogStore = create((set, get) => ({ entriesB = filterByTimeRange(entriesB, sourceB.startTime, sourceB.endTime); } + // Reassign IDs to avoid collisions between files + let nextId = 0; + entriesA = entriesA.map((e) => ({ ...e, id: nextId++ })); + entriesB = entriesB.map((e) => ({ ...e, id: nextId++ })); + const { commonKeys, onlyAKeys, onlyBKeys, entryClassification, stats } = classifyEntries(entriesA, entriesB); diff --git a/src/stores/ui-store.ts b/src/stores/ui-store.ts index 41fc57a80..04b05311d 100644 --- a/src/stores/ui-store.ts +++ b/src/stores/ui-store.ts @@ -645,6 +645,7 @@ export const useUiStore = create()( defaultShowInfoPane: state.defaultShowInfoPane, confirmTabClose: state.confirmTabClose, graphApiEnabled: state.graphApiEnabled, + recentSessions: state.recentSessions, }), merge: (persistedState, currentState) => { const raw = persistedState as Partial & { From 2faad79bc6a2d57568a4755289d1ee82d2bf44ca Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 2 Apr 2026 19:29:43 -0400 Subject: [PATCH 29/66] ci: force clean build to resolve stale cache From ddb19cb3266f2ae66445e4199ff79a1418752bd9 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 2 Apr 2026 20:39:32 -0400 Subject: [PATCH 30/66] fix: add missing script_body and parent_app_guid to RFC3339 test The sysmon merge brought a new test (build_timeline_sorts_rfc3339_timestamps) that was missing the two fields added to IntuneEvent in the AppWorkload PR. Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/intune/timeline.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src-tauri/src/intune/timeline.rs b/src-tauri/src/intune/timeline.rs index d665cf57b..827a61e09 100644 --- a/src-tauri/src/intune/timeline.rs +++ b/src-tauri/src/intune/timeline.rs @@ -606,6 +606,8 @@ mod tests { line_number: 2, start_time_epoch: None, end_time_epoch: None, + script_body: None, + parent_app_guid: None, }, IntuneEvent { id: 1, @@ -622,6 +624,8 @@ mod tests { line_number: 1, start_time_epoch: None, end_time_epoch: None, + script_body: None, + parent_app_guid: None, }, ]); From 18a29ec73dd21ecbf9c68964b82556614278ee44 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 2 Apr 2026 20:48:42 -0400 Subject: [PATCH 31/66] security: add explicit permissions to CI workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restrict GITHUB_TOKEN to contents:read across all jobs to satisfy the principle of least privilege. Resolves CodeQL alerts #1–#3. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/cmtrace-ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/cmtrace-ci.yml b/.github/workflows/cmtrace-ci.yml index af68fefd4..f3d1939a1 100644 --- a/.github/workflows/cmtrace-ci.yml +++ b/.github/workflows/cmtrace-ci.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [main] +permissions: + contents: read + env: CARGO_TERM_COLOR: always From e6db4bf5b5f0a2cd8df415c02959a245596634bd Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 2 Apr 2026 20:49:15 -0400 Subject: [PATCH 32/66] fix: gate sysmon module behind feature flag, fix clippy - Add sysmon feature to Cargo.toml, include in full feature set - Gate pub mod sysmon and analyze_sysmon_logs command with #[cfg(feature = "sysmon")] - Gate sysmon commands mod with #[cfg(feature = "sysmon")] - Add required-features = ["sysmon"] to sysmon_parser test - Fix clippy needless_range_loop in event_tracker.rs Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/Cargo.toml | 7 ++++++- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/intune/event_tracker.rs | 6 +++--- src-tauri/src/lib.rs | 2 ++ 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 59290bfd0..b1405c0ca 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -16,7 +16,8 @@ tauri-build = { version = "2", features = [] } [features] default = ["full"] -full = ["collector", "deployment", "dsregcmd", "event-log", "intune-diagnostics", "macos-diag"] +full = ["collector", "deployment", "dsregcmd", "event-log", "intune-diagnostics", "macos-diag", "sysmon"] +sysmon = ["dep:evtx"] event-log = ["dep:evtx"] collector = [] deployment = [] @@ -70,6 +71,10 @@ criterion = "0.5" tempfile = "3" plist = "1" +[[test]] +name = "sysmon_parser" +required-features = ["sysmon"] + [[bench]] name = "intune_pipeline" harness = false diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 5a4d705a0..a990a313d 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -25,5 +25,6 @@ pub mod macos_diag; pub mod parsing; pub mod registry_ops; pub mod reveal; +#[cfg(feature = "sysmon")] pub mod sysmon; pub mod system_preferences; diff --git a/src-tauri/src/intune/event_tracker.rs b/src-tauri/src/intune/event_tracker.rs index 98050c46a..741306bbe 100644 --- a/src-tauri/src/intune/event_tracker.rs +++ b/src-tauri/src/intune/event_tracker.rs @@ -782,9 +782,9 @@ fn collect_appworkload_context(lines: &[ImeLine], index: usize, guid: Option<&st let lookaround_start = index.saturating_sub(APPWORKLOAD_CONTEXT_LOOKAROUND); let lookaround_end = (index + APPWORKLOAD_CONTEXT_LOOKAROUND).min(lines.len().saturating_sub(1)); - for selected_index in lookaround_start..=lookaround_end { - if contains_ascii_case_insensitive(&lines[selected_index].message, guid) { - selected.insert(selected_index); + for (i, line) in lines[lookaround_start..=lookaround_end].iter().enumerate() { + if contains_ascii_case_insensitive(&line.message, guid) { + selected.insert(lookaround_start + i); } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 32b4403bd..ecb68cd87 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -16,6 +16,7 @@ pub mod macos_diag; mod menu; mod models; pub mod parser; +#[cfg(feature = "sysmon")] pub mod sysmon; mod state; mod watcher; @@ -140,6 +141,7 @@ pub fn run() { commands::graph_api::graph_resolve_guids, #[cfg(target_os = "windows")] commands::graph_api::graph_fetch_all_apps, + #[cfg(feature = "sysmon")] commands::sysmon::analyze_sysmon_logs, ]) .run(tauri::generate_context!()) From 0cb803eb7cc19424032d629aa9c2bee5213aa66d Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 2 Apr 2026 21:35:17 -0400 Subject: [PATCH 33/66] fix: resolve merge conflicts in graph_api.rs Unresolved conflict markers from d065202 merge caused unclosed delimiter errors on Windows CI builds. Kept the refactored fetch_paginated helper that supports multiple Intune endpoints. Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/graph_api.rs | 35 ----------------------------------- 1 file changed, 35 deletions(-) diff --git a/src-tauri/src/graph_api.rs b/src-tauri/src/graph_api.rs index dc54098d5..e92efccb8 100644 --- a/src-tauri/src/graph_api.rs +++ b/src-tauri/src/graph_api.rs @@ -389,17 +389,12 @@ pub fn resolve_guids( }) } -<<<<<<< HEAD /// Fetch all Intune apps, scripts, and remediations for pre-populating the cache. -======= -/// Fetch all Intune apps at once (for pre-populating the cache). ->>>>>>> d065202 (feat: add Microsoft Graph API integration for GUID resolution) pub fn fetch_all_apps(state: &GraphAuthState) -> Result, AppError> { let token = state .get_valid_token() .ok_or_else(|| AppError::Internal("Not authenticated. Please sign in first.".into()))?; -<<<<<<< HEAD let mut all: Vec = Vec::new(); // Win32/LOB/Store apps @@ -458,32 +453,17 @@ fn fetch_paginated( let agent = make_agent(); let mut items: Vec = Vec::new(); let mut next_url: Option = Some(initial_url.to_string()); -======= - let mut all_apps: Vec = Vec::new(); - let mut next_url: Option = Some(format!( - "{GRAPH_BETA_BASE}/deviceAppManagement/mobileApps?$select=id,displayName,publisher" - )); - - let agent = make_agent(); ->>>>>>> d065202 (feat: add Microsoft Graph API integration for GUID resolution) while let Some(url) = next_url.take() { let response = agent .get(&url) -<<<<<<< HEAD .set("Authorization", &format!("Bearer {token}")) -======= - .set("Authorization", &format!("Bearer {}", token.token)) ->>>>>>> d065202 (feat: add Microsoft Graph API integration for GUID resolution) .set("ConsistencyLevel", "eventual") .call() .map_err(|e| { if let ureq::Error::Status(code, resp) = e { let body = resp.into_string().unwrap_or_default(); -<<<<<<< HEAD log::warn!("Graph API HTTP {code} for {url}: {body}"); -======= ->>>>>>> d065202 (feat: add Microsoft Graph API integration for GUID resolution) AppError::Internal(format!("Graph API HTTP {code}: {body}")) } else { AppError::Internal(format!("Graph API request failed: {e}")) @@ -494,17 +474,12 @@ fn fetch_paginated( if let Some(value) = body.get("value").and_then(|v| v.as_array()) { for item in value { -<<<<<<< HEAD if let Some(mut app) = parse_app_json(item) { // Apply default type if the item doesn't have one if app.odata_type.is_none() { app.odata_type = default_type.map(String::from); } items.push(app); -======= - if let Some(app) = parse_app_json(item) { - all_apps.push(app); ->>>>>>> d065202 (feat: add Microsoft Graph API integration for GUID resolution) } } } @@ -515,17 +490,7 @@ fn fetch_paginated( .map(String::from); } -<<<<<<< HEAD Ok(items) -======= - let cache_map: HashMap = all_apps - .iter() - .map(|a| (a.id.clone(), a.clone())) - .collect(); - state.cache_apps(&cache_map); - - Ok(all_apps) ->>>>>>> d065202 (feat: add Microsoft Graph API integration for GUID resolution) } // ── Internal helpers ──────────────────────────────────────────────────────── From 3167de8af2e1a15e2f460ed0642defc2079fd3bb Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 2 Apr 2026 19:30:53 -0400 Subject: [PATCH 34/66] fix: resolve merge conflicts from upstream session/diff features Merge Graph API integration (graphApiEnabled, graphApiStatus) with upstream session save/restore (recentSessions) and diff features. Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/graph_api.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src-tauri/src/graph_api.rs b/src-tauri/src/graph_api.rs index e92efccb8..fb8ea1593 100644 --- a/src-tauri/src/graph_api.rs +++ b/src-tauri/src/graph_api.rs @@ -475,7 +475,6 @@ fn fetch_paginated( if let Some(value) = body.get("value").and_then(|v| v.as_array()) { for item in value { if let Some(mut app) = parse_app_json(item) { - // Apply default type if the item doesn't have one if app.odata_type.is_none() { app.odata_type = default_type.map(String::from); } From 5be5c7692d4a7570388eaf49276dd8741616eb41 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 2 Apr 2026 21:51:14 -0400 Subject: [PATCH 35/66] feat: implement live Windows Event Log queries Implement EvtOpenChannelEnum, EvtQuery, EvtNext, EvtRender, and EvtFormatMessage via Win32 API to query live event log channels. Auto-loads Application, System, Security, and Setup on "This Computer". - Raw FFI for channel enumeration (fixes NULL handle issue) - Buffer retry for EvtRender with both Win32 and HRESULT error codes - Rendered messages via EvtFormatMessage with publisher metadata cache - XML string parsing for EventID, Level, Provider, TimeCreated, EventData - Progressive channel loading with per-channel error handling - Channel picker: Event Viewer-style tree (Windows Logs / App & Services) - Resizable channel sidebar with drag handle - Resizable detail pane with drag handle - Arrow key navigation in timeline (Up/Down/Home/End) - Progress bar during channel loading - Status bar shows "Event Log" with channel/event counts - Load button for querying additional checked channels - Error messages surfaced to frontend via errorMessages field Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/event_log/commands.rs | 10 + src-tauri/src/event_log/live.rs | 495 ++++++++++++++++- src-tauri/src/event_log/models.rs | 1 + src-tauri/src/event_log/parser.rs | 1 + .../event-log-workspace/ChannelPicker.tsx | 517 +++++++++++++++--- .../event-log-workspace/EventLogWorkspace.tsx | 86 ++- .../event-log-workspace/EvtxDetailPane.tsx | 4 +- .../event-log-workspace/EvtxTimeline.tsx | 36 +- src/components/layout/AppShell.tsx | 86 +-- src/components/layout/StatusBar.tsx | 49 +- src/stores/evtx-store.ts | 105 +++- src/types/event-log-workspace.ts | 1 + 12 files changed, 1227 insertions(+), 164 deletions(-) diff --git a/src-tauri/src/event_log/commands.rs b/src-tauri/src/event_log/commands.rs index f5877c7d7..4273d477b 100644 --- a/src-tauri/src/event_log/commands.rs +++ b/src-tauri/src/event_log/commands.rs @@ -33,6 +33,7 @@ pub async fn evtx_query_channels( let mut all_records = Vec::new(); let mut channel_infos = Vec::new(); let mut parse_errors = 0u32; + let mut error_messages = Vec::new(); for channel in &channels { match super::live::query_channel(channel, max_events) { @@ -46,6 +47,13 @@ pub async fn evtx_query_channels( } Err(e) => { log::warn!("event=evtx_channel_query_error channel=\"{}\" error=\"{}\"", channel, e); + error_messages.push(format!("{}: {}", channel, e)); + // Still include channel in results with 0 events so frontend knows it was attempted + channel_infos.push(super::models::EvtxChannelInfo { + name: channel.clone(), + event_count: 0, + source_type: super::models::ChannelSourceType::Live, + }); parse_errors += 1; } } @@ -63,6 +71,7 @@ pub async fn evtx_query_channels( channels: channel_infos, total_records, parse_errors, + error_messages, }) }) .await @@ -76,6 +85,7 @@ pub async fn evtx_query_channels( channels: Vec::new(), total_records: 0, parse_errors: 0, + error_messages: vec![], }) } } diff --git a/src-tauri/src/event_log/live.rs b/src-tauri/src/event_log/live.rs index 4bf758891..552e6b42c 100644 --- a/src-tauri/src/event_log/live.rs +++ b/src-tauri/src/event_log/live.rs @@ -1,22 +1,493 @@ -use super::models::{EvtxChannelInfo, EvtxRecord}; +use std::collections::HashMap; +use std::ffi::c_void; +use std::sync::OnceLock; -/// Enumerate available Windows Event Log channels. -/// -/// Uses `wevtapi.dll` functions `EvtOpenChannelEnum` and `EvtNextChannelPath` -/// to discover all registered channels on the system. +use regex::Regex; + +use super::models::{ChannelSourceType, EvtxChannelInfo, EvtxField, EvtxLevel, EvtxRecord}; + +#[cfg(target_os = "windows")] +use windows::core::{Error, HSTRING, PCWSTR}; +#[cfg(target_os = "windows")] +use windows::Win32::System::EventLog::{ + EVT_HANDLE, EvtClose, EvtFormatMessage, EvtFormatMessageEvent, EvtNext, + EvtOpenPublisherMetadata, EvtQuery, EvtQueryChannelPath, EvtQueryReverseDirection, + EvtRender, EvtRenderEventXml, +}; + +// ── RAII handle wrapper ───────────────────────────────────────────────────── + +#[cfg(target_os = "windows")] +struct OwnedEvtHandle(EVT_HANDLE); + +#[cfg(target_os = "windows")] +impl OwnedEvtHandle { + fn new(handle: EVT_HANDLE) -> Self { + Self(handle) + } + fn raw(&self) -> EVT_HANDLE { + self.0 + } +} + +#[cfg(target_os = "windows")] +impl Drop for OwnedEvtHandle { + fn drop(&mut self) { + if !self.0.is_invalid() { + unsafe { + let _ = EvtClose(self.0); + } + } + } +} + +// ── Public API ────────────────────────────────────────────────────────────── + +/// Enumerate all registered Windows Event Log channels on the local system. +#[cfg(target_os = "windows")] pub fn enumerate_channels() -> Result, String> { - // Implementation uses wevtapi.dll — Windows only - // EvtOpenChannelEnum, EvtNextChannelPath - Err("Live event log queries are not yet implemented".to_string()) + // Use raw wevtapi.dll FFI — the high-level windows crate wrapper may not + // pass NULL correctly for the local-computer session handle. + #[link(name = "wevtapi")] + extern "system" { + fn EvtOpenChannelEnum(session: isize, flags: u32) -> isize; + fn EvtNextChannelPath( + channelenum: isize, + channelpathbuffersize: u32, + channelpathbuffer: *mut u16, + channelpathbufferused: *mut u32, + ) -> i32; + } + + let raw_handle = unsafe { EvtOpenChannelEnum(0, 0) }; + if raw_handle == 0 { + return Err("EvtOpenChannelEnum returned null handle".to_string()); + } + + let mut channels = Vec::new(); + let mut buffer = vec![0u16; 512]; + + loop { + let mut used = 0u32; + let ok = unsafe { + EvtNextChannelPath( + raw_handle, + buffer.len() as u32, + buffer.as_mut_ptr(), + &mut used, + ) + }; + + if ok != 0 { + let len = used.saturating_sub(1) as usize; + let name = String::from_utf16_lossy(&buffer[..len]); + channels.push(EvtxChannelInfo { + name, + event_count: 0, + source_type: ChannelSourceType::Live, + }); + } else { + let err = std::io::Error::last_os_error().raw_os_error().unwrap_or(0) as u32; + if err == 259 { + // ERROR_NO_MORE_ITEMS — done + break; + } else if err == 122 { + // ERROR_INSUFFICIENT_BUFFER — resize and retry + buffer.resize(used as usize, 0); + } else { + unsafe { let _ = EvtClose(EVT_HANDLE(raw_handle)); } + return Err(format!("EvtNextChannelPath failed: error {err}")); + } + } + } + + unsafe { let _ = EvtClose(EVT_HANDLE(raw_handle)); } + + channels.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + Ok(channels) } -/// Query a specific Windows Event Log channel for recent events. +/// Query events from a live Windows Event Log channel. /// -/// Uses `wevtapi.dll` functions `EvtQuery` and `EvtRender` to read -/// events from the specified channel. +/// Returns newest events first, capped at `max_events` (default 1000). +#[cfg(target_os = "windows")] +pub fn query_channel( + channel: &str, + max_events: Option, +) -> Result, String> { + let limit = max_events.unwrap_or(1000) as usize; + let channel_hstring = HSTRING::from(channel); + let query_string = HSTRING::from("*"); + + let query_handle = unsafe { + EvtQuery( + None, + &channel_hstring, + &query_string, + EvtQueryChannelPath.0 | EvtQueryReverseDirection.0, + ) + } + .map_err(|e| format_error(&format!("EvtQuery({channel})"), &e))?; + let query_handle = OwnedEvtHandle::new(query_handle); + log::info!("event=evtx_live_query channel=\"{channel}\" limit={limit}"); + + let mut records = Vec::new(); + let mut publisher_metadata = HashMap::>::new(); + + while records.len() < limit { + let mut raw_handles = [0isize; 16]; + let mut returned = 0u32; + + match unsafe { EvtNext(query_handle.raw(), &mut raw_handles, 0, 0, &mut returned) } { + Ok(()) => {} + Err(e) if is_no_more_items(&e) => break, + Err(e) => return Err(format_error("EvtNext", &e)), + } + + if returned == 0 { + break; + } + + for raw_handle in raw_handles.into_iter().take(returned as usize) { + if records.len() >= limit { + // Close remaining handles we won't use + unsafe { let _ = EvtClose(EVT_HANDLE(raw_handle)); } + continue; + } + + let event_handle = OwnedEvtHandle::new(EVT_HANDLE(raw_handle)); + let xml = render_event_xml(event_handle.raw()) + .map_err(|e| format_error("EvtRender", &e))?; + + let provider_name = extract_xml_attr(&xml, "Provider", "Name"); + + // Try to get a formatted message via EvtFormatMessage + let rendered_message = provider_name.as_deref().and_then(|provider| { + format_event_message(event_handle.raw(), provider, &mut publisher_metadata) + .ok() + .flatten() + }); + + if let Some(record) = parse_xml_to_record(&xml, channel, rendered_message.as_deref()) + { + records.push(record); + } else if records.is_empty() { + // Log the first unparseable XML so we can debug the format + log::warn!("event=evtx_parse_failed channel=\"{channel}\" xml_prefix=\"{}\"", + &xml[..xml.len().min(300)]); + } + } + } + + log::info!("event=evtx_live_query_done channel=\"{channel}\" records={}", records.len()); + Ok(records) +} + +// ── Non-Windows stubs ─────────────────────────────────────────────────────── + +#[cfg(not(target_os = "windows"))] +pub fn enumerate_channels() -> Result, String> { + Err("Live event log queries are only available on Windows.".to_string()) +} + +#[cfg(not(target_os = "windows"))] pub fn query_channel( _channel: &str, _max_events: Option, ) -> Result, String> { - Err("Live event log queries are not yet implemented".to_string()) + Err("Live event log queries are only available on Windows.".to_string()) +} + +// ── Win32 helpers (Windows only) ──────────────────────────────────────────── + +#[cfg(target_os = "windows")] +fn render_event_xml(event_handle: EVT_HANDLE) -> Result { + let mut buffer_used = 0u32; + let mut property_count = 0u32; + let mut buffer = vec![0u16; 4096]; + + loop { + match unsafe { + EvtRender( + None, + event_handle, + EvtRenderEventXml.0, + (buffer.len() * std::mem::size_of::()) as u32, + Some(buffer.as_mut_ptr() as *mut c_void), + &mut buffer_used, + &mut property_count, + ) + } { + Ok(()) => { + let utf16_len = + (buffer_used as usize / std::mem::size_of::()).saturating_sub(1); + return Ok(String::from_utf16_lossy(&buffer[..utf16_len])); + } + Err(e) => { + let code = e.code().0 as u32; + if code == 122 || code == 234 || code == 0x8007007A || code == 0x800700EA { + // Insufficient buffer or more data — resize and retry + let next_len = + (buffer_used as usize / std::mem::size_of::()).max(buffer.len() * 2); + buffer.resize(next_len, 0); + } else { + return Err(e); + } + } + } + } +} + +#[cfg(target_os = "windows")] +fn format_event_message( + event_handle: EVT_HANDLE, + provider_name: &str, + cache: &mut HashMap>, +) -> Result, Error> { + if !cache.contains_key(provider_name) { + let provider = HSTRING::from(provider_name); + let metadata = + unsafe { EvtOpenPublisherMetadata(EVT_HANDLE::default(), &provider, PCWSTR::null(), 0, 0) } + .ok() + .map(OwnedEvtHandle::new); + cache.insert(provider_name.to_string(), metadata); + } + + let Some(Some(metadata)) = cache.get(provider_name) else { + return Ok(None); + }; + + let mut buffer_used = 0u32; + let mut buffer = vec![0u16; 2048]; + + loop { + match unsafe { + EvtFormatMessage( + metadata.raw(), + event_handle, + 0, + None, + EvtFormatMessageEvent.0, + Some(buffer.as_mut_slice()), + &mut buffer_used, + ) + } { + Ok(()) => { + let utf16_len = buffer_used.saturating_sub(1) as usize; + let rendered = String::from_utf16_lossy(&buffer[..utf16_len]) + .trim() + .to_string(); + return Ok((!rendered.is_empty()).then_some(rendered)); + } + Err(e) if is_insufficient_buffer(&e) => { + buffer.resize(buffer_used.max(buffer.len() as u32 * 2) as usize, 0); + } + Err(e) if is_not_found(&e) || is_message_not_found(&e) => return Ok(None), + Err(e) => return Err(e), + } + } +} + +// ── XML parsing helpers ───────────────────────────────────────────────────── + +/// Parse rendered event XML into an EvtxRecord. +fn parse_xml_to_record( + xml: &str, + channel: &str, + rendered_message: Option<&str>, +) -> Option { + let event_id_str = extract_xml_text(xml, "EventID").unwrap_or_default(); + let event_id: u32 = event_id_str.parse().unwrap_or(0); + + let level_str = extract_xml_text(xml, "Level").unwrap_or_default(); + let level_val: u8 = level_str.parse().unwrap_or(4); + let level = EvtxLevel::from_level_value(level_val); + + let provider = extract_xml_attr(xml, "Provider", "Name").unwrap_or_default(); + let computer = extract_xml_text(xml, "Computer").unwrap_or_default(); + let event_record_id: u64 = extract_xml_text(xml, "EventRecordID") + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + + let timestamp = extract_xml_attr(xml, "TimeCreated", "SystemTime").unwrap_or_default(); + let timestamp_epoch = parse_timestamp_to_epoch_ms(×tamp); + + let event_data = extract_xml_event_data(xml); + + // Use rendered message if available, otherwise build summary from EventData + let message = rendered_message + .map(|s| s.to_string()) + .unwrap_or_else(|| build_event_data_summary(&event_data)); + + Some(EvtxRecord { + id: 0, // assigned by commands.rs after sorting + event_record_id, + timestamp, + timestamp_epoch, + provider, + channel: channel.to_string(), + event_id, + level, + computer, + message, + event_data, + raw_xml: xml.to_string(), + source_label: "Live".to_string(), + }) +} + +/// Extract an attribute value from an XML tag. +/// e.g. `extract_xml_attr(xml, "Provider", "Name")` for `` +fn extract_xml_attr(xml: &str, tag: &str, attr: &str) -> Option { + let tag_start = xml.find(&format!("<{tag}"))?; + let tag_end = xml[tag_start..].find('>')? + tag_start; + let tag_content = &xml[tag_start..=tag_end]; + + // Try both quote styles: Name='value' and Name="value" + for quote in ['"', '\''] { + let pattern = format!("{attr}={quote}"); + if let Some(attr_start) = tag_content.find(&pattern) { + let value_start = attr_start + pattern.len(); + if let Some(value_end) = tag_content[value_start..].find(quote) { + return Some(tag_content[value_start..value_start + value_end].to_string()); + } + } + } + None +} + +/// Extract text content between XML tags. +/// e.g. `extract_xml_text(xml, "EventID")` for `123` +fn extract_xml_text(xml: &str, tag: &str) -> Option { + let open = format!("<{tag}>"); + let open_with_attrs = format!("<{tag} "); + let close = format!(""); + + // Try simple value + if let Some(start) = xml.find(&open) { + let value_start = start + open.len(); + if let Some(end) = xml[value_start..].find(&close) { + return Some(xml[value_start..value_start + end].trim().to_string()); + } + } + + // Try value + if let Some(start) = xml.find(&open_with_attrs) { + let after_tag = &xml[start..]; + let tag_close = after_tag.find('>')?; + let value_start = start + tag_close + 1; + if let Some(end) = xml[value_start..].find(&close) { + return Some(xml[value_start..value_start + end].trim().to_string()); + } + } + + None +} + +/// Extract `value` pairs from EventData section. +fn extract_xml_event_data(xml: &str) -> Vec { + fn data_name_re() -> &'static Regex { + static CELL: OnceLock = OnceLock::new(); + CELL.get_or_init(|| { + Regex::new(r#"(.*?)"#).expect("data regex") + }) + } + + data_name_re() + .captures_iter(xml) + .map(|cap| EvtxField { + name: cap[1].to_string(), + value: cap[2].to_string(), + }) + .collect() +} + +/// Build a summary message from EventData fields (fallback when EvtFormatMessage unavailable). +fn build_event_data_summary(fields: &[EvtxField]) -> String { + fields + .iter() + .take(5) + .map(|f| { + let val = if f.value.len() > 80 { + format!("{}...", &f.value[..77]) + } else { + f.value.clone() + }; + format!("{}: {val}", f.name) + }) + .collect::>() + .join("; ") +} + +/// Parse an ISO 8601 timestamp to epoch milliseconds. +fn parse_timestamp_to_epoch_ms(timestamp: &str) -> i64 { + chrono::DateTime::parse_from_rfc3339(timestamp) + .or_else(|_| { + // Windows timestamps may omit timezone, assume UTC + chrono::NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%dT%H:%M:%S%.f") + .map(|naive| naive.and_utc().fixed_offset()) + }) + .map(|dt| dt.timestamp_millis()) + .unwrap_or(0) +} + +// ── Error helpers ─────────────────────────────────────────────────────────── + +#[cfg(target_os = "windows")] +fn format_error(context: &str, error: &Error) -> String { + let msg = error.message(); + if msg.trim().is_empty() { + format!("{context}: Windows error 0x{:08x}", error.code().0 as u32) + } else { + format!("{context}: {}", msg.trim()) + } +} + +#[cfg(target_os = "windows")] +fn is_insufficient_buffer(error: &Error) -> bool { + error.code().0 as u32 == 122 +} + +#[cfg(target_os = "windows")] +fn is_more_data(error: &Error) -> bool { + error.code().0 as u32 == 234 +} + +#[cfg(target_os = "windows")] +fn is_no_more_items(error: &Error) -> bool { + error.code().0 as u32 == 259 +} + +#[cfg(target_os = "windows")] +fn is_not_found(error: &Error) -> bool { + error.code().0 as u32 == 1168 +} + +#[cfg(target_os = "windows")] +fn is_message_not_found(error: &Error) -> bool { + error.code().0 as u32 == 15027 +} + +#[cfg(test)] +#[cfg(target_os = "windows")] +mod tests { + use super::*; + + #[test] + fn live_query_application() { + let channels = enumerate_channels().expect("enumerate should work"); + println!("Total channels: {}", channels.len()); + let has_app = channels.iter().any(|c| c.name == "Application"); + println!("Has Application channel: {has_app}"); + + let records = query_channel("Application", Some(3)).expect("query should work"); + println!("Application records: {}", records.len()); + for (i, r) in records.iter().enumerate() { + println!("--- Record {i} ---"); + println!(" EventID: {}, Provider: {}, Level: {:?}", r.event_id, r.provider, r.level); + println!(" Timestamp: {}", r.timestamp); + println!(" Message: {}", &r.message[..r.message.len().min(100)]); + println!(" XML prefix: {}", &r.raw_xml[..r.raw_xml.len().min(300)]); + } + } } diff --git a/src-tauri/src/event_log/models.rs b/src-tauri/src/event_log/models.rs index 43af4f151..494aca656 100644 --- a/src-tauri/src/event_log/models.rs +++ b/src-tauri/src/event_log/models.rs @@ -68,4 +68,5 @@ pub struct EvtxParseResult { pub channels: Vec, pub total_records: u64, pub parse_errors: u32, + pub error_messages: Vec, } diff --git a/src-tauri/src/event_log/parser.rs b/src-tauri/src/event_log/parser.rs index 466731d3b..17f6447e2 100644 --- a/src-tauri/src/event_log/parser.rs +++ b/src-tauri/src/event_log/parser.rs @@ -78,6 +78,7 @@ pub fn parse_evtx_files(paths: &[String]) -> Result { channels, total_records, parse_errors, + error_messages: vec![], }) } diff --git a/src/components/event-log-workspace/ChannelPicker.tsx b/src/components/event-log-workspace/ChannelPicker.tsx index 71aa587c0..e55fceb56 100644 --- a/src/components/event-log-workspace/ChannelPicker.tsx +++ b/src/components/event-log-workspace/ChannelPicker.tsx @@ -1,6 +1,97 @@ -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState, useEffect } from "react"; import { Button, Checkbox, Input, tokens } from "@fluentui/react-components"; import { useEvtxStore } from "../../stores/evtx-store"; +import type { EvtxChannelInfo } from "../../types/event-log-workspace"; + +const WINDOWS_LOGS = new Set(["Application", "Security", "Setup", "System", "ForwardedEvents"]); +const WINDOWS_LOGS_ORDER = ["Application", "Security", "System", "Setup", "ForwardedEvents"]; + +interface ChannelGroup { + key: string; + label: string; + channels: EvtxChannelInfo[]; +} + +interface ChannelSection { + label: string; + groups: ChannelGroup[]; + defaultExpanded: boolean; +} + +function buildSections(channels: EvtxChannelInfo[]): ChannelSection[] { + const windowsLogs: EvtxChannelInfo[] = []; + const serviceChannels: EvtxChannelInfo[] = []; + + for (const ch of channels) { + if (WINDOWS_LOGS.has(ch.name)) { + windowsLogs.push(ch); + } else { + serviceChannels.push(ch); + } + } + + windowsLogs.sort( + (a, b) => WINDOWS_LOGS_ORDER.indexOf(a.name) - WINDOWS_LOGS_ORDER.indexOf(b.name) + ); + + // Group service channels by provider prefix (before the "/") + const groupMap = new Map(); + const standalone: EvtxChannelInfo[] = []; + + for (const ch of serviceChannels) { + const slashIdx = ch.name.indexOf("/"); + if (slashIdx > 0) { + const prefix = ch.name.slice(0, slashIdx); + const existing = groupMap.get(prefix) ?? []; + existing.push(ch); + groupMap.set(prefix, existing); + } else { + standalone.push(ch); + } + } + + // Build groups: standalone channels become single-item groups + const serviceGroups: ChannelGroup[] = []; + + for (const ch of standalone) { + serviceGroups.push({ key: ch.name, label: ch.name, channels: [ch] }); + } + + for (const [prefix, chs] of groupMap) { + chs.sort((a, b) => a.name.localeCompare(b.name)); + serviceGroups.push({ key: prefix, label: prefix, channels: chs }); + } + + serviceGroups.sort((a, b) => a.label.localeCompare(b.label)); + + const sections: ChannelSection[] = []; + + if (windowsLogs.length > 0) { + sections.push({ + label: "Windows Logs", + groups: windowsLogs.map((ch) => ({ + key: ch.name, + label: ch.name, + channels: [ch], + })), + defaultExpanded: true, + }); + } + + if (serviceGroups.length > 0) { + sections.push({ + label: "Applications and Services Logs", + groups: serviceGroups, + defaultExpanded: false, + }); + } + + return sections; +} + +const MIN_SIDEBAR_WIDTH = 180; +const MAX_SIDEBAR_WIDTH = 500; +const DEFAULT_SIDEBAR_WIDTH = 280; export function ChannelPicker() { const channels = useEvtxStore((s) => s.channels); @@ -8,7 +99,39 @@ export function ChannelPicker() { const toggleChannel = useEvtxStore((s) => s.toggleChannel); const selectAllChannels = useEvtxStore((s) => s.selectAllChannels); const deselectAllChannels = useEvtxStore((s) => s.deselectAllChannels); + const sourceMode = useEvtxStore((s) => s.sourceMode); + const loadedChannels = useEvtxStore((s) => s.loadedChannels); + const loadSelectedChannels = useEvtxStore((s) => s.loadSelectedChannels); + const isLoading = useEvtxStore((s) => s.isLoading); + const [search, setSearch] = useState(""); + const [collapsedSections, setCollapsedSections] = useState>(new Set()); + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_SIDEBAR_WIDTH); + const resizeRef = useRef<{ startX: number; startWidth: number } | null>(null); + + useEffect(() => { + const onMouseMove = (e: MouseEvent) => { + if (!resizeRef.current) return; + const delta = e.clientX - resizeRef.current.startX; + setSidebarWidth( + Math.max(MIN_SIDEBAR_WIDTH, Math.min(resizeRef.current.startWidth + delta, MAX_SIDEBAR_WIDTH)) + ); + }; + const onMouseUp = () => { + if (resizeRef.current) { + resizeRef.current = null; + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + } + }; + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + return () => { + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + }; + }, []); const filteredChannels = useMemo(() => { if (!search.trim()) return channels; @@ -16,108 +139,334 @@ export function ChannelPicker() { return channels.filter((c) => c.name.toLowerCase().includes(lower)); }, [channels, search]); + const sections = useMemo(() => buildSections(filteredChannels), [filteredChannels]); + + // Compute inline — Set objects in useMemo deps don't trigger reliably + const unloadedSelectedCount = + sourceMode !== "live" + ? 0 + : [...selectedChannels].filter((ch) => !loadedChannels.has(ch)).length; + + const toggleSection = (label: string) => { + setCollapsedSections((prev) => { + const next = new Set(prev); + if (next.has(label)) next.delete(label); + else next.add(label); + return next; + }); + }; + + const toggleGroup = (key: string) => { + setCollapsedGroups((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + const isSectionExpanded = (section: ChannelSection) => + section.defaultExpanded ? !collapsedSections.has(section.label) : collapsedSections.has(section.label); + return ( -
+
+ {/* Header */}
- Channels ({selectedChannels.size}/{channels.length}) -
- setSearch(data.value)} - placeholder="Filter channels..." - size="small" - style={{ width: "100%" }} - /> -
- - -
-
- -
- {filteredChannels.map((channel) => (
- toggleChannel(channel.name)} - label={ - - {channel.name} - - ({channel.eventCount}) - - - } - /> + Channels ({selectedChannels.size}/{channels.length})
- ))} - {filteredChannels.length === 0 && ( -
setSearch(data.value)} + placeholder="Filter channels..." + size="small" + style={{ width: "100%" }} + /> +
+ + +
+ {sourceMode === "live" && unloadedSelectedCount > 0 && ( + + )} +
+ + {/* Tree */} +
+ {search.trim() ? ( + // Flat list when searching +
+ {filteredChannels.map((ch) => ( + toggleChannel(ch.name)} + indent={0} + /> + ))} +
+ ) : ( + sections.map((section) => { + const expanded = isSectionExpanded(section); + return ( +
+ toggleSection(section.label)} + indent={0} + count={section.groups.reduce((n, g) => n + g.channels.length, 0)} + bold + /> + {expanded && + section.groups.map((group) => { + if (group.channels.length === 1) { + // Single channel — render inline, no subfolder + const ch = group.channels[0]; + const displayName = + ch.name.includes("/") ? ch.name.split("/").pop()! : ch.name; + return ( + toggleChannel(ch.name)} + indent={1} + /> + ); + } + + // Multi-channel group — render as subfolder + const groupExpanded = !collapsedGroups.has(group.key); + return ( +
+ toggleGroup(group.key)} + indent={1} + count={group.channels.length} + /> + {groupExpanded && + group.channels.map((ch) => { + const subName = ch.name.split("/").pop() ?? ch.name; + return ( + toggleChannel(ch.name)} + indent={2} + /> + ); + })} +
+ ); + })} +
+ ); + }) + )} + {filteredChannels.length === 0 && ( +
+ No channels match filter +
+ )} +
+
+ + {/* Resize handle */} +
{ + e.preventDefault(); + resizeRef.current = { startX: e.clientX, startWidth: sidebarWidth }; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }} + /> +
+ ); +} + +// ── Subcomponents ───────────────────────────────────────────────────────── + +function TreeToggle({ + label, + expanded, + onToggle, + indent, + count, + bold, +}: { + label: string; + expanded: boolean; + onToggle: () => void; + indent: number; + count: number; + bold?: boolean; +}) { + return ( + + ); +} + +function ChannelLeaf({ + channel, + displayName, + selected, + loaded, + onToggle, + indent, +}: { + channel: EvtxChannelInfo; + displayName?: string; + selected: boolean; + loaded: boolean; + onToggle: () => void; + indent: number; +}) { + return ( +
+ - No channels match filter -
- )} -
+ {displayName ?? channel.name} + {loaded && channel.eventCount > 0 && ( + + ({channel.eventCount}) + + )} + + } + />
); } diff --git a/src/components/event-log-workspace/EventLogWorkspace.tsx b/src/components/event-log-workspace/EventLogWorkspace.tsx index bbea186c4..75e9c861f 100644 --- a/src/components/event-log-workspace/EventLogWorkspace.tsx +++ b/src/components/event-log-workspace/EventLogWorkspace.tsx @@ -1,4 +1,5 @@ -import { Spinner, tokens } from "@fluentui/react-components"; +import { useRef, useState, useEffect } from "react"; +import { ProgressBar, Spinner, tokens } from "@fluentui/react-components"; import { useEvtxStore } from "../../stores/evtx-store"; import { SourcePicker } from "./SourcePicker"; import { ChannelPicker } from "./ChannelPicker"; @@ -6,6 +7,10 @@ import { EvtxFilterBar } from "./EvtxFilterBar"; import { EvtxTimeline } from "./EvtxTimeline"; import { EvtxDetailPane } from "./EvtxDetailPane"; +const DEFAULT_DETAIL_HEIGHT = 300; +const MIN_DETAIL_HEIGHT = 100; +const MAX_DETAIL_RATIO = 0.7; + export function EventLogWorkspace() { const sourceMode = useEvtxStore((s) => s.sourceMode); const isLoading = useEvtxStore((s) => s.isLoading); @@ -13,15 +18,46 @@ export function EventLogWorkspace() { const channels = useEvtxStore((s) => s.channels); const selectedRecordId = useEvtxStore((s) => s.selectedRecordId); + const [detailHeight, setDetailHeight] = useState(DEFAULT_DETAIL_HEIGHT); + const resizeRef = useRef<{ startY: number; startHeight: number } | null>(null); + + useEffect(() => { + const onMouseMove = (e: MouseEvent) => { + if (!resizeRef.current) return; + const delta = resizeRef.current.startY - e.clientY; + const newHeight = Math.max( + MIN_DETAIL_HEIGHT, + Math.min(resizeRef.current.startHeight + delta, window.innerHeight * MAX_DETAIL_RATIO) + ); + setDetailHeight(newHeight); + }; + const onMouseUp = () => { + if (resizeRef.current) { + resizeRef.current = null; + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + } + }; + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + return () => { + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + if (resizeRef.current) { + resizeRef.current = null; + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + } + }; + }, []); + const hasData = sourceMode !== null && (records.length > 0 || channels.length > 0); - // No data loaded yet — show source picker if (!hasData && !isLoading) { return ; } - // Loading state - if (isLoading) { + if (isLoading && records.length === 0) { return (
+ {isLoading && ( + + )}
- {/* Detail pane — shown when a record is selected */} + {/* Resize handle + detail pane */} {selectedRecordId != null && ( -
- -
+ <> +
{ + e.preventDefault(); + resizeRef.current = { startY: e.clientY, startHeight: detailHeight }; + document.body.style.cursor = "row-resize"; + document.body.style.userSelect = "none"; + }} + /> +
+ +
+ )}
diff --git a/src/components/event-log-workspace/EvtxDetailPane.tsx b/src/components/event-log-workspace/EvtxDetailPane.tsx index 4db2a5e90..a88a54730 100644 --- a/src/components/event-log-workspace/EvtxDetailPane.tsx +++ b/src/components/event-log-workspace/EvtxDetailPane.tsx @@ -105,8 +105,10 @@ export function EvtxDetailPane() { padding: "8px", borderRadius: "4px", color: tokens.colorNeutralForeground1, - maxHeight: "120px", + minHeight: "60px", + maxHeight: "200px", overflow: "auto", + flexShrink: 0, }} > {record.message} diff --git a/src/components/event-log-workspace/EvtxTimeline.tsx b/src/components/event-log-workspace/EvtxTimeline.tsx index 6f95d8f48..ce55e0202 100644 --- a/src/components/event-log-workspace/EvtxTimeline.tsx +++ b/src/components/event-log-workspace/EvtxTimeline.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { tokens } from "@fluentui/react-components"; import { @@ -128,6 +128,37 @@ export function EvtxTimeline() { const monoFontSize = Math.max(10, fontSize - 1); const lineHeight = `${metrics.rowLineHeight}px`; + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key !== "ArrowUp" && e.key !== "ArrowDown" && e.key !== "Home" && e.key !== "End") return; + e.preventDefault(); + e.stopPropagation(); + + const currentIndex = selectedRecordId != null + ? sortedRecords.findIndex((r) => r.id === selectedRecordId) + : -1; + + let nextIndex: number; + if (e.key === "ArrowDown") { + nextIndex = currentIndex < sortedRecords.length - 1 ? currentIndex + 1 : currentIndex; + } else if (e.key === "ArrowUp") { + nextIndex = currentIndex > 0 ? currentIndex - 1 : 0; + } else if (e.key === "Home") { + nextIndex = 0; + } else { + nextIndex = sortedRecords.length - 1; + } + + if (nextIndex >= 0 && nextIndex < sortedRecords.length) { + setSelectedRecordId(sortedRecords[nextIndex].id); + virtualizer.scrollToIndex(nextIndex, { align: "auto" }); + // Keep focus on the container so subsequent arrow keys work + parentRef.current?.focus(); + } + }, + [selectedRecordId, sortedRecords, setSelectedRecordId, virtualizer] + ); + if (records.length === 0) { return (
- -
- ); + if (activeView === "dsregcmd") { + return ( +
+ +
+ ); + } + + return null; }; return ( @@ -482,46 +486,48 @@ export function AppShell() { backgroundColor: tokens.colorNeutralBackground2, }} > - {sidebarCollapsed ? ( -
- -
- ) : ( - + +
+ ) : ( + + ) )}
s.analysisError); const sysmonSourcePath = useSysmonStore((s) => s.sourcePath); + const evtxRecordCount = useEvtxStore((s) => s.records.length); + const evtxSourceMode = useEvtxStore((s) => s.sourceMode); + const evtxIsLoading = useEvtxStore((s) => s.isLoading); + const evtxLoadedChannelCount = useEvtxStore((s) => s.loadedChannels.size); + const evtxLoadElapsedMs = useEvtxStore((s) => s.loadElapsedMs); + const filterClauseCount = useFilterStore((s) => s.clauses.length); const filteredIds = useFilterStore((s) => s.filteredIds); const isFiltering = useFilterStore((s) => s.isFiltering); @@ -332,6 +339,29 @@ export function StatusBar() { deploymentResult.deferred > 0 ? `${deploymentResult.deferred} deferred` : null, ].filter(Boolean).join(" | "); } + } else if (activeView === "event-log") { + leftParts = [ + "Event Log", + evtxIsLoading + ? "Loading..." + : evtxSourceMode === "live" + ? `${evtxLoadedChannelCount} channel${evtxLoadedChannelCount !== 1 ? "s" : ""} loaded` + : evtxSourceMode === "files" + ? "File mode" + : "Ready", + ]; + + if (evtxIsLoading) { + rightStatusText = evtxRecordCount > 0 + ? `${evtxRecordCount.toLocaleString()} events loaded...` + : "Querying event logs..."; + rightTone = tokens.colorPaletteBlueForeground2; + } else if (evtxRecordCount > 0) { + const timeStr = evtxLoadElapsedMs != null + ? ` in ${(evtxLoadElapsedMs / 1000).toFixed(1)}s` + : ""; + rightStatusText = `${evtxRecordCount.toLocaleString()} events${timeStr}`; + } } else { const diagnostics = dsregcmdResult?.diagnostics ?? []; const errorCount = diagnostics.filter((item) => item.severity === "Error").length; @@ -381,11 +411,15 @@ export function StatusBar() { ? "New Intune" : activeView === "sysmon" ? "Sysmon Analysis" - : activeView === "deployment" - ? "Software Deployment" - : activeView === "macos-diag" - ? "macOS Diagnostics" - : "dsregcmd"; + : activeView === "event-log" + ? "Event Log" + : activeView === "deployment" + ? "Software Deployment" + : activeView === "macos-diag" + ? "macOS Diagnostics" + : activeView === "dsregcmd" + ? "dsregcmd" + : activeView; return (
)} + {activeView === "event-log" && evtxIsLoading && ( + + )} ; + loadedChannels: Set; filterLevels: Set; filterEventIds: string; filterSearch: string; @@ -30,6 +31,7 @@ interface EvtxState { parseFiles: (paths: string[]) => Promise; enumerateChannels: () => Promise; queryChannels: (channels: string[], maxEvents?: number) => Promise; + loadSelectedChannels: () => Promise; setSelectedChannels: (channels: Set) => void; toggleChannel: (channel: string) => void; selectAllChannels: () => void; @@ -67,6 +69,7 @@ export const useEvtxStore = create()((set, get) => ({ isLoading: false, loadError: null, selectedChannels: new Set(), + loadedChannels: new Set(), filterLevels: new Set(ALL_LEVELS), filterEventIds: "", filterSearch: "", @@ -88,17 +91,70 @@ export const useEvtxStore = create()((set, get) => ({ enumerateChannels: async () => { set({ isLoading: true, loadError: null }); try { + // Step 1: Enumerate all channels const channels = await invoke("evtx_enumerate_channels"); - const channelNames = new Set(channels.map((c) => c.name)); + + // Step 2: Auto-query the core Windows Logs channels immediately + const coreChannels = ["Application", "System", "Security", "Setup"]; + const availableCore = coreChannels.filter((c) => + channels.some((ch) => ch.name === c) + ); + + let updatedChannels = channels; + let loadError: string | null = null; + + // Show channels immediately, then load events progressively + const selectedNames = new Set(availableCore); set({ - channels, + channels: updatedChannels, sourceMode: "live", - isLoading: false, + isLoading: true, loadError: null, - selectedChannels: channelNames, + selectedChannels: selectedNames, + loadedChannels: new Set(), records: [], selectedRecordId: null, }); + + // Query core channels one at a time, updating the UI after each + for (const ch of availableCore) { + try { + const result = await invoke("evtx_query_channels", { + channels: [ch], + maxEvents: 1000, + }); + + console.log(`[evtx] ${ch}: got ${result.records.length} records, ${result.parseErrors} errors`, result.errorMessages); + + // Merge into current state + const state = get(); + const merged = [...state.records, ...result.records]; + merged.sort((a, b) => a.timestampEpoch - b.timestampEpoch); + for (let i = 0; i < merged.length; i++) merged[i].id = i; + + const countMap = new Map(result.channels.map((c) => [c.name, c.eventCount])); + const newChannels = state.channels.map((c) => ({ + ...c, + eventCount: countMap.get(c.name) ?? c.eventCount, + })); + const newLoaded = new Set(state.loadedChannels); + newLoaded.add(ch); + + set({ + records: merged, + channels: newChannels, + loadedChannels: newLoaded, + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + console.warn(`[evtx] Failed to query ${ch}: ${msg}`); + if (!loadError) { + loadError = `${ch}: ${msg}`; + } + } + } + + set({ isLoading: false, loadError }); } catch (error) { const message = error instanceof Error ? error.message : String(error); set({ isLoading: false, loadError: message }); @@ -112,13 +168,51 @@ export const useEvtxStore = create()((set, get) => ({ channels, maxEvents: maxEvents ?? null, }); - set(applyParseResult(result, "live")); + + // Merge new records with existing ones (for incremental channel loading) + const state = get(); + const existingChannelNames = new Set(state.records.map((r) => r.channel)); + // Only add records from channels we don't already have + const newRecords = result.records.filter((r) => !existingChannelNames.has(r.channel)); + const merged = [...state.records, ...newRecords]; + merged.sort((a, b) => a.timestampEpoch - b.timestampEpoch); + // Reassign IDs + for (let i = 0; i < merged.length; i++) merged[i].id = i; + + // Update channel event counts + const countMap = new Map(result.channels.map((c) => [c.name, c.eventCount])); + const updatedChannels = state.channels.map((c) => ({ + ...c, + eventCount: countMap.get(c.name) ?? c.eventCount, + })); + + const newLoaded = new Set(state.loadedChannels); + for (const ch of channels) newLoaded.add(ch); + + set({ + records: merged, + channels: updatedChannels, + loadedChannels: newLoaded, + isLoading: false, + loadError: null, + selectedRecordId: null, + }); } catch (error) { const message = error instanceof Error ? error.message : String(error); set({ isLoading: false, loadError: message }); } }, + loadSelectedChannels: async () => { + const state = get(); + // Find selected channels that haven't been loaded yet + const unloaded = [...state.selectedChannels].filter( + (ch) => !state.loadedChannels.has(ch) + ); + if (unloaded.length === 0) return; + await get().queryChannels(unloaded, 1000); + }, + setSelectedChannels: (channels) => set({ selectedChannels: channels }), toggleChannel: (channel) => { @@ -168,6 +262,7 @@ export const useEvtxStore = create()((set, get) => ({ isLoading: false, loadError: null, selectedChannels: new Set(), + loadedChannels: new Set(), filterLevels: new Set(ALL_LEVELS), filterEventIds: "", filterSearch: "", diff --git a/src/types/event-log-workspace.ts b/src/types/event-log-workspace.ts index 9f35c1e08..50e7b317b 100644 --- a/src/types/event-log-workspace.ts +++ b/src/types/event-log-workspace.ts @@ -32,4 +32,5 @@ export interface EvtxParseResult { channels: EvtxChannelInfo[]; totalRecords: number; parseErrors: number; + errorMessages: string[]; } From 0bdfe63451fe43f8536a6f054550af38eeb2eb49 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 3 Apr 2026 08:22:19 -0400 Subject: [PATCH 36/66] feat: live event log queries, Graph API scripts, parallel loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live Event Log: - Implement Win32 EvtQuery/EvtRender/EvtFormatMessage for live channel queries - Auto-load Application, System, Security, Setup in parallel - Event Viewer-style nested tree sidebar (split on - and /) - Resizable sidebar and detail pane with drag handles - Arrow key navigation in timeline - Progressive loading with spinner and elapsed time in status bar - Refresh button to reload channels - No event cap — loads all events on disk - DevTools auto-open in debug builds Graph API enhancements: - Fetch remediation scripts, platform scripts, shell scripts - GUID Registry tabbed view (All/Apps/Scripts/Remediations) - Category and publisher columns - Auto-connect on startup with status bar indicator - PolicyId extraction for HealthScripts events Fixes: - HRESULT error code handling (low 16-bit extraction) - EvtRender buffer retry for large events - Status bar shows correct workspace labels - Hide FileSidebar in Event Log workspace Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 4 +- src-tauri/src/event_log/commands.rs | 22 +- src-tauri/src/event_log/live.rs | 64 +- src-tauri/src/lib.rs | 6 + .../event-log-workspace/ChannelPicker.tsx | 618 ++++++++++-------- .../event-log-workspace/EventLogWorkspace.tsx | 4 +- src/stores/evtx-store.ts | 152 ++++- 7 files changed, 531 insertions(+), 339 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3768e4431..c9c262f8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,11 @@ All notable changes to this project will be documented in this file. - **Settings dialog** (replaces Accessibility dialog): Full settings UI with tabs for Appearance (themes, font size), Columns (visibility, ordering), Behavior (confirm tab close), Updates (auto-update toggle), and File Associations (Windows-only). Accessible via `Ctrl+,` or Window menu. - **Context menu**: Right-click any log row for Copy Line, Copy Message, Jump to Line, Quick Filter by severity/component, Reveal in File Manager, and Error Lookup. Uses native Tauri menu popup for OS-native feel. -- **Event Log workspace** (Windows, feature-gated): Parse `.evtx` files and query live Windows Event Log channels. Supports file-based EVTX parsing with channel grouping, severity filtering, and correlation linking. Frontend workspace with channel sidebar, severity badges, and detail pane. +- **Event Log workspace** (Windows, feature-gated): Parse `.evtx` files and query live Windows Event Log channels. Supports file-based EVTX parsing with channel grouping, severity filtering, and correlation linking. Frontend workspace with channel sidebar, severity badges, and detail pane. Live queries use Win32 Event Log API (`EvtQuery`, `EvtRender`, `EvtFormatMessage`). "This Computer" auto-loads Application, System, Security, and Setup channels in parallel with progressive UI updates. Event Viewer-style nested tree sidebar (split on `-` and `/`) with resizable drag handle. Arrow key navigation, resizable detail pane, per-channel load/refresh buttons, and loading spinner with elapsed time in the status bar. - **AppWorkload enrichment**: Parse "Get policies" JSON payloads in the log viewer to build GUID-to-app-name mappings. InfoPane shows resolved app names when log messages contain GUIDs, structured policy metadata cards, and decoded base64 PowerShell detection scripts via a lightweight syntax-highlighted code viewer. - **Activity view**: New "Activity" toggle in the Intune timeline tab groups events by app into collapsible cards. Each card shows worst status, event count, duration, and event type badges. Expanded rows display parsed structured fields (intent, detection, applicability, reboot, GRS expired, enforcement) as colored tags with inline GUID resolution and word-wrapped detail messages. - **GUID Registry dialog**: New Tools menu item showing a searchable table of all GUID-to-app-name mappings from the Intune analysis, with source confidence ranking (GraphApi > ApplicationName > Name > SetUpFilePath) and click-to-copy. -- **Microsoft Graph API integration** (Windows, opt-in): Resolve Intune app GUIDs to display names via Microsoft Graph API. Authenticates silently using WAM (Web Account Manager) with the device's existing Entra ID session — no app registration required. Gated behind Settings > Graph API toggle (off by default) with consent warnings. Pre-populate cache button fetches all tenant apps in one call. Graph-resolved names appear in the GUID Registry with a purple "Graph API" label and are used as the highest-confidence source during Intune log analysis. +- **Microsoft Graph API integration** (Windows, opt-in): Resolve Intune app GUIDs to display names via Microsoft Graph API. Authenticates silently using WAM (Web Account Manager) with the device's existing Entra ID session — no app registration required. Gated behind Settings > Graph API toggle (off by default) with consent warnings. Pre-populate cache fetches all apps, remediation scripts, platform scripts, and shell scripts in one call. GUID Registry dialog shows entries in tabbed view (All/Apps/Scripts/Remediations) with publisher and category columns. Auto-connects on startup when enabled, with status indicator in the status bar. - **SideCarScriptDetectionManager events**: Extract PowerShell script detection lifecycle events (start, complete, exit code, process ID) as standalone PowerShellScript events in the Intune timeline. - **Resizable InfoPane**: Drag handle between the log list and detail pane allows resizing (min 80px, max 70% viewport). - **Jump to Line**: Context menu action to jump to a specific line number in the log. diff --git a/src-tauri/src/event_log/commands.rs b/src-tauri/src/event_log/commands.rs index 4273d477b..26ca190f1 100644 --- a/src-tauri/src/event_log/commands.rs +++ b/src-tauri/src/event_log/commands.rs @@ -1,6 +1,16 @@ +use serde::Serialize; +use tauri::{AppHandle, Emitter}; + use super::models::{EvtxChannelInfo, EvtxParseResult}; use super::parser; +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct EvtxQueryProgress { + channel: String, + fetched: usize, +} + #[tauri::command] pub async fn evtx_parse_files(paths: Vec) -> Result { tokio::task::spawn_blocking(move || parser::parse_evtx_files(&paths)) @@ -26,6 +36,7 @@ pub async fn evtx_enumerate_channels() -> Result, String> { pub async fn evtx_query_channels( channels: Vec, max_events: Option, + app: AppHandle, ) -> Result { #[cfg(target_os = "windows")] { @@ -36,7 +47,14 @@ pub async fn evtx_query_channels( let mut error_messages = Vec::new(); for channel in &channels { - match super::live::query_channel(channel, max_events) { + let app_ref = &app; + let ch_name = channel.clone(); + match super::live::query_channel_with_progress(channel, max_events, |fetched, _| { + let _ = app_ref.emit("evtx-query-progress", EvtxQueryProgress { + channel: ch_name.clone(), + fetched, + }); + }) { Ok(records) => { channel_infos.push(super::models::EvtxChannelInfo { name: channel.clone(), @@ -79,7 +97,7 @@ pub async fn evtx_query_channels( } #[cfg(not(target_os = "windows"))] { - let _ = (channels, max_events); + let _ = (channels, max_events, app); Ok(EvtxParseResult { records: Vec::new(), channels: Vec::new(), diff --git a/src-tauri/src/event_log/live.rs b/src-tauri/src/event_log/live.rs index 552e6b42c..f2ec207a7 100644 --- a/src-tauri/src/event_log/live.rs +++ b/src-tauri/src/event_log/live.rs @@ -115,7 +115,17 @@ pub fn query_channel( channel: &str, max_events: Option, ) -> Result, String> { - let limit = max_events.unwrap_or(1000) as usize; + query_channel_with_progress(channel, max_events, |_, _| {}) +} + +/// Query with a progress callback: `on_progress(fetched_so_far, total_estimate)`. +#[cfg(target_os = "windows")] +pub fn query_channel_with_progress( + channel: &str, + max_events: Option, + on_progress: impl Fn(usize, Option), +) -> Result, String> { + let limit = max_events.map(|n| n as usize).unwrap_or(usize::MAX); let channel_hstring = HSTRING::from(channel); let query_string = HSTRING::from("*"); @@ -140,8 +150,13 @@ pub fn query_channel( match unsafe { EvtNext(query_handle.raw(), &mut raw_handles, 0, 0, &mut returned) } { Ok(()) => {} - Err(e) if is_no_more_items(&e) => break, - Err(e) => return Err(format_error("EvtNext", &e)), + Err(e) => { + if !is_no_more_items(&e) { + eprintln!("[evtx] EvtNext error: code=0x{:08x} w32={} msg=\"{}\"", + e.code().0 as u32, win32_code(&e), e.message()); + } + break; + } } if returned == 0 { @@ -171,6 +186,10 @@ pub fn query_channel( if let Some(record) = parse_xml_to_record(&xml, channel, rendered_message.as_deref()) { records.push(record); + // Report progress every 100 records + if records.len() % 100 == 0 { + on_progress(records.len(), None); + } } else if records.is_empty() { // Log the first unparseable XML so we can debug the format log::warn!("event=evtx_parse_failed channel=\"{channel}\" xml_prefix=\"{}\"", @@ -190,6 +209,15 @@ pub fn enumerate_channels() -> Result, String> { Err("Live event log queries are only available on Windows.".to_string()) } +#[cfg(not(target_os = "windows"))] +pub fn query_channel_with_progress( + _channel: &str, + _max_events: Option, + _on_progress: impl Fn(usize, Option), +) -> Result, String> { + Err("Live event log queries are only available on Windows.".to_string()) +} + #[cfg(not(target_os = "windows"))] pub fn query_channel( _channel: &str, @@ -223,16 +251,13 @@ fn render_event_xml(event_handle: EVT_HANDLE) -> Result { (buffer_used as usize / std::mem::size_of::()).saturating_sub(1); return Ok(String::from_utf16_lossy(&buffer[..utf16_len])); } + Err(e) if is_insufficient_buffer(&e) => { + let next_len = + (buffer_used as usize / std::mem::size_of::()).max(buffer.len() * 2); + buffer.resize(next_len, 0); + } Err(e) => { - let code = e.code().0 as u32; - if code == 122 || code == 234 || code == 0x8007007A || code == 0x800700EA { - // Insufficient buffer or more data — resize and retry - let next_len = - (buffer_used as usize / std::mem::size_of::()).max(buffer.len() * 2); - buffer.resize(next_len, 0); - } else { - return Err(e); - } + return Err(e); } } } @@ -443,29 +468,30 @@ fn format_error(context: &str, error: &Error) -> String { } } +/// Extract the Win32 error code from an HRESULT or raw error code. #[cfg(target_os = "windows")] -fn is_insufficient_buffer(error: &Error) -> bool { - error.code().0 as u32 == 122 +fn win32_code(error: &Error) -> u32 { + (error.code().0 & 0xFFFF) as u32 } #[cfg(target_os = "windows")] -fn is_more_data(error: &Error) -> bool { - error.code().0 as u32 == 234 +fn is_insufficient_buffer(error: &Error) -> bool { + win32_code(error) == 122 } #[cfg(target_os = "windows")] fn is_no_more_items(error: &Error) -> bool { - error.code().0 as u32 == 259 + win32_code(error) == 259 } #[cfg(target_os = "windows")] fn is_not_found(error: &Error) -> bool { - error.code().0 as u32 == 1168 + win32_code(error) == 1168 } #[cfg(target_os = "windows")] fn is_message_not_found(error: &Error) -> bool { - error.code().0 as u32 == 15027 + win32_code(error) == 15027 } #[cfg(test)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ecb68cd87..0054d640a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -65,6 +65,12 @@ pub fn run() { #[cfg(target_os = "windows")] app.manage(GraphAuthState::new()); + // Auto-open DevTools in debug builds + #[cfg(debug_assertions)] + if let Some(window) = app.get_webview_window("main") { + window.open_devtools(); + } + Ok(()) }) .manage(AppState::new(initial_file_paths)) diff --git a/src/components/event-log-workspace/ChannelPicker.tsx b/src/components/event-log-workspace/ChannelPicker.tsx index e55fceb56..509163ffc 100644 --- a/src/components/event-log-workspace/ChannelPicker.tsx +++ b/src/components/event-log-workspace/ChannelPicker.tsx @@ -1,97 +1,104 @@ -import { useMemo, useRef, useState, useEffect } from "react"; -import { Button, Checkbox, Input, tokens } from "@fluentui/react-components"; +import { memo, useMemo, useRef, useState, useEffect, useCallback } from "react"; +import { Button, Input, tokens } from "@fluentui/react-components"; import { useEvtxStore } from "../../stores/evtx-store"; import type { EvtxChannelInfo } from "../../types/event-log-workspace"; -const WINDOWS_LOGS = new Set(["Application", "Security", "Setup", "System", "ForwardedEvents"]); -const WINDOWS_LOGS_ORDER = ["Application", "Security", "System", "Setup", "ForwardedEvents"]; +// ── Tree data structure ───────────────────────────────────────────────────── -interface ChannelGroup { - key: string; - label: string; - channels: EvtxChannelInfo[]; +interface TreeNode { + name: string; + fullPath: string; + channel: EvtxChannelInfo | null; // leaf node if present + children: Map; } -interface ChannelSection { - label: string; - groups: ChannelGroup[]; - defaultExpanded: boolean; -} +const WINDOWS_LOGS = new Set(["Application", "Security", "Setup", "System", "ForwardedEvents"]); +const WINDOWS_LOGS_ORDER = ["Application", "Security", "System", "Setup", "ForwardedEvents"]; -function buildSections(channels: EvtxChannelInfo[]): ChannelSection[] { +function buildTree(channels: EvtxChannelInfo[]): { windowsLogs: EvtxChannelInfo[]; serviceTree: TreeNode } { const windowsLogs: EvtxChannelInfo[] = []; - const serviceChannels: EvtxChannelInfo[] = []; + const root: TreeNode = { name: "", fullPath: "", channel: null, children: new Map() }; for (const ch of channels) { if (WINDOWS_LOGS.has(ch.name)) { windowsLogs.push(ch); - } else { - serviceChannels.push(ch); + continue; } + + // Split channel path: "Microsoft-Windows-AAD/Operational" + // → split on "/" first, then split the provider part on "-" + const slashParts = ch.name.split("/"); + const providerParts = slashParts[0].split("-"); + const allParts = [...providerParts, ...slashParts.slice(1)]; + + let node = root; + for (const part of allParts) { + if (!node.children.has(part)) { + node.children.set(part, { + name: part, + fullPath: "", + channel: null, + children: new Map(), + }); + } + node = node.children.get(part)!; + } + node.channel = ch; + node.fullPath = ch.name; } + // Collapse single-child chains: if a node has exactly one child and no channel, + // merge it with its child (e.g., "Microsoft" → "Windows" becomes "Microsoft-Windows" if Windows only has children) + collapseChains(root); + windowsLogs.sort( (a, b) => WINDOWS_LOGS_ORDER.indexOf(a.name) - WINDOWS_LOGS_ORDER.indexOf(b.name) ); - // Group service channels by provider prefix (before the "/") - const groupMap = new Map(); - const standalone: EvtxChannelInfo[] = []; - - for (const ch of serviceChannels) { - const slashIdx = ch.name.indexOf("/"); - if (slashIdx > 0) { - const prefix = ch.name.slice(0, slashIdx); - const existing = groupMap.get(prefix) ?? []; - existing.push(ch); - groupMap.set(prefix, existing); - } else { - standalone.push(ch); - } - } - - // Build groups: standalone channels become single-item groups - const serviceGroups: ChannelGroup[] = []; - - for (const ch of standalone) { - serviceGroups.push({ key: ch.name, label: ch.name, channels: [ch] }); - } + return { windowsLogs, serviceTree: root }; +} - for (const [prefix, chs] of groupMap) { - chs.sort((a, b) => a.name.localeCompare(b.name)); - serviceGroups.push({ key: prefix, label: prefix, channels: chs }); +function collapseChains(node: TreeNode) { + // Process children first (bottom-up) + for (const child of node.children.values()) { + collapseChains(child); } - serviceGroups.sort((a, b) => a.label.localeCompare(b.label)); - - const sections: ChannelSection[] = []; - - if (windowsLogs.length > 0) { - sections.push({ - label: "Windows Logs", - groups: windowsLogs.map((ch) => ({ - key: ch.name, - label: ch.name, - channels: [ch], - })), - defaultExpanded: true, - }); + // If this node has exactly one child, no channel, and child also has no channel, + // merge child's name into this node + if (node.children.size === 1 && !node.channel) { + const [childName, child] = [...node.children.entries()][0]; + if (!child.channel || child.children.size > 0) { + // Merge: combine names with "-" + const mergedName = node.name ? `${node.name}-${childName}` : childName; + node.name = mergedName; + node.channel = child.channel; + node.fullPath = child.fullPath; + node.children = child.children; + } } +} - if (serviceGroups.length > 0) { - sections.push({ - label: "Applications and Services Logs", - groups: serviceGroups, - defaultExpanded: false, - }); +function countLeaves(node: TreeNode): number { + if (node.children.size === 0) return node.channel ? 1 : 0; + let count = node.channel ? 1 : 0; + for (const child of node.children.values()) { + count += countLeaves(child); } + return count; +} - return sections; +function getSortedChildren(node: TreeNode): TreeNode[] { + return [...node.children.values()].sort((a, b) => a.name.localeCompare(b.name)); } -const MIN_SIDEBAR_WIDTH = 180; +// ── Constants ─────────────────────────────────────────────────────────────── + +const MIN_SIDEBAR_WIDTH = 200; const MAX_SIDEBAR_WIDTH = 500; -const DEFAULT_SIDEBAR_WIDTH = 280; +const DEFAULT_SIDEBAR_WIDTH = 300; + +// ── Component ─────────────────────────────────────────────────────────────── export function ChannelPicker() { const channels = useEvtxStore((s) => s.channels); @@ -102,20 +109,19 @@ export function ChannelPicker() { const sourceMode = useEvtxStore((s) => s.sourceMode); const loadedChannels = useEvtxStore((s) => s.loadedChannels); const loadSelectedChannels = useEvtxStore((s) => s.loadSelectedChannels); + const refreshLoadedChannels = useEvtxStore((s) => s.refreshLoadedChannels); const isLoading = useEvtxStore((s) => s.isLoading); const [search, setSearch] = useState(""); - const [collapsedSections, setCollapsedSections] = useState>(new Set()); - const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + const [expanded, setExpanded] = useState>(() => new Set(["Windows Logs"])); const [sidebarWidth, setSidebarWidth] = useState(DEFAULT_SIDEBAR_WIDTH); const resizeRef = useRef<{ startX: number; startWidth: number } | null>(null); useEffect(() => { const onMouseMove = (e: MouseEvent) => { if (!resizeRef.current) return; - const delta = e.clientX - resizeRef.current.startX; setSidebarWidth( - Math.max(MIN_SIDEBAR_WIDTH, Math.min(resizeRef.current.startWidth + delta, MAX_SIDEBAR_WIDTH)) + Math.max(MIN_SIDEBAR_WIDTH, Math.min(resizeRef.current.startWidth + (e.clientX - resizeRef.current.startX), MAX_SIDEBAR_WIDTH)) ); }; const onMouseUp = () => { @@ -133,40 +139,27 @@ export function ChannelPicker() { }; }, []); + const { windowsLogs, serviceTree } = useMemo(() => buildTree(channels), [channels]); + const filteredChannels = useMemo(() => { - if (!search.trim()) return channels; + if (!search.trim()) return null; const lower = search.toLowerCase(); return channels.filter((c) => c.name.toLowerCase().includes(lower)); }, [channels, search]); - const sections = useMemo(() => buildSections(filteredChannels), [filteredChannels]); - - // Compute inline — Set objects in useMemo deps don't trigger reliably const unloadedSelectedCount = sourceMode !== "live" ? 0 : [...selectedChannels].filter((ch) => !loadedChannels.has(ch)).length; - const toggleSection = (label: string) => { - setCollapsedSections((prev) => { - const next = new Set(prev); - if (next.has(label)) next.delete(label); - else next.add(label); - return next; - }); - }; - - const toggleGroup = (key: string) => { - setCollapsedGroups((prev) => { + const toggleExpand = useCallback((key: string) => { + setExpanded((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); - }; - - const isSectionExpanded = (section: ChannelSection) => - section.defaultExpanded ? !collapsedSections.has(section.label) : collapsedSections.has(section.label); + }, []); return (
@@ -188,21 +181,10 @@ export function ChannelPicker() { borderBottom: `1px solid ${tokens.colorNeutralStroke2}`, display: "flex", flexDirection: "column", - gap: "6px", + gap: "5px", flexShrink: 0, }} > -
- Channels ({selectedChannels.size}/{channels.length}) -
setSearch(data.value)} @@ -218,109 +200,96 @@ export function ChannelPicker() { Deselect All
- {sourceMode === "live" && unloadedSelectedCount > 0 && ( - + {sourceMode === "live" && ( +
+ {unloadedSelectedCount > 0 && ( + + )} + +
)}
{/* Tree */} -
- {search.trim() ? ( - // Flat list when searching -
- {filteredChannels.map((ch) => ( - toggleChannel(ch.name)} - indent={0} - /> - ))} -
+
+ {filteredChannels ? ( + // Flat search results + filteredChannels.map((ch) => ( + toggleChannel(ch.name)} + depth={0} + /> + )) ) : ( - sections.map((section) => { - const expanded = isSectionExpanded(section); - return ( -
- toggleSection(section.label)} - indent={0} - count={section.groups.reduce((n, g) => n + g.channels.length, 0)} - bold + <> + {/* Windows Logs */} + toggleExpand("Windows Logs")} + depth={0} + /> + {expanded.has("Windows Logs") && + windowsLogs.map((ch) => ( + toggleChannel(ch.name)} + depth={1} /> - {expanded && - section.groups.map((group) => { - if (group.channels.length === 1) { - // Single channel — render inline, no subfolder - const ch = group.channels[0]; - const displayName = - ch.name.includes("/") ? ch.name.split("/").pop()! : ch.name; - return ( - toggleChannel(ch.name)} - indent={1} - /> - ); - } - - // Multi-channel group — render as subfolder - const groupExpanded = !collapsedGroups.has(group.key); - return ( -
- toggleGroup(group.key)} - indent={1} - count={group.channels.length} - /> - {groupExpanded && - group.channels.map((ch) => { - const subName = ch.name.split("/").pop() ?? ch.name; - return ( - toggleChannel(ch.name)} - indent={2} - /> - ); - })} -
- ); - })} -
- ); - }) + ))} + + {/* Applications and Services Logs */} + toggleExpand("AppServices")} + depth={0} + count={countLeaves(serviceTree)} + /> + {expanded.has("AppServices") && ( + + )} + )} - {filteredChannels.length === 0 && ( + {filteredChannels && filteredChannels.length === 0 && (
@@ -349,84 +318,150 @@ export function ChannelPicker() { ); } -// ── Subcomponents ───────────────────────────────────────────────────────── +// ── Tree rendering ────────────────────────────────────────────────────────── -function TreeToggle({ - label, +function TreeNodeView({ + node, + depth, expanded, + toggleExpand, + selectedChannels, + loadedChannels, + toggleChannel, +}: { + node: TreeNode; + depth: number; + expanded: Set; + toggleExpand: (key: string) => void; + selectedChannels: Set; + loadedChannels: Set; + toggleChannel: (name: string) => void; +}) { + const children = getSortedChildren(node); + + return ( + <> + {children.map((child) => { + const key = child.fullPath || child.name; + const hasChildren = child.children.size > 0; + const isExpanded = expanded.has(key); + + if (!hasChildren && child.channel) { + // Pure leaf — just a channel + return ( + toggleChannel(child.channel!.name)} + depth={depth} + /> + ); + } + + // Folder node (may also have a channel) + return ( +
+ toggleExpand(key)} + depth={depth} + count={countLeaves(child)} + channel={child.channel ?? undefined} + selected={child.channel ? selectedChannels.has(child.channel.name) : undefined} + loaded={child.channel ? loadedChannels.has(child.channel.name) : undefined} + onChannelToggle={child.channel ? () => toggleChannel(child.channel!.name) : undefined} + /> + {isExpanded && ( + + )} +
+ ); + })} + + ); +} + +// ── Leaf & folder rows ────────────────────────────────────────────────────── + +const ChannelLeaf = memo(function ChannelLeaf({ + name, + channel, + selected, + loaded, onToggle, - indent, - count, - bold, + depth, }: { - label: string; - expanded: boolean; + name: string; + channel: EvtxChannelInfo; + selected: boolean; + loaded: boolean; onToggle: () => void; - indent: number; - count: number; - bold?: boolean; + depth: number; }) { return ( - + + + {name} + {loaded && channel.eventCount > 0 && ( + + ({channel.eventCount}) + + )} + ); -} +}); -function ChannelLeaf({ +const FolderRow = memo(function FolderRow({ + label, + expanded, + onToggle, + depth, + count, channel, - displayName, selected, loaded, - onToggle, - indent, + onChannelToggle, }: { - channel: EvtxChannelInfo; - displayName?: string; - selected: boolean; - loaded: boolean; + label: string; + expanded: boolean; onToggle: () => void; - indent: number; + depth: number; + count?: number; + channel?: EvtxChannelInfo; + selected?: boolean; + loaded?: boolean; + onChannelToggle?: () => void; }) { return (
- - {displayName ?? channel.name} - {loaded && channel.eventCount > 0 && ( - - ({channel.eventCount}) - - )} - - } - /> + + {onChannelToggle != null && ( + + )} + + {label} + + {count != null && count > 0 && ( + + {count} + + )} + {loaded && channel && channel.eventCount > 0 && ( + + ({channel.eventCount}) + + )}
); -} +}); diff --git a/src/components/event-log-workspace/EventLogWorkspace.tsx b/src/components/event-log-workspace/EventLogWorkspace.tsx index 75e9c861f..5311f1c8e 100644 --- a/src/components/event-log-workspace/EventLogWorkspace.tsx +++ b/src/components/event-log-workspace/EventLogWorkspace.tsx @@ -82,9 +82,7 @@ export function EventLogWorkspace() { }} > {isLoading && ( - + )} diff --git a/src/stores/evtx-store.ts b/src/stores/evtx-store.ts index e0c297270..d05b34f49 100644 --- a/src/stores/evtx-store.ts +++ b/src/stores/evtx-store.ts @@ -1,5 +1,6 @@ import { create } from "zustand"; import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; import type { EvtxRecord, EvtxChannelInfo, @@ -18,6 +19,10 @@ interface EvtxState { channels: EvtxChannelInfo[]; sourceMode: EvtxSourceMode; isLoading: boolean; + loadingChannel: string | null; + loadingProgress: number | null; + loadStartTime: number | null; + loadElapsedMs: number | null; loadError: string | null; selectedChannels: Set; loadedChannels: Set; @@ -32,6 +37,7 @@ interface EvtxState { enumerateChannels: () => Promise; queryChannels: (channels: string[], maxEvents?: number) => Promise; loadSelectedChannels: () => Promise; + refreshLoadedChannels: () => Promise; setSelectedChannels: (channels: Set) => void; toggleChannel: (channel: string) => void; selectAllChannels: () => void; @@ -67,6 +73,10 @@ export const useEvtxStore = create()((set, get) => ({ channels: [], sourceMode: null, isLoading: false, + loadingChannel: null, + loadingProgress: null, + loadStartTime: null, + loadElapsedMs: null, loadError: null, selectedChannels: new Set(), loadedChannels: new Set(), @@ -103,58 +113,69 @@ export const useEvtxStore = create()((set, get) => ({ let updatedChannels = channels; let loadError: string | null = null; - // Show channels immediately, then load events progressively + // Show channels immediately, then load events in parallel const selectedNames = new Set(availableCore); + const startTime = performance.now(); set({ channels: updatedChannels, sourceMode: "live", isLoading: true, loadError: null, + loadStartTime: startTime, + loadElapsedMs: null, selectedChannels: selectedNames, loadedChannels: new Set(), records: [], selectedRecordId: null, }); - // Query core channels one at a time, updating the UI after each - for (const ch of availableCore) { + // Query all core channels in parallel (bypass queryChannels to avoid isLoading conflicts) + const mergeResult = (ch: string, result: EvtxParseResult) => { + console.log(`[evtx] ${ch}: got ${result.records.length} records, ${result.parseErrors} errors`, result.errorMessages); + const state = get(); + const merged = [...state.records, ...result.records]; + merged.sort((a, b) => a.timestampEpoch - b.timestampEpoch); + for (let i = 0; i < merged.length; i++) merged[i].id = i; + + const countMap = new Map(result.channels.map((c) => [c.name, c.eventCount])); + const newChannels = state.channels.map((c) => ({ + ...c, + eventCount: countMap.get(c.name) ?? c.eventCount, + })); + const newLoaded = new Set(state.loadedChannels); + newLoaded.add(ch); + + set({ + records: merged, + channels: newChannels, + loadedChannels: newLoaded, + loadElapsedMs: performance.now() - startTime, + }); + }; + + const promises = availableCore.map(async (ch) => { try { const result = await invoke("evtx_query_channels", { channels: [ch], - maxEvents: 1000, - }); - - console.log(`[evtx] ${ch}: got ${result.records.length} records, ${result.parseErrors} errors`, result.errorMessages); - - // Merge into current state - const state = get(); - const merged = [...state.records, ...result.records]; - merged.sort((a, b) => a.timestampEpoch - b.timestampEpoch); - for (let i = 0; i < merged.length; i++) merged[i].id = i; - - const countMap = new Map(result.channels.map((c) => [c.name, c.eventCount])); - const newChannels = state.channels.map((c) => ({ - ...c, - eventCount: countMap.get(c.name) ?? c.eventCount, - })); - const newLoaded = new Set(state.loadedChannels); - newLoaded.add(ch); - - set({ - records: merged, - channels: newChannels, - loadedChannels: newLoaded, + maxEvents: null, }); + mergeResult(ch, result); } catch (e) { const msg = e instanceof Error ? e.message : String(e); console.warn(`[evtx] Failed to query ${ch}: ${msg}`); - if (!loadError) { - loadError = `${ch}: ${msg}`; - } + if (!loadError) loadError = `${ch}: ${msg}`; } - } + }); - set({ isLoading: false, loadError }); + await Promise.all(promises); + + set({ + isLoading: false, + loadingChannel: null, + loadingProgress: null, + loadElapsedMs: performance.now() - startTime, + loadError, + }); } catch (error) { const message = error instanceof Error ? error.message : String(error); set({ isLoading: false, loadError: message }); @@ -205,12 +226,65 @@ export const useEvtxStore = create()((set, get) => ({ loadSelectedChannels: async () => { const state = get(); - // Find selected channels that haven't been loaded yet const unloaded = [...state.selectedChannels].filter( (ch) => !state.loadedChannels.has(ch) ); if (unloaded.length === 0) return; - await get().queryChannels(unloaded, 1000); + await get().queryChannels(unloaded); + }, + + refreshLoadedChannels: async () => { + const state = get(); + const loaded = [...state.loadedChannels]; + if (loaded.length === 0) return; + const startTime = performance.now(); + set({ + records: [], + loadedChannels: new Set(), + selectedRecordId: null, + isLoading: true, + loadStartTime: startTime, + loadElapsedMs: null, + }); + + const promises = loaded.map(async (ch) => { + try { + const result = await invoke("evtx_query_channels", { + channels: [ch], + maxEvents: null, + }); + + const s = get(); + const merged = [...s.records, ...result.records]; + merged.sort((a, b) => a.timestampEpoch - b.timestampEpoch); + for (let i = 0; i < merged.length; i++) merged[i].id = i; + + const countMap = new Map(result.channels.map((c) => [c.name, c.eventCount])); + const newChannels = s.channels.map((c) => ({ + ...c, + eventCount: countMap.get(c.name) ?? c.eventCount, + })); + const newLoaded = new Set(s.loadedChannels); + newLoaded.add(ch); + + set({ + records: merged, + channels: newChannels, + loadedChannels: newLoaded, + loadElapsedMs: performance.now() - startTime, + }); + } catch (e) { + console.warn(`[evtx] Refresh failed for ${ch}:`, e); + } + }); + + await Promise.all(promises); + set({ + isLoading: false, + loadingChannel: null, + loadingProgress: null, + loadElapsedMs: performance.now() - startTime, + }); }, setSelectedChannels: (channels) => set({ selectedChannels: channels }), @@ -263,6 +337,10 @@ export const useEvtxStore = create()((set, get) => ({ loadError: null, selectedChannels: new Set(), loadedChannels: new Set(), + loadingChannel: null, + loadingProgress: null, + loadStartTime: null, + loadElapsedMs: null, filterLevels: new Set(ALL_LEVELS), filterEventIds: "", filterSearch: "", @@ -271,3 +349,11 @@ export const useEvtxStore = create()((set, get) => ({ selectedRecordId: null, }), })); + +// Listen for progress events from the Rust backend +listen<{ channel: string; fetched: number }>("evtx-query-progress", (event) => { + useEvtxStore.setState({ + loadingChannel: event.payload.channel, + loadingProgress: event.payload.fetched, + }); +}); From d4bb04b2bb29f4094a6d23cf66b706c6cea57ae1 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 3 Apr 2026 08:32:40 -0400 Subject: [PATCH 37/66] fix: gate devtools and progress events for cross-platform CI - Gate open_devtools behind #[cfg(all(debug_assertions, desktop))] - Gate EvtxQueryProgress, Serialize, Emitter behind #[cfg(target_os = "windows")] - Fixes cargo check on Ubuntu CI Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/event_log/commands.rs | 6 +++++- src-tauri/src/lib.rs | 9 ++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/event_log/commands.rs b/src-tauri/src/event_log/commands.rs index 26ca190f1..c6b399feb 100644 --- a/src-tauri/src/event_log/commands.rs +++ b/src-tauri/src/event_log/commands.rs @@ -1,9 +1,13 @@ +#[cfg(target_os = "windows")] use serde::Serialize; -use tauri::{AppHandle, Emitter}; +use tauri::AppHandle; +#[cfg(target_os = "windows")] +use tauri::Emitter; use super::models::{EvtxChannelInfo, EvtxParseResult}; use super::parser; +#[cfg(target_os = "windows")] #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] struct EvtxQueryProgress { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0054d640a..4f018e940 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -66,9 +66,12 @@ pub fn run() { app.manage(GraphAuthState::new()); // Auto-open DevTools in debug builds - #[cfg(debug_assertions)] - if let Some(window) = app.get_webview_window("main") { - window.open_devtools(); + #[cfg(all(debug_assertions, desktop))] + { + use tauri::Manager as _; + if let Some(window) = app.get_webview_window("main") { + window.open_devtools(); + } } Ok(()) From 8b112385132dcc153792b62607e88e3ea2a9ffcb Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 5 Apr 2026 12:07:02 -0400 Subject: [PATCH 38/66] fix: address remaining Copilot PR #82 review comments, update changelog Resolve 4 outstanding Copilot review issues plus changelog updates: - DiffConfigDialog: filter to log-only tabs, validate selections exist - FileSidebar: filter directories and uncached files before merge - session.ts: replace unsafe cast with field-by-field validation and defaults - session-restore: load files individually for per-tab restore, restore active tab index and scroll positions Update changelog with all unreleased features, PR references (#72, #78, #79, #81, #82), and new Security section for CI permissions. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 16 +++++- src/components/dialogs/DiffConfigDialog.tsx | 16 ++++-- src/components/layout/FileSidebar.tsx | 7 ++- src/lib/session-restore.ts | 38 ++++++++++--- src/lib/session.ts | 62 ++++++++++++++++++++- 5 files changed, 118 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9c262f8f..e77e56f95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to this project will be documented in this file. -## [Unreleased] +## [Unreleased] — PR #82 ### Added @@ -18,25 +18,35 @@ All notable changes to this project will be documented in this file. - **Jump to Line**: Context menu action to jump to a specific line number in the log. - **Reveal in File Manager**: Context menu action to open the source file's location in Finder/Explorer. - **Quick Filter**: Context menu action to instantly filter by the selected row's severity or component. -- **Multi-file unified timeline**: Merge entries from multiple open log files into a single time-sorted view. Two entry points: "Merge Tabs..." button in the toolbar and "Merge into Timeline" button in the folder sidebar. Color-coded left borders distinguish source files. A legend bar provides per-file toggle visibility, correlation time window, and auto-correlate controls. Cross-file timestamp correlation highlights entries from other files within a configurable time window and shows them in the InfoPane with delta timestamps. +- **Multi-file unified timeline** (PR #81): Merge entries from multiple open log files into a single time-sorted view. Two entry points: "Merge Tabs..." button in the toolbar and "Merge into Timeline" button in the folder sidebar. Color-coded left borders distinguish source files. A legend bar provides per-file toggle visibility, correlation time window, and auto-correlate controls. Cross-file timestamp correlation highlights entries from other files within a configurable time window and shows them in the InfoPane with delta timestamps. - **Session save/restore**: Save the current workspace state (open files, scroll positions, filters, merged tabs, workspace context) to a `.cmtrace` JSON file via File > Save Session (Ctrl+Shift+S). Restore via File > Open Session or Recent Sessions submenu. Files are integrity-checked with SHA-256 hashes — warns if files have changed or are missing since the session was saved. New `compute_file_hash` Rust backend command. - **Log diff**: Compare two open log files side-by-side or in unified inline view. Fuzzy pattern matching normalizes GUIDs, timestamps, and long numbers so "same event, different instance" lines are recognized as matches. Stats bar shows common patterns vs. lines unique to each file. "Diff Tabs..." button in the toolbar opens a config dialog for source selection. - **Sysmon EVTX workspace** (PR #72): Full Sysmon analysis workspace for Windows `.evtx` event log files. - **Rust backend** (`src-tauri/src/sysmon/`): EVTX parser that reads all events (no cap) and classifies them into 23 Sysmon event types — process creation (ID 1), network connections (ID 3), file operations (IDs 11, 15, 23), registry activity (IDs 12, 13, 14), DNS queries (ID 22), image loads (ID 7), driver loads (ID 6), WMI activity (IDs 19, 20, 21), and more. Extracts structured fields (process names, hashes, parent processes, network destinations, registry keys) from XML event data. Produces dashboard aggregations: timeline bucketing with auto-scaling resolution (minute/5-minute/hourly/daily based on time span), top-N ranked lists (processes, network destinations, DNS queries, registry keys), event type distribution, and security alert classification (high-severity events like process injection, credential access, driver loads). Config extraction from event IDs 4/16 with hash algorithm inference. Pre-allocated HashMaps for 100K+ event performance. 14 backend tests covering summary generation, timeline bucketing, top-N ranking, config extraction, and security classification. - **Frontend** (`src/components/sysmon/`): Four-tab workspace — Dashboard (metric cards, event type donut chart, timeline histogram, security alerts, top process/network/DNS/registry lists), Events (searchable table with severity filtering and TanStack Virtual scrolling), Summary (file metadata), Config (Sysmon configuration XML viewer). Theme-aware chart colors via Fluent UI tokens. Zustand store with analysis progress events matching the Intune workspace pattern. - **Integration**: Registered as "sysmon" workspace with known source for `Microsoft-Windows-Sysmon%4Operational.evtx`. Toolbar workspace switcher, progress listener hook, `analyze_sysmon_logs` Tauri IPC command. +- **Intune failed app context** (PR #79): Expanded failed AppWorkload context export with polished output for troubleshooting failed app deployments. ### Fixed +- **PR #82 review fixes**: Resolved merged-tab ID collisions by prefixing entry IDs with source file index. Session restore now persists recent sessions to `localStorage` on save. Correlation refresh no longer stalls when switching merged tabs. Diff view properly cleans up state on close. +- **Intune cross-file sorting** (PR #78): Unified timeline sorting across multiple Intune log files now sorts by datetime consistently, fixing out-of-order entries when merging rotated log files. +- **Code review fixes** (PR #81): Scroll sync no longer fights user interaction. Session restore validates file paths before loading. Merge entry deduplication uses stable sort. UTF-8 safety enforced in merge filename display. GUID casing normalized to lowercase for consistent lookups. - **Rotated AppWorkload parsing**: Rotated AppWorkload files (e.g., `AppWorkload-20260401-160729.log`) now correctly parse as LogicalRecord framing instead of falling back to PhysicalLine. Changed IME filename detection from exact match to prefix match. - **GUID extraction priority**: App GUID extraction now prefers "for app " patterns over generic first-GUID matching, preventing user GUIDs from being used as app identifiers in StatusReport lines. -- **Tab close cleanup**: Closing the last tab now properly clears log content, filters, and UI state. +- **Tab close cleanup**: Closing the last tab now properly clears log content, filters, and UI state (#55). - **Button types**: Added explicit `type="button"` to prevent unintended form submissions. +- **Sysmon feature gating**: Sysmon module properly gated behind `sysmon` feature flag to prevent compilation on non-Windows targets without the feature enabled. Fixed clippy warnings in sysmon module. +- **Graph API merge conflicts**: Resolved integration conflicts between Graph API GUID resolution and existing Intune pipeline code. ### Changed - **Columns determined by parser**: Active columns are now derived from the detected parser format, not user toggles. Removed `hiddenColumns` from UI state. +### Security + +- **CI workflow permissions**: Added explicit `contents: read` and `actions: read` permissions to CI workflow to follow least-privilege principle. + ## [1.0.3] - 2026-03-31 ### Added diff --git a/src/components/dialogs/DiffConfigDialog.tsx b/src/components/dialogs/DiffConfigDialog.tsx index c25a77f4e..4d63a7cc8 100644 --- a/src/components/dialogs/DiffConfigDialog.tsx +++ b/src/components/dialogs/DiffConfigDialog.tsx @@ -26,14 +26,18 @@ export function DiffConfigDialog({ isOpen, onClose, onCompare }: DiffConfigDialo const [fileB, setFileB] = useState(""); const tabOptions = useMemo(() => { - return openTabs.map((tab) => { - const snapshot = getCachedTabSnapshot(tab.filePath); - const count = snapshot?.entries.length ?? 0; - return { filePath: tab.filePath, fileName: tab.fileName, entryCount: count }; - }); + return openTabs + .filter((tab) => tab.fileKind === "log") + .map((tab) => { + const snapshot = getCachedTabSnapshot(tab.filePath); + const count = snapshot?.entries.length ?? 0; + return { filePath: tab.filePath, fileName: tab.fileName, entryCount: count }; + }); }, [openTabs]); - const canCompare = fileA !== "" && fileB !== "" && fileA !== fileB; + const hasFileA = tabOptions.some((option) => option.filePath === fileA); + const hasFileB = tabOptions.some((option) => option.filePath === fileB); + const canCompare = fileA !== "" && fileB !== "" && fileA !== fileB && hasFileA && hasFileB; const handleCompare = () => { if (!canCompare) return; diff --git a/src/components/layout/FileSidebar.tsx b/src/components/layout/FileSidebar.tsx index c28bd86d5..63d97760d 100644 --- a/src/components/layout/FileSidebar.tsx +++ b/src/components/layout/FileSidebar.tsx @@ -17,6 +17,7 @@ import { getActiveSourceLabel, getActiveSourcePath, getBaseName, + getCachedTabSnapshot, getSourceFailureReason, useLogStore, } from "../../stores/log-store"; @@ -561,8 +562,10 @@ function LogSidebar() {
+ } + /> + + ); +} +``` + +**Important:** Before writing this file, check whether `SourceSummaryCard` and `getBaseName` are exported from FileSidebar.tsx. If `SourceSummaryCard` is not exported, you need to export it. If `getBaseName` is not exported, either export it from a shared utility or copy it into SysmonSidebar.tsx as shown above. + +- [ ] **Step 3: Update FileSidebar.tsx** + +1. Remove the inline `SysmonSidebar()` function (lines 817-855) +2. Add an import at the top: `import { SysmonSidebar } from "../../workspaces/sysmon/SysmonSidebar";` +3. The sidebar routing (line ~1063) already references `` — it should now use the imported version +4. Remove the `useSysmonStore` import from FileSidebar.tsx if no other code in the file uses it + +- [ ] **Step 4: Verify types compile** + +Run: `npx tsc --noEmit` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/workspaces/sysmon/SysmonSidebar.tsx src/components/layout/FileSidebar.tsx +git commit -m "refactor(sysmon): extract SysmonSidebar to src/workspaces/sysmon/" +``` + +--- + +### Task 8: Create sysmon workspace definition and register it + +**Files:** +- Create: `src/workspaces/sysmon/index.ts` +- Modify: `src/workspaces/registry.ts` — add sysmon import and registration + +- [ ] **Step 1: Create the workspace definition** + +```typescript +// src/workspaces/sysmon/index.ts +import { lazy } from "react"; +import type { WorkspaceDefinition } from "../types"; + +export const sysmonWorkspace: WorkspaceDefinition = { + id: "sysmon", + label: "Sysmon", + platforms: ["windows"], + component: lazy(() => + import("./SysmonWorkspace").then((m) => ({ default: m.SysmonWorkspace })) + ), + sidebar: lazy(() => + import("./SysmonSidebar").then((m) => ({ default: m.SysmonSidebar })) + ), + capabilities: {}, + fileFilters: [ + { name: "EVTX Files", extensions: ["evtx"] }, + { name: "All Files", extensions: ["*"] }, + ], + actionLabels: { + file: "Open EVTX File", + folder: "Open EVTX Folder", + placeholder: "Open Sysmon Source...", + }, +}; +``` + +Note: `onOpenSource` is intentionally omitted here — it will be wired up when consumers are refactored to use the registry (Phase 3, a future plan). For now, the definition captures metadata only. + +- [ ] **Step 2: Register sysmon in the registry** + +Update `src/workspaces/registry.ts`: + +```typescript +// src/workspaces/registry.ts +import type { PlatformKind, WorkspaceId } from "../types/log"; +import type { WorkspaceDefinition } from "./types"; +import { sysmonWorkspace } from "./sysmon"; + +const ALL_WORKSPACES: WorkspaceDefinition[] = [ + sysmonWorkspace, +]; + +export const workspaceRegistry = new Map( + ALL_WORKSPACES.map((ws) => [ws.id, ws]), +); + +export function getWorkspace(id: WorkspaceId): WorkspaceDefinition { + const ws = workspaceRegistry.get(id); + if (!ws) throw new Error(`Unknown workspace: ${id}`); + return ws; +} + +export function getAvailableWorkspaces( + platform: PlatformKind, + enabledWorkspaces?: readonly WorkspaceId[] | null, +): WorkspaceDefinition[] { + const enabled = enabledWorkspaces ? new Set(enabledWorkspaces) : null; + return ALL_WORKSPACES.filter((ws) => { + if (enabled && !enabled.has(ws.id)) return false; + return ws.platforms === "all" || ws.platforms.includes(platform); + }); +} +``` + +- [ ] **Step 3: Verify types compile** + +Run: `npx tsc --noEmit` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add src/workspaces/sysmon/index.ts src/workspaces/registry.ts +git commit -m "feat(workspaces): register sysmon as first workspace definition" +``` + +--- + +### Task 9: Final verification and cleanup + +**Files:** +- Verify: all files in `src/workspaces/sysmon/` +- Verify: no stale imports remain + +- [ ] **Step 1: Verify no references to old paths remain** + +Run these searches and confirm zero results: + +```bash +# Old type path +grep -r "types/sysmon" src/ --include="*.ts" --include="*.tsx" + +# Old store path +grep -r "stores/sysmon-store" src/ --include="*.ts" --include="*.tsx" + +# Old component path +grep -r "components/sysmon/" src/ --include="*.ts" --include="*.tsx" + +# Old hook path +grep -r "hooks/use-sysmon-analysis-progress" src/ --include="*.ts" --include="*.tsx" +``` + +Expected: No matches for any of these. + +- [ ] **Step 2: Verify the old directories are clean** + +```bash +# Should not exist +ls src/components/sysmon/ 2>&1 # expect: No such file or directory +ls src/types/sysmon.ts 2>&1 # expect: No such file or directory +ls src/stores/sysmon-store.ts 2>&1 # expect: No such file or directory +ls src/hooks/use-sysmon-analysis-progress.ts 2>&1 # expect: No such file or directory +``` + +- [ ] **Step 3: Verify the new workspace structure** + +```bash +ls src/workspaces/sysmon/ +``` + +Expected files: +``` +DashboardEventTypeChart.tsx +DashboardMetricCards.tsx +DashboardSecurityAlerts.tsx +DashboardTimeline.tsx +DashboardTopList.tsx +SysmonConfigView.tsx +SysmonDashboardView.tsx +SysmonEventTable.tsx +SysmonSidebar.tsx +SysmonSummaryView.tsx +SysmonWorkspace.tsx +index.ts +sysmon-store.ts +types.ts +use-sysmon-analysis-progress.ts +``` + +- [ ] **Step 4: Full type check** + +Run: `npx tsc --noEmit` +Expected: PASS with zero errors + +- [ ] **Step 5: Verify app builds** + +Run: `npm run frontend:build` +Expected: PASS — Vite build succeeds + +- [ ] **Step 6: Commit if any cleanup was needed** + +```bash +git add -A +git commit -m "chore(sysmon): final cleanup of workspace migration" +``` + +Only commit if there were changes to make. If Steps 1-5 all pass with no changes needed, skip this step. From 520ff4fd737b7cb5b8ceb03e0be8ce32d090972d Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 5 Apr 2026 16:45:53 -0400 Subject: [PATCH 41/66] feat(workspaces): add WorkspaceDefinition types --- src/workspaces/types.ts | 47 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/workspaces/types.ts diff --git a/src/workspaces/types.ts b/src/workspaces/types.ts new file mode 100644 index 000000000..3e066a3ea --- /dev/null +++ b/src/workspaces/types.ts @@ -0,0 +1,47 @@ +// src/workspaces/types.ts +import type { LazyExoticComponent, ComponentType } from "react"; +import type { LogSource, PlatformKind, WorkspaceId } from "../types/log"; + +export interface DialogFilter { + name: string; + extensions: string[]; +} + +export interface WorkspaceActionLabels { + file?: string; + folder?: string; + placeholder?: string; +} + +export interface WorkspaceCapabilities { + tabStrip?: boolean; + findBar?: boolean; + detailsPane?: boolean; + infoPane?: boolean; + footerBar?: boolean; + multiFileDrop?: boolean; + fontSizing?: boolean; +} + +export interface WorkspaceDefinition { + /** Unique workspace identifier. */ + id: WorkspaceId; + /** Human-readable label shown in toolbar dropdown. */ + label: string; + /** Platforms this workspace is available on. "all" means no restriction. */ + platforms: PlatformKind[] | "all"; + /** Lazy-loaded main workspace component. */ + component: LazyExoticComponent; + /** Lazy-loaded sidebar component. Omit for no sidebar. */ + sidebar?: LazyExoticComponent; + /** Boolean capability flags. All default to false if omitted. */ + capabilities?: WorkspaceCapabilities; + /** File dialog filters for the "Open File" action. */ + fileFilters?: DialogFilter[]; + /** Labels for toolbar open-file/folder buttons. */ + actionLabels?: WorkspaceActionLabels; + /** Handler for opening a source in this workspace. */ + onOpenSource?: (source: LogSource, trigger: string) => Promise; + /** Handler for opening a path directly (drag-and-drop, file association). */ + onOpenPath?: (path: string) => Promise; +} From 7aa152b359dab0a2275531c713c664bedb8c773a Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 5 Apr 2026 16:46:09 -0400 Subject: [PATCH 42/66] feat(workspaces): add central workspace registry --- src/workspaces/registry.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/workspaces/registry.ts diff --git a/src/workspaces/registry.ts b/src/workspaces/registry.ts new file mode 100644 index 000000000..10adec06e --- /dev/null +++ b/src/workspaces/registry.ts @@ -0,0 +1,26 @@ +// src/workspaces/registry.ts +import type { PlatformKind, WorkspaceId } from "../types/log"; +import type { WorkspaceDefinition } from "./types"; + +const ALL_WORKSPACES: WorkspaceDefinition[] = []; + +export const workspaceRegistry = new Map( + ALL_WORKSPACES.map((ws) => [ws.id, ws]), +); + +export function getWorkspace(id: WorkspaceId): WorkspaceDefinition { + const ws = workspaceRegistry.get(id); + if (!ws) throw new Error(`Unknown workspace: ${id}`); + return ws; +} + +export function getAvailableWorkspaces( + platform: PlatformKind, + enabledWorkspaces?: readonly WorkspaceId[] | null, +): WorkspaceDefinition[] { + const enabled = enabledWorkspaces ? new Set(enabledWorkspaces) : null; + return ALL_WORKSPACES.filter((ws) => { + if (enabled && !enabled.has(ws.id)) return false; + return ws.platforms === "all" || ws.platforms.includes(platform); + }); +} From 1cac6376cbce824b1c4d9e0d97f57dd24f64153f Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 5 Apr 2026 16:48:04 -0400 Subject: [PATCH 43/66] refactor(sysmon): move sysmon types to src/workspaces/sysmon/ Co-Authored-By: Claude Sonnet 4.6 --- src/components/sysmon/DashboardEventTypeChart.tsx | 2 +- src/components/sysmon/DashboardMetricCards.tsx | 2 +- src/components/sysmon/DashboardSecurityAlerts.tsx | 2 +- src/components/sysmon/DashboardTimeline.tsx | 2 +- src/components/sysmon/DashboardTopList.tsx | 2 +- src/components/sysmon/SysmonEventTable.tsx | 2 +- src/lib/commands.ts | 2 +- src/stores/sysmon-store.ts | 2 +- src/{types/sysmon.ts => workspaces/sysmon/types.ts} | 0 9 files changed, 8 insertions(+), 8 deletions(-) rename src/{types/sysmon.ts => workspaces/sysmon/types.ts} (100%) diff --git a/src/components/sysmon/DashboardEventTypeChart.tsx b/src/components/sysmon/DashboardEventTypeChart.tsx index 8f59bf920..d551573a3 100644 --- a/src/components/sysmon/DashboardEventTypeChart.tsx +++ b/src/components/sysmon/DashboardEventTypeChart.tsx @@ -1,6 +1,6 @@ import { tokens } from "@fluentui/react-components"; import { DonutChart } from "@fluentui/react-charts"; -import type { SysmonSummary } from "../../types/sysmon"; +import type { SysmonSummary } from "../../workspaces/sysmon/types"; interface DashboardEventTypeChartProps { summary: SysmonSummary; diff --git a/src/components/sysmon/DashboardMetricCards.tsx b/src/components/sysmon/DashboardMetricCards.tsx index 6c8a12a17..18c4b68ad 100644 --- a/src/components/sysmon/DashboardMetricCards.tsx +++ b/src/components/sysmon/DashboardMetricCards.tsx @@ -1,5 +1,5 @@ import { tokens } from "@fluentui/react-components"; -import type { SysmonSummary } from "../../types/sysmon"; +import type { SysmonSummary } from "../../workspaces/sysmon/types"; interface DashboardMetricCardsProps { summary: SysmonSummary; diff --git a/src/components/sysmon/DashboardSecurityAlerts.tsx b/src/components/sysmon/DashboardSecurityAlerts.tsx index de30e0faa..ea1576797 100644 --- a/src/components/sysmon/DashboardSecurityAlerts.tsx +++ b/src/components/sysmon/DashboardSecurityAlerts.tsx @@ -1,5 +1,5 @@ import { tokens, Badge } from "@fluentui/react-components"; -import type { SecuritySummary } from "../../types/sysmon"; +import type { SecuritySummary } from "../../workspaces/sysmon/types"; interface DashboardSecurityAlertsProps { securityEvents: SecuritySummary; diff --git a/src/components/sysmon/DashboardTimeline.tsx b/src/components/sysmon/DashboardTimeline.tsx index e890a936d..5a6c6a06e 100644 --- a/src/components/sysmon/DashboardTimeline.tsx +++ b/src/components/sysmon/DashboardTimeline.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { tokens, Dropdown, Option } from "@fluentui/react-components"; import { VerticalBarChart } from "@fluentui/react-charts"; -import type { SysmonDashboardData, TimeBucket } from "../../types/sysmon"; +import type { SysmonDashboardData, TimeBucket } from "../../workspaces/sysmon/types"; interface DashboardTimelineProps { dashboard: SysmonDashboardData; diff --git a/src/components/sysmon/DashboardTopList.tsx b/src/components/sysmon/DashboardTopList.tsx index 4e4c99e7e..6085f1f42 100644 --- a/src/components/sysmon/DashboardTopList.tsx +++ b/src/components/sysmon/DashboardTopList.tsx @@ -1,6 +1,6 @@ import { tokens } from "@fluentui/react-components"; import { HorizontalBarChart } from "@fluentui/react-charts"; -import type { RankedItem } from "../../types/sysmon"; +import type { RankedItem } from "../../workspaces/sysmon/types"; interface DashboardTopListProps { title: string; diff --git a/src/components/sysmon/SysmonEventTable.tsx b/src/components/sysmon/SysmonEventTable.tsx index 82335c9d6..f695c0fad 100644 --- a/src/components/sysmon/SysmonEventTable.tsx +++ b/src/components/sysmon/SysmonEventTable.tsx @@ -5,7 +5,7 @@ import { getLogListMetrics, LOG_MONOSPACE_FONT_FAMILY } from "../../lib/log-acce import { useSysmonStore } from "../../stores/sysmon-store"; import { useUiStore } from "../../stores/ui-store"; import { getThemeById } from "../../lib/themes"; -import type { SysmonEvent } from "../../types/sysmon"; +import type { SysmonEvent } from "../../workspaces/sysmon/types"; const DETAIL_HEIGHT = 200; diff --git a/src/lib/commands.ts b/src/lib/commands.ts index e99771a22..aaea7d7d6 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -11,7 +11,7 @@ import type { import type { EvidenceArtifactPreview, EvidenceBundleDetails, EvidenceArtifactIntakeKind } from "../types/evidence"; import type { RegistryParseResult } from "../types/registry"; import type { IntuneAnalysisResult } from "../types/intune"; -import type { SysmonAnalysisResult } from "../types/sysmon"; +import type { SysmonAnalysisResult } from "../workspaces/sysmon/types"; import type { DsregcmdAnalysisResult, DsregcmdCaptureResult, diff --git a/src/stores/sysmon-store.ts b/src/stores/sysmon-store.ts index f26b44a27..c8f48d1ae 100644 --- a/src/stores/sysmon-store.ts +++ b/src/stores/sysmon-store.ts @@ -7,7 +7,7 @@ import type { SysmonEventType, SysmonSeverity, SysmonSummary, -} from "../types/sysmon"; +} from "../workspaces/sysmon/types"; export type SysmonWorkspaceTab = "dashboard" | "events" | "summary" | "config"; diff --git a/src/types/sysmon.ts b/src/workspaces/sysmon/types.ts similarity index 100% rename from src/types/sysmon.ts rename to src/workspaces/sysmon/types.ts From b383d6635c5d5af0452d748734f8d49269b9c11c Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 5 Apr 2026 16:49:43 -0400 Subject: [PATCH 44/66] refactor(sysmon): move sysmon store to src/workspaces/sysmon/ Co-Authored-By: Claude Sonnet 4.6 --- src/components/layout/FileSidebar.tsx | 2 +- src/components/layout/StatusBar.tsx | 2 +- src/components/layout/Toolbar.tsx | 2 +- src/components/sysmon/SysmonConfigView.tsx | 2 +- src/components/sysmon/SysmonDashboardView.tsx | 2 +- src/components/sysmon/SysmonEventTable.tsx | 2 +- src/components/sysmon/SysmonSummaryView.tsx | 2 +- src/components/sysmon/SysmonWorkspace.tsx | 2 +- src/hooks/use-sysmon-analysis-progress.ts | 2 +- src/{stores => workspaces/sysmon}/sysmon-store.ts | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) rename src/{stores => workspaces/sysmon}/sysmon-store.ts (99%) diff --git a/src/components/layout/FileSidebar.tsx b/src/components/layout/FileSidebar.tsx index 63d97760d..b78e989c6 100644 --- a/src/components/layout/FileSidebar.tsx +++ b/src/components/layout/FileSidebar.tsx @@ -12,7 +12,7 @@ import { loadLogSource, loadSelectedLogFile } from "../../lib/log-source"; import { useFilterStore } from "../../stores/filter-store"; import { useIntuneStore } from "../../stores/intune-store"; import { useDsregcmdStore } from "../../stores/dsregcmd-store"; -import { useSysmonStore } from "../../stores/sysmon-store"; +import { useSysmonStore } from "../../workspaces/sysmon/sysmon-store"; import { getActiveSourceLabel, getActiveSourcePath, diff --git a/src/components/layout/StatusBar.tsx b/src/components/layout/StatusBar.tsx index b80bb8764..36a78c82d 100644 --- a/src/components/layout/StatusBar.tsx +++ b/src/components/layout/StatusBar.tsx @@ -21,7 +21,7 @@ import { import { useIntuneStore } from "../../stores/intune-store"; import { useDsregcmdStore } from "../../stores/dsregcmd-store"; import { useDeploymentStore } from "../../stores/deployment-store"; -import { useSysmonStore } from "../../stores/sysmon-store"; +import { useSysmonStore } from "../../workspaces/sysmon/sysmon-store"; import { useEvtxStore } from "../../stores/evtx-store"; interface SeverityCounts { diff --git a/src/components/layout/Toolbar.tsx b/src/components/layout/Toolbar.tsx index d98622107..cb4541e3a 100644 --- a/src/components/layout/Toolbar.tsx +++ b/src/components/layout/Toolbar.tsx @@ -34,7 +34,7 @@ import { useLogStore } from "../../stores/log-store"; import { useFilterStore } from "../../stores/filter-store"; import { useIntuneStore } from "../../stores/intune-store"; import { useDsregcmdStore } from "../../stores/dsregcmd-store"; -import { useSysmonStore } from "../../stores/sysmon-store"; +import { useSysmonStore } from "../../workspaces/sysmon/sysmon-store"; import { isIntuneWorkspace, getAvailableWorkspaces, type IntuneWorkspaceId, type WorkspaceId, type PlatformId, useUiStore } from "../../stores/ui-store"; import { ThemePicker } from "./ThemePicker"; import { diff --git a/src/components/sysmon/SysmonConfigView.tsx b/src/components/sysmon/SysmonConfigView.tsx index feb628863..d53ce4369 100644 --- a/src/components/sysmon/SysmonConfigView.tsx +++ b/src/components/sysmon/SysmonConfigView.tsx @@ -1,5 +1,5 @@ import { tokens } from "@fluentui/react-components"; -import { useSysmonStore } from "../../stores/sysmon-store"; +import { useSysmonStore } from "../../workspaces/sysmon/sysmon-store"; import { LOG_MONOSPACE_FONT_FAMILY } from "../../lib/log-accessibility"; export function SysmonConfigView() { diff --git a/src/components/sysmon/SysmonDashboardView.tsx b/src/components/sysmon/SysmonDashboardView.tsx index f009e5922..56c26b4a6 100644 --- a/src/components/sysmon/SysmonDashboardView.tsx +++ b/src/components/sysmon/SysmonDashboardView.tsx @@ -1,5 +1,5 @@ import { tokens } from "@fluentui/react-components"; -import { useSysmonStore } from "../../stores/sysmon-store"; +import { useSysmonStore } from "../../workspaces/sysmon/sysmon-store"; import { DashboardMetricCards } from "./DashboardMetricCards"; import { DashboardTimeline } from "./DashboardTimeline"; import { DashboardEventTypeChart } from "./DashboardEventTypeChart"; diff --git a/src/components/sysmon/SysmonEventTable.tsx b/src/components/sysmon/SysmonEventTable.tsx index f695c0fad..3b15abdad 100644 --- a/src/components/sysmon/SysmonEventTable.tsx +++ b/src/components/sysmon/SysmonEventTable.tsx @@ -2,7 +2,7 @@ import { useMemo, useRef } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { tokens } from "@fluentui/react-components"; import { getLogListMetrics, LOG_MONOSPACE_FONT_FAMILY } from "../../lib/log-accessibility"; -import { useSysmonStore } from "../../stores/sysmon-store"; +import { useSysmonStore } from "../../workspaces/sysmon/sysmon-store"; import { useUiStore } from "../../stores/ui-store"; import { getThemeById } from "../../lib/themes"; import type { SysmonEvent } from "../../workspaces/sysmon/types"; diff --git a/src/components/sysmon/SysmonSummaryView.tsx b/src/components/sysmon/SysmonSummaryView.tsx index 064ee9a3f..753a359c6 100644 --- a/src/components/sysmon/SysmonSummaryView.tsx +++ b/src/components/sysmon/SysmonSummaryView.tsx @@ -1,5 +1,5 @@ import { tokens } from "@fluentui/react-components"; -import { useSysmonStore } from "../../stores/sysmon-store"; +import { useSysmonStore } from "../../workspaces/sysmon/sysmon-store"; export function SysmonSummaryView() { const summary = useSysmonStore((s) => s.summary); diff --git a/src/components/sysmon/SysmonWorkspace.tsx b/src/components/sysmon/SysmonWorkspace.tsx index 26e1848c8..6136a4821 100644 --- a/src/components/sysmon/SysmonWorkspace.tsx +++ b/src/components/sysmon/SysmonWorkspace.tsx @@ -1,5 +1,5 @@ import { tokens, Button, Spinner, Tab, TabList } from "@fluentui/react-components"; -import { useSysmonStore, type SysmonWorkspaceTab } from "../../stores/sysmon-store"; +import { useSysmonStore, type SysmonWorkspaceTab } from "../../workspaces/sysmon/sysmon-store"; import { useAppActions } from "../layout/Toolbar"; import { SysmonEventTable } from "./SysmonEventTable"; import { SysmonSummaryView } from "./SysmonSummaryView"; diff --git a/src/hooks/use-sysmon-analysis-progress.ts b/src/hooks/use-sysmon-analysis-progress.ts index 1e11b355e..35489fcb4 100644 --- a/src/hooks/use-sysmon-analysis-progress.ts +++ b/src/hooks/use-sysmon-analysis-progress.ts @@ -1,6 +1,6 @@ import { useEffect } from "react"; import { listen } from "@tauri-apps/api/event"; -import { useSysmonStore, type SysmonAnalysisProgress } from "../stores/sysmon-store"; +import { useSysmonStore, type SysmonAnalysisProgress } from "../workspaces/sysmon/sysmon-store"; const SYSMON_ANALYSIS_PROGRESS_EVENT = "sysmon-analysis-progress"; diff --git a/src/stores/sysmon-store.ts b/src/workspaces/sysmon/sysmon-store.ts similarity index 99% rename from src/stores/sysmon-store.ts rename to src/workspaces/sysmon/sysmon-store.ts index c8f48d1ae..df3398db2 100644 --- a/src/stores/sysmon-store.ts +++ b/src/workspaces/sysmon/sysmon-store.ts @@ -7,7 +7,7 @@ import type { SysmonEventType, SysmonSeverity, SysmonSummary, -} from "../workspaces/sysmon/types"; +} from "./types"; export type SysmonWorkspaceTab = "dashboard" | "events" | "summary" | "config"; From 2d6eee18dc57aa69d638088aa3dc31b02a7546ea Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 5 Apr 2026 16:51:59 -0400 Subject: [PATCH 45/66] refactor(sysmon): move sysmon components to src/workspaces/sysmon/ Co-Authored-By: Claude Sonnet 4.6 --- src/components/layout/AppShell.tsx | 2 +- .../sysmon/DashboardEventTypeChart.tsx | 2 +- .../sysmon/DashboardMetricCards.tsx | 2 +- .../sysmon/DashboardSecurityAlerts.tsx | 2 +- src/{components => workspaces}/sysmon/DashboardTimeline.tsx | 2 +- src/{components => workspaces}/sysmon/DashboardTopList.tsx | 2 +- src/{components => workspaces}/sysmon/SysmonConfigView.tsx | 2 +- src/{components => workspaces}/sysmon/SysmonDashboardView.tsx | 2 +- src/{components => workspaces}/sysmon/SysmonEventTable.tsx | 4 ++-- src/{components => workspaces}/sysmon/SysmonSummaryView.tsx | 2 +- src/{components => workspaces}/sysmon/SysmonWorkspace.tsx | 4 ++-- 11 files changed, 13 insertions(+), 13 deletions(-) rename src/{components => workspaces}/sysmon/DashboardEventTypeChart.tsx (96%) rename src/{components => workspaces}/sysmon/DashboardMetricCards.tsx (97%) rename src/{components => workspaces}/sysmon/DashboardSecurityAlerts.tsx (97%) rename src/{components => workspaces}/sysmon/DashboardTimeline.tsx (97%) rename src/{components => workspaces}/sysmon/DashboardTopList.tsx (96%) rename src/{components => workspaces}/sysmon/SysmonConfigView.tsx (98%) rename src/{components => workspaces}/sysmon/SysmonDashboardView.tsx (98%) rename src/{components => workspaces}/sysmon/SysmonEventTable.tsx (98%) rename src/{components => workspaces}/sysmon/SysmonSummaryView.tsx (97%) rename src/{components => workspaces}/sysmon/SysmonWorkspace.tsx (96%) diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 9b59adbe2..0ff950a5f 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -27,7 +27,7 @@ import { DsregcmdWorkspace } from "../dsregcmd/DsregcmdWorkspace"; import { MacosDiagWorkspace } from "../macos-diag/MacosDiagWorkspace"; import { DeploymentWorkspace } from "../deployment/DeploymentWorkspace"; import { EventLogWorkspace } from "../event-log-workspace/EventLogWorkspace"; -import { SysmonWorkspace } from "../sysmon/SysmonWorkspace"; +import { SysmonWorkspace } from "../../workspaces/sysmon/SysmonWorkspace"; import { RegistryViewer } from "../registry-view/RegistryViewer"; import type { FilterClause } from "../dialogs/FilterDialog"; import type { LogEntry } from "../../types/log"; diff --git a/src/components/sysmon/DashboardEventTypeChart.tsx b/src/workspaces/sysmon/DashboardEventTypeChart.tsx similarity index 96% rename from src/components/sysmon/DashboardEventTypeChart.tsx rename to src/workspaces/sysmon/DashboardEventTypeChart.tsx index d551573a3..b1699f33c 100644 --- a/src/components/sysmon/DashboardEventTypeChart.tsx +++ b/src/workspaces/sysmon/DashboardEventTypeChart.tsx @@ -1,6 +1,6 @@ import { tokens } from "@fluentui/react-components"; import { DonutChart } from "@fluentui/react-charts"; -import type { SysmonSummary } from "../../workspaces/sysmon/types"; +import type { SysmonSummary } from "./types"; interface DashboardEventTypeChartProps { summary: SysmonSummary; diff --git a/src/components/sysmon/DashboardMetricCards.tsx b/src/workspaces/sysmon/DashboardMetricCards.tsx similarity index 97% rename from src/components/sysmon/DashboardMetricCards.tsx rename to src/workspaces/sysmon/DashboardMetricCards.tsx index 18c4b68ad..625753942 100644 --- a/src/components/sysmon/DashboardMetricCards.tsx +++ b/src/workspaces/sysmon/DashboardMetricCards.tsx @@ -1,5 +1,5 @@ import { tokens } from "@fluentui/react-components"; -import type { SysmonSummary } from "../../workspaces/sysmon/types"; +import type { SysmonSummary } from "./types"; interface DashboardMetricCardsProps { summary: SysmonSummary; diff --git a/src/components/sysmon/DashboardSecurityAlerts.tsx b/src/workspaces/sysmon/DashboardSecurityAlerts.tsx similarity index 97% rename from src/components/sysmon/DashboardSecurityAlerts.tsx rename to src/workspaces/sysmon/DashboardSecurityAlerts.tsx index ea1576797..73898b548 100644 --- a/src/components/sysmon/DashboardSecurityAlerts.tsx +++ b/src/workspaces/sysmon/DashboardSecurityAlerts.tsx @@ -1,5 +1,5 @@ import { tokens, Badge } from "@fluentui/react-components"; -import type { SecuritySummary } from "../../workspaces/sysmon/types"; +import type { SecuritySummary } from "./types"; interface DashboardSecurityAlertsProps { securityEvents: SecuritySummary; diff --git a/src/components/sysmon/DashboardTimeline.tsx b/src/workspaces/sysmon/DashboardTimeline.tsx similarity index 97% rename from src/components/sysmon/DashboardTimeline.tsx rename to src/workspaces/sysmon/DashboardTimeline.tsx index 5a6c6a06e..93282322c 100644 --- a/src/components/sysmon/DashboardTimeline.tsx +++ b/src/workspaces/sysmon/DashboardTimeline.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { tokens, Dropdown, Option } from "@fluentui/react-components"; import { VerticalBarChart } from "@fluentui/react-charts"; -import type { SysmonDashboardData, TimeBucket } from "../../workspaces/sysmon/types"; +import type { SysmonDashboardData, TimeBucket } from "./types"; interface DashboardTimelineProps { dashboard: SysmonDashboardData; diff --git a/src/components/sysmon/DashboardTopList.tsx b/src/workspaces/sysmon/DashboardTopList.tsx similarity index 96% rename from src/components/sysmon/DashboardTopList.tsx rename to src/workspaces/sysmon/DashboardTopList.tsx index 6085f1f42..5c7b7754e 100644 --- a/src/components/sysmon/DashboardTopList.tsx +++ b/src/workspaces/sysmon/DashboardTopList.tsx @@ -1,6 +1,6 @@ import { tokens } from "@fluentui/react-components"; import { HorizontalBarChart } from "@fluentui/react-charts"; -import type { RankedItem } from "../../workspaces/sysmon/types"; +import type { RankedItem } from "./types"; interface DashboardTopListProps { title: string; diff --git a/src/components/sysmon/SysmonConfigView.tsx b/src/workspaces/sysmon/SysmonConfigView.tsx similarity index 98% rename from src/components/sysmon/SysmonConfigView.tsx rename to src/workspaces/sysmon/SysmonConfigView.tsx index d53ce4369..2db5cfa02 100644 --- a/src/components/sysmon/SysmonConfigView.tsx +++ b/src/workspaces/sysmon/SysmonConfigView.tsx @@ -1,5 +1,5 @@ import { tokens } from "@fluentui/react-components"; -import { useSysmonStore } from "../../workspaces/sysmon/sysmon-store"; +import { useSysmonStore } from "./sysmon-store"; import { LOG_MONOSPACE_FONT_FAMILY } from "../../lib/log-accessibility"; export function SysmonConfigView() { diff --git a/src/components/sysmon/SysmonDashboardView.tsx b/src/workspaces/sysmon/SysmonDashboardView.tsx similarity index 98% rename from src/components/sysmon/SysmonDashboardView.tsx rename to src/workspaces/sysmon/SysmonDashboardView.tsx index 56c26b4a6..37d63fbc4 100644 --- a/src/components/sysmon/SysmonDashboardView.tsx +++ b/src/workspaces/sysmon/SysmonDashboardView.tsx @@ -1,5 +1,5 @@ import { tokens } from "@fluentui/react-components"; -import { useSysmonStore } from "../../workspaces/sysmon/sysmon-store"; +import { useSysmonStore } from "./sysmon-store"; import { DashboardMetricCards } from "./DashboardMetricCards"; import { DashboardTimeline } from "./DashboardTimeline"; import { DashboardEventTypeChart } from "./DashboardEventTypeChart"; diff --git a/src/components/sysmon/SysmonEventTable.tsx b/src/workspaces/sysmon/SysmonEventTable.tsx similarity index 98% rename from src/components/sysmon/SysmonEventTable.tsx rename to src/workspaces/sysmon/SysmonEventTable.tsx index 3b15abdad..f8e4baaf8 100644 --- a/src/components/sysmon/SysmonEventTable.tsx +++ b/src/workspaces/sysmon/SysmonEventTable.tsx @@ -2,10 +2,10 @@ import { useMemo, useRef } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { tokens } from "@fluentui/react-components"; import { getLogListMetrics, LOG_MONOSPACE_FONT_FAMILY } from "../../lib/log-accessibility"; -import { useSysmonStore } from "../../workspaces/sysmon/sysmon-store"; +import { useSysmonStore } from "./sysmon-store"; import { useUiStore } from "../../stores/ui-store"; import { getThemeById } from "../../lib/themes"; -import type { SysmonEvent } from "../../workspaces/sysmon/types"; +import type { SysmonEvent } from "./types"; const DETAIL_HEIGHT = 200; diff --git a/src/components/sysmon/SysmonSummaryView.tsx b/src/workspaces/sysmon/SysmonSummaryView.tsx similarity index 97% rename from src/components/sysmon/SysmonSummaryView.tsx rename to src/workspaces/sysmon/SysmonSummaryView.tsx index 753a359c6..64b837ebe 100644 --- a/src/components/sysmon/SysmonSummaryView.tsx +++ b/src/workspaces/sysmon/SysmonSummaryView.tsx @@ -1,5 +1,5 @@ import { tokens } from "@fluentui/react-components"; -import { useSysmonStore } from "../../workspaces/sysmon/sysmon-store"; +import { useSysmonStore } from "./sysmon-store"; export function SysmonSummaryView() { const summary = useSysmonStore((s) => s.summary); diff --git a/src/components/sysmon/SysmonWorkspace.tsx b/src/workspaces/sysmon/SysmonWorkspace.tsx similarity index 96% rename from src/components/sysmon/SysmonWorkspace.tsx rename to src/workspaces/sysmon/SysmonWorkspace.tsx index 6136a4821..0fa2bcf79 100644 --- a/src/components/sysmon/SysmonWorkspace.tsx +++ b/src/workspaces/sysmon/SysmonWorkspace.tsx @@ -1,6 +1,6 @@ import { tokens, Button, Spinner, Tab, TabList } from "@fluentui/react-components"; -import { useSysmonStore, type SysmonWorkspaceTab } from "../../workspaces/sysmon/sysmon-store"; -import { useAppActions } from "../layout/Toolbar"; +import { useSysmonStore, type SysmonWorkspaceTab } from "./sysmon-store"; +import { useAppActions } from "../../components/layout/Toolbar"; import { SysmonEventTable } from "./SysmonEventTable"; import { SysmonSummaryView } from "./SysmonSummaryView"; import { SysmonConfigView } from "./SysmonConfigView"; From 1cad7d118f0e45605180a4940a8d12970f3a90a6 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 5 Apr 2026 16:52:57 -0400 Subject: [PATCH 46/66] refactor(sysmon): move analysis progress hook to src/workspaces/sysmon/ Co-Authored-By: Claude Sonnet 4.6 --- src/components/layout/AppShell.tsx | 2 +- .../sysmon}/use-sysmon-analysis-progress.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/{hooks => workspaces/sysmon}/use-sysmon-analysis-progress.ts (84%) diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 0ff950a5f..c3b3a4508 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -37,7 +37,7 @@ import { useFilterStore } from "../../stores/filter-store"; import { switchToTab } from "../../lib/log-source"; import { useFileWatcher } from "../../hooks/use-file-watcher"; import { useIntuneAnalysisProgress } from "../../hooks/use-intune-analysis-progress"; -import { useSysmonAnalysisProgress } from "../../hooks/use-sysmon-analysis-progress"; +import { useSysmonAnalysisProgress } from "../../workspaces/sysmon/use-sysmon-analysis-progress"; import { useKeyboard } from "../../hooks/use-keyboard"; import { useDragDrop } from "../../hooks/use-drag-drop"; import { useFileAssociation } from "../../hooks/use-file-association"; diff --git a/src/hooks/use-sysmon-analysis-progress.ts b/src/workspaces/sysmon/use-sysmon-analysis-progress.ts similarity index 84% rename from src/hooks/use-sysmon-analysis-progress.ts rename to src/workspaces/sysmon/use-sysmon-analysis-progress.ts index 35489fcb4..008e4a4d1 100644 --- a/src/hooks/use-sysmon-analysis-progress.ts +++ b/src/workspaces/sysmon/use-sysmon-analysis-progress.ts @@ -1,6 +1,6 @@ import { useEffect } from "react"; import { listen } from "@tauri-apps/api/event"; -import { useSysmonStore, type SysmonAnalysisProgress } from "../workspaces/sysmon/sysmon-store"; +import { useSysmonStore, type SysmonAnalysisProgress } from "./sysmon-store"; const SYSMON_ANALYSIS_PROGRESS_EVENT = "sysmon-analysis-progress"; From ea65183f21e178004192ef9e01c42daf8f24633a Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 5 Apr 2026 16:54:33 -0400 Subject: [PATCH 47/66] refactor(sysmon): extract SysmonSidebar to src/workspaces/sysmon/ Co-Authored-By: Claude Sonnet 4.6 --- src/components/layout/FileSidebar.tsx | 41 +-------- src/workspaces/sysmon/SysmonSidebar.tsx | 113 ++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 40 deletions(-) create mode 100644 src/workspaces/sysmon/SysmonSidebar.tsx diff --git a/src/components/layout/FileSidebar.tsx b/src/components/layout/FileSidebar.tsx index b78e989c6..c38d5e79d 100644 --- a/src/components/layout/FileSidebar.tsx +++ b/src/components/layout/FileSidebar.tsx @@ -12,7 +12,7 @@ import { loadLogSource, loadSelectedLogFile } from "../../lib/log-source"; import { useFilterStore } from "../../stores/filter-store"; import { useIntuneStore } from "../../stores/intune-store"; import { useDsregcmdStore } from "../../stores/dsregcmd-store"; -import { useSysmonStore } from "../../workspaces/sysmon/sysmon-store"; +import { SysmonSidebar } from "../../workspaces/sysmon/SysmonSidebar"; import { getActiveSourceLabel, getActiveSourcePath, @@ -814,45 +814,6 @@ function IntuneSidebar() { ); } -function SysmonSidebar() { - const summary = useSysmonStore((s) => s.summary); - const sourcePath = useSysmonStore((s) => s.sourcePath); - const isAnalyzing = useSysmonStore((s) => s.isAnalyzing); - const analysisError = useSysmonStore((s) => s.analysisError); - const progressMessage = useSysmonStore((s) => s.progressMessage); - - const title = sourcePath ? getBaseName(sourcePath) : "Sysmon"; - const subtitle = sourcePath ?? "Open a folder containing Sysmon EVTX files to begin."; - - return ( - <> - - {isAnalyzing &&
{progressMessage ?? "Analyzing..."}
} - {analysisError &&
{analysisError}
} - {summary && ( - <> -
Events: {summary.totalEvents.toLocaleString()}
-
Processes: {summary.uniqueProcesses.toLocaleString()}
-
Files: {summary.sourceFiles.length}
- {summary.parseErrors > 0 && ( -
- Parse errors: {summary.parseErrors} -
- )} - - )} - {!isAnalyzing && !analysisError && !summary &&
Ready
} -
- } - /> - - ); -} function DsregcmdSidebar() { const result = useDsregcmdStore((s) => s.result); diff --git a/src/workspaces/sysmon/SysmonSidebar.tsx b/src/workspaces/sysmon/SysmonSidebar.tsx new file mode 100644 index 000000000..0f21d3948 --- /dev/null +++ b/src/workspaces/sysmon/SysmonSidebar.tsx @@ -0,0 +1,113 @@ +import type { ReactNode } from "react"; +import { Badge, Caption1, Subtitle2, tokens } from "@fluentui/react-components"; +import { useSysmonStore } from "./sysmon-store"; + +// Minimal inline utility — mirrors the one imported from log-store in FileSidebar.tsx +function getBaseName(path: string | null | undefined): string { + if (!path) return ""; + return path.split(/[\\/]/).pop() ?? ""; +} + +function SourceSummaryCard({ + badge, + title, + subtitle, + body, +}: { + badge: string; + title: string; + subtitle: string; + body: ReactNode; +}) { + return ( +
+ + {badge} + + + {title} + + + {subtitle} + +
{body}
+
+ ); +} + +export function SysmonSidebar() { + const summary = useSysmonStore((s) => s.summary); + const sourcePath = useSysmonStore((s) => s.sourcePath); + const isAnalyzing = useSysmonStore((s) => s.isAnalyzing); + const analysisError = useSysmonStore((s) => s.analysisError); + const progressMessage = useSysmonStore((s) => s.progressMessage); + + const title = sourcePath ? getBaseName(sourcePath) : "Sysmon"; + const subtitle = sourcePath ?? "Open a folder containing Sysmon EVTX files to begin."; + + return ( + <> + + {isAnalyzing &&
{progressMessage ?? "Analyzing..."}
} + {analysisError &&
{analysisError}
} + {summary && ( + <> +
Events: {summary.totalEvents.toLocaleString()}
+
Processes: {summary.uniqueProcesses.toLocaleString()}
+
Files: {summary.sourceFiles.length}
+ {summary.parseErrors > 0 && ( +
+ Parse errors: {summary.parseErrors} +
+ )} + + )} + {!isAnalyzing && !analysisError && !summary &&
Ready
} +
+ } + /> + + ); +} From 35d155bb9b2fbd5525e384cad69dcf9470926b38 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 5 Apr 2026 16:55:48 -0400 Subject: [PATCH 48/66] feat(workspaces): register sysmon as first workspace definition Co-Authored-By: Claude Sonnet 4.6 --- src/workspaces/registry.ts | 5 ++++- src/workspaces/sysmon/index.ts | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 src/workspaces/sysmon/index.ts diff --git a/src/workspaces/registry.ts b/src/workspaces/registry.ts index 10adec06e..2c9568b23 100644 --- a/src/workspaces/registry.ts +++ b/src/workspaces/registry.ts @@ -1,8 +1,11 @@ // src/workspaces/registry.ts import type { PlatformKind, WorkspaceId } from "../types/log"; import type { WorkspaceDefinition } from "./types"; +import { sysmonWorkspace } from "./sysmon"; -const ALL_WORKSPACES: WorkspaceDefinition[] = []; +const ALL_WORKSPACES: WorkspaceDefinition[] = [ + sysmonWorkspace, +]; export const workspaceRegistry = new Map( ALL_WORKSPACES.map((ws) => [ws.id, ws]), diff --git a/src/workspaces/sysmon/index.ts b/src/workspaces/sysmon/index.ts new file mode 100644 index 000000000..9e46d5aa0 --- /dev/null +++ b/src/workspaces/sysmon/index.ts @@ -0,0 +1,25 @@ +// src/workspaces/sysmon/index.ts +import { lazy } from "react"; +import type { WorkspaceDefinition } from "../types"; + +export const sysmonWorkspace: WorkspaceDefinition = { + id: "sysmon", + label: "Sysmon", + platforms: ["windows"], + component: lazy(() => + import("./SysmonWorkspace").then((m) => ({ default: m.SysmonWorkspace })) + ), + sidebar: lazy(() => + import("./SysmonSidebar").then((m) => ({ default: m.SysmonSidebar })) + ), + capabilities: {}, + fileFilters: [ + { name: "EVTX Files", extensions: ["evtx"] }, + { name: "All Files", extensions: ["*"] }, + ], + actionLabels: { + file: "Open EVTX File", + folder: "Open EVTX Folder", + placeholder: "Open Sysmon Source...", + }, +}; From eb9713cd9dd1af74662278394421a74da86578e1 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 5 Apr 2026 17:03:51 -0400 Subject: [PATCH 49/66] docs: add workspace registry Phase 3 implementation plan 6 tasks: shim definitions, AppShell/FileSidebar/ui-store/Toolbar refactors Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-04-05-workspace-registry-phase3.md | 429 ++++++++++++++++++ 1 file changed, 429 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-05-workspace-registry-phase3.md diff --git a/docs/superpowers/plans/2026-04-05-workspace-registry-phase3.md b/docs/superpowers/plans/2026-04-05-workspace-registry-phase3.md new file mode 100644 index 000000000..a0b2f51b9 --- /dev/null +++ b/docs/superpowers/plans/2026-04-05-workspace-registry-phase3.md @@ -0,0 +1,429 @@ +# Workspace Registry Phase 3: Wire Consumers to Registry + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace workspace-specific if/else chains in consumer files with registry lookups so that adding a new workspace only requires adding a definition — not touching AppShell, Toolbar, FileSidebar, or ui-store. + +**Architecture:** Create shim `WorkspaceDefinition` entries for all non-migrated workspaces that point to their current component locations. Then refactor consumers to use the registry. The shims are temporary — Phase 4 will replace them with real self-contained workspace folders. + +**Tech Stack:** React 19, TypeScript, Zustand, Tauri v2, Fluent UI, React.lazy/Suspense + +--- + +## File Structure + +**New files to create:** +- `src/workspaces/log/index.ts` — Shim definition +- `src/workspaces/intune/index.ts` — Shim definition +- `src/workspaces/new-intune/index.ts` — Shim definition +- `src/workspaces/dsregcmd/index.ts` — Shim definition +- `src/workspaces/macos-diag/index.ts` — Shim definition +- `src/workspaces/deployment/index.ts` — Shim definition +- `src/workspaces/event-log/index.ts` — Shim definition + +**Files to modify:** +- `src/workspaces/registry.ts` — Register all workspaces +- `src/components/layout/AppShell.tsx` — Replace renderWorkspace() with registry lookup +- `src/components/layout/FileSidebar.tsx` — Replace sidebar routing with registry lookup +- `src/stores/ui-store.ts` — Remove WORKSPACE_PLATFORM_MAP, use registry +- `src/components/layout/Toolbar.tsx` — Replace WORKSPACE_LABELS, file filters, action labels with registry lookups + +--- + +### Task 1: Create shim workspace definitions + +Create a shim `WorkspaceDefinition` for each non-migrated workspace. Each shim: +- Lives in `src/workspaces/{id}/index.ts` +- Uses `React.lazy()` to import the existing component from its current location +- Captures the metadata (label, platforms, file filters, action labels) currently hardcoded in Toolbar.tsx and ui-store.ts +- Does NOT include `onOpenSource` yet (Phase 3b concern) + +**Files to create:** + +- [ ] **Step 1: Create `src/workspaces/log/index.ts`** + +Read `src/components/layout/Toolbar.tsx` to get exact filter constants (LOG_FILE_DIALOG_FILTERS at lines 76-81). Then create: + +```typescript +import { lazy } from "react"; +import type { WorkspaceDefinition } from "../types"; + +export const logWorkspace: WorkspaceDefinition = { + id: "log", + label: "Log Explorer", + platforms: "all", + component: lazy(() => + import("../../components/log-view/LogListView").then((m) => ({ + default: m.LogListView, + })) + ), + capabilities: { + tabStrip: true, + findBar: true, + detailsPane: true, + infoPane: true, + footerBar: true, + multiFileDrop: true, + fontSizing: true, + }, + fileFilters: [ + { name: "Log Files", extensions: ["log", "txt", "csv", "json", "xml", "evtx"] }, + { name: "All Files", extensions: ["*"] }, + ], +}; +``` + +Note: The log workspace is the most complex — it has DiffView, TabStrip, etc. The `component` field only needs to point to the main view. The special rendering logic (DiffView switching, TabStrip) will remain in AppShell for now, gated by `workspace.capabilities?.tabStrip`. Read AppShell to understand the log workspace's renderWorkspace() logic and adapt the shim accordingly. + +- [ ] **Step 2: Create `src/workspaces/intune/index.ts`** + +```typescript +import { lazy } from "react"; +import type { WorkspaceDefinition } from "../types"; + +export const intuneWorkspace: WorkspaceDefinition = { + id: "intune", + label: "Intune Diagnostics", + platforms: "all", + component: lazy(() => + import("../../components/intune/IntuneDashboard").then((m) => ({ + default: m.IntuneDashboard, + })) + ), + sidebar: lazy(() => + import("../../components/layout/FileSidebar").then((m) => ({ + default: m.IntuneSidebar, + })) + ), + fileFilters: [ + { name: "IME Log Files", extensions: ["log", "txt", "cab", "zip"] }, + { name: "All Files", extensions: ["*"] }, + ], + actionLabels: { + file: "Open IME Log File", + folder: "Open IME Or Evidence Folder", + placeholder: "Open Intune Source...", + }, +}; +``` + +Note: Check that `IntuneDashboard` and `IntuneSidebar` are exported from their current files. If IntuneSidebar is not exported from FileSidebar.tsx, add the export. + +- [ ] **Step 3: Create `src/workspaces/new-intune/index.ts`** + +```typescript +import { lazy } from "react"; +import type { WorkspaceDefinition } from "../types"; + +export const newIntuneWorkspace: WorkspaceDefinition = { + id: "new-intune", + label: "New Intune Workspace", + platforms: "all", + component: lazy(() => + import("../../components/intune/NewIntuneWorkspace").then((m) => ({ + default: m.NewIntuneWorkspace, + })) + ), + sidebar: lazy(() => + import("../../components/layout/FileSidebar").then((m) => ({ + default: m.IntuneSidebar, + })) + ), + fileFilters: [ + { name: "IME Log Files", extensions: ["log", "txt", "cab", "zip"] }, + { name: "All Files", extensions: ["*"] }, + ], + actionLabels: { + file: "Open IME Log File", + folder: "Open IME Or Evidence Folder", + placeholder: "Open Intune Source...", + }, +}; +``` + +- [ ] **Step 4: Create `src/workspaces/dsregcmd/index.ts`** + +```typescript +import { lazy } from "react"; +import type { WorkspaceDefinition } from "../types"; + +export const dsregcmdWorkspace: WorkspaceDefinition = { + id: "dsregcmd", + label: "dsregcmd", + platforms: ["windows"], + component: lazy(() => + import("../../components/dsregcmd/DsregcmdWorkspace").then((m) => ({ + default: m.DsregcmdWorkspace, + })) + ), + sidebar: lazy(() => + import("../../components/layout/FileSidebar").then((m) => ({ + default: m.DsregcmdSidebar, + })) + ), + fileFilters: [ + { name: "Text Files", extensions: ["txt"] }, + { name: "Log Files", extensions: ["log"] }, + { name: "All Files", extensions: ["*"] }, + ], + actionLabels: { + file: "Open Text File", + folder: "Open Evidence Folder", + placeholder: "Open dsregcmd Source...", + }, +}; +``` + +- [ ] **Step 5: Create remaining shim definitions** + +Create `src/workspaces/macos-diag/index.ts`, `src/workspaces/deployment/index.ts`, `src/workspaces/event-log/index.ts` following the same pattern. + +For each, read the relevant component imports in AppShell.tsx and the platform map in ui-store.ts to get the correct values: +- macos-diag: `platforms: ["macos"]`, component = MacosDiagWorkspace +- deployment: `platforms: ["windows"]`, component = DeploymentWorkspace, `capabilities: { footerBar: true }` +- event-log: `platforms: "all"`, component = EventLogWorkspace + +For sidebar: check which sidebar each workspace uses in FileSidebar.tsx routing. Log, deployment, and macos-diag all use LogSidebar. Event-log may use LogSidebar or have no sidebar — check the code. + +- [ ] **Step 6: Register all workspaces in registry.ts** + +Update `src/workspaces/registry.ts` to import and register all 8 workspace definitions (sysmon is already registered). + +- [ ] **Step 7: Verify types compile** + +Run: `npx tsc --noEmit` +Expected: PASS + +- [ ] **Step 8: Commit** + +```bash +git add src/workspaces/ +git commit -m "feat(workspaces): add shim definitions for all workspaces" +``` + +--- + +### Task 2: Refactor AppShell to use registry + +Replace the `renderWorkspace()` if/else chain with a registry lookup. + +**Files:** +- Modify: `src/components/layout/AppShell.tsx` + +- [ ] **Step 1: Read AppShell.tsx fully to understand the current rendering logic** + +The log workspace has special rendering (DiffView, TabStrip, highlight results) that other workspaces don't. Understand this before simplifying. + +- [ ] **Step 2: Replace renderWorkspace() with registry lookup** + +The new pattern: + +```typescript +import { Suspense } from "react"; +import { getWorkspace } from "../../workspaces/registry"; + +// In renderWorkspace(): +const workspace = getWorkspace(activeView); +const WorkspaceComponent = workspace.component; +return ( + }> + + +); +``` + +IMPORTANT: The log workspace currently has special inline rendering with DiffView, highlightResults, tab-close cleanup, etc. that is NOT just rendering ``. You need to handle this. Options: +- Keep the log special case as a single `if (activeView === "log") { ... } else { registry lookup }` — this is acceptable since log is the primary workspace with unique complexity +- Or create a wrapper component that handles the log-specific rendering and point the log workspace definition's `component` to that wrapper + +The second approach is cleaner. Create a `LogWorkspaceView` wrapper if one doesn't exist, or point the lazy import to the existing log rendering logic. + +- [ ] **Step 3: Replace capability-gated UI** + +```typescript +const workspace = getWorkspace(activeView); +// Replace: {activeView === "log" && } +// With: {workspace.capabilities?.tabStrip && } + +// Replace: {showFindBar && activeView === "log" && ...} +// With: {showFindBar && workspace.capabilities?.findBar && ...} +``` + +- [ ] **Step 4: Remove old workspace component imports that are now lazy-loaded via registry** + +Remove direct imports of IntuneDashboard, NewIntuneWorkspace, MacosDiagWorkspace, SysmonWorkspace, DeploymentWorkspace, EventLogWorkspace, DsregcmdWorkspace from AppShell.tsx. They're now loaded via `React.lazy()` in the workspace definitions. + +- [ ] **Step 5: Verify types compile** + +Run: `npx tsc --noEmit` + +- [ ] **Step 6: Commit** + +```bash +git add src/components/layout/AppShell.tsx +git commit -m "refactor(appshell): use workspace registry for component routing" +``` + +--- + +### Task 3: Refactor FileSidebar to use registry + +Replace the sidebar if/else chain with a registry lookup. + +**Files:** +- Modify: `src/components/layout/FileSidebar.tsx` + +- [ ] **Step 1: Read the current sidebar routing and understand exports** + +The sidebar routing at ~line 1020-1026 dispatches to LogSidebar, IntuneSidebar, SysmonSidebar, DsregcmdSidebar. SysmonSidebar is already imported from the workspace. The others are inline in FileSidebar.tsx. + +For the registry to work, IntuneSidebar and DsregcmdSidebar need to be exported from FileSidebar.tsx (or extracted). LogSidebar is used by log, deployment, and macos-diag workspaces. + +- [ ] **Step 2: Export sidebar components that aren't yet exported** + +Add `export` to `IntuneSidebar`, `DsregcmdSidebar`, and `LogSidebar` function definitions in FileSidebar.tsx. Also export `SidebarFooter` if it's used by workspace definitions. + +- [ ] **Step 3: Update workspace shim definitions to point to correct sidebars** + +Ensure each workspace definition's `sidebar` field points to the correct component. Log, deployment, and macos-diag should point to LogSidebar. Update the shim index.ts files if needed. + +- [ ] **Step 4: Replace sidebar routing with registry lookup** + +```typescript +import { getWorkspace } from "../../workspaces/registry"; + +// Replace the if/else chain with: +const workspace = getWorkspace(activeView); +const SidebarComponent = workspace.sidebar; +return SidebarComponent ? ( + +) : ; +``` + +Also handle the footer conditional: +```typescript +{workspace.capabilities?.footerBar && } +``` + +- [ ] **Step 5: Verify types compile** + +Run: `npx tsc --noEmit` + +- [ ] **Step 6: Commit** + +```bash +git add src/components/layout/FileSidebar.tsx src/workspaces/ +git commit -m "refactor(sidebar): use workspace registry for sidebar routing" +``` + +--- + +### Task 4: Refactor ui-store to use registry + +Remove `WORKSPACE_PLATFORM_MAP` and delegate to registry. + +**Files:** +- Modify: `src/stores/ui-store.ts` + +- [ ] **Step 1: Read ui-store.ts to understand current usage of WORKSPACE_PLATFORM_MAP and getAvailableWorkspaces** + +- [ ] **Step 2: Replace getAvailableWorkspaces()** + +Import `getAvailableWorkspaces` from the registry and re-export or replace the ui-store version: + +```typescript +import { getAvailableWorkspaces as getAvailableWorkspaceDefs } from "../workspaces/registry"; + +export function getAvailableWorkspaces( + platform: PlatformKind, + enabledWorkspaces?: readonly WorkspaceId[] | null, +): WorkspaceId[] { + return getAvailableWorkspaceDefs(platform, enabledWorkspaces).map((ws) => ws.id); +} +``` + +Note: The ui-store version returns `WorkspaceId[]` while the registry version returns `WorkspaceDefinition[]`. Keep the ui-store signature for backwards compatibility — it just delegates to the registry now. + +- [ ] **Step 3: Remove WORKSPACE_PLATFORM_MAP** + +Delete the `WORKSPACE_PLATFORM_MAP` constant (lines 42-51). All platform data now comes from the registry. + +- [ ] **Step 4: Verify types compile** + +Run: `npx tsc --noEmit` + +- [ ] **Step 5: Commit** + +```bash +git add src/stores/ui-store.ts +git commit -m "refactor(ui-store): delegate platform gating to workspace registry" +``` + +--- + +### Task 5: Refactor Toolbar metadata lookups + +Replace WORKSPACE_LABELS, getOpenFileDialogFilters(), getOpenActionLabels() with registry lookups. + +**Files:** +- Modify: `src/components/layout/Toolbar.tsx` + +- [ ] **Step 1: Read Toolbar.tsx to understand all workspace metadata usage** + +- [ ] **Step 2: Replace WORKSPACE_LABELS** + +Remove the `WORKSPACE_LABELS` constant. Wherever it's used (workspace dropdown, etc.), replace with: +```typescript +const workspace = getWorkspace(activeView); +const label = workspace.label; +``` + +- [ ] **Step 3: Replace getOpenFileDialogFilters()** + +Remove the function and the per-workspace filter constants (LOG_FILE_DIALOG_FILTERS, INTUNE_FILE_DIALOG_FILTERS, etc.). Replace with: +```typescript +const workspace = getWorkspace(activeView); +const filters = workspace.fileFilters ?? [ + { name: "Log Files", extensions: ["log", "txt", "csv", "json", "xml", "evtx"] }, + { name: "All Files", extensions: ["*"] }, +]; +``` + +Keep a default fallback for workspaces that don't define filters. + +- [ ] **Step 4: Replace getOpenActionLabels()** + +Remove the function. Replace with: +```typescript +const workspace = getWorkspace(activeView); +const actionLabels = workspace.actionLabels ?? { + file: "Open File", + folder: "Open Folder", + placeholder: "Open...", +}; +``` + +- [ ] **Step 5: Verify types compile** + +Run: `npx tsc --noEmit` + +- [ ] **Step 6: Commit** + +```bash +git add src/components/layout/Toolbar.tsx +git commit -m "refactor(toolbar): use workspace registry for labels and file filters" +``` + +--- + +### Task 6: Final verification + +- [ ] **Step 1: Run `npx tsc --noEmit`** — must pass +- [ ] **Step 2: Run `npm run frontend:build`** — must pass +- [ ] **Step 3: Verify no workspace-specific if/else chains remain in the refactored sections** + +Check that AppShell.renderWorkspace(), FileSidebar routing, WORKSPACE_PLATFORM_MAP, WORKSPACE_LABELS, getOpenFileDialogFilters(), getOpenActionLabels() are all gone. + +Note: Some workspace-specific logic will remain in Toolbar.tsx (openSourceForWorkspace, analysis handlers, command state). These are deferred to Phase 3b or handled per-workspace during Phase 4 migration. The goal of Phase 3 is to eliminate the pure-data if/else chains, not to move all workspace behavior. + +- [ ] **Step 4: Commit if cleanup needed** From cc614896e141e1040c165e78e060864f75925c9e Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 5 Apr 2026 17:06:24 -0400 Subject: [PATCH 50/66] feat(workspaces): add shim definitions for all workspaces Creates shim WorkspaceDefinition objects for the 7 remaining workspaces (log, intune, new-intune, dsregcmd, macos-diag, deployment, event-log), exports LogSidebar/IntuneSidebar/DsregcmdSidebar from FileSidebar.tsx, and registers all 8 workspaces in the workspace registry. Co-Authored-By: Claude Sonnet 4.6 --- src/components/layout/FileSidebar.tsx | 6 ++--- src/workspaces/deployment/index.ts | 33 +++++++++++++++++++++++ src/workspaces/dsregcmd/index.ts | 29 ++++++++++++++++++++ src/workspaces/event-log/index.ts | 30 +++++++++++++++++++++ src/workspaces/intune/index.ts | 28 +++++++++++++++++++ src/workspaces/log/index.ts | 39 +++++++++++++++++++++++++++ src/workspaces/macos-diag/index.ts | 30 +++++++++++++++++++++ src/workspaces/new-intune/index.ts | 28 +++++++++++++++++++ src/workspaces/registry.ts | 14 ++++++++++ 9 files changed, 234 insertions(+), 3 deletions(-) create mode 100644 src/workspaces/deployment/index.ts create mode 100644 src/workspaces/dsregcmd/index.ts create mode 100644 src/workspaces/event-log/index.ts create mode 100644 src/workspaces/intune/index.ts create mode 100644 src/workspaces/log/index.ts create mode 100644 src/workspaces/macos-diag/index.ts create mode 100644 src/workspaces/new-intune/index.ts diff --git a/src/components/layout/FileSidebar.tsx b/src/components/layout/FileSidebar.tsx index c38d5e79d..254e2a769 100644 --- a/src/components/layout/FileSidebar.tsx +++ b/src/components/layout/FileSidebar.tsx @@ -321,7 +321,7 @@ function SourceSummaryCard({ ); } -function LogSidebar() { +export function LogSidebar() { const activeSource = useLogStore((s) => s.activeSource); const sourceEntries = useLogStore((s) => s.sourceEntries); const bundleMetadata = useLogStore((s) => s.bundleMetadata); @@ -615,7 +615,7 @@ function LogSidebar() { ); } -function IntuneSidebar() { +export function IntuneSidebar() { const activeView = useUiStore((s) => s.activeView); const intuneAnalysisState = useIntuneStore((s) => s.analysisState); const intuneIsAnalyzing = useIntuneStore((s) => s.isAnalyzing); @@ -815,7 +815,7 @@ function IntuneSidebar() { } -function DsregcmdSidebar() { +export function DsregcmdSidebar() { const result = useDsregcmdStore((s) => s.result); const sourceContext = useDsregcmdStore((s) => s.sourceContext); const analysisState = useDsregcmdStore((s) => s.analysisState); diff --git a/src/workspaces/deployment/index.ts b/src/workspaces/deployment/index.ts new file mode 100644 index 000000000..3add3c558 --- /dev/null +++ b/src/workspaces/deployment/index.ts @@ -0,0 +1,33 @@ +// src/workspaces/deployment/index.ts +import { lazy } from "react"; +import type { WorkspaceDefinition } from "../types"; + +export const deploymentWorkspace: WorkspaceDefinition = { + id: "deployment", + label: "Software Deployment", + platforms: ["windows"], + component: lazy(() => + import("../../components/deployment/DeploymentWorkspace").then((m) => ({ + default: m.DeploymentWorkspace, + })) + ), + sidebar: lazy(() => + import("../../components/layout/FileSidebar").then((m) => ({ + default: m.LogSidebar, + })) + ), + capabilities: { + footerBar: true, + }, + fileFilters: [ + { name: "Log Files", extensions: ["log"] }, + { name: "Old Log Files", extensions: ["lo_"] }, + { name: "Registry Files", extensions: ["reg"] }, + { name: "All Files", extensions: ["*"] }, + ], + actionLabels: { + file: "Open File", + folder: "Open Folder", + placeholder: "Open...", + }, +}; diff --git a/src/workspaces/dsregcmd/index.ts b/src/workspaces/dsregcmd/index.ts new file mode 100644 index 000000000..92a989299 --- /dev/null +++ b/src/workspaces/dsregcmd/index.ts @@ -0,0 +1,29 @@ +// src/workspaces/dsregcmd/index.ts +import { lazy } from "react"; +import type { WorkspaceDefinition } from "../types"; + +export const dsregcmdWorkspace: WorkspaceDefinition = { + id: "dsregcmd", + label: "dsregcmd", + platforms: ["windows"], + component: lazy(() => + import("../../components/dsregcmd/DsregcmdWorkspace").then((m) => ({ + default: m.DsregcmdWorkspace, + })) + ), + sidebar: lazy(() => + import("../../components/layout/FileSidebar").then((m) => ({ + default: m.DsregcmdSidebar, + })) + ), + fileFilters: [ + { name: "Text Files", extensions: ["txt"] }, + { name: "Log Files", extensions: ["log"] }, + { name: "All Files", extensions: ["*"] }, + ], + actionLabels: { + file: "Open Text File", + folder: "Open Evidence Folder", + placeholder: "Open dsregcmd Source...", + }, +}; diff --git a/src/workspaces/event-log/index.ts b/src/workspaces/event-log/index.ts new file mode 100644 index 000000000..a8ad9c68c --- /dev/null +++ b/src/workspaces/event-log/index.ts @@ -0,0 +1,30 @@ +// src/workspaces/event-log/index.ts +import { lazy } from "react"; +import type { WorkspaceDefinition } from "../types"; + +export const eventLogWorkspace: WorkspaceDefinition = { + id: "event-log", + label: "Event Log Viewer", + platforms: "all", + component: lazy(() => + import("../../components/event-log-workspace/EventLogWorkspace").then( + (m) => ({ default: m.EventLogWorkspace }) + ) + ), + sidebar: lazy(() => + import("../../components/layout/FileSidebar").then((m) => ({ + default: m.DsregcmdSidebar, + })) + ), + fileFilters: [ + { name: "Log Files", extensions: ["log"] }, + { name: "Old Log Files", extensions: ["lo_"] }, + { name: "Registry Files", extensions: ["reg"] }, + { name: "All Files", extensions: ["*"] }, + ], + actionLabels: { + file: "Open File", + folder: "Open Folder", + placeholder: "Open...", + }, +}; diff --git a/src/workspaces/intune/index.ts b/src/workspaces/intune/index.ts new file mode 100644 index 000000000..726fbd671 --- /dev/null +++ b/src/workspaces/intune/index.ts @@ -0,0 +1,28 @@ +// src/workspaces/intune/index.ts +import { lazy } from "react"; +import type { WorkspaceDefinition } from "../types"; + +export const intuneWorkspace: WorkspaceDefinition = { + id: "intune", + label: "Intune Diagnostics", + platforms: "all", + component: lazy(() => + import("../../components/intune/IntuneDashboard").then((m) => ({ + default: m.IntuneDashboard, + })) + ), + sidebar: lazy(() => + import("../../components/layout/FileSidebar").then((m) => ({ + default: m.IntuneSidebar, + })) + ), + fileFilters: [ + { name: "Intune IME Logs", extensions: ["log"] }, + { name: "All Files", extensions: ["*"] }, + ], + actionLabels: { + file: "Open IME Log File", + folder: "Open IME Or Evidence Folder", + placeholder: "Open Intune Source...", + }, +}; diff --git a/src/workspaces/log/index.ts b/src/workspaces/log/index.ts new file mode 100644 index 000000000..0bd8606a5 --- /dev/null +++ b/src/workspaces/log/index.ts @@ -0,0 +1,39 @@ +// src/workspaces/log/index.ts +import { lazy } from "react"; +import type { WorkspaceDefinition } from "../types"; + +export const logWorkspace: WorkspaceDefinition = { + id: "log", + label: "Log Explorer", + platforms: "all", + component: lazy(() => + import("../../components/log-view/LogListView").then((m) => ({ + default: m.LogListView, + })) + ), + sidebar: lazy(() => + import("../../components/layout/FileSidebar").then((m) => ({ + default: m.LogSidebar, + })) + ), + capabilities: { + tabStrip: true, + findBar: true, + detailsPane: true, + infoPane: true, + footerBar: true, + multiFileDrop: true, + fontSizing: true, + }, + fileFilters: [ + { name: "Log Files", extensions: ["log"] }, + { name: "Old Log Files", extensions: ["lo_"] }, + { name: "Registry Files", extensions: ["reg"] }, + { name: "All Files", extensions: ["*"] }, + ], + actionLabels: { + file: "Open File", + folder: "Open Folder", + placeholder: "Open...", + }, +}; diff --git a/src/workspaces/macos-diag/index.ts b/src/workspaces/macos-diag/index.ts new file mode 100644 index 000000000..14e9ff624 --- /dev/null +++ b/src/workspaces/macos-diag/index.ts @@ -0,0 +1,30 @@ +// src/workspaces/macos-diag/index.ts +import { lazy } from "react"; +import type { WorkspaceDefinition } from "../types"; + +export const macosDiagWorkspace: WorkspaceDefinition = { + id: "macos-diag", + label: "macOS Diagnostics", + platforms: ["macos"], + component: lazy(() => + import("../../components/macos-diag/MacosDiagWorkspace").then((m) => ({ + default: m.MacosDiagWorkspace, + })) + ), + sidebar: lazy(() => + import("../../components/layout/FileSidebar").then((m) => ({ + default: m.LogSidebar, + })) + ), + fileFilters: [ + { name: "Log Files", extensions: ["log"] }, + { name: "Old Log Files", extensions: ["lo_"] }, + { name: "Registry Files", extensions: ["reg"] }, + { name: "All Files", extensions: ["*"] }, + ], + actionLabels: { + file: "Open File", + folder: "Open Folder", + placeholder: "Open...", + }, +}; diff --git a/src/workspaces/new-intune/index.ts b/src/workspaces/new-intune/index.ts new file mode 100644 index 000000000..d0f5d3049 --- /dev/null +++ b/src/workspaces/new-intune/index.ts @@ -0,0 +1,28 @@ +// src/workspaces/new-intune/index.ts +import { lazy } from "react"; +import type { WorkspaceDefinition } from "../types"; + +export const newIntuneWorkspace: WorkspaceDefinition = { + id: "new-intune", + label: "New Intune Workspace", + platforms: "all", + component: lazy(() => + import("../../components/intune/NewIntuneWorkspace").then((m) => ({ + default: m.NewIntuneWorkspace, + })) + ), + sidebar: lazy(() => + import("../../components/layout/FileSidebar").then((m) => ({ + default: m.IntuneSidebar, + })) + ), + fileFilters: [ + { name: "Intune IME Logs", extensions: ["log"] }, + { name: "All Files", extensions: ["*"] }, + ], + actionLabels: { + file: "Open IME Log File", + folder: "Open IME Or Evidence Folder", + placeholder: "Open Intune Source...", + }, +}; diff --git a/src/workspaces/registry.ts b/src/workspaces/registry.ts index 2c9568b23..e9bbb14cf 100644 --- a/src/workspaces/registry.ts +++ b/src/workspaces/registry.ts @@ -1,9 +1,23 @@ // src/workspaces/registry.ts import type { PlatformKind, WorkspaceId } from "../types/log"; import type { WorkspaceDefinition } from "./types"; +import { logWorkspace } from "./log"; +import { intuneWorkspace } from "./intune"; +import { newIntuneWorkspace } from "./new-intune"; +import { dsregcmdWorkspace } from "./dsregcmd"; +import { macosDiagWorkspace } from "./macos-diag"; +import { deploymentWorkspace } from "./deployment"; +import { eventLogWorkspace } from "./event-log"; import { sysmonWorkspace } from "./sysmon"; const ALL_WORKSPACES: WorkspaceDefinition[] = [ + logWorkspace, + intuneWorkspace, + newIntuneWorkspace, + dsregcmdWorkspace, + macosDiagWorkspace, + deploymentWorkspace, + eventLogWorkspace, sysmonWorkspace, ]; From 6b1535878dcc668e572fd24f6f02bcadaa1e7eb2 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 5 Apr 2026 17:08:23 -0400 Subject: [PATCH 51/66] refactor(appshell): use workspace registry for component routing Replace the 8-way if/else chain in renderWorkspace() with a registry lookup + Suspense for all non-log workspaces. Log workspace inline rendering (DiffView, RegistryViewer, folder overlay, InfoPane) is preserved as a special case. TabStrip and FindBar conditionals now consult workspace capabilities flags instead of hard-coding activeView. Co-Authored-By: Claude Sonnet 4.6 --- src/components/layout/AppShell.tsx | 81 ++++++------------------------ 1 file changed, 14 insertions(+), 67 deletions(-) diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index c3b3a4508..4368171bd 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, Suspense } from "react"; import { tokens, ProgressBar, Spinner } from "@fluentui/react-components"; import { invoke } from "@tauri-apps/api/core"; import { Toolbar } from "./Toolbar"; @@ -21,13 +21,7 @@ import { CollectionCompleteDialog } from "../dialogs/CollectionCompleteDialog"; import { UpdateDialog } from "../dialogs/UpdateDialog"; import { MergeTabsDialog } from "../dialogs/MergeTabsDialog"; import { DiffConfigDialog } from "../dialogs/DiffConfigDialog"; -import { IntuneDashboard } from "../intune/IntuneDashboard"; -import { NewIntuneWorkspace } from "../intune/NewIntuneWorkspace"; -import { DsregcmdWorkspace } from "../dsregcmd/DsregcmdWorkspace"; -import { MacosDiagWorkspace } from "../macos-diag/MacosDiagWorkspace"; -import { DeploymentWorkspace } from "../deployment/DeploymentWorkspace"; -import { EventLogWorkspace } from "../event-log-workspace/EventLogWorkspace"; -import { SysmonWorkspace } from "../../workspaces/sysmon/SysmonWorkspace"; +import { getWorkspace } from "../../workspaces/registry"; import { RegistryViewer } from "../registry-view/RegistryViewer"; import type { FilterClause } from "../dialogs/FilterDialog"; import type { LogEntry } from "../../types/log"; @@ -403,63 +397,16 @@ export function AppShell() { ); } - if (activeView === "intune") { - return ( -
- -
- ); - } - - if (activeView === "new-intune") { - return ( -
- -
- ); - } - - if (activeView === "macos-diag") { - return ( -
- -
- ); - } - - if (activeView === "sysmon") { - return ( -
- -
- ); - } - - if (activeView === "deployment") { - return ( -
- -
- ); - } - - if (activeView === "event-log") { - return ( -
- -
- ); - } - - if (activeView === "dsregcmd") { - return ( -
- -
- ); - } - - return null; + // All other workspaces: registry lookup with lazy loading + const workspace = getWorkspace(activeView); + const WorkspaceComponent = workspace.component; + return ( +
+ + + +
+ ); }; return ( @@ -473,8 +420,8 @@ export function AppShell() { }} > - {activeView === "log" && } - {showFindBar && activeView === "log" && ( + {getWorkspace(activeView).capabilities?.tabStrip && } + {showFindBar && getWorkspace(activeView).capabilities?.findBar && ( setShowFindBar(false)} /> )} From c7e460bcae70fd82c6658bf235dd580e5893f8b2 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 5 Apr 2026 17:09:57 -0400 Subject: [PATCH 52/66] refactor(sidebar): use workspace registry for sidebar routing Replace the ternary chain (isIntuneWorkspace / activeView === 'sysmon' checks) with a registry lookup via getWorkspace(activeView).sidebar, and drive the footer bar from capabilities.footerBar. Remove now-unused SysmonSidebar direct import and isIntuneWorkspace import. Co-Authored-By: Claude Sonnet 4.6 --- src/components/layout/FileSidebar.tsx | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/components/layout/FileSidebar.tsx b/src/components/layout/FileSidebar.tsx index 254e2a769..3e207d8e7 100644 --- a/src/components/layout/FileSidebar.tsx +++ b/src/components/layout/FileSidebar.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useState, Suspense, type ReactNode } from "react"; import { Badge, Button, @@ -12,7 +12,7 @@ import { loadLogSource, loadSelectedLogFile } from "../../lib/log-source"; import { useFilterStore } from "../../stores/filter-store"; import { useIntuneStore } from "../../stores/intune-store"; import { useDsregcmdStore } from "../../stores/dsregcmd-store"; -import { SysmonSidebar } from "../../workspaces/sysmon/SysmonSidebar"; +import { getWorkspace } from "../../workspaces/registry"; import { getActiveSourceLabel, getActiveSourcePath, @@ -22,7 +22,7 @@ import { useLogStore, } from "../../stores/log-store"; import type { FolderEntry, LogSource } from "../../types/log"; -import { isIntuneWorkspace, useUiStore, type WorkspaceId } from "../../stores/ui-store"; +import { useUiStore, type WorkspaceId } from "../../stores/ui-store"; import { useAppActions } from "./Toolbar"; export const FILE_SIDEBAR_RECOMMENDED_WIDTH = 280; @@ -1017,14 +1017,18 @@ export function FileSidebar({ width = FILE_SIDEBAR_RECOMMENDED_WIDTH, activeView
)} - {activeView === "log" || activeView === "deployment" || activeView === "macos-diag" - ? - : isIntuneWorkspace(activeView) - ? - : activeView === "sysmon" - ? - : } - {(activeView === "log" || activeView === "deployment") && } + {(() => { + const workspace = getWorkspace(activeView); + const SidebarComponent = workspace.sidebar; + return SidebarComponent ? ( + + + + ) : ( + + ); + })()} + {getWorkspace(activeView).capabilities?.footerBar && } ); } From 865a74dd3fc3bb082bebd09237fa88723836c5b1 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 5 Apr 2026 17:11:17 -0400 Subject: [PATCH 53/66] refactor(ui-store): delegate platform gating to workspace registry Remove WORKSPACE_PLATFORM_MAP constant and inline filter logic from getAvailableWorkspaces(). Delegate to getRegistryWorkspaces() from the workspace registry instead, mapping WorkspaceDefinition[] back to WorkspaceId[] to preserve the existing public API surface. Co-Authored-By: Claude Sonnet 4.6 --- src/stores/ui-store.ts | 26 +++----------------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/src/stores/ui-store.ts b/src/stores/ui-store.ts index 56b0a186f..efa43fd2a 100644 --- a/src/stores/ui-store.ts +++ b/src/stores/ui-store.ts @@ -13,6 +13,7 @@ import { useFilterStore } from "./filter-store"; import type { ColumnId } from "../lib/column-config"; import type { CollectionResult } from "../lib/commands"; import type { WorkspaceId } from "../types/log"; +import { getAvailableWorkspaces as getRegistryWorkspaces } from "../workspaces/registry"; export type { WorkspaceId } from "../types/log"; @@ -38,32 +39,11 @@ export interface CollectionProgressState { totalItems: number; } -/** Which workspaces are available on each platform. */ -const WORKSPACE_PLATFORM_MAP: Record = { - log: "all", - intune: "all", - "new-intune": "all", - dsregcmd: ["windows"], - "macos-diag": ["macos"], - deployment: ["windows"], - "event-log": "all", - sysmon: ["windows"], -}; - export function getAvailableWorkspaces( platform: PlatformId, - enabledWorkspaces?: readonly WorkspaceId[] | null + enabledWorkspaces?: readonly WorkspaceId[] | null, ): WorkspaceId[] { - const enabled = enabledWorkspaces ? new Set(enabledWorkspaces) : null; - - return (Object.keys(WORKSPACE_PLATFORM_MAP) as WorkspaceId[]).filter((ws) => { - if (enabled && !enabled.has(ws)) { - return false; - } - - const platforms = WORKSPACE_PLATFORM_MAP[ws]; - return platforms === "all" || platforms.includes(platform); - }); + return getRegistryWorkspaces(platform, enabledWorkspaces).map((ws) => ws.id); } /** Source context for a tab — enough to restore sidebar and skip redundant folder re-parsing. */ From e4b0d11f116a5ff1092af4710213c44db1efcbc8 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 5 Apr 2026 17:14:11 -0400 Subject: [PATCH 54/66] refactor(toolbar): use workspace registry for labels and file filters Replace WORKSPACE_LABELS, getOpenFileDialogFilters(), and getOpenActionLabels() with direct lookups from getWorkspace() in the workspace registry, eliminating ~75 lines of duplicated metadata. Co-Authored-By: Claude Sonnet 4.6 --- src/components/layout/Toolbar.tsx | 110 ++++++------------------------ 1 file changed, 20 insertions(+), 90 deletions(-) diff --git a/src/components/layout/Toolbar.tsx b/src/components/layout/Toolbar.tsx index cb4541e3a..d586393d1 100644 --- a/src/components/layout/Toolbar.tsx +++ b/src/components/layout/Toolbar.tsx @@ -36,6 +36,7 @@ import { useIntuneStore } from "../../stores/intune-store"; import { useDsregcmdStore } from "../../stores/dsregcmd-store"; import { useSysmonStore } from "../../workspaces/sysmon/sysmon-store"; import { isIntuneWorkspace, getAvailableWorkspaces, type IntuneWorkspaceId, type WorkspaceId, type PlatformId, useUiStore } from "../../stores/ui-store"; +import { getWorkspace } from "../../workspaces/registry"; import { ThemePicker } from "./ThemePicker"; import { getLogSourcePath, @@ -73,90 +74,9 @@ function resolveRefreshSource( return null; } -const LOG_FILE_DIALOG_FILTERS = [ - { name: "Log Files", extensions: ["log"] }, - { name: "Old Log Files", extensions: ["lo_"] }, - { name: "Registry Files", extensions: ["reg"] }, - { name: "All Files", extensions: ["*"] }, -]; - -const INTUNE_FILE_DIALOG_FILTERS = [ - { name: "Intune IME Logs", extensions: ["log"] }, - { name: "All Files", extensions: ["*"] }, -]; - -const DSREGCMD_FILE_DIALOG_FILTERS = [ - { name: "Text Files", extensions: ["txt"] }, - { name: "Log Files", extensions: ["log"] }, - { name: "All Files", extensions: ["*"] }, -]; - const LIVE_INTUNE_SOURCE_ID = "windows-intune-ime-logs"; const LIVE_SYSMON_SOURCE_ID = "windows-sysmon-live-events"; -const WORKSPACE_LABELS: Record = { - log: "Log Explorer", - intune: "Intune Diagnostics", - "new-intune": "New Intune Workspace", - dsregcmd: "dsregcmd", - "macos-diag": "macOS Diagnostics", - deployment: "Software Deployment", - "event-log": "Event Log Viewer", - sysmon: "Sysmon", -}; - -const SYSMON_FILE_DIALOG_FILTERS = [ - { name: "EVTX Files", extensions: ["evtx"] }, - { name: "All Files", extensions: ["*"] }, -]; - -function getOpenFileDialogFilters(workspace: WorkspaceId) { - if (isIntuneWorkspace(workspace)) { - return INTUNE_FILE_DIALOG_FILTERS; - } - - if (workspace === "dsregcmd") { - return DSREGCMD_FILE_DIALOG_FILTERS; - } - - if (workspace === "sysmon") { - return SYSMON_FILE_DIALOG_FILTERS; - } - - return LOG_FILE_DIALOG_FILTERS; -} - -function getOpenActionLabels(workspace: WorkspaceId) { - if (workspace === "dsregcmd") { - return { - file: "Open Text File", - folder: "Open Evidence Folder", - openPlaceholder: "Open dsregcmd Source...", - }; - } - - if (isIntuneWorkspace(workspace)) { - return { - file: "Open IME Log File", - folder: "Open IME Or Evidence Folder", - openPlaceholder: "Open Intune Source...", - }; - } - - if (workspace === "sysmon") { - return { - file: "Open EVTX File", - folder: "Open EVTX Folder", - openPlaceholder: "Open Sysmon Source...", - }; - } - - return { - file: "Open File", - folder: "Open Folder", - openPlaceholder: "Open...", - }; -} async function inferPathKind(path: string): Promise<"file" | "folder" | "unknown"> { try { @@ -593,9 +513,15 @@ export function useAppActions(): AppActionHandlers { const isLogWorkspace = activeWorkspace === "log"; + const activeWorkspaceDefinition = getWorkspace(activeWorkspace); + const fileDialogFilters = activeWorkspaceDefinition.fileFilters ?? [ + { name: "Log Files", extensions: ["log", "txt", "csv", "json", "xml", "evtx"] }, + { name: "All Files", extensions: ["*"] }, + ]; + const selected = await open({ multiple: isLogWorkspace, - filters: getOpenFileDialogFilters(activeWorkspace), + filters: fileDialogFilters, }); if (!selected) return; @@ -916,10 +842,14 @@ export function Toolbar() { }; }, []); - const openLabels = useMemo( - () => getOpenActionLabels(activeView), - [activeView] - ); + const openLabels = useMemo(() => { + const ws = getWorkspace(activeView); + return ws.actionLabels ?? { + file: "Open File", + folder: "Open Folder", + placeholder: "Open...", + }; + }, [activeView]); return ( @@ -940,9 +870,9 @@ export function Toolbar() { @@ -1131,7 +1061,7 @@ export function Toolbar() { Workspace: { if (data.optionValue) { @@ -1143,7 +1073,7 @@ export function Toolbar() { aria-label="Workspace" > {availableWorkspaces.map((wsId) => ( - + ))} From 96c679d27c6361741fb50a79d90326a91e73d0e9 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 5 Apr 2026 17:24:16 -0400 Subject: [PATCH 55/66] refactor(dsregcmd): migrate workspace to src/workspaces/dsregcmd/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move all dsregcmd-specific files into src/workspaces/dsregcmd/ to make the workspace fully self-contained, following the same pattern as sysmon. - git mv types/dsregcmd.ts → workspaces/dsregcmd/types.ts - git mv stores/dsregcmd-store.ts → workspaces/dsregcmd/dsregcmd-store.ts - git mv components/dsregcmd/* → workspaces/dsregcmd/ (7 files) - Extract DsregcmdSidebar from FileSidebar.tsx into workspaces/dsregcmd/DsregcmdSidebar.tsx - Update index.ts shim to use local ./ imports - Update all external importers (Toolbar, StatusBar, EvidenceBundleDialog, commands.ts, dsregcmd-source.ts, dsregcmd-store.test.ts, event-log/index.ts) Co-Authored-By: Claude Sonnet 4.6 --- .../dialogs/EvidenceBundleDialog.tsx | 2 +- src/components/layout/FileSidebar.tsx | 88 ------ src/components/layout/StatusBar.tsx | 2 +- src/components/layout/Toolbar.tsx | 2 +- src/lib/commands.ts | 2 +- src/lib/dsregcmd-source.ts | 4 +- src/stores/dsregcmd-store.test.ts | 2 +- .../dsregcmd/DiagnosticInsightsCard.tsx | 2 +- .../dsregcmd/DsregcmdEventLogSurface.tsx | 2 +- src/workspaces/dsregcmd/DsregcmdSidebar.tsx | 278 ++++++++++++++++++ .../dsregcmd/DsregcmdWorkspace.tsx | 4 +- .../dsregcmd/FactGroupRenderer.tsx | 0 .../dsregcmd/PolicyEvidencePane.tsx | 0 .../dsregcmd/dsregcmd-formatters.ts | 2 +- .../dsregcmd}/dsregcmd-store.ts | 4 +- .../dsregcmd/fact-group-builders.ts | 2 +- src/workspaces/dsregcmd/index.ts | 4 +- .../dsregcmd/types.ts} | 2 +- src/workspaces/event-log/index.ts | 2 +- 19 files changed, 297 insertions(+), 107 deletions(-) rename src/{components => workspaces}/dsregcmd/DiagnosticInsightsCard.tsx (97%) rename src/{components => workspaces}/dsregcmd/DsregcmdEventLogSurface.tsx (99%) create mode 100644 src/workspaces/dsregcmd/DsregcmdSidebar.tsx rename src/{components => workspaces}/dsregcmd/DsregcmdWorkspace.tsx (99%) rename src/{components => workspaces}/dsregcmd/FactGroupRenderer.tsx (100%) rename src/{components => workspaces}/dsregcmd/PolicyEvidencePane.tsx (100%) rename src/{components => workspaces}/dsregcmd/dsregcmd-formatters.ts (99%) rename src/{stores => workspaces/dsregcmd}/dsregcmd-store.ts (97%) rename src/{components => workspaces}/dsregcmd/fact-group-builders.ts (99%) rename src/{types/dsregcmd.ts => workspaces/dsregcmd/types.ts} (99%) diff --git a/src/components/dialogs/EvidenceBundleDialog.tsx b/src/components/dialogs/EvidenceBundleDialog.tsx index 34c99d460..8e75d7836 100644 --- a/src/components/dialogs/EvidenceBundleDialog.tsx +++ b/src/components/dialogs/EvidenceBundleDialog.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { tokens } from "@fluentui/react-components"; import { inspectEvidenceArtifact, inspectEvidenceBundle } from "../../lib/commands"; -import { useDsregcmdStore } from "../../stores/dsregcmd-store"; +import { useDsregcmdStore } from "../../workspaces/dsregcmd/dsregcmd-store"; import { useIntuneStore } from "../../stores/intune-store"; import { useLogStore } from "../../stores/log-store"; import { isIntuneWorkspace, useUiStore } from "../../stores/ui-store"; diff --git a/src/components/layout/FileSidebar.tsx b/src/components/layout/FileSidebar.tsx index 3e207d8e7..7ab090b56 100644 --- a/src/components/layout/FileSidebar.tsx +++ b/src/components/layout/FileSidebar.tsx @@ -11,7 +11,6 @@ import { getLogListMetrics, LOG_UI_FONT_FAMILY } from "../../lib/log-accessibili import { loadLogSource, loadSelectedLogFile } from "../../lib/log-source"; import { useFilterStore } from "../../stores/filter-store"; import { useIntuneStore } from "../../stores/intune-store"; -import { useDsregcmdStore } from "../../stores/dsregcmd-store"; import { getWorkspace } from "../../workspaces/registry"; import { getActiveSourceLabel, @@ -814,93 +813,6 @@ export function IntuneSidebar() { ); } - -export function DsregcmdSidebar() { - const result = useDsregcmdStore((s) => s.result); - const sourceContext = useDsregcmdStore((s) => s.sourceContext); - const analysisState = useDsregcmdStore((s) => s.analysisState); - const isAnalyzing = useDsregcmdStore((s) => s.isAnalyzing); - const { openSourceFileDialog, openSourceFolderDialog, pasteDsregcmdSource, captureDsregcmdSource } = useAppActions(); - - const diagnostics = result?.diagnostics ?? []; - const errorCount = diagnostics.filter((item) => item.severity === "Error").length; - const warningCount = diagnostics.filter((item) => item.severity === "Warning").length; - const infoCount = diagnostics.filter((item) => item.severity === "Info").length; - - return ( - <> - -
{analysisState.message}
-
Lines: {sourceContext.rawLineCount}
-
Chars: {sourceContext.rawCharCount}
- {result &&
Join type: {result.derived.joinTypeLabel}
} -
- } - /> - - {(analysisState.phase === "analyzing" || analysisState.phase === "error") && ( - - )} - -
- void captureDsregcmdSource().catch((err) => console.error("[dsregcmd-sidebar] capture failed", err))} /> - void pasteDsregcmdSource().catch((err) => console.error("[dsregcmd-sidebar] paste failed", err))} /> - void openSourceFileDialog().catch((err) => console.error("[dsregcmd-sidebar] open file failed", err))} /> - void openSourceFolderDialog().catch((err) => console.error("[dsregcmd-sidebar] open folder failed", err))} /> -
- -
- {!result && !isAnalyzing && analysisState.phase !== "error" && ( - - )} - - {result && ( - <> - -
-
Join type: {result.derived.joinTypeLabel}
-
PRT present: {result.derived.azureAdPrtPresent === null ? 'Unknown' : result.derived.azureAdPrtPresent ? 'Yes' : 'No'}
-
MDM enrolled: {result.derived.mdmEnrolled === null ? 'Unknown' : result.derived.mdmEnrolled ? 'Yes' : 'No'}
-
Issues: {errorCount} errors • {warningCount} warnings • {infoCount} info
- {sourceContext.evidenceFilePath && ( -
Evidence file: {sourceContext.evidenceFilePath}
- )} - {sourceContext.bundlePath && ( -
Bundle root: {sourceContext.bundlePath}
- )} -
- - - {diagnostics.length === 0 ? ( - - ) : ( - diagnostics.slice(0, 8).map((item) => ( -
-
{item.severity}
-
{item.title}
-
{item.summary}
-
- )) - )} - - )} -
- - ); -} - function SidebarFooter() { const isPaused = useLogStore((s) => s.isPaused); const isLoading = useLogStore((s) => s.isLoading); diff --git a/src/components/layout/StatusBar.tsx b/src/components/layout/StatusBar.tsx index 36a78c82d..16d89ac43 100644 --- a/src/components/layout/StatusBar.tsx +++ b/src/components/layout/StatusBar.tsx @@ -19,7 +19,7 @@ import { useUiStore, } from "../../stores/ui-store"; import { useIntuneStore } from "../../stores/intune-store"; -import { useDsregcmdStore } from "../../stores/dsregcmd-store"; +import { useDsregcmdStore } from "../../workspaces/dsregcmd/dsregcmd-store"; import { useDeploymentStore } from "../../stores/deployment-store"; import { useSysmonStore } from "../../workspaces/sysmon/sysmon-store"; import { useEvtxStore } from "../../stores/evtx-store"; diff --git a/src/components/layout/Toolbar.tsx b/src/components/layout/Toolbar.tsx index d586393d1..5a214c772 100644 --- a/src/components/layout/Toolbar.tsx +++ b/src/components/layout/Toolbar.tsx @@ -33,7 +33,7 @@ import { import { useLogStore } from "../../stores/log-store"; import { useFilterStore } from "../../stores/filter-store"; import { useIntuneStore } from "../../stores/intune-store"; -import { useDsregcmdStore } from "../../stores/dsregcmd-store"; +import { useDsregcmdStore } from "../../workspaces/dsregcmd/dsregcmd-store"; import { useSysmonStore } from "../../workspaces/sysmon/sysmon-store"; import { isIntuneWorkspace, getAvailableWorkspaces, type IntuneWorkspaceId, type WorkspaceId, type PlatformId, useUiStore } from "../../stores/ui-store"; import { getWorkspace } from "../../workspaces/registry"; diff --git a/src/lib/commands.ts b/src/lib/commands.ts index aaea7d7d6..aa5431634 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -16,7 +16,7 @@ import type { DsregcmdAnalysisResult, DsregcmdCaptureResult, DsregcmdResolvedSource, -} from "../types/dsregcmd"; +} from "../workspaces/dsregcmd/types"; export interface FileAssociationPromptStatus { supported: boolean; diff --git a/src/lib/dsregcmd-source.ts b/src/lib/dsregcmd-source.ts index d445fa320..f75270cde 100644 --- a/src/lib/dsregcmd-source.ts +++ b/src/lib/dsregcmd-source.ts @@ -3,9 +3,9 @@ import type { DsregcmdAnalysisResult, DsregcmdSourceContext, DsregcmdSourceDescriptor, -} from "../types/dsregcmd"; +} from "../workspaces/dsregcmd/types"; import { analyzeDsregcmd, captureDsregcmd, loadDsregcmdSource } from "./commands"; -import { useDsregcmdStore } from "../stores/dsregcmd-store"; +import { useDsregcmdStore } from "../workspaces/dsregcmd/dsregcmd-store"; function getBaseName(path: string | null): string { if (!path) { diff --git a/src/stores/dsregcmd-store.test.ts b/src/stores/dsregcmd-store.test.ts index a1674ea27..65903646a 100644 --- a/src/stores/dsregcmd-store.test.ts +++ b/src/stores/dsregcmd-store.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from "vitest"; -import { useDsregcmdStore } from "./dsregcmd-store"; +import { useDsregcmdStore } from "../workspaces/dsregcmd/dsregcmd-store"; describe("dsregcmd-store", () => { beforeEach(() => { diff --git a/src/components/dsregcmd/DiagnosticInsightsCard.tsx b/src/workspaces/dsregcmd/DiagnosticInsightsCard.tsx similarity index 97% rename from src/components/dsregcmd/DiagnosticInsightsCard.tsx rename to src/workspaces/dsregcmd/DiagnosticInsightsCard.tsx index 13b0c691f..469d3a96c 100644 --- a/src/components/dsregcmd/DiagnosticInsightsCard.tsx +++ b/src/workspaces/dsregcmd/DiagnosticInsightsCard.tsx @@ -1,5 +1,5 @@ import { Badge, tokens } from "@fluentui/react-components"; -import type { DsregcmdDiagnosticInsight } from "../../types/dsregcmd"; +import type { DsregcmdDiagnosticInsight } from "./types"; import { getSeverityColor } from "./dsregcmd-formatters"; export function IssueCard({ issue }: { issue: DsregcmdDiagnosticInsight }) { diff --git a/src/components/dsregcmd/DsregcmdEventLogSurface.tsx b/src/workspaces/dsregcmd/DsregcmdEventLogSurface.tsx similarity index 99% rename from src/components/dsregcmd/DsregcmdEventLogSurface.tsx rename to src/workspaces/dsregcmd/DsregcmdEventLogSurface.tsx index 102c20fd7..4de84c9aa 100644 --- a/src/components/dsregcmd/DsregcmdEventLogSurface.tsx +++ b/src/workspaces/dsregcmd/DsregcmdEventLogSurface.tsx @@ -2,7 +2,7 @@ import { useVirtualizer } from "@tanstack/react-virtual"; import { useMemo, useRef, useCallback } from "react"; import { tokens } from "@fluentui/react-components"; import { LOG_MONOSPACE_FONT_FAMILY } from "../../lib/log-accessibility"; -import { useDsregcmdStore } from "../../stores/dsregcmd-store"; +import { useDsregcmdStore } from "./dsregcmd-store"; import type { EventLogAnalysis, EventLogEntry, EventLogChannel, EventLogSeverity } from "../../types/event-log"; const COLLAPSED_ROW_ESTIMATE = 28; diff --git a/src/workspaces/dsregcmd/DsregcmdSidebar.tsx b/src/workspaces/dsregcmd/DsregcmdSidebar.tsx new file mode 100644 index 000000000..190149b22 --- /dev/null +++ b/src/workspaces/dsregcmd/DsregcmdSidebar.tsx @@ -0,0 +1,278 @@ +import type { ReactNode } from "react"; +import { + Badge, + Button, + Caption1, + Subtitle2, + tokens, +} from "@fluentui/react-components"; +import { useDsregcmdStore } from "./dsregcmd-store"; +import { useAppActions } from "../../components/layout/Toolbar"; + +// --------------------------------------------------------------------------- +// Inline helpers — mirrors components in FileSidebar.tsx +// --------------------------------------------------------------------------- + +function SourceSummaryCard({ + badge, + title, + subtitle, + body, +}: { + badge: string; + title: string; + subtitle: string; + body: ReactNode; +}) { + return ( +
+ + {badge} + + + {title} + + + {subtitle} + +
{body}
+
+ ); +} + +function SourceStatusNotice({ + kind, + message, + detail, +}: { + kind: string; + message: string; + detail?: string; +}) { + const colors = + kind === "missing" || kind === "error" + ? { border: tokens.colorPaletteRedBorder2, background: tokens.colorPaletteRedBackground1, text: tokens.colorPaletteRedForeground2 } + : kind === "empty" || kind === "awaiting-file-selection" + ? { border: tokens.colorPaletteYellowBorder2, background: tokens.colorPaletteYellowBackground1, text: tokens.colorPaletteMarigoldForeground2 } + : { border: tokens.colorPaletteBlueBorderActive, background: tokens.colorPaletteBlueBackground2, text: tokens.colorPaletteBlueForeground2 }; + + return ( +
+
{message}
+ {detail &&
{detail}
} +
+ ); +} + +function EmptyState({ title, body }: { title: string; body: string }) { + return ( +
+ {title} +
{body}
+
+ ); +} + +function SectionHeader({ title, caption }: { title: string; caption?: string }) { + return ( +
+ + {title} + + {caption && ( + + {caption} + + )} +
+ ); +} + +function SidebarActionButton({ + label, + disabled, + onClick, +}: { + label: string; + disabled: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +// --------------------------------------------------------------------------- +// DsregcmdSidebar +// --------------------------------------------------------------------------- + +export function DsregcmdSidebar() { + const result = useDsregcmdStore((s) => s.result); + const sourceContext = useDsregcmdStore((s) => s.sourceContext); + const analysisState = useDsregcmdStore((s) => s.analysisState); + const isAnalyzing = useDsregcmdStore((s) => s.isAnalyzing); + const { openSourceFileDialog, openSourceFolderDialog, pasteDsregcmdSource, captureDsregcmdSource } = useAppActions(); + + const diagnostics = result?.diagnostics ?? []; + const errorCount = diagnostics.filter((item) => item.severity === "Error").length; + const warningCount = diagnostics.filter((item) => item.severity === "Warning").length; + const infoCount = diagnostics.filter((item) => item.severity === "Info").length; + + return ( + <> + +
{analysisState.message}
+
Lines: {sourceContext.rawLineCount}
+
Chars: {sourceContext.rawCharCount}
+ {result &&
Join type: {result.derived.joinTypeLabel}
} +
+ } + /> + + {(analysisState.phase === "analyzing" || analysisState.phase === "error") && ( + + )} + +
+ void captureDsregcmdSource().catch((err) => console.error("[dsregcmd-sidebar] capture failed", err))} /> + void pasteDsregcmdSource().catch((err) => console.error("[dsregcmd-sidebar] paste failed", err))} /> + void openSourceFileDialog().catch((err) => console.error("[dsregcmd-sidebar] open file failed", err))} /> + void openSourceFolderDialog().catch((err) => console.error("[dsregcmd-sidebar] open folder failed", err))} /> +
+ +
+ {!result && !isAnalyzing && analysisState.phase !== "error" && ( + + )} + + {result && ( + <> + +
+
Join type: {result.derived.joinTypeLabel}
+
PRT present: {result.derived.azureAdPrtPresent === null ? 'Unknown' : result.derived.azureAdPrtPresent ? 'Yes' : 'No'}
+
MDM enrolled: {result.derived.mdmEnrolled === null ? 'Unknown' : result.derived.mdmEnrolled ? 'Yes' : 'No'}
+
Issues: {errorCount} errors • {warningCount} warnings • {infoCount} info
+ {sourceContext.evidenceFilePath && ( +
Evidence file: {sourceContext.evidenceFilePath}
+ )} + {sourceContext.bundlePath && ( +
Bundle root: {sourceContext.bundlePath}
+ )} +
+ + + {diagnostics.length === 0 ? ( + + ) : ( + diagnostics.slice(0, 8).map((item) => ( +
+
{item.severity}
+
{item.title}
+
{item.summary}
+
+ )) + )} + + )} +
+ + ); +} diff --git a/src/components/dsregcmd/DsregcmdWorkspace.tsx b/src/workspaces/dsregcmd/DsregcmdWorkspace.tsx similarity index 99% rename from src/components/dsregcmd/DsregcmdWorkspace.tsx rename to src/workspaces/dsregcmd/DsregcmdWorkspace.tsx index 35b22199c..5b40fda78 100644 --- a/src/components/dsregcmd/DsregcmdWorkspace.tsx +++ b/src/workspaces/dsregcmd/DsregcmdWorkspace.tsx @@ -8,9 +8,9 @@ import { Button, Textarea, tokens } from "@fluentui/react-components"; import { LOG_MONOSPACE_FONT_FAMILY } from "../../lib/log-accessibility"; import { save } from "@tauri-apps/plugin-dialog"; import { writeText } from "@tauri-apps/plugin-clipboard-manager"; -import { useDsregcmdStore } from "../../stores/dsregcmd-store"; +import { useDsregcmdStore } from "./dsregcmd-store"; import { DsregcmdEventLogSurface } from "./DsregcmdEventLogSurface"; -import { useAppActions } from "../layout/Toolbar"; +import { useAppActions } from "../../components/layout/Toolbar"; import { writeTextOutputFile } from "../../lib/commands"; import { formatBool, diff --git a/src/components/dsregcmd/FactGroupRenderer.tsx b/src/workspaces/dsregcmd/FactGroupRenderer.tsx similarity index 100% rename from src/components/dsregcmd/FactGroupRenderer.tsx rename to src/workspaces/dsregcmd/FactGroupRenderer.tsx diff --git a/src/components/dsregcmd/PolicyEvidencePane.tsx b/src/workspaces/dsregcmd/PolicyEvidencePane.tsx similarity index 100% rename from src/components/dsregcmd/PolicyEvidencePane.tsx rename to src/workspaces/dsregcmd/PolicyEvidencePane.tsx diff --git a/src/components/dsregcmd/dsregcmd-formatters.ts b/src/workspaces/dsregcmd/dsregcmd-formatters.ts similarity index 99% rename from src/components/dsregcmd/dsregcmd-formatters.ts rename to src/workspaces/dsregcmd/dsregcmd-formatters.ts index 8f423a314..9dcf7dfb9 100644 --- a/src/components/dsregcmd/dsregcmd-formatters.ts +++ b/src/workspaces/dsregcmd/dsregcmd-formatters.ts @@ -9,7 +9,7 @@ import type { DsregcmdPolicyEvidenceValue, DsregcmdSeverity, DsregcmdSourceContext, -} from "../../types/dsregcmd"; +} from "./types"; import { tokens } from "@fluentui/react-components"; import { buildDiagnosticsGroup, diff --git a/src/stores/dsregcmd-store.ts b/src/workspaces/dsregcmd/dsregcmd-store.ts similarity index 97% rename from src/stores/dsregcmd-store.ts rename to src/workspaces/dsregcmd/dsregcmd-store.ts index 11e691e60..9393e6c4d 100644 --- a/src/stores/dsregcmd-store.ts +++ b/src/workspaces/dsregcmd/dsregcmd-store.ts @@ -4,8 +4,8 @@ import type { DsregcmdAnalysisState, DsregcmdSourceContext, DsregcmdSourceDescriptor, -} from "../types/dsregcmd"; -import type { EventLogChannel, EventLogSeverity } from "../types/event-log"; +} from "./types"; +import type { EventLogChannel, EventLogSeverity } from "../../types/event-log"; const emptySourceContext: DsregcmdSourceContext = { source: null, diff --git a/src/components/dsregcmd/fact-group-builders.ts b/src/workspaces/dsregcmd/fact-group-builders.ts similarity index 99% rename from src/components/dsregcmd/fact-group-builders.ts rename to src/workspaces/dsregcmd/fact-group-builders.ts index 15a3553ef..fc5559683 100644 --- a/src/components/dsregcmd/fact-group-builders.ts +++ b/src/workspaces/dsregcmd/fact-group-builders.ts @@ -6,7 +6,7 @@ import type { DsregcmdAnalysisResult, DsregcmdSourceContext, -} from "../../types/dsregcmd"; +} from "./types"; import { type DisplayConfidenceAssessment, type DisplayPhaseAssessment, diff --git a/src/workspaces/dsregcmd/index.ts b/src/workspaces/dsregcmd/index.ts index 92a989299..76b191ea8 100644 --- a/src/workspaces/dsregcmd/index.ts +++ b/src/workspaces/dsregcmd/index.ts @@ -7,12 +7,12 @@ export const dsregcmdWorkspace: WorkspaceDefinition = { label: "dsregcmd", platforms: ["windows"], component: lazy(() => - import("../../components/dsregcmd/DsregcmdWorkspace").then((m) => ({ + import("./DsregcmdWorkspace").then((m) => ({ default: m.DsregcmdWorkspace, })) ), sidebar: lazy(() => - import("../../components/layout/FileSidebar").then((m) => ({ + import("./DsregcmdSidebar").then((m) => ({ default: m.DsregcmdSidebar, })) ), diff --git a/src/types/dsregcmd.ts b/src/workspaces/dsregcmd/types.ts similarity index 99% rename from src/types/dsregcmd.ts rename to src/workspaces/dsregcmd/types.ts index eaee9a482..50d4849f6 100644 --- a/src/types/dsregcmd.ts +++ b/src/workspaces/dsregcmd/types.ts @@ -280,7 +280,7 @@ export interface DsregcmdAnalysisResult { enrollmentEvidence: DsregcmdEnrollmentEvidence | null; activeEvidence: DsregcmdActiveEvidence | null; scheduledTaskEvidence: DsregcmdScheduledTaskEvidence | null; - eventLogAnalysis: import("./event-log").EventLogAnalysis | null; + eventLogAnalysis: import("../../types/event-log").EventLogAnalysis | null; } export interface DsregcmdCaptureResult { diff --git a/src/workspaces/event-log/index.ts b/src/workspaces/event-log/index.ts index a8ad9c68c..db5b58850 100644 --- a/src/workspaces/event-log/index.ts +++ b/src/workspaces/event-log/index.ts @@ -12,7 +12,7 @@ export const eventLogWorkspace: WorkspaceDefinition = { ) ), sidebar: lazy(() => - import("../../components/layout/FileSidebar").then((m) => ({ + import("../dsregcmd/DsregcmdSidebar").then((m) => ({ default: m.DsregcmdSidebar, })) ), From d38a0f46847c77ae1efdf757fc009b710c94a479 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 5 Apr 2026 17:26:30 -0400 Subject: [PATCH 56/66] refactor(event-log): migrate workspace to src/workspaces/event-log/ Move types, store, and all 7 components out of src/types/, src/stores/, and src/components/event-log-workspace/ into the unified workspace directory. Update all internal imports to use relative paths within the workspace. Co-Authored-By: Claude Sonnet 4.6 --- .../event-log}/ChannelPicker.tsx | 4 ++-- .../event-log}/EventLogWorkspace.tsx | 2 +- .../event-log}/EvtxDetailPane.tsx | 2 +- .../event-log}/EvtxFilterBar.tsx | 4 ++-- .../event-log}/EvtxTimeline.tsx | 4 ++-- .../event-log}/EvtxTimelineRow.tsx | 2 +- .../event-log}/SourcePicker.tsx | 2 +- src/{stores => workspaces/event-log}/evtx-store.ts | 2 +- src/workspaces/event-log/index.ts | 2 +- .../event-log-workspace.ts => workspaces/event-log/types.ts} | 0 10 files changed, 12 insertions(+), 12 deletions(-) rename src/{components/event-log-workspace => workspaces/event-log}/ChannelPicker.tsx (99%) rename src/{components/event-log-workspace => workspaces/event-log}/EventLogWorkspace.tsx (98%) rename src/{components/event-log-workspace => workspaces/event-log}/EvtxDetailPane.tsx (99%) rename src/{components/event-log-workspace => workspaces/event-log}/EvtxFilterBar.tsx (97%) rename src/{components/event-log-workspace => workspaces/event-log}/EvtxTimeline.tsx (97%) rename src/{components/event-log-workspace => workspaces/event-log}/EvtxTimelineRow.tsx (98%) rename src/{components/event-log-workspace => workspaces/event-log}/SourcePicker.tsx (98%) rename src/{stores => workspaces/event-log}/evtx-store.ts (99%) rename src/{types/event-log-workspace.ts => workspaces/event-log/types.ts} (100%) diff --git a/src/components/event-log-workspace/ChannelPicker.tsx b/src/workspaces/event-log/ChannelPicker.tsx similarity index 99% rename from src/components/event-log-workspace/ChannelPicker.tsx rename to src/workspaces/event-log/ChannelPicker.tsx index 509163ffc..9e631a059 100644 --- a/src/components/event-log-workspace/ChannelPicker.tsx +++ b/src/workspaces/event-log/ChannelPicker.tsx @@ -1,7 +1,7 @@ import { memo, useMemo, useRef, useState, useEffect, useCallback } from "react"; import { Button, Input, tokens } from "@fluentui/react-components"; -import { useEvtxStore } from "../../stores/evtx-store"; -import type { EvtxChannelInfo } from "../../types/event-log-workspace"; +import { useEvtxStore } from "./evtx-store"; +import type { EvtxChannelInfo } from "./types"; // ── Tree data structure ───────────────────────────────────────────────────── diff --git a/src/components/event-log-workspace/EventLogWorkspace.tsx b/src/workspaces/event-log/EventLogWorkspace.tsx similarity index 98% rename from src/components/event-log-workspace/EventLogWorkspace.tsx rename to src/workspaces/event-log/EventLogWorkspace.tsx index 5311f1c8e..285c6890a 100644 --- a/src/components/event-log-workspace/EventLogWorkspace.tsx +++ b/src/workspaces/event-log/EventLogWorkspace.tsx @@ -1,6 +1,6 @@ import { useRef, useState, useEffect } from "react"; import { ProgressBar, Spinner, tokens } from "@fluentui/react-components"; -import { useEvtxStore } from "../../stores/evtx-store"; +import { useEvtxStore } from "./evtx-store"; import { SourcePicker } from "./SourcePicker"; import { ChannelPicker } from "./ChannelPicker"; import { EvtxFilterBar } from "./EvtxFilterBar"; diff --git a/src/components/event-log-workspace/EvtxDetailPane.tsx b/src/workspaces/event-log/EvtxDetailPane.tsx similarity index 99% rename from src/components/event-log-workspace/EvtxDetailPane.tsx rename to src/workspaces/event-log/EvtxDetailPane.tsx index a88a54730..af0c358bf 100644 --- a/src/components/event-log-workspace/EvtxDetailPane.tsx +++ b/src/workspaces/event-log/EvtxDetailPane.tsx @@ -6,7 +6,7 @@ import { getLogListMetrics, } from "../../lib/log-accessibility"; import { useUiStore } from "../../stores/ui-store"; -import { useEvtxStore } from "../../stores/evtx-store"; +import { useEvtxStore } from "./evtx-store"; export function EvtxDetailPane() { const records = useEvtxStore((s) => s.records); diff --git a/src/components/event-log-workspace/EvtxFilterBar.tsx b/src/workspaces/event-log/EvtxFilterBar.tsx similarity index 97% rename from src/components/event-log-workspace/EvtxFilterBar.tsx rename to src/workspaces/event-log/EvtxFilterBar.tsx index 701c38c7c..fc19f7960 100644 --- a/src/components/event-log-workspace/EvtxFilterBar.tsx +++ b/src/workspaces/event-log/EvtxFilterBar.tsx @@ -3,8 +3,8 @@ import { Button, Dropdown, Input, Option, tokens } from "@fluentui/react-compone import { useEvtxStore, type EvtxSortField, -} from "../../stores/evtx-store"; -import type { EvtxLevel } from "../../types/event-log-workspace"; +} from "./evtx-store"; +import type { EvtxLevel } from "./types"; const LEVELS: EvtxLevel[] = ["Critical", "Error", "Warning", "Information", "Verbose"]; diff --git a/src/components/event-log-workspace/EvtxTimeline.tsx b/src/workspaces/event-log/EvtxTimeline.tsx similarity index 97% rename from src/components/event-log-workspace/EvtxTimeline.tsx rename to src/workspaces/event-log/EvtxTimeline.tsx index ce55e0202..79fd8f666 100644 --- a/src/components/event-log-workspace/EvtxTimeline.tsx +++ b/src/workspaces/event-log/EvtxTimeline.tsx @@ -6,8 +6,8 @@ import { getLogListMetrics, } from "../../lib/log-accessibility"; import { useUiStore } from "../../stores/ui-store"; -import { useEvtxStore, type EvtxSortField } from "../../stores/evtx-store"; -import type { EvtxRecord, EvtxLevel } from "../../types/event-log-workspace"; +import { useEvtxStore, type EvtxSortField } from "./evtx-store"; +import type { EvtxRecord, EvtxLevel } from "./types"; import { EvtxTimelineRow } from "./EvtxTimelineRow"; const LEVEL_ORDER: Record = { diff --git a/src/components/event-log-workspace/EvtxTimelineRow.tsx b/src/workspaces/event-log/EvtxTimelineRow.tsx similarity index 98% rename from src/components/event-log-workspace/EvtxTimelineRow.tsx rename to src/workspaces/event-log/EvtxTimelineRow.tsx index 9415c9b4c..57eea564e 100644 --- a/src/components/event-log-workspace/EvtxTimelineRow.tsx +++ b/src/workspaces/event-log/EvtxTimelineRow.tsx @@ -3,7 +3,7 @@ import { tokens } from "@fluentui/react-components"; import { LOG_MONOSPACE_FONT_FAMILY, } from "../../lib/log-accessibility"; -import type { EvtxRecord, EvtxLevel } from "../../types/event-log-workspace"; +import type { EvtxRecord, EvtxLevel } from "./types"; const LEVEL_COLORS: Record = { Critical: tokens.colorPaletteRedForeground1, diff --git a/src/components/event-log-workspace/SourcePicker.tsx b/src/workspaces/event-log/SourcePicker.tsx similarity index 98% rename from src/components/event-log-workspace/SourcePicker.tsx rename to src/workspaces/event-log/SourcePicker.tsx index 2a47f7b54..44307a4da 100644 --- a/src/components/event-log-workspace/SourcePicker.tsx +++ b/src/workspaces/event-log/SourcePicker.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { Button, Spinner, tokens } from "@fluentui/react-components"; import { open } from "@tauri-apps/plugin-dialog"; -import { useEvtxStore } from "../../stores/evtx-store"; +import { useEvtxStore } from "./evtx-store"; import { useUiStore } from "../../stores/ui-store"; const EVTX_FILE_DIALOG_FILTERS = [ diff --git a/src/stores/evtx-store.ts b/src/workspaces/event-log/evtx-store.ts similarity index 99% rename from src/stores/evtx-store.ts rename to src/workspaces/event-log/evtx-store.ts index d05b34f49..1c9a60d42 100644 --- a/src/stores/evtx-store.ts +++ b/src/workspaces/event-log/evtx-store.ts @@ -6,7 +6,7 @@ import type { EvtxChannelInfo, EvtxLevel, EvtxParseResult, -} from "../types/event-log-workspace"; +} from "./types"; export type EvtxSourceMode = "files" | "live" | null; export type EvtxSortField = "time" | "eventId" | "level" | "provider" | "channel"; diff --git a/src/workspaces/event-log/index.ts b/src/workspaces/event-log/index.ts index db5b58850..50b3c3a8d 100644 --- a/src/workspaces/event-log/index.ts +++ b/src/workspaces/event-log/index.ts @@ -7,7 +7,7 @@ export const eventLogWorkspace: WorkspaceDefinition = { label: "Event Log Viewer", platforms: "all", component: lazy(() => - import("../../components/event-log-workspace/EventLogWorkspace").then( + import("./EventLogWorkspace").then( (m) => ({ default: m.EventLogWorkspace }) ) ), diff --git a/src/types/event-log-workspace.ts b/src/workspaces/event-log/types.ts similarity index 100% rename from src/types/event-log-workspace.ts rename to src/workspaces/event-log/types.ts From fed75faa348fed943b2c7191b7ab4b3b17658cc0 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 5 Apr 2026 17:29:51 -0400 Subject: [PATCH 57/66] refactor(macos-diag): migrate workspace to src/workspaces/macos-diag/ Move types, store, and all 9 components from their scattered locations (src/types/, src/stores/, src/components/macos-diag/) into the unified workspace directory src/workspaces/macos-diag/. Update all internal and external import paths accordingly. TypeScript passes clean. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/commands.ts | 2 +- src/lib/profile-utils.ts | 2 +- .../macos-diag/MacosDiagDefenderTab.tsx | 0 .../macos-diag/MacosDiagEnvironmentBanner.tsx | 0 src/{components => workspaces}/macos-diag/MacosDiagFdaGuide.tsx | 0 .../macos-diag/MacosDiagIntuneLogsTab.tsx | 0 .../macos-diag/MacosDiagPackagesTab.tsx | 0 .../macos-diag/MacosDiagProfilesTab.tsx | 0 src/{components => workspaces}/macos-diag/MacosDiagTabStrip.tsx | 0 .../macos-diag/MacosDiagUnifiedLogTab.tsx | 0 .../macos-diag/MacosDiagWorkspace.tsx | 0 src/workspaces/macos-diag/index.ts | 2 +- src/{stores => workspaces/macos-diag}/macos-diag-store.ts | 0 src/{types/macos-diag.ts => workspaces/macos-diag/types.ts} | 0 14 files changed, 3 insertions(+), 3 deletions(-) rename src/{components => workspaces}/macos-diag/MacosDiagDefenderTab.tsx (100%) rename src/{components => workspaces}/macos-diag/MacosDiagEnvironmentBanner.tsx (100%) rename src/{components => workspaces}/macos-diag/MacosDiagFdaGuide.tsx (100%) rename src/{components => workspaces}/macos-diag/MacosDiagIntuneLogsTab.tsx (100%) rename src/{components => workspaces}/macos-diag/MacosDiagPackagesTab.tsx (100%) rename src/{components => workspaces}/macos-diag/MacosDiagProfilesTab.tsx (100%) rename src/{components => workspaces}/macos-diag/MacosDiagTabStrip.tsx (100%) rename src/{components => workspaces}/macos-diag/MacosDiagUnifiedLogTab.tsx (100%) rename src/{components => workspaces}/macos-diag/MacosDiagWorkspace.tsx (100%) rename src/{stores => workspaces/macos-diag}/macos-diag-store.ts (100%) rename src/{types/macos-diag.ts => workspaces/macos-diag/types.ts} (100%) diff --git a/src/lib/commands.ts b/src/lib/commands.ts index aa5431634..706ff2fc8 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -350,7 +350,7 @@ import type { MacosPackageInfo, MacosPackageFiles, MacosUnifiedLogResult, -} from "../types/macos-diag"; +} from "../workspaces/macos-diag/types"; export async function macosScanEnvironment(): Promise { return invokeCommand("macos_scan_environment"); diff --git a/src/lib/profile-utils.ts b/src/lib/profile-utils.ts index bd3bcaacb..87ac12532 100644 --- a/src/lib/profile-utils.ts +++ b/src/lib/profile-utils.ts @@ -1,4 +1,4 @@ -import type { MacosMdmProfile } from "../types/macos-diag"; +import type { MacosMdmProfile } from "../workspaces/macos-diag/types"; // --------------------------------------------------------------------------- // Payload type knowledge base diff --git a/src/components/macos-diag/MacosDiagDefenderTab.tsx b/src/workspaces/macos-diag/MacosDiagDefenderTab.tsx similarity index 100% rename from src/components/macos-diag/MacosDiagDefenderTab.tsx rename to src/workspaces/macos-diag/MacosDiagDefenderTab.tsx diff --git a/src/components/macos-diag/MacosDiagEnvironmentBanner.tsx b/src/workspaces/macos-diag/MacosDiagEnvironmentBanner.tsx similarity index 100% rename from src/components/macos-diag/MacosDiagEnvironmentBanner.tsx rename to src/workspaces/macos-diag/MacosDiagEnvironmentBanner.tsx diff --git a/src/components/macos-diag/MacosDiagFdaGuide.tsx b/src/workspaces/macos-diag/MacosDiagFdaGuide.tsx similarity index 100% rename from src/components/macos-diag/MacosDiagFdaGuide.tsx rename to src/workspaces/macos-diag/MacosDiagFdaGuide.tsx diff --git a/src/components/macos-diag/MacosDiagIntuneLogsTab.tsx b/src/workspaces/macos-diag/MacosDiagIntuneLogsTab.tsx similarity index 100% rename from src/components/macos-diag/MacosDiagIntuneLogsTab.tsx rename to src/workspaces/macos-diag/MacosDiagIntuneLogsTab.tsx diff --git a/src/components/macos-diag/MacosDiagPackagesTab.tsx b/src/workspaces/macos-diag/MacosDiagPackagesTab.tsx similarity index 100% rename from src/components/macos-diag/MacosDiagPackagesTab.tsx rename to src/workspaces/macos-diag/MacosDiagPackagesTab.tsx diff --git a/src/components/macos-diag/MacosDiagProfilesTab.tsx b/src/workspaces/macos-diag/MacosDiagProfilesTab.tsx similarity index 100% rename from src/components/macos-diag/MacosDiagProfilesTab.tsx rename to src/workspaces/macos-diag/MacosDiagProfilesTab.tsx diff --git a/src/components/macos-diag/MacosDiagTabStrip.tsx b/src/workspaces/macos-diag/MacosDiagTabStrip.tsx similarity index 100% rename from src/components/macos-diag/MacosDiagTabStrip.tsx rename to src/workspaces/macos-diag/MacosDiagTabStrip.tsx diff --git a/src/components/macos-diag/MacosDiagUnifiedLogTab.tsx b/src/workspaces/macos-diag/MacosDiagUnifiedLogTab.tsx similarity index 100% rename from src/components/macos-diag/MacosDiagUnifiedLogTab.tsx rename to src/workspaces/macos-diag/MacosDiagUnifiedLogTab.tsx diff --git a/src/components/macos-diag/MacosDiagWorkspace.tsx b/src/workspaces/macos-diag/MacosDiagWorkspace.tsx similarity index 100% rename from src/components/macos-diag/MacosDiagWorkspace.tsx rename to src/workspaces/macos-diag/MacosDiagWorkspace.tsx diff --git a/src/workspaces/macos-diag/index.ts b/src/workspaces/macos-diag/index.ts index 14e9ff624..ec8969195 100644 --- a/src/workspaces/macos-diag/index.ts +++ b/src/workspaces/macos-diag/index.ts @@ -7,7 +7,7 @@ export const macosDiagWorkspace: WorkspaceDefinition = { label: "macOS Diagnostics", platforms: ["macos"], component: lazy(() => - import("../../components/macos-diag/MacosDiagWorkspace").then((m) => ({ + import("./MacosDiagWorkspace").then((m) => ({ default: m.MacosDiagWorkspace, })) ), diff --git a/src/stores/macos-diag-store.ts b/src/workspaces/macos-diag/macos-diag-store.ts similarity index 100% rename from src/stores/macos-diag-store.ts rename to src/workspaces/macos-diag/macos-diag-store.ts diff --git a/src/types/macos-diag.ts b/src/workspaces/macos-diag/types.ts similarity index 100% rename from src/types/macos-diag.ts rename to src/workspaces/macos-diag/types.ts From ca56ba0910c2d1e5da16b7f403129266f31eb773 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 5 Apr 2026 17:31:53 -0400 Subject: [PATCH 58/66] refactor(deployment): migrate workspace to src/workspaces/deployment/ Moves deployment-store.ts and all 3 component files (DeploymentWorkspace, DeploymentErrorCard, DeploymentSuccessTable) from their legacy locations into src/workspaces/deployment/. Updates all import paths in the moved files, the workspace index shim, StatusBar.tsx, and both dynamic imports in Toolbar.tsx. Co-Authored-By: Claude Sonnet 4.6 --- src/components/layout/StatusBar.tsx | 2 +- src/components/layout/Toolbar.tsx | 4 ++-- .../deployment/DeploymentErrorCard.tsx | 2 +- .../deployment/DeploymentSuccessTable.tsx | 2 +- .../deployment/DeploymentWorkspace.tsx | 2 +- src/{stores => workspaces/deployment}/deployment-store.ts | 0 src/workspaces/deployment/index.ts | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) rename src/{components => workspaces}/deployment/DeploymentErrorCard.tsx (99%) rename src/{components => workspaces}/deployment/DeploymentSuccessTable.tsx (98%) rename src/{components => workspaces}/deployment/DeploymentWorkspace.tsx (99%) rename src/{stores => workspaces/deployment}/deployment-store.ts (100%) diff --git a/src/components/layout/StatusBar.tsx b/src/components/layout/StatusBar.tsx index 16d89ac43..9ee6deead 100644 --- a/src/components/layout/StatusBar.tsx +++ b/src/components/layout/StatusBar.tsx @@ -20,7 +20,7 @@ import { } from "../../stores/ui-store"; import { useIntuneStore } from "../../stores/intune-store"; import { useDsregcmdStore } from "../../workspaces/dsregcmd/dsregcmd-store"; -import { useDeploymentStore } from "../../stores/deployment-store"; +import { useDeploymentStore } from "../../workspaces/deployment/deployment-store"; import { useSysmonStore } from "../../workspaces/sysmon/sysmon-store"; import { useEvtxStore } from "../../stores/evtx-store"; diff --git a/src/components/layout/Toolbar.tsx b/src/components/layout/Toolbar.tsx index 5a214c772..2f879ace4 100644 --- a/src/components/layout/Toolbar.tsx +++ b/src/components/layout/Toolbar.tsx @@ -423,7 +423,7 @@ export function useAppActions(): AppActionHandlers { ? source.defaultPath : null; if (folderPath) { - const { useDeploymentStore } = await import("../../stores/deployment-store"); + const { useDeploymentStore } = await import("../../workspaces/deployment/deployment-store"); await useDeploymentStore.getState().analyzeFolder(folderPath); return; } @@ -458,7 +458,7 @@ export function useAppActions(): AppActionHandlers { } if (activeWorkspace === "deployment") { - const { useDeploymentStore } = await import("../../stores/deployment-store"); + const { useDeploymentStore } = await import("../../workspaces/deployment/deployment-store"); await useDeploymentStore.getState().analyzeFolder(path); return; } diff --git a/src/components/deployment/DeploymentErrorCard.tsx b/src/workspaces/deployment/DeploymentErrorCard.tsx similarity index 99% rename from src/components/deployment/DeploymentErrorCard.tsx rename to src/workspaces/deployment/DeploymentErrorCard.tsx index 3005618b5..4a2b8aeb7 100644 --- a/src/components/deployment/DeploymentErrorCard.tsx +++ b/src/workspaces/deployment/DeploymentErrorCard.tsx @@ -3,7 +3,7 @@ import { LOG_MONOSPACE_FONT_FAMILY } from "../../lib/log-accessibility"; import { useDeploymentStore, type DeploymentLogFile, -} from "../../stores/deployment-store"; +} from "./deployment-store"; import { useLogStore } from "../../stores/log-store"; import { useUiStore } from "../../stores/ui-store"; import { loadPathAsLogSource } from "../../lib/log-source"; diff --git a/src/components/deployment/DeploymentSuccessTable.tsx b/src/workspaces/deployment/DeploymentSuccessTable.tsx similarity index 98% rename from src/components/deployment/DeploymentSuccessTable.tsx rename to src/workspaces/deployment/DeploymentSuccessTable.tsx index 7a30fe2b8..85ef394ca 100644 --- a/src/components/deployment/DeploymentSuccessTable.tsx +++ b/src/workspaces/deployment/DeploymentSuccessTable.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { tokens } from "@fluentui/react-components"; import { LOG_MONOSPACE_FONT_FAMILY } from "../../lib/log-accessibility"; -import type { DeploymentLogFile } from "../../stores/deployment-store"; +import type { DeploymentLogFile } from "./deployment-store"; function displayName(file: DeploymentLogFile): string { return file.appName ?? file.fileName; diff --git a/src/components/deployment/DeploymentWorkspace.tsx b/src/workspaces/deployment/DeploymentWorkspace.tsx similarity index 99% rename from src/components/deployment/DeploymentWorkspace.tsx rename to src/workspaces/deployment/DeploymentWorkspace.tsx index 589dccc74..557b16803 100644 --- a/src/components/deployment/DeploymentWorkspace.tsx +++ b/src/workspaces/deployment/DeploymentWorkspace.tsx @@ -3,7 +3,7 @@ import { tokens, Spinner, Button } from "@fluentui/react-components"; import { useDeploymentStore, type DeploymentLogFile, -} from "../../stores/deployment-store"; +} from "./deployment-store"; import { useLogStore } from "../../stores/log-store"; import { DeploymentErrorCard } from "./DeploymentErrorCard"; import { DeploymentSuccessTable } from "./DeploymentSuccessTable"; diff --git a/src/stores/deployment-store.ts b/src/workspaces/deployment/deployment-store.ts similarity index 100% rename from src/stores/deployment-store.ts rename to src/workspaces/deployment/deployment-store.ts diff --git a/src/workspaces/deployment/index.ts b/src/workspaces/deployment/index.ts index 3add3c558..22b616d7f 100644 --- a/src/workspaces/deployment/index.ts +++ b/src/workspaces/deployment/index.ts @@ -7,7 +7,7 @@ export const deploymentWorkspace: WorkspaceDefinition = { label: "Software Deployment", platforms: ["windows"], component: lazy(() => - import("../../components/deployment/DeploymentWorkspace").then((m) => ({ + import("./DeploymentWorkspace").then((m) => ({ default: m.DeploymentWorkspace, })) ), From c65220957a76a6e0d61743e586d47c175923db10 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 5 Apr 2026 17:38:19 -0400 Subject: [PATCH 59/66] refactor(intune): migrate workspace to src/workspaces/intune/ Moves all 19 intune components, the intune store (1,095 lines), types, hook, and test file from their scattered locations into the consolidated src/workspaces/intune/ directory. Extracts IntuneSidebar from FileSidebar.tsx into its own file with helpers inlined. Updates all importers across layout, dialogs, hooks, and lib modules. Both the intune and new-intune workspace shims now reference local workspace paths. Co-Authored-By: Claude Sonnet 4.6 --- .../dialogs/EvidenceBundleDialog.tsx | 2 +- src/components/dialogs/GuidRegistryDialog.tsx | 4 +- .../dialogs/settings/GraphApiTab.tsx | 2 +- src/components/layout/AppShell.tsx | 2 +- src/components/layout/FileSidebar.tsx | 200 ---------- src/components/layout/StatusBar.tsx | 2 +- src/components/layout/Toolbar.tsx | 2 +- .../log-view/AppWorkloadScriptDetail.tsx | 2 +- src/hooks/use-graph-api-startup.ts | 2 +- src/lib/commands.ts | 2 +- src/lib/graph-registry.ts | 2 +- src/lib/intune-sort.ts | 6 +- src/lib/session-save.ts | 2 +- src/types/event-log.ts | 2 +- .../intune/DownloadStats.tsx | 6 +- .../intune/DownloadSurface.tsx | 2 +- .../intune/EventActivityView.tsx | 4 +- .../intune/EventLogSurface.tsx | 2 +- .../intune/EventTimeline.tsx | 4 +- .../intune/EventTimelineRow.test.tsx | 2 +- .../intune/EventTimelineRow.tsx | 2 +- .../intune/IntuneDashboard.tsx | 2 +- .../intune/IntuneDashboardHeader.tsx | 4 +- .../intune/IntuneDashboardNavBar.tsx | 6 +- src/workspaces/intune/IntuneSidebar.tsx | 360 ++++++++++++++++++ .../intune/InvestigationPanel.tsx | 2 +- .../intune/NewIntuneWorkspace.tsx | 6 +- .../intune/OverviewSurface.tsx | 4 +- .../intune/ScriptCodeViewer.tsx | 0 .../intune/SummaryView.tsx | 4 +- .../intune/SummaryViewComponents.tsx | 2 +- src/workspaces/intune/index.ts | 4 +- .../intune/intune-dashboard-utils.ts | 4 +- .../intune}/intune-store.test.ts | 0 .../intune}/intune-store.ts | 12 +- .../intune/summary-view-logic.ts | 2 +- .../intune.ts => workspaces/intune/types.ts} | 4 +- .../intune}/use-intune-analysis-progress.ts | 4 +- .../intune/useTimeWindowFilter.ts | 4 +- src/workspaces/new-intune/index.ts | 4 +- 40 files changed, 421 insertions(+), 261 deletions(-) rename src/{components => workspaces}/intune/DownloadStats.tsx (98%) rename src/{components => workspaces}/intune/DownloadSurface.tsx (99%) rename src/{components => workspaces}/intune/EventActivityView.tsx (99%) rename src/{components => workspaces}/intune/EventLogSurface.tsx (99%) rename src/{components => workspaces}/intune/EventTimeline.tsx (98%) rename src/{components => workspaces}/intune/EventTimelineRow.test.tsx (97%) rename src/{components => workspaces}/intune/EventTimelineRow.tsx (99%) rename src/{components => workspaces}/intune/IntuneDashboard.tsx (99%) rename src/{components => workspaces}/intune/IntuneDashboardHeader.tsx (97%) rename src/{components => workspaces}/intune/IntuneDashboardNavBar.tsx (98%) create mode 100644 src/workspaces/intune/IntuneSidebar.tsx rename src/{components => workspaces}/intune/InvestigationPanel.tsx (99%) rename src/{components => workspaces}/intune/NewIntuneWorkspace.tsx (99%) rename src/{components => workspaces}/intune/OverviewSurface.tsx (99%) rename src/{components => workspaces}/intune/ScriptCodeViewer.tsx (100%) rename src/{components => workspaces}/intune/SummaryView.tsx (99%) rename src/{components => workspaces}/intune/SummaryViewComponents.tsx (99%) rename src/{components => workspaces}/intune/intune-dashboard-utils.ts (98%) rename src/{stores => workspaces/intune}/intune-store.test.ts (100%) rename src/{stores => workspaces/intune}/intune-store.ts (98%) rename src/{components => workspaces}/intune/summary-view-logic.ts (99%) rename src/{types/intune.ts => workspaces/intune/types.ts} (98%) rename src/{hooks => workspaces/intune}/use-intune-analysis-progress.ts (82%) rename src/{components => workspaces}/intune/useTimeWindowFilter.ts (98%) diff --git a/src/components/dialogs/EvidenceBundleDialog.tsx b/src/components/dialogs/EvidenceBundleDialog.tsx index 8e75d7836..2005b35e6 100644 --- a/src/components/dialogs/EvidenceBundleDialog.tsx +++ b/src/components/dialogs/EvidenceBundleDialog.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { tokens } from "@fluentui/react-components"; import { inspectEvidenceArtifact, inspectEvidenceBundle } from "../../lib/commands"; import { useDsregcmdStore } from "../../workspaces/dsregcmd/dsregcmd-store"; -import { useIntuneStore } from "../../stores/intune-store"; +import { useIntuneStore } from "../../workspaces/intune/intune-store"; import { useLogStore } from "../../stores/log-store"; import { isIntuneWorkspace, useUiStore } from "../../stores/ui-store"; import { formatDisplayDateTime } from "../../lib/date-time-format"; diff --git a/src/components/dialogs/GuidRegistryDialog.tsx b/src/components/dialogs/GuidRegistryDialog.tsx index bf1649e48..b09ce631b 100644 --- a/src/components/dialogs/GuidRegistryDialog.tsx +++ b/src/components/dialogs/GuidRegistryDialog.tsx @@ -13,8 +13,8 @@ import { import { SearchRegular } from "@fluentui/react-icons"; import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { LOG_MONOSPACE_FONT_FAMILY } from "../../lib/log-accessibility"; -import { useIntuneStore } from "../../stores/intune-store"; -import type { GuidCategory, GuidRegistryEntry } from "../../types/intune"; +import { useIntuneStore } from "../../workspaces/intune/intune-store"; +import type { GuidCategory, GuidRegistryEntry } from "../../workspaces/intune/types"; const SOURCE_LABELS: Record = { ApplicationName: { label: "AppName", color: tokens.colorPaletteGreenForeground1 }, diff --git a/src/components/dialogs/settings/GraphApiTab.tsx b/src/components/dialogs/settings/GraphApiTab.tsx index 86b92419f..c04253f5d 100644 --- a/src/components/dialogs/settings/GraphApiTab.tsx +++ b/src/components/dialogs/settings/GraphApiTab.tsx @@ -8,7 +8,7 @@ import { graphFetchAllApps, type GraphAuthStatus, } from "../../../lib/commands"; -import { useIntuneStore } from "../../../stores/intune-store"; +import { useIntuneStore } from "../../../workspaces/intune/intune-store"; import { buildGraphRegistryEntries } from "../../../lib/graph-registry"; export function GraphApiTab() { diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 4368171bd..cf7f4d998 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -30,7 +30,7 @@ import { useLogStore } from "../../stores/log-store"; import { useFilterStore } from "../../stores/filter-store"; import { switchToTab } from "../../lib/log-source"; import { useFileWatcher } from "../../hooks/use-file-watcher"; -import { useIntuneAnalysisProgress } from "../../hooks/use-intune-analysis-progress"; +import { useIntuneAnalysisProgress } from "../../workspaces/intune/use-intune-analysis-progress"; import { useSysmonAnalysisProgress } from "../../workspaces/sysmon/use-sysmon-analysis-progress"; import { useKeyboard } from "../../hooks/use-keyboard"; import { useDragDrop } from "../../hooks/use-drag-drop"; diff --git a/src/components/layout/FileSidebar.tsx b/src/components/layout/FileSidebar.tsx index 7ab090b56..b40b32d1d 100644 --- a/src/components/layout/FileSidebar.tsx +++ b/src/components/layout/FileSidebar.tsx @@ -10,7 +10,6 @@ import { formatDisplayDateTime } from "../../lib/date-time-format"; import { getLogListMetrics, LOG_UI_FONT_FAMILY } from "../../lib/log-accessibility"; import { loadLogSource, loadSelectedLogFile } from "../../lib/log-source"; import { useFilterStore } from "../../stores/filter-store"; -import { useIntuneStore } from "../../stores/intune-store"; import { getWorkspace } from "../../workspaces/registry"; import { getActiveSourceLabel, @@ -614,205 +613,6 @@ export function LogSidebar() { ); } -export function IntuneSidebar() { - const activeView = useUiStore((s) => s.activeView); - const intuneAnalysisState = useIntuneStore((s) => s.analysisState); - const intuneIsAnalyzing = useIntuneStore((s) => s.isAnalyzing); - const intuneSummary = useIntuneStore((s) => s.summary); - const eventLogAnalysis = useIntuneStore((s) => s.eventLogAnalysis); - const intuneEvidenceBundle = useIntuneStore((s) => s.evidenceBundle); - const intuneSourceContext = useIntuneStore((s) => s.sourceContext); - const intuneTimelineScope = useIntuneStore((s) => s.timelineScope); - const setIntuneTimelineFileScope = useIntuneStore((s) => s.setTimelineFileScope); - - const intuneIncludedFiles = intuneSourceContext.includedFiles; - const intuneSelectedFilePath = intuneTimelineScope.filePath; - const intuneRequestedPath = intuneAnalysisState.requestedPath; - const hasIntuneResults = intuneSummary != null || intuneIncludedFiles.length > 0; - const workspaceTitle = activeView === "new-intune" ? "New Intune Workspace" : "Intune diagnostics workspace"; - const workspaceBadge = activeView === "new-intune" ? "New Intune" : intuneEvidenceBundle ? "Intune Bundle" : "Intune"; - - return ( - <> - -
{intuneAnalysisState.message}
-
Included files: {intuneIncludedFiles.length}
- {intuneEvidenceBundle && ( -
- Bundle: {intuneEvidenceBundle.bundleLabel ?? intuneEvidenceBundle.bundleId ?? "Detected"} -
- )} - {intuneSummary &&
Events: {intuneSummary.totalEvents}
} - {eventLogAnalysis && ( -
- Event logs: {eventLogAnalysis.totalEntryCount} entries - {eventLogAnalysis.sourceKind === "Live" && eventLogAnalysis.liveQuery - ? ` across ${eventLogAnalysis.liveQuery.channelsWithResultsCount}/${eventLogAnalysis.liveQuery.attemptedChannelCount} queried channels` - : ` across ${eventLogAnalysis.parsedFileCount} channel(s)`} -
- )} -
- } - /> - - {(intuneAnalysisState.phase === "analyzing" || - intuneAnalysisState.phase === "error" || - intuneAnalysisState.phase === "empty") && ( - - )} - -
- {!hasIntuneResults && !intuneIsAnalyzing && intuneAnalysisState.phase !== "error" && ( - - )} - - {intuneIsAnalyzing && ( - - )} - - {!hasIntuneResults && intuneAnalysisState.phase === "error" && ( - - )} - - {intuneSummary && ( - <> - -
- Events - {intuneSummary.totalEvents.toLocaleString()} - Downloads - {intuneSummary.totalDownloads} - {eventLogAnalysis && ( - <> - Event logs - {eventLogAnalysis.totalEntryCount.toLocaleString()} entries - Severity - {eventLogAnalysis.errorEntryCount} errors, {eventLogAnalysis.warningEntryCount} warnings - - )} - {eventLogAnalysis?.sourceKind === "Live" && eventLogAnalysis.liveQuery && ( - <> - Live query - {eventLogAnalysis.liveQuery.successfulChannelCount} ok, {eventLogAnalysis.liveQuery.failedChannelCount} failed - - )} - {intuneSummary.logTimeSpan && ( - <> - Time span - {intuneSummary.logTimeSpan} - - )} -
- - )} - - {intuneIncludedFiles.length > 0 && ( - <> - - {intuneIncludedFiles.map((path) => { - const isSelected = intuneSelectedFilePath === path; - return ( - - ); - })} - - )} -
- - ); -} - function SidebarFooter() { const isPaused = useLogStore((s) => s.isPaused); const isLoading = useLogStore((s) => s.isLoading); diff --git a/src/components/layout/StatusBar.tsx b/src/components/layout/StatusBar.tsx index 9ee6deead..5431f7eb6 100644 --- a/src/components/layout/StatusBar.tsx +++ b/src/components/layout/StatusBar.tsx @@ -18,7 +18,7 @@ import { isIntuneWorkspace, useUiStore, } from "../../stores/ui-store"; -import { useIntuneStore } from "../../stores/intune-store"; +import { useIntuneStore } from "../../workspaces/intune/intune-store"; import { useDsregcmdStore } from "../../workspaces/dsregcmd/dsregcmd-store"; import { useDeploymentStore } from "../../workspaces/deployment/deployment-store"; import { useSysmonStore } from "../../workspaces/sysmon/sysmon-store"; diff --git a/src/components/layout/Toolbar.tsx b/src/components/layout/Toolbar.tsx index 2f879ace4..c816d9f6d 100644 --- a/src/components/layout/Toolbar.tsx +++ b/src/components/layout/Toolbar.tsx @@ -32,7 +32,7 @@ import { } from "../../lib/dsregcmd-source"; import { useLogStore } from "../../stores/log-store"; import { useFilterStore } from "../../stores/filter-store"; -import { useIntuneStore } from "../../stores/intune-store"; +import { useIntuneStore } from "../../workspaces/intune/intune-store"; import { useDsregcmdStore } from "../../workspaces/dsregcmd/dsregcmd-store"; import { useSysmonStore } from "../../workspaces/sysmon/sysmon-store"; import { isIntuneWorkspace, getAvailableWorkspaces, type IntuneWorkspaceId, type WorkspaceId, type PlatformId, useUiStore } from "../../stores/ui-store"; diff --git a/src/components/log-view/AppWorkloadScriptDetail.tsx b/src/components/log-view/AppWorkloadScriptDetail.tsx index f6830312d..db693d000 100644 --- a/src/components/log-view/AppWorkloadScriptDetail.tsx +++ b/src/components/log-view/AppWorkloadScriptDetail.tsx @@ -1,7 +1,7 @@ import { useMemo } from "react"; import { tokens } from "@fluentui/react-components"; import { LOG_MONOSPACE_FONT_FAMILY } from "../../lib/log-accessibility"; -import { ScriptCodeViewer } from "../intune/ScriptCodeViewer"; +import { ScriptCodeViewer } from "../../workspaces/intune/ScriptCodeViewer"; interface PolicyEntry { id: string; diff --git a/src/hooks/use-graph-api-startup.ts b/src/hooks/use-graph-api-startup.ts index 87eda32aa..97a4225a1 100644 --- a/src/hooks/use-graph-api-startup.ts +++ b/src/hooks/use-graph-api-startup.ts @@ -1,5 +1,5 @@ import { useUiStore } from "../stores/ui-store"; -import { useIntuneStore } from "../stores/intune-store"; +import { useIntuneStore } from "../workspaces/intune/intune-store"; import { buildGraphRegistryEntries } from "../lib/graph-registry"; import { graphAuthenticate, diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 706ff2fc8..6b820a81e 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -10,7 +10,7 @@ import type { } from "../types/log"; import type { EvidenceArtifactPreview, EvidenceBundleDetails, EvidenceArtifactIntakeKind } from "../types/evidence"; import type { RegistryParseResult } from "../types/registry"; -import type { IntuneAnalysisResult } from "../types/intune"; +import type { IntuneAnalysisResult } from "../workspaces/intune/types"; import type { SysmonAnalysisResult } from "../workspaces/sysmon/types"; import type { DsregcmdAnalysisResult, diff --git a/src/lib/graph-registry.ts b/src/lib/graph-registry.ts index 059693813..517ede10a 100644 --- a/src/lib/graph-registry.ts +++ b/src/lib/graph-registry.ts @@ -1,5 +1,5 @@ import type { GraphAppInfo } from "./commands"; -import type { GuidCategory, GuidRegistryEntry } from "../types/intune"; +import type { GuidCategory, GuidRegistryEntry } from "../workspaces/intune/types"; function categorizeOdataType(odataType: string | null): GuidCategory { if (!odataType) return "unknown"; diff --git a/src/lib/intune-sort.ts b/src/lib/intune-sort.ts index 4d8eafee2..a2ac3fb56 100644 --- a/src/lib/intune-sort.ts +++ b/src/lib/intune-sort.ts @@ -1,6 +1,6 @@ -import type { IntuneEvent, DownloadStat } from "../types/intune"; -import { STATUS_RANK } from "../types/intune"; -import type { IntuneSortField, DownloadSortField } from "../stores/intune-store"; +import type { IntuneEvent, DownloadStat } from "../workspaces/intune/types"; +import { STATUS_RANK } from "../workspaces/intune/types"; +import type { IntuneSortField, DownloadSortField } from "../workspaces/intune/intune-store"; export type SortDirection = "asc" | "desc"; diff --git a/src/lib/session-save.ts b/src/lib/session-save.ts index 496305e69..ee198bf1a 100644 --- a/src/lib/session-save.ts +++ b/src/lib/session-save.ts @@ -4,7 +4,7 @@ import { writeTextFile } from "@tauri-apps/plugin-fs"; import { useLogStore, getCachedTabSnapshot } from "../stores/log-store"; import { useUiStore } from "../stores/ui-store"; import { useFilterStore } from "../stores/filter-store"; -import { useIntuneStore } from "../stores/intune-store"; +import { useIntuneStore } from "../workspaces/intune/intune-store"; import type { SessionFile, SessionTab } from "./session"; interface FileHashResult { diff --git a/src/types/event-log.ts b/src/types/event-log.ts index 13259dec3..67f0238d4 100644 --- a/src/types/event-log.ts +++ b/src/types/event-log.ts @@ -1,4 +1,4 @@ -import type { IntuneTimestampBounds } from "./intune"; +import type { IntuneTimestampBounds } from "../workspaces/intune/types"; export type EventLogSeverity = | "Critical" diff --git a/src/components/intune/DownloadStats.tsx b/src/workspaces/intune/DownloadStats.tsx similarity index 98% rename from src/components/intune/DownloadStats.tsx rename to src/workspaces/intune/DownloadStats.tsx index 1fa4e980b..cbb659b75 100644 --- a/src/components/intune/DownloadStats.tsx +++ b/src/workspaces/intune/DownloadStats.tsx @@ -1,11 +1,11 @@ import { useMemo, useCallback } from "react"; import { tokens } from "@fluentui/react-components"; import { LOG_UI_FONT_FAMILY, LOG_MONOSPACE_FONT_FAMILY } from "../../lib/log-accessibility"; -import type { DownloadStat } from "../../types/intune"; +import type { DownloadStat } from "./types"; import { formatDisplayDateTime } from "../../lib/date-time-format"; import { compareDownloads } from "../../lib/intune-sort"; -import { useIntuneStore } from "../../stores/intune-store"; -import type { DownloadSortField } from "../../stores/intune-store"; +import { useIntuneStore } from "./intune-store"; +import type { DownloadSortField } from "./intune-store"; interface DownloadStatsProps { downloads: DownloadStat[]; diff --git a/src/components/intune/DownloadSurface.tsx b/src/workspaces/intune/DownloadSurface.tsx similarity index 99% rename from src/components/intune/DownloadSurface.tsx rename to src/workspaces/intune/DownloadSurface.tsx index 0f4d2d0c1..c6d62f3b5 100644 --- a/src/components/intune/DownloadSurface.tsx +++ b/src/workspaces/intune/DownloadSurface.tsx @@ -8,7 +8,7 @@ import { shorthands, tokens, } from "@fluentui/react-components"; -import type { DownloadStat } from "../../types/intune"; +import type { DownloadStat } from "./types"; import { formatDisplayDateTime } from "../../lib/date-time-format"; import { LOG_UI_FONT_FAMILY, diff --git a/src/components/intune/EventActivityView.tsx b/src/workspaces/intune/EventActivityView.tsx similarity index 99% rename from src/components/intune/EventActivityView.tsx rename to src/workspaces/intune/EventActivityView.tsx index 119c1a8ec..4404b6c5a 100644 --- a/src/components/intune/EventActivityView.tsx +++ b/src/workspaces/intune/EventActivityView.tsx @@ -8,8 +8,8 @@ import { } from "../../lib/log-accessibility"; import { formatDisplayDateTime } from "../../lib/date-time-format"; import { useUiStore } from "../../stores/ui-store"; -import { useIntuneStore } from "../../stores/intune-store"; -import type { IntuneEvent, IntuneStatus, IntuneEventType, GuidRegistryEntry } from "../../types/intune"; +import { useIntuneStore } from "./intune-store"; +import type { IntuneEvent, IntuneStatus, IntuneEventType, GuidRegistryEntry } from "./types"; import { STATUS_COLORS, EVENT_TYPE_LABELS, formatDuration } from "./EventTimelineRow"; /** A group of events sharing the same app identity. */ diff --git a/src/components/intune/EventLogSurface.tsx b/src/workspaces/intune/EventLogSurface.tsx similarity index 99% rename from src/components/intune/EventLogSurface.tsx rename to src/workspaces/intune/EventLogSurface.tsx index 7f4a65b0f..ac20e5674 100644 --- a/src/components/intune/EventLogSurface.tsx +++ b/src/workspaces/intune/EventLogSurface.tsx @@ -12,7 +12,7 @@ import type { import { useIntuneStore, getCorrelationLinksForEntry, -} from "../../stores/intune-store"; +} from "./intune-store"; const COLLAPSED_ROW_ESTIMATE = 28; const EXPANDED_ROW_ESTIMATE = 200; diff --git a/src/components/intune/EventTimeline.tsx b/src/workspaces/intune/EventTimeline.tsx similarity index 98% rename from src/components/intune/EventTimeline.tsx rename to src/workspaces/intune/EventTimeline.tsx index 2ad3a4e96..f1346694f 100644 --- a/src/components/intune/EventTimeline.tsx +++ b/src/workspaces/intune/EventTimeline.tsx @@ -6,9 +6,9 @@ import { getLogListMetrics, } from "../../lib/log-accessibility"; import { useUiStore } from "../../stores/ui-store"; -import type { IntuneEvent } from "../../types/intune"; +import type { IntuneEvent } from "./types"; import { compareEvents } from "../../lib/intune-sort"; -import { useIntuneStore } from "../../stores/intune-store"; +import { useIntuneStore } from "./intune-store"; import { EventTimelineRow, getFileName } from "./EventTimelineRow"; import { EventActivityView } from "./EventActivityView"; diff --git a/src/components/intune/EventTimelineRow.test.tsx b/src/workspaces/intune/EventTimelineRow.test.tsx similarity index 97% rename from src/components/intune/EventTimelineRow.test.tsx rename to src/workspaces/intune/EventTimelineRow.test.tsx index 6e9267357..738ca7392 100644 --- a/src/components/intune/EventTimelineRow.test.tsx +++ b/src/workspaces/intune/EventTimelineRow.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi, beforeEach } from "vitest"; import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { EventTimelineRow } from "./EventTimelineRow"; -import type { IntuneEvent } from "../../types/intune"; +import type { IntuneEvent } from "./types"; describe("EventTimelineRow", () => { beforeEach(() => { diff --git a/src/components/intune/EventTimelineRow.tsx b/src/workspaces/intune/EventTimelineRow.tsx similarity index 99% rename from src/components/intune/EventTimelineRow.tsx rename to src/workspaces/intune/EventTimelineRow.tsx index 0ede257b9..ce3b5900c 100644 --- a/src/components/intune/EventTimelineRow.tsx +++ b/src/workspaces/intune/EventTimelineRow.tsx @@ -6,7 +6,7 @@ import { formatDisplayDateTime } from "../../lib/date-time-format"; import { LOG_MONOSPACE_FONT_FAMILY, } from "../../lib/log-accessibility"; -import type { IntuneEvent, IntuneStatus, IntuneEventType } from "../../types/intune"; +import type { IntuneEvent, IntuneStatus, IntuneEventType } from "./types"; import { ScriptCodeViewer } from "./ScriptCodeViewer"; export const STATUS_COLORS: Record = { diff --git a/src/components/intune/IntuneDashboard.tsx b/src/workspaces/intune/IntuneDashboard.tsx similarity index 99% rename from src/components/intune/IntuneDashboard.tsx rename to src/workspaces/intune/IntuneDashboard.tsx index 23697b50d..896eaa43d 100644 --- a/src/components/intune/IntuneDashboard.tsx +++ b/src/workspaces/intune/IntuneDashboard.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo } from "react"; import { tokens } from "@fluentui/react-components"; -import { useIntuneStore } from "../../stores/intune-store"; +import { useIntuneStore } from "./intune-store"; import { EventTimeline } from "./EventTimeline"; import { DownloadStats } from "./DownloadStats"; import { SummaryView } from "./SummaryView"; diff --git a/src/components/intune/IntuneDashboardHeader.tsx b/src/workspaces/intune/IntuneDashboardHeader.tsx similarity index 97% rename from src/components/intune/IntuneDashboardHeader.tsx rename to src/workspaces/intune/IntuneDashboardHeader.tsx index 50208e4c7..02ee4907a 100644 --- a/src/components/intune/IntuneDashboardHeader.tsx +++ b/src/workspaces/intune/IntuneDashboardHeader.tsx @@ -1,8 +1,8 @@ import { useMemo } from "react"; import { tokens } from "@fluentui/react-components"; import { LOG_UI_FONT_FAMILY } from "../../lib/log-accessibility"; -import { useIntuneStore } from "../../stores/intune-store"; -import { useAppActions } from "../layout/Toolbar"; +import { useIntuneStore } from "./intune-store"; +import { useAppActions } from "../../components/layout/Toolbar"; import { buildSourceFamilySummary } from "./intune-dashboard-utils"; export function IntuneDashboardHeader() { diff --git a/src/components/intune/IntuneDashboardNavBar.tsx b/src/workspaces/intune/IntuneDashboardNavBar.tsx similarity index 98% rename from src/components/intune/IntuneDashboardNavBar.tsx rename to src/workspaces/intune/IntuneDashboardNavBar.tsx index f50831ffd..b559b880c 100644 --- a/src/components/intune/IntuneDashboardNavBar.tsx +++ b/src/workspaces/intune/IntuneDashboardNavBar.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; import { tokens } from "@fluentui/react-components"; -import { useIntuneStore } from "../../stores/intune-store"; +import { useIntuneStore } from "./intune-store"; import type { DownloadStat, IntuneEvent, @@ -8,8 +8,8 @@ import type { IntuneStatus, IntuneSummary, IntuneTimeWindowPreset, -} from "../../types/intune"; -import type { IntuneSortField, IntuneTimelineViewMode } from "../../stores/intune-store"; +} from "./types"; +import type { IntuneSortField, IntuneTimelineViewMode } from "./intune-store"; import { selectStyle, getFileName } from "./intune-dashboard-utils"; type TabId = "timeline" | "downloads" | "summary"; diff --git a/src/workspaces/intune/IntuneSidebar.tsx b/src/workspaces/intune/IntuneSidebar.tsx new file mode 100644 index 000000000..38a4ca608 --- /dev/null +++ b/src/workspaces/intune/IntuneSidebar.tsx @@ -0,0 +1,360 @@ +import type { ReactNode } from "react"; +import { Badge, Caption1, Subtitle2, tokens } from "@fluentui/react-components"; +import { getBaseName } from "../../stores/log-store"; +import { useUiStore } from "../../stores/ui-store"; +import { useIntuneStore } from "./intune-store"; + +// --------------------------------------------------------------------------- +// Helpers (inlined from FileSidebar.tsx to keep this component self-contained) +// --------------------------------------------------------------------------- + +function SourceSummaryCard({ + badge, + title, + subtitle, + body, +}: { + badge: string; + title: string; + subtitle: string; + body: ReactNode; +}) { + return ( +
+ + {badge} + + + {title} + + + {subtitle} + +
{body}
+
+ ); +} + +function SourceStatusNotice({ + kind, + message, + detail, +}: { + kind: string; + message: string; + detail?: string; +}) { + const colors = + kind === "missing" || kind === "error" + ? { border: tokens.colorPaletteRedBorder2, background: tokens.colorPaletteRedBackground1, text: tokens.colorPaletteRedForeground2 } + : kind === "empty" || kind === "awaiting-file-selection" + ? { border: tokens.colorPaletteYellowBorder2, background: tokens.colorPaletteYellowBackground1, text: tokens.colorPaletteMarigoldForeground2 } + : { border: tokens.colorPaletteBlueBorderActive, background: tokens.colorPaletteBlueBackground2, text: tokens.colorPaletteBlueForeground2 }; + + return ( +
+
{message}
+ {detail &&
{detail}
} +
+ ); +} + +function SectionHeader({ title, caption }: { title: string; caption?: string }) { + return ( +
+ + {title} + + {caption && ( + + {caption} + + )} +
+ ); +} + +function EmptyState({ title, body }: { title: string; body: string }) { + return ( +
+ {title} +
{body}
+
+ ); +} + +// --------------------------------------------------------------------------- +// IntuneSidebar +// --------------------------------------------------------------------------- + +export function IntuneSidebar() { + const activeView = useUiStore((s) => s.activeView); + const intuneAnalysisState = useIntuneStore((s) => s.analysisState); + const intuneIsAnalyzing = useIntuneStore((s) => s.isAnalyzing); + const intuneSummary = useIntuneStore((s) => s.summary); + const eventLogAnalysis = useIntuneStore((s) => s.eventLogAnalysis); + const intuneEvidenceBundle = useIntuneStore((s) => s.evidenceBundle); + const intuneSourceContext = useIntuneStore((s) => s.sourceContext); + const intuneTimelineScope = useIntuneStore((s) => s.timelineScope); + const setIntuneTimelineFileScope = useIntuneStore((s) => s.setTimelineFileScope); + + const intuneIncludedFiles = intuneSourceContext.includedFiles; + const intuneSelectedFilePath = intuneTimelineScope.filePath; + const intuneRequestedPath = intuneAnalysisState.requestedPath; + const hasIntuneResults = intuneSummary != null || intuneIncludedFiles.length > 0; + const workspaceTitle = activeView === "new-intune" ? "New Intune Workspace" : "Intune diagnostics workspace"; + const workspaceBadge = activeView === "new-intune" ? "New Intune" : intuneEvidenceBundle ? "Intune Bundle" : "Intune"; + + return ( + <> + +
{intuneAnalysisState.message}
+
Included files: {intuneIncludedFiles.length}
+ {intuneEvidenceBundle && ( +
+ Bundle: {intuneEvidenceBundle.bundleLabel ?? intuneEvidenceBundle.bundleId ?? "Detected"} +
+ )} + {intuneSummary &&
Events: {intuneSummary.totalEvents}
} + {eventLogAnalysis && ( +
+ Event logs: {eventLogAnalysis.totalEntryCount} entries + {eventLogAnalysis.sourceKind === "Live" && eventLogAnalysis.liveQuery + ? ` across ${eventLogAnalysis.liveQuery.channelsWithResultsCount}/${eventLogAnalysis.liveQuery.attemptedChannelCount} queried channels` + : ` across ${eventLogAnalysis.parsedFileCount} channel(s)`} +
+ )} +
+ } + /> + + {(intuneAnalysisState.phase === "analyzing" || + intuneAnalysisState.phase === "error" || + intuneAnalysisState.phase === "empty") && ( + + )} + +
+ {!hasIntuneResults && !intuneIsAnalyzing && intuneAnalysisState.phase !== "error" && ( + + )} + + {intuneIsAnalyzing && ( + + )} + + {!hasIntuneResults && intuneAnalysisState.phase === "error" && ( + + )} + + {intuneSummary && ( + <> + +
+ Events + {intuneSummary.totalEvents.toLocaleString()} + Downloads + {intuneSummary.totalDownloads} + {eventLogAnalysis && ( + <> + Event logs + {eventLogAnalysis.totalEntryCount.toLocaleString()} entries + Severity + {eventLogAnalysis.errorEntryCount} errors, {eventLogAnalysis.warningEntryCount} warnings + + )} + {eventLogAnalysis?.sourceKind === "Live" && eventLogAnalysis.liveQuery && ( + <> + Live query + {eventLogAnalysis.liveQuery.successfulChannelCount} ok, {eventLogAnalysis.liveQuery.failedChannelCount} failed + + )} + {intuneSummary.logTimeSpan && ( + <> + Time span + {intuneSummary.logTimeSpan} + + )} +
+ + )} + + {intuneIncludedFiles.length > 0 && ( + <> + + {intuneIncludedFiles.map((path) => { + const isSelected = intuneSelectedFilePath === path; + return ( + + ); + })} + + )} +
+ + ); +} diff --git a/src/components/intune/InvestigationPanel.tsx b/src/workspaces/intune/InvestigationPanel.tsx similarity index 99% rename from src/components/intune/InvestigationPanel.tsx rename to src/workspaces/intune/InvestigationPanel.tsx index c3ea4b98f..dcc293e40 100644 --- a/src/components/intune/InvestigationPanel.tsx +++ b/src/workspaces/intune/InvestigationPanel.tsx @@ -9,7 +9,7 @@ import { } from "@fluentui/react-components"; import { DownloadSurface } from "./DownloadSurface"; import { EventTimeline } from "./EventTimeline"; -import type { DownloadStat, IntuneEvent, IntuneEventType, IntuneStatus } from "../../types/intune"; +import type { DownloadStat, IntuneEvent, IntuneEventType, IntuneStatus } from "./types"; /** Inline style that forces Fluent typography components to inherit font size. */ const inheritFontSize: React.CSSProperties = { fontSize: "inherit" }; diff --git a/src/components/intune/NewIntuneWorkspace.tsx b/src/workspaces/intune/NewIntuneWorkspace.tsx similarity index 99% rename from src/components/intune/NewIntuneWorkspace.tsx rename to src/workspaces/intune/NewIntuneWorkspace.tsx index 986b220f0..3673f2089 100644 --- a/src/components/intune/NewIntuneWorkspace.tsx +++ b/src/workspaces/intune/NewIntuneWorkspace.tsx @@ -13,9 +13,9 @@ import { tokens, } from "@fluentui/react-components"; import { getLogListMetrics } from "../../lib/log-accessibility"; -import { useIntuneStore } from "../../stores/intune-store"; +import { useIntuneStore } from "./intune-store"; import { useUiStore } from "../../stores/ui-store"; -import { useAppActions } from "../layout/Toolbar"; +import { useAppActions } from "../../components/layout/Toolbar"; import { EventLogSurface } from "./EventLogSurface"; import { InvestigationPanel } from "./InvestigationPanel"; import { OverviewSurface } from "./OverviewSurface"; @@ -27,7 +27,7 @@ import type { IntuneRepeatedFailureGroup, IntuneRemediationPriority, IntuneStatus, -} from "../../types/intune"; +} from "./types"; type NewIntuneSurface = "overview" | "timeline" | "downloads" | "event-logs"; diff --git a/src/components/intune/OverviewSurface.tsx b/src/workspaces/intune/OverviewSurface.tsx similarity index 99% rename from src/components/intune/OverviewSurface.tsx rename to src/workspaces/intune/OverviewSurface.tsx index 9227808f4..23b5ab97c 100644 --- a/src/components/intune/OverviewSurface.tsx +++ b/src/workspaces/intune/OverviewSurface.tsx @@ -13,7 +13,7 @@ import { } from "@fluentui/react-components"; import { formatDisplayDateTime } from "../../lib/date-time-format"; import { getLogListMetrics } from "../../lib/log-accessibility"; -import { getEventLogEntryIdsForDiagnostic } from "../../stores/intune-store"; +import { getEventLogEntryIdsForDiagnostic } from "./intune-store"; import { useUiStore } from "../../stores/ui-store"; import type { EventLogAnalysis, @@ -25,7 +25,7 @@ import type { IntuneEventType, IntuneRepeatedFailureGroup, IntuneRemediationPriority, -} from "../../types/intune"; +} from "./types"; /** Inline style that forces Fluent typography components to inherit font size. */ const inheritFontSize: React.CSSProperties = { fontSize: "inherit" }; diff --git a/src/components/intune/ScriptCodeViewer.tsx b/src/workspaces/intune/ScriptCodeViewer.tsx similarity index 100% rename from src/components/intune/ScriptCodeViewer.tsx rename to src/workspaces/intune/ScriptCodeViewer.tsx diff --git a/src/components/intune/SummaryView.tsx b/src/workspaces/intune/SummaryView.tsx similarity index 99% rename from src/components/intune/SummaryView.tsx rename to src/workspaces/intune/SummaryView.tsx index 2be2d8691..4cb72d5b6 100644 --- a/src/components/intune/SummaryView.tsx +++ b/src/workspaces/intune/SummaryView.tsx @@ -1,13 +1,13 @@ import { useMemo, useRef, useState } from "react"; import { tokens } from "@fluentui/react-components"; import { LOG_UI_FONT_FAMILY, LOG_MONOSPACE_FONT_FAMILY } from "../../lib/log-accessibility"; -import { useIntuneStore } from "../../stores/intune-store"; +import { useIntuneStore } from "./intune-store"; import type { IntuneDiagnosticInsight, IntuneEvent, IntuneTimeWindowPreset, IntuneSummary, -} from "../../types/intune"; +} from "./types"; import { buildDominantSourceLabel, buildSourceFamilySummary, diff --git a/src/components/intune/SummaryViewComponents.tsx b/src/workspaces/intune/SummaryViewComponents.tsx similarity index 99% rename from src/components/intune/SummaryViewComponents.tsx rename to src/workspaces/intune/SummaryViewComponents.tsx index 554e22590..0f4aff9f0 100644 --- a/src/components/intune/SummaryViewComponents.tsx +++ b/src/workspaces/intune/SummaryViewComponents.tsx @@ -6,7 +6,7 @@ import type { IntuneLogSourceKind, IntuneRepeatedFailureGroup, IntuneSourceFamilySummary, -} from "../../types/intune"; +} from "./types"; import { formatSourceFamilyDetail, formatTimestampBounds, diff --git a/src/workspaces/intune/index.ts b/src/workspaces/intune/index.ts index 726fbd671..0a5cc18cb 100644 --- a/src/workspaces/intune/index.ts +++ b/src/workspaces/intune/index.ts @@ -7,12 +7,12 @@ export const intuneWorkspace: WorkspaceDefinition = { label: "Intune Diagnostics", platforms: "all", component: lazy(() => - import("../../components/intune/IntuneDashboard").then((m) => ({ + import("./IntuneDashboard").then((m) => ({ default: m.IntuneDashboard, })) ), sidebar: lazy(() => - import("../../components/layout/FileSidebar").then((m) => ({ + import("./IntuneSidebar").then((m) => ({ default: m.IntuneSidebar, })) ), diff --git a/src/components/intune/intune-dashboard-utils.ts b/src/workspaces/intune/intune-dashboard-utils.ts similarity index 98% rename from src/components/intune/intune-dashboard-utils.ts rename to src/workspaces/intune/intune-dashboard-utils.ts index ee978fd07..802f1ac24 100644 --- a/src/components/intune/intune-dashboard-utils.ts +++ b/src/workspaces/intune/intune-dashboard-utils.ts @@ -9,7 +9,7 @@ import type { IntuneRemediationPriority, IntuneSourceFamilySummary, IntuneTimestampBounds, -} from "../../types/intune"; +} from "./types"; export function getFileName(path: string): string { const normalized = path.replace(/\\/g, "/"); @@ -346,7 +346,7 @@ export function formatSourceFamilyDetail(family: IntuneSourceFamilySummary): str } export function buildDominantSourceLabel( - dominantSource: NonNullable + dominantSource: NonNullable ): string { const share = dominantSource.eventShare != null ? ` (${formatEventShare(dominantSource.eventShare)})` : ""; return `${getFileName(dominantSource.filePath)}${share}`; diff --git a/src/stores/intune-store.test.ts b/src/workspaces/intune/intune-store.test.ts similarity index 100% rename from src/stores/intune-store.test.ts rename to src/workspaces/intune/intune-store.test.ts diff --git a/src/stores/intune-store.ts b/src/workspaces/intune/intune-store.ts similarity index 98% rename from src/stores/intune-store.ts rename to src/workspaces/intune/intune-store.ts index 4d5243e70..8f4c1a271 100644 --- a/src/stores/intune-store.ts +++ b/src/workspaces/intune/intune-store.ts @@ -1,12 +1,12 @@ import { create } from "zustand"; -import { parseDisplayDateTimeValue } from "../lib/date-time-format"; -import type { EvidenceBundleMetadata } from "../types/evidence"; +import { parseDisplayDateTimeValue } from "../../lib/date-time-format"; +import type { EvidenceBundleMetadata } from "../../types/evidence"; import type { EventLogAnalysis, EventLogChannel, EventLogCorrelationLink, EventLogSeverity, -} from "../types/event-log"; +} from "../../types/event-log"; import type { AppPolicyMetadata, DownloadStat, @@ -28,7 +28,7 @@ import type { IntuneTimeWindowPreset, IntuneTimelineScope, IntuneTimestampBounds, -} from "../types/intune"; +} from "./types"; export type IntuneWorkspaceTab = "timeline" | "downloads" | "summary"; export type IntuneTimelineViewMode = "list" | "activity"; @@ -171,7 +171,7 @@ interface IntuneState { evidenceBundle: EvidenceBundleMetadata | null; eventLogAnalysis: EventLogAnalysis | null; policyMetadata: Record; - guidRegistry: Record; + guidRegistry: Record; sourceFile: string | null; sourceFiles: string[]; sourceContext: IntuneSourceContext; @@ -218,7 +218,7 @@ interface IntuneState { setFilterStatus: (status: IntuneStatus | "All") => void; setEventLogFilterChannel: (channel: EventLogChannel | "All") => void; setEventLogFilterSeverity: (severity: EventLogSeverity | "All") => void; - mergeGuidRegistry: (entries: Record) => void; + mergeGuidRegistry: (entries: Record) => void; selectEventLogEntry: (id: number | null) => void; setActiveTab: (tab: IntuneWorkspaceTab) => void; setTimelineViewMode: (mode: IntuneTimelineViewMode) => void; diff --git a/src/components/intune/summary-view-logic.ts b/src/workspaces/intune/summary-view-logic.ts similarity index 99% rename from src/components/intune/summary-view-logic.ts rename to src/workspaces/intune/summary-view-logic.ts index e8e0bb154..fa77c0232 100644 --- a/src/components/intune/summary-view-logic.ts +++ b/src/workspaces/intune/summary-view-logic.ts @@ -9,7 +9,7 @@ import type { IntuneRemediationPriority, IntuneStatus, IntuneSummary, -} from "../../types/intune"; +} from "./types"; import { formatEventShare, getFileName, diff --git a/src/types/intune.ts b/src/workspaces/intune/types.ts similarity index 98% rename from src/types/intune.ts rename to src/workspaces/intune/types.ts index 2f3772ad9..957aa2e59 100644 --- a/src/types/intune.ts +++ b/src/workspaces/intune/types.ts @@ -1,5 +1,5 @@ -import type { EvidenceBundleMetadata } from "./evidence"; -import type { EventLogAnalysis } from "./event-log"; +import type { EvidenceBundleMetadata } from "../../types/evidence"; +import type { EventLogAnalysis } from "../../types/event-log"; export type IntuneEventType = | "Win32App" diff --git a/src/hooks/use-intune-analysis-progress.ts b/src/workspaces/intune/use-intune-analysis-progress.ts similarity index 82% rename from src/hooks/use-intune-analysis-progress.ts rename to src/workspaces/intune/use-intune-analysis-progress.ts index ed439ee3e..52d68cd42 100644 --- a/src/hooks/use-intune-analysis-progress.ts +++ b/src/workspaces/intune/use-intune-analysis-progress.ts @@ -1,7 +1,7 @@ import { useEffect } from "react"; import { listen } from "@tauri-apps/api/event"; -import { useIntuneStore } from "../stores/intune-store"; -import type { IntuneAnalysisProgressEvent } from "../types/intune"; +import { useIntuneStore } from "./intune-store"; +import type { IntuneAnalysisProgressEvent } from "./types"; const INTUNE_ANALYSIS_PROGRESS_EVENT = "intune-analysis-progress"; diff --git a/src/components/intune/useTimeWindowFilter.ts b/src/workspaces/intune/useTimeWindowFilter.ts similarity index 98% rename from src/components/intune/useTimeWindowFilter.ts rename to src/workspaces/intune/useTimeWindowFilter.ts index d41373d5b..692787f4b 100644 --- a/src/components/intune/useTimeWindowFilter.ts +++ b/src/workspaces/intune/useTimeWindowFilter.ts @@ -1,13 +1,13 @@ import { useMemo } from "react"; import { parseDisplayDateTimeValue } from "../../lib/date-time-format"; -import { useIntuneStore } from "../../stores/intune-store"; +import { useIntuneStore } from "./intune-store"; import type { DownloadStat, IntuneEvent, IntuneEventType, IntuneSummary, IntuneTimeWindowPreset, -} from "../../types/intune"; +} from "./types"; export function useTimeWindowFilter() { const events = useIntuneStore((s) => s.events); diff --git a/src/workspaces/new-intune/index.ts b/src/workspaces/new-intune/index.ts index d0f5d3049..ce458fa86 100644 --- a/src/workspaces/new-intune/index.ts +++ b/src/workspaces/new-intune/index.ts @@ -7,12 +7,12 @@ export const newIntuneWorkspace: WorkspaceDefinition = { label: "New Intune Workspace", platforms: "all", component: lazy(() => - import("../../components/intune/NewIntuneWorkspace").then((m) => ({ + import("../intune/NewIntuneWorkspace").then((m) => ({ default: m.NewIntuneWorkspace, })) ), sidebar: lazy(() => - import("../../components/layout/FileSidebar").then((m) => ({ + import("../intune/IntuneSidebar").then((m) => ({ default: m.IntuneSidebar, })) ), From 161d41c8e0503b279f247dfee40acab5cbf58d88 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 5 Apr 2026 18:14:27 -0400 Subject: [PATCH 60/66] chore: remove empty component directories after workspace migration Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/settings.json | 7 +++++++ .../brainstorm/16702-1775419897/content/waiting.html | 3 +++ .../brainstorm/16702-1775419897/state/server-stopped | 1 + .superpowers/brainstorm/16702-1775419897/state/server.log | 3 +++ .superpowers/brainstorm/16702-1775419897/state/server.pid | 1 + src/workspaces/macos-diag/MacosDiagDefenderTab.tsx | 4 ++-- src/workspaces/macos-diag/MacosDiagEnvironmentBanner.tsx | 2 +- src/workspaces/macos-diag/MacosDiagIntuneLogsTab.tsx | 4 ++-- src/workspaces/macos-diag/MacosDiagPackagesTab.tsx | 2 +- src/workspaces/macos-diag/MacosDiagProfilesTab.tsx | 2 +- src/workspaces/macos-diag/MacosDiagTabStrip.tsx | 4 ++-- src/workspaces/macos-diag/MacosDiagUnifiedLogTab.tsx | 2 +- src/workspaces/macos-diag/MacosDiagWorkspace.tsx | 2 +- src/workspaces/macos-diag/macos-diag-store.ts | 2 +- 14 files changed, 27 insertions(+), 12 deletions(-) create mode 100644 .claude/settings.json create mode 100644 .superpowers/brainstorm/16702-1775419897/content/waiting.html create mode 100644 .superpowers/brainstorm/16702-1775419897/state/server-stopped create mode 100644 .superpowers/brainstorm/16702-1775419897/state/server.log create mode 100644 .superpowers/brainstorm/16702-1775419897/state/server.pid diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..598d3e923 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "enabledPlugins": { + "github@claude-plugins-official": true, + "typescript-lsp@claude-plugins-official": true, + "microsoft-docs@claude-plugins-official": true + } +} diff --git a/.superpowers/brainstorm/16702-1775419897/content/waiting.html b/.superpowers/brainstorm/16702-1775419897/content/waiting.html new file mode 100644 index 000000000..f92c257ac --- /dev/null +++ b/.superpowers/brainstorm/16702-1775419897/content/waiting.html @@ -0,0 +1,3 @@ +
+

Continuing in terminal...

+
\ No newline at end of file diff --git a/.superpowers/brainstorm/16702-1775419897/state/server-stopped b/.superpowers/brainstorm/16702-1775419897/state/server-stopped new file mode 100644 index 000000000..812da17cf --- /dev/null +++ b/.superpowers/brainstorm/16702-1775419897/state/server-stopped @@ -0,0 +1 @@ +{"reason":"idle timeout","timestamp":1775422957440} diff --git a/.superpowers/brainstorm/16702-1775419897/state/server.log b/.superpowers/brainstorm/16702-1775419897/state/server.log new file mode 100644 index 000000000..f32784e47 --- /dev/null +++ b/.superpowers/brainstorm/16702-1775419897/state/server.log @@ -0,0 +1,3 @@ +{"type":"server-started","port":65283,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:65283","screen_dir":"/Users/Adam.Gell/repo/cmtraceopen/.superpowers/brainstorm/16702-1775419897/content","state_dir":"/Users/Adam.Gell/repo/cmtraceopen/.superpowers/brainstorm/16702-1775419897/state"} +{"type":"screen-added","file":"/Users/Adam.Gell/repo/cmtraceopen/.superpowers/brainstorm/16702-1775419897/content/waiting.html"} +{"type":"server-stopped","reason":"idle timeout"} diff --git a/.superpowers/brainstorm/16702-1775419897/state/server.pid b/.superpowers/brainstorm/16702-1775419897/state/server.pid new file mode 100644 index 000000000..9236551df --- /dev/null +++ b/.superpowers/brainstorm/16702-1775419897/state/server.pid @@ -0,0 +1 @@ +16711 diff --git a/src/workspaces/macos-diag/MacosDiagDefenderTab.tsx b/src/workspaces/macos-diag/MacosDiagDefenderTab.tsx index 635fba774..c8673fcf1 100644 --- a/src/workspaces/macos-diag/MacosDiagDefenderTab.tsx +++ b/src/workspaces/macos-diag/MacosDiagDefenderTab.tsx @@ -7,12 +7,12 @@ import { Spinner, tokens, } from "@fluentui/react-components"; -import { useMacosDiagStore } from "../../stores/macos-diag-store"; +import { useMacosDiagStore } from "./macos-diag-store"; import { useUiStore } from "../../stores/ui-store"; import { useLogStore } from "../../stores/log-store"; import { macosInspectDefender, openLogFile } from "../../lib/commands"; import { getLogListMetrics } from "../../lib/log-accessibility"; -import type { MacosLogFileEntry } from "../../types/macos-diag"; +import type { MacosLogFileEntry } from "./types"; const useStyles = makeStyles({ healthCard: { diff --git a/src/workspaces/macos-diag/MacosDiagEnvironmentBanner.tsx b/src/workspaces/macos-diag/MacosDiagEnvironmentBanner.tsx index 3784e8500..21cdd0c52 100644 --- a/src/workspaces/macos-diag/MacosDiagEnvironmentBanner.tsx +++ b/src/workspaces/macos-diag/MacosDiagEnvironmentBanner.tsx @@ -4,7 +4,7 @@ import { shorthands, tokens, } from "@fluentui/react-components"; -import type { MacosDiagEnvironment } from "../../types/macos-diag"; +import type { MacosDiagEnvironment } from "./types"; const useStyles = makeStyles({ banner: { diff --git a/src/workspaces/macos-diag/MacosDiagIntuneLogsTab.tsx b/src/workspaces/macos-diag/MacosDiagIntuneLogsTab.tsx index aa8ecd9d0..360f0f981 100644 --- a/src/workspaces/macos-diag/MacosDiagIntuneLogsTab.tsx +++ b/src/workspaces/macos-diag/MacosDiagIntuneLogsTab.tsx @@ -7,12 +7,12 @@ import { Spinner, tokens, } from "@fluentui/react-components"; -import { useMacosDiagStore } from "../../stores/macos-diag-store"; +import { useMacosDiagStore } from "./macos-diag-store"; import { useUiStore } from "../../stores/ui-store"; import { macosScanIntuneLogs, openLogFile } from "../../lib/commands"; import { getLogListMetrics } from "../../lib/log-accessibility"; import { useLogStore } from "../../stores/log-store"; -import type { MacosLogFileEntry } from "../../types/macos-diag"; +import type { MacosLogFileEntry } from "./types"; const useStyles = makeStyles({ statCards: { diff --git a/src/workspaces/macos-diag/MacosDiagPackagesTab.tsx b/src/workspaces/macos-diag/MacosDiagPackagesTab.tsx index 130b30961..d361e234a 100644 --- a/src/workspaces/macos-diag/MacosDiagPackagesTab.tsx +++ b/src/workspaces/macos-diag/MacosDiagPackagesTab.tsx @@ -7,7 +7,7 @@ import { Spinner, tokens, } from "@fluentui/react-components"; -import { useMacosDiagStore } from "../../stores/macos-diag-store"; +import { useMacosDiagStore } from "./macos-diag-store"; import { useUiStore } from "../../stores/ui-store"; import { macosListPackages, diff --git a/src/workspaces/macos-diag/MacosDiagProfilesTab.tsx b/src/workspaces/macos-diag/MacosDiagProfilesTab.tsx index c7d15cac9..ec02b6022 100644 --- a/src/workspaces/macos-diag/MacosDiagProfilesTab.tsx +++ b/src/workspaces/macos-diag/MacosDiagProfilesTab.tsx @@ -7,7 +7,7 @@ import { Spinner, tokens, } from "@fluentui/react-components"; -import { useMacosDiagStore } from "../../stores/macos-diag-store"; +import { useMacosDiagStore } from "./macos-diag-store"; import { useUiStore } from "../../stores/ui-store"; import { macosListProfiles } from "../../lib/commands"; import { getLogListMetrics } from "../../lib/log-accessibility"; diff --git a/src/workspaces/macos-diag/MacosDiagTabStrip.tsx b/src/workspaces/macos-diag/MacosDiagTabStrip.tsx index 0f0ec4577..fddca076a 100644 --- a/src/workspaces/macos-diag/MacosDiagTabStrip.tsx +++ b/src/workspaces/macos-diag/MacosDiagTabStrip.tsx @@ -3,8 +3,8 @@ import { shorthands, tokens, } from "@fluentui/react-components"; -import { useMacosDiagStore } from "../../stores/macos-diag-store"; -import type { MacosDiagTabId } from "../../types/macos-diag"; +import { useMacosDiagStore } from "./macos-diag-store"; +import type { MacosDiagTabId } from "./types"; const useStyles = makeStyles({ strip: { diff --git a/src/workspaces/macos-diag/MacosDiagUnifiedLogTab.tsx b/src/workspaces/macos-diag/MacosDiagUnifiedLogTab.tsx index 4a07c55d4..532107336 100644 --- a/src/workspaces/macos-diag/MacosDiagUnifiedLogTab.tsx +++ b/src/workspaces/macos-diag/MacosDiagUnifiedLogTab.tsx @@ -8,7 +8,7 @@ import { tokens, } from "@fluentui/react-components"; import { useVirtualizer } from "@tanstack/react-virtual"; -import { useMacosDiagStore } from "../../stores/macos-diag-store"; +import { useMacosDiagStore } from "./macos-diag-store"; import { useUiStore } from "../../stores/ui-store"; import { macosQueryUnifiedLog } from "../../lib/commands"; import { getLogListMetrics } from "../../lib/log-accessibility"; diff --git a/src/workspaces/macos-diag/MacosDiagWorkspace.tsx b/src/workspaces/macos-diag/MacosDiagWorkspace.tsx index a8ddd5568..a39977989 100644 --- a/src/workspaces/macos-diag/MacosDiagWorkspace.tsx +++ b/src/workspaces/macos-diag/MacosDiagWorkspace.tsx @@ -5,7 +5,7 @@ import { Spinner, tokens, } from "@fluentui/react-components"; -import { useMacosDiagStore } from "../../stores/macos-diag-store"; +import { useMacosDiagStore } from "./macos-diag-store"; import { macosScanEnvironment } from "../../lib/commands"; import { MacosDiagEnvironmentBanner } from "./MacosDiagEnvironmentBanner"; import { MacosDiagTabStrip } from "./MacosDiagTabStrip"; diff --git a/src/workspaces/macos-diag/macos-diag-store.ts b/src/workspaces/macos-diag/macos-diag-store.ts index 5bbb7091c..21e0de7c5 100644 --- a/src/workspaces/macos-diag/macos-diag-store.ts +++ b/src/workspaces/macos-diag/macos-diag-store.ts @@ -9,7 +9,7 @@ import type { MacosPackageInfo, MacosPackageFiles, MacosUnifiedLogResult, -} from "../types/macos-diag"; +} from "./types"; export type EnvironmentPhase = "idle" | "scanning" | "ready" | "error"; From 3e603a5fc562b2c3df4ebccb83d105da58f2463b Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 5 Apr 2026 18:14:52 -0400 Subject: [PATCH 61/66] chore: add .superpowers/ to gitignore, remove tracked artifacts Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/settings.json | 7 ------- .gitignore | 3 +++ .../brainstorm/16702-1775419897/content/waiting.html | 3 --- .../brainstorm/16702-1775419897/state/server-stopped | 1 - .superpowers/brainstorm/16702-1775419897/state/server.log | 3 --- .superpowers/brainstorm/16702-1775419897/state/server.pid | 1 - 6 files changed, 3 insertions(+), 15 deletions(-) delete mode 100644 .claude/settings.json delete mode 100644 .superpowers/brainstorm/16702-1775419897/content/waiting.html delete mode 100644 .superpowers/brainstorm/16702-1775419897/state/server-stopped delete mode 100644 .superpowers/brainstorm/16702-1775419897/state/server.log delete mode 100644 .superpowers/brainstorm/16702-1775419897/state/server.pid diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 598d3e923..000000000 --- a/.claude/settings.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "enabledPlugins": { - "github@claude-plugins-official": true, - "typescript-lsp@claude-plugins-official": true, - "microsoft-docs@claude-plugins-official": true - } -} diff --git a/.gitignore b/.gitignore index dc5e1edc7..dfd9430b3 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ src-tauri/target-test .DS_Store Thumbs.db +# Brainstorming artifacts +.superpowers/ + # Env .env .env.local diff --git a/.superpowers/brainstorm/16702-1775419897/content/waiting.html b/.superpowers/brainstorm/16702-1775419897/content/waiting.html deleted file mode 100644 index f92c257ac..000000000 --- a/.superpowers/brainstorm/16702-1775419897/content/waiting.html +++ /dev/null @@ -1,3 +0,0 @@ -
-

Continuing in terminal...

-
\ No newline at end of file diff --git a/.superpowers/brainstorm/16702-1775419897/state/server-stopped b/.superpowers/brainstorm/16702-1775419897/state/server-stopped deleted file mode 100644 index 812da17cf..000000000 --- a/.superpowers/brainstorm/16702-1775419897/state/server-stopped +++ /dev/null @@ -1 +0,0 @@ -{"reason":"idle timeout","timestamp":1775422957440} diff --git a/.superpowers/brainstorm/16702-1775419897/state/server.log b/.superpowers/brainstorm/16702-1775419897/state/server.log deleted file mode 100644 index f32784e47..000000000 --- a/.superpowers/brainstorm/16702-1775419897/state/server.log +++ /dev/null @@ -1,3 +0,0 @@ -{"type":"server-started","port":65283,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:65283","screen_dir":"/Users/Adam.Gell/repo/cmtraceopen/.superpowers/brainstorm/16702-1775419897/content","state_dir":"/Users/Adam.Gell/repo/cmtraceopen/.superpowers/brainstorm/16702-1775419897/state"} -{"type":"screen-added","file":"/Users/Adam.Gell/repo/cmtraceopen/.superpowers/brainstorm/16702-1775419897/content/waiting.html"} -{"type":"server-stopped","reason":"idle timeout"} diff --git a/.superpowers/brainstorm/16702-1775419897/state/server.pid b/.superpowers/brainstorm/16702-1775419897/state/server.pid deleted file mode 100644 index 9236551df..000000000 --- a/.superpowers/brainstorm/16702-1775419897/state/server.pid +++ /dev/null @@ -1 +0,0 @@ -16711 From 10d74afc2f0c1b0b8b19048737129d0615d170b2 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 5 Apr 2026 18:52:44 -0400 Subject: [PATCH 62/66] refactor: extract SourceSummaryCard to shared component, consolidate getBaseName - Create src/components/common/sidebar-primitives.tsx with SourceSummaryCard, SourceStatusNotice, SectionHeader, EmptyState, and SidebarActionButton - Remove inline copies of all five components from FileSidebar, IntuneSidebar, DsregcmdSidebar, and SysmonSidebar; import from shared module instead - Widen canonical getBaseName signature to string | null | undefined - Remove exported getBaseName from log-store; import from file-paths instead - Update all callers in log-source, dsregcmd-source, EvidenceBundleDialog, StatusBar, IntuneSidebar, SysmonSidebar, and FileSidebar to use file-paths - Also removes local getDirectoryName copy from EvidenceBundleDialog Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/common/sidebar-primitives.tsx | 210 ++++++++++++++++++ .../dialogs/EvidenceBundleDialog.tsx | 22 +- src/components/layout/FileSidebar.tsx | 187 +--------------- src/components/layout/StatusBar.tsx | 2 +- src/lib/dsregcmd-source.ts | 9 +- src/lib/file-paths.ts | 2 +- src/lib/log-source.ts | 8 +- src/stores/log-store.ts | 9 +- src/workspaces/dsregcmd/DsregcmdSidebar.tsx | 194 +--------------- src/workspaces/intune/IntuneSidebar.tsx | 163 +------------- src/workspaces/sysmon/SysmonSidebar.tsx | 75 +------ 11 files changed, 244 insertions(+), 637 deletions(-) create mode 100644 src/components/common/sidebar-primitives.tsx diff --git a/src/components/common/sidebar-primitives.tsx b/src/components/common/sidebar-primitives.tsx new file mode 100644 index 000000000..803f5548e --- /dev/null +++ b/src/components/common/sidebar-primitives.tsx @@ -0,0 +1,210 @@ +/** + * Shared primitive components used across all workspace sidebars + * (FileSidebar, IntuneSidebar, DsregcmdSidebar, SysmonSidebar). + * + * These were previously duplicated inline in each sidebar file. Keep all + * sidebar-specific business logic in each workspace's own sidebar component. + */ + +import type { ReactNode } from "react"; +import { + Badge, + Button, + Caption1, + Subtitle2, + tokens, +} from "@fluentui/react-components"; + +// --------------------------------------------------------------------------- +// SourceSummaryCard +// --------------------------------------------------------------------------- + +export function SourceSummaryCard({ + badge, + title, + subtitle, + body, +}: { + badge: string; + title: string; + subtitle: string; + body: ReactNode; +}) { + return ( +
+ + {badge} + + + {title} + + + {subtitle} + +
{body}
+
+ ); +} + +// --------------------------------------------------------------------------- +// SourceStatusNotice +// --------------------------------------------------------------------------- + +export function SourceStatusNotice({ + kind, + message, + detail, +}: { + kind: string; + message: string; + detail?: string; +}) { + const colors = + kind === "missing" || kind === "error" + ? { border: tokens.colorPaletteRedBorder2, background: tokens.colorPaletteRedBackground1, text: tokens.colorPaletteRedForeground2 } + : kind === "empty" || kind === "awaiting-file-selection" + ? { border: tokens.colorPaletteYellowBorder2, background: tokens.colorPaletteYellowBackground1, text: tokens.colorPaletteMarigoldForeground2 } + : { border: tokens.colorPaletteBlueBorderActive, background: tokens.colorPaletteBlueBackground2, text: tokens.colorPaletteBlueForeground2 }; + + return ( +
+
{message}
+ {detail &&
{detail}
} +
+ ); +} + +// --------------------------------------------------------------------------- +// SectionHeader +// --------------------------------------------------------------------------- + +export function SectionHeader({ title, caption }: { title: string; caption?: string }) { + return ( +
+ + {title} + + {caption && ( + + {caption} + + )} +
+ ); +} + +// --------------------------------------------------------------------------- +// EmptyState +// --------------------------------------------------------------------------- + +export function EmptyState({ title, body }: { title: string; body: string }) { + return ( +
+ {title} +
{body}
+
+ ); +} + +// --------------------------------------------------------------------------- +// SidebarActionButton +// --------------------------------------------------------------------------- + +export function SidebarActionButton({ + label, + disabled, + onClick, +}: { + label: string; + disabled: boolean; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/src/components/dialogs/EvidenceBundleDialog.tsx b/src/components/dialogs/EvidenceBundleDialog.tsx index 2005b35e6..c116dc9b2 100644 --- a/src/components/dialogs/EvidenceBundleDialog.tsx +++ b/src/components/dialogs/EvidenceBundleDialog.tsx @@ -6,6 +6,7 @@ import { useIntuneStore } from "../../workspaces/intune/intune-store"; import { useLogStore } from "../../stores/log-store"; import { isIntuneWorkspace, useUiStore } from "../../stores/ui-store"; import { formatDisplayDateTime } from "../../lib/date-time-format"; +import { getBaseName, getDirectoryName } from "../../lib/file-paths"; import { useAppActions } from "../layout/Toolbar"; import type { EvidenceArtifactRecord, @@ -57,27 +58,6 @@ interface ArtifactNavigationState { const TEXT_LIKE_EXTENSIONS = new Set([".log", ".lo_", ".txt"]); -function getBaseName(path: string | null): string { - if (!path) { - return ""; - } - - return path.split(/[\\/]/).pop() ?? path; -} - -function getDirectoryName(path: string | null): string | null { - if (!path) { - return null; - } - - const normalized = path.replace(/\\/g, "/"); - const lastSeparator = normalized.lastIndexOf("/"); - if (lastSeparator <= 0) { - return null; - } - - return path.slice(0, lastSeparator); -} function formatUtcDateTime(value: string | null): string { if (!value) { diff --git a/src/components/layout/FileSidebar.tsx b/src/components/layout/FileSidebar.tsx index b40b32d1d..04cc8ae87 100644 --- a/src/components/layout/FileSidebar.tsx +++ b/src/components/layout/FileSidebar.tsx @@ -1,12 +1,11 @@ -import { useCallback, useEffect, useMemo, useState, Suspense, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useState, Suspense } from "react"; import { Badge, Button, - Caption1, - Subtitle2, tokens, } from "@fluentui/react-components"; import { formatDisplayDateTime } from "../../lib/date-time-format"; +import { getBaseName } from "../../lib/file-paths"; import { getLogListMetrics, LOG_UI_FONT_FAMILY } from "../../lib/log-accessibility"; import { loadLogSource, loadSelectedLogFile } from "../../lib/log-source"; import { useFilterStore } from "../../stores/filter-store"; @@ -14,7 +13,6 @@ import { getWorkspace } from "../../workspaces/registry"; import { getActiveSourceLabel, getActiveSourcePath, - getBaseName, getCachedTabSnapshot, getSourceFailureReason, useLogStore, @@ -22,6 +20,13 @@ import { import type { FolderEntry, LogSource } from "../../types/log"; import { useUiStore, type WorkspaceId } from "../../stores/ui-store"; import { useAppActions } from "./Toolbar"; +import { + EmptyState, + SectionHeader, + SidebarActionButton, + SourceStatusNotice, + SourceSummaryCard, +} from "../common/sidebar-primitives"; export const FILE_SIDEBAR_RECOMMENDED_WIDTH = 280; @@ -72,116 +77,6 @@ function formatModified(unixMs: number | null): string { return formatDisplayDateTime(unixMs) ?? "Modified time unavailable"; } -function SectionHeader({ title, caption }: { title: string; caption?: string }) { - return ( -
- - {title} - - {caption && ( - - {caption} - - )} -
- ); -} - -function EmptyState({ title, body }: { title: string; body: string }) { - return ( -
- {title} -
{body}
-
- ); -} - -function SidebarActionButton({ - label, - disabled, - onClick, -}: { - label: string; - disabled: boolean; - onClick: () => void; -}) { - return ( - - ); -} - -function SourceStatusNotice({ - kind, - message, - detail, -}: { - kind: string; - message: string; - detail?: string; -}) { - const colors = - kind === "missing" || kind === "error" - ? { border: tokens.colorPaletteRedBorder2, background: tokens.colorPaletteRedBackground1, text: tokens.colorPaletteRedForeground2 } - : kind === "empty" || kind === "awaiting-file-selection" - ? { border: tokens.colorPaletteYellowBorder2, background: tokens.colorPaletteYellowBackground1, text: tokens.colorPaletteMarigoldForeground2 } - : { border: tokens.colorPaletteBlueBorderActive, background: tokens.colorPaletteBlueBackground2, text: tokens.colorPaletteBlueForeground2 }; - - return ( -
-
{message}
- {detail &&
{detail}
} -
- ); -} - function FileRow({ entry, isSelected, @@ -255,70 +150,6 @@ function FileRow({ ); } -function SourceSummaryCard({ - badge, - title, - subtitle, - body, -}: { - badge: string; - title: string; - subtitle: string; - body: ReactNode; -}) { - return ( -
- - {badge} - - - {title} - - - {subtitle} - -
{body}
-
- ); -} - export function LogSidebar() { const activeSource = useLogStore((s) => s.activeSource); const sourceEntries = useLogStore((s) => s.sourceEntries); diff --git a/src/components/layout/StatusBar.tsx b/src/components/layout/StatusBar.tsx index 5431f7eb6..f3d6da7a0 100644 --- a/src/components/layout/StatusBar.tsx +++ b/src/components/layout/StatusBar.tsx @@ -1,9 +1,9 @@ import { useMemo } from "react"; import { Badge, Spinner, tokens } from "@fluentui/react-components"; import { LOG_UI_FONT_FAMILY } from "../../lib/log-accessibility"; +import { getBaseName } from "../../lib/file-paths"; import { getActiveSourceLabel, - getBaseName, getParserSelectionDisplay, getSourceFailureReason, getStreamStateSnapshot, diff --git a/src/lib/dsregcmd-source.ts b/src/lib/dsregcmd-source.ts index f75270cde..d252ca4f6 100644 --- a/src/lib/dsregcmd-source.ts +++ b/src/lib/dsregcmd-source.ts @@ -5,16 +5,9 @@ import type { DsregcmdSourceDescriptor, } from "../workspaces/dsregcmd/types"; import { analyzeDsregcmd, captureDsregcmd, loadDsregcmdSource } from "./commands"; +import { getBaseName } from "./file-paths"; import { useDsregcmdStore } from "../workspaces/dsregcmd/dsregcmd-store"; -function getBaseName(path: string | null): string { - if (!path) { - return ""; - } - - return path.split(/[\\/]/).pop() ?? path; -} - function buildSourceContext( source: DsregcmdSourceDescriptor, rawInput: string, diff --git a/src/lib/file-paths.ts b/src/lib/file-paths.ts index c3d6dc679..0bf2523c5 100644 --- a/src/lib/file-paths.ts +++ b/src/lib/file-paths.ts @@ -26,7 +26,7 @@ * getBaseName("/var/log/install.log") * // => "install.log" */ -export function getBaseName(path: string | null): string { +export function getBaseName(path: string | null | undefined): string { if (!path) { return ""; } diff --git a/src/lib/log-source.ts b/src/lib/log-source.ts index a9081ef9e..46d095e51 100644 --- a/src/lib/log-source.ts +++ b/src/lib/log-source.ts @@ -9,6 +9,7 @@ import { } from "./commands"; import { useLogStore, setCachedTabSnapshot, getCachedTabSnapshot } from "../stores/log-store"; import { getColumnsForParser, getColumnsForAggregate } from "./column-config"; +import { getBaseName } from "./file-paths"; import { useUiStore, type TabSourceContext } from "../stores/ui-store"; import { useFilterStore } from "../stores/filter-store"; import type { @@ -60,13 +61,6 @@ export interface KnownSourceCatalogActionIds { menuId?: string | null; } -function getBaseName(path: string | null): string { - if (!path) { - return ""; - } - - return path.split(/[\\/]/).pop() ?? path; -} function classifySourceError(error: unknown): { kind: "missing" | "error"; message: string } { const message = error instanceof Error ? error.message : String(error); diff --git a/src/stores/log-store.ts b/src/stores/log-store.ts index 8fec078db..29bb802c5 100644 --- a/src/stores/log-store.ts +++ b/src/stores/log-store.ts @@ -16,6 +16,7 @@ import { getColumnDef, } from "../lib/column-config"; import { formatLogEntryTimestamp } from "../lib/date-time-format"; +import { getBaseName } from "../lib/file-paths"; import { buildGuidNameMap, mergeGuidNameMap } from "../lib/guid-name-map"; import { type MergedTabState, @@ -189,14 +190,6 @@ function computeFindMatches( return matchIds; } -export function getBaseName(path: string | null): string { - if (!path) { - return ""; - } - - return path.split(/[\\/]/).pop() ?? path; -} - export function hasSourceContext( activeSource: LogSource | null, openFilePath: string | null diff --git a/src/workspaces/dsregcmd/DsregcmdSidebar.tsx b/src/workspaces/dsregcmd/DsregcmdSidebar.tsx index 190149b22..a165a248a 100644 --- a/src/workspaces/dsregcmd/DsregcmdSidebar.tsx +++ b/src/workspaces/dsregcmd/DsregcmdSidebar.tsx @@ -1,191 +1,13 @@ -import type { ReactNode } from "react"; -import { - Badge, - Button, - Caption1, - Subtitle2, - tokens, -} from "@fluentui/react-components"; +import { tokens } from "@fluentui/react-components"; import { useDsregcmdStore } from "./dsregcmd-store"; import { useAppActions } from "../../components/layout/Toolbar"; - -// --------------------------------------------------------------------------- -// Inline helpers — mirrors components in FileSidebar.tsx -// --------------------------------------------------------------------------- - -function SourceSummaryCard({ - badge, - title, - subtitle, - body, -}: { - badge: string; - title: string; - subtitle: string; - body: ReactNode; -}) { - return ( -
- - {badge} - - - {title} - - - {subtitle} - -
{body}
-
- ); -} - -function SourceStatusNotice({ - kind, - message, - detail, -}: { - kind: string; - message: string; - detail?: string; -}) { - const colors = - kind === "missing" || kind === "error" - ? { border: tokens.colorPaletteRedBorder2, background: tokens.colorPaletteRedBackground1, text: tokens.colorPaletteRedForeground2 } - : kind === "empty" || kind === "awaiting-file-selection" - ? { border: tokens.colorPaletteYellowBorder2, background: tokens.colorPaletteYellowBackground1, text: tokens.colorPaletteMarigoldForeground2 } - : { border: tokens.colorPaletteBlueBorderActive, background: tokens.colorPaletteBlueBackground2, text: tokens.colorPaletteBlueForeground2 }; - - return ( -
-
{message}
- {detail &&
{detail}
} -
- ); -} - -function EmptyState({ title, body }: { title: string; body: string }) { - return ( -
- {title} -
{body}
-
- ); -} - -function SectionHeader({ title, caption }: { title: string; caption?: string }) { - return ( -
- - {title} - - {caption && ( - - {caption} - - )} -
- ); -} - -function SidebarActionButton({ - label, - disabled, - onClick, -}: { - label: string; - disabled: boolean; - onClick: () => void; -}) { - return ( - - ); -} +import { + EmptyState, + SectionHeader, + SidebarActionButton, + SourceStatusNotice, + SourceSummaryCard, +} from "../../components/common/sidebar-primitives"; // --------------------------------------------------------------------------- // DsregcmdSidebar diff --git a/src/workspaces/intune/IntuneSidebar.tsx b/src/workspaces/intune/IntuneSidebar.tsx index 38a4ca608..843c1bc39 100644 --- a/src/workspaces/intune/IntuneSidebar.tsx +++ b/src/workspaces/intune/IntuneSidebar.tsx @@ -1,160 +1,13 @@ -import type { ReactNode } from "react"; -import { Badge, Caption1, Subtitle2, tokens } from "@fluentui/react-components"; -import { getBaseName } from "../../stores/log-store"; +import { Badge, tokens } from "@fluentui/react-components"; +import { getBaseName } from "../../lib/file-paths"; import { useUiStore } from "../../stores/ui-store"; import { useIntuneStore } from "./intune-store"; - -// --------------------------------------------------------------------------- -// Helpers (inlined from FileSidebar.tsx to keep this component self-contained) -// --------------------------------------------------------------------------- - -function SourceSummaryCard({ - badge, - title, - subtitle, - body, -}: { - badge: string; - title: string; - subtitle: string; - body: ReactNode; -}) { - return ( -
- - {badge} - - - {title} - - - {subtitle} - -
{body}
-
- ); -} - -function SourceStatusNotice({ - kind, - message, - detail, -}: { - kind: string; - message: string; - detail?: string; -}) { - const colors = - kind === "missing" || kind === "error" - ? { border: tokens.colorPaletteRedBorder2, background: tokens.colorPaletteRedBackground1, text: tokens.colorPaletteRedForeground2 } - : kind === "empty" || kind === "awaiting-file-selection" - ? { border: tokens.colorPaletteYellowBorder2, background: tokens.colorPaletteYellowBackground1, text: tokens.colorPaletteMarigoldForeground2 } - : { border: tokens.colorPaletteBlueBorderActive, background: tokens.colorPaletteBlueBackground2, text: tokens.colorPaletteBlueForeground2 }; - - return ( -
-
{message}
- {detail &&
{detail}
} -
- ); -} - -function SectionHeader({ title, caption }: { title: string; caption?: string }) { - return ( -
- - {title} - - {caption && ( - - {caption} - - )} -
- ); -} - -function EmptyState({ title, body }: { title: string; body: string }) { - return ( -
- {title} -
{body}
-
- ); -} +import { + EmptyState, + SectionHeader, + SourceStatusNotice, + SourceSummaryCard, +} from "../../components/common/sidebar-primitives"; // --------------------------------------------------------------------------- // IntuneSidebar diff --git a/src/workspaces/sysmon/SysmonSidebar.tsx b/src/workspaces/sysmon/SysmonSidebar.tsx index 0f21d3948..a8ed7bc2b 100644 --- a/src/workspaces/sysmon/SysmonSidebar.tsx +++ b/src/workspaces/sysmon/SysmonSidebar.tsx @@ -1,76 +1,7 @@ -import type { ReactNode } from "react"; -import { Badge, Caption1, Subtitle2, tokens } from "@fluentui/react-components"; +import { tokens } from "@fluentui/react-components"; +import { getBaseName } from "../../lib/file-paths"; import { useSysmonStore } from "./sysmon-store"; - -// Minimal inline utility — mirrors the one imported from log-store in FileSidebar.tsx -function getBaseName(path: string | null | undefined): string { - if (!path) return ""; - return path.split(/[\\/]/).pop() ?? ""; -} - -function SourceSummaryCard({ - badge, - title, - subtitle, - body, -}: { - badge: string; - title: string; - subtitle: string; - body: ReactNode; -}) { - return ( -
- - {badge} - - - {title} - - - {subtitle} - -
{body}
-
- ); -} +import { SourceSummaryCard } from "../../components/common/sidebar-primitives"; export function SysmonSidebar() { const summary = useSysmonStore((s) => s.summary); From 26b10ea0d31b07c7d0df3fe37e952865258a0aba Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 5 Apr 2026 18:54:43 -0400 Subject: [PATCH 63/66] refactor(ui-store): replace getUiChromeStatus if/else chain with registry lookup Add statusLabel to WorkspaceDefinition; set overrides on log, intune, and new-intune workspaces. getUiChromeStatus now resolves labels via getWorkspace() and branches only on capabilities.detailsPane, eliminating the 8-way if/else. Co-Authored-By: Claude Sonnet 4.6 --- src/stores/ui-store.ts | 65 +++++------------------------- src/workspaces/intune/index.ts | 1 + src/workspaces/log/index.ts | 1 + src/workspaces/new-intune/index.ts | 1 + src/workspaces/types.ts | 2 + 5 files changed, 15 insertions(+), 55 deletions(-) diff --git a/src/stores/ui-store.ts b/src/stores/ui-store.ts index efa43fd2a..fa95df10d 100644 --- a/src/stores/ui-store.ts +++ b/src/stores/ui-store.ts @@ -13,7 +13,7 @@ import { useFilterStore } from "./filter-store"; import type { ColumnId } from "../lib/column-config"; import type { CollectionResult } from "../lib/commands"; import type { WorkspaceId } from "../types/log"; -import { getAvailableWorkspaces as getRegistryWorkspaces } from "../workspaces/registry"; +import { getAvailableWorkspaces as getRegistryWorkspaces, getWorkspace } from "../workspaces/registry"; export type { WorkspaceId } from "../types/log"; @@ -85,66 +85,21 @@ export function getUiChromeStatus( showDetails: boolean, showInfoPane: boolean ): UiChromeStatus { - if (activeView === "new-intune") { - return { - viewLabel: "New Intune Workspace", - detailsLabel: "Details hidden in New Intune Workspace", - infoLabel: "Info hidden in New Intune Workspace", - }; - } - - if (activeView === "intune") { - return { - viewLabel: "Intune workspace", - detailsLabel: "Details hidden in Intune workspace", - infoLabel: "Info hidden in Intune workspace", - }; - } - - if (activeView === "sysmon") { - return { - viewLabel: "Sysmon workspace", - detailsLabel: "Details hidden in Sysmon workspace", - infoLabel: "Info hidden in Sysmon workspace", - }; - } - - if (activeView === "dsregcmd") { - return { - viewLabel: "dsregcmd workspace", - detailsLabel: "Details hidden in dsregcmd workspace", - infoLabel: "Info hidden in dsregcmd workspace", - }; - } - - if (activeView === "macos-diag") { - return { - viewLabel: "macOS Diagnostics workspace", - detailsLabel: "Details hidden in macOS Diagnostics workspace", - infoLabel: "Info hidden in macOS Diagnostics workspace", - }; - } - - if (activeView === "deployment") { - return { - viewLabel: "Software Deployment workspace", - detailsLabel: "Details hidden in Software Deployment workspace", - infoLabel: "Info hidden in Software Deployment workspace", - }; - } + const workspace = getWorkspace(activeView as WorkspaceId); + const viewLabel = workspace.statusLabel ?? `${workspace.label} workspace`; - if (activeView === "event-log") { + if (workspace.capabilities?.detailsPane) { return { - viewLabel: "Event Log Viewer workspace", - detailsLabel: "Details hidden in Event Log Viewer workspace", - infoLabel: "Info hidden in Event Log Viewer workspace", + viewLabel, + detailsLabel: showDetails ? "Details on" : "Details off", + infoLabel: showInfoPane ? "Info on" : "Info off", }; } return { - viewLabel: "Log view", - detailsLabel: showDetails ? "Details on" : "Details off", - infoLabel: showInfoPane ? "Info on" : "Info off", + viewLabel, + detailsLabel: `Details hidden in ${viewLabel}`, + infoLabel: `Info hidden in ${viewLabel}`, }; } diff --git a/src/workspaces/intune/index.ts b/src/workspaces/intune/index.ts index 0a5cc18cb..dafe1b066 100644 --- a/src/workspaces/intune/index.ts +++ b/src/workspaces/intune/index.ts @@ -5,6 +5,7 @@ import type { WorkspaceDefinition } from "../types"; export const intuneWorkspace: WorkspaceDefinition = { id: "intune", label: "Intune Diagnostics", + statusLabel: "Intune workspace", platforms: "all", component: lazy(() => import("./IntuneDashboard").then((m) => ({ diff --git a/src/workspaces/log/index.ts b/src/workspaces/log/index.ts index 0bd8606a5..ac6fcfcc4 100644 --- a/src/workspaces/log/index.ts +++ b/src/workspaces/log/index.ts @@ -5,6 +5,7 @@ import type { WorkspaceDefinition } from "../types"; export const logWorkspace: WorkspaceDefinition = { id: "log", label: "Log Explorer", + statusLabel: "Log view", platforms: "all", component: lazy(() => import("../../components/log-view/LogListView").then((m) => ({ diff --git a/src/workspaces/new-intune/index.ts b/src/workspaces/new-intune/index.ts index ce458fa86..117fe1722 100644 --- a/src/workspaces/new-intune/index.ts +++ b/src/workspaces/new-intune/index.ts @@ -5,6 +5,7 @@ import type { WorkspaceDefinition } from "../types"; export const newIntuneWorkspace: WorkspaceDefinition = { id: "new-intune", label: "New Intune Workspace", + statusLabel: "New Intune Workspace", platforms: "all", component: lazy(() => import("../intune/NewIntuneWorkspace").then((m) => ({ diff --git a/src/workspaces/types.ts b/src/workspaces/types.ts index 3e066a3ea..b172d74d0 100644 --- a/src/workspaces/types.ts +++ b/src/workspaces/types.ts @@ -28,6 +28,8 @@ export interface WorkspaceDefinition { id: WorkspaceId; /** Human-readable label shown in toolbar dropdown. */ label: string; + /** Override for the status bar view label. Defaults to `${label} workspace`. */ + statusLabel?: string; /** Platforms this workspace is available on. "all" means no restriction. */ platforms: PlatformKind[] | "all"; /** Lazy-loaded main workspace component. */ From 0cb5b684a4ff83eb21ba92a540c853ebc81b3be4 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 5 Apr 2026 19:01:38 -0400 Subject: [PATCH 64/66] refactor(toolbar): wire onOpenSource handlers and capability-based commandState Move workspace-specific analysis logic (intune, dsregcmd, sysmon, deployment) from Toolbar.tsx into each workspace's onOpenSource in its definition. Replace the openSourceForWorkspace if/else chain with a registry lookup. Add knownSources and tailing to WorkspaceCapabilities and use them in commandState instead of hardcoded workspace ID checks. Co-Authored-By: Claude Sonnet 4.6 --- src/components/layout/Toolbar.tsx | 248 +++++------------------------ src/workspaces/deployment/index.ts | 12 ++ src/workspaces/dsregcmd/index.ts | 17 ++ src/workspaces/intune/index.ts | 96 ++++++++++- src/workspaces/log/index.ts | 2 + src/workspaces/new-intune/index.ts | 2 + src/workspaces/sysmon/index.ts | 38 ++++- src/workspaces/types.ts | 4 + 8 files changed, 212 insertions(+), 207 deletions(-) diff --git a/src/components/layout/Toolbar.tsx b/src/components/layout/Toolbar.tsx index c816d9f6d..b7234a8c0 100644 --- a/src/components/layout/Toolbar.tsx +++ b/src/components/layout/Toolbar.tsx @@ -1,5 +1,4 @@ import { - startTransition, useCallback, useEffect, useMemo, @@ -20,8 +19,6 @@ import { import { open } from "@tauri-apps/plugin-dialog"; import { platform } from "@tauri-apps/plugin-os"; import { - analyzeIntuneLogs, - analyzeSysmonLogs, getAvailableWorkspaces as getAvailableBackendWorkspaces, inspectPathKind, } from "../../lib/commands"; @@ -35,11 +32,10 @@ import { useFilterStore } from "../../stores/filter-store"; import { useIntuneStore } from "../../workspaces/intune/intune-store"; import { useDsregcmdStore } from "../../workspaces/dsregcmd/dsregcmd-store"; import { useSysmonStore } from "../../workspaces/sysmon/sysmon-store"; -import { isIntuneWorkspace, getAvailableWorkspaces, type IntuneWorkspaceId, type WorkspaceId, type PlatformId, useUiStore } from "../../stores/ui-store"; +import { isIntuneWorkspace, getAvailableWorkspaces, type WorkspaceId, type PlatformId, useUiStore } from "../../stores/ui-store"; import { getWorkspace } from "../../workspaces/registry"; import { ThemePicker } from "./ThemePicker"; import { - getLogSourcePath, getKnownSourceMetadataById, loadLogSource, loadPathAsLogSource, @@ -74,10 +70,8 @@ function resolveRefreshSource( return null; } -const LIVE_INTUNE_SOURCE_ID = "windows-intune-ime-logs"; const LIVE_SYSMON_SOURCE_ID = "windows-sysmon-live-events"; - async function inferPathKind(path: string): Promise<"file" | "folder" | "unknown"> { try { return await inspectPathKind(path); @@ -86,26 +80,6 @@ async function inferPathKind(path: string): Promise<"file" | "folder" | "unknown } } -function createIntuneAnalysisRequestId(): string { - return `intune-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; -} - -function shouldSyncSourceBeforeIntuneAnalysis(source: LogSource): boolean { - if (source.kind === "file") { - return true; - } - - return source.kind === "known" && source.pathKind === "file"; -} - -function shouldIncludeLiveEventLogs(source: LogSource): boolean { - return source.kind === "known" && source.sourceId === LIVE_INTUNE_SOURCE_ID; -} - -function shouldIncludeSysmonLiveEventLogs(source: LogSource): boolean { - return source.kind === "known" && source.sourceId === LIVE_SYSMON_SOURCE_ID; -} - export interface OpenKnownSourceCatalogAction extends KnownSourceCatalogActionIds { trigger: string; @@ -171,17 +145,11 @@ export function useAppActions(): AppActionHandlers { const bundleMetadata = useLogStore((s) => s.bundleMetadata); const intuneIsAnalyzing = useIntuneStore((s) => s.isAnalyzing); const intuneEvidenceBundle = useIntuneStore((s) => s.evidenceBundle); - const beginIntuneAnalysis = useIntuneStore((s) => s.beginAnalysis); - const failIntuneAnalysis = useIntuneStore((s) => s.failAnalysis); - const setIntuneResults = useIntuneStore((s) => s.setResults); const dsregcmdIsAnalyzing = useDsregcmdStore((s) => s.isAnalyzing); const dsregcmdSource = useDsregcmdStore((s) => s.sourceContext.source); const dsregcmdBundlePath = useDsregcmdStore((s) => s.sourceContext.bundlePath); const sysmonIsAnalyzing = useSysmonStore((s) => s.isAnalyzing); const sysmonSourcePath = useSysmonStore((s) => s.sourcePath); - const beginSysmonAnalysis = useSysmonStore((s) => s.beginAnalysis); - const setSysmonResults = useSysmonStore((s) => s.setResults); - const failSysmonAnalysis = useSysmonStore((s) => s.failAnalysis); const activeWorkspace = useUiStore((s) => s.activeWorkspace); const activeView = useUiStore((s) => s.activeView); @@ -217,16 +185,15 @@ export function useAppActions(): AppActionHandlers { ); const isSourceCommandBusy = isLoading || intuneIsAnalyzing || dsregcmdIsAnalyzing || sysmonIsAnalyzing; - const commandState = useMemo( - () => ({ + const commandState = useMemo(() => { + const ws = getWorkspace(activeWorkspace); + const wsCaps = ws.capabilities ?? {}; + return { canOpenSources: !isSourceCommandBusy, - canOpenKnownSources: - !isSourceCommandBusy && activeWorkspace !== "dsregcmd", - canPauseResume: - activeWorkspace === "log" && !isLoading && refreshSource !== null, - canFind: activeWorkspace === "log" && entriesCount > 0, - canFilter: - activeWorkspace === "log" && entriesCount > 0 && !isFiltering, + canOpenKnownSources: !isSourceCommandBusy && (wsCaps.knownSources ?? true), + canPauseResume: (wsCaps.tailing ?? false) && !isLoading && refreshSource !== null, + canFind: (wsCaps.findBar ?? false) && entriesCount > 0, + canFilter: (wsCaps.findBar ?? false) && entriesCount > 0 && !isFiltering, canRefresh: !isSourceCommandBusy && (activeWorkspace === "dsregcmd" @@ -234,8 +201,8 @@ export function useAppActions(): AppActionHandlers { : activeWorkspace === "sysmon" ? sysmonSourcePath !== null : refreshSource !== null), - canToggleDetailsPane: activeView === "log", - canToggleInfoPane: activeView === "log", + canToggleDetailsPane: wsCaps.detailsPane ?? false, + canToggleInfoPane: wsCaps.infoPane ?? false, canShowEvidenceBundle: activeView === "log" ? bundleMetadata !== null @@ -256,28 +223,27 @@ export function useAppActions(): AppActionHandlers { isFiltering, filterError, activeView, - }), - [ - activeWorkspace, - activeFilterCount, - activeView, - bundleMetadata, - dsregcmdBundlePath, - dsregcmdSource, - entriesCount, - filterError, - intuneEvidenceBundle, - intuneIsAnalyzing, - isFiltering, - isLoading, - isPaused, - isSourceCommandBusy, - refreshSource, - showDetails, - showInfoPane, - sysmonSourcePath, - ] - ); + }; + }, [ + activeWorkspace, + activeFilterCount, + activeView, + bundleMetadata, + dsregcmdBundlePath, + dsregcmdSource, + entriesCount, + filterError, + intuneEvidenceBundle, + intuneIsAnalyzing, + isFiltering, + isLoading, + isPaused, + isSourceCommandBusy, + refreshSource, + showDetails, + showInfoPane, + sysmonSourcePath, + ]); const loadLogWorkspaceSource = useCallback( async (source: LogSource, trigger: string) => { @@ -301,142 +267,16 @@ export function useAppActions(): AppActionHandlers { [] ); - const analyzeIntuneWorkspaceSource = useCallback( - async (source: LogSource, trigger: string, workspace: IntuneWorkspaceId) => { - useUiStore.getState().ensureWorkspaceVisible(workspace, trigger); - const requestId = createIntuneAnalysisRequestId(); - beginIntuneAnalysis( - getLogSourcePath(source), - source.kind === "known" ? "known" : source.kind, - requestId - ); - - try { - if (shouldSyncSourceBeforeIntuneAnalysis(source)) { - await loadLogSource(source).catch((error) => { - console.warn("[app-actions] failed to sync source before Intune analysis", { - source, - trigger, - error, - }); - }); - } - - const result = await analyzeIntuneLogs(getLogSourcePath(source), requestId, { - includeLiveEventLogs: shouldIncludeLiveEventLogs(source), - graphApiEnabled: useUiStore.getState().graphApiEnabled, - }); - - startTransition(() => { - setIntuneResults( - result.events, - result.downloads, - result.summary, - result.diagnostics, - result.sourceFile, - result.sourceFiles, - { - diagnosticsConfidence: result.diagnosticsConfidence, - diagnosticsCoverage: result.diagnosticsCoverage, - repeatedFailures: result.repeatedFailures, - evidenceBundle: result.evidenceBundle ?? null, - eventLogAnalysis: result.eventLogAnalysis ?? null, - policyMetadata: result.policyMetadata ?? undefined, - guidRegistry: result.guidRegistry, - } - ); - }); - } catch (error) { - console.error("[app-actions] failed to analyze Intune source", { - source, - trigger, - error, - }); - failIntuneAnalysis(error); - } - }, - [beginIntuneAnalysis, failIntuneAnalysis, setIntuneResults] - ); - - const analyzeDsregcmdWorkspaceSource = useCallback( - async (source: LogSource, trigger: string) => { - useUiStore.getState().ensureWorkspaceVisible("dsregcmd", trigger); - - if (source.kind === "known") { - throw new Error("Known log presets are not supported in the dsregcmd workspace."); - } - - await analyzeDsregcmdSource(source); - }, - [] - ); - - const analyzeSysmonWorkspaceSource = useCallback( - async (source: LogSource, trigger: string) => { - useUiStore.getState().ensureWorkspaceVisible("sysmon", trigger); - const sourcePath = getLogSourcePath(source); - const requestId = `sysmon-${Date.now()}`; - beginSysmonAnalysis(sourcePath, requestId); - - try { - const result = await analyzeSysmonLogs(sourcePath, requestId, { - includeLiveEventLogs: shouldIncludeSysmonLiveEventLogs(source), - }); - startTransition(() => { - setSysmonResults(result); - }); - } catch (error) { - console.error("[app-actions] failed to analyze Sysmon source", { - source, - trigger, - error, - }); - failSysmonAnalysis(error instanceof Error ? error.message : String(error)); - } - }, - [beginSysmonAnalysis, setSysmonResults, failSysmonAnalysis] - ); - const openSourceForWorkspace = useCallback( async (source: LogSource, trigger: string, workspace: WorkspaceId) => { - if (isIntuneWorkspace(workspace)) { - await analyzeIntuneWorkspaceSource(source, trigger, workspace); - return; + const ws = getWorkspace(workspace); + if (ws.onOpenSource) { + await ws.onOpenSource(source, trigger); + } else { + await loadLogWorkspaceSource(source, trigger); } - - if (workspace === "dsregcmd") { - await analyzeDsregcmdWorkspaceSource(source, trigger); - return; - } - - if (workspace === "sysmon") { - await analyzeSysmonWorkspaceSource(source, trigger); - return; - } - - if (workspace === "deployment") { - // Extract folder path from source - const folderPath = - source.kind === "folder" - ? source.path - : source.kind === "known" - ? source.defaultPath - : null; - if (folderPath) { - const { useDeploymentStore } = await import("../../workspaces/deployment/deployment-store"); - await useDeploymentStore.getState().analyzeFolder(folderPath); - return; - } - } - - await loadLogWorkspaceSource(source, trigger); }, - [ - analyzeDsregcmdWorkspaceSource, - analyzeIntuneWorkspaceSource, - analyzeSysmonWorkspaceSource, - loadLogWorkspaceSource, - ] + [loadLogWorkspaceSource], ); const openPathForActiveWorkspace = useCallback( @@ -453,7 +293,7 @@ export function useAppActions(): AppActionHandlers { pathKind === "folder" ? { kind: "folder", path } : { kind: "file", path }; - await analyzeIntuneWorkspaceSource(source, "drag-drop.path-open", activeWorkspace); + await getWorkspace(activeWorkspace).onOpenSource!(source, "drag-drop.path-open"); return; } @@ -469,7 +309,7 @@ export function useAppActions(): AppActionHandlers { fallbackToFolder: true, }); }, - [activeWorkspace, analyzeIntuneWorkspaceSource] + [activeWorkspace], ); const openKnownSourceCatalogAction = useCallback( @@ -687,11 +527,11 @@ export function useAppActions(): AppActionHandlers { if (activeWorkspace === "sysmon") { if (sysmonSourcePath) { const isLiveSource = sysmonSourcePath === "live-event-log"; - await analyzeSysmonWorkspaceSource( + await getWorkspace("sysmon").onOpenSource!( isLiveSource ? { kind: "known", sourceId: LIVE_SYSMON_SOURCE_ID, defaultPath: sysmonSourcePath, pathKind: "folder" } : { kind: "file", path: sysmonSourcePath }, - "app-actions.refresh" + "app-actions.refresh", ); } return; @@ -702,7 +542,7 @@ export function useAppActions(): AppActionHandlers { } if (isIntuneWorkspace(activeWorkspace)) { - await analyzeIntuneWorkspaceSource(refreshSource, "app-actions.refresh", activeWorkspace); + await getWorkspace(activeWorkspace).onOpenSource!(refreshSource, "app-actions.refresh"); return; } @@ -714,8 +554,6 @@ export function useAppActions(): AppActionHandlers { }); }, [ activeWorkspace, - analyzeIntuneWorkspaceSource, - analyzeSysmonWorkspaceSource, commandState.canRefresh, refreshSource, selectedSourceFilePath, diff --git a/src/workspaces/deployment/index.ts b/src/workspaces/deployment/index.ts index 22b616d7f..25b92c742 100644 --- a/src/workspaces/deployment/index.ts +++ b/src/workspaces/deployment/index.ts @@ -30,4 +30,16 @@ export const deploymentWorkspace: WorkspaceDefinition = { folder: "Open Folder", placeholder: "Open...", }, + onOpenSource: async (source, _trigger) => { + const folderPath = + source.kind === "folder" + ? source.path + : source.kind === "known" + ? source.defaultPath + : null; + if (folderPath) { + const { useDeploymentStore } = await import("./deployment-store"); + await useDeploymentStore.getState().analyzeFolder(folderPath); + } + }, }; diff --git a/src/workspaces/dsregcmd/index.ts b/src/workspaces/dsregcmd/index.ts index 76b191ea8..4f93b3c76 100644 --- a/src/workspaces/dsregcmd/index.ts +++ b/src/workspaces/dsregcmd/index.ts @@ -16,6 +16,9 @@ export const dsregcmdWorkspace: WorkspaceDefinition = { default: m.DsregcmdSidebar, })) ), + capabilities: { + knownSources: false, + }, fileFilters: [ { name: "Text Files", extensions: ["txt"] }, { name: "Log Files", extensions: ["log"] }, @@ -26,4 +29,18 @@ export const dsregcmdWorkspace: WorkspaceDefinition = { folder: "Open Evidence Folder", placeholder: "Open dsregcmd Source...", }, + onOpenSource: async (source, trigger) => { + const [{ useUiStore }, { analyzeDsregcmdSource }] = await Promise.all([ + import("../../stores/ui-store"), + import("../../lib/dsregcmd-source"), + ]); + + useUiStore.getState().ensureWorkspaceVisible("dsregcmd", trigger); + + if (source.kind === "known") { + throw new Error("Known log presets are not supported in the dsregcmd workspace."); + } + + await analyzeDsregcmdSource(source); + }, }; diff --git a/src/workspaces/intune/index.ts b/src/workspaces/intune/index.ts index dafe1b066..0e34e9a04 100644 --- a/src/workspaces/intune/index.ts +++ b/src/workspaces/intune/index.ts @@ -1,6 +1,99 @@ // src/workspaces/intune/index.ts -import { lazy } from "react"; +import { startTransition, lazy } from "react"; import type { WorkspaceDefinition } from "../types"; +import type { IntuneWorkspaceId } from "../../stores/ui-store"; +import type { LogSource } from "../../types/log"; + +const LIVE_INTUNE_SOURCE_ID = "windows-intune-ime-logs"; + +function shouldSyncSourceBeforeIntuneAnalysis(source: LogSource): boolean { + if (source.kind === "file") { + return true; + } + return source.kind === "known" && source.pathKind === "file"; +} + +function shouldIncludeLiveEventLogs(source: LogSource): boolean { + return source.kind === "known" && source.sourceId === LIVE_INTUNE_SOURCE_ID; +} + +function createIntuneAnalysisRequestId(): string { + return `intune-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + +/** + * Shared onOpenSource handler for intune workspaces. + * Used by both the "intune" and "new-intune" workspace definitions. + */ +export function createIntuneOnOpenSource( + workspaceId: IntuneWorkspaceId, +): WorkspaceDefinition["onOpenSource"] { + return async (source, trigger) => { + const [ + { useUiStore }, + { getLogSourcePath, loadLogSource }, + { analyzeIntuneLogs }, + { useIntuneStore }, + ] = await Promise.all([ + import("../../stores/ui-store"), + import("../../lib/log-source"), + import("../../lib/commands"), + import("./intune-store"), + ]); + + useUiStore.getState().ensureWorkspaceVisible(workspaceId, trigger); + const requestId = createIntuneAnalysisRequestId(); + useIntuneStore.getState().beginAnalysis( + getLogSourcePath(source), + source.kind === "known" ? "known" : source.kind, + requestId, + ); + + try { + if (shouldSyncSourceBeforeIntuneAnalysis(source)) { + await loadLogSource(source).catch((error) => { + console.warn("[intune] failed to sync source before Intune analysis", { + source, + trigger, + error, + }); + }); + } + + const result = await analyzeIntuneLogs(getLogSourcePath(source), requestId, { + includeLiveEventLogs: shouldIncludeLiveEventLogs(source), + graphApiEnabled: useUiStore.getState().graphApiEnabled, + }); + + startTransition(() => { + useIntuneStore.getState().setResults( + result.events, + result.downloads, + result.summary, + result.diagnostics, + result.sourceFile, + result.sourceFiles, + { + diagnosticsConfidence: result.diagnosticsConfidence, + diagnosticsCoverage: result.diagnosticsCoverage, + repeatedFailures: result.repeatedFailures, + evidenceBundle: result.evidenceBundle ?? null, + eventLogAnalysis: result.eventLogAnalysis ?? null, + policyMetadata: result.policyMetadata ?? undefined, + guidRegistry: result.guidRegistry, + }, + ); + }); + } catch (error) { + console.error("[intune] failed to analyze Intune source", { + source, + trigger, + error, + }); + useIntuneStore.getState().failAnalysis(error); + } + }; +} export const intuneWorkspace: WorkspaceDefinition = { id: "intune", @@ -26,4 +119,5 @@ export const intuneWorkspace: WorkspaceDefinition = { folder: "Open IME Or Evidence Folder", placeholder: "Open Intune Source...", }, + onOpenSource: createIntuneOnOpenSource("intune"), }; diff --git a/src/workspaces/log/index.ts b/src/workspaces/log/index.ts index ac6fcfcc4..9ae071bbc 100644 --- a/src/workspaces/log/index.ts +++ b/src/workspaces/log/index.ts @@ -25,6 +25,8 @@ export const logWorkspace: WorkspaceDefinition = { footerBar: true, multiFileDrop: true, fontSizing: true, + tailing: true, + knownSources: true, }, fileFilters: [ { name: "Log Files", extensions: ["log"] }, diff --git a/src/workspaces/new-intune/index.ts b/src/workspaces/new-intune/index.ts index 117fe1722..3a55da7df 100644 --- a/src/workspaces/new-intune/index.ts +++ b/src/workspaces/new-intune/index.ts @@ -1,6 +1,7 @@ // src/workspaces/new-intune/index.ts import { lazy } from "react"; import type { WorkspaceDefinition } from "../types"; +import { createIntuneOnOpenSource } from "../intune"; export const newIntuneWorkspace: WorkspaceDefinition = { id: "new-intune", @@ -26,4 +27,5 @@ export const newIntuneWorkspace: WorkspaceDefinition = { folder: "Open IME Or Evidence Folder", placeholder: "Open Intune Source...", }, + onOpenSource: createIntuneOnOpenSource("new-intune"), }; diff --git a/src/workspaces/sysmon/index.ts b/src/workspaces/sysmon/index.ts index 9e46d5aa0..4ca78f7da 100644 --- a/src/workspaces/sysmon/index.ts +++ b/src/workspaces/sysmon/index.ts @@ -1,7 +1,13 @@ // src/workspaces/sysmon/index.ts -import { lazy } from "react"; +import { startTransition, lazy } from "react"; import type { WorkspaceDefinition } from "../types"; +const LIVE_SYSMON_SOURCE_ID = "windows-sysmon-live-events"; + +function shouldIncludeSysmonLiveEventLogs(source: import("../../types/log").LogSource): boolean { + return source.kind === "known" && source.sourceId === LIVE_SYSMON_SOURCE_ID; +} + export const sysmonWorkspace: WorkspaceDefinition = { id: "sysmon", label: "Sysmon", @@ -22,4 +28,34 @@ export const sysmonWorkspace: WorkspaceDefinition = { folder: "Open EVTX Folder", placeholder: "Open Sysmon Source...", }, + onOpenSource: async (source, trigger) => { + const [{ useUiStore }, { getLogSourcePath }, { analyzeSysmonLogs }, { useSysmonStore }] = + await Promise.all([ + import("../../stores/ui-store"), + import("../../lib/log-source"), + import("../../lib/commands"), + import("./sysmon-store"), + ]); + + useUiStore.getState().ensureWorkspaceVisible("sysmon", trigger); + const sourcePath = getLogSourcePath(source); + const requestId = `sysmon-${Date.now()}`; + useSysmonStore.getState().beginAnalysis(sourcePath, requestId); + + try { + const result = await analyzeSysmonLogs(sourcePath, requestId, { + includeLiveEventLogs: shouldIncludeSysmonLiveEventLogs(source), + }); + startTransition(() => { + useSysmonStore.getState().setResults(result); + }); + } catch (error) { + console.error("[sysmon] failed to analyze Sysmon source", { + source, + trigger, + error, + }); + useSysmonStore.getState().failAnalysis(error instanceof Error ? error.message : String(error)); + } + }, }; diff --git a/src/workspaces/types.ts b/src/workspaces/types.ts index b172d74d0..1fbc13a0b 100644 --- a/src/workspaces/types.ts +++ b/src/workspaces/types.ts @@ -21,6 +21,10 @@ export interface WorkspaceCapabilities { footerBar?: boolean; multiFileDrop?: boolean; fontSizing?: boolean; + /** Whether the toolbar's known-source presets menu is available. Defaults to true if omitted. */ + knownSources?: boolean; + /** Whether pause/resume tailing is supported. Only the log workspace has this. */ + tailing?: boolean; } export interface WorkspaceDefinition { From 5564831a7a41e826a6ff75ca63c2bc2de63cabb4 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 5 Apr 2026 19:06:41 -0400 Subject: [PATCH 65/66] fix: update evtx-store import path after workspace migration Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/layout/StatusBar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/layout/StatusBar.tsx b/src/components/layout/StatusBar.tsx index f3d6da7a0..19c18eb8a 100644 --- a/src/components/layout/StatusBar.tsx +++ b/src/components/layout/StatusBar.tsx @@ -22,7 +22,7 @@ import { useIntuneStore } from "../../workspaces/intune/intune-store"; import { useDsregcmdStore } from "../../workspaces/dsregcmd/dsregcmd-store"; import { useDeploymentStore } from "../../workspaces/deployment/deployment-store"; import { useSysmonStore } from "../../workspaces/sysmon/sysmon-store"; -import { useEvtxStore } from "../../stores/evtx-store"; +import { useEvtxStore } from "../../workspaces/event-log/evtx-store"; interface SeverityCounts { errors: number; From b1c6452f3efca530804b56d89063e5423e6c42a8 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 6 Apr 2026 09:36:54 -0400 Subject: [PATCH 66/66] fix: address Copilot PR #84 review comments - event-log: remove incorrect DsregcmdSidebar, fix file filters to EVTX - deployment: handle file sources by analyzing parent directory - types: move IntuneTimestampBounds to shared types to break circular dep Co-Authored-By: Claude Opus 4.6 (1M context) --- src/types/event-log.ts | 5 ++++- src/workspaces/deployment/index.ts | 16 ++++++++++------ src/workspaces/event-log/index.ts | 15 ++++----------- src/workspaces/intune/types.ts | 10 ++++------ 4 files changed, 22 insertions(+), 24 deletions(-) diff --git a/src/types/event-log.ts b/src/types/event-log.ts index 67f0238d4..eec6abd2e 100644 --- a/src/types/event-log.ts +++ b/src/types/event-log.ts @@ -1,4 +1,7 @@ -import type { IntuneTimestampBounds } from "../workspaces/intune/types"; +export interface IntuneTimestampBounds { + firstTimestamp: string | null; + lastTimestamp: string | null; +} export type EventLogSeverity = | "Critical" diff --git a/src/workspaces/deployment/index.ts b/src/workspaces/deployment/index.ts index 25b92c742..80b8e01fc 100644 --- a/src/workspaces/deployment/index.ts +++ b/src/workspaces/deployment/index.ts @@ -31,12 +31,16 @@ export const deploymentWorkspace: WorkspaceDefinition = { placeholder: "Open...", }, onOpenSource: async (source, _trigger) => { - const folderPath = - source.kind === "folder" - ? source.path - : source.kind === "known" - ? source.defaultPath - : null; + let folderPath: string | null = null; + if (source.kind === "folder") { + folderPath = source.path; + } else if (source.kind === "known") { + folderPath = source.defaultPath; + } else if (source.kind === "file") { + // Analyze the parent directory of the selected file + const lastSep = Math.max(source.path.lastIndexOf("/"), source.path.lastIndexOf("\\")); + folderPath = lastSep > 0 ? source.path.substring(0, lastSep) : null; + } if (folderPath) { const { useDeploymentStore } = await import("./deployment-store"); await useDeploymentStore.getState().analyzeFolder(folderPath); diff --git a/src/workspaces/event-log/index.ts b/src/workspaces/event-log/index.ts index 50b3c3a8d..6678edf3a 100644 --- a/src/workspaces/event-log/index.ts +++ b/src/workspaces/event-log/index.ts @@ -11,20 +11,13 @@ export const eventLogWorkspace: WorkspaceDefinition = { (m) => ({ default: m.EventLogWorkspace }) ) ), - sidebar: lazy(() => - import("../dsregcmd/DsregcmdSidebar").then((m) => ({ - default: m.DsregcmdSidebar, - })) - ), fileFilters: [ - { name: "Log Files", extensions: ["log"] }, - { name: "Old Log Files", extensions: ["lo_"] }, - { name: "Registry Files", extensions: ["reg"] }, + { name: "EVTX Files", extensions: ["evtx"] }, { name: "All Files", extensions: ["*"] }, ], actionLabels: { - file: "Open File", - folder: "Open Folder", - placeholder: "Open...", + file: "Open EVTX File", + folder: "Open EVTX Folder", + placeholder: "Open Event Log Source...", }, }; diff --git a/src/workspaces/intune/types.ts b/src/workspaces/intune/types.ts index 957aa2e59..9032c4f50 100644 --- a/src/workspaces/intune/types.ts +++ b/src/workspaces/intune/types.ts @@ -1,5 +1,8 @@ import type { EvidenceBundleMetadata } from "../../types/evidence"; -import type { EventLogAnalysis } from "../../types/event-log"; +import type { EventLogAnalysis, IntuneTimestampBounds } from "../../types/event-log"; + +// Re-export so existing consumers don't break +export type { IntuneTimestampBounds } from "../../types/event-log"; export type IntuneEventType = | "Win32App" @@ -86,11 +89,6 @@ export const STATUS_RANK: Record = { Unknown: 5, }; -export interface IntuneTimestampBounds { - firstTimestamp: string | null; - lastTimestamp: string | null; -} - export interface IntuneDiagnosticsFileCoverage { filePath: string; eventCount: number;