{ "version": 3, "sources": ["../ts/i18n/I18n.ts", "../ts/utils/MaskModeState.ts", "../ts/utils/Storage.ts", "../ts/utils/MPHosts.ts", "../ts/utils/Framing.ts", "../ts/utils/Base64.ts", "../ts/utils/Bi.ts", "../ts/utils/Auth.ts", "../ts/utils/Modal.ts", "../ts/utils/FormatUtils.ts", "../ts/utils/ConfirmModal.ts", "../ts/utils/ImpersonationNotify.ts", "../ts/utils/Api.ts", "../ts/Main.ts", "../ts/stores/AboutStore.ts", "../ts/stores/ADStore.ts", "../ts/utils/DataTables.ts", "../ts/utils/ColumnLayout.ts", "../ts/utils/CsvFormula.ts", "../ts/utils/Page.ts", "../ts/utils/Header.ts", "../ts/utils/LoadingCount.ts", "../ts/stores/AdminStore.ts", "../ts/utils/ResponseError.ts", "../ts/models/Artifact.ts", "../ts/stores/ArtifactsStore.ts", "../ts/stores/EnvironmentStore.ts", "../ts/models/Event.ts", "../ts/stores/EventStore.ts", "../ts/models/NodeMetric.ts", "../ts/models/Protections.ts", "../ts/models/QATests.ts", "../ts/utils/MetricsUtils.ts", "../ts/models/UnauthorizedExecutable.ts", "../ts/models/ServiceInfo.ts", "../ts/models/NodeIncident.ts", "../ts/models/Incident.ts", "../ts/stores/ReportsStore.ts", "../ts/utils/DeepLink.ts", "../ts/utils/EvidenceBanner.ts", "../ts/utils/EvidenceScope.ts", "../ts/utils/OverlapSeeds.ts", "../ts/utils/UrlParams.ts", "../ts/utils/HistoryIndex.ts", "../ts/managers/NavigationManager.ts", "../ts/utils/TenantNameDisplay.ts", "../ts/utils/TamTenantPrefilter.ts", "../ts/utils/ThemeSetting.ts", "../ts/utils/Theme.ts", "../ts/utils/ThemeSync.ts", "../ts/utils/ClusterStatsChart.ts", "../ts/utils/AppBranding.ts", "../ts/i18n/I18nDom.ts", "../ts/utils/ReportUtils.ts", "../ts/utils/SidebarOrder.ts", "../ts/utils/RetiredPages.ts", "../ts/utils/SidebarVisibility.ts", "../ts/utils/LatestSave.ts", "../ts/utils/SidebarSave.ts", "../ts/stores/PresenceStore.ts", "../ts/utils/UserStatusPref.ts", "../ts/utils/SmbReportParams.ts", "../ts/utils/PageLoadGuards.ts", "../ts/bi/Markdown.ts", "../ts/utils/KnowledgeView.ts", "../ts/stores/LogStore.ts", "../ts/stores/NodeConfigStore.ts", "../ts/stores/NodeLifecycleStore.ts", "../ts/models/Node.ts", "../ts/stores/NodeStore.ts", "../ts/utils/ArchivedNodesSetting.ts", "../ts/models/Applications.ts", "../ts/utils/OsOverlay.ts", "../ts/stores/TenantStore.ts", "../ts/models/Subtenant.ts", "../ts/stores/SubtenantStore.ts", "../ts/utils/IncludeAllShown.ts", "../ts/models/DirectoryTreeNode.ts", "../ts/stores/DirectoryTreeNodeStore.ts", "../ts/models/ProcessTreeNode.ts", "../ts/stores/ProcessTreeNodeStore.ts", "../ts/models/RansomwareInfo.ts", "../ts/stores/RansomwareInfoStore.ts", "../ts/utils/DarkRemap.ts", "../ts/utils/OpPalette.ts", "../ts/models/Settings.ts", "../ts/stores/SettingsStore.ts", "../ts/stores/SystemInfoStore.ts", "../ts/models/Timeline.ts", "../ts/stores/TimelineStore.ts", "../ts/models/Tenant.ts", "../ts/models/UserProfile.ts", "../ts/stores/UserProfileStore.ts", "../ts/models/ClusterStats.ts", "../ts/stores/ClusterStatsStore.ts", "../ts/stores/TreescansStore.ts", "../ts/stores/ExecutableStore.ts", "../ts/models/TenantExecutable.ts", "../ts/models/NodeService.ts", "../ts/models/TenantService.ts", "../ts/stores/ServiceStore.ts", "../ts/models/Constellation.ts", "../ts/stores/ConstellationStore.ts", "../ts/stores/NotificationStore.ts", "../ts/utils/TenantScopeSetting.ts", "../ts/stores/IncidentsStore.ts", "../ts/stores/SqlStore.ts", "../ts/models/Sql.ts", "../ts/stores/UploadChunkedFileStore.ts", "../ts/utils/UploadChunks.ts", "../ts/stores/DeveloperStore.ts", "../ts/models/DeveloperFiles.ts", "../ts/models/InsightSkill.ts", "../ts/models/InsightSkillRun.ts", "../ts/stores/InsightsStore.ts", "../ts/utils/LruCache.ts", "../ts/models/AclRuleTree.ts", "../ts/models/AclRuleSummary.ts", "../ts/models/AclSimulation.ts", "../ts/models/AclSimulationDelta.ts", "../ts/stores/AclSimulationStore.ts", "../ts/models/EventHistogram.ts", "../ts/models/DriftReport.ts", "../ts/stores/DriftReportStore.ts", "../ts/stores/ChangeReportStore.ts", "../ts/stores/KnowledgeManageStore.ts", "../ts/utils/KnowledgeEditorState.ts", "../ts/stores/DiversityReportStore.ts", "../ts/models/Caller.ts", "../ts/stores/CallerStore.ts", "../ts/stores/KnowledgeStore.ts", "../ts/models/DiskUsage.ts", "../ts/stores/DiskUsageStore.ts", "../ts/stores/NodeExecutableStore.ts", "../ts/models/NodeExecutable.ts", "../ts/stores/OnyxStore.ts", "../ts/stores/ZammadStore.ts", "../ts/utils/ZammadMemberPager.ts", "../ts/stores/CHBackupStore.ts", "../ts/stores/PGBackupStore.ts", "../ts/stores/TenantProfilingStore.ts", "../ts/stores/ReleaseStatusStore.ts", "../ts/managers/StoreManager.ts", "../ts/utils/HomePageSetting.ts", "../ts/handlers/EventHandlers.ts", "../ts/utils/FetchGeneration.ts", "../ts/handlers/WebSocketHandler.ts", "../ts/utils/MaskModeBootNotice.ts", "../ts/utils/MaskMode.ts", "../ts/utils/ButtonUtils.ts", "../ts/ui/AboutUI.ts", "../ts/ui/AIVisibilityUI.ts", "../ts/utils/PageLoadGate.ts", "../ts/utils/SummaryCharts.ts", "../ts/utils/Gauge.ts", "../ts/utils/AIVisibilityHeadline.ts", "../ts/utils/ReportExport.ts", "../ts/utils/ChartTheme.ts", "../ts/utils/SlideNoteHeight.ts", "../ts/utils/SlideTablePages.ts", "../ts/ui/AIVisibilityShared.ts", "../ts/ui/LinkedInPostsUI.ts", "../ts/utils/Drawer.ts", "../ts/ui/ConfirmedRansomwareUI.ts", "../ts/ui/RansomGroupsPanel.ts", "../ts/utils/ConfirmedLanes.ts", "../ts/ui/SiteTrafficUI.ts", "../ts/ui/CareersUI.ts", "../ts/ui/KevUI.ts", "../ts/ui/KevSummary.ts", "../ts/ui/VulnSourceChips.ts", "../ts/ui/MissingPatchesUI.ts", "../ts/utils/PatchVersionsNote.ts", "../ts/utils/AsOf.ts", "../ts/utils/TenantScopeToggle.ts", "../ts/ui/CveFleetUI.ts", "../ts/utils/VirtualPatchWorkflowModal.ts", "../ts/ui/AdminUI.ts", "../ts/utils/ConnectorRedirectFormDrawer.ts", "../ts/ui/MSSPAdminUI.ts", "../ts/ui/SiteAccessUI.ts", "../ts/ui/VulnSourcesUI.ts", "../ts/utils/ShortId.ts", "../ts/utils/UploadModal.ts", "../ts/utils/VulnConnectorDrawer.ts", "../ts/utils/VulnImportOutcome.ts", "../ts/ui/TenantProfilingEconomicsUI.ts", "../ts/ui/APIPerfAdminUI.ts", "../ts/utils/APIPerfRules.ts", "../ts/ui/TAMAdminUI.ts", "../ts/utils/SecretsResync.ts", "../ts/ui/ArtifactsUI.ts", "../ts/utils/FormatFileSize.ts", "../ts/utils/NodeWarnings.ts", "../ts/utils/ElementUtils.ts", "../ts/ui/DirectoryTreeUI.ts", "../ts/utils/PathEventsDrawer.ts", "../ts/utils/EventDetailDrawer.ts", "../ts/utils/PathTreeRenderer.ts", "../ts/utils/FolderPickerPaths.ts", "../ts/utils/TreeUtils.ts", "../ts/utils/LegendToggleBadge.ts", "../ts/utils/LegendRenderer.ts", "../ts/utils/DirectoryTreeRenderer.ts", "../ts/ui/ExecSummaryUI.ts", "../ts/utils/NodeDashboard.ts", "../ts/utils/CompromisedFileChart.ts", "../ts/ui/CreateReportUI.ts", "../ts/models/PreCheckInfo.ts", "../ts/models/ImportInfo.ts", "../ts/models/StatusInfo.ts", "../ts/utils/CreateReport.ts", "../ts/ui/ReportsUI.ts", "../ts/utils/ContextMenu.ts", "../ts/ui/LogsUI.ts", "../ts/ui/NodeConfigsUI.ts", "../ts/utils/NodeConfigDrawer.ts", "../ts/utils/ConfigSchemaBadge.ts", "../ts/ui/NodesUI.ts", "../ts/utils/InsightsRunModal.ts", "../ts/utils/InsightCertifyPref.ts", "../ts/utils/InsightsInlineMarkdown.ts", "../ts/utils/TimeRangeModal.ts", "../ts/utils/NodeAnalysisModal.ts", "../ts/utils/AclSimulationRunModal.ts", "../ts/utils/Connectivity.ts", "../ts/utils/CoalescedReload.ts", "../ts/ui/NotificationsUI.ts", "../ts/utils/DrawCoalescer.ts", "../ts/utils/WindowFilter.ts", "../ts/utils/ZammadNavigation.ts", "../ts/utils/RulesDrawer.ts", "../ts/utils/alertTypes.ts", "../ts/utils/RuleFormDrawer.ts", "../ts/utils/ImpactCaveats.ts", "../ts/utils/SearchExpr.ts", "../ts/utils/AlertTypesDrawer.ts", "../ts/utils/NotificationDetailDrawer.ts", "../ts/utils/NotificationCauseView.ts", "../ts/utils/NotificationCauses.ts", "../ts/ui/IncidentsUI.ts", "../ts/utils/IncidentDetailDrawer.ts", "../ts/utils/EscalateDrawer.ts", "../ts/utils/KeyValueDescription.ts", "../ts/ui/ProcessTreeUI.ts", "../ts/models/RegistryTreeNode.ts", "../ts/stores/RegistryTreeNodeStore.ts", "../ts/utils/IPEnrichment.ts", "../ts/utils/ProcessTreeRenderer.ts", "../ts/utils/ProcessTreeUtils.ts", "../ts/utils/ProcessTreeSearch.ts", "../ts/utils/RegistryTreeRenderer.ts", "../ts/ui/ProfileUI.ts", "../ts/utils/CallerModals.ts", "../ts/utils/QrModal.ts", "../ts/ui/MaskModeSection.ts", "../ts/ui/ProtectionsUI.ts", "../ts/ui/RansomwareUI.ts", "../ts/ui/RegistryTreeUI.ts", "../ts/ui/SystemInfoUI.ts", "../ts/utils/IconUtils.ts", "../ts/ui/TimelineUI.ts", "../ts/ui/TimeToDetectUI.ts", "../ts/ui/ClusterStatsUI.ts", "../ts/utils/FormatMemorySize.ts", "../ts/ui/TreescansUI.ts", "../ts/utils/TreescanSnapshotDrawer.ts", "../ts/ui/ForbiddenUI.ts", "../ts/ui/EventsUI.ts", "../ts/utils/OpColors.ts", "../ts/utils/BlobExpr.ts", "../ts/ui/ExecutablesUI.ts", "../ts/utils/ExecutableTable.ts", "../ts/utils/EventsModal.ts", "../ts/utils/ExecutableEditModal.ts", "../ts/ui/ServicesUI.ts", "../ts/utils/ServiceTable.ts", "../ts/ui/ConstellationsUI.ts", "../ts/utils/ConstellationRenderer.ts", "../ts/utils/ConstellationFilters.ts", "../ts/utils/ConstellationGraph.ts", "../ts/utils/ConstellationUtils.ts", "../ts/utils/ConstellationInfoPanel.ts", "../ts/utils/ConstellationViewToggles.ts", "../ts/ui/SqlUI.ts", "../ts/utils/SqlFormatter.ts", "../ts/utils/CodeMirrorUtils.ts", "../ts/ui/BiTimePicker.ts", "../ts/bi/DataViews.ts", "../ts/bi/HourlySource.ts", "../ts/bi/TimeRange.ts", "../ts/ui/DashboardGallery.ts", "../ts/ui/BiSharing.ts", "../ts/bi/Kql.ts", "../ts/bi/Formula.ts", "../ts/bi/Lens.ts", "../ts/bi/Filters.ts", "../ts/bi/Search.ts", "../ts/bi/Launcher.ts", "../ts/bi/Dashboard.ts", "../ts/bi/SavedObjects.ts", "../ts/ui/BiObjectMenus.ts", "../ts/ui/BiFilterBar.ts", "../ts/ui/KqlTextArea.ts", "../ts/ui/BiStyles.ts", "../ts/ui/ChartTimeZoom.ts", "../ts/ui/DeveloperUI.ts", "../ts/ui/InsightsUI.ts", "../ts/ui/ACLSimulationEventSummaryUI.ts", "../ts/ui/ACLSimulationRuleSummaryUI.ts", "../ts/utils/PathUtils.ts", "../ts/ui/ACLSimulationRuleTreeUI.ts", "../ts/utils/AclRuleTreeRenderer.ts", "../ts/ui/ACLSimulationsUI.ts", "../ts/ui/ConfigDeltaUI.ts", "../ts/ui/ACLSimulationEventSummaryDeltaUI.ts", "../ts/ui/ACLSimulationRuleSummaryDeltaUI.ts", "../ts/ui/ACLSimulationRuleTreeDeltaUI.ts", "../ts/ui/NodeApplicationsUI.ts", "../ts/utils/PartialListNote.ts", "../ts/utils/AppExecutablesDrawer.ts", "../ts/utils/AppPathsDrawer.ts", "../ts/utils/PathsTable.ts", "../ts/utils/AppTreeDrawer.ts", "../ts/utils/TreeWindowDefaults.ts", "../ts/utils/PathIdentifiedBadge.ts", "../ts/ui/ApplicationsUI.ts", "../ts/utils/AppNodesDrawer.ts", "../ts/utils/FleetScopeQuery.ts", "../ts/ui/DriftReportsUI.ts", "../ts/ui/ChangeReportsUI.ts", "../ts/ui/KnowledgeEditorUI.ts", "../ts/utils/HoldControls.ts", "../ts/utils/KbIconAction.ts", "../ts/ui/MarkdownView.ts", "../ts/ui/ChangeReportUI.ts", "../ts/ui/ChangeActivityDrawer.ts", "../ts/ui/ChangeReportLabels.ts", "../ts/utils/ChangeFilters.ts", "../ts/ui/ChangeRecordPanel.ts", "../ts/ui/ChangeReportSummary.ts", "../ts/ui/DriftReportTimeRangeUI.ts", "../ts/utils/NodeSummaryView.ts", "../ts/ui/DriftSummaryPanel.ts", "../ts/ui/DiversityReportUI.ts", "../ts/utils/DiversityPathsDrawer.ts", "../ts/utils/NumberPromptModal.ts", "../ts/ui/KnowledgeUI.ts", "../ts/ui/KnowledgeSourcesUI.ts", "../ts/utils/KnowledgeSources.ts", "../ts/ui/KnowledgeFeedbackUI.ts", "../ts/ui/DiskUsageUI.ts", "../ts/ui/BackupsUI.ts", "../ts/ui/TenantProfilingUI.ts", "../ts/ui/TenantOperationsDrawer.ts", "../ts/ui/NetworkProfilerUI.ts", "../ts/utils/NetworkApps.ts", "../ts/utils/NetworkGraph.ts", "../ts/utils/NetFilters.ts", "../ts/ui/ComponentProfileTable.ts", "../ts/ui/ReleaseStatusUI.ts", "../ts/utils/MyTenantsChip.ts", "../ts/utils/PermittedReleasesDrawer.ts", "../ts/stores/NodeHealthStore.ts", "../ts/ui/NodeHealthUI.ts", "../ts/utils/NodeHealthHistoryDrawer.ts", "../ts/utils/RecoveryDrilldownDrawer.ts", "../ts/utils/HeartbeatFormat.ts", "../ts/utils/NodeRecoveryDrawer.ts", "../ts/ui/TenantHealthUI.ts", "../ts/ui/DiscoverUI.ts", "../ts/bi/Names.ts", "../ts/stores/BiNameStore.ts", "../ts/ui/BiReportForm.ts", "../ts/stores/BiReportStore.ts", "../ts/bi/TenantScope.ts", "../ts/bi/Reports.ts", "../ts/stores/BiFieldStore.ts", "../ts/stores/BiSavedObjectStore.ts", "../ts/ui/BiDocumentDrawer.ts", "../ts/bi/ChartLayout.ts", "../ts/bi/DiscoverTable.ts", "../ts/ui/BiTenantScope.ts", "../ts/ui/VisualizeUI.ts", "../ts/ui/BiLensErrors.ts", "../ts/ui/LensChart.ts", "../ts/bi/MetricFormat.ts", "../ts/stores/BiDashboardStore.ts", "../ts/stores/BiAlertStore.ts", "../ts/bi/Alerts.ts", "../ts/ui/DashboardsUI.ts", "../ts/ui/TenantLauncher.ts", "../ts/ui/SavedObjectsUI.ts", "../ts/ui/BenchmarkComplianceUI.ts", "../ts/ui/BenchmarkTemplatesUI.ts", "../ts/ui/CallersUI.ts", "../ts/ui/NodeConstellationUI.ts", "../ts/utils/NodeConstellationWindow.ts", "../ts/ui/NodeComponentProfileUI.ts", "../ts/utils/FolderPickerModal.ts", "../ts/utils/RecentPicks.ts", "../ts/utils/PathSuggestField.ts", "../ts/ui/LolbinDrawer.ts", "../ts/ui/ComponentProfileUI.ts", "../ts/ui/ComponentFleetNodes.ts", "../ts/ui/NodeServicesUI.ts", "../ts/ui/NodeTreescanUI.ts", "../ts/ui/NodeEventsUI.ts", "../ts/utils/EventSubscriptionEnded.ts", "../ts/ui/SMBFileIOReportUI.ts", "../ts/ui/SMBNetworkReportUI.ts", "../ts/ui/NodeExecutablesUI.ts", "../ts/ui/ZammadOrganizationsUI.ts", "../ts/ui/ZammadOrganizationDetailsUI.ts", "../ts/utils/ZammadNewTicketModal.ts", "../ts/utils/ZammadMemberMenu.ts", "../ts/utils/ZammadTicketsTable.ts", "../ts/ui/ZammadTicketsUI.ts", "../ts/ui/ZammadTicketDetailsUI.ts", "../ts/utils/ZammadEditTicketModal.ts", "../ts/utils/ZammadTicketEdit.ts", "../ts/ui/ZammadUI.ts", "../ts/ui/ADSummaryUI.ts", "../ts/utils/AdNodeFilter.ts", "../ts/ui/ADEventHourlyUI.ts", "../ts/utils/ADChart.ts", "../ts/utils/AdFacetDrill.ts", "../ts/ui/ADEventCatalogSummary.ts", "../ts/utils/AdWindowFilter.ts", "../ts/utils/AdBreakdownTable.ts", "../ts/ui/ADSessionChangeHourlyUI.ts", "../ts/ui/ADSessionChangeSummary.ts", "../ts/ui/ADServiceChangeHourlyUI.ts", "../ts/ui/ADServiceChangeSummary.ts", "../ts/ui/ADScheduledTaskUI.ts", "../ts/ui/ADScheduledTaskSummary.ts", "../ts/ui/ADKerberosTicketUI.ts", "../ts/ui/ADKerberosTicketSummary.ts", "../ts/ui/ADDCSyncHourlyUI.ts", "../ts/ui/ADDCSyncSummary.ts", "../ts/ui/ADDomainControllersUI.ts", "../ts/ui/ADDomainControllersSummary.ts", "../ts/ui/ADInventoryUI.ts", "../ts/models/ADInventory.ts", "../ts/ui/ADPermissionsUI.ts", "../ts/ui/ADPermissionsCsv.ts", "../ts/ui/ADPermissionsStatus.ts", "../ts/ui/ADLdapChangeHourlyUI.ts", "../ts/ui/ADLdapChangeSummary.ts", "../ts/ui/ADGroupChangeHourlyUI.ts", "../ts/ui/ADGroupChangeSummary.ts", "../ts/ui/ADUSNChangeHourlyUI.ts", "../ts/ui/ADUSNSummary.ts", "../ts/utils/ChangedAttributeGroups.ts", "../ts/ui/OnyxUI.ts", "../ts/ui/SupportUI.ts", "../ts/stores/SupportStore.ts", "../ts/utils/SupportSparkline.ts", "../ts/managers/UIManager.ts", "../ts/datatables/SearchExprControl.ts", "../ts/datatables/SearchBlobControl.ts", "../ts/datatables/SearchTimeRangeControl.ts", "../ts/utils/Setup.ts", "../ts/utils/CustomTooltips.ts", "../ts/utils/SelectMenu.ts"], "sourcesContent": ["/**\n * Client-side i18n runtime.\n *\n * Dashcam renders ~100% client-side, so this is where essentially all\n * localization happens \u2014 there are no server templates to translate.\n *\n * Catalogs are fetched at runtime from /static/i18n/.json rather than\n * bundled into app.mjs. Two reasons: adding a language becomes a file drop with\n * no rebuild, and a user reading one language shouldn't download all of them.\n *\n * Usage:\n * TS: t('nav.incidents') \u2192 \"Incidents\"\n * t('nodes.count', { n: 12 }) \u2192 \"12 nodes\"\n * HTML: \n * \n * then applyTranslations(root) \u2014 see I18nDom.ts\n */\n\n/** Locales we ship a catalog for. `en` is the source language and the fallback. */\nexport const SUPPORTED_LOCALES = ['en', 'zh-Hans', 'zh-Hant', 'ja', 'es', 'fr', 'fr-CA'] as const;\nexport type Locale = (typeof SUPPORTED_LOCALES)[number];\n\nexport const DEFAULT_LOCALE: Locale = 'en';\n\n/**\n * Native display name for each supported locale \u2014 for a language picker. Shown\n * in the language's own script so a reader can find their language without\n * already reading the current one.\n */\n// Each label carries its public tag so the picker is unambiguous about which\n// variant it is \u2014 \u4E2D\u6587\uFF08\u7B80\u4F53\uFF09alone doesn't tell a reader whether this is the\n// zh-CN they were told to expect.\nexport const LOCALE_LABELS: Record = {\n en: 'English \u00B7 en',\n 'zh-Hans': '\u4E2D\u6587\uFF08\u7B80\u4F53\uFF09\u00B7 zh-CN',\n 'zh-Hant': '\u4E2D\u6587\uFF08\u7E41\u9AD4\uFF09\u00B7 zh-TW',\n ja: '\u65E5\u672C\u8A9E \u00B7 ja',\n es: 'Espa\u00F1ol \u00B7 es',\n fr: 'Fran\u00E7ais \u00B7 fr',\n 'fr-CA': 'Fran\u00E7ais (Canada) \u00B7 fr-CA',\n};\n\n/**\n * The tag we ADVERTISE for each shipped catalog \u2014 what the picker stores and\n * what shareable `?lang=` links should say. Mainland partners expect `zh-CN`,\n * not the script-generic `zh-Hans` (partnerships ask); both \u2014 like `zh-SG` and\n * bare `zh` \u2014 resolve to the same Simplified catalog via matchLocale. Traditional\n * advertises as `zh-TW` (Taiwan is the seed variant); `zh-HK` / `zh-MO` and\n * script-generic `zh-Hant` all resolve to the same catalog. The catalog FILES\n * stay script-accurate (`zh-Hans.json` / `zh-Hant.json`) so the two never collide.\n */\nexport const PUBLIC_TAGS: Record = {\n en: 'en',\n 'zh-Hans': 'zh-CN',\n 'zh-Hant': 'zh-TW',\n ja: 'ja',\n es: 'es',\n fr: 'fr',\n 'fr-CA': 'fr-CA',\n};\n\n/** Key under which the stored locale tag is remembered. */\nconst LOCALE_STORAGE_KEY = 'dashcam.locale';\n\n/**\n * Where the stored tag came from, as JSON `{ source, owner? }`: `local` is an\n * explicit choice (the picker or ?lang=), `server` a copy of an account's\n * users.locale that adoptServerLocale() wrote. `owner` names the account (env +\n * user ID) it belongs to. A tag with no marker (or an unreadable one) predates\n * the marker, when adoption wrote the same key as the picker, so it may be a\n * stale adopted copy or an explicit pick: it reads as `legacy`. The account's\n * server locale replaces it; a failed settings read keeps it (and claims it\n * for the signed-in account, still `legacy`); a read that confirms the\n * account has no locale keeps it as the account's explicit choice: a value\n * with no owner cannot be told apart from a ?lang= pick, so it is kept,\n * accepting that on a shared browser it may have been adopted for an\n * earlier account.\n */\nconst LOCALE_META_KEY = 'dashcam.locale.meta';\n\ntype LocaleSource = 'local' | 'server' | 'legacy';\ninterface LocaleMeta { source: LocaleSource; owner: string | null }\n\n/** The signed-in account, once adoptServerLocale() has been told it. */\nlet localeOwner: string | null = null;\n\ntype Catalog = Record;\n\nlet activeLocale: Locale = DEFAULT_LOCALE;\n// What resolveLocale() chose at startup, before any catalog-failure fallback\n// to English: adoptServerLocale() compares against it, so a catalog that\n// keeps failing cannot turn adoption into a reload loop.\nlet resolvedLocale: Locale = DEFAULT_LOCALE;\nlet catalog: Catalog = {};\nlet fallbackCatalog: Catalog = {};\nlet ready = false;\n\n/**\n * Match a BCP-47 tag from the browser against what we ship.\n *\n * The interesting case is Chinese. A browser may send `zh`, `zh-CN`,\n * `zh-Hans-CN`, `zh-TW`, or `zh-Hant-TW`. Simplified and Traditional are\n * different scripts, not dialects \u2014 serving one to the other's reader looks\n * like we tried and got it wrong. We ship both catalogs, so each tag routes to\n * its own script: only tags that imply Simplified map to zh-Hans, only those\n * that imply Traditional map to zh-Hant. Bare `zh` (no script, no region)\n * defaults to Simplified, the larger reader base.\n */\nexport function matchLocale(tag: string): Locale | null {\n const t = tag.trim().toLowerCase();\n if (!t) return null;\n\n if (t === 'en' || t.startsWith('en-')) return 'en';\n if (t === 'es' || t.startsWith('es-')) return 'es';\n if (t === 'ja' || t.startsWith('ja-')) return 'ja';\n\n // French: Canada gets its own catalog (terminology: courriel, \u2026); every\n // other region \u2014 FR, BE, CH, LU, and the rest \u2014 reads the fr base.\n if (t === 'fr-ca' || t.startsWith('fr-ca-')) return 'fr-CA';\n if (t === 'fr' || t.startsWith('fr-')) return 'fr';\n\n if (t === 'zh' || t.startsWith('zh-')) {\n // Explicit script subtag wins.\n if (t.includes('hant')) return 'zh-Hant';\n if (t.includes('hans')) return 'zh-Hans';\n // No script subtag: infer from region. TW/HK/MO use Traditional;\n // CN/SG (and bare `zh`) use Simplified.\n if (/-(tw|hk|mo)\\b/.test(t)) return 'zh-Hant';\n return 'zh-Hans'; // bare `zh` and zh-CN / zh-SG\n }\n return null;\n}\n\n/**\n * Resolve which locale to use.\n *\n * Precedence: explicit choice (?lang= or a stored preference) \u2192 the browser's\n * Accept-Language order \u2192 en.\n *\n * The server-persisted per-user locale slots in between the explicit choice\n * and the browser \u2014 but the profile loads AFTER initI18n, so it's applied via\n * adoptServerLocale() once the profile arrives rather than here (which also\n * replaces a stored value that was only adopted, or belongs to another\n * account, so `stored` here is provisional until then). A tenant-level\n * default is still pending its own API surface.\n */\nexport function resolveLocale(\n search: string = typeof location !== 'undefined' ? location.search : '',\n browserLocales: readonly string[] = typeof navigator !== 'undefined' ? navigator.languages ?? [] : [],\n stored: string | null = readStoredLocale(),\n): Locale {\n const fromQuery = new URLSearchParams(search).get('lang');\n if (fromQuery) {\n const m = matchLocale(fromQuery);\n if (m) return m;\n }\n if (stored) {\n const m = matchLocale(stored);\n if (m) return m;\n }\n for (const tag of browserLocales) {\n const m = matchLocale(tag);\n if (m) return m;\n }\n return DEFAULT_LOCALE;\n}\n\nfunction readStoredLocale(): string | null {\n try {\n return localStorage.getItem(LOCALE_STORAGE_KEY);\n } catch {\n return null; // storage can throw in private mode / blocked contexts\n }\n}\n\nfunction readLocaleMeta(): LocaleMeta {\n try {\n const raw = localStorage.getItem(LOCALE_META_KEY);\n if (raw !== null) {\n const v = JSON.parse(raw) as { source?: unknown; owner?: unknown };\n if (v?.source === 'local' || v?.source === 'server' || v?.source === 'legacy') {\n return { source: v.source, owner: typeof v.owner === 'string' ? v.owner : null };\n }\n }\n } catch {\n /* blocked storage or a malformed marker: treat as a pre-marker value */\n }\n return { source: 'legacy', owner: null };\n}\n\n/** Store the tag and its marker; true only when the tag reads back as written. */\nfunction writeLocale(locale: Locale, source: LocaleSource): boolean {\n const tag = PUBLIC_TAGS[locale] ?? locale;\n try {\n localStorage.setItem(LOCALE_STORAGE_KEY, tag);\n localStorage.setItem(LOCALE_META_KEY, JSON.stringify({ source, owner: localeOwner }));\n } catch {\n /* non-fatal: the choice just won't persist */\n }\n return readStoredLocale() === tag;\n}\n\n/**\n * Apply the server-persisted per-user locale once the profile arrives.\n *\n * Precedence: an explicit local choice (?lang=, or the picker's stored choice\n * made by this same account) wins; the account's server value beats a copy\n * this browser adopted earlier (the account changed language elsewhere, or\n * another account signed in) and the browser fallback. A stored value that\n * belongs to another account is dropped when this account has no server\n * locale. A null `tag` is also what a failed settings read returns, so on\n * its own it never resets this account's own stored value; `unset` is the\n * server confirming the account has no locale (the read succeeded), and then\n * a copy adopted from this account's server value is dropped too, so the\n * page follows the account's Auto setting. An explicit local pick is kept\n * either way, and so is a value from before the source marker (see\n * LOCALE_META_KEY).\n * `owner` identifies the signed-in account (env + user ID); without it a\n * stored explicit choice is kept for anyone.\n *\n * Returns true when the effective locale changed and the caller should reload\n * (catalogs load at startup, so a reload is the correct way to re-render). It\n * returns true only once the new state is stored, so the post-reload session\n * resolves to the same locale and this returns false: it never loops, even\n * when storage refuses the write.\n */\nexport function adoptServerLocale(tag: string | null | undefined, owner: string | null = null, unset = false): boolean {\n localeOwner = owner;\n try {\n const q = new URLSearchParams(location.search).get('lang');\n if (q && matchLocale(q)) return false; // explicit query choice wins\n } catch { /* no location \u2014 fall through */ }\n const raw = readStoredLocale();\n const stored = raw ? matchLocale(raw) : null;\n const meta = readLocaleMeta();\n const otherOwner = !!stored && !!owner && !!meta.owner && meta.owner !== owner;\n if (stored && !otherOwner && meta.source === 'local') {\n // This account's explicit choice wins. One nobody owned yet (?lang=\n // before sign-in) becomes this account's, so the next account does\n // not inherit it.\n if (owner && !meta.owner) writeLocale(stored, 'local');\n return false;\n }\n const m = tag ? matchLocale(tag) : null;\n if (m) {\n if (stored === m && !otherOwner && meta.source === 'server' && meta.owner === owner) return false;\n return writeLocale(m, 'server') && m !== resolvedLocale;\n }\n if (!stored) return false;\n if (!otherOwner && !(unset && meta.source === 'server')) {\n // No server value (never chosen, or the read failed): keep this\n // account's stored value, and claim one nobody owned yet. A\n // pre-marker value the read confirms has no account locale behind it\n // is not an adopted copy: keep it as the account's explicit choice.\n if (unset && meta.source === 'legacy') writeLocale(stored, 'local');\n else if (owner && !meta.owner) writeLocale(stored, meta.source);\n return false;\n }\n // Another account's value, or a copy adopted from this account's server\n // value that the server now confirms is unset: back to the browser's order.\n clearLocale();\n if (readStoredLocale() !== null) return false;\n return resolveLocale(undefined, undefined, null) !== resolvedLocale;\n}\n\n/** Remember an explicit user choice (stored as the public tag). Caller reloads to apply. */\nexport function setLocale(locale: Locale): void {\n writeLocale(locale, 'local');\n}\n\n/** True when this browser holds a stored language (a choice, or an adopted account value). */\nexport function hasStoredLocale(): boolean {\n return readStoredLocale() !== null;\n}\n\n/**\n * Forget the stored language choice, returning resolution to the browser's\n * Accept-Language order (\"Auto\"). Caller reloads to apply \u2014 and should also\n * clear the server-persisted users.locale, or adoptServerLocale() will re-pin\n * the old choice into storage when the profile next arrives.\n */\nexport function clearLocale(): void {\n try {\n localStorage.removeItem(LOCALE_STORAGE_KEY);\n localStorage.removeItem(LOCALE_META_KEY);\n } catch {\n /* non-fatal */\n }\n}\n\nexport function getLocale(): Locale {\n return activeLocale;\n}\n\nexport function isReady(): boolean {\n return ready;\n}\n\nasync function fetchCatalog(locale: Locale): Promise {\n const resp = await fetch(`/static/i18n/${locale}.json`, { credentials: 'same-origin' });\n if (!resp.ok) throw new Error(`catalog ${locale}: ${resp.status}`);\n return (await resp.json()) as Catalog;\n}\n\n/**\n * Load the active catalog and stamp . Call once, before first render.\n *\n * A catalog that fails to load must not take the app down \u2014 an untranslated UI\n * is degraded, a blank one is an outage. On failure we fall back to English and\n * carry on.\n */\nexport async function initI18n(): Promise {\n activeLocale = resolvedLocale = resolveLocale();\n\n // Persist an explicit ?lang= choice so it survives in-app navigation (which\n // drops the query string) and future visits \u2014 same effect as picking it in\n // the language menu. A bad ?lang= value is ignored, not stored.\n try {\n const q = new URLSearchParams(location.search).get('lang');\n if (q && matchLocale(q)) setLocale(activeLocale);\n } catch {\n /* no location / storage \u2014 non-fatal */\n }\n\n try {\n catalog = await fetchCatalog(activeLocale);\n } catch (err) {\n console.error('[i18n] failed to load catalog, falling back to English', err);\n activeLocale = DEFAULT_LOCALE;\n catalog = {};\n }\n\n if (activeLocale !== DEFAULT_LOCALE) {\n // Keep English around so a key missing from a translation renders in\n // English rather than as a raw key.\n try {\n fallbackCatalog = await fetchCatalog(DEFAULT_LOCALE);\n } catch (err) {\n console.error('[i18n] failed to load the English fallback catalog', err);\n fallbackCatalog = {};\n }\n } else {\n fallbackCatalog = catalog;\n }\n\n if (typeof document !== 'undefined') {\n document.documentElement.lang = activeLocale;\n }\n ready = true;\n return activeLocale;\n}\n\n/**\n * Brand nouns: the partner-configurable product vocabulary. Catalog strings\n * reference the product noun as `{nodeNoun}` (\"Mimic node\") and the vendor\n * word as `{vendor}` (\"Mimic\") so an MSSP-managed tenant can present its own\n * identity without a separate catalog. Resolution order per placeholder:\n * per-call params \u2192 the MSSP override (set by chrome branding on tenant\n * scope) \u2192 the locale's own `product.nodeNoun` / `product.vendor` entries.\n */\nlet brandNounOverrides: { nodeNoun?: string; vendor?: string; supportEmail?: string } = {};\n\n/** Set (or clear, by omitting) the MSSP brand-noun overrides. */\nexport function setBrandNouns(nouns: { nodeNoun?: string; vendor?: string; supportEmail?: string }): void {\n brandNounOverrides = { ...nouns };\n}\n\nfunction brandNoun(name: 'nodeNoun' | 'vendor' | 'supportEmail'): string | null {\n const override = brandNounOverrides[name];\n if (override) return override;\n const key = `product.${name}`;\n return catalog[key] ?? fallbackCatalog[key] ?? null;\n}\n\n/**\n * The product noun for direct use in code-built UI (section headings, labels\n * assembled in TS rather than via catalog strings). Same resolution as the\n * {nodeNoun} placeholder: MSSP override \u2192 locale default \u2192 \"Mimic node\".\n */\nexport function brandNodeNoun(): string {\n return brandNoun('nodeNoun') ?? 'Mimic node';\n}\n\n/**\n * Translate a key.\n *\n * Missing keys return the key itself, never an empty string: a visible\n * `nav.incidents` in the UI is an obvious bug report, whereas a blank label is\n * a mystery to whoever finds it.\n *\n * `params` interpolates `{name}` placeholders. `{nodeNoun}` and `{vendor}`\n * resolve even without params (see brand nouns above), so every existing\n * call site picks up the brand vocabulary with no signature change.\n */\nexport function t(key: string, params?: Record): string {\n const raw = catalog[key] ?? fallbackCatalog[key] ?? key;\n if (!params && raw.indexOf('{') === -1) return raw;\n return raw.replace(/\\{(\\w+)\\}/g, (whole, name: string) => {\n if (params && Object.prototype.hasOwnProperty.call(params, name)) {\n return String(params[name]);\n }\n if (name === 'nodeNoun' || name === 'vendor' || name === 'supportEmail') {\n return brandNoun(name) ?? whole;\n }\n return whole;\n });\n}\n\n/**\n * Translate a count-bearing phrase.\n *\n * The catalog stores plural variants as sibling keys `.one` and\n * `.other` (zh-Hans has no plural forms, so its catalogs simply give both\n * keys the same value \u2014 parity checking stays trivial). The count is exposed to\n * the template as `{n}` alongside any extra params:\n *\n * tn('body.nodesCount', 3) // \"3 nodes\" from \"{n} nodes\"\n * tn('body.nodesCount', 1) // \"1 node\" from \"{n} node\"\n *\n * en/es only distinguish one/other, so CLDR plural categories beyond that are\n * deliberately out of scope until a locale needs them.\n */\nexport function tn(key: string, n: number, params?: Record): string {\n const variant = n === 1 ? `${key}.one` : `${key}.other`;\n return t(variant, { n: n.toLocaleString(activeLocale), ...params });\n}\n\n/** Test seam: load a catalog directly instead of fetching it. */\nexport function __setCatalogForTest(locale: Locale, entries: Catalog, fallback: Catalog = {}): void {\n activeLocale = resolvedLocale = locale;\n catalog = entries;\n fallbackCatalog = fallback;\n ready = true;\n}\n", "/**\n * The Demo mode flags this browser keeps (DASH-2022), apart from the DOM\n * and storage wrapper so they can be tested alone; utils/MaskMode drives\n * them.\n */\n\n// The boot-time hint: whether Demo mode was on at the last load of any tab\n// in this browser. Main reads it before the profile arrives, so a cached\n// partner chrome is never painted first while Demo mode is on.\nexport const MASK_MODE_HINT_KEY = 'dashcam_mask_mode_on';\n// This tab's own copy, for the keys kept per tab (sessionStorage).\nconst TAB_KEY = 'dashcam_mask_mode_seen';\n// Set once a staff member who may use Demo mode has loaded in this browser.\n// Demo mode can be turned on from another device, so for them no cached\n// chrome is painted before the profile says whether it is on.\nconst STAFF_KEY = 'dashcam_mask_mode_staff';\n\nfunction read(store: Storage, key: string): boolean | null {\n\ttry {\n\t\tconst v = store.getItem(key);\n\t\treturn v === null ? null : v === '1';\n\t} catch {\n\t\treturn null;\n\t}\n}\n\n/** Writes the flag and reads it back: false when the browser keeps no storage. */\nfunction write(store: Storage, key: string, on: boolean): boolean {\n\ttry {\n\t\tstore.setItem(key, on ? '1' : '0');\n\t\treturn store.getItem(key) === (on ? '1' : '0');\n\t} catch {\n\t\treturn false;\n\t}\n}\n\n/** True when the last load in this browser had Demo mode on. */\nexport function maskModeHinted(): boolean {\n\treturn read(localStorage, MASK_MODE_HINT_KEY) === true;\n}\n\n/**\n * True when the boot must not paint cached chrome before the profile:\n * Demo mode was on at the last load, or someone who may turn it on (from\n * any device) uses this browser.\n */\nexport function maskModeBootStandard(): boolean {\n\treturn maskModeHinted() || read(localStorage, STAFF_KEY) === true;\n}\n\n/**\n * Called once the profile has loaded. When Demo mode changed since this\n * browser or this tab last loaded (turned on from another device, or\n * expired), calls clear; returns true when the page must reload so the\n * boot (branding first) runs under the new state.\n */\nexport function reconcileMaskModeState(active: boolean, staff: boolean, clear: () => void): boolean {\n\tif (staff) write(localStorage, STAFF_KEY, true);\n\tconst tab = read(sessionStorage, TAB_KEY);\n\t// No hint is a browser that has not loaded since this shipped: Demo\n\t// mode was off. A new tab has no flag of its own and goes by the hint.\n\tconst was = read(localStorage, MASK_MODE_HINT_KEY) ?? false;\n\tif ((tab ?? was) !== active || was !== active) clear();\n\twrite(sessionStorage, TAB_KEY, active);\n\t// Reload only when the hint is now stored, so a browser that keeps\n\t// none cannot loop.\n\tif (was === active) return false;\n\treturn write(localStorage, MASK_MODE_HINT_KEY, active);\n}\n\n/** Records a change made here; the hint's storage event reloads the other tabs. */\nexport function recordMaskModeState(active: boolean): void {\n\twrite(sessionStorage, TAB_KEY, active);\n\twrite(localStorage, MASK_MODE_HINT_KEY, active);\n}\n\n/**\n * Removes every key of store named prefix\u2026, bare or under one env prefix\n * (\"prod:prefix\u2026\"), the two spellings the storage wrapper's scoped keys take.\n */\nexport function removeKeysWithPrefix(store: Storage, prefix: string): void {\n\ttry {\n\t\tconst doomed: string[] = [];\n\t\tfor (let i = 0; i < store.length; i++) {\n\t\t\tconst k = store.key(i);\n\t\t\tif (k === null) continue;\n\t\t\tconst at = k.indexOf(prefix);\n\t\t\tif (at === 0 || (at > 0 && k.indexOf(':') === at - 1)) doomed.push(k);\n\t\t}\n\t\tfor (const k of doomed) store.removeItem(k);\n\t} catch {\n\t\t// No storage: nothing was kept.\n\t}\n}\n\n/**\n * Removes every key a per-key marker names: each in the marker's own scope\n * (\"prod:copyStoreKey:hostname\" removes \"prod:hostname\"), and the bare key\n * a scoped read falls back to. Each marker seen is removed BEFORE its value:\n * a writer stores the value and then marks it again (ButtonUtils), so a\n * value written in another tab after this removal keeps a marker for the\n * next clear. A marker written meanwhile and not seen is left as it is.\n */\nexport function clearMarkedKeys(store: Storage, prefix: string): void {\n\ttry {\n\t\tconst marked: [string, string][] = [];\n\t\tfor (let i = 0; i < store.length; i++) {\n\t\t\tconst k = store.key(i);\n\t\t\tif (k === null) continue;\n\t\t\tconst at = k.indexOf(prefix);\n\t\t\tif (at === 0 || (at > 0 && k.indexOf(':') === at - 1)) marked.push([k, k.slice(0, at) + k.slice(at + prefix.length)]);\n\t\t}\n\t\tfor (const [marker, value] of marked) {\n\t\t\tstore.removeItem(marker);\n\t\t\tstore.removeItem(value);\n\t\t\tconst bare = value.slice(value.indexOf(':') + 1);\n\t\t\tif (bare !== value && !marker.startsWith(prefix)) store.removeItem(bare);\n\t\t}\n\t} catch {\n\t\t// No storage: nothing was kept.\n\t}\n}\n\n// Set once this tab starts a reload for a Demo mode change (its kept names\n// already cleared): from then on nothing is stored under a key that clear\n// empties (sealedKeys, sealedPrefixes) or in the chrome caches, so a request\n// that settles late or a click before the page goes cannot carry a name of\n// the old state into the new one, whose boot clears nothing (this tab's flag\n// already records the new state). Every other key (the sign-in token, the\n// locale) is still stored. A reload the user cancels (a \"Leave site?\"\n// prompt) leaves the page running with these keys sealed until it reloads.\nlet sealed = false;\nconst sealedKeys = new Set();\nconst sealedPrefixes: string[] = [];\n\n/** Stops every later store under keys (or a key starting with one of prefixes) in this tab. */\nexport function sealClientState(keys: Iterable, prefixes: readonly string[]): void {\n\tsealed = true;\n\tfor (const k of keys) sealedKeys.add(k);\n\tsealedPrefixes.push(...prefixes);\n}\n\n/** True once a Demo mode reload has started in this tab. */\nexport function clientStateSealed(): boolean {\n\treturn sealed;\n}\n\n/** True when a store under key (unscoped) must not land: see sealed. */\nexport function clientKeySealed(key: string): boolean {\n\treturn sealed && (sealedKeys.has(key) || sealedPrefixes.some(p => key.startsWith(p)));\n}\n\n/**\n * The rest of every key that starts with prefix, bare or after one scope\n * segment (\"prod:\"), as removeKeysWithPrefix matches them: the keys a\n * per-key marker names (\"copyStoreKey:hostname\" gives \"hostname\").\n */\nexport function keysAfterPrefix(store: Storage, prefix: string): string[] {\n\tconst out: string[] = [];\n\ttry {\n\t\tfor (let i = 0; i < store.length; i++) {\n\t\t\tconst k = store.key(i);\n\t\t\tif (k === null) continue;\n\t\t\tconst at = k.indexOf(prefix);\n\t\t\tif (at === 0 || (at > 0 && k.indexOf(':') === at - 1)) out.push(k.slice(at + prefix.length));\n\t\t}\n\t} catch {\n\t\t// No storage: nothing was kept.\n\t}\n\treturn out;\n}\n\n// The address params a Demo mode reload keeps: which page, tenant and\n// sub-tenant scope, and ids or settings a page needs to load. None holds a\n// name. Every other param may hold one typed or clicked under the old\n// state (Events search text and value filters, report and app names,\n// deep-link seeds), and is dropped with the hash.\nexport const MASK_MODE_KEPT_PARAMS = [\n\t'page', 'tenantId', 'subtenantId', 'allSubtenants',\n\t'nodeId', 'deltaId', 'reportId', 'simulationId', 'tab', 'tpTab', 'lang',\n];\n\n/** The address a reload after a Demo mode change goes to (see MASK_MODE_KEPT_PARAMS). */\nexport function maskModeReloadURL(href: string): string {\n\tconst u = new URL(href);\n\tconst kept = new URLSearchParams();\n\tfor (const k of MASK_MODE_KEPT_PARAMS) {\n\t\tfor (const v of u.searchParams.getAll(k)) kept.append(k, v);\n\t}\n\tu.search = kept.toString();\n\tu.hash = '';\n\treturn u.toString();\n}\n\n/** The history.state key that records the Demo mode state an entry was made under. */\nexport const MASK_MODE_HISTORY_KEY = 'dcMaskMode';\n\n/**\n * state with the Demo mode state it is made under recorded (a plain object\n * or none; any other state, which no page uses, is left as it is).\n */\nexport function stampHistoryState(state: unknown, active: boolean): unknown {\n\tif (state === null || state === undefined) return { [MASK_MODE_HISTORY_KEY]: active };\n\tif (typeof state === 'object' && !Array.isArray(state)) return { ...(state as Record), [MASK_MODE_HISTORY_KEY]: active };\n\treturn state;\n}\n\n/**\n * True when a load restored from the history (Back or Forward) comes from\n * an entry made under another Demo mode state, so its address can hold\n * names of that state: one stamped otherwise, or, while Demo mode is on,\n * one never stamped.\n */\nexport function maskModeHistoryStale(navType: string | undefined, state: unknown, active: boolean): boolean {\n\tif (navType !== 'back_forward') return false;\n\tconst stamp = state !== null && typeof state === 'object' ? (state as Record)[MASK_MODE_HISTORY_KEY] : undefined;\n\treturn typeof stamp === 'boolean' ? stamp !== active : active;\n}\n\n/** The query flag of a boot that must hold the standard chrome. */\nexport const STANDARD_BOOT_PARAM = 'standardChrome';\n\n/**\n * The address a boot that painted cached chrome reloads at to hold the\n * standard chrome: the Demo mode reload address (maskModeReloadURL, so no\n * name kept in the old address survives) with the flag.\n */\nexport function maskModeStandardBootURL(href: string): string {\n\tconst u = new URL(maskModeReloadURL(href));\n\tu.searchParams.set(STANDARD_BOOT_PARAM, '1');\n\treturn u.toString();\n}\n\n/**\n * The state to reload into after a re-check of GET /api/mask-mode, or null\n * when the server's answer matches the state the page loaded under or is\n * not a state at all.\n */\nexport function maskModeRecheckChange(booted: boolean, body: unknown): boolean | null {\n\tconst active = (body as { active?: unknown } | null)?.active;\n\treturn typeof active === 'boolean' && active !== booted ? active : null;\n}\n\n/**\n * What the re-check does when GET /api/mask-mode fails. Refused (apiFetch\n * rejects a 403 as 'forbidden'): Demo mode is no longer this caller's, the\n * role was taken away, and the server treats it as off, so a page loaded\n * with it on reloads out of it ('reload') and one loaded off stops asking\n * ('stop'), since asking again would only add refusals to the audit.\n * Anything else: the next check tries again ('retry').\n */\nexport function maskModeRecheckFailure(booted: boolean, err: unknown): 'reload' | 'stop' | 'retry' {\n\tif ((err as Error | null)?.message !== 'forbidden') return 'retry';\n\treturn booted ? 'reload' : 'stop';\n}\n", "import { DiversityKeyType } from \"../models/DriftReport\";\nimport type { Environment } from \"../stores/EnvironmentStore\";\nimport { MessageType } from \"./Header\";\nimport { clientKeySealed } from \"./MaskModeState\";\nimport { AllPages, SidebarViews } from \"./Page\";\n\nexport interface LocalStorageSchema {\n // settings\n env: Environment;\n // management-plane origin config, served by /api/env/environment and\n // cached for the pre-auth paths (see utils/MPHosts.ts)\n mpAppBaseURL: string;\n\n // navigation\n currentPage: AllPages;\n sidebarState: SidebarViews;\n defaultTenantId: string;\n\n // selection\n selectedTenantId: string;\n selectedSubtenantId: string;\n selectedNodeId: string;\n selectedNodeIds: string[];\n selectedTimeIntervals: { nodeId: string; times: Date[] } | null;\n selectedReportId: string;\n selectedDriftReportId: string;\n selectedChangeReportId: string;\n changeReportsSubtenantFilter: boolean;\n selectedSMBReportId: string;\n selectedAclSimulationId: string;\n selectedZammadOrganizationId: string;\n selectedZammadTicketId: string;\n selectedZammadTab: \"tickets\" | \"organizations\";\n selectedReportName: string;\n selectedReport: string;\n incidentType: string;\n\n // tables\n search: string;\n // nodesShowAll is gone: the Nodes page's \"Show All\" checkbox hid every\n // node with no telemetry, which made the headline node count depend on\n // the viewer's browser rather than on the fleet. The roster is shown in\n // full now. Stale values may linger in a browser's localStorage; nothing\n // reads them.\n // nodesHideArchived / tenantProfilingHideArchived were the two page-local\n // archived toggles. Both are gone (ENG-4129): archived scope is the\n // show-archived-nodes user setting now, so the two pages can't disagree\n // with each other or with the server-side scope. Stale values may linger\n // in a browser's localStorage; nothing reads them.\n nodesUseCache: boolean;\n reportsEnableRowColor: boolean;\n exesShowDetails: boolean;\n svcsShowDetails: boolean;\n notificationsShowOpen: boolean;\n notificationsShowTriage: boolean;\n notificationsShowClosed: boolean;\n notificationsRangePreset: string;\n // Notifications page time-window (preset dropdown) \u2014 independent from the\n // AD reports' window (tenantAdWindow*). Defaults to \"30d\".\n notifWindowPreset: string;\n notifWindowFromMs: number;\n notifWindowToMs: number;\n notificationsSummaryCollapsed: boolean;\n incidentsSummaryCollapsed: boolean;\n driftSummaryCollapsed: boolean;\n adUsnSummaryCollapsed: boolean;\n changeReportSummaryCollapsed: boolean;\n changeReportActivityCollapsed: boolean;\n changeReportTableCollapsed: boolean;\n mpSummaryCollapsed: boolean;\n // AI Visibility: the summary shell, and each table card by element id\n // (avQueriesCard / avChecksCard / avFindingsCard).\n avSummaryCollapsed: boolean;\n avCardCollapsed: Record;\n // Site Traffic's summary shell. Its table cards share avCardCollapsed:\n // they moved from AI Visibility with their ids (ENG-4695).\n stSummaryCollapsed: boolean;\n cveFleetSummaryCollapsed: boolean;\n // The Admin Performance tab's cards, by element id\n // (apiPerfRegressionsCard / apiPerfRoutesCard / apiPerfCompareCard).\n apiPerfCardCollapsed: Record;\n bcSummaryCollapsed: boolean;\n // Per-indicator risk-gauge overrides \u2014 weights, plus the editable Min\n // (floor) and Max (ceiling) ceilings. All client-side what-if knobs.\n adUsnNotableWeights: Record;\n adUsnNotableFloors: Record;\n adUsnNotableMaxes: Record;\n adEventCatalogSummaryCollapsed: boolean;\n adEventCatalogNotableWeights: Record;\n adEventCatalogNotableFloors: Record;\n adEventCatalogNotableMaxes: Record;\n adSchedTaskSummaryCollapsed: boolean;\n adSchedTaskNotableWeights: Record;\n adSchedTaskNotableFloors: Record;\n adSchedTaskNotableMaxes: Record;\n adKerberosSummaryCollapsed: boolean;\n adServiceChangeSummaryCollapsed: boolean;\n adSessionChangeSummaryCollapsed: boolean;\n adLdapChangeSummaryCollapsed: boolean;\n adGroupChangeSummaryCollapsed: boolean;\n adGroupChangeNotableWeights: Record;\n adGroupChangeNotableFloors: Record;\n adGroupChangeNotableMaxes: Record;\n adDCSyncSummaryCollapsed: boolean;\n adDomainControllersSummaryCollapsed: boolean;\n adInventorySummaryCollapsed: boolean;\n adInventoryCardsCollapsed: Record;\n adPermissionsSummaryCollapsed: boolean;\n adPermissionsCardsCollapsed: Record;\n zammadOrganizationMasked: boolean;\n applicationsSubtenantFilter: boolean;\n applicationsSummaryCollapsed: boolean;\n kevSummaryCollapsed: boolean;\n executablesSubtenantFilter: boolean;\n servicesSubtenantFilter: boolean;\n nodeHealthSubtenantFilter: boolean;\n driftReportsSubtenantFilter: boolean;\n missingPatchesSubtenantFilter: boolean;\n eventsSubtenantFilter: boolean;\n reportsSubtenantFilter: boolean;\n // Tenant-wide Active Directory reports \u2014 single shared Include-All-Sub-Tenants\n // toggle; settable from any tenant AD report (and the summary) and applied to\n // all of them, so the scope never differs report to report.\n tenantAdSubtenantFilter: boolean;\n // Tenant-wide Active Directory reports \u2014 single shared per-node filter\n // (\"\" = all nodes in the subtenant scope, else a node_id). Settable from\n // any tenant AD page; the Nodes-page \"AD Events\" link deep-links here with\n // a node preselected. A node selection overrides the subtenant scope.\n tenantAdNodeFilter: string;\n // Tenant AD Event Catalog \"Window\" control: preset (\"all\"|\"24h\"|\"7d\"|\"30d\"|\n // \"90d\"|\"custom\") + the custom interval bounds (epoch ms) when preset=custom.\n tenantAdWindowPreset: string;\n tenantAdWindowFromMs: number;\n tenantAdWindowToMs: number;\n // Tenant Network report \u2014 same filter trio as the tenant AD reports:\n // Include-All-Sub-Tenants toggle, per-node filter (\"\" = the subtenant\n // scope, else a node_id; the Nodes-page \"Network Events\" link deep-links\n // here), and the sticky \"Window\" preset + custom bounds.\n tenantNetSubtenantFilter: boolean;\n tenantNetNodeFilter: string;\n tenantNetWindowPreset: string;\n tenantNetWindowFromMs: number;\n tenantNetWindowToMs: number;\n // Set once a stored \"All Events\" preset has been moved to the Last 24\n // Hours default (DASH-2048), so a later choice of All Events sticks.\n tenantNetWindowDefault24h: boolean;\n\n // trees\n showBadges: boolean;\n showTooltips: boolean;\n showGoodProcesses: boolean;\n showGoodKeys: boolean;\n procTreeSearchField: string;\n\n\n // reports\n lsSideBySideToggle: boolean;\n lsWideViewToggle: boolean;\n lsLargeCardsToggle: boolean;\n iiAutoUploadToggle: boolean; // deprecated?\n\n // admin \u2014 the per-browser Show Cluster Stats / Callers / Logs / Disk\n // Usage / Developer / SQL toggles. Profile no longer sets them: the\n // Sidebar panel replaced them (users.sidebar_hidden). They are still read\n // once, for a user who has never used that panel, so the sidebar they\n // had is the sidebar they keep (SidebarOrder.legacyHiddenEntries).\n showClusterStats: boolean;\n showCallers: boolean;\n showLogs: boolean;\n showDiskUsage: boolean;\n showDeveloper: boolean;\n showSql: boolean;\n\n // constellation\n /** Constellation view toggles, both pages: hide the Windows / Mimic application\n * hubs with their executables and resources, and the application-name\n * labels when the graph is too dense to read them. */\n constellationShowWindows: boolean;\n constellationShowMimic: boolean;\n constellationShowAppNames: boolean;\n /** Node-scoped constellation window (ENG-3410) \u2014 same shared\n * WindowFilter contract the AD reports use. */\n nodeConstellationWindowPreset: string;\n nodeConstellationWindowFromMs: number;\n nodeConstellationWindowToMs: number;\n /** X-Ray window (DASH-1808), same WindowFilter contract. */\n nodeComponentProfileWindowPreset: string;\n nodeComponentProfileWindowFromMs: number;\n nodeComponentProfileWindowToMs: number;\n\n // drift exes\n cxSideBySide: boolean\n cxHiddenCols: string[];\n\n // drift reports\n driftOpShowRead: boolean;\n driftOpShowWrite: boolean;\n driftOpShowExecute: boolean;\n driftOpShowInvasive: boolean;\n diversityNodeThreshold: number;\n diversityMaxBackupAgeHours: number;\n diversityAppNormId: string;\n\n // Disk Usage\n diskUsageShowBy: \"tenant\" | \"node\";\n\n cxTagShowNew: boolean;\n cxTagShowDup: boolean;\n cxTagShowPatched: boolean;\n diversityKeyType: DiversityKeyType;\n\n // messages (plain text, never HTML: Header.enableMessageBar escapes them)\n pendingRootMessage: string;\n pendingRootMessageType: MessageType;\n pendingReportMessage: string;\n pendingReportMessageType: MessageType;\n pendingExplorerMessage: string;\n pendingExplorerMessageType: MessageType;\n\n // sql\n sqlChartType: string; // e.g. \"stacked-column\", \"line\", \"table\"\n sqlChart3D: boolean;\n sqlTzMode: 'utc' | 'local'; // SQL viewer timezone toggle for date/time columns\n biTimeRange: unknown; // global time picker state (bi/TimeRange.TimeRangeState), sanitized on read\n biPinnedFilters: unknown; // pinned filter pills (bi/Filters.FilterPill[]), shared across pages\n biFilters: unknown; // this page's filter pills\n biSearch: unknown; // search-bar KQL text\n biFiltersDiscover: unknown; // the Discover page's filter pills\n biSearchDiscover: unknown; // the Discover page's search text\n biDiscoverView: unknown; // the Discover page's data view id\n biDiscoverColumns: unknown; // Discover columns per data view: Record\n biDiscoverHist: unknown; // Discover histogram per data view: Record\n biFiltersVisualize: unknown; // the Visualize page's filter pills\n biSearchVisualize: unknown; // the Visualize page's search text\n biVisualizeView: unknown; // the Visualize page's data view id\n biVisualizeSpecs: unknown; // Visualize chart specs per data view: Record\n biVisualizeDrop: unknown; // one-shot: a field Discover sent to Visualize ({ viewId, field })\n biFiltersDashboard: unknown; // the Dashboards page's filter pills\n biSearchDashboard: unknown; // the Dashboards page's search text\n biOpenSaved: unknown; // one-shot: a saved visualization or search to open ({ kind, id })\n biOpenDashboard: unknown; // one-shot: the dashboard another page opens ({ id, edit })\n biDashboardDrafts: unknown; // unsaved dashboard edits per id ({ title, folder, tags, shared, spec, at }), until saved or discarded\n biPanelEdit: unknown; // one-shot: a dashboard panel to add or edit in Visualize ({ dashboardId, panelId, lens? })\n sqlHandoff: unknown; // one-shot: a query another page opened in SQL ({ engine, sql, pills, search })\n sqlTzFormat: 'iso' | 'iso-space' | 'short' | 'date' | 'locale' | 'relative'; // SQL viewer date format\n sqlImportTable: string;\n sqlImportKeys: string; // Stringified JSON array of PartitionKey\n sqlEngine: string; // e.g. \"postgres\" or \"clickhouse\"\n sqlQueryPostgres: string; // last selected query (postgres)\n sqlQueryClickhouse: string; // last selected query (clickhouse)\n}\n\nclass TypedLocalStorage> {\n // Injected by Main.ts once env is loaded (Main.ts guarantees env exists)\n // \n // Keys that should be per-tab (use sessionStorage instead of localStorage)\n private static readonly SESSION_KEYS = new Set([\n 'selectedReportId',\n 'selectedDriftReportId',\n 'selectedChangeReportId',\n 'selectedSMBReportId',\n 'selectedAclSimulationId',\n 'selectedZammadOrganizationId',\n 'selectedZammadTicketId',\n 'selectedZammadTab',\n 'selectedReportName',\n 'selectedReport',\n 'selectedNodeIds',\n 'selectedTimeIntervals'\n ]);\n\n // Injected by Main.ts once env is loaded (Main.ts guarantees env exists)\n private static envProvider:\n (() => (Environment | string | null | undefined)) | undefined;\n static registerEnvProvider(fn: () => (Environment | string | null | undefined)) {\n TypedLocalStorage.envProvider = fn;\n }\n\n /** Build \":\" prefix; empty when env unknown (shouldn't happen due to Main.ts). */\n private envPrefix(): string {\n const raw = TypedLocalStorage.envProvider?.();\n const s = (typeof raw === 'string' ? raw : (raw as any)) ?? '';\n return s ? `${String(s).toLowerCase()}:` : '';\n }\n\n /** Prefix every key with env, except the bootstrap key \"env\". */\n private keyFor(key: string): string {\n if (key === 'env') return key; // the only unscoped key\n return `${this.envPrefix()}${key}`;\n }\n\n get(key: K): T[K] | undefined {\n // Scoped read\n const k = this.keyFor(key as string);\n const engine = TypedLocalStorage.SESSION_KEYS.has(key as string) ? sessionStorage : localStorage;\n let raw = engine.getItem(k);\n if (raw === null) return undefined;\n try { return JSON.parse(raw) as T[K]; } catch { return undefined; }\n }\n\n\n getDefault(key: K, defaultValue: T[K]): T[K] {\n return this.get(key) ?? defaultValue;\n }\n\n // A key the Demo mode clear empties is not stored once a Demo mode\n // reload has started (MaskModeState.sealed).\n set(key: K, value: T[K]): void {\n if (clientKeySealed(key as string)) return;\n const k = this.keyFor(key as string); // default is scoped\n const engine = TypedLocalStorage.SESSION_KEYS.has(key as string) ? sessionStorage : localStorage;\n engine.setItem(k, JSON.stringify(value));\n }\n\n remove(key: K): void {\n const k = this.keyFor(key as string);\n const engine = TypedLocalStorage.SESSION_KEYS.has(key as string) ? sessionStorage : localStorage;\n engine.removeItem(k);\n }\n\n has(key: K): boolean {\n const k = this.keyFor(key as string);\n const engine = TypedLocalStorage.SESSION_KEYS.has(key as string) ? sessionStorage : localStorage;\n return engine.getItem(k) !== null || engine.getItem(key as string) !== null;\n\n }\n\n // ---- Dynamic helpers for keys NOT in LocalStorageSchema (env\u2011scoped by default) ----\n //\n // getScoped falls back to the bare, un-prefixed key so values written before\n // env-scoping still resolve. That fallback obliges set/remove to deal with\n // the legacy key too \u2014 otherwise it becomes a zombie: readable but not\n // removable, and permanently shadowing the scoped one.\n //\n // That is not hypothetical. `activeSubtenant:` is read here by\n // SubtenantStore.getSelectedId, and NodeStore.fetchPage turns it into\n // `&subtenantID=` on every nodes request. A leftover unscoped entry pinned\n // affected browsers to a stale subtenant in *every* env (the fallback is\n // env-agnostic), silently hiding nodes that lived outside it. Selecting\n // \"All\" in the switcher called removeScoped, which deleted only the scoped\n // key \u2014 so the bare one resurfaced on the next read and the pin survived\n // every reload. Clearing site data by hand was the only way out.\n getScoped(key: string): string | undefined {\n const k = this.keyFor(key);\n return localStorage.getItem(k) ?? localStorage.getItem(key) ?? undefined;\n }\n setScoped(key: string, value: string) {\n if (clientKeySealed(key)) return;\n const k = this.keyFor(key);\n localStorage.setItem(k, value);\n // Retire any legacy entry now that a scoped one exists, so it can't\n // resurface through the read-fallback if the scoped key is later removed.\n if (k !== key) localStorage.removeItem(key);\n }\n removeScoped(key: string) {\n const k = this.keyFor(key);\n localStorage.removeItem(k);\n // Remove what getScoped can read, not just what setScoped wrote.\n if (k !== key) localStorage.removeItem(key);\n }\n\n // Raw (unscoped) escape hatches if ever needed\n getUnsafe(key: string): string | undefined { return localStorage.getItem(key) ?? undefined; }\n setUnsafe(key: string, value: string) { if (!clientKeySealed(key)) localStorage.setItem(key, value); }\n removeUnsafe(key: string) { localStorage.removeItem(key); }\n}\n\nexport const storage = new TypedLocalStorage();\nexport const registerEnvProvider = TypedLocalStorage.registerEnvProvider;\n", "// utils/MPHosts.ts \u2014 the management plane's origins as the frontend knows them.\n//\n// The app embeds NO domain names. The backend owns the host configuration\n// (config/hosts.go, injected by the deployment; the server refuses to boot\n// without it) and serves what the SPA needs on the PUBLIC, unauthenticated\n// /api/env/environment endpoint:\n//\n// mpAppBaseURL \u2014 the MP console origin (login/logout UI, and the only\n// origin allowed to iframe us; mirrors FrameGuard).\n//\n// It is cached in localStorage on every successful EnvironmentStore load so\n// pre-auth paths work on later visits, and ensureMPHosts() fetches on demand\n// when the cache is cold. There is deliberately NO fallback: with a cold\n// cache and an unreachable backend, login redirection reports itself\n// unavailable rather than pointing at an invented host.\n\nimport { storage } from \"./Storage\";\n\n// rememberMPHosts persists the backend-served MP origin config. Called on\n// every successful /api/env/environment load, so a changed configmap\n// propagates on the next boot without a frontend rebuild.\nexport function rememberMPHosts(appBaseURL?: string): void {\n if (appBaseURL) storage.set(\"mpAppBaseURL\", appBaseURL.replace(/\\/+$/, \"\"));\n}\n\n// mpAppBaseURL returns the cached MP console origin, or null when the cache\n// is cold (call ensureMPHosts() first on async paths).\nexport function mpAppBaseURL(): string | null {\n return storage.get(\"mpAppBaseURL\") || null;\n}\n\n\nlet ensureInFlight: Promise | null = null;\n\n// ensureMPHosts populates the cache from /api/env/environment when cold.\n// Plain fetch, not apiFetch: the endpoint is public and this must work\n// pre-auth (it runs at the very start of the login flow).\nexport async function ensureMPHosts(): Promise {\n if (mpAppBaseURL() !== null) return;\n if (!ensureInFlight) {\n ensureInFlight = (async () => {\n try {\n const resp = await fetch(\"/api/env/environment\");\n if (!resp.ok) return;\n const data = await resp.json();\n rememberMPHosts(data.mpAppBaseURL);\n } catch {\n // Leave the cache cold; callers handle a null origin.\n }\n })();\n }\n try {\n await ensureInFlight;\n } finally {\n ensureInFlight = null;\n }\n}\n", "// Framing \u2014 helpers for running Dashcam inside the MP console's iframe.\n//\n// The console embeds our drift-report pages. That changes one thing\n// fundamentally: we cannot log a user in by redirecting, which is the only\n// way the SPA knows how to do it. Navigating the *frame* to the IdP fails\n// because the MP console origin serves frame-ancestors 'none' (this is\n// exactly why the page it replaces rendered as \"refused to connect\"), and\n// navigating the *top* window is blocked for a cross-origin frame unless the\n// user has interacted with us first. So the frame has to ask, then break out\n// on the click. See renderFramedSignIn.\n\nimport { t } from '../i18n/I18n';\nimport { mpAppBaseURL } from './MPHosts';\n\n// isFramed reports whether we're rendered inside any iframe.\n//\n// Reading window.top.location would throw cross-origin, so compare the window\n// objects instead \u2014 that's readable regardless of origin. The try/catch is\n// belt-and-braces for exotic embedders.\nexport function isFramed(): boolean {\n try {\n return window.top !== window.self;\n } catch {\n // A thrown SecurityError means a cross-origin ancestor exists, which\n // is itself proof we're framed.\n return true;\n }\n}\n\n// consoleOrigin returns the MP console origin allowed to embed the host we're\n// served from, or null when we can't establish one.\n//\n// Mirrors the server's frame-ancestors allowlist (middleware/frame_guard.go):\n// both sides use exactly the configured MP console origin, and neither ever\n// guesses one \u2014 the server decides who *may* frame us, this decides who we'll\n// break out to. Null (cold cache and unreachable backend) disables the\n// break-out rather than trusting an invented origin; startOAuthLogin warms\n// the cache before the framed path needs this.\nexport function consoleOrigin(): string | null {\n return mpAppBaseURL();\n}\n\n// trustedParentUrl returns the console URL to send the top window back to\n// after login, or null if we can't establish one we trust.\n//\n// The referrer is the only handle we have on the parent \u2014 its URL is not\n// readable cross-origin. It is also frequently trimmed: under\n// strict-origin-when-cross-origin (what the console sends) the referrer is the\n// bare origin, not the page path. So this usually resolves to the console root\n// rather than the drift-reports route the user started on. That is a real if\n// minor UX cost of not being able to see the parent, and the alternative \u2014\n// guessing their route \u2014 would be worse.\n//\n// Anything that isn't exactly the expected console origin returns null: we\n// never navigate the top window to an origin we didn't expect, or an attacker\n// who frames us could use our login flow as an open redirect.\nexport function trustedParentUrl(): string | null {\n const expected = consoleOrigin();\n if (!expected || !document.referrer) return null;\n\n try {\n const ref = new URL(document.referrer);\n return ref.origin === expected ? ref.href : null;\n } catch {\n return null;\n }\n}\n\n// renderFramedSignIn replaces the page with a sign-in prompt that breaks out\n// to `authorizeUrl` in the top window when clicked.\n//\n// Why a button and not an automatic redirect: a cross-origin iframe may only\n// navigate its top-level window with user activation. An automatic top\n// navigation on load is silently blocked, which would leave the user staring\n// at a frame that never resolves \u2014 the same blank box we're fixing. The click\n// supplies the activation.\n//\n// `topReturnUrl`, when given, is stashed for the callback to return the top\n// window to the console after login (see consumeFramedTopReturn).\nexport function renderFramedSignIn(authorizeUrl: string, topReturnUrl: string | null): void {\n if (topReturnUrl) {\n sessionStorage.setItem(FRAMED_TOP_RETURN_KEY, topReturnUrl);\n }\n\n document.body.replaceChildren();\n document.body.classList.add(\"framed-signin\");\n\n const panel = document.createElement(\"div\");\n panel.className = \"framed-signin__panel\";\n\n const message = document.createElement(\"p\");\n message.className = \"framed-signin__message\";\n message.textContent = t('body.signInToViewDriftReports');\n\n const button = document.createElement(\"button\");\n button.type = \"button\";\n button.className = \"framed-signin__button\";\n button.textContent = t('body.signIn');\n button.addEventListener(\"click\", () => {\n // Top-level, not the frame: the IdP refuses to be framed.\n window.open(authorizeUrl, \"_top\");\n });\n\n panel.append(message, button);\n document.body.append(panel);\n}\n\n// The path prefix the MP console's iframe points at. It is named after the\n// Elixir app this replaced; the console hardcodes it, so we serve it rather\n// than rename it. Retiring that pod without touching their repo is the whole\n// point \u2014 the name outliving the app is the price.\nconst DRIFT_REPORT_BETA_PREFIX = \"/drift-report-beta\";\n\nexport interface DriftReportBetaTarget {\n page: string;\n reportId?: string;\n}\n\n// driftReportBetaTarget maps the console's iframe URL to the page the SPA\n// should show, or null when we're not being served under that prefix.\n//\n// Mirrors the two routes the Elixir app served:\n// /drift-report-beta \u2192 the report list\n// /drift-report-beta/reports/ \u2192 that report's detail\n//\n// Anything else under the prefix falls back to the list rather than 404ing:\n// the console is the only caller, an unknown sub-path means we misread its\n// URL, and a list is a more useful answer than an error page.\nexport function driftReportBetaTarget(pathname: string): DriftReportBetaTarget | null {\n if (pathname !== DRIFT_REPORT_BETA_PREFIX &&\n !pathname.startsWith(DRIFT_REPORT_BETA_PREFIX + \"/\")) {\n return null;\n }\n\n const rest = pathname.slice(DRIFT_REPORT_BETA_PREFIX.length).replace(/^\\/+|\\/+$/g, \"\");\n const parts = rest ? rest.split(\"/\") : [];\n\n if (parts.length >= 2 && parts[0] === \"reports\" && parts[1]) {\n return { page: \"drift-report-time-range\", reportId: parts[1] };\n }\n return { page: \"drift-reports\" };\n}\n\n// applyDriftReportBetaPath rewrites the console's /drift-report-beta URL into\n// the ?page= form the rest of the app routes on, in place and without a\n// reload. No-op when we're not under that prefix.\n//\n// The rewrite normalizes the URL to \"/\" so nothing downstream has to know this\n// prefix exists \u2014 DeepLink, NavigationManager and the OAuth return-to all see\n// an ordinary Dashcam URL. It also means a login round-trip comes back to the\n// rewritten URL and still lands on the report, since the return-to is captured\n// after this runs.\n//\n// Existing query params win: if the console ever starts passing ?page= or\n// ?reportId= explicitly, an explicit parameter should beat one inferred from\n// the path.\nexport function applyDriftReportBetaPath(): void {\n const target = driftReportBetaTarget(window.location.pathname);\n if (!target) return;\n\n // Record that this load came from the console before the path is rewritten\n // away \u2014 pinConsoleEmbedTenant runs later (it needs the profile) and by then\n // the URL no longer says where we came from.\n sessionStorage.setItem(CONSOLE_EMBED_KEY, \"1\");\n\n // Drop our chrome. Applied here, at the earliest point we know this is an\n // embedded load, so the sidebar never paints and then vanishes.\n document.body.classList.add(\"console-embed\");\n\n const url = new URL(window.location.href);\n if (!url.searchParams.get(\"page\")) {\n url.searchParams.set(\"page\", target.page);\n }\n if (target.reportId && !url.searchParams.get(\"reportId\")) {\n url.searchParams.set(\"reportId\", target.reportId);\n }\n\n // The console was built to embed drift-report-beta, which read the tenant\n // as ?tenant_id= (snake_case). Dashcam's own param is ?tenantId=\n // (camelCase). Verified against the prod access log, the console requests\n // /drift-report-beta?tenant_id= \u2014 so canonicalize it to tenantId here,\n // before the tenant is resolved, so the console's selected tenant flows\n // through the normal path and wins over the profile default in\n // pinConsoleEmbedTenant. Without this the snake_case param is ignored and\n // an operator viewing another tenant sees their own (empty) home tenant.\n const snakeTenant = url.searchParams.get(\"tenant_id\");\n if (snakeTenant && !url.searchParams.get(\"tenantId\")) {\n url.searchParams.set(\"tenantId\", snakeTenant);\n }\n url.searchParams.delete(\"tenant_id\");\n\n url.pathname = \"/\";\n\n window.history.replaceState({}, document.title, url.toString());\n}\n\nconst CONSOLE_EMBED_KEY = \"dashcam_console_embed\";\n\n// isConsoleEmbed reports whether this load entered through the console's\n// /drift-report-beta path.\nexport function isConsoleEmbed(): boolean {\n return sessionStorage.getItem(CONSOLE_EMBED_KEY) === \"1\";\n}\n\n// pinConsoleEmbedTenant forces the tenant for a console-embedded load to the\n// one on the signed-in profile, and returns it (null when not applicable).\n//\n// This exists because of a cross-tenant bug, not for convenience. The console\n// passes no tenant in its iframe src, and Dashcam's normal fallback is\n// localStorage.selectedTenantId \u2014 which the frame shares with standalone\n// Dashcam, since they are same-origin. Left alone, the console's page for\n// tenant B renders tenant A's drift reports because A is what you last looked\n// at in another tab. Observed, not hypothetical.\n//\n// The profile's tenant is what drift-report-beta itself used (MpAuth reads\n// data.tenantID), so this matches the behavior being replaced rather than\n// inventing one. An explicit ?tenantId= still wins \u2014 if the console ever starts\n// passing one, it is a better answer than the profile's default.\n//\n// This is a display choice, not an authorization: the server enforces tenant\n// entitlement on every call regardless of what is pinned here.\nexport function pinConsoleEmbedTenant(profileTenantId: string | undefined | null): string | null {\n if (!isConsoleEmbed()) return null;\n\n const explicit = new URL(window.location.href).searchParams.get(\"tenantId\");\n if (explicit) return explicit;\n\n const tenantId = (profileTenantId || \"\").trim();\n if (!tenantId) return null;\n\n const url = new URL(window.location.href);\n url.searchParams.set(\"tenantId\", tenantId);\n window.history.replaceState({}, document.title, url.toString());\n return tenantId;\n}\n\nconst FRAMED_TOP_RETURN_KEY = \"oauth_framed_top_return\";\n\n// consumeFramedTopReturn returns the console URL stashed before a framed\n// sign-in, clearing it so a later normal login can't be hijacked back to it.\n// Re-validates the origin on the way out rather than trusting what we stored,\n// so a tampered sessionStorage entry can't turn the callback into an open\n// redirect.\nexport function consumeFramedTopReturn(): string | null {\n const stored = sessionStorage.getItem(FRAMED_TOP_RETURN_KEY);\n sessionStorage.removeItem(FRAMED_TOP_RETURN_KEY);\n if (!stored) return null;\n\n const expected = consoleOrigin();\n if (!expected) return null;\n\n try {\n return new URL(stored).origin === expected ? stored : null;\n } catch {\n return null;\n }\n}\n", "export function base64Utf8(s: string): string {\n const bytes = new TextEncoder().encode(s);\n // build binary string without spread (avoids stack/alloc issues on big strings)\n let bin = '';\n const chunk = 0x8000;\n for (let i = 0; i < bytes.length; i += chunk) {\n bin += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk) as any);\n }\n return btoa(bin);\n}", "import { base64Utf8 } from '../utils/Base64';\nimport { storage } from './Storage';\n\nconst BI_HDR = \"X-Dashcam-BI\";\nconst SID_KEY = \"bi.sid\";\nconst SEQ_KEY = \"bi.seq\";\nconst BREAD_KEY = \"bi.breadcrumbs\";\nconst STEP_KEY = \"bi.step\";\nconst EXCLUDE_KEYS = new Set([\"token\"]);\n\nexport function initBI() {\n // no-op for now; placeholder to run any future migrations or sampling toggles\n getSID(); getStep(); // ensure IDs exist before first request\n}\n\nexport function onRouteChange(newPath: string) {\n breadcrumbsPush(newPath);\n newStep();\n}\n\nexport function buildBIHeaderValue(): string {\n const payload = buildHeader();\n const json = JSON.stringify(payload);\n // base64 (ASCII-safe across proxies)\n return base64Utf8(json);\n}\n\nexport function headerName() { return BI_HDR; }\n\n/* Sometimes we\u2019ll want to tag a specific API call (\u201Cclicked upgrade\u201D)\n Example usage:\n await withBIAttrs({ action: \"clicked_upgrade\" }, () =>\n apiFetch('/api/tenants/.../upgrade', { method: 'POST' })\n);\n*/\nexport async function withBIAttrs(attrs: Record, fn: () => Promise): Promise {\n const prev = (window as any).__BI_ATTRS__;\n (window as any).__BI_ATTRS__ = { ...(prev || {}), ...attrs };\n try { return await fn(); }\n finally { (window as any).__BI_ATTRS__ = prev; } // restore\n}\n\n// NEW: turn \"/?tenantId=...&page=reports\" \u2192 \"reports\"\n// support page=, nodesPage=, mainPage=; default to \"nodes\" when missing.\nfunction extractPageKeyFromUrlish(input: string | URL): string {\n const url = (input instanceof URL)\n ? input\n : new URL(input, location.origin);\n\n const params = url.searchParams;\n const key =\n params.get('page') ||\n params.get('nodesPage') ||\n params.get('mainPage');\n\n return (key && key.trim()) ? key.trim() : 'nodes';\n}\n\n\nfunction uuid() {\n // lightweight UUIDv4; replace with your util if you prefer\n return crypto.randomUUID();\n}\n\nfunction getSID() {\n let sid = localStorage.getItem(SID_KEY);\n if (!sid) { sid = uuid(); localStorage.setItem(SID_KEY, sid); }\n return sid;\n}\n\nfunction getSeq() {\n const n = parseInt(localStorage.getItem(SEQ_KEY) || \"0\", 10) + 1;\n localStorage.setItem(SEQ_KEY, String(n));\n return n;\n}\n\nfunction getStep() {\n let s = sessionStorage.getItem(STEP_KEY);\n if (!s) { s = uuid(); sessionStorage.setItem(STEP_KEY, s); }\n return s;\n}\n\nfunction newStep() {\n const s = uuid();\n sessionStorage.setItem(STEP_KEY, s);\n return s;\n}\n\nfunction breadcrumbsPush(path: string) {\n const max = 10;\n const arr: string[] = JSON.parse(sessionStorage.getItem(BREAD_KEY) || \"[]\");\n const crumb = extractPageKeyFromUrlish(path);\n\n if (!arr.length || arr[arr.length - 1] !== crumb) {\n arr.push(crumb);\n while (arr.length > max) arr.shift();\n sessionStorage.setItem(BREAD_KEY, JSON.stringify(arr));\n }\n return arr;\n}\n\nfunction buildHeader(): any {\n const [vw, vh] = [window.innerWidth, window.innerHeight];\n const [sw, sh] = [screen.width, screen.height];\n const crumbs = JSON.parse(sessionStorage.getItem(BREAD_KEY) || \"[]\");\n const params = new URLSearchParams(location.search);\n const page = extractPageKeyFromUrlish(location.href); // \"nodes\" | \"reports\" | ...\n const utm: Record = {};\n for (const [k, v] of params.entries()) {\n if (!EXCLUDE_KEYS.has(k)) utm[k] = v;\n }\n const tenantId = storage.get(\"selectedTenantId\");\n return {\n v: 1,\n sid: getSID(),\n step: getStep(),\n seq: getSeq(),\n page,\n title: document.title || undefined,\n breadcrumbs: crumbs,\n ref: document.referrer || undefined,\n tenant_id: tenantId || undefined,\n utm: utm,\n ts_ms: Date.now(),\n vp: [vw, vh],\n scr: [sw, sh],\n lang: navigator.language,\n attrs: (window as any).__BI_ATTRS__ || undefined,\n };\n}\n\nfunction encodeHeader(obj: any): string {\n const json = JSON.stringify(obj);\n return base64Utf8(json);\n}\n\n// Patch fetch\nconst _fetch = window.fetch.bind(window);\nwindow.fetch = (input: RequestInfo | URL, init?: RequestInit) => {\n init = init || {};\n const hdrs = new Headers(init.headers || {});\n hdrs.set(BI_HDR, encodeHeader(buildHeader()));\n init.headers = hdrs;\n return _fetch(input, init);\n};\n\n// Axios example\nif ((window as any).axios) {\n (window as any).axios.interceptors.request.use((config: any) => {\n config.headers = config.headers || {};\n config.headers[BI_HDR] = encodeHeader(buildHeader());\n return config;\n });\n}\n\n\n// Minimal generic hook: observe URL changes\nlet lastPath = location.pathname + location.search + location.hash;\nnew MutationObserver(() => {\n const curr = location.pathname + location.search + location.hash;\n if (curr !== lastPath) { lastPath = curr; onRouteChange(location.pathname + location.search); }\n}).observe(document, { subtree: true, childList: true });\n// If you have a router (Next.js/React Router), prefer its official route-change event to call onRouteChange().\n", "// utils/Auth.ts\nimport { apiFetch } from \"./Api\";\nimport { consumeFramedTopReturn, isFramed, renderFramedSignIn, trustedParentUrl } from \"./Framing\";\nimport { ensureMPHosts } from \"./MPHosts\";\nimport { storage } from \"./Storage\";\n\nconst ACCESS_TOKEN_KEY = \"oauth_access_token\";\nconst EXPIRES_AT_KEY = \"oauth_expires_at_ms\";\n\nconst PKCE_VERIFIER_KEY = \"oauth_pkce_verifier\";\nconst OAUTH_STATE_KEY = \"oauth_state\";\nconst OAUTH_NONCE_KEY = \"oauth_nonce\";\nconst RETURN_TO_KEY = \"oauth_return_to\";\n\ntype OAuthConfig = {\n authUrl: string;\n clientId: string;\n redirectUrl: string;\n scope: string;\n};\n\ntype ExchangeResponse = {\n accessToken: string;\n expiresIn: number;\n tokenType: string;\n idToken?: string;\n};\n\ntype RefreshResponse = ExchangeResponse;\n\nfunction nowMs() { return Date.now(); }\n\nexport function getAccessToken(): string | undefined {\n return storage.getScoped(ACCESS_TOKEN_KEY);\n}\n\nfunction base64UrlEncodeString(value: string): string {\n const bytes = new TextEncoder().encode(value);\n let bin = \"\";\n for (const b of bytes) bin += String.fromCharCode(b);\n return btoa(bin).replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/g, \"\");\n}\n\nexport function getWebSocketAuthProtocols(): string[] | undefined {\n const tok = getAccessToken();\n if (!tok) return undefined;\n return [`dashcam.oauth.${base64UrlEncodeString(tok)}`];\n}\n\nexport function getExpiresAtMs(): number | undefined {\n const raw = storage.getScoped(EXPIRES_AT_KEY);\n if (!raw) return undefined;\n const n = Number(raw);\n return Number.isFinite(n) ? n : undefined;\n}\n\nexport function clearSession() {\n storage.removeScoped(ACCESS_TOKEN_KEY);\n storage.removeScoped(EXPIRES_AT_KEY);\n}\n\nexport function setSession(accessToken: string, expiresInSec: number) {\n storage.setScoped(ACCESS_TOKEN_KEY, accessToken);\n storage.setScoped(EXPIRES_AT_KEY, String(nowMs() + (expiresInSec * 1000)));\n}\n\nexport function hasValidAccessToken(skewSec: number = 30): boolean {\n const tok = getAccessToken();\n const exp = getExpiresAtMs();\n if (!tok || !exp) return false;\n return exp > (nowMs() + skewSec * 1000);\n}\n\n// hasMPSessionCookie reports whether the browser already has the MP\n// session cookie set (e.g. from the env-cookies dev workflow). The\n// cookie is HttpOnly when set by MP, so document.cookie won't see it\n// from JS \u2014 but the local dev workflow sets it via document.cookie\n// (no HttpOnly flag) so it IS visible. Either way, when present the\n// Dashcam backend's cookie-auth middleware will accept the request,\n// so we can skip the FE's OAuth-token-in-localStorage check and\n// avoid the login prompt.\nexport function hasMPSessionCookie(): boolean {\n return document.cookie.split(\";\").some(c => c.trim().startsWith(\"__Secure-signInSessionId=\"));\n}\n\n// ---------- PKCE helpers ----------\nfunction base64UrlEncode(bytes: ArrayBuffer): string {\n const bin = String.fromCharCode(...new Uint8Array(bytes));\n const b64 = btoa(bin);\n return b64.replace(/\\+/g, \"-\").replace(/\\//g, \"_\").replace(/=+$/g, \"\");\n}\n\nfunction randomString(bytesLen: number): string {\n const bytes = new Uint8Array(bytesLen);\n crypto.getRandomValues(bytes);\n return base64UrlEncode(bytes.buffer);\n}\n\nasync function sha256(input: string): Promise {\n const enc = new TextEncoder();\n return crypto.subtle.digest(\"SHA-256\", enc.encode(input));\n}\n\nasync function pkceChallenge(verifier: string): Promise {\n const digest = await sha256(verifier);\n return base64UrlEncode(digest);\n}\n\n// ---------- API calls ----------\nasync function fetchOAuthConfig(): Promise {\n const resp = await apiFetch(\"/api/oauth/config\", { method: \"GET\" });\n if (!resp.ok) throw new Error(`oauth config failed: ${resp.status}`);\n return resp.json();\n}\n\nasync function exchangeCode(code: string, codeVerifier: string, redirectUri: string): Promise {\n const resp = await apiFetch(\"/api/oauth/exchange\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n code,\n codeVerifier,\n redirectUri,\n }),\n });\n if (!resp.ok) {\n const t = await resp.text().catch(() => \"\");\n throw new Error(`oauth exchange failed: ${resp.status} ${t}`);\n }\n return resp.json();\n}\n\nlet refreshInFlight: Promise | null = null;\n\nexport async function refreshAccessToken(): Promise {\n if (refreshInFlight) return refreshInFlight;\n\n refreshInFlight = (async () => {\n const token = getAccessToken();\n if (!token) return null;\n\n // skipAuthRedirect: a 401 here must come back to us as a plain\n // response. Without it, apiFetch's own 401 handler re-enters\n // refreshAccessToken, gets handed this very in-flight promise, and\n // awaits it from inside itself \u2014 a deadlock that leaves the SPA on\n // the blank pre-auth overlay with a dead token still in localStorage.\n const resp = await apiFetch(\"/api/oauth/refresh\", {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${token}`,\n },\n }, { skipAuthRedirect: true });\n\n if (!resp.ok) {\n return null;\n }\n\n const data = (await resp.json()) as RefreshResponse;\n if (!data?.accessToken || !data?.expiresIn) return null;\n\n setSession(data.accessToken, data.expiresIn);\n return data.accessToken;\n })();\n\n try {\n return await refreshInFlight;\n } finally {\n refreshInFlight = null;\n }\n}\n\n// ---------- Login + callback ----------\nexport async function startOAuthLogin(): Promise {\n // Warm the MP origin cache first: the framed break-out below needs the\n // console origin, and the pre-hop decision needs the env config. Both\n // come from the backend \u2014 the app embeds no domain names.\n await ensureMPHosts();\n const cfg = await fetchOAuthConfig();\n console.debug(\"[oauth] starting login with config =\", cfg);\n\n const verifier = randomString(32);\n const challenge = await pkceChallenge(verifier);\n\n const state = randomString(16);\n const nonce = randomString(16);\n sessionStorage.setItem(PKCE_VERIFIER_KEY, verifier);\n sessionStorage.setItem(OAUTH_STATE_KEY, state);\n sessionStorage.setItem(OAUTH_NONCE_KEY, nonce);\n sessionStorage.setItem(RETURN_TO_KEY, window.location.href);\n\n const u = new URL(cfg.authUrl);\n u.searchParams.set(\"response_type\", \"code\");\n u.searchParams.set(\"client_id\", cfg.clientId);\n u.searchParams.set(\"redirect_uri\", cfg.redirectUrl);\n u.searchParams.set(\"scope\", cfg.scope);\n u.searchParams.set(\"code_challenge\", challenge);\n u.searchParams.set(\"code_challenge_method\", \"S256\");\n u.searchParams.set(\"state\", state);\n u.searchParams.set(\"nonce\", nonce);\n const authorizeUrl = u.toString();\n console.debug(\"[oauth] authorize url =\", authorizeUrl);\n\n // Inside the MP console's iframe we cannot redirect to log in: the IdP\n // serves frame-ancestors 'none', so navigating the frame there renders as\n // \"refused to connect\", and a cross-origin frame can't navigate the top\n // window without user activation. Ask instead, and break out on the click.\n // Every login path funnels through here (Main, Api, WebSocketHandler), so\n // this one guard covers them all.\n if (isFramed()) {\n console.debug(\"[oauth] framed \u2014 prompting for a top-level sign-in\");\n renderFramedSignIn(authorizeUrl, trustedParentUrl());\n return new Promise(() => {});\n }\n\n\n window.location.replace(authorizeUrl);\n return new Promise(() => {});\n}\n\nexport async function handleOAuthCallbackIfPresent(): Promise {\n const url = new URL(window.location.href);\n console.debug(\"[oauth] handleOAuthCallbackIfPresent::checking callback URL =\", url);\n const code = url.searchParams.get(\"code\");\n console.debug(\"[oauth] code from URL =\", code);\n if (!code) {\n console.debug(\"[oauth] no code in callback URL\");\n return false;\n }\n\n const returnedState = url.searchParams.get(\"state\") || \"\";\n const expectedState = sessionStorage.getItem(OAUTH_STATE_KEY) || \"\";\n\n if (!expectedState || returnedState !== expectedState) {\n sessionStorage.removeItem(OAUTH_NONCE_KEY);\n clearSession();\n throw new Error(\"oauth_state_mismatch\");\n }\n\n const verifier = sessionStorage.getItem(PKCE_VERIFIER_KEY) || \"\";\n if (!verifier) {\n sessionStorage.removeItem(OAUTH_NONCE_KEY);\n clearSession();\n throw new Error(\"missing_pkce_verifier\");\n }\n\n // Read expected nonce once (and clear it regardless)\n const expectedNonce = sessionStorage.getItem(OAUTH_NONCE_KEY) || \"\";\n sessionStorage.removeItem(OAUTH_NONCE_KEY);\n\n const cfg = await fetchOAuthConfig();\n const redirectUri = cfg.redirectUrl || `${window.location.origin}/auth/callback`;\n\n const data = await exchangeCode(code, verifier, redirectUri);\n\n // validate nonce in id_token\n // NOTE: This requires openid scope and that your exchange returns idToken.\n if (expectedNonce) {\n const idt = data.idToken || \"\";\n const payload = idt ? decodeJwtPayload(idt) : null;\n const actualNonce = (payload?.nonce ?? \"\") as string;\n if (!actualNonce || actualNonce !== expectedNonce) {\n clearSession();\n throw new Error(\"oauth_nonce_mismatch\");\n }\n }\n setSession(data.accessToken, data.expiresIn);\n\n // Clean up callback params\n url.searchParams.delete(\"code\");\n url.searchParams.delete(\"state\");\n url.searchParams.delete(\"session_state\");\n\n sessionStorage.removeItem(PKCE_VERIFIER_KEY);\n sessionStorage.removeItem(OAUTH_STATE_KEY);\n\n // Return to where we started\n const returnTo = sessionStorage.getItem(RETURN_TO_KEY);\n sessionStorage.removeItem(RETURN_TO_KEY);\n\n // A framed sign-in broke out to the top window to get here, so \"where we\n // started\" is the console, not us \u2014 send the top window back to it. This\n // has to be a real navigation: replaceState below only accepts a\n // same-origin URL and would throw on the console's. The session we just\n // established rides along, so the re-embedded frame renders authenticated.\n const framedTopReturn = consumeFramedTopReturn();\n if (framedTopReturn) {\n window.location.replace(framedTopReturn);\n return true;\n }\n\n window.history.replaceState({}, document.title, returnTo ?? url.toString());\n return true;\n}\n\n// --- Add helper somewhere in Auth.ts (near other helpers) ---\nfunction decodeJwtPayload(token: string): any | null {\n // JWT: header.payload.signature\n const parts = token.split(\".\");\n if (parts.length < 2) return null;\n\n // base64url \u2192 base64\n let b64 = parts[1].replace(/-/g, \"+\").replace(/_/g, \"/\");\n // pad\n while (b64.length % 4) b64 += \"=\";\n\n try {\n const json = atob(b64);\n return JSON.parse(json);\n } catch {\n return null;\n }\n}\n", "// static/ts/utils/Modal.ts\n\nexport type ModalButtonOpts = {\n label: string;\n className?: string;\n id?: string;\n icon?: {\n fontAwesome?: string;\n materialIcon?: string;\n after?: boolean;\n };\n onClick: () => void;\n afterRender?: (btn: HTMLButtonElement) => void;\n dismissOnClick?: boolean; // whether to auto\u2011hide after click\n primary?: boolean; // mark this as the primary action (for Cmd/Ctrl+Enter)\n};\n\n/**\n * A thin wrapper around a Bootstrap modal.\n * Pass it the modal container id (the outermost .modal element),\n * and it finds .modal-title, .modal-body, .modal-footer spots.\n */\nexport class Modal {\n private modalEl: HTMLElement;\n private $modal: any;\n private titleEl: HTMLElement;\n private descEl: HTMLElement | undefined;\n private bodyEl: HTMLElement;\n private footerEl: HTMLElement;\n private openerBeforeShow: HTMLElement | null = null;\n private onCmdEnter?: (e: KeyboardEvent) => void;\n\n constructor(modalId: string) {\n this.modalEl = document.getElementById(modalId)!;\n this.$modal = (window as any).$(this.modalEl);\n this.titleEl = this.modalEl.querySelector('.modal-title') as HTMLElement;\n this.descEl = this.modalEl.querySelector(\".modal-description\");\n this.bodyEl = this.modalEl.querySelector('.modal-body') as HTMLElement;\n this.footerEl = this.modalEl.querySelector('.modal-footer') as HTMLElement;\n }\n\n /** Sets the modal title. */\n public setTitle(html: string) {\n this.titleEl.innerHTML = html;\n }\n\n public setDescription(html: string) {\n if (!this.descEl) {\n console.error(\"Description element does not exist\");\n return;\n }\n this.descEl.innerHTML = html;\n }\n\n /** Sets the modal body. */\n public setBody(html: string) {\n this.bodyEl.innerHTML = html;\n }\n\n public setBodyHtml(html: HTMLElement) {\n this.bodyEl.innerHTML = \"\";\n this.bodyEl.appendChild(html);\n }\n\n public toggleBody(visible: boolean) {\n this.bodyEl.style.display = visible ? \"\" : \"none\";\n }\n\n /**\n * Replaces the footer with the given buttons.\n * Each button gets appended in order.\n */\n public setButtons(buttons: ModalButtonOpts[]) {\n this.footerEl.innerHTML = '';\n\n // \uD83D\uDC47 Detect the \"single Close button\" case\n const isSoloClose =\n buttons.length === 1 &&\n (buttons[0].label || '').trim().toLowerCase() === 'close';\n console.log('isSoloClose:', isSoloClose, 'buttons:', buttons);\n\n // Left-align for solo Close, otherwise let CSS (flex-end) handle it\n if (isSoloClose) {\n this.footerEl.style.justifyContent = 'flex-start';\n } else {\n this.footerEl.style.justifyContent = '';\n }\n\n buttons.forEach(({ label, id, icon, className, onClick, dismissOnClick, afterRender, primary }) => {\n const btn = document.createElement('button');\n btn.type = 'button';\n btn.className = className || 'btn btn-secondary';\n\n // Tag the actual primary so the keyboard handler can reliably find it\n if (primary || /\\bbtn-primary\\b/.test(btn.className)) {\n btn.setAttribute('data-primary', 'true');\n }\n if (id) {\n btn.id = id;\n }\n\n if (icon) {\n const i = document.createElement('i');\n if (icon.materialIcon) {\n i.className = `material-icons ${icon.after ? 'icon-margin-left' : ''}`;\n i.textContent = icon.materialIcon;\n } else if (icon.fontAwesome) {\n i.className = `${icon.after ? 'icon-margin-left' : 'icon-margin-right'} fas fa-solid ${icon.fontAwesome}`;\n i.textContent = '';\n }\n\n btn.append(label);\n if (icon.after) {\n btn.append(i);\n } else {\n btn.prepend(i);\n }\n } else {\n btn.append(label);\n }\n\n // Apply SOLO CLOSE styling directly on the button\n if (isSoloClose) {\n btn.style.backgroundColor = '#5a6672';\n btn.style.color = '#fff';\n btn.style.borderColor = '#5a6672';\n }\n\n btn.addEventListener('click', () => {\n onClick();\n if (dismissOnClick) this.hide();\n });\n\n this.footerEl.appendChild(btn);\n\n if (afterRender) afterRender(btn);\n });\n }\n\n /** Show the modal (backdrop static, Esc closes; \u2318/Ctrl+Enter = primary) */\n public show() {\n // remember who had focus before opening\n this.openerBeforeShow = document.activeElement as HTMLElement | null;\n\n // once it\u2019s fully hidden, restore focus\n this.$modal.one('hidden.bs.modal', () => {\n this.openerBeforeShow?.focus();\n this.openerBeforeShow = null;\n // safety: remove key handler if still attached\n if (this.onCmdEnter) {\n document.removeEventListener('keydown', this.onCmdEnter, true);\n }\n });\n\n this.$modal.modal({ backdrop: 'static', keyboard: true }); // (re)init; allow Esc to dismiss\n this.$modal.modal('show');\n\n // \u2318/Ctrl+Enter \u2192 click rightmost button in footer (primary)\n this.onCmdEnter = (e: KeyboardEvent) => {\n if (!(e.metaKey || e.ctrlKey) || e.key !== 'Enter' || e.altKey || e.shiftKey) return;\n // Prefer an explicitly marked primary, then .btn-primary, else the last\n // button that does not opt out (data-cmd-enter=\"skip\", e.g. a switch)\n const fallback = this.footerEl.querySelectorAll('button:not([data-cmd-enter=\"skip\"])');\n const primary =\n this.footerEl.querySelector('button[data-primary=\"true\"]') ||\n this.footerEl.querySelector('button.btn-primary') ||\n fallback[fallback.length - 1];\n\n if (!primary || primary.disabled) return;\n e.preventDefault();\n primary.click();\n };\n document.addEventListener('keydown', this.onCmdEnter, true);\n }\n\n /** Hide the modal */\n public hide() {\n // blur anything still focused inside so aria-hidden won't conflict\n const active = document.activeElement as HTMLElement | null;\n if (active && this.modalEl.contains(active)) {\n active.blur();\n }\n\n // remove key handler\n if (this.onCmdEnter) {\n document.removeEventListener('keydown', this.onCmdEnter, true);\n this.onCmdEnter = undefined;\n }\n\n this.$modal.modal('hide');\n }\n\n /** Returns the outer modal DOM element (the container). */\n public getElement(): HTMLElement {\n return this.modalEl;\n }\n}\n", "// FormatUtils.ts\nimport { t, getLocale } from '../i18n/I18n';\n\n/**\n * Locale-ordered numeric date. Only the component ORDER and separator come from\n * the active UI locale \u2014 the time portion is built separately and stays a\n * locale-invariant 24-hour clock, so English output is byte-identical to the\n * old hand-rolled `MM/DD/YYYY`:\n * en \u2192 07/18/2026\n * zh-Hans \u2192 2026/07/18\n * es \u2192 18/07/2026\n * We assemble via Intl.formatToParts (not toLocaleDateString) so the result\n * carries no locale comma/era and matches the previous zero-padded numeric look.\n */\nfunction localeDateParts(date: Date): string {\n try {\n return new Intl.DateTimeFormat(getLocale(), {\n year: 'numeric', month: '2-digit', day: '2-digit',\n }).formatToParts(date).map(p => p.value).join('');\n } catch {\n // Defensive: if Intl throws (ancient engine / bad locale), keep US order.\n const pad = (n: number) => String(n).padStart(2, '0');\n return `${pad(date.getMonth() + 1)}/${pad(date.getDate())}/${date.getFullYear()}`;\n }\n}\n\n/**\n * Locale-aware number formatting for DISPLAY (thousands separators, optional\n * fraction digits). Use this instead of a bare `.toLocaleString()`, which honors\n * the browser locale rather than the app's chosen UI language: es groups with\n * \".\" (1.234.567) where en/zh group with \",\" (1,234,567). Not for machine output\n * or values that get parsed back \u2014 those must stay locale-neutral.\n */\nexport function formatNumber(n: number, options?: Intl.NumberFormatOptions): string {\n try {\n return n.toLocaleString(getLocale(), options);\n } catch {\n return n.toLocaleString();\n }\n}\n\nexport function formatJSONInline(jsonString: string): string {\n try {\n const obj = JSON.parse(jsonString);\n const formattedJson = JSON.stringify(obj, null, 1)\n .replace(/\\n/g, '') // Remove newlines to make it single-line\n .replace(/\\s{2,}/g, ' ') // Replace multiple spaces with a single space\n .replace(/\\}$/, ' }'); // Add a space before the closing brace\n return formattedJson\n .replace(/\"/g, '"'); // Encode double quotes for HTML attribute compatibility\n } catch (e) {\n return jsonString; // Return original string if parsing fails\n }\n}\n\nexport function formatEpochToCustomString(epochMillis: number, showSeconds = true, showMilliseconds = true): string {\n const date = new Date(epochMillis);\n const pad = (num, size) => String(num).padStart(size, '0');\n\n const year = date.getFullYear();\n if (year < 2000) {\n return '---';\n }\n const datePart = localeDateParts(date); // locale-ordered; en stays MM/DD/YYYY\n\n const hours = pad(date.getHours(), 2);\n const minutes = pad(date.getMinutes(), 2);\n const seconds = pad(date.getSeconds(), 2);\n\n if (!showSeconds) {\n return `${datePart} ${hours}:${minutes}`;\n }\n\n let dateTimeString = `${datePart} ${hours}:${minutes}:${seconds}`;\n\n if (showMilliseconds) {\n const milliseconds = pad(date.getMilliseconds(), 3);\n dateTimeString += `.${milliseconds}`;\n }\n\n return dateTimeString;\n}\n\nexport function formatIsoStringToCustomString(isoString: string, showSeconds = true, showMilliseconds = true): string {\n const epochMillis = new Date(isoString).getTime();\n return formatEpochToCustomString(epochMillis, showSeconds, showMilliseconds);\n}\n\n/** A server's YYYY-MM-DD day as the viewer's locale writes it. A day, not\n * an instant, so read and written in UTC; anything else, a day the\n * calendar lacks (2026-02-30) included, is shown as sent. */\nexport function formatUtcDay(day: string): string {\n const d = /^\\d{4}-\\d{2}-\\d{2}$/.test(day) ? new Date(`${day}T00:00:00Z`) : null;\n // Date rolls an out-of-month day over (02-30 \u2192 03-02), so it must read back as sent.\n return d && !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === day\n ? d.toLocaleDateString(getLocale(), { dateStyle: 'medium', timeZone: 'UTC' }) : day;\n}\n\nexport function formatIsoStringToDateString(isoString: string): string {\n const date = new Date(isoString);\n // Format as YYYY-MM-DD (or your desired format)\n return date.toISOString().split('T')[0];\n}\n\n\n\n// Example usage\n// Output: July 2nd, 2024 4:26:08 AM\nexport function formatIsoStringToReadableString(isoString) {\n const date = new Date(isoString);\n\n const months = [\"January\", \"February\", \"March\", \"April\", \"May\", \"June\",\n \"July\", \"August\", \"September\", \"October\", \"November\", \"December\"];\n const suffixes = [\"th\", \"st\", \"nd\", \"rd\"];\n\n const day = date.getDate();\n const month = months[date.getMonth()];\n const year = date.getFullYear();\n\n const hour = date.getHours() % 12 || 12; // Convert 0 to 12 for 12AM\n const minutes = date.getMinutes().toString().padStart(2, '0');\n const seconds = date.getSeconds().toString().padStart(2, '0');\n const ampm = date.getHours() >= 12 ? 'PM' : 'AM';\n\n // Get the appropriate suffix for the day\n const suffix = (day % 10 === 1 && day !== 11) ? 'st' :\n (day % 10 === 2 && day !== 12) ? 'nd' :\n (day % 10 === 3 && day !== 13) ? 'rd' : 'th';\n\n return `${month} ${day}${suffix}, ${year} ${hour}:${minutes}:${seconds} ${ampm}`;\n}\n\n\nexport function formatDateRange(startNanoString, endNanoString) {\n // Convert nanoseconds to milliseconds for JavaScript Date\n const startDate = new Date(startNanoString / 1e6);\n const endDate = new Date(endNanoString / 1e6);\n\n // Format start date\n const startFormatted = formatFullDate(startDate);\n\n // Format end date based on whether it's the same day as the start date\n let endFormatted;\n if (startDate.toDateString() === endDate.toDateString()) {\n endFormatted = formatTimeOnly(endDate);\n } else {\n endFormatted = formatFullDate(endDate);\n }\n\n return `${startFormatted} to ${endFormatted}`;\n}\n\nfunction formatFullDate(date) {\n const year = date.getFullYear();\n const month = (date.getMonth() + 1).toString().padStart(2, '0'); // Months are zero-indexed\n const day = date.getDate().toString().padStart(2, '0');\n const hour = date.getHours() % 12 || 12;\n const minutes = date.getMinutes().toString().padStart(2, '0');\n const seconds = date.getSeconds().toString().padStart(2, '0');\n const ampm = date.getHours() >= 12 ? 'PM' : 'AM';\n return `${year}-${month}-${day} ${hour}:${minutes}:${seconds} ${ampm}`;\n}\n\nexport function formatTimeOnly(date) {\n const hour = date.getHours() % 12 || 12;\n const minutes = date.getMinutes().toString().padStart(2, '0');\n const seconds = date.getSeconds().toString().padStart(2, '0');\n const ampm = date.getHours() >= 12 ? 'PM' : 'AM';\n return `${hour}:${minutes}:${seconds} ${ampm}`;\n}\n\n\n\nexport function formatEpochToTimeString(epochMillis: number, showMilliseconds = true): string {\n const date = new Date(epochMillis);\n const pad = (num, size) => String(num).padStart(size, '0');\n\n const hours = pad(date.getHours(), 2);\n const minutes = pad(date.getMinutes(), 2);\n const seconds = pad(date.getSeconds(), 2);\n\n let timeString = `${hours}:${minutes}:${seconds}`;\n\n if (showMilliseconds) {\n const milliseconds = pad(date.getMilliseconds(), 3);\n timeString += `.${milliseconds}`;\n }\n\n return timeString\n}\n\nexport function formatIsoStringToTimeString(isoString: string, showMilliseconds = true): string {\n const epochMillis = new Date(isoString).getTime();\n return formatEpochToTimeString(epochMillis, showMilliseconds);\n}\n\nexport function createCollapsible(jsonString: string, containerId: string): void {\n const container = document.getElementById(containerId);\n if (!container) return;\n\n const button = document.createElement('button');\n button.innerHTML = t('body.showJson');\n button.classList.add('collapsible');\n\n const content = document.createElement('div');\n content.classList.add('content');\n\n const pre = document.createElement('pre');\n pre.textContent = jsonString;\n\n content.appendChild(pre);\n container.appendChild(button);\n container.appendChild(content);\n\n button.addEventListener('click', function () {\n this.classList.toggle('active');\n const content = this.nextElementSibling as HTMLElement;\n if (content.style.display === 'block') {\n content.style.display = 'none';\n } else {\n content.style.display = 'block';\n }\n });\n}\n\nexport function formatEpochToUTCString(epochMillis: number): string {\n const date = new Date(epochMillis);\n const pad = (num: number) => num.toString().padStart(2, '0');\n\n const year = date.getUTCFullYear();\n const month = pad(date.getUTCMonth() + 1); // getMonth() is zero-indexed\n const day = pad(date.getUTCDate());\n const hours = pad(date.getUTCHours());\n const minutes = pad(date.getUTCMinutes());\n const seconds = pad(date.getUTCSeconds());\n\n return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} UTC`;\n}\n\nexport function formatDuration(duration: number, isNanoseconds: boolean = true, neverShowMilliseconds = false): string {\n let totalSeconds;\n\n if (isNanoseconds) {\n totalSeconds = duration / 1e9; // Convert nanoseconds to seconds\n } else {\n totalSeconds = duration / 1000; // Convert milliseconds to seconds\n }\n\n const days = Math.floor(totalSeconds / 86400);\n const hours = Math.floor((totalSeconds % 86400) / 3600);\n const minutes = Math.floor((totalSeconds % 3600) / 60);\n const seconds = Math.floor(totalSeconds % 60);\n const milliseconds = Math.floor((totalSeconds * 1000) % 1000);\n\n // Format milliseconds as a three-digit number\n let formattedMilliseconds = milliseconds.toString().padStart(3, '0');\n\n // Construct the display string based on the total duration\n if (totalSeconds < 10 && !neverShowMilliseconds) {\n return `${seconds}.${formattedMilliseconds}s`;\n } else if (days > 0) {\n return `${days}d ${hours}h ${minutes}m`;\n } else if (hours > 0) {\n return `${hours}h ${minutes}m`;\n } else {\n return `${minutes > 0 ? `${minutes}m ` : ''}${seconds}s`;\n }\n}\n\nexport function formatElapsedDuration(ms: number): string {\n const totalSeconds = Math.floor(ms / 1000);\n const minutes = Math.floor(totalSeconds / 60);\n const seconds = totalSeconds % 60;\n\n const mm = String(minutes).padStart(2, '0');\n const ss = String(seconds).padStart(2, '0');\n\n if (minutes > 0) {\n return `${mm}:${ss}`;\n }\n\n if (seconds === 0) {\n return '<1s';\n }\n\n if (seconds < 10) {\n return `${String(seconds)}s`;\n }\n return `${ss}s`;\n}\n\nexport function formatElapsed(ms: number): string {\n const totalSeconds = Math.floor(ms / 1000);\n const minutes = Math.floor(totalSeconds / 60);\n const seconds = totalSeconds % 60;\n\n const mm = String(minutes).padStart(2, '0');\n const ss = String(seconds).padStart(2, '0');\n\n return `${mm}:${ss}`;\n}\n\nexport function formatToFlexibleRFC3339(nanoseconds: number): string {\n const ns = BigInt(nanoseconds);\n const ms = ns / BigInt(1_000_000);\n const date = new Date(Number(ms));\n // toISOString() gives \"2026-03-06T22:36:15.320Z\" \u2014 strip the .NNN millis and Z\n // so we can append full nanosecond-precision fractional instead.\n const isoBase = date.toISOString().replace(/\\.\\d{3}Z$/, '');\n const fractional = (ns % BigInt(1000000000)).toString().padStart(9, '0').replace(/0+$/, '');\n return fractional ? `${isoBase}.${fractional}Z` : `${isoBase}Z`;\n}\n\nexport function formatHourBucketRange(bucketStartIso: string): string {\n try {\n const start = new Date(bucketStartIso);\n if (Number.isNaN(start.getTime())) return bucketStartIso;\n\n const end = new Date(start.getTime() + 60 * 60 * 1000);\n\n const pad = (n: number) => String(n).padStart(2, '0');\n\n const datePart = localeDateParts(start); // locale-ordered; en stays MM/DD/YYYY\n\n const startTime =\n `${pad(start.getHours())}:${pad(start.getMinutes())}`;\n\n const endTime =\n `${pad(end.getHours())}:${pad(end.getMinutes())}`;\n\n return `${datePart} ${startTime}\u2013${endTime}`;\n } catch {\n return bucketStartIso;\n }\n}\n\n\nexport function applyRenderjsonReplacements(container: HTMLElement, replacements: Record void>): void {\n container.querySelectorAll('span.key').forEach((keySpan) => {\n const rawKey = keySpan.textContent || '';\n const key = rawKey.replace(/^\"(.*)\"$/, '$1').trim();\n\n if (key && replacements[key]) {\n // Find the value element immediately after the colon and whitespace spans\n // The structure is usually:\n // span.key -> (whitespace/text nodes) -> span.object.syntax (colon) -> (whitespace) -> value span (.string, .number, etc)\n\n let valueElement: HTMLElement | null = null;\n let next = keySpan.nextSibling;\n\n while (next && !valueElement) {\n if (next.nodeType === 1) { // element\n const el = next as HTMLElement;\n // The value usually has class string, number, boolean, null\n if (el.classList.contains('string') ||\n el.classList.contains('number') ||\n el.classList.contains('boolean') ||\n el.classList.contains('null')) {\n valueElement = el;\n break;\n }\n }\n next = next.nextSibling;\n }\n\n if (valueElement) {\n replacements[key](valueElement);\n }\n }\n });\n}\n\n// Normalize an image path (Windows)\n// - Remove surrounding quotes\n// - Normalize slashes to backslashes\n// - Strip device prefixes (\\\\?\\ and \\\\?\\Volume{GUID}\\)\n// - Strip drive letters (C:\\ \u2192 \\)\n// - Collapse duplicate backslashes\n// - Ensure exactly ONE leading backslash for local paths\n// - Preserve real UNC: \\\\server\\share\\...\n// utils/FormatUtils.ts (or PathUtils.ts \u2014 keep just ONE definition and import it everywhere)\nexport function normalizeImagePath(input: string): string {\n if (!input) return '';\n let s = String(input).trim();\n\n // strip wrapping quotes\n s = s.replace(/^[\"']|[\"']$/g, '');\n\n // POSIX paths are already native \u2014 every rewrite below is Windows-only\n // (see normalizePath in PathUtils.ts). Without this a Linux exe path\n // renders as \\usr\\bin\\chronyc.\n if (s.startsWith('/')) return s;\n\n // normalize slashes\n s = s.replace(/\\//g, '\\\\');\n\n // strip the \\\\?\\ device prefix: \\\\?\\C:\\x \u2192 C:\\x, \\\\?\\UNC\\srv\\share \u2192 \\\\srv\\share\n if (s.startsWith('\\\\\\\\?\\\\')) s = s.slice(4).replace(/^UNC\\\\/i, '\\\\\\\\');\n\n // strip a Volume{GUID}\\ prefix (left by the \\\\?\\ strip) if present\n s = s.replace(/^volume\\{[^}]+\\}\\\\?/i, '\\\\');\n\n // a real UNC path: exactly two leading backslashes, then a server name\n const unc = /^\\\\\\\\[^\\\\?]/.test(s);\n\n // drop drive letter (C:, D:, etc.)\n s = s.replace(/^[A-Za-z]:/, '');\n\n // collapse multiple backslashes, keeping a UNC path's leading pair\n s = (unc ? '\\\\' : '') + s.replace(/\\\\+/g, '\\\\');\n\n // enforce exactly one leading backslash\n if (!s.startsWith('\\\\')) s = '\\\\' + s;\n\n // trim a trailing backslash unless it's just root\n if (s.length > 1) s = s.replace(/\\\\$/, '');\n\n return s;\n}\n\n\n/**\n * Changes the opacity of an existing RGBA color string.\n *\n * @param rgbaColor - The original RGBA color string (e.g., \"rgba(19, 172, 60, 0.3)\").\n * @param newOpacity - The new opacity value, a number between 0 and 1.\n * @returns A new RGBA string with the updated opacity.\n */\nexport function changeRgbaOpacity(rgbaColor: string, newOpacity: number): string {\n // Use a regular expression to extract the R, G, and B values.\n const match = rgbaColor.match(/rgba?\\((\\s*\\d+\\s*,\\s*\\d+\\s*,\\s*\\d+\\s*),/);\n\n if (!match) {\n // Return the original color if it's not a valid RGB or RGBA string.\n return rgbaColor;\n }\n\n // Clamp the new opacity value to be between 0 and 1.\n const clampedOpacity = Math.max(0, Math.min(1, newOpacity));\n\n // Return the new RGBA string.\n return `rgba(${match[1].trim()}, ${clampedOpacity})`;\n}\n\nexport function capitalize(value: string): string {\n if (!value) return '';\n return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();\n}\n\n// utils/FormatUtils.ts\nexport function joinNonEmpty(\n parts: Array,\n sep = ' '\n): string {\n return parts\n .filter(p => p !== undefined && p !== null && p !== false && String(p).trim() !== '')\n .map(p => String(p))\n .join(sep);\n}\n\n// The String(s ?? '') coercion is deliberate: error call sites pass\n// `err.message` or `err?.message ?? err` straight in, and a thrown non-Error\n// (a string, an object, undefined) must render, not throw a second TypeError\n// from inside a catch block.\nexport function escapeHtml(s: string): string {\n return String(s ?? '').replace(/[&<>\"']/g, (c) => {\n switch (c) {\n case '&': return '&';\n case '<': return '<';\n case '>': return '>';\n case '\"': return '"';\n case \"'\": return ''';\n default: return c;\n }\n });\n}\n// Value for a data-tooltip attribute written into an HTML template string. The\n// parser decodes the attribute once and CustomTooltips renders dataset.tooltip\n// as innerHTML, so plain text has to be escaped twice to arrive as text. Not\n// for a tooltip that is meant to carry markup, nor for one set through\n// setAttribute or dataset (no parser pass: escape once there).\nexport function tipAttr(text: string): string {\n return escapeHtml(escapeHtml(String(text ?? \"\")));\n}\n\n// Locale-aware list join (\"A, B and C\" / \"A\u3001B\u3001C\"); falls back to the\n// catalog's own conjunction keys where Intl.ListFormat is missing.\nexport function joinNames(names: string[]): string {\n try {\n return new Intl.ListFormat(getLocale(), { style: 'long', type: 'conjunction' }).format(names);\n } catch {\n if (names.length <= 1) return names[0] ?? '';\n if (names.length === 2) return t('body.xAndY', { a: names[0], b: names[1] });\n return t('body.listCommaAndLast', { list: names.slice(0, -1).join(', '), last: names[names.length - 1] });\n }\n}\n", "// static/ts/utils/ConfirmModal.ts\nimport { Modal } from \"./Modal\";\nimport { t } from '../i18n/I18n';\nimport { escapeHtml } from \"./FormatUtils\";\n\nexport type ShowConfirmModalArgs = {\n title: string;\n\n /** Main prompt (HTML allowed). Keep caller responsible for escaping unsafe values. */\n messageHtml: string;\n\n /** Optional small line above the message. */\n subtitleHtml?: string;\n\n /** Optional details block below the message (HTML allowed). */\n detailsHtml?: string;\n\n /** Button labels */\n cancelLabel?: string; // default: Cancel\n confirmLabel?: string; // default: Confirm\n\n /** Button classes */\n cancelClassName?: string; // default: btn btn-secondary\n confirmClassName?: string; // default: btn btn-primary\n\n /** Optional button icons (ModalButtonOpts icon shape) */\n cancelIcon?: { materialIcon?: string; fontAwesome?: string; after?: boolean };\n confirmIcon?: { materialIcon?: string; fontAwesome?: string; after?: boolean };\n\n /**\n * Defaults to true: modal hides immediately (like DriftReportsUI), then calls onConfirm.\n * If onConfirm then rejects, the modal opens again with the error in it (unless\n * another confirm has taken the modal meanwhile), so a failed action never\n * reads as done. A caller that reports its own errors catches them instead.\n * If false: modal stays open while onConfirm runs; errors are shown in-modal.\n */\n closeOnConfirm?: boolean;\n /** A short question with a short answer: a 520px dialog, centred, instead of the 1100px form-sized one. */\n compact?: boolean;\n /** Lift the dialog above an open drawer (askConfirm). */\n overDrawer?: boolean;\n /** A class for this confirm's dialog only (e.g. to place it over a drawer). */\n dialogClass?: string;\n\n onConfirm: () => void | Promise;\n onCancel?: () => void;\n\n /**\n * Runs once the dialog has closed, however it closed (its action done,\n * Cancel, Esc), after Modal has handed focus back to the opener; only\n * while this confirm still owns the modal. For a caller whose action\n * may have replaced the opener.\n */\n afterClose?: () => void;\n};\n\n/** A shown confirm: close() hides the modal only while this confirm still owns it. */\n/** A shown confirm: close() hides the modal only while this confirm still owns it. */\nexport type ConfirmModalHandle = {\n close(): void;\n /** Still waiting to show or showing: not closed, and no newer confirm took the modal. */\n isOpen(): boolean;\n};\n\nlet _modal: Modal | null = null;\n// Bumped by every showConfirmModal: a confirm whose action fails after the\n// modal was handed to a newer confirm must not reopen it over that one.\nlet _generation = 0;\n// The askConfirm waiting for an answer: whichever confirm takes the modal\n// next settles it with what it was answered so far (\"no\" when unanswered,\n// \"yes\" when confirmed and still fading out), so it never waits forever.\nlet _pendingAsk: (() => void) | null = null;\n// Where #confirmModal is in Bootstrap's show/hide cycle, kept from its own\n// events: Bootstrap ignores show() and hide() while a transition runs.\nlet _phase: \"hidden\" | \"showing\" | \"shown\" | \"hiding\" = \"hidden\";\n// The dialogClass the dialog carries now, dropped when the next confirm is built.\nlet _dialogClass = \"\";\n\nfunction trackPhase(): void {\n const el = document.getElementById(\"confirmModal\");\n const $ = (window as any).$;\n if (!el || !$ || el.dataset.phaseTracked) return;\n el.dataset.phaseTracked = \"1\";\n _phase = \"hidden\";\n // Bound before any confirm's own handlers, so they read the new phase.\n $(el).on(\"show.bs.modal\", () => { _phase = \"showing\"; })\n .on(\"shown.bs.modal\", () => { _phase = \"shown\"; })\n .on(\"hide.bs.modal\", () => { _phase = \"hiding\"; })\n .on(\"hidden.bs.modal\", () => { _phase = \"hidden\"; });\n}\n\nfunction ensureCSS(): void {\n if (document.getElementById(\"confirm-modal-style\")) return;\n\n const style = document.createElement(\"style\");\n style.id = \"confirm-modal-style\";\n style.textContent = `\n /* Scope all styles to #confirmModal */\n #confirmModal .btn-secondary {\n background-color: #5a6672;\n color: #fff;\n border: none;\n }\n\n #confirmModal .btn-primary {\n background-color: var(--brand-accent, #d4b25a);\n color: #fff;\n border: none;\n }\n\n /* Remove unused description spacing */\n #confirmModal .modal-description {\n display: none !important;\n margin: 0 !important;\n padding: 0 !important;\n height: 0 !important;\n }\n\n /* Wider modal */\n #confirmModal .modal-dialog {\n width: 1100px;\n max-width: 92vw;\n min-width: 560px;\n margin-left: auto;\n margin-right: auto;\n }\n\n /* Compact: a short question sits in a small, centred dialog. */\n #confirmModal.confirm-compact .modal-dialog { width: 520px; min-width: 0; }\n #confirmModal.confirm-compact .modal-content { max-width: none !important; }\n /* askConfirm asks over whatever is open, a drawer (z 1040+) included. */\n #confirmModal.confirm-over-drawer { z-index: 2050; }\n\n /* Stretch content to full modal width (prevents odd \"not centered\" look) */\n #confirmModal .modal-content {\n width: 100% !important;\n padding: unset;\n border-radius: unset;\n align-items: stretch; /* important */\n }\n\n #confirmModal .modal-title {\n white-space: nowrap;\n text-wrap: wrap;\n }\n\n /* Footer divider line like Drift Reports */\n #confirmModal .modal-footer {\n display: flex;\n align-items: center;\n background: transparent;\n border-top: 1px solid rgba(0,0,0,0.12) !important;\n padding: .65rem 1.5rem;\n width: 100%;\n }\n\n /* Buttons: a bit taller */\n #confirmModal .modal-footer button {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: 8px;\n line-height: 1;\n padding: 8px 14px;\n min-height: 38px;\n font-size: 1.0rem;\n }\n\n /* Ensure modal icons align with label */\n #confirmModal .modal-footer button i.material-icons,\n #confirmModal .modal-footer button i.material-icons-outlined {\n font-size: 18px;\n line-height: 1;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n }\n\n /* Cancel left */\n #confirmModal .modal-footer .btn:first-child {\n margin-left: 0;\n margin-right: auto;\n }\n\n /* Confirm right */\n #confirmModal .modal-footer .btn:last-child {\n margin-right: .75rem;\n }\n\n /* Make green */\n #confirmModal code {\n color: #1a8b62 !important;\n font-size: 0.98em;\n }\n\n #confirmModal .modal-body {\n padding: 1.0rem 1.5rem 0.55rem; /* smaller bottom padding => warning closer to footer line */\n width: 100%;\n font-size: 1rem;\n }\n\n /* Center the inner content area */\n #confirmModal .confirmModalInner {\n max-width: 900px;\n margin: 0 auto;\n width: 100%;\n }\n\n /* If callers use Bootstrap \"small\", keep it readable */\n #confirmModal .confirmModalInner .small {\n font-size: 0.95rem !important;\n }\n\n #confirmModal #confirmModalMessage {\n margin-top: 6px;\n margin-bottom: 24px;\n text-align: center;\n font-size: 1.1rem;\n /* Regular weight, as in the Drift Reports dialogs. */\n font-weight: 400;\n }\n\n #confirmModal #confirmModalSubtitle {\n font-size: 1.0rem;\n opacity: .85;\n margin-bottom: .25rem;\n text-align: center;\n }\n\n #confirmModal #confirmModalDetails {\n margin-left: 30px;\n margin-top: 10px;\n text-align: left;\n }\n\n #confirmModal #confirmModalError {\n margin-top: 10px;\n min-height: 1.25rem;\n }\n\n /* Warning row (solid yellow icon + tight spacing near footer) */\n #confirmModal .confirmModalWarningRow {\n margin-top: 24px;\n margin-bottom: 0; /* keep it close to the footer divider */\n display: flex;\n align-items: flex-start;\n gap: 8px;\n }\n\n #confirmModal .confirmModalWarningIcon {\n color: #ffc107; /* Bootstrap warning yellow */\n font-size: 20px;\n line-height: 1;\n margin-top: 1px;\n }\n\n @media (max-width: 600px) {\n #confirmModal .modal-dialog { width: auto; min-width: unset; }\n #confirmModal .confirmModalInner { max-width: none; }\n }\n `;\n document.head.appendChild(style);\n}\n\nfunction ensureDom(): void {\n const el = document.getElementById(\"confirmModal\");\n if (\n el &&\n el.querySelector(\".modal-title\") &&\n el.querySelector(\".modal-body\") &&\n el.querySelector(\".modal-footer\")\n ) {\n return;\n }\n\n const host = el ?? document.createElement(\"div\");\n host.id = \"confirmModal\";\n host.className = \"modal fade modal-custom\";\n host.setAttribute(\"tabindex\", \"-1\");\n\n host.innerHTML = `\n
\n
\n
\n
\n \n
\n\n

\n\n
\n\n
\n
\n
\n `;\n\n if (!el) document.body.appendChild(host);\n}\n\nexport function showConfirmModal(args: ShowConfirmModalArgs): ConfirmModalHandle {\n // A waiting askConfirm loses the modal to this confirm: settle it now.\n _pendingAsk?.();\n ensureCSS();\n ensureDom();\n trackPhase();\n if (!_modal) _modal = new Modal(\"confirmModal\");\n const generation = ++_generation;\n let closed = false;\n // Set when this confirm's dialog has hidden (cleared when it reopens\n // with an error).\n let done = false;\n // Builds and shows this confirm once the modal is at rest and hidden:\n // Bootstrap ignores show() mid-transition (the old hidden event would\n // then close this one), and showing over an open confirm would restart\n // its focus hand-back and leave its Cmd/Ctrl+Enter listener behind. So\n // wait out an opening or closing, close an open one first, then show.\n // A newer confirm called meanwhile takes the modal instead.\n const build = () => {\n const el0 = document.getElementById(\"confirmModal\");\n el0?.classList.toggle(\"confirm-compact\", !!args.compact);\n // Above any open drawer (z 1040+), for the confirms that ask over one.\n el0?.classList.toggle(\"confirm-over-drawer\", !!args.overDrawer);\n if (_dialogClass) el0?.classList.remove(_dialogClass);\n _dialogClass = args.dialogClass || \"\";\n if (_dialogClass) el0?.classList.add(_dialogClass);\n\n const cancelLabel = args.cancelLabel || t('common.cancel');\n const confirmLabel = args.confirmLabel || t('common.confirm');\n const cancelClassName = args.cancelClassName || \"btn btn-secondary\";\n const confirmClassName = args.confirmClassName || \"btn btn-primary\";\n const closeOnConfirm = args.closeOnConfirm !== false;\n\n // Build body\n const body = document.createElement(\"div\");\n body.innerHTML = `\n
\n
\n

\n
\n
\n
\n `;\n\n const subtitleEl = body.querySelector(\"#confirmModalSubtitle\")!;\n const msgEl = body.querySelector(\"#confirmModalMessage\")!;\n const detailsEl = body.querySelector(\"#confirmModalDetails\")!;\n const errEl = body.querySelector(\"#confirmModalError\")!;\n\n subtitleEl.innerHTML = args.subtitleHtml || \"\";\n subtitleEl.style.display = args.subtitleHtml ? \"\" : \"none\";\n\n msgEl.innerHTML = args.messageHtml || \"\";\n\n detailsEl.innerHTML = args.detailsHtml || \"\";\n detailsEl.style.display = args.detailsHtml ? \"\" : \"none\";\n\n errEl.textContent = \"\";\n\n _modal.setTitle(args.title);\n _modal.setDescription(\"\"); // keep description area unused for consistent layout\n _modal.setBodyHtml(body);\n\n let confirmBtn: HTMLButtonElement | null = null;\n let cancelBtn: HTMLButtonElement | null = null;\n\n const runConfirm = async () => {\n // Bootstrap ignores hide() while the dialog is still opening or\n // closing, so a click (or Cmd/Ctrl+Enter) then would act while the\n // dialog stays open, and could act again: confirm only once at rest.\n if (_phase === \"showing\" || _phase === \"hiding\") return;\n errEl.textContent = \"\";\n\n // If we're keeping the modal open while confirming, disable buttons.\n if (!closeOnConfirm) {\n if (confirmBtn) confirmBtn.disabled = true;\n if (cancelBtn) cancelBtn.disabled = true;\n } else {\n // DriftReports-style: hide immediately, then execute.\n _modal!.hide();\n }\n\n try {\n await args.onConfirm();\n\n // If we kept it open, close it after success, unless a newer\n // confirm has taken the modal meanwhile (its form stays).\n if (!closeOnConfirm && generation === _generation) _modal!.hide();\n } catch (e: any) {\n const msg = e?.message ? String(e.message) : t('body.operationFailed');\n\n if (!closeOnConfirm) {\n errEl.textContent = msg;\n if (confirmBtn) confirmBtn.disabled = false;\n if (cancelBtn) cancelBtn.disabled = false;\n } else {\n // The modal is already hidden: open it again with the error,\n // so the failure is seen (the action did not happen).\n console.error(\"[ConfirmModal] onConfirm failed:\", e);\n if (generation !== _generation) return; // a newer confirm owns the modal\n errEl.textContent = msg;\n reshowAfterHidden();\n }\n }\n };\n\n // Bootstrap ignores show() while the hide transition is still running,\n // so a fast failure waits for the modal to finish hiding first.\n const reshowAfterHidden = () => {\n const el = document.getElementById(\"confirmModal\");\n const $ = (window as any).$;\n const reshow = () => { if (generation === _generation) { done = false; _modal!.show(); } };\n if (el && $ && el.style.display !== \"none\" && !el.classList.contains(\"show\")) {\n $(el).one(\"hidden.bs.modal\", reshow);\n } else {\n reshow();\n }\n };\n\n _modal.setButtons([\n {\n label: cancelLabel,\n className: cancelClassName,\n icon: args.cancelIcon,\n onClick: () => {\n _modal!.hide();\n args.onCancel?.();\n },\n afterRender: (btn) => { cancelBtn = btn; },\n },\n {\n label: confirmLabel,\n className: confirmClassName,\n icon: args.confirmIcon,\n onClick: () => void runConfirm(),\n primary: true,\n afterRender: (btn) => { confirmBtn = btn; },\n },\n ]);\n\n _modal.show();\n // Registered after Modal.show's own hidden handler, so afterClose\n // runs after the opener's focus.\n const el = document.getElementById(\"confirmModal\");\n const $ = (window as any).$;\n if (el && $) {\n $(el).one(\"hidden.bs.modal\", () => {\n if (generation !== _generation) return; // a newer confirm owns the modal\n done = true;\n args.afterClose?.();\n });\n }\n };\n const whenAtRest = () => {\n if (closed || generation !== _generation) return;\n const el = document.getElementById(\"confirmModal\");\n const $ = (window as any).$;\n if (!el || !$ || _phase === \"hidden\") return build();\n if (_phase === \"showing\") {\n $(el).one(\"shown.bs.modal\", whenAtRest);\n } else {\n $(el).one(\"hidden.bs.modal\", whenAtRest);\n if (_phase === \"shown\") _modal!.hide();\n }\n };\n whenAtRest();\n return {\n close: () => {\n if (generation !== _generation) return;\n closed = true;\n _modal!.hide();\n // Bootstrap ignores a hide while the dialog opens.\n if (_phase === \"showing\") {\n (window as any).$(document.getElementById(\"confirmModal\")).one(\"shown.bs.modal\", () => {\n if (generation === _generation) _modal!.hide();\n });\n }\n },\n isOpen: () => generation === _generation && !closed && !done,\n };\n}\n\n/**\n * window.confirm in the app's own confirm dialog (showConfirmModal, compact):\n * resolves true when the confirm button was pressed, false when the dialog\n * closed any other way (Cancel, the close button, Esc) or another confirm\n * took the modal before it was answered. It normally resolves once the\n * dialog has closed and handed focus back to its opener, so what the caller\n * does next (opening a drawer, focusing an editor) is not undone by that\n * hand-back; an ask another confirm takes over resolves at once, with the\n * answer it was given. title and message are text, escaped here.\n */\nexport function askConfirm(opts: { title: string; message: string; confirmLabel?: string }): Promise {\n return new Promise(resolve => {\n let settled = false;\n let confirmed = false;\n const settle = (ok: boolean) => {\n if (settled) return;\n settled = true;\n if (_pendingAsk === supersede) _pendingAsk = null;\n resolve(ok);\n };\n const supersede = () => settle(confirmed);\n showConfirmModal({\n title: escapeHtml(opts.title),\n messageHtml: escapeHtml(opts.message),\n confirmLabel: opts.confirmLabel,\n compact: true,\n overDrawer: true,\n onConfirm: () => { confirmed = true; },\n afterClose: () => settle(confirmed),\n });\n // Set after the call: showConfirmModal settles the previous ask, not this one.\n _pendingAsk = supersede;\n });\n}\n", "// static/ts/utils/ImpersonationNotify.ts\n// -----------------------------------------------------------------------------\n// When a role-gated API call comes back 403/404 *while impersonation is\n// active*, surface a confirm modal explaining the situation and offer a\n// one-click \"Stop impersonating\" button. Without this, users hit the\n// page \u2192 see a generic \"Forbidden\" error and have to navigate back to\n// Profile to figure out why. The check is on-demand (only fires when an\n// error happens) so there's no per-page cost.\n//\n// Fires fire-and-forget from apiFetch; the caller already gets a\n// rejected promise, this just adds context.\n// -----------------------------------------------------------------------------\n\nimport { t as t2 } from \"../i18n/I18n\";\nimport { showConfirmModal, type ConfirmModalHandle } from \"./ConfirmModal\";\nimport { getAccessToken } from \"./Auth\";\n\ntype ImpersonateState = {\n active: boolean;\n superEmail?: string;\n impersonatedEmail?: string;\n impersonatedFirstName?: string;\n impersonatedDisplayName?: string;\n impersonatedTenantId?: string;\n};\n\n// Concurrent 403s during a page load would otherwise stack modals.\n// One in flight at a time: the notice already waiting or showing, until it\n// closes (however it closes) or another confirm takes the dialog.\nlet current: ConfirmModalHandle | null = null;\nlet probeInFlight: Promise | null = null;\n\n/**\n * Best-effort: if impersonation is currently active, pop a modal\n * naming the impersonated user and offering a Stop button. No-op\n * otherwise. Safe to call repeatedly \u2014 concurrent calls share a single\n * probe, and we won't stack modals.\n *\n * Uses fetch directly (not apiFetch) to avoid an infinite loop when\n * /api/impersonate itself returns 403 \u2014 that would re-enter this path.\n */\nexport function notifyImpersonationOnForbidden(): void {\n if (current?.isOpen() || probeInFlight) return;\n\n probeInFlight = (async () => {\n try {\n const resp = await fetch(\"/api/impersonate\", {\n credentials: \"same-origin\",\n headers: bearerHeader(),\n });\n if (!resp.ok) return;\n const state = (await resp.json()) as ImpersonateState;\n if (!state.active) return;\n if (current?.isOpen()) return;\n showModal(state);\n } catch {\n // swallow \u2014 the original error already surfaced; we just\n // couldn't add impersonation context.\n }\n })().finally(() => {\n probeInFlight = null;\n });\n}\n\nfunction bearerHeader(): Record {\n // Tokens live under an env-scoped key (e.g. `prod:oauth_access_token`);\n // raw localStorage.getItem(\"oauth_access_token\") misses them and the\n // probe goes out unauthenticated.\n const tok = getAccessToken();\n return tok ? { Authorization: `Bearer ${tok}` } : {};\n}\n\nfunction showModal(state: ImpersonateState): void {\n const targetEmail = escapeText(state.impersonatedEmail ?? t2('body.anotherUser'));\n // Raw for the Cancel button, whose label is appended as a text node;\n // escaped only where it lands in the HTML message and details.\n const targetFirstRaw =\n state.impersonatedFirstName ||\n state.impersonatedDisplayName ||\n state.impersonatedEmail ||\n t2('body.themLower');\n const targetFirst = escapeText(targetFirstRaw);\n\n current = showConfirmModal({\n title: t2('body.accessDeniedWhileImpersonating'),\n messageHtml: t2('body.youreViewingAsNoAccess', { email: targetEmail }),\n detailsHtml:\n `` +\n t2('body.stopImpersonatingToReturn', { name: targetFirst }) +\n ``,\n cancelLabel: t2('body.continueAsName', { name: targetFirstRaw }),\n cancelClassName: \"btn btn-secondary\",\n confirmLabel: t2('body.stopImpersonating'),\n confirmClassName: \"btn btn-primary\",\n closeOnConfirm: true,\n onConfirm: async () => {\n try {\n await fetch(\"/api/impersonate\", {\n method: \"DELETE\",\n credentials: \"same-origin\",\n headers: bearerHeader(),\n });\n } catch {\n // best-effort; reload anyway so a transient network\n // glitch doesn't leave the user stuck.\n }\n window.location.reload();\n },\n onCancel: () => {\n // Keep impersonation active and navigate to Profile \u2014\n // staying on the forbidden page would just re-show the same\n // 403 alongside the rebuilt sidebar. Profile is always\n // accessible to the impersonated user.\n const tenantId = new URLSearchParams(window.location.search).get(\"tenantId\");\n const target = tenantId\n ? `/?tenantId=${encodeURIComponent(tenantId)}&page=profile`\n : \"/?page=profile\";\n window.location.assign(target);\n },\n });\n}\n\nfunction escapeText(s: string): string {\n return String(s ?? \"\")\n .replace(/&/g, \"&\")\n .replace(//g, \">\")\n .replace(/\"/g, \""\")\n .replace(/'/g, \"'\");\n}\n", "// utils/Api.ts\nimport { ensureMPHosts, mpAppBaseURL } from \"./MPHosts\";\nimport { buildBIHeaderValue, headerName } from \"./Bi\";\nimport { getAccessToken, refreshAccessToken, startOAuthLogin, clearSession } from \"./Auth\";\nimport { notifyImpersonationOnForbidden } from \"./ImpersonationNotify\";\nimport { t } from \"../i18n/I18n\";\n\n// Mirrors middleware/cors.go nullOriginCredentialedPaths: the auth/session\n// endpoints the SPA reads with the session cookie while a SASE gateway's\n// redirect hop taints its Origin to \"null\".\nexport const SASE_COOKIE_PATHS: ReadonlySet = new Set([\"/api/oauth/config\", \"/api/oauth/reset-session\", \"/api/user/profile\"]);\n\n/** Whether a request is one of the SASE login-path GETs that need the cookie\n * on a cross-origin redirect (see the credentials note in apiFetch). */\nexport function usesSaseCookie(url: string, method?: string): boolean {\n const m = (method || \"GET\").toUpperCase();\n if (m !== \"GET\" && m !== \"HEAD\") return false;\n let path: string;\n try {\n path = new URL(url, \"https://dashcam.invalid\").pathname;\n } catch {\n return false;\n }\n return SASE_COOKIE_PATHS.has(path);\n}\n\nexport async function apiFetch(\n url: string,\n init: RequestInit = {},\n opts?: { skipAuthRedirect?: boolean; _retried?: boolean },\n): Promise {\n const headers = new Headers(init.headers || {});\n try {\n headers.set(headerName(), buildBIHeaderValue());\n } catch {\n // never block calls on BI errors\n }\n\n // Attach bearer if we have one. Otherwise fall back to cookie auth\n // (the local-dev cookie-injection workflow still needs that path).\n if (!headers.has(\"Authorization\")) {\n const tok = getAccessToken();\n if (tok) headers.set(\"Authorization\", `Bearer ${tok}`);\n }\n init.headers = headers;\n\n // \"same-origin\" by default: every /api call is same-origin, and the\n // bearer header is what authenticates it (the HttpOnly `dashcam_session`\n // cookie still rides along on a same-origin request). The exception is\n // the login path behind enterprise SASE gateways (Zscaler): they\n // cross-origin-redirect API GETs (appending _xm_byp/_sm_byp), the browser\n // strips the Authorization header on that redirect and taints the Origin\n // to \"null\", so only the SameSite=None cookie authenticates. The server\n // grants Access-Control-Allow-Credentials to Origin \"null\" only for GETs\n // of the auth/session paths (middleware/cors.go\n // nullOriginCredentialedPaths), so those, and only those, use \"include\".\n if (!init.credentials) {\n init.credentials = usesSaseCookie(url, init.method) ? \"include\" : \"same-origin\";\n }\n\n const resp = await fetch(url, init);\n\n if (resp.status !== 401 || opts?.skipAuthRedirect) {\n if (resp.status === 403) {\n // A change refused because the caller is viewing as someone\n // else is not a missing permission: its own message says why,\n // and the caller shows it in the page's message bar.\n const code = await forbiddenCode(resp);\n const readOnly = code.code === \"view_as_read_only\" || code.code === \"refused_with_reason\" ? code.error : \"\";\n if (readOnly) return Promise.reject(new Error(readOnly));\n // Closed in Demo mode (DASH-2022): say so, not \"forbidden\".\n if (code.code === \"mask_mode_refused\") return Promise.reject(new MaskModeRefusedError());\n // Fire-and-forget: if impersonation is active, pop a modal\n // explaining the situation with a Stop button. Without this,\n // an impersonated user navigating to a tab they can't see\n // just gets a generic Forbidden error with no context.\n notifyImpersonationOnForbidden();\n return Promise.reject(new Error(\"forbidden\"));\n }\n return resp;\n }\n\n // 401 path \u2014 try refresh once if we had a token\n const hadToken = !!getAccessToken();\n if (hadToken && !opts?._retried) {\n const newTok = await refreshAccessToken();\n if (newTok) {\n const retryHeaders = new Headers(init.headers || {});\n retryHeaders.set(\"Authorization\", `Bearer ${newTok}`);\n return apiFetch(url, { ...init, headers: retryHeaders }, { ...opts, _retried: true });\n }\n }\n\n // Refresh failed => attempt OAuth login.\n // If OAuth endpoints are not configured (404), fall back to MP login instead of looping.\n clearSession();\n\n // Local-dev shortcut for the stale-token case: if a previously stored\n // token is now rejected, re-bounce through /dev/login to grab the\n // freshest one from secrets/env-tokens.json. sessionStorage guard\n // prevents infinite bouncing if /dev/login itself can't recover.\n if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') {\n const recent = sessionStorage.getItem('dev_login_attempt_ms');\n const recentlyTried = recent && (Date.now() - parseInt(recent, 10) < 10_000);\n if (!recentlyTried) {\n sessionStorage.setItem('dev_login_attempt_ms', String(Date.now()));\n const returnTo = location.pathname + location.search + location.hash;\n window.location.replace('/dev/login?return_to=' + encodeURIComponent(returnTo));\n return new Promise(() => { });\n }\n }\n\n try {\n await startOAuthLogin();\n } catch {\n redirectToLogin(true);\n }\n return new Promise(() => { });\n}\n\n\n/**\n * Redirect the user to a login experience.\n *\n * Currently this still uses the Management Plane login:\n * /login?redirectUrl=\n * where the origin is the backend-served config (utils/MPHosts.ts \u2014 the app\n * embeds no domain names, so with a cold cache AND an unreachable backend\n * there is nowhere sane to send the user and we abort with an error instead).\n *\n * Fire-and-forget: the async config fetch resolves before navigation, but\n * callers never needed the navigation to be synchronous \u2014 every call site\n * abandons the page afterwards anyway.\n */\nexport function redirectToLogin(replaceWindow: boolean = false) {\n void (async () => {\n await ensureMPHosts();\n const base = mpAppBaseURL();\n if (!base) {\n console.error('[redirectToLogin] MP app origin unknown (env config unreachable); aborting redirect.');\n return;\n }\n const url = `${base}/login?redirectUrl=${encodeURIComponent(window.location.href)}`;\n\n if (replaceWindow === true) {\n window.location.replace(url);\n } else {\n window.location.href = url;\n }\n })();\n}\n\n/**\n * forceReauth \u2014 drop the dead Dashcam credential and bounce to a fresh login.\n *\n * Used when an authenticated bootstrap call (notably /api/user/profile) fails:\n * the stored token is rejected by MP, refresh can't recover, and continuing\n * would just dead-end on the blank pre-auth overlay. We:\n * 1. clear the localStorage OAuth token (clearSession), and\n * 2. expire the HttpOnly `dashcam_session` cookie via /api/oauth/reset-session\n * (JS can't delete an HttpOnly cookie itself).\n * We deliberately do NOT touch MP's __Secure-signInSessionId cookie \u2014 keeping it\n * lets the OAuth /authorize hop SSO straight from the user's MP session and mint\n * a brand-new token without a manual MP re-login.\n *\n * A short sessionStorage guard prevents a tight loop: if we already bounced once\n * and the profile still fails, we stop at the MP login page instead of spinning.\n */\nexport async function forceReauth(): Promise {\n clearSession();\n\n const GUARD_KEY = 'dashcam_reauth_attempt_ms';\n const last = sessionStorage.getItem(GUARD_KEY);\n const recentlyTried = !!last && (Date.now() - parseInt(last, 10) < 20_000);\n\n // Expire the HttpOnly dashcam_session cookie server-side (best-effort).\n try {\n await fetch('/api/oauth/reset-session', { method: 'GET', credentials: 'include' });\n } catch {\n // Network hiccup \u2014 proceed to re-login anyway; a stale cookie is\n // overwritten by the next successful exchange.\n }\n\n if (recentlyTried) {\n // Already re-authed once this session and still failing \u2014 break the loop\n // and hand off to the MP login page definitively.\n redirectToLogin(true);\n return;\n }\n sessionStorage.setItem(GUARD_KEY, String(Date.now()));\n\n // Local dev: grab a fresh token from /dev/login rather than real OAuth.\n if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') {\n const returnTo = location.pathname + location.search + location.hash;\n window.location.replace('/dev/login?return_to=' + encodeURIComponent(returnTo));\n return;\n }\n\n try {\n await startOAuthLogin();\n } catch {\n redirectToLogin(true);\n }\n}\n\nexport async function jsonFetch(\n url: string,\n init: RequestInit = {},\n): Promise {\n const resp = await apiFetch(url, init);\n\n if (resp.status === 204) return undefined;\n\n const ct = resp.headers.get(\"content-type\") ?? \"\";\n if (!ct.includes(\"application/json\")) {\n if (!resp.ok) throw new Error((await resp.text()) || resp.statusText);\n return undefined;\n }\n\n const raw = await resp.text();\n if (!raw.trim()) {\n if (!resp.ok) throw new Error(resp.statusText);\n return undefined;\n }\n\n let data: any;\n try { data = JSON.parse(raw); }\n catch { throw new Error(\"Server returned non-JSON data\"); }\n\n if (!resp.ok) throw new Error(data.error ?? resp.statusText);\n return data as T;\n}\n\nexport async function postJson(\n url: string,\n body: object,\n opts: RequestInit = {}\n): Promise {\n return jsonFetch(url, {\n ...opts,\n method: opts.method ?? \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n ...(opts.headers || {}),\n },\n body: JSON.stringify(body),\n });\n}\n\n/** A request closed in Demo mode (DASH-2022); its message says so. */\nexport class MaskModeRefusedError extends Error {\n constructor() {\n super(t(\"maskMode.refused\"));\n this.name = \"MaskModeRefusedError\";\n }\n}\n\n/** A 403's `code` and `error`: a View As refusal of a change\n * (`view_as_read_only`) or a reasoned refusal (`refused_with_reason`), both\n * shown with the server's message, or a Demo mode\n * refusal (`mask_mode_refused`); empty for any other 403. */\nasync function forbiddenCode(resp: Response): Promise<{ code: string; error: string }> {\n const none = { code: \"\", error: \"\" };\n if (!(resp.headers.get(\"Content-Type\") || \"\").includes(\"application/json\")) return none;\n try {\n const body = await resp.clone().json();\n return {\n code: typeof body?.code === \"string\" ? body.code : \"\",\n error: typeof body?.error === \"string\" ? body.error : \"\",\n };\n } catch {\n return none;\n }\n}\n", "import { applyDriftReportBetaPath, pinConsoleEmbedTenant } from './utils/Framing';\nimport { EventHandlerManager } from './handlers/EventHandlers';\nimport { NavigationManager } from './managers/NavigationManager';\nimport { pageToStoreEvent } from './managers/NavigationManager';\nimport StoreManager from './managers/StoreManager';\nimport { WebSocketHandler } from './handlers/WebSocketHandler';\nimport { handleDeepLink, handleTenantIdOnLoad, navigate } from './utils/DeepLink';\nimport { resolveHomePage } from './utils/HomePageSetting';\nimport { apiFetch, forceReauth } from './utils/Api';\nimport { showMaskModeBootNotice } from './utils/MaskModeBootNotice';\nimport { handleOAuthCallbackIfPresent, hasMPSessionCookie, hasValidAccessToken, startOAuthLogin } from \"./utils/Auth\";\nimport { AllPages, isRetiredTrackerPage } from './utils/Page';\nimport UIManager from './managers/UIManager';\nimport { Header } from './utils/Header';\nimport { setupLibraries } from './utils/Setup';\nimport { CustomTooltips } from './utils/CustomTooltips';\nimport { storage, registerEnvProvider } from './utils/Storage';\nimport { registerSqlViewerProvider } from './utils/ButtonUtils';\nimport { initBI } from './utils/Bi';\nimport { applyAppBranding, applyCachedChrome, applyCachedHostChrome, applyTenantChrome, claimChromeCache, releaseStandardChrome, useStandardChrome } from './utils/AppBranding';\nimport { guardMaskModeHistory, maskModeBootStandard, reconcileMaskMode, reloadForMaskModeChange, reloadWithStandardChrome, watchMaskMode, watchMaskModeFromOtherTabs } from './utils/MaskMode';\nimport { initI18n, adoptServerLocale } from './i18n/I18n';\nimport { initTheme } from './utils/Theme';\nimport { initDarkRemap } from './utils/DarkRemap';\nimport { initChartTheme } from './utils/ChartTheme';\nimport { initOpPalette } from './utils/OpPalette';\nimport { adoptServerTheme } from './utils/ThemeSetting';\nimport { applyTranslations } from './i18n/I18nDom';\nimport { applyDataTablesLanguage } from './utils/DataTables';\nimport { initSelectMenus } from './utils/SelectMenu';\nimport { renderWhenStoreReady } from './utils/PageLoadGuards';\n\n// Main initialization function\nasync function main() {\n\tconsole.log(\"Application starting...\");\n\n\t// The inline head script already set html[data-theme] before first paint;\n\t// this re-applies it from the same stored preference and starts following\n\t// OS appearance changes while the page is open.\n\tinitTheme();\n\t// Dark equivalents of the colours page CSS and renderers hardcode.\n\tinitDarkRemap();\n\t// Chart.js chrome (ticks, gridlines, legends) follows the theme too.\n\tinitChartTheme();\n\t// Op palette colours already painted swap to the other theme's variant.\n\tinitOpPalette();\n\n\t// Cached host chrome first, synchronously: on a partner domain it brands\n\t// the sidebar/title/nouns before the first paint. It is keyed by the\n\t// host, not the account. The per-tenant boot cache is applied only once\n\t// the profile names the account (below), still before the reveal: it may\n\t// hold the previous account's tenant brand on a shared browser.\n\t// Demo mode on at this browser's last load, or a staff member who can\n\t// turn it on from any device, keeps the Mimic chrome instead until the\n\t// profile below says which.\n\tconst bootStandard = maskModeBootStandard();\n\tif (bootStandard) useStandardChrome();\n\twatchMaskModeFromOtherTabs();\n\tapplyCachedHostChrome();\n\t// True when a partner chrome painted from the boot cache. When false the\n\t// reveal below waits (briefly) for the server's answer, so a first visit\n\t// to a partner-branded tenant never shows the Dashcam defaults first.\n\tlet chromeFromCache = false;\n\n\t// Load the message catalog and stamp before anything renders,\n\t// so no English frame is painted first. initI18n never throws: a catalog\n\t// that fails to load degrades to English rather than taking the app down.\n\tconst locale = await initI18n();\n\tapplyTranslations(document);\n\t// Localize DataTables' built-in control strings globally, now that the\n\t// catalog is loaded and before any table initializes.\n\tapplyDataTablesLanguage();\n\tconsole.log(`Application locale: ${locale}`);\n\n\t// Brand the chrome (title, sidebar logo/text, favicon) from the app-default\n\t// branding row, falling back to the markup's hardcoded defaults. Fire and\n\t// forget \u2014 a slow or failed branding fetch must not delay app startup.\n\tvoid applyAppBranding();\n\n\tinitBI()\n\n\t// Draw our own \n // (13px DM Sans, 1px #aaa, 3px radius, transparent) so header\n // dropdowns read as the same control as the table length menu.\n select.className = 'filter-input';\n\n const chosen = JSON.stringify(storage.get(dropdown.localStorageName) ?? dropdown.defaultChoice);\n for (const [optionName, optionValue] of Object.entries(dropdown.choices)) {\n const option = document.createElement('option');\n option.value = JSON.stringify(optionValue);\n // Text, not HTML: option names include node hostnames\n // (NetFilters, AdNodeFilter), which the node itself reports.\n option.textContent = optionName;\n if (option.value === chosen) {\n option.selected = true;\n }\n\n select.appendChild(option);\n }\n\n select.addEventListener('change', (e: Event) => {\n const target = e.target as HTMLSelectElement;\n const raw = target.value;\n let parsed: any = raw;\n try { parsed = JSON.parse(raw); } catch { /* non-JSON is okay */ }\n try { storage.set(dropdown.localStorageName, parsed as any); } catch { }\n try { dropdown.callback?.(parsed as any); } catch (e) { console.error('Dropdown callback failed:', e); }\n });\n\n wrapper.appendChild(label);\n wrapper.appendChild(select);\n\n this.pageOptions.appendChild(wrapper);\n }\n });\n\n // create buttons\n for (const btn of (buttons ?? [])) {\n let b = this.pageOptions.querySelector(`#${btn.id}`);\n\n if (!b) {\n b = document.createElement('button');\n b.id = btn.id;\n b.className = 'btn btn-sm align-items-center';\n b.type = 'button';\n\n // base styling (only once)\n b.style.background = 'transparent';\n b.style.color = 'inherit';\n b.style.border = 'none';\n b.style.padding = '0';\n b.style.margin = '0';\n b.style.cursor = 'pointer';\n b.style.outline = 'none';\n\n this.pageOptions.appendChild(b);\n }\n\n // ---- UPDATE STATE EVERY TIME ----\n\n // title\n b.title = btn.title ?? \"\";\n\n b.className = 'btn btn-sm d-inline-flex align-items-center';\n b.type = 'button';\n\n // Remove bootstrap button visuals\n b.style.background = 'transparent';\n b.style.border = 'none';\n b.style.boxShadow = 'none';\n b.style.padding = '0';\n b.style.margin = '0';\n b.style.cursor = 'pointer';\n b.style.outline = 'none';\n b.style.display = 'inline-flex';\n b.style.alignItems = 'center';\n b.style.gap = '6px'; // space between icon + label\n\n b.innerHTML = \"\"; // always clear first\n\n // label first (icon on the right)\n if (btn.label) {\n const span = document.createElement('span');\n span.textContent = btn.label;\n\n // baseline alignment\n span.style.lineHeight = '1';\n span.style.display = 'inline-flex';\n span.style.alignItems = 'center';\n\n // optional label font size\n if (btn.labelFontSize) {\n span.style.fontSize = btn.labelFontSize;\n }\n\n b.appendChild(span);\n }\n\n // icon second (to the right)\n if (btn.icon?.materialIcon) {\n const i = document.createElement('span');\n i.className = `material-icons ${btn.icon.className ?? ''}`.trim();\n i.textContent = btn.icon.materialIcon;\n\n // baseline alignment\n i.style.lineHeight = '1';\n i.style.display = 'inline-flex';\n i.style.alignItems = 'center';\n\n // optional icon font size\n if (btn.iconFontSize) {\n i.style.fontSize = btn.iconFontSize;\n }\n\n b.appendChild(i);\n }\n\n // remove previous listeners by replacing handler\n b.onclick = (e) => {\n e.preventDefault();\n if (btn.stopPropagation) {\n e.stopPropagation();\n e.stopImmediatePropagation();\n }\n btn.onClick();\n };\n }\n }\n\n /**\n * Counts a load and shows the spinner. Returns the epoch to pass back to\n * hideLoading(), or null when another page is showing and nothing was\n * counted.\n */\n public showLoading(text?: string): number | null {\n if (!Header.startLoadingTime) Header.startLoadingTime = new Date();\n let epoch: number | null = null;\n this.validatePage(() => {\n epoch = Header.loading.show();\n this.display(this.loaderEl, true);\n this.display(this.iconEl, false);\n this.loadingTextEl.innerHTML = text ? `(${text})` : \"\";\n });\n return epoch;\n }\n\n /**\n * Uncounts a load and hides the spinner once none is left. Given the\n * epoch its showLoading() returned, a load started before a navigation\n * reset the count uncounts nothing; given null, nothing was counted.\n */\n public hideLoading(epoch?: number | null) {\n if (epoch === null) return;\n let cleared = false;\n this.validatePage(() => {\n // Keep the spinner up while other loads are still outstanding.\n if (!Header.loading.hide(epoch)) return;\n cleared = true;\n this.display(this.loaderEl, false);\n this.display(this.iconEl, true);\n this.loadingTextEl.innerHTML = \"\";\n });\n if (cleared && Header.startLoadingTime) {\n const now = new Date();\n const diff = now.getTime() - Header.startLoadingTime.getTime();\n const seconds = Math.floor(diff / 1000);\n if (seconds < 1) {\n console.debug(`Hiding loading after ${diff} ms (started at ${Header.startLoadingTime.toISOString()}, ended at ${now.toISOString()})`);\n } else {\n console.debug(`Hiding loading after ${seconds} secs (started at ${Header.startLoadingTime.toISOString()}, ended at ${now.toISOString()})`);\n }\n Header.startLoadingTime = undefined;\n }\n }\n\n public getSubtitleElement(): HTMLElement | null {\n let el: HTMLElement | null = null;\n this.validatePage(() => {\n el = this.subtitleEl;\n });\n return el;\n }\n\n private display(el: HTMLElement, visible: boolean) {\n el.style.display = visible ? \"flex\" : \"none\";\n }\n\n private validatePage(callback: () => void) {\n // if none, then it's being called from main\n // so it bypasses the check\n if (this.onPage === \"none\" || currentlyOnPage(this.onPage)) {\n callback();\n }\n }\n}\n", "/**\n * The header spinner's reference count, so the spinner hides only once every\n * outstanding load has finished. Header keeps one shared instance: every\n * page, Main and NavigationManager build their own Header over the one\n * spinner.\n *\n * `show()` counts a load and returns the epoch it was counted in; `reset()`\n * (a navigation) zeroes the count and starts a new epoch. `hide(epoch)`\n * uncounts a load only in the epoch that counted it, so a load started\n * before a reset settles without taking the count of one started after it.\n * `hide()` with no epoch always uncounts.\n *\n * Dependency-free so the node test runner can load it from the tsc output.\n */\nexport class LoadingCount {\n private count = 0;\n private epoch = 0;\n\n /** Count a load; pass the returned epoch to hide(). */\n public show(): number {\n this.count++;\n return this.epoch;\n }\n\n /** Uncount a load. True when the spinner should hide: no load is left. */\n public hide(epoch?: number): boolean {\n if (epoch !== undefined && epoch !== this.epoch) return false;\n this.count = Math.max(0, this.count - 1);\n return this.count === 0;\n }\n\n /** Drop every outstanding load and start a new epoch. */\n public reset(): void {\n this.count = 0;\n this.epoch++;\n }\n}\n", "// static/ts/stores/AdminStore.ts\nimport { apiFetch } from \"../utils/Api\";\nimport { responseErrorText } from \"../utils/ResponseError\";\n\nexport interface BlockedDomain {\n domain: string;\n blocked_at: string;\n blocked_by?: string;\n reason?: string;\n}\n\n// MCP connector redirect allowlist rows for the Admin\u2192Connector Redirects\n// tab. Mirrors db.ConnectorRedirectPattern; \"*\" in a pattern matches exactly\n// one path segment or the entire port.\nexport interface ConnectorRedirect {\n pattern: string;\n label?: string;\n notes?: string;\n created_at: string;\n created_by?: string;\n}\n\n// Research-access requests for the Admin\u2192Site Access tab. Mirrors\n// db.SiteAccessRequest; status is one of pending | invited | accepted |\n// declined.\nexport interface SiteAccessRequest {\n ID: number;\n Email: string;\n Name: string;\n Company: string;\n Reason: string;\n Status: string;\n CreatedAt: string;\n InvitedAt: string | null;\n // Who on our side acted. Empty for pending rows and for anything actioned\n // before these columns existed \u2014 never a placeholder, since the historical\n // actor was not recorded anywhere and cannot be recovered.\n InvitedBy: string;\n DecidedBy: string;\n}\n\n/** One status's page of the review queue (see listSiteAccessRequests). */\nexport interface SiteAccessRequestList {\n requests: SiteAccessRequest[];\n truncated: boolean;\n limit: number;\n}\n\n/** A decline refused because the request was already accepted. */\nexport class SiteAccessAlreadyAcceptedError extends Error {\n constructor() {\n super('site access request already accepted');\n this.name = 'SiteAccessAlreadyAcceptedError';\n }\n}\n\n// What issuing an invite reports back. Emailed distinguishes \"they have it\"\n// from \"you must send this yourself\" \u2014 until a sending domain is verified the\n// link has to be delivered by hand, so the UI always shows it.\nexport interface InviteResult {\n email: string;\n link: string;\n emailed: boolean;\n mail_error?: string;\n}\n\n// Partner registry rows for the Admin\u2192Partners tab (ENG-3088). DERIVED from\n// the management plane's partner admins by the server's partner sweep; the tab\n// reads them, re-runs the sweep, and edits only the node noun. Tenant names are\n// resolved client-side from listAdminTenants().\nexport interface PartnerBranding {\n has_logo: boolean;\n logo_data_url?: string;\n primary_color_light?: string;\n primary_color_dark?: string;\n secondary_color_light?: string;\n secondary_color_dark?: string;\n}\n\n// One partner_admin holder and the tenants they hold the role on.\n// One partner_admin holder on the partner's own tenant.\nexport interface PartnerAdmin {\n email: string;\n}\n\nexport interface PartnerInfo {\n mssp_id: string;\n name: string;\n node_noun?: string;\n partner_tenant_id?: string;\n domain?: string;\n admins: PartnerAdmin[];\n tenant_ids: string[];\n // Subset of tenant_ids whose assignment grants the partner's admins\n // standing admin access in the management plane.\n standing_admin_tenant_ids: string[];\n swept_at?: string;\n // The last sweep ran and did not find this partner: it brands nothing.\n stale: boolean;\n // Legacy hand-seeded row no sweep has matched to an MP tenant; deletable.\n orphan: boolean;\n branding: PartnerBranding;\n}\n\nexport interface PartnerSweepState {\n ran_at: string;\n // Active partner assignments the management plane returned, Mimic's own\n // creator edges included; creator_edges is how many of those were skipped.\n edges_scanned: number;\n creator_edges: number;\n partners_found: number;\n // Partner tenants whose user list could not be read (their admins are missing).\n tenant_errors: number;\n // The edge walk hit its page cap: managed sets may be incomplete.\n truncated: boolean;\n error?: string;\n}\n\nexport interface PartnerList {\n partners: PartnerInfo[];\n sweep: PartnerSweepState | null;\n}\n\n// Vulnerability Sources tab (ENG-4292). Mirrors db.VendorSourceSummary,\n// db.VendorFileRow and db.NodeWindowsUpdateStateRow.\nexport interface VulnSourceSummary {\n source: string;\n files: number;\n findings: number;\n lastReceived: string;\n}\n\nexport interface VulnSourcesOverview {\n sources: VulnSourceSummary[];\n formats: string[];\n sourceIds: string[];\n}\n\nexport interface VulnSourceFile {\n id: number;\n route: string;\n nodeId: string;\n fileName: string;\n source: string;\n format: string;\n sizeBytes: number;\n fileMtime?: string;\n receivedAt: string;\n uploadedBy: string;\n parseStatus: string;\n parseError: string;\n findingCount: number;\n}\n\nexport interface VulnSourceNode {\n nodeId: string;\n /** From the node roster; empty when no source knows the node's name. */\n hostname: string;\n reportedAt: string;\n osBuild: number;\n osUbr: number;\n installedCount: number;\n missingCount: number;\n missingSource: string;\n rebootPending: boolean;\n chunkCount: number;\n complete: boolean;\n lastHeartbeatAt?: string;\n}\n\n// Vendor connectors (ENG-4346). Mirrors db.VendorConnectorRow (never the\n// secret \u2014 hasSecret says whether one is stored), db.VendorConnectorRun and\n// connectors.Vendor.\nexport interface VulnConnector {\n id: number;\n tenantId: string;\n vendor: string;\n label: string;\n apiUrl: string;\n authUrl: string;\n clientId: string;\n hasSecret: boolean;\n extra: Record;\n enabled: boolean;\n schedule: string;\n nextRunAt?: string;\n running: boolean;\n lastRunAt?: string;\n lastStatus: string;\n lastError: string;\n lastFindings: number;\n lastAssets: number;\n createdBy: string;\n createdAt: string;\n updatedAt: string;\n}\n\nexport interface VulnConnectorVendor {\n id: string;\n name: string;\n authUrl: string;\n /** The vendor's fixed endpoint when needsApiUrl is false. */\n apiUrl: string;\n apiUrlHint: string;\n needsApiUrl: boolean;\n /** 'client_credentials' (token URL + client id + secret), 'api_token' (one token) or 'api_key_secret' (an API key and secret, no token URL). */\n auth: 'client_credentials' | 'api_token' | 'api_key_secret';\n /** True when the vendor scans assets of its own, so the asset knobs apply. */\n assetScoped: boolean;\n /** True when the vendor filters by its own asset-type enum (Wiz), so the asset-types field applies. */\n assetTypes: boolean;\n}\n\nexport interface VulnConnectorRun {\n id: number;\n connectorId: number;\n startedAt: string;\n finishedAt?: string;\n status: string;\n findings: number;\n assets: number;\n retired: number;\n error: string;\n triggeredBy: string;\n}\n\nexport interface VulnConnectorInput {\n vendor: string;\n label: string;\n apiUrl: string;\n authUrl: string;\n clientId: string;\n secret?: string;\n extra?: Record;\n enabled?: boolean;\n}\n\n// Admin page Performance tab (DASH-2176 phase 2): GET /api/api-perf and\n// /api/api-perf/compare. Latency is of 2xx calls; counts are of every status.\nexport interface APIPerfRoute {\n method: string;\n route: string;\n shape: string;\n calls: number;\n ok: number;\n clientErrors: number;\n serverErrors: number;\n p50Ms: number;\n p95Ms: number;\n p99Ms: number;\n avgMs: number;\n maxMs: number;\n pgShare: number | null;\n chShare: number | null;\n tenants: number;\n}\n\nexport interface APIPerfBuild {\n build: string;\n firstDay: string;\n lastDay: string;\n calls: number;\n}\n\nexport interface APIPerfTenantCalls {\n /** The nil UUID for calls with no tenant. */\n tenantId: string;\n calls: number;\n}\n\nexport interface APIPerfRegression {\n day: string;\n tenantId: string;\n method: string;\n route: string;\n shape: string;\n baselineP50Ms: number;\n baselineP95Ms: number;\n baselineCalls: number;\n currentP50Ms: number;\n currentP95Ms: number;\n currentCalls: number;\n baselinePgShare: number | null;\n currentPgShare: number | null;\n baselineChShare: number | null;\n currentChShare: number | null;\n firstBadBuild: string;\n builds: string[];\n}\n\nexport interface APIPerfRun {\n day: string;\n ranAt: string;\n // Fleet-wide counts: absent for a reader limited to some tenants.\n groups?: number;\n regressions?: number;\n}\n\nexport interface APIPerfReport {\n from: string;\n to: string;\n routes: APIPerfRoute[];\n builds: APIPerfBuild[];\n tenants: APIPerfTenantCalls[];\n // Names of the tenants the report's rows carry, and only those.\n tenantNames: Record;\n regressions: APIPerfRegression[];\n lastRun: APIPerfRun | null;\n}\n\nexport interface APIPerfBuildLatency {\n calls: number;\n p50Ms: number;\n p95Ms: number;\n}\n\nexport interface APIPerfCompareRow {\n method: string;\n route: string;\n shape: string;\n a: APIPerfBuildLatency | null;\n b: APIPerfBuildLatency | null;\n}\n\nexport interface APIPerfQuery {\n days: number;\n /** \"\" for every tenant. */\n tenantId: string;\n /** \"\" for every build. */\n build: string;\n}\n\nexport interface AdminTenant {\n id: string;\n name: string;\n /** MP's raw customer_environment enum value (prod / qa / dev / demo / partner / other); \"\" when unset. */\n customerEnvironment: string;\n}\n\nexport default class AdminStore {\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n PUB/SUB status \u2013 property-like\n \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n public get pubSubGalactusStatus(): Promise<\"active\" | \"inactive\" | \"error\"> {\n return (async () => {\n try {\n const res = await apiFetch(\"/api/pubsub/galactus/status\");\n // A 403/500 carries {\"error\": \u2026}: that is an unknown status, not \"inactive\".\n if (!res.ok) return \"error\";\n const json = await res.json();\n return json?.status === \"active\" || json?.status === \"inactive\" ? json.status : \"error\";\n } catch (err) {\n console.error(\"[AdminStore] pubsub status failed:\", err);\n return \"error\";\n }\n })();\n }\n\n public get pubSubLifecycleStatus(): Promise<\"active\" | \"inactive\" | \"error\"> {\n return (async () => {\n try {\n const res = await apiFetch(\"/api/pubsub/lifecycle/status\");\n // A 403/500 carries {\"error\": \u2026}: that is an unknown status, not \"inactive\".\n if (!res.ok) return \"error\";\n const json = await res.json();\n return json?.status === \"active\" || json?.status === \"inactive\" ? json.status : \"error\";\n } catch (err) {\n console.error(\"[AdminStore] pubsub status failed:\", err);\n return \"error\";\n }\n })();\n }\n\n public async analyzeAllExecutables(): Promise<{ started: number }> {\n const r = await apiFetch('/api/admin/executables/analyze-all', { method: 'POST' });\n if (!r.ok) throw new Error(await r.text());\n return await r.json();\n }\n\n async analyzeAllServices(): Promise<{ started: number }> {\n const r = await apiFetch('/api/admin/services/analyze-all', { method: 'POST' });\n if (!r.ok) throw new Error(await r.text());\n return await r.json();\n }\n\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n Subscribe / unsubscribe\n \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n async subscribeGalactusPubSub(): Promise {\n const res = await apiFetch(\"/api/pubsub/galactus/subscribe\");\n if (!res.ok) throw new Error(`Subscribe failed: ${res.statusText}`);\n return res.json();\n }\n\n async unsubscribeGalactusPubSub(): Promise {\n const res = await apiFetch(\"/api/pubsub/galactus/unsubscribe\");\n if (!res.ok) throw new Error(`Unsubscribe failed: ${res.statusText}`);\n return res.json();\n }\n\n async subscribeLifecyclePubSub(): Promise {\n const res = await apiFetch(\"/api/pubsub/lifecycle/subscribe\");\n if (!res.ok) throw new Error(`Subscribe failed: ${res.statusText}`);\n return res.json();\n }\n\n async unsubscribeLifecyclePubSub(): Promise {\n const res = await apiFetch(\"/api/pubsub/lifecycle/unsubscribe\");\n if (!res.ok) throw new Error(`Unsubscribe failed: ${res.statusText}`);\n return res.json();\n }\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n Reset helpers\n \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n async resetDatabase(): Promise {\n const res = await apiFetch(\"/api/reset-db\", { method: \"DELETE\" });\n if (!res.ok) throw new Error(`DB reset failed: ${res.statusText}`);\n return res.json();\n }\n\n async resetCallers(): Promise {\n const res = await apiFetch(\"/api/reset-callers\", { method: \"DELETE\" });\n if (!res.ok) throw new Error(`Callers reset failed: ${res.statusText}`);\n return res.json();\n }\n\n async resetLogs(): Promise {\n const res = await apiFetch(\"/api/reset-logs\", { method: \"DELETE\" });\n if (!res.ok) throw new Error(`Logs reset failed: ${res.statusText}`);\n return res.json();\n }\n\n async resetLifecycle(): Promise {\n const res = await apiFetch(\"/api/reset-lifecycle\", { method: \"DELETE\" });\n if (!res.ok) throw new Error(`Lifecycle reset failed: ${res.statusText}`);\n return res.json();\n }\n\n async resetBIEvents(): Promise {\n const res = await apiFetch(\"/api/reset-bi-events\", { method: \"DELETE\" });\n if (!res.ok) throw new Error(`BI events reset failed: ${res.statusText}`);\n return res.json();\n }\n\n async resetDeveloperFiles(): Promise {\n const res = await apiFetch(\"/api/reset-developer-files\", { method: \"DELETE\" });\n if (!res.ok) throw new Error(`Developer Files reset failed: ${res.statusText}`);\n return res.json();\n }\n\n async resetACLSimulationTables(): Promise {\n const res = await apiFetch(\"/api/reset-acl-simulation-tables\", { method: \"DELETE\" });\n if (!res.ok) throw new Error(`ACL Simulation Tables reset failed: ${res.statusText}`);\n return res.json();\n }\n\n async resetExecutables(): Promise {\n const res = await apiFetch(\"/api/reset-executables\", { method: \"DELETE\" });\n // The body says what happened (a table reset whose job keys were left\n // reads differently from a reset that did not run).\n if (!res.ok) throw new Error(await responseErrorText(res, `Executables reset failed: ${res.statusText}`));\n return res.json();\n }\n\n async resetServices(): Promise {\n const res = await apiFetch(\"/api/reset-services\", { method: \"DELETE\" });\n if (!res.ok) throw new Error(`Services reset failed: ${res.statusText}`);\n return res.json();\n }\n\n async resyncSecrets(): Promise {\n const res = await apiFetch(\"/api/admin/resync-secrets\", { method: \"POST\" });\n if (!res.ok) throw new Error(await responseErrorText(res, `Resync secrets failed: ${res.statusText}`));\n return res.json();\n }\n\n async resetMPNotifications(): Promise {\n const res = await apiFetch(\"/api/reset-mp-notifications\", { method: \"DELETE\" });\n if (!res.ok) throw new Error(`MP\u2011notifications reset failed: ${res.statusText}`);\n return res.json();\n }\n\n // resetDriftReportTables\n async resetDriftReportTables(): Promise {\n const res = await apiFetch(\"/api/reset-drift-report-tables\", { method: \"DELETE\" });\n if (!res.ok) throw new Error(`Drift Report Tables reset failed: ${res.statusText}`);\n return res.json();\n }\n\n async resetMPReleaseTables(): Promise {\n const res = await apiFetch(\"/api/reset-mp-release-tables\", { method: \"DELETE\" });\n if (!res.ok) throw new Error(`MP Release Tables reset failed: ${res.statusText}`);\n return res.json();\n }\n\n async resetNodeBuckets(): Promise {\n const res = await apiFetch(\"/api/reset-node-buckets\", { method: \"DELETE\" });\n if (!res.ok) throw new Error(`Node Buckets reset failed: ${res.statusText}`);\n return res.json();\n }\n\n async resetNodeBackups(): Promise {\n const res = await apiFetch(\"/api/reset-node-backups\", { method: \"DELETE\" });\n if (!res.ok) throw new Error(`Node Backups reset failed: ${res.statusText}`);\n return res.json();\n }\n\n\n async listBlockedDomains(): Promise {\n const resp = await apiFetch(\"/api/unauthorized-domains\");\n if (!resp.ok) throw new Error(`list blocked domains failed: ${resp.statusText}`);\n const j = await resp.json();\n return (j[\"domains\"] ?? []) as BlockedDomain[];\n }\n\n async blockDomain(domain: string, reason: string): Promise {\n const resp = await apiFetch(\"/api/unauthorized-domains\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ domain, reason }),\n });\n if (!resp.ok) throw new Error(`block domain failed: ${resp.statusText}`);\n }\n\n async unblockDomain(domain: string): Promise {\n const resp = await apiFetch(`/api/unauthorized-domains/${encodeURIComponent(domain)}`, {\n method: \"DELETE\",\n });\n if (!resp.ok) throw new Error(`unblock domain failed: ${resp.statusText}`);\n }\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n Connector redirect allowlist (Admin tab)\n \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n async listConnectorRedirects(): Promise {\n const resp = await apiFetch(\"/api/connector-redirects\");\n if (!resp.ok) throw new Error(`list connector redirects failed: ${resp.statusText}`);\n const j = await resp.json();\n return (j[\"patterns\"] ?? []) as ConnectorRedirect[];\n }\n\n async addConnectorRedirect(pattern: string, label: string, notes: string): Promise {\n const resp = await apiFetch(\"/api/connector-redirects\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ pattern, label, notes }),\n });\n if (!resp.ok) {\n // Validation failures carry a descriptive message (\"host wildcards are\n // not allowed\", \u2026) \u2014 surface it rather than the bare status text.\n const msg = (await resp.json().catch(() => null))?.error ?? resp.statusText;\n throw new Error(`add connector redirect failed: ${msg}`);\n }\n }\n\n async removeConnectorRedirect(pattern: string): Promise {\n // Patterns contain slashes, so the pattern travels as a query parameter.\n const resp = await apiFetch(`/api/connector-redirects?pattern=${encodeURIComponent(pattern)}`, {\n method: \"DELETE\",\n });\n if (!resp.ok) throw new Error(`remove connector redirect failed: ${resp.statusText}`);\n }\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n Partner registry (Admin \u2192 Partners)\n \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n async listPartners(): Promise {\n const r = await apiFetch(\"/api/mssps\");\n if (!r.ok) throw new Error(`list partners failed: ${r.statusText}`);\n const j = await r.json();\n return { partners: (j[\"partners\"] ?? []) as PartnerInfo[], sweep: (j[\"sweep\"] ?? null) as PartnerSweepState | null };\n }\n\n // Re-derive the registry from the management plane now (same path as the\n // hourly sweep). Resolves once the sweep has finished.\n async runPartnerSweep(): Promise {\n const r = await apiFetch(\"/api/mssps/sweep\", { method: \"POST\" });\n if (!r.ok) throw new Error(await r.text());\n const j = await r.json();\n return (j[\"sweep\"] ?? null) as PartnerSweepState | null;\n }\n\n async updatePartnerNoun(msspId: string, nodeNoun: string): Promise {\n const r = await apiFetch(`/api/mssps/${encodeURIComponent(msspId)}`, {\n method: \"PUT\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ node_noun: nodeNoun }),\n });\n if (!r.ok) throw new Error(await r.text());\n return await r.json();\n }\n\n // Only legacy rows the sweep never matched (orphan) can be deleted; the\n // server refuses the rest.\n async deletePartner(msspId: string): Promise {\n const r = await apiFetch(`/api/mssps/${encodeURIComponent(msspId)}`, { method: \"DELETE\" });\n if (!r.ok) throw new Error(await r.text());\n }\n\n // Full tenant roster from the admin-gated /api/tenants \u2014 feeds the\n // Partners tab's id\u2192name resolution.\n // \u2500\u2500 Vulnerability Sources tab (ENG-4292) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n async listVulnSources(tenantId: string): Promise {\n const r = await apiFetch(`/api/tenants/${encodeURIComponent(tenantId)}/vuln-sources`);\n if (!r.ok) throw new Error(`list vulnerability sources failed: ${r.statusText}`);\n const j = await r.json();\n return {\n sources: (j.sources ?? []) as VulnSourceSummary[],\n formats: (j.formats ?? []) as string[],\n sourceIds: (j.sourceIds ?? []) as string[],\n };\n }\n\n async listVulnSourceFiles(tenantId: string): Promise {\n const r = await apiFetch(`/api/tenants/${encodeURIComponent(tenantId)}/vuln-sources/files`);\n if (!r.ok) throw new Error(`list vulnerability source files failed: ${r.statusText}`);\n const j = await r.json();\n return (j.files ?? []) as VulnSourceFile[];\n }\n\n async listVulnSourceNodes(tenantId: string): Promise {\n const r = await apiFetch(`/api/tenants/${encodeURIComponent(tenantId)}/vuln-sources/nodes`);\n if (!r.ok) throw new Error(`list node update state failed: ${r.statusText}`);\n const j = await r.json();\n return (j.nodes ?? []) as VulnSourceNode[];\n }\n\n private connectorsBase(tenantId: string): string {\n return `/api/tenants/${encodeURIComponent(tenantId)}/vuln-sources/connectors`;\n }\n\n private async connectorError(r: Response, fallback: string): Promise {\n try {\n const j = await r.json();\n if (j?.error) return new Error(String(j.error));\n } catch { /* not JSON */ }\n return new Error(`${fallback}: ${r.statusText}`);\n }\n\n async listVulnConnectors(tenantId: string): Promise<{ connectors: VulnConnector[]; vendors: VulnConnectorVendor[] }> {\n const r = await apiFetch(this.connectorsBase(tenantId));\n if (!r.ok) throw await this.connectorError(r, 'list connectors failed');\n const j = await r.json();\n return { connectors: (j.connectors ?? []) as VulnConnector[], vendors: (j.vendors ?? []) as VulnConnectorVendor[] };\n }\n\n async createVulnConnector(tenantId: string, input: VulnConnectorInput): Promise {\n const r = await apiFetch(this.connectorsBase(tenantId), {\n method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input),\n });\n if (!r.ok) throw await this.connectorError(r, 'create connector failed');\n return (await r.json()).connector as VulnConnector;\n }\n\n async updateVulnConnector(tenantId: string, id: number, input: VulnConnectorInput): Promise {\n const r = await apiFetch(`${this.connectorsBase(tenantId)}/${id}`, {\n method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input),\n });\n if (!r.ok) throw await this.connectorError(r, 'update connector failed');\n return (await r.json()).connector as VulnConnector;\n }\n\n async setVulnConnectorEnabled(tenantId: string, id: number, enabled: boolean): Promise {\n const r = await apiFetch(`${this.connectorsBase(tenantId)}/${id}/enabled`, {\n method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled }),\n });\n if (!r.ok) throw await this.connectorError(r, 'update connector failed');\n }\n\n async deleteVulnConnector(tenantId: string, id: number): Promise {\n const r = await apiFetch(`${this.connectorsBase(tenantId)}/${id}`, { method: 'DELETE' });\n if (!r.ok) throw await this.connectorError(r, 'delete connector failed');\n }\n\n async testVulnConnector(tenantId: string, id: number): Promise<{ ok: boolean; message?: string; error?: string }> {\n const r = await apiFetch(`${this.connectorsBase(tenantId)}/${id}/test`, { method: 'POST' });\n if (!r.ok) throw await this.connectorError(r, 'test connector failed');\n return await r.json();\n }\n\n async runVulnConnector(tenantId: string, id: number): Promise {\n const r = await apiFetch(`${this.connectorsBase(tenantId)}/${id}/run`, { method: 'POST' });\n if (!r.ok) throw await this.connectorError(r, 'run connector failed');\n return (await r.json()).runId as number;\n }\n\n async listVulnConnectorRuns(tenantId: string, id: number): Promise {\n const r = await apiFetch(`${this.connectorsBase(tenantId)}/${id}/runs`);\n if (!r.ok) throw await this.connectorError(r, 'list runs failed');\n return ((await r.json()).runs ?? []) as VulnConnectorRun[];\n }\n\n // signal aborts a superseded load: the server then cancels its queries.\n async getAPIPerfReport(q: APIPerfQuery, signal?: AbortSignal): Promise {\n const p = new URLSearchParams({ days: String(q.days) });\n if (q.tenantId) p.set(\"tenantId\", q.tenantId);\n if (q.build) p.set(\"build\", q.build);\n const r = await apiFetch(`/api/api-perf?${p.toString()}`, { signal });\n if (!r.ok) throw new Error(await responseErrorText(r, `API performance failed: ${r.statusText}`));\n const j = await r.json();\n return {\n from: j.from ?? \"\",\n to: j.to ?? \"\",\n routes: j.routes ?? [],\n builds: j.builds ?? [],\n tenants: j.tenants ?? [],\n tenantNames: j.tenantNames ?? {},\n regressions: j.regressions ?? [],\n lastRun: j.lastRun ?? null,\n };\n }\n\n // The rows, and the last day (YYYY-MM-DD) of the window they cover.\n async compareAPIPerfBuilds(q: Omit, a: string, b: string, signal?: AbortSignal): Promise<{ to: string; rows: APIPerfCompareRow[] }> {\n const p = new URLSearchParams({ days: String(q.days), a, b });\n if (q.tenantId) p.set(\"tenantId\", q.tenantId);\n const r = await apiFetch(`/api/api-perf/compare?${p.toString()}`, { signal });\n if (!r.ok) throw new Error(await responseErrorText(r, `API performance compare failed: ${r.statusText}`));\n const j = await r.json();\n return { to: j.to ?? \"\", rows: j.rows ?? [] };\n }\n\n async listAdminTenants(): Promise {\n const r = await apiFetch(\"/api/tenants\");\n if (!r.ok) throw new Error(`list tenants failed: ${r.statusText}`);\n const j = await r.json();\n return ((j[\"tenants\"] ?? []) as Array<{ id: string; name: string; customerEnvironment?: string }>)\n .filter(t => !!t.id)\n .map(t => ({ id: t.id, name: t.name || t.id, customerEnvironment: (t.customerEnvironment ?? '').trim() }));\n }\n\n async deleteExecutable(sha256: string): Promise {\n const resp = await apiFetch(`/api/executables/${sha256}`, { method: \"DELETE\" });\n if (!resp.ok) throw new Error(`delete executable failed: ${resp.statusText}`);\n }\n\n async editExecutable(\n sha256: string,\n patch: Record\n ): Promise {\n const resp = await apiFetch(`/api/executables/${sha256}`, {\n method: \"PUT\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify(patch),\n });\n if (!resp.ok) {\n throw new Error(`edit executable failed: ${resp.status} ${resp.statusText}`);\n }\n return resp.json();\n }\n\n\n /**\n * One page of the review queue, oldest first. `truncated` says more rows\n * matched than the server's `limit` returned, so the page can say the\n * list is cut rather than show it as the whole queue.\n */\n async listSiteAccessRequests(status?: string): Promise {\n const q = status ? `?status=${encodeURIComponent(status)}` : \"\";\n const r = await apiFetch(`/api/site-access/requests${q}`);\n if (!r.ok) throw new Error(`list access requests failed: ${r.statusText}`);\n const j = await r.json();\n return {\n requests: (j[\"requests\"] ?? []) as SiteAccessRequest[],\n truncated: j[\"truncated\"] === true,\n limit: typeof j[\"limit\"] === \"number\" ? j[\"limit\"] : 0,\n };\n }\n\n async inviteSiteUser(email: string): Promise {\n const r = await apiFetch(\"/api/site-access/invite\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ email }),\n });\n const j = await r.json().catch(() => ({}));\n if (!r.ok) throw new Error(j[\"error\"] ?? r.statusText);\n return j as InviteResult;\n }\n\n async deleteSiteAccessRequest(email: string): Promise {\n const r = await apiFetch(\"/api/site-access/delete\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ email }),\n });\n if (!r.ok) throw new Error(`delete failed: ${r.statusText}`);\n }\n\n async declineSiteAccessRequest(email: string): Promise {\n const r = await apiFetch(\"/api/site-access/decline\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ email }),\n });\n // 409: the invitee created their account, which declining cannot undo.\n if (r.status === 409) throw new SiteAccessAlreadyAcceptedError();\n if (!r.ok) throw new Error(`decline failed: ${r.statusText}`);\n }\n\n}\n", "/**\n * The error text a JSON error body carries: its `error`, else its `message`,\n * with any `failures` listed after it; `fallback` when the body says nothing\n * (not JSON, or neither field). A store throws this instead of the bare\n * status text, so the page shows what the server said went wrong.\n *\n * Dependency-free so the node test runner can load it from the tsc output.\n */\nexport function bodyErrorText(body: unknown, fallback: string): string {\n if (!body || typeof body !== 'object') return fallback;\n const b = body as { error?: unknown; message?: unknown; failures?: unknown };\n const pick = (v: unknown): string => (typeof v === 'string' ? v.trim() : '');\n let text = pick(b.error) || pick(b.message);\n if (!text) return fallback;\n if (Array.isArray(b.failures) && b.failures.length) {\n text += ` (${b.failures.map(f => (typeof f === 'string' ? f : JSON.stringify(f))).join('; ')})`;\n }\n return text;\n}\n\n/** bodyErrorText for a response, reading its body as JSON when it can. */\nexport async function responseErrorText(resp: Response, fallback: string): Promise {\n let body: unknown = null;\n try {\n body = await resp.json();\n } catch {\n /* not JSON: the fallback */\n }\n return bodyErrorText(body, fallback);\n}\n", "export interface IArtifact {\n id: string;\n nodeIncidentId: string;\n title: string;\n name: string;\n filename: string;\n size: number;\n compressedSize: number;\n isCompressed: boolean;\n}\n\nexport interface IArtifactCategory {\n title: string;\n order: number;\n artifacts: IArtifact[];\n}\n\nexport class ArtifactCategory implements IArtifactCategory {\n title: string;\n order: number;\n artifacts: Artifact[];\n\n constructor(data: any) {\n this.title = data.title;\n this.artifacts = data.artifacts.map((artifactData: any) => new Artifact(artifactData));\n }\n}\n\n\n// Artifacts implements IArtifacts\nexport class Artifact implements IArtifact {\n id: string;\n nodeIncidentId: string;\n title: string;\n name: string;\n filename: string;\n size: number;\n compressedSize: number;\n isCompressed: boolean;\n\n\n constructor(data: any) {\n this.id = data.id;\n this.nodeIncidentId = data.nodeIncidentId;\n this.title = data.title;\n this.name = data.name;\n this.filename = data.filename;\n this.size = data.size;\n this.compressedSize = data.compressedSize;\n this.isCompressed = data.isCompressed;\n }\n\n}\n", "import { Artifact, ArtifactCategory } from '../models/Artifact';\nimport { apiFetch } from '../utils/Api';\nimport { storage } from '../utils/Storage';\n\n// Categories which have a specified order (not all do in which case it's sorted by Name)\nconst orderedCategories = new Map([\n ['Compromised Files', 1],\n ['Compromised Registry Keys', 2],\n ['Node Events', 3],\n ['Filesystem Maps', 4],\n ['Registry Maps', 5],\n ['Config Files', 6],\n]);\n\n\nexport default class ArtifactsStore {\n /**\n * nodeIncidentID -> array of IArtifactCategory\n * If no data or error, store null.\n */\n private categoriesMap: Map = new Map();\n\n /**\n * Loads the artifacts from the server as a plain array, then groups them\n * by title. Finally, it converts them to IArtifactCategory[] and caches\n * them in `categoriesMap`.\n */\n private load(incidentId: string, nodeIncidentId: string, callback?: () => void): void {\n const tenantId = storage.get('selectedTenantId')\n apiFetch(`/api/tenants/${tenantId}/incidents/${incidentId}/node-incidents/${nodeIncidentId}/artifacts`)\n .then(response => {\n if (!response.ok) {\n throw new Error('Failed to fetch artifacts');\n }\n return response.json();\n })\n .then(data => {\n const artifacts = data.artifacts || [];\n\n // Group them by title\n const categoryMap = new Map();\n artifacts.forEach((artifactData: any) => {\n const artifact = new Artifact(artifactData);\n if (!categoryMap.has(artifact.title)) {\n categoryMap.set(artifact.title, []);\n }\n categoryMap.get(artifact.title)?.push(artifact);\n });\n\n // Convert Map => array of ArtifactCategory objects\n const categories: ArtifactCategory[] = [];\n for (const [title, artifacts] of categoryMap.entries()) {\n categories.push({\n title,\n order: orderedCategories.get(title) || Number.MAX_SAFE_INTEGER,\n artifacts,\n });\n }\n\n // Now sort the categories. First by `order`, then (if tied) by `title`.\n categories.sort((a, b) => {\n // Compare the numeric order\n if (a.order !== b.order) {\n return a.order - b.order;\n }\n // If the order is the same, fallback to alphabetical\n return a.title.localeCompare(b.title);\n });\n\n // Cache it\n this.categoriesMap.set(nodeIncidentId, categories);\n\n if (callback) {\n callback();\n }\n })\n .catch(error => {\n console.error('Failed to load artifacts:', error);\n // In case of error, store null to mark a load failure\n this.categoriesMap.set(nodeIncidentId, null);\n\n if (callback) {\n callback();\n }\n });\n }\n\n public get(\n incidentId: string,\n nodeIncidentID: string,\n callback: (categories: ArtifactCategory[] | null) => void\n ): void {\n const cached = this.categoriesMap.get(nodeIncidentID);\n if (cached) {\n // Already loaded\n callback(cached);\n } else {\n // Not in cache => load from server\n this.load(incidentId, nodeIncidentID, () => {\n callback(this.categoriesMap.get(nodeIncidentID) || null);\n });\n }\n }\n\n public getAsync(incidentId: string, nodeIncidentID: string): Promise {\n return new Promise((resolve) => {\n this.get(incidentId, nodeIncidentID, (categories) => resolve(categories));\n });\n }\n\n public viewJson(url: string, callback: (json: any) => void) {\n apiFetch(url)\n .then(response => {\n if (!response.ok) {\n throw new Error(`API Error: ${response.status} ${response.statusText}`);\n }\n return response.json();\n })\n .then((json) => {\n callback(json);\n })\n .catch(error => {\n console.error('Artifacts store: failed to view artifact', error);\n callback(undefined);\n });\n }\n}\n", "// static/ts/stores/EnvironmentStore.ts\n// -------------------------------------\n\nimport { apiFetch } from \"../utils/Api\";\nimport { rememberMPHosts } from \"../utils/MPHosts\";\nimport { storage } from \"../utils/Storage\";\n\n/** Shape returned by GET /api/env/environment */\ninterface EnvResponse {\n environment: string;\n isRunningInGCP: boolean;\n mpAppBaseURL?: string;\n insightsEnabled: boolean;\n onyxEnrichmentEnabled?: boolean;\n zammadEnabled?: boolean;\n ticketProvider?: string;\n nodeReleasesEnabled: boolean;\n incidentioEnabled: boolean;\n}\n\nconst ENDPOINT = '/api/env/environment';\n\nexport type Environment = \"qa\" | \"uat\" | \"prod\";\n\nexport default class EnvironmentStore {\n private env: Environment | null = null;\n private inGcp = false;\n private insightsEnabled = false;\n // Default true: an older backend without the field keeps today's behavior.\n private onyxEnrichmentEnabled = true;\n private zammadEnabled = false;\n private ticketProviderName = \"\";\n private nodeReleasesEnabled = false;\n private incidentioEnabled = false;\n\n /** One-shot fetch; call this once during app bootstrap. */\n async load(): Promise {\n try {\n const resp = await apiFetch(ENDPOINT);\n if (!resp.ok) {\n console.error(`ENV fetch failed (${resp.status})`);\n return;\n }\n\n const data: EnvResponse = await resp.json();\n this.env = data.environment?.toLowerCase() as Environment || null;\n this.inGcp = data.isRunningInGCP || false;\n\n this.insightsEnabled = !!data.insightsEnabled\n this.onyxEnrichmentEnabled = data.onyxEnrichmentEnabled !== false\n this.zammadEnabled = !!data.zammadEnabled\n this.ticketProviderName = data.ticketProvider || \"\"\n this.nodeReleasesEnabled = !!data.nodeReleasesEnabled\n this.incidentioEnabled = !!data.incidentioEnabled\n\n if (this.env) {\n console.debug('ENV loaded:', this.env);\n storage.set('env', this.env);\n }\n // Cache the MP origin config beside env \u2014 the login-redirect and\n // framed break-out paths read it from storage, possibly pre-auth on\n // a later visit (see utils/MPHosts.ts).\n rememberMPHosts(data.mpAppBaseURL);\n } catch (err) {\n console.error('ENV fetch error:', err);\n }\n }\n\n /** `\"qa\" | \"uat\" | \"prod\"` */\n get environment(): string | null {\n return this.env;\n }\n\n /** Convenience helper \u2013 true after `load()` and a non-null value */\n get isLoaded(): boolean {\n return this.env !== null;\n }\n\n get isRunningInGCP(): boolean { return this.inGcp; }\n\n get isInsightsEnabled(): boolean { return this.insightsEnabled; }\n\n get isOnyxEnrichmentEnabled(): boolean { return this.onyxEnrichmentEnabled; }\n\n /** True when the backend has a configured Zammad integration. Gates the\n * Zammad Orgs / Tickets sidebar entries + pages. */\n get isZammadEnabled(): boolean { return this.zammadEnabled; }\n\n /** Active ticket provider (\"zammad\" or \"\" when none is configured). Decides\n * whether Notifications offers \"Create Ticket\" or the explicit \"Create\n * Zammad Ticket\" action. */\n get ticketProvider(): string { return this.ticketProviderName; }\n\n /** Mirrors Dashcam's `nodeReleasesEnabled` capability \u2014 true only when\n * the env has a provisioned NODE_RELEASES_TOKEN. Used to hide the\n * Release Status nav entry in envs that don't ingest. */\n get isNodeReleasesEnabled(): boolean { return this.nodeReleasesEnabled; }\n\n /** True when the backend has INCIDENTIO_ESCALATIONS_API_KEY seeded\n * (currently uat / prod only). Used to hide the Incidents sidebar entry\n * + skip the page in envs without the integration. */\n get isIncidentioEnabled(): boolean { return this.incidentioEnabled; }\n\n /** Clear cached value (rarely needed). */\n clear(): void {\n this.env = null;\n this.inGcp = false;\n this.insightsEnabled = false;\n this.zammadEnabled = false;\n this.ticketProviderName = \"\";\n this.nodeReleasesEnabled = false;\n this.incidentioEnabled = false;\n storage.remove('env');\n }\n}\n", "// event.model.ts\n\n/*\n * =================================================================================\n * This data model is based on the new Go backend \"galactus\" event structures.\n *\n * The top-level `Event` class acts as a discriminated union, similar to the\n * custom UnmarshalJSON function in Go's `event.go`. It inspects the\n * `operationName` field of the incoming raw data and instantiates the\n * appropriate sub-model (`Signal`, `Hallmark`, `Deflection`, or `KernelRecord`).\n *\n * Each sub-model is responsible for parsing its specific fields from the raw\n * data, mapping JSON snake_case names to TypeScript camelCase properties.\n *\n * Helper methods in the `Event` class have been rewritten to navigate this\n * new structure to extract relevant information.\n * =================================================================================\n */\n\n// #region Constants\n// ---------------------------------------------------------------------------------\n// Constants ported from galactus/event.go\n// ---------------------------------------------------------------------------------\n\nexport const STATUS_BLOCKED_BY_MIMIC_NODE = 0xE0000022;\nexport const STATUS_INVALID_PARAMETER = 0xC000000D;\n\n// File Access Constants (for decoding access masks)\nexport const FILE_READ_DATA = 0x00000001;\nexport const FILE_WRITE_DATA = 0x00000002;\nexport const FILE_APPEND_DATA = 0x00000004;\n\nexport const READ_CONTROL = 0x00020000;\nexport const WRITE_DAC = 0x00040000;\nexport const WRITE_OWNER = 0x00080000;\nexport const FILE_READ_ATTRIBUTES = 0x00000080;\nexport const FILE_WRITE_ATTRIBUTES = 0x00000100;\n\n// #endregion\n\n\n// helpers\n// NORMALIZE a path_type coming as string or number to our union type\nfunction normalizePathType(pt: any): PathType | undefined {\n if (pt === null || pt === undefined) return undefined;\n if (typeof pt === 'number') {\n if (pt === 1) return 'path';\n if (pt === 2) return 'registry';\n if (pt === 3) return 'object';\n return undefined;\n }\n const s = String(pt).trim().toLowerCase();\n if (s === '1' || s === 'path') return 'path';\n if (s === '2' || s === 'registry') return 'registry';\n if (s === '3' || s === 'object') return 'object';\n return undefined;\n}\n\n// Parse a JSON array that may be an actual array, a stringified array, or empty.\nfunction tryParseJSONArray(raw: any): any[] | undefined {\n if (!raw) return undefined;\n if (Array.isArray(raw)) return raw;\n if (typeof raw === 'string') {\n const s = raw.trim();\n if (!s) return undefined;\n try {\n const arr = JSON.parse(s);\n return Array.isArray(arr) ? arr : undefined;\n } catch { /* swallow */ }\n }\n return undefined;\n}\n\n\n\n// #region Type Aliases\n// ---------------------------------------------------------------------------------\n// Core type definitions based on Go custom types\n// ---------------------------------------------------------------------------------\n\n\nexport type OperationType =\n | 'Signal'\n | 'Hallmark'\n | 'Deflection'\n | 'KernelRecord'\n | 'Behavior'\n | 'Treescan'\n | AccessPatternOperation;\n\n/**\n * The access-pattern plugins' operation names (ENG-4548). Nodes that run\n * them instead of the profiler carry their file / registry / cross-process /\n * network / execution activity here, not in `Behavior` (which on those nodes\n * holds only Authenticode records). `*_record` is emitted once per pattern\n * with its identity; `*_counts` is the periodic volume for an access_id.\n */\nexport type AccessPatternOperation =\n | 'file_access_record' | 'file_access_counts'\n | 'registry_access_record' | 'registry_access_counts'\n | 'cross_process_access_record' | 'cross_process_access_counts'\n | 'network_access_record' | 'network_access_counts'\n | 'execution_record' | 'execution_counts';\n\n/** The five `*_record` access-pattern operations. */\nexport const ACCESS_PATTERN_RECORD_OPS: ReadonlySet = new Set([\n 'file_access_record',\n 'registry_access_record',\n 'cross_process_access_record',\n 'network_access_record',\n 'execution_record',\n]);\n\n// #endregion\n\n// #region Signal/Hallmark/Deflection/Behavior Payloads\n// ---------------------------------------------------------------------------------\n// Interfaces and classes for reusable sub-payloads defined in events_payloads.go\n// These are used by Signal, Hallmark, Deflection, and Behavior events.\n// ---------------------------------------------------------------------------------\n\n\nexport interface IProcessCreate {\n pid?: number;\n actorId?: string;\n parentId?: string;\n creatorId?: string;\n volumeId?: number;\n commandLine?: string;\n resourcePath?: string;\n sidDomainName?: string;\n sidAccountName?: string;\n sid?: string;\n sidNameUse?: string;\n}\n\nexport class ProcessCreate implements IProcessCreate, HasEventDetails {\n pid?: number;\n actorId?: string;\n parentId?: string;\n creatorId?: string;\n volumeId?: number;\n commandLine?: string;\n resourcePath?: string;\n sidDomainName?: string;\n sidAccountName?: string;\n sid?: string;\n sidNameUse?: string;\n\n constructor(data: any) {\n this.pid = parseNumeric(data.pid);\n this.actorId = parseIdString(data.actor_id);\n this.parentId = parseIdString(data.parent_id);\n this.creatorId = parseIdString(data.creator_id);\n this.volumeId = parseNumeric(data.volume_id);\n this.commandLine = data.command_line;\n this.resourcePath = data.resource_path;\n this.sidDomainName = data.sid_domain_name;\n this.sidAccountName = data.sid_account_name;\n this.sid = data.sid;\n this.sidNameUse = data.sid_name_use;\n }\n\n toEventDetailsMap(): Map {\n const out: [string, any][] = [];\n if (this.actorId) out.push(['actorId', this.actorId]);\n if (this.pid != null) out.push(['pid', this.pid]);\n if (this.parentId) out.push(['parentId', this.parentId]);\n if (this.creatorId) out.push(['creatorId', this.creatorId]);\n if (this.volumeId != null) out.push(['volumeId', this.volumeId]);\n if (this.resourcePath) out.push(['resourcePath', this.resourcePath]);\n if (this.commandLine) out.push(['commandLine', this.commandLine]);\n if (this.sidDomainName || this.sidAccountName) {\n const user = this.sidDomainName && this.sidAccountName\n ? `${this.sidDomainName}/${this.sidAccountName}`\n : (this.sidAccountName ?? this.sidDomainName);\n if (user) out.push(['user', user]);\n }\n if (this.sid) out.push(['sid', this.sid]);\n if (this.sidNameUse) out.push(['sidNameUse', this.sidNameUse]);\n const m = new Map(out); cleanUpKeys(m); return m;\n }\n}\n\nexport interface IHandleIO {\n resourcePath?: string;\n readCount?: number;\n writeCount?: number;\n bytesRead?: number;\n bytesWritten?: number;\n volumeId?: number;\n actorId?: string;\n aclRuleId?: number;\n initialName?: string;\n latestName?: string;\n createFileId?: number;\n createInitialSize?: number;\n closeFinalSize?: number;\n\n creatorUniqueId?: string;\n processExecutablePath?: string;\n processCommandLine?: string;\n\n createDesiredAccess?: number;\n createDisposition?: number;\n createEnumStateDepth?: number;\n createEnumStateWellKnown?: number;\n createGrantedAccess?: number;\n createHandleType?: number;\n createOptions?: number;\n createOutcome?: number;\n createShareAccess?: number;\n closeStreamState?: number;\n handleId?: number;\n}\n\nexport class HandleIO implements IHandleIO, HasEventDetails {\n resourcePath?: string;\n readCount?: number;\n writeCount?: number;\n bytesRead?: number;\n bytesWritten?: number;\n volumeId?: number;\n actorId?: string;\n aclRuleId?: number;\n initialName?: string;\n latestName?: string;\n createFileId?: number;\n createInitialSize?: number;\n closeFinalSize?: number;\n\n creatorUniqueId?: string;\n processExecutablePath?: string;\n processCommandLine?: string;\n\n createDesiredAccess?: number;\n createDisposition?: number;\n createEnumStateDepth?: number;\n createEnumStateWellKnown?: number;\n createGrantedAccess?: number;\n createHandleType?: number;\n createOptions?: number;\n createOutcome?: number;\n createShareAccess?: number;\n closeStreamState?: number;\n handleId?: number;\n\n constructor(d: any) {\n this.resourcePath = d.resource_path;\n this.readCount = parseNumeric(d.read_count);\n this.writeCount = parseNumeric(d.write_count);\n this.bytesRead = parseNumeric(d.bytes_read);\n this.bytesWritten = parseNumeric(d.bytes_written);\n this.volumeId = parseNumeric(d.volume_id);\n this.actorId = parseIdString(d.actor_id);\n this.aclRuleId = parseNumeric(d.acl_rule_id);\n this.initialName = d.initial_name;\n this.latestName = d.latest_name;\n this.createFileId = parseNumeric(d.create_file_id);\n this.createInitialSize = parseNumeric(d.create_initial_size);\n this.closeFinalSize = parseNumeric(d.close_final_size);\n this.creatorUniqueId = parseIdString(d.creator_unique_id);\n this.processExecutablePath = d.process_executable_path;\n this.processCommandLine = d.process_command_line;\n\n this.createDesiredAccess = parseNumeric(d.create_desired_access);\n this.createDisposition = parseNumeric(d.create_disposition);\n this.createEnumStateDepth = parseNumeric(d.create_enum_state_depth);\n this.createEnumStateWellKnown = parseNumeric(d.create_enum_state_well_known);\n this.createGrantedAccess = parseNumeric(d.create_granted_access);\n this.createHandleType = parseNumeric(d.create_handle_type);\n this.createOptions = parseNumeric(d.create_options);\n this.createOutcome = parseNumeric(d.create_outcome);\n this.createShareAccess = parseNumeric(d.create_share_access);\n this.closeStreamState = parseNumeric(d.close_stream_state);\n this.handleId = parseNumeric(d.handle_id); // backfilled from handle_unique_id on server\n }\n\n toEventDetailsMap(): Map {\n const out: [string, any][] = [];\n if (this.actorId) out.push(['actorId', this.actorId]);\n if (this.aclRuleId != null) out.push(['aclRuleId', this.aclRuleId]);\n if (this.volumeId != null) out.push(['volumeId', this.volumeId]);\n if (this.readCount != null) out.push(['readCount', this.readCount]);\n if (this.writeCount != null) out.push(['writeCount', this.writeCount]);\n if (this.bytesRead != null) out.push(['bytesRead', this.bytesRead]);\n if (this.bytesWritten != null) out.push(['bytesWritten', this.bytesWritten]);\n if (this.resourcePath) out.push(['resourcePath', this.resourcePath]);\n if (this.initialName) out.push(['initialName', this.initialName]);\n if (this.latestName) out.push(['latestName', this.latestName]);\n if (this.creatorUniqueId) out.push(['creatorUniqueId', this.creatorUniqueId]);\n if (this.processExecutablePath) out.push(['processExecutablePath', this.processExecutablePath]);\n if (this.processCommandLine) out.push(['processCommandLine', this.processCommandLine]);\n\n if (this.createDesiredAccess != null) out.push(['createDesiredAccess', this.createDesiredAccess]);\n if (this.createDisposition != null) out.push(['createDisposition', this.createDisposition]);\n if (this.createEnumStateDepth != null) out.push(['createEnumStateDepth', this.createEnumStateDepth]);\n if (this.createEnumStateWellKnown != null) out.push(['createEnumStateWellKnown', this.createEnumStateWellKnown]);\n if (this.createGrantedAccess != null) out.push(['createGrantedAccess', this.createGrantedAccess]);\n if (this.createHandleType != null) out.push(['createHandleType', this.createHandleType]);\n if (this.createOptions != null) out.push(['createOptions', this.createOptions]);\n if (this.createOutcome != null) out.push(['createOutcome', this.createOutcome]);\n if (this.createShareAccess != null) out.push(['createShareAccess', this.createShareAccess]);\n if (this.closeStreamState != null) out.push(['closeStreamState', this.closeStreamState]);\n if (this.handleId != null) out.push(['handleId', this.handleId]);\n const m = new Map(out); cleanUpKeys(m); return m;\n }\n}\n\nexport interface ICreateIO {\n name?: string;\n actorId?: string;\n desiredAccess?: number;\n processCommandLine?: string;\n processExecutablePath?: string;\n ruleId?: number;\n handleId?: number;\n}\n\nexport class CreateIO implements ICreateIO, HasEventDetails {\n name?: string;\n actorId?: string;\n desiredAccess?: number;\n processCommandLine?: string;\n processExecutablePath?: string;\n ruleId?: number;\n handleId?: number;\n\n constructor(d: any) {\n this.name = d.name\n this.actorId = parseIdString(d.actor_id)\n this.desiredAccess = parseNumeric(d.desired_access);\n this.processCommandLine = d.process_command_line;\n this.processExecutablePath = d.process_executable_path;\n this.ruleId = parseNumeric(d.rule_id);\n this.handleId = parseNumeric(d.handle_id);\n }\n\n toEventDetailsMap(): Map {\n const out: [string, any][] = [];\n if (this.actorId) out.push(['actorId', this.actorId]);\n if (this.name) out.push(['name', this.name]);\n if (this.desiredAccess != null) out.push(['desiredAccess', this.desiredAccess]);\n if (this.processExecutablePath) out.push(['exePath', this.processExecutablePath]);\n if (this.processCommandLine) out.push(['commandLine', this.processCommandLine]);\n if (this.ruleId != null) out.push(['ruleId', this.ruleId]);\n if (this.handleId != null) out.push(['handleId', this.handleId]);\n const m = new Map(out); cleanUpKeys(m); return m;\n }\n}\n\ninterface ICloseIO {\n name?: string;\n actorId?: string;\n processCommandLine?: string;\n processExecutablePath?: string;\n ruleId?: number;\n}\n\nexport class CloseIO implements ICloseIO, HasEventDetails {\n name?: string;\n actorId?: string;\n processCommandLine?: string;\n processExecutablePath?: string;\n ruleId?: number;\n\n constructor(d: any) {\n this.name = d.name;\n this.actorId = parseIdString(d.actor_id);\n this.processCommandLine = d.process_command_line;\n this.processExecutablePath = d.process_executable_path;\n this.ruleId = parseNumeric(d.rule_id);\n }\n\n toEventDetailsMap(): Map {\n const out: [string, any][] = [];\n if (this.actorId) out.push(['actorId', this.actorId]);\n if (this.name) out.push(['name', this.name]);\n if (this.processExecutablePath) out.push(['exePath', this.processExecutablePath]);\n if (this.processCommandLine) out.push(['commandLine', this.processCommandLine]);\n if (this.ruleId != null) out.push(['ruleId', this.ruleId]);\n const m = new Map(out);\n cleanUpKeys(m);\n return m;\n }\n}\n\n\nexport interface IVolumeMount {\n processId?: string;\n mountFlags?: number;\n mountSource?: string;\n mountTarget?: string;\n filesystemTypeName?: string;\n processCommandLine?: string;\n processExecutablePath?: string;\n ruleId?: number;\n}\n\nexport class VolumeMount implements IVolumeMount, HasEventDetails {\n processId?: string;\n mountFlags?: number;\n mountSource?: string;\n mountTarget?: string;\n filesystemTypeName?: string;\n processCommandLine?: string;\n processExecutablePath?: string;\n ruleId?: number;\n\n constructor(d: any) {\n this.processId = parseIdString(d.process_id);\n this.mountFlags = parseNumeric(d.mount_flags);\n this.mountSource = d.mount_source;\n this.mountTarget = d.mount_target;\n this.filesystemTypeName = d.filesystem_type_name;\n\n this.processCommandLine = d.process_command_line;\n this.processExecutablePath = d.process_executable_path;\n\n this.ruleId = parseNumeric(d.rule_id);\n }\n\n toEventDetailsMap(): Map {\n const out: [string, any][] = [];\n\n if (this.processId) out.push(['processId', this.processId]);\n if (this.mountSource) out.push(['mountSource', this.mountSource]);\n if (this.mountTarget) out.push(['mountTarget', this.mountTarget]);\n if (this.filesystemTypeName) out.push(['filesystemTypeName', this.filesystemTypeName]);\n if (this.mountFlags != null) out.push(['mountFlags', this.mountFlags]); // keep numeric; UI can show hex if desired\n if (this.processExecutablePath) out.push(['exePath', this.processExecutablePath]);\n if (this.processCommandLine) out.push(['commandLine', this.processCommandLine]);\n if (this.ruleId != null) out.push(['ruleId', this.ruleId]);\n\n const m = new Map(out);\n cleanUpKeys(m);\n return m;\n }\n}\n\n\n\nexport interface IFailedIO {\n resourcePath?: string;\n volumeId?: number;\n actorId?: string;\n creatorUniqueId?: string;\n processExecutablePath?: string;\n processCommandLine?: string;\n}\n\nexport class FailedIO implements IFailedIO, HasEventDetails {\n resourcePath?: string;\n volumeId?: number;\n actorId?: string;\n creatorUniqueId?: string;\n processExecutablePath?: string;\n processCommandLine?: string;\n\n constructor(d: any) {\n this.resourcePath = d.resource_path;\n this.volumeId = parseNumeric(d.volume_id);\n this.actorId = parseIdString(d.actor_id);\n this.creatorUniqueId = parseIdString(d.creator_unique_id);\n this.processExecutablePath = d.process_executable_path;\n this.processCommandLine = d.process_command_line;\n }\n\n toEventDetailsMap(): Map {\n const out: [string, any][] = [];\n if (this.actorId) out.push(['actorId', this.actorId]);\n if (this.creatorUniqueId) out.push(['creatorUniqueId', this.creatorUniqueId]);\n if (this.volumeId != null) out.push(['volumeId', this.volumeId]);\n if (this.resourcePath) out.push(['resourcePath', this.resourcePath]);\n if (this.processExecutablePath) out.push(['exePath', this.processExecutablePath]);\n if (this.processCommandLine) out.push(['commandLine', this.processCommandLine]);\n const m = new Map(out); cleanUpKeys(m); return m;\n }\n}\n\nexport interface IRegistryOperation {\n actorId: string;\n creatorUniqueId?: string;\n key: string;\n aux?: string;\n registryOperation: number;\n registryStatus?: number;\n processExecutablePath?: string;\n processCommandLine?: string;\n}\n\nexport class RegistryOperation implements IRegistryOperation, HasEventDetails {\n actorId: string;\n creatorUniqueId?: string;\n key: string;\n aux?: string;\n registryOperation: number;\n registryStatus?: number;\n processExecutablePath?: string;\n processCommandLine?: string;\n\n constructor(d: any) {\n this.actorId = parseIdString(d.actor_id);\n this.creatorUniqueId = parseIdString(d.creator_unique_id);\n this.key = d.registry_key;\n this.aux = d.registry_aux;\n this.registryOperation = Number(d.registry_operation);\n this.registryStatus = parseNumeric(d.registry_status);\n this.processExecutablePath = d.process_executable_path;\n this.processCommandLine = d.process_command_line;\n }\n\n toEventDetailsMap(): Map {\n const out: [string, any][] = [];\n out.push(['actorId', this.actorId]);\n if (this.creatorUniqueId) out.push(['creatorUniqueId', this.creatorUniqueId]);\n out.push(['registryOperation', this.registryOperation]);\n if (this.registryStatus != null) out.push(['registryStatus', this.registryStatus]);\n out.push(['key', this.key]);\n if (this.aux) out.push(['aux', this.aux]);\n if (this.processExecutablePath) out.push(['exePath', this.processExecutablePath]);\n if (this.processCommandLine) out.push(['commandLine', this.processCommandLine]);\n const m = new Map(out); cleanUpKeys(m); return m;\n }\n}\n\nexport interface IObjectAction {\n action?: string;\n actorId: string;\n sourceId?: string;\n targetId?: string;\n victimId?: string;\n grantedAccess?: number;\n requestedAccess?: number;\n accessRestricted?: boolean;\n creatorUniqueId?: string;\n targetCreatorId?: string;\n targetImagePath?: string;\n targetCommandLine?: string;\n processExecutablePath?: string;\n processCommandLine?: string;\n ruleId?: string;\n}\n\nexport class ObjectAction implements IObjectAction {\n action?: string;\n actorId: string;\n sourceId?: string;\n targetId?: string;\n victimId?: string;\n grantedAccess?: number;\n requestedAccess?: number;\n accessRestricted?: boolean;\n creatorUniqueId?: string;\n targetCreatorId?: string;\n targetImagePath?: string;\n targetCommandLine?: string;\n processExecutablePath?: string;\n processCommandLine?: string;\n ruleId?: string;\n\n constructor(d: any) {\n this.actorId = parseIdString(d.actor_id);\n this.sourceId = parseIdString(d.source_id);\n this.targetId = parseIdString(d.target_id);\n this.victimId = parseIdString(d.victim_id);\n this.grantedAccess = parseNumeric(d.access_granted);\n this.requestedAccess = parseNumeric(d.access_requested);\n this.accessRestricted = d.access_restricted === true;\n this.creatorUniqueId = parseIdString(d.creator_unique_id);\n this.targetCreatorId = parseIdString(d.target_creator_id);\n this.targetImagePath = d.target_image_path;\n this.targetCommandLine = d.target_command_line;\n this.processExecutablePath = d.process_executable_path;\n this.processCommandLine = d.process_command_line;\n this.action = d.action;\n this.ruleId = d.rule_id;\n }\n\n toEventDetailsMap(): Map {\n const out: [string, any][] = [];\n out.push(['actorId', this.actorId]);\n if (this.sourceId) out.push(['sourceId', this.sourceId]);\n if (this.targetId) out.push(['targetId', this.targetId]);\n if (this.victimId) out.push(['victimId', this.victimId]);\n if (this.grantedAccess != null) out.push(['grantedAccess', this.grantedAccess]);\n if (this.requestedAccess != null) out.push(['requestedAccess', this.requestedAccess]);\n if (this.accessRestricted != null) out.push(['accessRestricted', this.accessRestricted]);\n if (this.creatorUniqueId) out.push(['creatorUniqueId', this.creatorUniqueId]);\n if (this.targetCreatorId) out.push(['targetCreatorId', this.targetCreatorId]);\n if (this.targetImagePath) out.push(['targetImagePath', this.targetImagePath]);\n if (this.targetCommandLine) out.push(['targetCommandLine', this.targetCommandLine]);\n if (this.processExecutablePath) out.push(['exePath', this.processExecutablePath]);\n if (this.processCommandLine) out.push(['commandLine', this.processCommandLine]);\n if (this.action) out.push(['action', this.action]);\n if (this.ruleId) out.push(['ruleId', this.ruleId]);\n const m = new Map(out); cleanUpKeys(m); return m;\n }\n}\n\ninterface IProcessTerminate {\n processId: string;\n uniqueDirectories?: number;\n latestFilenameExt?: string[];\n identicalFiles?: string[];\n}\n\nexport class ProcessTerminate implements IProcessTerminate, HasEventDetails {\n processId: string;\n uniqueDirectories?: number;\n latestFilenameExt?: string[];\n identicalFiles?: string[];\n\n constructor(d: any) {\n this.processId = parseIdString(d.process_terminating_process_id);\n this.uniqueDirectories = parseNumeric(d.process_terminating_unique_directories);\n this.latestFilenameExt = d.process_terminating_unique_latest_filename_extension;\n this.identicalFiles = d.process_terminating_identical_files_filename;\n }\n\n toEventDetailsMap(): Map {\n const out: [string, any][] = [['processId', this.processId]];\n if (this.uniqueDirectories != null) out.push(['uniqueDirectories', this.uniqueDirectories]);\n if (this.latestFilenameExt?.length) out.push(['latestFilenameExt', this.latestFilenameExt.join(', ')]);\n if (this.identicalFiles?.length) out.push(['identicalFiles', this.identicalFiles.join(', ')]);\n const m = new Map(out); cleanUpKeys(m); return m;\n }\n}\n\n\n// Additional payloads for completeness\nexport interface ISecurityEventLog {\n task: string;\n user: string;\n systemEventId: number;\n}\nexport class SecurityEventLog implements ISecurityEventLog {\n task: string;\n user: string;\n systemEventId: number;\n\n constructor(data: any) {\n this.task = data.task;\n this.user = data.user;\n this.systemEventId = data.system_event_id;\n }\n}\n\nexport interface IServiceChange {\n state: string;\n serviceName: string;\n service: string;\n}\nexport class ServiceChange implements IServiceChange {\n state: string;\n serviceName: string;\n service: string;\n constructor(d: any) {\n this.state = d.state;\n this.serviceName = d.service_name;\n this.service = d.service;\n }\n toEventDetailsMap(): Map {\n const m = new Map([\n ['state', this.state],\n ['service', this.service],\n ['serviceName', this.serviceName],\n ]);\n cleanUpKeys(m); return m;\n }\n}\n\nexport interface ISessionChange {\n sessionId: number;\n sessionChangeType?: string;\n sessionTerminated?: boolean;\n connectState?: string;\n userName?: string;\n domainName?: string;\n clientName?: string;\n clientIp?: string;\n homeDirectory?: string;\n accountDisabled?: boolean;\n userDescription?: string;\n}\n\nexport class SessionChange implements ISessionChange, HasEventDetails {\n sessionId: number;\n sessionChangeType?: string;\n sessionTerminated?: boolean;\n connectState?: string;\n userName?: string;\n domainName?: string;\n clientName?: string;\n clientIp?: string;\n homeDirectory?: string;\n accountDisabled?: boolean;\n userDescription?: string;\n\n constructor(d: any) {\n this.sessionId = Number(d.session_id);\n this.sessionChangeType = d.session_change_type;\n this.sessionTerminated = Boolean(d.session_terminated);\n this.connectState = d.connect_state;\n this.userName = d.user_name;\n this.domainName = d.domain_name;\n this.clientName = d.client_name;\n this.clientIp = d.client_ip;\n this.homeDirectory = d.home_directory;\n this.accountDisabled = d.account_disabled === true;\n this.userDescription = d.user_description;\n }\n\n toEventDetailsMap(): Map {\n const out: [string, any][] = [['sessionId', this.sessionId]];\n if (this.connectState) out.push(['connectState', this.connectState]);\n if (this.sessionChangeType) out.push(['sessionChangeType', this.sessionChangeType]);\n if (this.sessionTerminated != null) out.push(['sessionTerminated', this.sessionTerminated]);\n if (this.userName) out.push(['userName', this.userName]);\n if (this.domainName) out.push(['domainName', this.domainName]);\n if (this.clientName) out.push(['clientName', this.clientName]);\n if (this.clientIp) out.push(['clientIp', this.clientIp]);\n if (this.homeDirectory) out.push(['homeDirectory', this.homeDirectory]);\n if (this.accountDisabled != null) out.push(['accountDisabled', this.accountDisabled]);\n if (this.userDescription) out.push(['userDescription', this.userDescription]);\n const m = new Map(out); cleanUpKeys(m); return m;\n }\n}\n\n\nexport interface IDCSyncAttack {\n ipAddress?: string;\n}\n\nexport class DCSyncAttack implements IDCSyncAttack, HasEventDetails {\n ipAddress?: string;\n constructor(d: any) { this.ipAddress = d.ip_address; }\n toEventDetailsMap(): Map {\n const m = new Map();\n if (this.ipAddress) m.set('ipAddress', this.ipAddress);\n cleanUpKeys(m); return m;\n }\n}\n\n\n\n/*\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500*\\\n * Behavior *\n\\*\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500*/\n\nexport type PathType = 'path' | 'object' | 'registry';\ninterface IBehaviorPath {\n pathType: PathType;\n path: string;\n count?: number;\n denied?: boolean;\n operationMask?: number;\n}\n\nexport interface IBehaviorAccessTimestamp { seconds?: number; nanos?: number; }\nexport class BehaviorAccessTimestamp implements IBehaviorAccessTimestamp {\n seconds?: number; nanos?: number;\n constructor(d: any) {\n this.seconds = parseNumeric(d?.seconds);\n this.nanos = parseNumeric(d?.nanos);\n }\n}\n\nexport interface IBehaviorAccess {\n timestamp?: BehaviorAccessTimestamp;\n operation?: number;\n}\nexport class BehaviorAccess implements IBehaviorAccess {\n timestamp?: BehaviorAccessTimestamp;\n operation?: number;\n constructor(d: any) {\n this.operation = parseNumeric(d?.operation);\n this.timestamp = d?.timestamp ? new BehaviorAccessTimestamp(d.timestamp) : undefined;\n }\n}\n\n\n\n// #region Main Discriminated Union Payloads\n// ---------------------------------------------------------------------------------\n// These classes represent the primary branches of an Event: Signal, Hallmark,\n// Deflection, and KernelRecord. Their constructors dispatch to the correct\n// sub-payload constructor based on an `event_type` or `header_type` field.\n// ---------------------------------------------------------------------------------\n\nabstract class BaseEventPayload {\n id: string;\n tenantId: string;\n nodeId: string;\n spanId: string;\n traceId: string;\n configId: string;\n credentialId: string;\n serviceName: string;\n operationName: OperationType;\n eventTime: string;\n startTime?: number;\n duration?: number;\n mimicId: string;\n mimicDescription: string;\n mimicScore: number;\n\n protected constructor(data: any) {\n this.id = data.id;\n this.tenantId = data.tenant_id;\n this.nodeId = data.node_id;\n this.spanId = data.span_id;\n this.traceId = data.trace_id;\n this.configId = data.config_id;\n this.credentialId = data.credential_id;\n this.serviceName = data.service_name;\n this.operationName = data.operation_name;\n this.eventTime = data.event_time;\n this.startTime = data.start_time;\n this.duration = data.duration;\n this.mimicId = data.mimic_id;\n this.mimicDescription = data.mimic_description;\n this.mimicScore = data.mimic_score;\n }\n\n /** Entries to prepend to every event details map (container-level fields). */\n protected baseHeaderEntries(): [string, any][] {\n const out: [string, any][] = [];\n\n if (this.operationName) out.push(['operationName', this.operationName]);\n if (this.serviceName) out.push(['serviceName', this.serviceName]);\n\n if (this.mimicId) out.push(['mimicId', this.mimicId]);\n if (this.mimicDescription) out.push(['mimicDescription', this.mimicDescription]);\n if (this.mimicScore != null) out.push(['mimicScore', this.mimicScore]);\n\n return out;\n }\n\n}\n\n\n\nexport const OP_READ = 1; // READ\nexport const OP_WRITE = 2; // WRITE\nexport const OP_EXECUTE = 4; // EXECUTE\nexport const OP_INVASIVE = 8; // INVASIVE\n\n\nexport class BehaviorPath implements IBehaviorPath {\n pathType: PathType;\n path: string;\n count?: number;\n denied?: boolean;\n operationMask?: number;\n\n access?: BehaviorAccess[];\n combinedOperation?: number;\n\n constructor(data: any) {\n this.pathType = normalizePathType(data.path_type) ?? data.path_type;\n this.path = data.path;\n this.count = parseNumeric(data.count);\n this.denied = Boolean(data.denied);\n this.operationMask = parseNumeric(data.operation_mask);\n\n this.access = Array.isArray(data.access) ? data.access.map((a: any) => new BehaviorAccess(a)) : undefined;\n this.combinedOperation = parseNumeric(data.combined_operation);\n }\n\n /**\n * operationMaskToNames\n * Convert a numeric mask (bitflags) into an array of names.\n * e.g. 3 -> ['READ','WRITE']\n */\n operationMaskToNames(mask?: number | null): string[] {\n if (mask == null) return [];\n const out: string[] = [];\n if ((mask & OP_READ) !== 0) out.push('READ');\n if ((mask & OP_WRITE) !== 0) out.push('WRITE');\n if ((mask & OP_EXECUTE) !== 0) out.push('EXECUTE');\n if ((mask & OP_INVASIVE) !== 0) out.push('INVASIVE');\n return out;\n }\n\n /**\n * operationMaskToDisplay\n * Friendly single-line display of the mask.\n * Examples:\n * - [ 'READ', 'WRITE' ] => \"READ|WRITE\"\n * - [] => \"---\"\n */\n operationMaskToDisplay(mask?: number | null): string {\n const names = this.operationMaskToNames(mask);\n if (names.length === 0) return '---';\n return names.join('|');\n }\n}\n\nexport interface IBehaviorAncestor {\n actorId?: string;\n parentId?: string;\n creatorId?: string;\n imagePath?: string;\n commandLine?: string;\n}\n\n\n/**\n * Profiler-specific fields. Lives under `Behavior.profiler` after the\n * variant-pointer split in lib-dashcam (see ENG-1684). Older builds that\n * sent these fields flat at the top level are normalized server-side\n * before reaching the client, so this typed sub-object is the only path.\n */\nexport interface IBehaviorProfiler {\n commandLine?: string;\n processId?: string;\n creatorId?: string;\n parentId?: string;\n executableType?: number;\n session?: number;\n emissionTrigger?: string;\n profilerSchemaVersion?: string;\n paths?: IBehaviorPath[];\n pathsJsonTruncatedEntries?: number;\n ancestors?: IBehaviorAncestor[];\n\n /* SID / domain \u2014 populated from the process create that triggered the record */\n sid?: string;\n sidDomainHash?: string;\n sidAccountHash?: string;\n sidNameUse?: number | string;\n domainController?: boolean;\n}\n\n/**\n * Authenticode-monitor specific fields. Lives under\n * `Behavior.authenticodeMonitor`. No TS UI currently reads these typed\n * fields \u2014 the events modal raw-JSON view shows them generically \u2014 so we\n * keep this loose-typed for now and add a typed class only when a\n * concrete UI need lands.\n */\nexport interface IBehaviorAuthenticodeMonitor {\n confirmationId?: number;\n confirmationResult?: boolean;\n confirmationTime?: number;\n fileCompanyName?: string;\n fileDescription?: string;\n fileVersion?: string;\n productName?: string;\n}\n\nexport interface IBehavior {\n /* -- core identifiers -- */\n id: string;\n tenantId: string;\n nodeId: string;\n\n /* -- tracing / context -- */\n spanId: string;\n traceId: string;\n configId: string;\n credentialId: string;\n serviceName: string;\n service: string;\n\n /* -- operation metadata -- */\n operationName: OperationType;\n eventTime: string; // \u00B5s-since-epoch or RFC-3339\n startTime?: number; // ns-since-epoch\n duration?: number; // ns\n\n /* -- mimic context -- */\n mimicId?: string;\n mimicDescription?: string;\n recordType?: string; // RECORD_TYPE_PROFILER | RECORD_TYPE_AUTHENTICODE_MONITOR\n\n /* -- shared join keys / image attribution -- */\n sha256sum?: string;\n imagePath?: string;\n driverLoadId?: number;\n count?: number;\n\n /* certificate (kept on common envelope \u2014 populated for legacy\n consolidated records and new authenticode records alike) */\n certIssuer?: string;\n certSubject?: string;\n certValidTo?: string;\n certValidFrom?: string;\n certSerialNumber?: string;\n certSignatureAlgo?: string;\n certThumbSha1?: string;\n certThumbSha256?: string;\n certSelfSigned?: boolean;\n certTrustNotes?: string;\n\n /* -- variant payloads (exactly one is populated per record) -- */\n profiler?: IBehaviorProfiler;\n authenticodeMonitor?: IBehaviorAuthenticodeMonitor;\n}\n\nexport class BehaviorAncestor implements IBehaviorAncestor {\n actorId?: string;\n parentId?: string;\n creatorId?: string;\n imagePath?: string;\n commandLine?: string;\n constructor(d: any) {\n this.actorId = parseIdString(d?.actor_id);\n this.parentId = parseIdString(d?.parent_id);\n this.creatorId = parseIdString(d?.creator_id);\n this.imagePath = d?.image_path;\n this.commandLine = d?.command_line;\n }\n}\n\nexport class BehaviorProfiler implements IBehaviorProfiler {\n commandLine?: string;\n processId?: string;\n creatorId?: string;\n parentId?: string;\n executableType?: number;\n session?: number;\n emissionTrigger?: string;\n profilerSchemaVersion?: string;\n paths?: BehaviorPath[];\n pathsJsonTruncatedEntries?: number;\n ancestors?: BehaviorAncestor[];\n sid?: string;\n sidDomainHash?: string;\n sidAccountHash?: string;\n sidNameUse?: number | string;\n domainController?: boolean;\n\n constructor(data: any) {\n this.commandLine = data?.command_line;\n this.processId = parseIdString(data?.process_id);\n this.creatorId = parseIdString(data?.creator_id);\n this.parentId = parseIdString(data?.parent_id);\n this.executableType = parseNumeric(data?.executable_type);\n this.session = parseNumeric(data?.session);\n this.emissionTrigger = data?.emission_trigger;\n this.profilerSchemaVersion = data?.profiler_schema_version;\n this.pathsJsonTruncatedEntries = parseNumeric(data?.paths_json_truncated_entries);\n this.paths = Array.isArray(data?.paths) ? data.paths.map((p: any) => new BehaviorPath(p)) : undefined;\n this.ancestors = Array.isArray(data?.ancestors) ? data.ancestors.map((a: any) => new BehaviorAncestor(a)) : undefined;\n\n this.sid = data?.sid;\n this.sidDomainHash = data?.sid_domain_hash;\n this.sidAccountHash = data?.sid_account_hash;\n this.sidNameUse = data?.sid_name_use;\n this.domainController = data?.domain_controller;\n }\n}\n\n\nexport class Behavior implements IBehavior, HasEventDetails {\n /* -- core identifiers -- */\n id: string;\n tenantId: string;\n nodeId: string;\n\n toEventDetailsMap(): Map {\n const b: any = this as any;\n const out: [string, any][] = [];\n\n // Keep id first (if present)\n if (b.id) out.push(['id', b.id]);\n\n // Common-envelope fields (omit noisy/container keys + variant pointers)\n const omit = new Set([\n 'id', 'tenantId', 'nodeId', 'eventTime', 'startTime', 'duration',\n 'operationName', 'spanId', 'traceId', 'configId', 'service',\n 'profiler', 'authenticodeMonitor',\n ]);\n for (const [k, v] of Object.entries(b)) {\n if (!omit.has(k) && v !== undefined) out.push([k, v]);\n }\n\n // Profiler variant \u2014 flatten its scalar fields back into the modal\n // and expand its paths/ancestors.\n const p = this.profiler;\n if (p) {\n const profilerOmit = new Set(['paths', 'ancestors']);\n for (const [k, v] of Object.entries(p as any)) {\n if (!profilerOmit.has(k) && v !== undefined) out.push([k, v]);\n }\n\n const paths = Array.isArray(p.paths) ? p.paths : [];\n paths.forEach((bp: BehaviorPath, idx: number) => {\n const i = idx + 1;\n const prefix = `path[${i}]`;\n out.push([`${prefix}.path`, bp.path ?? '']);\n out.push([`${prefix}.type`, bp.pathType ?? '']);\n out.push([`${prefix}.count`, (bp.count ?? 1)]);\n if (bp.denied !== undefined) out.push([`${prefix}.denied`, !!bp.denied]);\n if (bp.operationMask !== undefined) out.push([`${prefix}.operationMask`, bp.operationMask]);\n if (bp.combinedOperation !== undefined) out.push([`${prefix}.combinedOperation`, bp.combinedOperation]);\n const ops = this.summarizeBehaviorOps(bp);\n if (ops) out.push([`${prefix}.ops`, ops]);\n });\n\n if (p.pathsJsonTruncatedEntries != null && p.pathsJsonTruncatedEntries > 0) {\n out.push(['pathsTruncated', p.pathsJsonTruncatedEntries]);\n }\n\n if (p.ancestors?.length) {\n out.push(['ancestorChain', this.formatAncestorChain(p.ancestors)]);\n }\n }\n\n // Authenticode-monitor variant \u2014 flatten its fields with a prefix so\n // they're visually grouped in the modal.\n const am = this.authenticodeMonitor;\n if (am) {\n for (const [k, v] of Object.entries(am as any)) {\n if (v !== undefined) out.push([`authenticode.${k}`, v]);\n }\n }\n\n const m = new Map(out);\n cleanUpKeys(m);\n return m;\n }\n\n /* -- tracing / context -- */\n spanId: string;\n traceId: string;\n configId: string;\n credentialId: string;\n serviceName: string;\n service: string;\n\n /* -- operation metadata -- */\n operationName: OperationType;\n eventTime: string; // \u00B5s-since-epoch or RFC-3339\n startTime?: number; // ns-since-epoch\n duration?: number; // ns\n\n /* -- mimic context -- */\n mimicId?: string;\n mimicDescription?: string;\n recordType?: string;\n\n /* -- shared join keys / image attribution (common envelope) -- */\n sha256sum?: string;\n imagePath?: string;\n driverLoadId?: number;\n count?: number;\n\n /* certificate (common envelope \u2014 populated for legacy consolidated\n records and new authenticode records alike) */\n certIssuer?: string;\n certSubject?: string;\n certValidTo?: string;\n certValidFrom?: string;\n certSerialNumber?: string;\n certSignatureAlgo?: string;\n certThumbSha1?: string;\n certThumbSha256?: string;\n certSelfSigned?: boolean;\n certTrustNotes?: string;\n\n /* -- variant payloads (exactly one is populated per record) -- */\n profiler?: BehaviorProfiler;\n authenticodeMonitor?: IBehaviorAuthenticodeMonitor;\n\n constructor(data: any) {\n /* identifiers */\n this.id = data.id;\n this.tenantId = data.tenant_id;\n this.nodeId = data.node_id;\n\n /* tracing / context */\n this.spanId = data.span_id;\n this.traceId = data.trace_id;\n this.configId = data.config_id;\n this.credentialId = data.credential_id;\n this.serviceName = data.service_name;\n this.service = data.service;\n\n /* operation metadata */\n this.operationName = data.operation_name;\n this.eventTime = data.event_time;\n this.startTime = parseNumeric(data.start_time);\n this.duration = parseNumeric(data.duration);\n\n /* mimic context */\n this.mimicId = data.mimic_id;\n this.mimicDescription = data.mimic_description;\n this.recordType = data.record_type;\n\n /* shared join keys */\n this.sha256sum = data.sha256sum;\n this.imagePath = data.image_path;\n this.driverLoadId = parseNumeric(data.driver_load_id);\n this.count = parseNumeric(data.count);\n\n /* certificate */\n this.certIssuer = data.cert_issuer;\n this.certSubject = data.cert_subject;\n this.certValidTo = data.cert_valid_to;\n this.certValidFrom = data.cert_valid_from;\n this.certSerialNumber = data.cert_serial_number;\n this.certSignatureAlgo = data.cert_signature_algo;\n this.certThumbSha1 = data.cert_thumbprint_sha1;\n this.certThumbSha256 = data.cert_thumbprint_sha256;\n this.certSelfSigned = data.cert_self_signed;\n this.certTrustNotes = data.cert_trust_notes;\n\n /* variant payloads */\n if (data.profiler) {\n this.profiler = new BehaviorProfiler(data.profiler);\n }\n if (data.authenticode_monitor) {\n const am = data.authenticode_monitor;\n this.authenticodeMonitor = {\n confirmationId: parseNumeric(am.confirmation_id),\n confirmationResult: am.confirmation_result,\n confirmationTime: parseNumeric(am.confirmation_time),\n fileCompanyName: am.file_company_name,\n fileDescription: am.file_description,\n fileVersion: am.file_version,\n productName: am.product_name,\n };\n }\n }\n\n private summarizeBehaviorOps(p: BehaviorPath): string | undefined {\n if (!p) return '';\n const parts: string[] = [];\n\n // Access[] breakdown: count each mask value and show by name.\n if (Array.isArray(p.access) && p.access.length > 0) {\n const counts = new Map();\n for (const a of p.access) {\n const op = Number(a?.operation ?? 0);\n if (!op || Number.isNaN(op)) continue;\n counts.set(op, (counts.get(op) ?? 0) + 1);\n }\n if (counts.size) {\n const items = [...counts.entries()]\n .sort(([a], [b]) => a - b)\n .map(([mask, cnt]) => `${p.operationMaskToDisplay(mask)}\u00D7${cnt}`);\n parts.push(`Ops: ${items.join(', ')}`);\n }\n }\n\n // CombinedOperation (mask)\n if (p.combinedOperation != null && Number(p.combinedOperation) !== 0) {\n const m = Number(p.combinedOperation);\n parts.push(`Combined: ${p.operationMaskToDisplay(m)} (0x${m.toString(16)})`);\n }\n\n // OperationMask (mask)\n if (p.operationMask != null && Number(p.operationMask) !== 0) {\n const m = Number(p.operationMask);\n parts.push(`Mask: ${p.operationMaskToDisplay(m)} (0x${m.toString(16)})`);\n }\n\n return parts.join(' ');\n }\n\n private formatAncestorChain(as: BehaviorAncestor[]): string {\n const baseName = (p?: string) => {\n if (!p) return '';\n const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\\\'));\n return i >= 0 ? p.slice(i + 1) : p;\n };\n const items = as\n .filter(a => a && (a.actorId || a.imagePath))\n .map(a => {\n const id = a.actorId ?? '';\n const base = baseName(a.imagePath);\n return base ? `${id}(${base})` : `${id}`;\n });\n return items.join(' \\u2192 ');\n }\n\n}\n/*\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500*/\n\n\n// #endregion\n\n// #region Kernel Record Enums\n// ---------------------------------------------------------------------------------\n// Enums ported from the Go model for type safety and readability.\n// ---------------------------------------------------------------------------------\n\nexport enum KrCreateOutcome {\n INVALID,\n SUPERSEDED,\n OPENED,\n CREATED,\n OVERWRITTEN,\n EXISTS,\n DOES_NOT_EXIST,\n UNUSED,\n FILE_OPLOCK_BROKEN_TO_LEVEL_2,\n FILE_OPLOCK_BROKEN_TO_NONE,\n FILE_OPBATCH_BREAK_UNDERWAY,\n}\n\nexport enum KrStreamDisposition {\n INVALID,\n SUPERSEDE,\n OPEN,\n CREATE,\n OPEN_IF,\n OVERWRITE,\n OVERWRITE_IF,\n}\n\nexport enum KrShareAccess {\n INVALID,\n NONE,\n READ,\n WRITE,\n READ_WRITE,\n DELETE,\n READ_DELETE,\n WRITE_DELETE,\n READ_WRITE_DELETE,\n}\n\nexport enum KrStreamState {\n INVALID,\n UNKNOWN,\n FILE_REMAINS,\n FILE_DELETED,\n LINK_DELETED,\n STREAM_DELETED,\n}\n\nexport enum KrHandleType {\n INVALID,\n VOLUME,\n DIRECTORY,\n FILE,\n}\n\n// A comprehensive list of registry operation types.\nexport type KrRegNotifyClass =\n | 'RegNtDeleteKey' | 'RegNtSetValueKey' | 'RegNtDeleteValueKey' | 'RegNtSetInformationKey'\n | 'RegNtRenameKey' | 'RegNtEnumerateKey' | 'RegNtEnumerateValueKey' | 'RegNtQueryKey'\n | 'RegNtQueryValueKey' | 'RegNtQueryMultipleValueKey' | 'RegNtPreCreateKey' | 'RegNtPostCreateKey'\n | 'RegNtPreOpenKey' | 'RegNtPostOpenKey' | 'RegNtKeyHandleClose' | 'RegNtPostDeleteKey'\n | 'RegNtPostSetValueKey' | 'RegNtPostDeleteValueKey' | 'RegNtPostSetInformationKey' | 'RegNtPostRenameKey'\n | 'RegNtPostEnumerateKey' | 'RegNtPostEnumerateValueKey' | 'RegNtPostQueryKey' | 'RegNtPostQueryValueKey'\n | 'RegNtPostQueryMultipleValueKey' | 'RegNtPostKeyHandleClose' | 'RegNtPreCreateKeyEx' | 'RegNtPostCreateKeyEx'\n | 'RegNtPreOpenKeyEx' | 'RegNtPostOpenKeyEx' | 'RegNtPreFlushKey' | 'RegNtPostFlushKey'\n | 'RegNtPreLoadKey' | 'RegNtPostLoadKey' | 'RegNtPreUnLoadKey' | 'RegNtPostUnLoadKey'\n | 'RegNtPreQueryKeySecurity' | 'RegNtPostQueryKeySecurity' | 'RegNtPreSetKeySecurity' | 'RegNtPostSetKeySecurity'\n | 'RegNtCallbackObjectContextCleanup' | 'RegNtPreRestoreKey' | 'RegNtPostRestoreKey' | 'RegNtPreSaveKey'\n | 'RegNtPostSaveKey' | 'RegNtPreReplaceKey' | 'RegNtPostReplaceKey' | 'RegNtPreQueryKeyName'\n | 'RegNtPostQueryKeyName' | 'RegNtPreSaveMergedKey' | 'RegNtPostSaveMergedKey';\n\n\n// #endregion\n\n// #region Kernel Record Payloads\n// ---------------------------------------------------------------------------------\n// Interfaces and classes for KernelRecord payloads, now accurately modeled\n// from the provided Go `Kr*` struct definitions.\n// ---------------------------------------------------------------------------------\n\n/** Helper to parse Go's tolerant `IntS`/`Uint64S` types */\nfunction parseNumeric(value: any): number | undefined {\n if (value === null || value === undefined) return undefined;\n const num = Number(value);\n return isNaN(num) ? undefined : num;\n}\n\n/**\n * Helper for process-identity ids (actor_id / creator_id / parent_id / \u2026).\n * These are full-range u64 hashes serialized as JSON strings (ENG-2916) \u2014\n * a JS number corrupts them above 2^53, so they must never pass through\n * Number()/parseInt. Missing values collapse to '' so falsiness checks work.\n */\nexport function parseIdString(v: unknown): string {\n if (v === null || v === undefined || v === '') return '';\n return String(v);\n}\n\nexport interface IIOChunk {\n size: number;\n offset?: number;\n sequence?: number;\n}\nexport class IOChunk implements IIOChunk {\n size: number;\n offset?: number;\n sequence?: number;\n\n constructor(data: any, prefix: string, index: number) {\n this.size = parseNumeric(data[`${prefix}[${index}]_size`])!;\n this.offset = parseNumeric(data[`${prefix}[${index}]_offset`]);\n this.sequence = parseNumeric(data[`${prefix}[${index}]_sequence`]);\n }\n}\n\nexport interface IKrHandleIO {\n actorId: string;\n volumeId: number;\n initialName: string;\n latestName?: string;\n bytesRead?: number;\n bytesWritten?: number;\n readCount?: number;\n writeCount?: number;\n createEventTime: string; // ISO 8601 date string\n createCreationTime: string; // ISO 8601 date string\n createFileId: number;\n createInitialSize: number;\n createHandleType: string;\n createDesiredAccess: number;\n createGrantedAccess: number;\n createShareAccess: string;\n createOptions: number;\n createDisposition: string;\n createOutcome: string;\n closeEventTime: string; // ISO 8601 date string\n closeFinalSize: number;\n closeStreamState: string;\n firstReads: IOChunk[];\n firstWrites: IOChunk[];\n lastReads: IOChunk[];\n lastWrites: IOChunk[];\n}\n\nexport class KrHandleIO implements IKrHandleIO {\n actorId: string; volumeId: number; initialName: string; latestName?: string; bytesRead?: number;\n bytesWritten?: number; readCount?: number; writeCount?: number; createEventTime: string;\n createCreationTime: string; createFileId: number; createInitialSize: number;\n createHandleType: string; createDesiredAccess: number; createGrantedAccess: number;\n createShareAccess: string; createOptions: number; createDisposition: string; createOutcome: string;\n closeEventTime: string; closeFinalSize: number; closeStreamState: string;\n firstReads: IOChunk[] = []; firstWrites: IOChunk[] = []; lastReads: IOChunk[] = []; lastWrites: IOChunk[] = [];\n\n constructor(data: any) {\n this.actorId = parseIdString(data.handle_io_actor_id);\n this.volumeId = parseNumeric(data.handle_io_volume_id)!;\n this.initialName = data.handle_io_initial_name;\n this.latestName = data.handle_io_latest_name;\n this.bytesRead = parseNumeric(data.handle_io_bytes_read);\n this.bytesWritten = parseNumeric(data.handle_io_bytes_written);\n this.readCount = parseNumeric(data.handle_io_read_count);\n this.writeCount = parseNumeric(data.handle_io_write_count);\n this.createEventTime = data.handle_io_create_event_time;\n this.createCreationTime = data.handle_io_create_creation_time;\n this.createFileId = parseNumeric(data.handle_io_create_file_id)!;\n this.createInitialSize = parseNumeric(data.handle_io_create_initial_size)!;\n this.createHandleType = data.handle_io_create_handle_type;\n this.createDesiredAccess = data.handle_io_create_desired_access;\n this.createGrantedAccess = data.handle_io_create_granted_access;\n this.createShareAccess = data.handle_io_create_share_access;\n this.createOptions = data.handle_io_create_options;\n this.createDisposition = data.handle_io_create_disposition;\n this.createOutcome = data.handle_io_create_outcome;\n this.closeEventTime = data.handle_io_close_event_time;\n this.closeFinalSize = parseNumeric(data.handle_io_close_final_size)!;\n this.closeStreamState = data.handle_io_close_stream_state;\n\n // Mimic Go's UnmarshalJSON for indexed chunks\n const parseChunks = (prefix: string, target: IOChunk[]) => {\n for (let i = 0; ; i++) {\n if (data[`${prefix}[${i}]_size`] === undefined) break;\n target.push(new IOChunk(data, prefix, i));\n }\n };\n parseChunks('handle_io_first_reads', this.firstReads);\n parseChunks('handle_io_first_writes', this.firstWrites);\n parseChunks('handle_io_last_reads', this.lastReads);\n parseChunks('handle_io_last_writes', this.lastWrites);\n }\n}\n\nexport interface IKrFailedIO {\n actorId: string; volumeId: number; name: string; desiredAccess: number;\n options: number; disposition: string; shareAccess: string; status: number;\n streamState?: string;\n}\n\nexport class KrFailedIO implements IKrFailedIO {\n actorId: string; volumeId: number; name: string; desiredAccess: number;\n options: number; disposition: string; shareAccess: string; status: number;\n streamState?: string;\n\n constructor(data: any) {\n const knownKeys = ['failed_io_actor_id', 'failed_io_volume_id', 'failed_io_name', 'failed_io_desired_access', 'failed_io_options', 'failed_io_disposition', 'failed_io_share_access', 'failed_io_status', 'failed_io_stream_state'];\n this.actorId = parseIdString(data.failed_io_actor_id);\n this.volumeId = parseNumeric(data.failed_io_volume_id)!;\n this.name = data.failed_io_name;\n this.desiredAccess = data.failed_io_desired_access;\n this.options = data.failed_io_options;\n this.disposition = data.failed_io_disposition;\n this.shareAccess = data.failed_io_share_access;\n this.status = data.failed_io_status;\n this.streamState = data.failed_io_stream_state;\n }\n}\n\nexport interface IKrRegistryOperation {\n actorId: string; key: string; aux?: string; operation: string;\n status?: number; ruleId?: string;\n}\nexport class KrRegistryOperation implements IKrRegistryOperation {\n actorId: string; key: string; aux?: string; operation: KrRegNotifyClass;\n status?: number; ruleId?: string;\n\n constructor(data: any) {\n this.actorId = parseIdString(data.registry_operation_actor_id);\n this.key = data.registry_operation_key;\n this.aux = data.registry_operation_aux;\n this.operation = data.registry_operation_operation;\n this.status = data.registry_operation_status;\n this.ruleId = data.registry_operation_rule_id;\n }\n}\n\nexport interface IKrObjectAction {\n // actorId / targetId are stringified id keys on the wire (ENG-2916);\n // object_action_source_id / _victim_id are not, so they stay numeric.\n action: string; actorId: string; sourceId: number; targetId: string;\n victimId: number; grantedAccess?: number; requestedAccess?: number;\n}\nexport class KrObjectAction implements IKrObjectAction {\n action: string; actorId: string; sourceId: number; targetId: string;\n victimId: number; grantedAccess?: number; requestedAccess?: number;\n\n constructor(data: any) {\n this.action = data.object_action_action;\n this.actorId = parseIdString(data.object_action_actor_id);\n this.sourceId = parseNumeric(data.object_action_source_id)!;\n this.targetId = parseIdString(data.object_action_target_id);\n this.victimId = parseNumeric(data.object_action_victim_id)!;\n this.grantedAccess = data.object_action_granted_access;\n this.requestedAccess = data.object_action_requested_access;\n }\n}\n\nexport interface IKrProcessCreate {\n pid: number; session?: number; processId: string; parentId: string;\n creatorId: string; volumeId?: number; driverId?: number; imageFileName?: string;\n commandLine?: string; creationTime?: string; processOwner: string; sid?: string;\n sidDomain?: string; sidAccount?: string; sidNameUse?: string;\n}\nexport class KrProcessCreate implements IKrProcessCreate {\n pid: number; session?: number; processId: string; parentId: string; creatorId: string;\n volumeId?: number; driverId?: number; imageFileName?: string; commandLine?: string;\n creationTime?: string; processOwner: string; sid?: string; sidDomain?: string;\n sidAccount?: string; sidNameUse?: string;\n\n constructor(data: any) {\n this.pid = parseNumeric(data.process_create_pid)!;\n this.session = parseNumeric(data.process_create_session);\n this.processId = parseIdString(data.process_create_process_id);\n this.parentId = parseIdString(data.process_create_parent_id);\n this.creatorId = parseIdString(data.process_create_creator_id);\n this.volumeId = parseNumeric(data.process_create_volume_id);\n this.driverId = parseNumeric(data.process_create_driver_id);\n this.imageFileName = data.process_create_image_file_name;\n this.commandLine = data.process_create_command_line;\n this.creationTime = data.process_create_creation_time;\n this.processOwner = data.process_owner;\n this.sid = data.process_create_sid_sid;\n this.sidDomain = data.process_create_sid_domain_name;\n this.sidAccount = data.process_create_sid_account_name;\n this.sidNameUse = data.process_create_sid_sid_name_use;\n }\n}\n\nexport interface IKrProcessTerminating {\n processId: string;\n}\nexport class KrProcessTerminating implements IKrProcessTerminating {\n processId: string;\n constructor(data: any) {\n this.processId = parseIdString(data.process_terminating_process_id);\n }\n}\n\nexport interface IKrVolumeMount {\n driverId: number; volumeId: string; deviceType: number; mountFlags: number;\n volumeName: string; contentType: string; serialNumber: number; correlationId: string;\n filesystemType: number;\n}\nexport class KrVolumeMount implements IKrVolumeMount {\n driverId: number; volumeId: string; deviceType: number; mountFlags: number;\n volumeName: string; contentType: string; serialNumber: number; correlationId: string;\n filesystemType: number;\n constructor(data: any) {\n this.driverId = parseNumeric(data.volume_mount_driver_id)!;\n this.volumeId = data.volume_mount_volume_id;\n this.deviceType = parseNumeric(data.volume_mount_device_type)!;\n this.mountFlags = parseNumeric(data.volume_mount_mount_flags)!;\n this.volumeName = data.volume_mount_volume_name;\n this.contentType = data.volume_mount_content_type;\n this.serialNumber = parseNumeric(data.volume_mount_serial_number)!;\n this.correlationId = data.volume_mount_correlation_id;\n this.filesystemType = parseNumeric(data.volume_mount_filesystem_type)!;\n }\n}\n\nexport interface IKrVolumeDismount {\n reason: string;\n volumeId: number;\n}\nexport class KrVolumeDismount implements IKrVolumeDismount {\n reason: string;\n volumeId: number;\n constructor(data: any) {\n this.reason = data.volume_dismount_reason;\n this.volumeId = parseNumeric(data.volume_dismount_volume_id)!;\n }\n}\n\nexport interface IKrModuleLoad {\n actorId: string; targetProcessId: string; appIdNative?: number;\n volumeId?: number; moduleTier?: string; systemModeImage: boolean;\n imageSignatureLevel?: string; imageSignatureType?: string;\n moduleName?: string; baseAddr: number; moduleSize?: number;\n}\nexport class KrModuleLoad implements IKrModuleLoad {\n actorId: string; targetProcessId: string; appIdNative?: number; volumeId?: number;\n moduleTier?: string; systemModeImage: boolean; imageSignatureLevel?: string;\n imageSignatureType?: string; moduleName?: string; baseAddr: number; moduleSize?: number;\n constructor(data: any) {\n this.actorId = parseIdString(data.module_load_actor_id);\n this.targetProcessId = parseIdString(data.module_load_target_process_id);\n this.appIdNative = parseNumeric(data.module_load_app_id_native);\n this.volumeId = parseNumeric(data.module_load_volume_id);\n this.moduleTier = data.module_load_module_tier;\n this.systemModeImage = data.module_load_module_properties_system_mode_image;\n this.imageSignatureLevel = data.module_load_module_properties_image_signature_level;\n this.imageSignatureType = data.module_load_module_properties_image_signature_type;\n this.moduleName = data.module_load_module_name;\n this.baseAddr = parseNumeric(data.module_load_base_address)!;\n this.moduleSize = parseNumeric(data.module_load_module_size);\n }\n}\n// #endregion\n\n// THIS CLASS MUST BE REPLACED\nexport class KernelRecord {\n id: string;\n tenantId: string;\n nodeId: string;\n operationName: OperationType;\n headerType: string;\n headerTime: string;\n spanId: string;\n serviceName: string;\n traceId: string;\n startTimeUS: number;\n durationNS: number;\n payloadRaw: any;\n\n handleIo?: KrHandleIO;\n failedIo?: KrFailedIO;\n registryOperation?: KrRegistryOperation;\n objectAction?: KrObjectAction;\n processCreate?: KrProcessCreate;\n processTerminate?: KrProcessTerminating;\n moduleLoad?: KrModuleLoad;\n volumeMount?: KrVolumeMount;\n volumeDismount?: KrVolumeDismount;\n\n constructor(data: any) {\n this.id = data.id;\n this.tenantId = data.tenant_id;\n this.nodeId = data.node_id;\n this.operationName = data.operation_name;\n this.headerType = data.header_type;\n this.headerTime = data.header_time;\n this.spanId = data.span_id;\n this.serviceName = data.service_name;\n this.traceId = data.trace_id;\n this.startTimeUS = data.start_time;\n this.durationNS = data.duration;\n this.payloadRaw = data;\n\n // The Kr* constructors now parse the flat `data` object directly.\n let payload: any;\n switch (this.headerType) {\n case 'RECORD_TYPE_HANDLE_IO': payload = this.handleIo = new KrHandleIO(data); break;\n case 'RECORD_TYPE_FAILED_IO': payload = this.failedIo = new KrFailedIO(data); break;\n case 'RECORD_TYPE_REGISTRY_OPERATION': payload = this.registryOperation = new KrRegistryOperation(data); break;\n case 'RECORD_TYPE_OBJECT_ACTION': payload = this.objectAction = new KrObjectAction(data); break;\n case 'RECORD_TYPE_PROCESS_CREATE': payload = this.processCreate = new KrProcessCreate(data); break;\n case 'RECORD_TYPE_PROCESS_TERMINATING': payload = this.processTerminate = new KrProcessTerminating(data); break;\n case 'RECORD_TYPE_MODULE_LOAD': payload = this.moduleLoad = new KrModuleLoad(data); break;\n case 'RECORD_TYPE_VOLUME_MOUNT': payload = this.volumeMount = new KrVolumeMount(data); break;\n case 'RECORD_TYPE_VOLUME_DISMOUNT': payload = this.volumeDismount = new KrVolumeDismount(data); break;\n }\n if (payload) payload.timestamp = this.headerTime;\n }\n}\n\ninterface ISignal {\n eventType: string;\n processCreate?: ProcessCreate;\n handleIo?: HandleIO;\n createIo?: CreateIO;\n closeIo?: CloseIO;\n failedIo?: FailedIO;\n registryOperation?: RegistryOperation;\n processTerminate?: ProcessTerminate;\n objectAction?: ObjectAction;\n securityEventLog?: SecurityEventLog;\n serviceChange?: ServiceChange;\n sessionChange?: SessionChange;\n dcSyncAttack?: DCSyncAttack;\n volumeMount?: VolumeMount;\n}\n\nexport class Signal extends BaseEventPayload implements ISignal, HasEventDetails {\n eventType: string;\n processCreate?: ProcessCreate;\n handleIo?: HandleIO;\n createIo?: CreateIO;\n closeIo?: CloseIO;\n failedIo?: FailedIO;\n registryOperation?: RegistryOperation;\n processTerminate?: ProcessTerminate;\n objectAction?: ObjectAction;\n securityEventLog?: SecurityEventLog;\n serviceChange?: ServiceChange;\n sessionChange?: SessionChange;\n dcSyncAttack?: DCSyncAttack;\n volumeMount?: VolumeMount;\n\n constructor(data: any) {\n super(data);\n this.eventType = data.event_type;\n switch (true) {\n case !!data.processCreate: this.processCreate = new ProcessCreate(data.processCreate); break;\n case !!data.handleIO: this.handleIo = new HandleIO(data.handleIO); break;\n case !!data.createIO: this.createIo = new CreateIO(data.createIO); break;\n case !!data.failedIO: this.failedIo = new FailedIO(data.failedIO); break;\n case !!data.registryOperation: this.registryOperation = new RegistryOperation(data.registryOperation); break;\n case !!data.processTerminate: this.processTerminate = new ProcessTerminate(data.processTerminate); break;\n case !!data.objectAction: this.objectAction = new ObjectAction(data.objectAction); break;\n case !!data.securityEventLog: this.securityEventLog = new SecurityEventLog(data.securityEventLog); break;\n case !!data.serviceChange: this.serviceChange = new ServiceChange(data.serviceChange); break;\n case !!data.dcSyncAttack: this.dcSyncAttack = new DCSyncAttack(data.dcSyncAttack); break;\n case !!data.volumeMount: this.volumeMount = new VolumeMount(data.volumeMount); break;\n }\n }\n\n toEventDetailsMap(): Map {\n // 1) Container/base fields first\n const out: [string, any][] = [...this.baseHeaderEntries()];\n if (this.eventType) out.push(['eventType', this.eventType]); // Signal-specific\n\n // 2) First available subtype payload\n const candidate =\n this.processCreate ||\n this.handleIo ||\n this.createIo ||\n this.closeIo ||\n this.failedIo ||\n this.registryOperation ||\n this.objectAction ||\n this.processTerminate ||\n this.securityEventLog ||\n this.serviceChange ||\n this.sessionChange ||\n this.dcSyncAttack ||\n this.volumeMount;\n\n // 3) Convert subtype to details map (prefer HasEventDetails)\n let details: Map;\n if (candidate && typeof (candidate as any).toEventDetailsMap === 'function') {\n details = (candidate as any).toEventDetailsMap();\n } else {\n details = new Map(Object.entries(candidate ?? {}));\n cleanUpKeys(details);\n }\n\n // 4) Append payload details after container fields (preserves order; avoids dup keys)\n const skipKeys = new Set(['eventTime', 'startTime', 'duration', 'creationTime', 'tenantId']);\n for (const [k, v] of details.entries()) {\n if (skipKeys.has(k)) continue;\n if (!out.some(([key]) => key === k)) out.push([k, v]);\n }\n\n return new Map(out);\n }\n\n}\n\nexport class Hallmark extends BaseEventPayload implements HasEventDetails {\n eventType: string;\n processCreate?: ProcessCreate;\n handleIo?: HandleIO;\n failedIo?: FailedIO;\n registryOperation?: RegistryOperation;\n processTerminate?: ProcessTerminate;\n objectAction?: ObjectAction;\n\n constructor(data: any) {\n super(data);\n this.eventType = data.event_type;\n switch (true) {\n case !!data.processCreate: this.processCreate = new ProcessCreate(data.processCreate); break;\n case !!data.handleIO: this.handleIo = new HandleIO(data.handleIO); break;\n case !!data.failedIO: this.failedIo = new FailedIO(data.failedIO); break;\n case !!data.registryOperation: this.registryOperation = new RegistryOperation(data.registryOperation); break;\n case !!data.processTerminate: this.processTerminate = new ProcessTerminate(data.processTerminate); break;\n case !!data.objectAction: this.objectAction = new ObjectAction(data.objectAction); break;\n }\n }\n\n toEventDetailsMap(): Map {\n // 1) Container/base fields first\n const out: [string, any][] = [...this.baseHeaderEntries()];\n if (this.eventType) out.push(['eventType', this.eventType]);\n\n // 2) First available subtype payload\n const candidate =\n this.processCreate ||\n this.handleIo ||\n this.failedIo ||\n this.registryOperation ||\n this.objectAction ||\n this.processTerminate;\n\n // 3) Convert subtype to details map (prefer HasEventDetails)\n let details: Map;\n if (candidate && typeof (candidate as any).toEventDetailsMap === 'function') {\n details = (candidate as any).toEventDetailsMap();\n } else {\n details = new Map(Object.entries(candidate ?? {}));\n cleanUpKeys(details);\n }\n\n\n // 4) Append payload details, skipping duplicates (especially time fields already in baseHeaderEntries)\n const skipKeys = new Set(['eventTime', 'startTime', 'duration', 'creationTime', 'tenantId']);\n for (const [k, v] of details.entries()) {\n if (skipKeys.has(k)) continue;\n if (!out.some(([key]) => key === k)) out.push([k, v]);\n }\n return new Map(out);\n }\n}\n\nexport class Deflection extends BaseEventPayload implements HasEventDetails {\n eventType: string;\n processCreate?: ProcessCreate;\n failedIo?: FailedIO;\n registryOperation?: RegistryOperation;\n objectAction?: ObjectAction;\n volumeMount?: VolumeMount;\n\n constructor(data: any) {\n super(data);\n this.eventType = data.event_type;\n switch (true) {\n case !!data.processCreate: this.processCreate = new ProcessCreate(data.processCreate); break;\n case !!data.failedIO: this.failedIo = new FailedIO(data.failedIO); break;\n case !!data.registryOperation: this.registryOperation = new RegistryOperation(data.registryOperation); break;\n case !!data.objectAction: this.objectAction = new ObjectAction(data.objectAction); break;\n case !!data.volumeMount: this.volumeMount = new VolumeMount(data.volumeMount); break;\n }\n }\n\n toEventDetailsMap(): Map {\n // 1) Container/base fields first\n const out: [string, any][] = [...this.baseHeaderEntries()];\n if (this.eventType) out.push(['eventType', this.eventType]); // Signal-specific\n\n // 2) First available subtype payload\n const candidate =\n this.processCreate ||\n this.failedIo ||\n this.registryOperation ||\n this.objectAction ||\n this.volumeMount;\n\n // 3) Convert subtype to details map (prefer HasEventDetails)\n let details: Map;\n if (candidate && typeof (candidate as any).toEventDetailsMap === 'function') {\n details = (candidate as any).toEventDetailsMap();\n } else {\n details = new Map(Object.entries(candidate ?? {}));\n cleanUpKeys(details);\n }\n\n // 4) Append payload details, skipping duplicates (especially time fields already in baseHeaderEntries)\n const skipKeys = new Set([\n 'eventTime',\n 'startTime',\n 'duration',\n 'creationTime',\n 'tenantId',\n ]);\n\n for (const [k, v] of details.entries()) {\n if (skipKeys.has(k)) continue;\n if (!out.some(([key]) => key === k)) out.push([k, v]);\n }\n\n return new Map(out);\n }\n}\n\n// #endregion\n\n// #region Top-Level Event Class\n// ---------------------------------------------------------------------------------\n// The main `Event` class. It receives a raw JSON object, determines the\n// `OperationType`, and delegates construction to the appropriate class. It also\n// contains refactored helper methods for accessing data within the new structure.\n// ---------------------------------------------------------------------------------\n\nexport interface IEvent {\n tenantId: string;\n nodeId: string;\n eventId: string;\n timestamp: Date | null;\n operationName: OperationType;\n signal?: Signal;\n hallmark?: Hallmark;\n deflection?: Deflection;\n kernelRecord?: KernelRecord;\n behavior?: Behavior;\n payload?: any;\n raw?: any;\n}\n\nexport interface HasEventDetails {\n toEventDetailsMap(): Map;\n}\n\n// tiny shared helper so all implementations get the same cleanup\nexport function mapFromObject(obj: any): Map {\n const m = new Map(Object.entries(obj ?? {}));\n cleanUpKeys(m); // your existing helper\n return m;\n}\n\nfunction cleanUpKeys(map: Map): void {\n const keysToRemove = [\n \"id\",\n \"node_id\",\n \"tenant_id\",\n \"operation_name\",\n \"nodeId\",\n \"tenantId\",\n \"operationName\",\n ];\n for (const key of keysToRemove) {\n map.delete(key);\n }\n}\n\nexport class Event implements IEvent {\n tenantId: string;\n nodeId: string;\n eventId: string;\n timestamp: Date | null;\n operationName: OperationType;\n signal?: Signal;\n hallmark?: Hallmark;\n deflection?: Deflection;\n kernelRecord?: KernelRecord;\n behavior?: Behavior;\n payload?: any;\n raw?: any;\n\n constructor(private data: any) {\n // 1. Assign initial top-level properties\n this.tenantId = data.tenantId;\n this.nodeId = data.nodeId;\n this.eventId = data.eventId;\n this.timestamp = this.parseTimestamp(data.timestamp);\n this.operationName = data.operationName;\n this.raw = data.raw;\n this.payload = data.payload;\n\n // 2. Dispatch using the switch(true) pattern for robustness.\n // It executes the first case that evaluates to true.\n // Pass the whole raw event into each constructor, as constructing the specific event types requires this data\n switch (true) {\n // Check if the nested `signal` object exists and is not null\n case data.signal != null:\n this.signal = new Signal(data.signal);\n break;\n\n // Check if the nested `hallmark` object exists\n case data.hallmark != null:\n this.hallmark = new Hallmark(data.hallmark);\n break;\n\n // Check if the nested `deflection` object exists\n case data.deflection != null:\n this.deflection = new Deflection(data.deflection);\n break;\n\n // Check if the nested `kernelRecord` object exists\n case data.kernelRecord != null:\n this.kernelRecord = new KernelRecord(data.kernelRecord);\n break;\n\n case data.behavior != null:\n this.behavior = new Behavior(data.behavior);\n break;\n }\n }\n\n public toJSON(): any {\n return this.data;\n }\n\n private parseTimestamp(value: any): Date | null {\n if (value === null || value === undefined) {\n return null;\n }\n\n // Check if the value is a high-precision number (nanoseconds)\n if (typeof value === 'number') {\n // The JavaScript Date constructor expects milliseconds, so we convert.\n // nanoseconds -> microseconds -> milliseconds\n const milliseconds = value / 1000 / 1000;\n return new Date(milliseconds);\n }\n\n // Check if the value is a string (like RFC3339 from KernelRecords)\n if (typeof value === 'string') {\n const date = new Date(value);\n // Check if the parsed date is valid\n return isNaN(date.getTime()) ? null : date;\n }\n\n // Return null if the type is unexpected\n return null;\n }\n\n getEventType(): string {\n return this.signal?.eventType ||\n this.hallmark?.eventType ||\n this.deflection?.eventType ||\n this.kernelRecord?.headerType ||\n '';\n }\n\n getProcessId(): string | null {\n // Logic ported from Go's `event.GetPid()`. Ids travel as strings\n // (full-range u64 hashes \u2014 ENG-2916); never coerce to number.\n if (this.signal) {\n if (this.signal.processCreate) return parseIdString(this.signal.processCreate.pid);\n if (this.signal.handleIo) return this.signal.handleIo.actorId;\n if (this.signal.createIo) return this.signal.createIo.actorId;\n if (this.signal.closeIo && this.signal.closeIo.actorId) return this.signal.closeIo.actorId;\n if (this.signal.failedIo) return this.signal.failedIo.actorId;\n if (this.signal.volumeMount && this.signal.volumeMount.processId) return this.signal.volumeMount.processId;\n }\n if (this.hallmark) {\n if (this.hallmark.processCreate) return parseIdString(this.hallmark.processCreate.pid);\n if (this.hallmark.handleIo) return this.hallmark.handleIo.actorId;\n if (this.hallmark.failedIo) return this.hallmark.failedIo.actorId;\n }\n if (this.deflection) {\n if (this.deflection.processCreate) return parseIdString(this.deflection.processCreate.pid);\n if (this.deflection.failedIo) return this.deflection.failedIo.actorId;\n if (this.deflection.registryOperation) return this.deflection.registryOperation.actorId;\n if (this.deflection.volumeMount && this.deflection.volumeMount.processId) return this.deflection.volumeMount.processId;\n }\n if (this.kernelRecord) {\n if (this.kernelRecord.handleIo) return this.kernelRecord.handleIo.actorId;\n if (this.kernelRecord.failedIo) return this.kernelRecord.failedIo.actorId;\n if (this.kernelRecord.registryOperation) return this.kernelRecord.registryOperation.actorId;\n if (this.kernelRecord.objectAction) return this.kernelRecord.objectAction.actorId;\n if (this.kernelRecord.processCreate) return this.kernelRecord.processCreate.processId;\n if (this.kernelRecord.processTerminate) return this.kernelRecord.processTerminate.processId;\n }\n\n if (this.behavior && this.behavior.profiler?.processId) {\n return this.behavior.profiler.processId;\n }\n return null;\n }\n\n // Place this inside your `Event` class.\n\n /**\n * Gets the specific registry operation type, such as 'RegNtPostDeleteKey'.\n * @returns The operation string or '---' if not a registry event.\n */\n getRegistryOperationType(): string {\n // Check each possible location for a registry operation payload\n // and return the .operation field if it exists.\n return this.kernelRecord?.registryOperation?.operation\n ?? (this.getEventType() === '' ? '---' : this.getEventType());\n }\n\n\n\n /**\n * Determines the read/write status of the event.\n * @param osType The operating system ('windows' or 'linux') this event originated from.\n * This is required because the new event model does not contain osType, but\n * interpreting 'desiredAccess' for KernelRecords depends on it.\n * @returns A string representing the status (e.g., 'R/W', 'Blocked', '---').\n */\n getRwStatus(osType: string): string {\n // 1. Handle high-level Signal/Hallmark events.\n // These are based on byte counts, which are OS-agnostic.\n const handleIo = this.signal?.handleIo || this.hallmark?.handleIo;\n if (handleIo) {\n const bytesRead = handleIo.bytesRead ?? 0;\n const bytesWritten = handleIo.bytesWritten ?? 0;\n\n if (bytesRead > 0 && bytesWritten > 0) return 'R/W';\n if (bytesRead > 0) return 'R/O';\n if (bytesWritten > 0) return 'W/O';\n if (handleIo.latestName && (handleIo.initialName !== handleIo.latestName)) return 'Renamed';\n\n // Note: The Signal/Hallmark HandleIO payload does not contain a 'desiredAccess' field,\n // so we cannot perform the Linux-specific check here. It defaults to 'Open'.\n return 'Open';\n }\n\n // 2. Handle Deflections, which are always considered 'Blocked'.\n if (this.deflection) {\n if (this.deflection.failedIo || this.deflection.registryOperation) {\n return 'Blocked';\n }\n }\n\n // 3. Handle KernelRecord HandleIO events.\n // This is where the `osType` parameter is now critical.\n if (this.kernelRecord?.handleIo) {\n const krIo = this.kernelRecord.handleIo;\n const bytesRead = krIo.bytesRead ?? 0;\n const bytesWritten = krIo.bytesWritten ?? 0;\n\n // First, check status based on byte counts and renames.\n if (bytesRead > 0 && bytesWritten > 0) return 'R/W';\n if (bytesRead > 0) return 'R/O';\n if (bytesWritten > 0) return 'W/O';\n if (krIo.latestName && (krIo.initialName !== krIo.latestName)) return 'Renamed';\n\n // If it's a Linux event and we have desiredAccess, use the specific helper.\n if (osType === 'linux' && krIo.createDesiredAccess != null) {\n return this.getLinuxRwStatusFromDesiredAccess(krIo.createDesiredAccess);\n }\n\n // Otherwise, it's a generic 'Open' for Windows or if access rights are unknown.\n return 'Open';\n }\n\n // 4. Handle KernelRecord FailedIO events.\n if (this.kernelRecord?.failedIo) {\n const krFailedIo = this.kernelRecord.failedIo;\n\n // 'Blocked' status always takes precedence.\n if (krFailedIo.status === STATUS_BLOCKED_BY_MIMIC_NODE) {\n return 'Blocked';\n }\n\n // If it's a Linux event, try to get status from desiredAccess.\n if (osType === 'linux' && krFailedIo.desiredAccess != null) {\n return this.getLinuxRwStatusFromDesiredAccess(krFailedIo.desiredAccess);\n }\n\n // Otherwise, it's a generic 'Failed'.\n return 'Failed';\n }\n\n // 5. Default fallback.\n return '---';\n }\n\n private getLinuxRwStatusFromDesiredAccess(desiredAccess: number): string {\n let rwStatus = 'Open';\n // lower bits: 0 -> O_RDONLY, 1 -> O_WRONLY, 2 -> O_RDWR\n // plus O_APPEND=0x400\n\n // Decode the lower two bits\n switch (desiredAccess & 0x3) { // O_ACCMODE\n case 0: // O_RDONLY\n rwStatus = 'R/O';\n break;\n case 1: // O_WRONLY\n rwStatus = 'W/O';\n break;\n case 2: // O_RDWR\n rwStatus = 'R/W';\n break;\n }\n\n // If O_APPEND (0x400) is set => it's definitely writing\n if ((desiredAccess & 0x400) !== 0) {\n // If we had R/O from above, make it R/W; or if 'Open', treat it as W/O\n if (rwStatus === 'R/O') {\n rwStatus = 'R/W';\n } else if (rwStatus === 'Open') {\n rwStatus = 'W/O';\n }\n }\n return rwStatus;\n }\n\n private formatLocalTimestamp(d: Date | null): string | undefined {\n if (!d) return undefined;\n // Local timezone, 24h clock, 2-digit date parts, with milliseconds\n const s = new Intl.DateTimeFormat(undefined, {\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit',\n second: '2-digit',\n hour12: false,\n fractionalSecondDigits: 3,\n }).format(d);\n // Most locales insert a comma between date and time (e.g., \"09/03/2025, 11:25:29.611\")\n // Remove that comma for the required style: \"09/03/2025 11:25:29.611\"\n return s.replace(/,\\s*/, ' ');\n }\n\n\n // Event implements a single dispatcher. UI calls ONLY this.\n // Event implements a single dispatcher. UI calls ONLY this.\n toEventDetailsMap(): Map {\n const envelope: any =\n this.signal ??\n this.hallmark ??\n this.deflection ??\n this.kernelRecord ??\n this.behavior;\n\n // Helper to format the Event.timestamp consistently\n const stamp = this.formatLocalTimestamp(this.timestamp);\n\n if (envelope && typeof envelope.toEventDetailsMap === 'function') {\n const details = envelope.toEventDetailsMap(); // Map\n\n // Rebuild with 'id' first (if present), then 'timestamp', then the rest\n const out = new Map();\n\n if (details.has('id')) {\n out.set('id', details.get('id'));\n details.delete('id');\n }\n if (stamp) {\n out.set('timestamp', stamp);\n }\n\n // append remaining keys in original order (skipping duplicates just in case)\n for (const [k, v] of details) {\n if (!out.has(k)) out.set(k, v);\n }\n return out;\n }\n\n // Safety fallback for odd shapes / legacy\n const raw = new Map(Object.entries(this.raw ?? {}));\n cleanUpKeys(raw);\n // Keep time fields only in the heading\n for (const k of ['eventTime', 'startTime', 'duration', 'creationTime', 'tenantId']) raw.delete(k);\n\n // Build result: id (if present) \u2192 timestamp \u2192 rest of raw\n const out = new Map();\n if (raw.has('id')) {\n out.set('id', raw.get('id'));\n raw.delete('id');\n }\n if (stamp) out.set('timestamp', stamp);\n for (const [k, v] of raw) out.set(k, v);\n return out;\n }\n\n getProcessIdDescription(): string {\n const actorId = this.getProcessId();\n return `Process ID: ${actorId && actorId !== '0' ? actorId : '---'}`;\n }\n}\n// #endregion\n\n// #region Event Bucket\ninterface IEventBucket {\n eventTimestamp: string;\n nodeId: string;\n kernelRecords: number;\n signals: number;\n hallmarks: number;\n deflections: number;\n}\n\nexport class EventBucket implements IEventBucket {\n eventTimestamp: string;\n nodeId: string;\n kernelRecords: number;\n signals: number;\n hallmarks: number;\n deflections: number;\n\n constructor(data: any) {\n this.eventTimestamp = data.event_timestamp;\n this.kernelRecords = data.kernel_records;\n this.signals = data.signals;\n this.hallmarks = data.hallmarks;\n this.deflections = data.deflections;\n this.nodeId = data.node_id;\n }\n}", "import { Event } from '../models/Event';\nimport { apiFetch } from '../utils/Api';\nimport { normalizeImagePath } from '../utils/FormatUtils';\nimport { WebSocketHandler } from '../handlers/WebSocketHandler';\n\nexport type EventsWSSubscribeRequest = {\n operation_names?: string[];\n header_types?: string[];\n record_types?: string[];\n ttl_seconds?: number;\n};\n\nexport type EventsWSSubscribeResponse = {\n subscription_id: string;\n ws_path?: string;\n expires_at_ms?: number;\n};\n\n\n// This store caches API calls while in-flight to prevent cache stampeding\nexport default class EventStore {\n public eventMap: Map = new Map();\n public createOptionsMap: Map = new Map();\n public desiredAccessMap: Map = new Map();\n private eventByIdMap: Map = new Map();\n\n // These will store promises for requests that are currently in progress.\n private inFlightEvents: Map> = new Map();\n private inFlightCreateOptions: Map> = new Map();\n private inFlightDesiredAccess: Map> = new Map();\n\n private behaviorByShasCache: Map = new Map();\n private inFlightBehaviorByShas: Map> = new Map();\n\n private webSocketHandler!: WebSocketHandler;\n private onEventUpdate?: (p: any) => void;\n\n\n public setWebSocketHandler(wsh: WebSocketHandler) {\n this.webSocketHandler = wsh;\n }\n\n private makeBehaviorKey(\n tenantId: string,\n nodeIds: string[],\n shas: string[],\n start?: Date,\n end?: Date,\n limit?: number\n ): string {\n const n = (nodeIds ?? []).slice().filter(Boolean).sort().join(',');\n const s = (shas ?? []).slice().map(x => (x || '').toLowerCase()).sort().join(',');\n const a = start ? start.toISOString() : '-';\n const b = end ? end.toISOString() : '-';\n const l = limit ?? -1;\n return `bhv:${tenantId}:${n}:${s}:${a}:${b}:${l}`;\n }\n\n private load(tenantId: string, nodeId: string, eventIDs: string[] | null): Promise {\n if (!eventIDs) {\n console.error(`No event IDs provided.`);\n return Promise.resolve(null); // Return a resolved promise with null\n }\n return apiFetch(`/api/tenants/${tenantId}/nodes/${nodeId}/events-by-ids`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ eventIDs })\n }).then(response => {\n if (!response.ok) throw new Error('Failed to fetch events by IDs');\n return response.json();\n }).then(data => {\n return data.events.map((e: any) => new Event(e));\n }).catch(error => {\n return null; // On error, resolve the promise with null\n });\n }\n\n private loadCreateOptionsDescriptions(value: number): Promise {\n return apiFetch(`/api/decode-create-options/${value}`)\n .then(response => {\n if (!response.ok) throw new Error('Failed to fetch create options descriptions');\n return response.json();\n })\n .then(data => {\n if (data.createOptions) {\n this.createOptionsMap.set(value, data.createOptions); // Cache the result\n return data.createOptions;\n }\n console.error(`No create options descriptions available for value: ${value}`);\n return null;\n })\n .catch(error => {\n console.error('Failed to load create options descriptions:', error);\n return null; // On error, resolve the promise with null\n });\n }\n\n private loadDesiredAccessDescriptions(value: number, osType: string): Promise {\n // Use a composite key for the cache, as the result depends on both value and osType\n const key = `${value}-${osType}`;\n return apiFetch(`/api/decode-desired-access/${value}?osType=${osType}`)\n .then(response => {\n if (!response.ok) throw new Error('Failed to fetch desired access descriptions');\n return response.json();\n })\n .then(data => {\n if (data.desiredAccess) {\n this.desiredAccessMap.set(key, data.desiredAccess); // Cache the result\n return data.desiredAccess;\n }\n console.error(`No desired access descriptions available for value: ${value}`);\n return null;\n })\n .catch(error => {\n console.error('Failed to load desired access descriptions:', error);\n return null; // On error, resolve the promise with null\n });\n }\n\n public async getBehaviorEventsForShas(\n tenantId: string,\n nodeIds: string[] | string,\n shas: string[],\n opts?: { start?: Date; end?: Date; limit?: number }\n ): Promise {\n const nodes = Array.isArray(nodeIds) ? nodeIds : [nodeIds];\n if (!tenantId || nodes.length === 0 || !shas?.length) return [];\n\n const key = this.makeBehaviorKey(tenantId, nodes, shas, opts?.start, opts?.end, opts?.limit);\n\n // 1) cache\n const cached = this.behaviorByShasCache.get(key);\n if (cached) return cached;\n\n // 2) in-flight de-dupe\n const inflight = this.inFlightBehaviorByShas.get(key);\n if (inflight) return inflight;\n\n // 3) call server once\n const body: any = {\n node_ids: nodes,\n shas,\n };\n if (opts?.start) body.start = opts.start.toISOString();\n if (opts?.end) body.end = opts.end.toISOString();\n if (opts?.limit && opts.limit > 0) body.limit = opts.limit;\n\n const p = apiFetch(`/api/tenants/${tenantId}/behavior-events-for-shas`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(body)\n })\n .then(async (resp) => {\n if (!resp.ok) throw new Error(`HTTP ${resp.status}`);\n const data = await resp.json();\n const raw = Array.isArray(data?.behaviors) ? data.behaviors : [];\n const list = raw.map((r: any) => {\n if (!r.timestamp && r.event_timestamp) r.timestamp = r.event_timestamp; // normalize if needed\n const e = new Event(r);\n try {\n const rawPath = (e as any)?.behavior?.imagePath ?? (r?.behavior?.imagePath ?? r?.imagePath);\n const norm = normalizeImagePath(rawPath);\n (e as any).behavior = (e as any).behavior ?? {};\n (e as any).behavior._rawImagePath = rawPath; // keep for debugging\n (e as any).behavior.imagePath = norm; // overwrite with normalized path\n } catch { /* ignore shape mismatches */ }\n return e;\n });\n // Ensure global ASC order\n list.sort((a, b) => (a.timestamp?.getTime() ?? 0) - (b.timestamp?.getTime() ?? 0));\n this.behaviorByShasCache.set(key, list);\n return list;\n })\n .catch(err => {\n console.error('[EventStore] getBehaviorEventsForShas failed:', err);\n return [] as Event[];\n })\n .finally(() => {\n this.inFlightBehaviorByShas.delete(key);\n });\n\n this.inFlightBehaviorByShas.set(key, p);\n return p;\n }\n\n public clearBehaviorShasCache(): void {\n this.behaviorByShasCache.clear();\n this.inFlightBehaviorByShas.clear();\n }\n\n public getEvents(\n tenantId: string,\n nodeId: string,\n nodeIncidentId: string,\n key: string,\n eventIDs: string[] | null,\n callback: (events: Event[] | null) => void\n ): void {\n\n const id = `${nodeIncidentId}-${key}`;\n\n // 1. Check permanent cache.\n if (this.eventMap.has(id)) {\n callback(this.eventMap.get(id)!);\n return;\n }\n\n // 2. Check for in-flight request.\n const inFlightPromise = this.inFlightEvents.get(id);\n if (inFlightPromise) {\n inFlightPromise.then(result => callback(result));\n return;\n }\n\n // 3. Make a new request.\n const newPromise = this.load(tenantId, nodeId, eventIDs).then(events => {\n // The load function doesn't set the cache itself, so we do it here.\n if (events) {\n this.eventMap.set(id, events);\n }\n return events;\n });\n\n this.inFlightEvents.set(id, newPromise);\n\n newPromise.finally(() => {\n this.inFlightEvents.delete(id);\n });\n\n newPromise.then(result => {\n callback(result);\n });\n }\n\n public async getEventAsync(\n tenantId: string,\n nodeId: string,\n eventId: string\n ): Promise {\n if (!eventId) return null;\n\n // quick single-item cache\n const cached = this.eventByIdMap.get(eventId);\n if (cached) return cached;\n\n const list = await this.load(tenantId, nodeId, [eventId]);\n const ev = list?.[0] ?? null;\n\n if (ev) this.eventByIdMap.set(eventId, ev);\n return ev;\n }\n\n\n public getCreateOptionsDescriptions(value: number, callback: (createOptionsDescriptions: string[] | null) => void): void {\n // 1. Check permanent cache first for a quick return.\n if (this.createOptionsMap.has(value)) {\n callback(this.createOptionsMap.get(value)!);\n return;\n }\n\n // 2. Check for an in-flight request. If found, wait on the existing promise.\n const inFlightPromise = this.inFlightCreateOptions.get(value);\n if (inFlightPromise) {\n inFlightPromise.then(result => callback(result));\n return;\n }\n\n // 3. No cached data, no in-flight request. Make a new one.\n const newPromise = this.loadCreateOptionsDescriptions(value);\n\n // CRITICAL: Store the promise in the in-flight map IMMEDIATELY.\n this.inFlightCreateOptions.set(value, newPromise);\n\n // When the promise settles (succeeds or fails), remove it from the in-flight map.\n newPromise.finally(() => {\n this.inFlightCreateOptions.delete(value);\n });\n\n // Attach this original caller's callback to the new promise.\n newPromise.then(result => {\n callback(result);\n });\n }\n\n public getDesiredAccessDescriptions(value: number, osType: string, callback: (desiredAccessDescriptions: string[] | null) => void): void {\n const key = `${value}-${osType}`;\n\n // 1. Check permanent cache.\n if (this.desiredAccessMap.has(key)) {\n callback(this.desiredAccessMap.get(key)!);\n return;\n }\n\n // CORRECTION: Original code had a bug here, checking createOptionsMap instead of desiredAccessMap\n // I have corrected it above.\n\n // 2. Check for an in-flight request.\n const inFlightPromise = this.inFlightDesiredAccess.get(key);\n if (inFlightPromise) {\n inFlightPromise.then(result => callback(result));\n return;\n }\n\n // 3. No cached data, no in-flight request. Make a new one.\n const newPromise = this.loadDesiredAccessDescriptions(value, osType);\n\n // CRITICAL: Store the promise in the in-flight map IMMEDIATELY.\n this.inFlightDesiredAccess.set(key, newPromise);\n\n // When the promise settles, clean up the in-flight map.\n newPromise.finally(() => {\n this.inFlightDesiredAccess.delete(key);\n });\n\n // Attach callback to the new promise.\n newPromise.then(result => {\n callback(result);\n });\n }\n\n public async subscribeEventsWS(\n tenantId: string,\n nodeId: string,\n body: EventsWSSubscribeRequest = {}\n ): Promise {\n const resp = await apiFetch(`/api/tenants/${tenantId}/nodes/${nodeId}/events/ws-subscriptions`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify(body ?? {}),\n });\n if (!resp.ok) throw new Error(await resp.text());\n return await resp.json();\n }\n\n public async unsubscribeEventsWS(\n tenantId: string,\n nodeId: string,\n subscriptionId: string\n ): Promise {\n const resp = await apiFetch(`/api/tenants/${tenantId}/nodes/${nodeId}/events/ws-subscriptions/${subscriptionId}`, {\n method: \"DELETE\",\n });\n if (!resp.ok) throw new Error(await resp.text());\n }\n\n public connectEventWS(\n tenantId: string,\n nodeId: string,\n subscriptionId: string,\n onUpdate: (p: any) => void\n ): void {\n this.onEventUpdate = onUpdate;\n if (!this.webSocketHandler) throw new Error('WebSocketHandler not set');\n if (!subscriptionId || !nodeId) throw new Error(\"connectEventWS requires subscriptionId + nodeId\");\n this.webSocketHandler.connectWebSocket(tenantId, 'event-update', subscriptionId, true, nodeId);\n }\n\n public disconnectEventWS(): void {\n this.onEventUpdate = undefined;\n if (!this.webSocketHandler) throw new Error('WebSocketHandler not set');\n this.webSocketHandler.disconnectWebSocket('event-update');\n }\n\n /** Called by WebSocketHandler when an event message arrives */\n public handleWebSocketEventUpdate(update: any) {\n if (!this.onEventUpdate) {\n console.warn('[EventStore] No onEventUpdate handler set; ignoring event WS message');\n return;\n }\n console.debug(`[EventStore] WS event update:`, update);\n this.onEventUpdate(update);\n }\n\n\n}\n", "export interface INodeMetric {\n node: string;\n metric: number; // Duration in nanoseconds as sent by Go backend\n metricLabel: string;\n}\n\nexport enum MetricType {\n Duration,\n Count\n}\n\n\nexport class NodeMetric implements INodeMetric {\n node: string;\n metric: number;\n metricLabel: string;\n metricType: MetricType;\n\n\n constructor(data: any) {\n this.node = data.node;\n this.metric = data.metric;\n this.metricLabel = data.metricLabel;\n }\n\n}", "// ===================================================================\n// 1. Foundational Interfaces (Matching Go structs)\n// ===================================================================\n\nexport interface IPathWithVolumeInfo {\n driveLetter: string;\n volumeId: number;\n path: string;\n}\n\nexport interface IRegistryKey {\n key: string;\n friendlyName: string;\n}\n\nexport interface IACLRule {\n rule_id?: number;\n actor_type?: string;\n actor_descriptor?: string;\n resource_type?: string;\n resource_descriptor?: string;\n capability?: string;\n permission?: string;\n // A v4 @protections/access_control rule: the asset it guards and, for a\n // grant, the role granted (no rule_id).\n asset?: string;\n role?: string;\n}\n\nexport interface IProtectedPath {\n pathInfo?: IPathWithVolumeInfo;\n acl?: IACLRule;\n}\n\nexport interface IProtectedRegKey {\n pathInfo?: IRegistryKey; // Note: The type here is IRegistryKey\n acl?: IACLRule;\n}\n\n// ===================================================================\n// 2. Main Protections Interface\n// ===================================================================\n\nexport interface IProtections {\n protectedFolders: IProtectedPath[];\n objectList: IProtectedPath[];\n executionList: IProtectedPath[]; // Added for completeness\n registryKeyList: IProtectedRegKey[];\n hasMultipleVolumes: boolean;\n}\n\n// ===================================================================\n// 3. Implementation Classes\n// ===================================================================\n\n// --- Foundational Data Classes ---\n\nexport class PathWithVolumeInfo implements IPathWithVolumeInfo {\n driveLetter: string;\n volumeId: number;\n path: string;\n\n constructor(data: IPathWithVolumeInfo) {\n this.driveLetter = data.driveLetter;\n this.volumeId = data.volumeId;\n this.path = data.path;\n }\n}\n\nexport class RegistryKey implements IRegistryKey {\n key: string;\n friendlyName: string;\n\n constructor(data: IRegistryKey) {\n this.key = data.key;\n this.friendlyName = data.friendlyName;\n }\n}\nexport class ACLRule implements IACLRule {\n rule_id?: number;\n actor_type?: string;\n actor_descriptor?: string;\n resource_type?: string;\n resource_descriptor?: string;\n capability?: string;\n permission?: string;\n asset?: string;\n role?: string;\n\n constructor(data: IACLRule) {\n Object.assign(this, data);\n }\n\n\n /**\n * Return an HTML string with each key bolded and its value plain,\n * formatted as: key: value\n * each pair separated by an HTML newline (
).\n */\n toHtml(): string {\n const escape = (input: unknown) => {\n const s = String(input ?? '');\n return s.replace(/[&<>\"']/g, ch =>\n ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' } as Record)[ch]\n );\n };\n\n return Object.entries(this)\n .filter(([_, value]) => value !== undefined) // skip undefined fields\n .map(([key, value]) => `${escape(key)}: ${escape(value)}
\\n`)\n .join('');\n }\n}\n\n\n// --- Wrapper Classes ---\n\nexport class ProtectedPath implements IProtectedPath {\n pathInfo?: PathWithVolumeInfo;\n acl?: ACLRule;\n\n constructor(data: IProtectedPath) {\n if (data.pathInfo) {\n this.pathInfo = new PathWithVolumeInfo(data.pathInfo);\n }\n if (data.acl) {\n this.acl = new ACLRule(data.acl);\n }\n }\n}\n\nexport class ProtectedRegKey implements IProtectedRegKey {\n pathInfo?: RegistryKey;\n acl?: ACLRule;\n\n constructor(data: IProtectedRegKey) {\n if (data.pathInfo) {\n this.pathInfo = new RegistryKey(data.pathInfo);\n }\n if (data.acl) {\n this.acl = new ACLRule(data.acl);\n }\n }\n}\n\n\n// --- Main Protections Class (Updated) ---\n\nexport class Protections implements IProtections {\n protectedFolders: ProtectedPath[];\n objectList: ProtectedPath[];\n executionList: ProtectedPath[];\n registryKeyList: ProtectedRegKey[];\n hasMultipleVolumes: boolean;\n\n constructor(data: any) {\n // Map raw data to the correct wrapper class instances\n this.protectedFolders = data.protectedFolders ? data.protectedFolders.map(item => new ProtectedPath(item)) : [];\n this.objectList = data.objectList ? data.objectList.map(item => new ProtectedPath(item)) : [];\n this.executionList = data.executionList ? data.executionList.map(item => new ProtectedPath(item)) : [];\n this.registryKeyList = data.registryKeyList ? data.registryKeyList.map(item => new ProtectedRegKey(item)) : [];\n\n this.hasMultipleVolumes = this.getNumUniqueVolumes() > 1;\n }\n\n /**\n * Updated to look inside the ProtectedPath wrapper for pathInfo.\n */\n getNumUniqueVolumes(): number {\n const volumeIds = this.protectedFolders\n // 1. Filter out items that are ACLs (i.e., they don't have pathInfo)\n .filter(item => !!item.pathInfo)\n // 2. Map the remaining items to their volumeId\n .map(item => item.pathInfo!.volumeId); // The '!' is safe because of the filter\n\n return new Set(volumeIds).size;\n }\n\n /**\n * HELPER: Example of how to extract a clean list of just PathWithVolumeInfo\n */\n public static getPathInfoList(items: ProtectedPath[]): PathWithVolumeInfo[] {\n return items\n .filter(item => !!item.pathInfo)\n .map(item => item.pathInfo!);\n }\n\n /**\n * HELPER: Example of how to extract a clean list of just RegistryKey\n */\n public static getRegistryKeyList(items: ProtectedRegKey[]): RegistryKey[] {\n return items\n .filter(item => !!item.pathInfo)\n .map(item => item.pathInfo!);\n }\n}", "\nexport interface IQATest {\n testName: string;\n testResult: TestResult;\n testType: TestType;\n webSitePrePostTestDetails?: IWebSitePrePostTestDetails;\n volumeShadowCopyDetails?: string;\n dbTestDetails?: string;\n}\n\nexport enum TestResult {\n PASS = \"PASS\",\n PRE_CHECK_FAIL = \"PRE_CHECK_FAIL\",\n FAIL = \"FAIL\",\n NO_DATA = \"NO_DATA\",\n NOT_APPLICABLE = \"NOT_APPLICABLE\"\n}\n\nexport const TestResultDisplayMap: Record = {\n [TestResult.PASS]: \"Pass\",\n [TestResult.PRE_CHECK_FAIL]: \"PreCheck Fail\",\n [TestResult.FAIL]: \"Fail\",\n [TestResult.NO_DATA]: \"No Data\",\n [TestResult.NOT_APPLICABLE]: \"Not Applicable\"\n};\n\nexport enum TestType {\n WEBSITE_TEST = \"WEBSITE_TEST\",\n VOLUME_SHADOW_COPY_TEST = \"VOLUME_SHADOW_COPY_TEST\",\n DB_TEST = \"DB_TEST\",\n EXCHANGE_TEST = \"EXCHANGE_TEST\"\n}\n\nexport interface IWebSitePrePostTestDetails {\n precheckTestDetails: IWebSiteTestDetails[];\n postcheckTestDetails: IWebSiteTestDetails[];\n}\n\nexport interface IWebSiteTestDetails {\n service: string;\n passed: boolean;\n}\n\nexport class WebSiteTestDetails implements IWebSiteTestDetails {\n service: string;\n passed: boolean;\n\n constructor(data: any) {\n this.service = data.service;\n this.passed = data.passed;\n }\n}\n\n\n\nexport class WebSitePrePostTestDetails implements IWebSitePrePostTestDetails {\n precheckTestDetails: WebSiteTestDetails[];\n postcheckTestDetails: WebSiteTestDetails[];\n\n constructor(data: any) {\n this.precheckTestDetails = (data.precheckTestDetails || []).map(\n (d: any) => new WebSiteTestDetails(d)\n );\n this.postcheckTestDetails = (data.postcheckTestDetails || []).map(\n (d: any) => new WebSiteTestDetails(d)\n );\n }\n}\n\nexport class QATest implements IQATest {\n testName: string;\n testResult: TestResult;\n testType: TestType;\n webSitePrePostTestDetails?: WebSitePrePostTestDetails;\n volumeShadowCopyDetails?: string;\n dbTestDetails?: string;\n\n constructor(data: any) {\n this.testName = data.testName;\n \n // If the backend returns \"PASS\", \"FAIL\", etc. as strings, \n // you can cast them directly to TestResult or parse as needed:\n this.testResult = data.testResult as TestResult;\n\n // Same pattern for TestType\n this.testType = data.testType as TestType;\n\n // Populate optional fields only if they exist\n if (data.webSitePrePostTestDetails) {\n this.webSitePrePostTestDetails = new WebSitePrePostTestDetails(data.webSitePrePostTestDetails);\n }\n if (data.volumeShadowCopyDetails) {\n this.volumeShadowCopyDetails = data.volumeShadowCopyDetails;\n }\n if (data.dbTestDetails) {\n this.dbTestDetails = data.dbTestDetails;\n }\n }\n}", "import { NodeIncident } from '../models/NodeIncident';\nimport { formatNumber } from './FormatUtils';\nimport { t } from '../i18n/I18n';\n\nexport class MultipleMetrics {\n label: string;\n metrics: {\n prefix?: string | HTMLElement; // Updated to accept either string or HTMLElement\n value: number;\n description: string;\n color?: string\n suffix?: HTMLElement;\n }[];\n}\n\nexport class MetricsUI {\n private nodeIncident: NodeIncident;\n\n constructor(nodeIncident: NodeIncident) {\n this.nodeIncident = nodeIncident;\n }\n\n public static getIconDetailsForTestResult(testResult: string): { className: string, color: string } {\n switch (testResult) {\n case 'PASS':\n return { className: 'fa-solid fa-check-circle', color: 'green' };\n case 'FAIL':\n return { className: 'fa-solid fa-times-circle', color: 'red' };\n case 'PRE_CHECK_FAIL':\n return { className: 'fa fa-exclamation-triangle', color: 'orange' };\n case 'NO_DATA':\n return { className: 'fa fa-info-circle', color: 'gray' };\n default:\n return { className: '', color: '' };\n }\n }\n\n\n public static getIconForTestResult(testResult: string): HTMLElement {\n const icon = document.createElement('i');\n icon.style.marginRight = '5px'; // Add some spacing between icon and text\n\n const iconDetails = MetricsUI.getIconDetailsForTestResult(testResult);\n icon.className = iconDetails.className;\n icon.style.color = iconDetails.color;\n return icon;\n }\n\n public createMultipleMetrics(): HTMLElement {\n const metrics: MultipleMetrics[] = [\n {\n label: t('body.corrupted'),\n metrics: [\n {\n value: this.nodeIncident.numberCorruptedFilesInProtectedFolders,\n description: t('body.protectedType'),\n suffix: MetricsUI.getIconForTestResult(this.nodeIncident.numberCorruptedFilesInProtectedFolders === 0 ? 'PASS' : 'FAIL'),\n color: this.nodeIncident.numberCorruptedFilesInProtectedFolders === 0 ? 'green' : 'red'\n },\n { value: this.nodeIncident.numberCorruptedFiles, description: t('body.otherFiles') },\n { prefix: t('body.inPrefix'), value: this.nodeIncident.numberCorruptedFolders, description: t('body.folders') }\n ]\n },\n {\n label: t('body.exposed'),\n metrics: [\n {\n value: this.nodeIncident.numberExposedFilesInProtectedFolders,\n description: t('body.protectedType'),\n suffix: MetricsUI.getIconForTestResult(this.nodeIncident.numberExposedFilesInProtectedFolders === 0 ? 'PASS' : null),\n color: this.nodeIncident.numberExposedFilesInProtectedFolders === 0 ? 'green' : null\n },\n { value: this.nodeIncident.numberExposedFiles, description: t('body.otherFiles') },\n { prefix: t('body.inPrefix'), value: this.nodeIncident.numberExposedFolders, description: t('body.folders') }\n ]\n },\n {\n label: t('body.dropped'),\n metrics: [\n {\n value: this.nodeIncident.numberDroppedFilesInProtectedFolders,\n description: t('body.protectedType'),\n suffix: MetricsUI.getIconForTestResult(this.nodeIncident.numberDroppedFilesInProtectedFolders === 0 ? 'PASS' : null),\n color: this.nodeIncident.numberDroppedFilesInProtectedFolders === 0 ? 'green' : null\n },\n { value: this.nodeIncident.numberDroppedFiles, description: t('body.otherFiles') },\n { prefix: t('body.inPrefix'), value: this.nodeIncident.numberDroppedFolders, description: t('body.folders') }\n ]\n }\n ];\n\n const container: HTMLElement = document.createElement('div');\n container.className = 'multiple-metrics-container';\n\n metrics.forEach((metric: MultipleMetrics) => {\n const card: HTMLElement = document.createElement('div');\n card.className = 'multiple-metric-card';\n\n const title: HTMLElement = document.createElement('div');\n title.className = 'multiple-metric-title';\n title.textContent = metric.label;\n title.style.textAlign = 'left';\n title.style.fontWeight = 'bold';\n card.appendChild(title);\n\n const table: HTMLElement = document.createElement('table');\n metric.metrics.forEach((item) => {\n const row: HTMLElement = document.createElement('tr');\n\n const valueWithPrefixCell: HTMLElement = document.createElement('td');\n valueWithPrefixCell.className = 'multiple-metric-value';\n valueWithPrefixCell.style.textAlign = 'right';\n\n if (item.prefix) {\n const prefixSpan: HTMLElement = document.createElement('span');\n prefixSpan.textContent = item.prefix + ' ';\n prefixSpan.style.fontWeight = 'normal';\n valueWithPrefixCell.appendChild(prefixSpan);\n }\n\n const valueSpan: HTMLElement = document.createElement('span');\n valueSpan.textContent = formatNumber(item.value);\n valueSpan.style.display = 'inline-block';\n valueSpan.style.fontWeight = 'bold';\n valueWithPrefixCell.appendChild(valueSpan);\n\n row.appendChild(valueWithPrefixCell);\n\n const descriptionCell: HTMLElement = document.createElement('td');\n descriptionCell.textContent = item.description;\n descriptionCell.className = 'multiple-metric-description';\n\n if (item.color) {\n valueWithPrefixCell.style.fontWeight = 'bold';\n valueWithPrefixCell.style.color = item.color;\n descriptionCell.style.color = item.color;\n }\n\n row.appendChild(descriptionCell);\n\n if (item.suffix) {\n row.appendChild(item.suffix);\n }\n\n table.appendChild(row);\n });\n\n card.appendChild(table);\n container.appendChild(card);\n });\n\n return container;\n }\n\n public createRegistryMetrics(): HTMLElement {\n // Similar structure for registry keys\n const metrics: MultipleMetrics[] = [\n {\n label: t('body.corrupted'),\n metrics: [\n {\n value: this.nodeIncident.numberCorruptedRegistryKeysInProtectedKeys,\n description: t('body.protectedType'),\n suffix: MetricsUI.getIconForTestResult(this.nodeIncident.numberCorruptedRegistryKeysInProtectedKeys === 0 ? 'PASS' : 'FAIL'),\n color: this.nodeIncident.numberCorruptedRegistryKeysInProtectedKeys === 0 ? 'green' : 'red'\n },\n { value: this.nodeIncident.numberCorruptedRegistryKeys, description: t('body.otherKeys') }\n ]\n },\n {\n label: t('body.exposed'),\n metrics: [\n {\n value: this.nodeIncident.numberExposedRegistryKeysInProtectedKeys,\n description: t('body.protectedType'),\n suffix: MetricsUI.getIconForTestResult(this.nodeIncident.numberExposedRegistryKeysInProtectedKeys === 0 ? 'PASS' : null),\n color: this.nodeIncident.numberExposedRegistryKeysInProtectedKeys === 0 ? 'green' : null\n },\n { value: this.nodeIncident.numberExposedRegistryKeys, description: t('body.otherKeys') }\n ]\n },\n {\n label: t('body.dropped'),\n metrics: [\n {\n value: this.nodeIncident.numberDroppedRegistryKeysInProtectedKeys,\n description: t('body.protectedType'),\n suffix: MetricsUI.getIconForTestResult(this.nodeIncident.numberDroppedRegistryKeysInProtectedKeys === 0 ? 'PASS' : null),\n color: this.nodeIncident.numberDroppedRegistryKeysInProtectedKeys === 0 ? 'green' : null\n },\n { value: this.nodeIncident.numberDroppedRegistryKeys, description: t('body.otherKeys') }\n ]\n }\n ];\n\n const container: HTMLElement = document.createElement('div');\n container.className = 'multiple-metrics-container';\n\n metrics.forEach((metric: MultipleMetrics) => {\n const card: HTMLElement = document.createElement('div');\n card.className = 'multiple-metric-card';\n\n const title: HTMLElement = document.createElement('div');\n title.className = 'multiple-metric-title';\n title.textContent = metric.label;\n title.style.textAlign = 'left';\n title.style.fontWeight = 'bold';\n card.appendChild(title);\n\n const table: HTMLElement = document.createElement('table');\n metric.metrics.forEach((item) => {\n const row: HTMLElement = document.createElement('tr');\n\n const valueCell: HTMLElement = document.createElement('td');\n valueCell.className = 'multiple-metric-value';\n valueCell.style.textAlign = 'right';\n\n const valueSpan: HTMLElement = document.createElement('span');\n valueSpan.textContent = formatNumber(item.value);\n valueSpan.style.display = 'inline-block';\n valueSpan.style.fontWeight = 'bold';\n valueCell.appendChild(valueSpan);\n\n row.appendChild(valueCell);\n\n const descriptionCell: HTMLElement = document.createElement('td');\n descriptionCell.textContent = item.description;\n descriptionCell.className = 'multiple-metric-description';\n\n if (item.color) {\n valueCell.style.fontWeight = 'bold';\n valueCell.style.color = item.color;\n descriptionCell.style.color = item.color;\n }\n\n row.appendChild(descriptionCell);\n\n if (item.suffix) {\n row.appendChild(item.suffix);\n }\n\n table.appendChild(row);\n });\n\n card.appendChild(table);\n container.appendChild(card);\n });\n\n return container;\n }\n}", "export interface IUnauthorizedExecutable {\n programExecutable: string;\n knownRansomware: boolean;\n ransomwareName: string;\n processCreates: number;\n deflections: number;\n hallmarks: number;\n startTime: number;\n endTime: number;\n}\n\nexport class UnauthorizedExecutable implements IUnauthorizedExecutable {\n programExecutable: string;\n knownRansomware: boolean;\n ransomwareName: string;\n processCreates: number;\n deflections: number;\n hallmarks: number;\n startTime: number;\n endTime: number;\n\n constructor(data: any) {\n this.programExecutable = data.programExecutable;\n this.knownRansomware = data.knownRansomware;\n this.ransomwareName = data.ransomwareName;\n this.processCreates = data.processCreates;\n this.deflections = data.deflections;\n this.hallmarks = data.hallmarks;\n this.startTime = data.startTime;\n this.endTime = data.endTime;\n }\n}", "\nexport interface IServiceInfo {\n name: string;\n displayName: string;\n state: string;\n status: number;\n startMode: string;\n pathName: string;\n startName: string;\n description: string;\n exitCode: number;\n checkPoint: number;\n lastUpdateSec?: number;\n}\n\nexport interface IServiceInfoHistoryEvent {\n duration: number;\n serviceInfo: IServiceInfo[];\n}\n\nexport class ServiceInfoHistoryEvent implements IServiceInfoHistoryEvent {\n duration: number; // Duration in seconds\n serviceInfo: ServiceInfo[];\n\n constructor(data: any) {\n this.duration = data.duration;\n this.serviceInfo = data.serviceInfo ? data.serviceInfo.map((info: any) => new ServiceInfo(info)) : [];\n }\n}\n\nexport class ServiceInfo implements IServiceInfo {\n name: string;\n displayName: string;\n state: string;\n status: number;\n startMode: string;\n pathName: string;\n startName: string;\n description: string;\n exitCode: number;\n checkPoint: number;\n lastUpdateSec?: number;\n\n constructor(data: IServiceInfo) {\n this.name = data.name;\n this.displayName = data.displayName;\n this.state = data.state;\n this.status = data.status;\n this.startMode = data.startMode;\n this.pathName = data.pathName;\n this.startName = data.startName;\n this.description = data.description;\n this.exitCode = data.exitCode;\n this.checkPoint = data.checkPoint;\n this.lastUpdateSec = data.lastUpdateSec ?? 0;\n }\n}", "import { NodeMetric } from './NodeMetric';\nimport { Protections, IProtections } from './Protections';\nimport { PathWithVolumeInfo, RegistryKey } from './Protections';\nimport { QATest, IQATest, TestType, TestResult, TestResultDisplayMap } from './QATests';\nimport { MetricsUI } from '../utils/MetricsUtils';\nimport { UnauthorizedExecutable } from './UnauthorizedExecutable';\nimport { ServiceInfoHistoryEvent } from './ServiceInfo';\n\nexport interface INodeIncident {\n id: string;\n tenantId: string;\n nodeId: string;\n hostname: string;\n osType: string;\n startTime: number;\n endTime: number;\n outputBucket: string;\n nodeVersion: string;\n totalEvents: number;\n signals: IEventCountByType;\n hallmarks: IEventCountByType;\n deflections: IEventCountByType;\n behaviors: IEventCountByType;\n kernelRecords: IEventCountByType;\n statsHistoryMap: IStatsHistoryMap;\n nodeMetrics: NodeMetric[];\n numberCorruptedFolders: number;\n numberCorruptedFiles: number;\n numberExposedFolders: number;\n numberExposedFiles: number;\n numberDroppedFolders: number;\n numberDroppedFiles: number;\n numberCorruptedFilesInProtectedFolders: number;\n corruptedFileTypeCounts: IFileTypeCount[];\n corruptedFolderFileCounts: IFolderFileCount[];\n numberExposedFilesInProtectedFolders: number;\n exposedFileTypeCounts: IFileTypeCount[];\n exposedFolderFileCounts: IFolderFileCount[];\n droppedFileTypeCounts: IFileTypeCount[];\n droppedFolderFileCounts: IFolderFileCount[];\n numberDroppedFilesInProtectedFolders: number;\n\n // Registry keys stats\n numberCorruptedRegistryKeys: number;\n numberExposedRegistryKeys: number;\n numberDroppedRegistryKeys: number;\n numberCorruptedRegistryKeysInProtectedKeys: number;\n numberExposedRegistryKeysInProtectedKeys: number;\n numberDroppedRegistryKeysInProtectedKeys: number;\n\n protections: IProtections;\n protectionsStatsMap: Map>;\n qaTests: IQATest[];\n ransomwareInfo: RansomwareInfo[];\n unauthorizedExecutables: UnauthorizedExecutable[];\n serviceInfoHistory: ServiceInfoHistoryEvent[];\n\n}\n\n\nexport interface IFileTypeCount {\n fileType: string;\n count: number;\n}\n\nexport interface IFolderFileCount {\n folder: string;\n count: number;\n}\n\nexport interface IProtectionsStats {\n numDeflections: number;\n numProtected: number;\n numCriticalCorrupted: number;\n numCriticalExposed: number;\n numCriticalDropped: number;\n numNotByRansomProcesses: number;\n}\n\nexport interface IRansomwareInfo {\n name: string;\n files: IRansomwareFileInfo[];\n}\n\nexport interface IRansomwareFileInfo {\n deploy: string;\n sha256: string;\n}\n\nexport class FileTypeCount implements IFileTypeCount {\n fileType: string;\n count: number;\n\n constructor(data: any) {\n this.fileType = data.fileType;\n this.count = data.count;\n }\n}\n\nexport class FolderFileCount implements IFolderFileCount {\n folder: string;\n count: number;\n\n constructor(data: any) {\n this.folder = data.folder;\n this.count = data.count;\n }\n}\n\nexport class ProtectionsStats implements IProtectionsStats {\n numDeflections: number;\n numProtected: number;\n numCriticalCorrupted: number;\n numCriticalExposed: number;\n numCriticalDropped: number;\n numNotByRansomProcesses: number;\n\n constructor(data?: any) {\n this.numDeflections = data?.numDeflections || 0;\n this.numProtected = data?.numProtected || 0;\n this.numCriticalCorrupted = data?.numCriticalCorrupted || 0;\n this.numCriticalExposed = data?.numCriticalExposed || 0;\n this.numCriticalDropped = data?.numCriticalDropped || 0;\n this.numNotByRansomProcesses = data?.numNotByRansomProcesses || 0;\n }\n}\n\n\nexport class RansomwareFileInfo implements IRansomwareFileInfo {\n deploy: string;\n sha256: string;\n constructor(data: any) {\n this.deploy = data.deploy;\n this.sha256 = data.sha256;\n }\n}\n\nexport class RansomwareInfo implements IRansomwareInfo {\n name: string;\n files: RansomwareFileInfo[];\n\n constructor(data: any) {\n this.name = data.name;\n this.files = (data.files || []).map((info: any) => new RansomwareFileInfo(info));\n }\n}\n\n\nexport interface ICompromisedCountByType {\n numProtected: number;\n numOther: number;\n numFolders: number;\n}\n\nexport interface IStatsCountByType {\n eventCountByType?: IEventCountByType;\n compromisedCountByType?: ICompromisedCountByType;\n}\n\nexport interface IStatsHistory {\n duration: number; // in seconds\n statsCountByType?: IStatsCountByType;\n}\n\nexport interface IStatsHistoryMap {\n [category: string]: IStatsHistory[];\n}\n\nexport interface IEventCountByType {\n handleIo: number;\n createIo: number;\n failedIo: number;\n /** RECORD_TYPE_FILE_OPEN \u2014 the Linux eBPF file-open deflection. */\n fileOpen: number;\n pathTypePath: number;\n\n objectAction: number;\n securityEventLog: number;\n serviceChange: number;\n sessionChange: number;\n dsyncAttack: number;\n pathTypeObject: number;\n\n registryOperation: number;\n hasRegistryOperations: boolean;\n processCreate: number;\n processTerminate: number;\n pathTypeRegistry: number;\n authenticode: number;\n}\n\nexport class EventCountByType implements IEventCountByType {\n handleIo: number;\n createIo: number;\n failedIo: number;\n fileOpen: number;\n pathTypePath: number;\n\n objectAction: number;\n securityEventLog: number;\n serviceChange: number;\n sessionChange: number;\n dsyncAttack: number;\n pathTypeObject: number;\n\n registryOperation: number;\n hasRegistryOperations: boolean;\n processCreate: number;\n processTerminate: number;\n pathTypeRegistry: number;\n authenticode: number;\n\n constructor(data: any) {\n this.handleIo = data.handleIo ?? 0;\n this.createIo = data.createIo ?? 0;\n this.failedIo = data.failedIo ?? 0;\n // Reports built before ENG-4076 have no fileOpen key; 0 is correct for\n // them \u2014 they need a re-import to pick the count up.\n this.fileOpen = data.fileOpen ?? 0;\n this.pathTypePath = data.pathTypePath ?? 0;\n this.objectAction = data.objectAction ?? 0;\n this.securityEventLog = data.securityEventLog ?? 0;\n this.serviceChange = data.serviceChange ?? 0;\n this.sessionChange = data.sessionChange ?? 0;\n this.dsyncAttack = data.dsyncAttack ?? 0;\n this.pathTypeObject = data.pathTypeObject ?? 0;\n this.registryOperation = data.registryOperation ?? 0;\n this.hasRegistryOperations = this.registryOperation > 0;\n this.processCreate = data.processCreate ?? 0;\n this.processTerminate = data.processTerminate ?? 0;\n this.pathTypeRegistry = data.pathTypeRegistry ?? 0;\n this.authenticode = data.authenticode ?? 0;\n }\n\n getTotal(): number {\n return this.handleIo +\n this.createIo +\n this.failedIo +\n this.fileOpen +\n this.objectAction +\n this.registryOperation +\n this.processCreate +\n this.processTerminate +\n this.securityEventLog +\n this.serviceChange +\n this.sessionChange +\n this.dsyncAttack +\n this.pathTypePath +\n this.pathTypeObject +\n this.pathTypeRegistry;\n }\n}\n\nexport class CompromisedCountByType implements ICompromisedCountByType {\n numProtected: number;\n numOther: number;\n numFolders: number;\n\n constructor(data: any) {\n this.numProtected = data.numProtected || 0;\n this.numOther = data.numOther || 0;\n this.numFolders = data.numFolders || 0;\n }\n\n getTotal(): number {\n return this.numProtected + this.numOther + this.numFolders;\n }\n}\n\nexport class StatsCountByType implements IStatsCountByType {\n eventCountByType?: EventCountByType;\n compromisedCountByType?: CompromisedCountByType;\n\n constructor(data: any) {\n if (data.eventCountByType) {\n this.eventCountByType = new EventCountByType(data.eventCountByType);\n }\n if (data.compromisedCountByType) {\n this.compromisedCountByType = new CompromisedCountByType(data.compromisedCountByType);\n }\n }\n\n getTotal(): number {\n let total = 0;\n\n if (this.eventCountByType) {\n total += this.eventCountByType.getTotal();\n }\n if (this.compromisedCountByType) {\n total += this.compromisedCountByType.getTotal();\n }\n\n return total;\n }\n\n}\n\nexport class StatsHistory implements IStatsHistory {\n duration: number;\n statsCountByType?: StatsCountByType;\n\n constructor(data: any) {\n this.duration = data.duration || 0;\n if (data.statsCountByType) {\n this.statsCountByType = new StatsCountByType(data.statsCountByType);\n }\n }\n}\n\nexport class StatsHistoryMap implements IStatsHistoryMap {\n [category: string]: StatsHistory[];\n\n constructor(data: any) {\n // Clear out any pre-existing keys\n for (const key of Object.keys(this)) {\n delete this[key];\n }\n // Populate from data\n if (data && typeof data === 'object') {\n for (const [category, arr] of Object.entries(data)) {\n if (Array.isArray(arr)) {\n this[category] = arr.map((item: any) => new StatsHistory(item));\n } else {\n this[category] = [];\n }\n }\n }\n }\n}\n\n\nexport class NodeIncident implements INodeIncident {\n id: string;\n tenantId: string;\n nodeId: string;\n incidentId: string;\n hostname: string;\n osType: string;\n startTime: number;\n endTime: number;\n outputBucket: string;\n nodeVersion: string;\n totalEvents: number;\n signals: EventCountByType;\n hallmarks: EventCountByType;\n deflections: EventCountByType;\n behaviors: EventCountByType;\n kernelRecords: EventCountByType;\n behaviorRecords: number;\n statsHistoryMap: StatsHistoryMap;\n nodeMetrics: NodeMetric[];\n numberCorruptedFolders: number;\n numberCorruptedFiles: number;\n numberExposedFolders: number;\n numberExposedFiles: number;\n numberDroppedFolders: number;\n numberDroppedFiles: number;\n numberCorruptedFilesInProtectedFolders: number;\n corruptedFileTypeCounts: FileTypeCount[];\n corruptedFolderFileCounts: FolderFileCount[];\n numberExposedFilesInProtectedFolders: number;\n exposedFileTypeCounts: FileTypeCount[];\n exposedFolderFileCounts: FolderFileCount[];\n droppedFileTypeCounts: FileTypeCount[];\n droppedFolderFileCounts: FolderFileCount[];\n numberDroppedFilesInProtectedFolders: number;\n\n // Registry keys stats\n numberCorruptedRegistryKeys: number;\n numberExposedRegistryKeys: number;\n numberDroppedRegistryKeys: number;\n numberCorruptedRegistryKeysInProtectedKeys: number;\n numberExposedRegistryKeysInProtectedKeys: number;\n numberDroppedRegistryKeysInProtectedKeys: number;\n\n protections: Protections;\n protectionsStatsMap: Map>;\n qaTests: QATest[];\n websiteTestResult: string;\n ransomwareInfo: RansomwareInfo[];\n unauthorizedExecutables: UnauthorizedExecutable[];\n serviceInfoHistory: ServiceInfoHistoryEvent[];\n\n constructor(data: any) {\n this.id = data.id;\n this.tenantId = data.tenantId;\n this.nodeId = data.nodeId;\n this.incidentId = data.incidentId;\n this.hostname = data.hostname;\n this.osType = data.osType;\n this.startTime = data.startTime;\n this.endTime = data.endTime;\n this.outputBucket = data.outputBucket;\n this.nodeVersion = data.nodeVersion;\n this.totalEvents = data.totalEvents;\n this.nodeVersion = data.nodeVersion;\n this.signals = new EventCountByType(data.signals);\n this.hallmarks = new EventCountByType(data.hallmarks);\n this.deflections = new EventCountByType(data.deflections);\n this.behaviors = new EventCountByType(data.behaviors);\n this.kernelRecords = new EventCountByType(data.kernelRecords);\n this.behaviorRecords = data.behaviorRecords || 0;\n this.statsHistoryMap = new StatsHistoryMap(data.statsHistoryMap);\n this.nodeMetrics = data.nodeMetrics ? data.nodeMetrics.map((metric: any) => new NodeMetric(metric)) : [];\n this.numberCorruptedFolders = data.numberCorruptedFolders;\n this.numberCorruptedFiles = data.numberCorruptedFiles;\n this.numberExposedFolders = data.numberExposedFolders;\n this.numberExposedFiles = data.numberExposedFiles;\n this.numberDroppedFolders = data.numberDroppedFolders;\n this.numberDroppedFiles = data.numberDroppedFiles;\n this.numberCorruptedFilesInProtectedFolders = data.numberCorruptedFilesInProtectedFolders;\n this.corruptedFileTypeCounts = (data.corruptedFileTypeCounts || []).map((count) => new FileTypeCount(count));\n this.corruptedFolderFileCounts = (data.corruptedFolderFileCounts || []).map((count: any) => new FolderFileCount(count));\n this.numberExposedFilesInProtectedFolders = data.numberExposedFilesInProtectedFolders;\n this.exposedFileTypeCounts = (data.exposedFileTypeCounts || []).map((count) => new FileTypeCount(count));\n this.exposedFolderFileCounts = (data.exposedFolderFileCounts || []).map((count: any) => new FolderFileCount(count));\n this.droppedFileTypeCounts = (data.droppedFileTypeCounts || []).map((count) => new FileTypeCount(count));\n this.droppedFolderFileCounts = (data.droppedFolderFileCounts || []).map((count: any) => new FolderFileCount(count));\n this.numberDroppedFilesInProtectedFolders = data.numberDroppedFilesInProtectedFolders;\n\n this.numberCorruptedRegistryKeys = data.numberCorruptedRegistryKeys;\n this.numberExposedRegistryKeys = data.numberExposedRegistryKeys;\n this.numberDroppedRegistryKeys = data.numberDroppedRegistryKeys;\n this.numberCorruptedRegistryKeysInProtectedKeys = data.numberCorruptedRegistryKeysInProtectedKeys;\n this.numberExposedRegistryKeysInProtectedKeys = data.numberExposedRegistryKeysInProtectedKeys;\n this.numberDroppedRegistryKeysInProtectedKeys = data.numberDroppedRegistryKeysInProtectedKeys;\n\n this.protections = data.protections ? new Protections(data.protections) : null;\n this.protectionsStatsMap = data.protectionsStatsMap ? new Map(Object.entries(data.protectionsStatsMap).map(([key, value]) => [key, new Map(Object.entries(value).map(([key, value]) => [key, new ProtectionsStats(value)]))])) : new Map>();\n this.qaTests = data.qaTests ? data.qaTests.map((test: any) => new QATest(test)) : [];\n this.websiteTestResult = this.getWebsiteTestResult();\n this.ransomwareInfo = data.ransomwareInfo ? data.ransomwareInfo.map((info: any) => new RansomwareInfo(info)) : [];\n this.unauthorizedExecutables = data.unauthorizedExecutables ? data.unauthorizedExecutables.map((exe: any) => new UnauthorizedExecutable(exe)) : [];\n this.serviceInfoHistory = data.serviceInfoHistory ? data.serviceInfoHistory.map((event: any) => new ServiceInfoHistoryEvent(event)) : [];\n }\n\n computeProtectionsTotals(protectionType: string): ProtectionsStats {\n let totalDeflections = 0;\n let totalCorrupted = 0;\n let totalExposed = 0;\n let totalDropped = 0;\n let totalProtected = 0;\n\n const table = this.getProtectionsTable(protectionType);\n\n // The table lists RULES, but the stats map is keyed by the RESOURCE the\n // rule guards \u2014 so several rules on one path all resolve to the same\n // key. Summing per rule therefore counted that path's stats once per\n // rule: the reference node has four rules on /opt/pysec/backend (2001\n // allow nginx, 2002/2003 allow systemd, 2004 deny ANY), so its 2,442\n // protected files were reported as 9,768. Count each resource once.\n const countedKeys = new Set();\n\n table.forEach((item: PathWithVolumeInfo | RegistryKey) => {\n let key: string;\n\n if ('friendlyName' in item && 'key' in item) {\n key = (item as RegistryKey).friendlyName.toUpperCase();\n } else if ('volumeId' in item && 'path' in item) {\n key = `${(item as PathWithVolumeInfo).volumeId}:${(item as PathWithVolumeInfo).path}`;\n }\n\n if (key === undefined || countedKeys.has(key)) {\n return;\n }\n countedKeys.add(key);\n\n const stats = this.protectionsStatsMap.get(protectionType)?.get(key) || new ProtectionsStats({\n numDeflections: 0,\n numCriticalCorrupted: 0,\n numCriticalExposed: 0,\n numCriticalDropped: 0,\n numProtected: 0\n });\n\n totalDeflections += stats.numDeflections;\n totalCorrupted += stats.numCriticalCorrupted;\n totalExposed += stats.numCriticalExposed;\n totalDropped += stats.numCriticalDropped;\n totalProtected += stats.numProtected;\n });\n\n return new ProtectionsStats({\n numDeflections: totalDeflections,\n numCriticalCorrupted: totalCorrupted,\n numCriticalExposed: totalExposed,\n numCriticalDropped: totalDropped,\n numProtected: totalProtected,\n numNotByRansomProcesses: 0\n });\n }\n\n private getProtectionsTable(protectionType: string): (PathWithVolumeInfo | RegistryKey)[] {\n switch (protectionType) {\n case \"File\":\n return Protections.getPathInfoList(this.protections.protectedFolders) || [];\n case \"Registry\":\n return Protections.getRegistryKeyList(this.protections.registryKeyList) || [];\n case \"Object\":\n return Protections.getPathInfoList(this.protections.objectList) || [];\n default:\n console.warn(`Unknown protection type: ${protectionType}`);\n return [];\n }\n }\n\n public getCurrentServiceInfo(): ServiceInfoHistoryEvent | null {\n if (!this.serviceInfoHistory.length) {\n return null;\n }\n\n return this.serviceInfoHistory[this.serviceInfoHistory.length - 1];\n }\n\n private getWebsiteTestResult(): string | null {\n let websiteQATests = this.qaTests.filter(test => test.testType === TestType.WEBSITE_TEST);\n let test = websiteQATests.length > 0 ? websiteQATests[0] : null;\n if (!test) {\n return null;\n }\n\n if (test.testType === TestType.WEBSITE_TEST) {\n return TestResultDisplayMap[test.testResult];\n }\n\n if (test.testType === TestResultDisplayMap[TestResult.NO_DATA]) {\n return null;\n }\n\n return null;\n }\n\n getWebSiteTestDetails(): string | null {\n const websiteTestResult = this.getWebsiteTestResult()\n if (!websiteTestResult) {\n return null;\n }\n return websiteTestResult.toLocaleUpperCase();;\n }\n\n getWebSiteIconTestDetails(): { className: string, color: string } | null {\n const result = this.getWebSiteTestDetails();\n if (!result) {\n return null;\n }\n const iconDetails = MetricsUI.getIconDetailsForTestResult(result.toLocaleUpperCase());\n return iconDetails;\n }\n\n /**\n * Determine the most relevant ransomware label for this incident.\n *\n * Priority:\n * 1. First entry in `ransomwareInfo` (backend-supplied ground truth)\n * 2. If *all* UnauthorizedExecutables are explicitly marked \u201Cno\u201D,\n * return `\"Not Detected\"`\n * 3. Otherwise `null`\n */\n getRansomwareName(): string | null {\n /* 1 \u2500 explicit ransomware info from backend */\n if (this.ransomwareInfo?.length && this.ransomwareInfo[0].name && this.ransomwareInfo[0].name !== 'Not Detected') {\n if (this.ransomwareInfo[0].name === 'Unknown') {\n return null; // Explicitly return null for \"Unknown\"\n }\n return this.ransomwareInfo[0].name;\n }\n\n /* 2 \u2500 infer from UnauthorizedExecutables */\n if (this.unauthorizedExecutables?.length) {\n // Property is usually a string like \"Yes\"/\"No\"\n const anyYes = this.unauthorizedExecutables\n .some((ue: any) =>\n typeof ue.ransomware === 'string'\n ? ue.ransomware.toLowerCase() !== 'no'\n : Boolean(ue.isRansomware) // fallback boolean flag\n );\n\n if (!anyYes) {\n return 'Not Detected';\n }\n }\n\n /* 3 \u2500 unknown */\n return null;\n }\n\n getTotalDeflections(): number {\n const {\n failedIo,\n fileOpen,\n handleIo,\n createIo,\n objectAction,\n registryOperation,\n processCreate,\n processTerminate,\n } = this.deflections;\n\n return failedIo + fileOpen + handleIo + objectAction + registryOperation + processCreate + processTerminate;\n }\n\n /** Sum of *Signal* events for this node */\n getTotalSignals(): number {\n const {\n failedIo,\n handleIo,\n createIo,\n objectAction,\n securityEventLog,\n serviceChange,\n sessionChange,\n dsyncAttack,\n registryOperation,\n processCreate,\n processTerminate,\n } = this.signals;\n\n return (\n failedIo +\n handleIo +\n createIo +\n objectAction +\n securityEventLog +\n serviceChange +\n sessionChange +\n dsyncAttack +\n registryOperation +\n processCreate +\n processTerminate\n );\n }\n\n /** Sum of *Hallmark* events for this node */\n getTotalHallmarks(): number {\n const {\n failedIo,\n createIo,\n handleIo,\n objectAction,\n securityEventLog,\n serviceChange,\n sessionChange,\n dsyncAttack,\n registryOperation,\n processCreate,\n processTerminate,\n } = this.hallmarks;\n\n return (\n failedIo +\n createIo +\n handleIo +\n objectAction +\n securityEventLog +\n serviceChange +\n sessionChange +\n dsyncAttack +\n registryOperation +\n processCreate +\n processTerminate\n );\n }\n\n getTotalBehaviorRecords(): number {\n return this.behaviorRecords;\n }\n\n getTotalKernelRecords(): number {\n const {\n failedIo,\n handleIo,\n objectAction,\n registryOperation,\n processCreate,\n processTerminate,\n } = this.kernelRecords;\n\n return failedIo + handleIo + objectAction + registryOperation + processCreate + processTerminate;\n }\n\n getTimeToDetect(): number | null {\n let timeToDetectMetric = this.nodeMetrics.find(metric => metric.metricLabel === 'Time to detect');\n return timeToDetectMetric ? timeToDetectMetric.metric : null;\n }\n\n getFirstUnauthorizedExecutableStartTime(): number | null {\n if (!this.unauthorizedExecutables.length) {\n return null;\n }\n\n return Math.min(...this.unauthorizedExecutables.map(ue => ue.startTime));\n }\n\n hasCompromisedFiles(): boolean {\n return (\n this.numberCorruptedFiles +\n this.numberExposedFiles +\n this.numberDroppedFiles +\n this.numberCorruptedFilesInProtectedFolders +\n this.numberExposedFilesInProtectedFolders +\n this.numberDroppedFilesInProtectedFolders +\n this.numberCorruptedFolders +\n this.numberExposedFolders +\n this.numberDroppedFolders\n ) > 0;\n }\n\n hasCompromisedRegistryKeys(): boolean {\n return (\n this.numberCorruptedRegistryKeys +\n this.numberExposedRegistryKeys +\n this.numberDroppedRegistryKeys +\n this.numberCorruptedRegistryKeysInProtectedKeys +\n this.numberExposedRegistryKeysInProtectedKeys +\n this.numberDroppedRegistryKeysInProtectedKeys\n ) > 0;\n }\n\n hasBehaviorRecords(): boolean {\n return this.behaviorRecords > 0;\n }\n}\n\n", "import { INodeIncident, NodeIncident } from './NodeIncident';\n\nexport enum IncidentStatus {\n Initialized = \"initialized\",\n Initializing = \"initializing\",\n InitializationWaiting = \"initialization_waiting\",\n InProgress = \"in_progress\",\n ReadyForImport = \"ready_for_import\",\n Importing = \"importing\",\n Imported = \"imported\",\n Failed = \"failed\",\n}\n\n\nexport interface IIncident {\n id: string;\n name: string;\n creator: string;\n outputBucket: string\n nodes: INodeIncident[];\n status: IncidentStatus;\n tenantId: string;\n startTime: number;\n endTime: number;\n hasCompromisedNodes: boolean;\n hasCorruptedNodes: boolean;\n hasRegistryOperations: boolean;\n hasRegistryProtections: boolean;\n protectionsFilter: string;\n webSiteTestDetails: string;\n ransomwareName: string | null;\n nodeVersion: string;\n osType: string;\n operationId: string;\n importStartTime?: number;\n}\n\n\nexport class Incident implements IIncident {\n id: string;\n reportFlags: number;\n name: string;\n creator: string;\n outputBucket: string\n nodes: NodeIncident[];\n status: IncidentStatus;\n tenantId: string;\n startTime: number;\n endTime: number;\n hasCompromisedNodes: boolean;\n hasCorruptedNodes: boolean;\n hasRegistryOperations: boolean;\n hasRegistryProtections: boolean;\n protectionsFilter: string;\n webSiteTestDetails: string;\n ransomwareName: string | null;\n nodeVersion: string;\n osType: string;\n operationId: string;\n importStartTime?: number;\n modifyTime?: number;\n\n constructor(data: any) {\n this.id = data.id;\n this.reportFlags = data.reportFlags\n this.name = data.name;\n this.creator = data.creator;\n this.outputBucket = data.outputBucket;\n this.nodes = Array.isArray(data.nodes) ? data.nodes.map((nodeData: any) => new NodeIncident(nodeData)) : [];\n this.status = data.status;\n this.tenantId = data.tenantId;\n this.startTime = data.startTime;\n this.endTime = data.endTime;\n this.hasCompromisedNodes = this.getCompromisedNodeStatus();\n this.hasCorruptedNodes = this.getCorruptedNodeStatus();\n this.hasRegistryOperations = this.nodes.some(node => node.deflections.hasRegistryOperations);\n this.hasRegistryProtections = this.getHasRegistryProtections();\n this.protectionsFilter = this.getProtectionsFilter();\n this.webSiteTestDetails = this.getWebSiteTestDetails();\n this.ransomwareName = this.getRansomwareName();\n this.nodeVersion = this.getNodeVersion();\n this.osType = this.getOSType();\n this.operationId = data.operationId;\n this.importStartTime = data.importStartTime;\n this.modifyTime = data.modifyTime;\n }\n\n getCorruptedNodeStatus(): boolean {\n let fileTotalCorrupted = 0;\n let registryTotalCorrupted = 0;\n\n this.nodes.forEach((node) => {\n const fileStats = node.computeProtectionsTotals(\"File\");\n fileTotalCorrupted += fileStats.numCriticalCorrupted;\n\n const registryStats = node.computeProtectionsTotals(\"Registry\");\n registryTotalCorrupted += registryStats.numCriticalCorrupted;\n });\n\n // Return true if either total is greater than 0\n return fileTotalCorrupted > 0 || registryTotalCorrupted > 0;\n }\n\n getCompromisedNodeStatus(): boolean {\n let fileTotalCompromised = 0;\n let registryTotalCompromised = 0;\n\n this.nodes.forEach((node) => {\n const fileStats = node.computeProtectionsTotals(\"File\");\n fileTotalCompromised += fileStats.numCriticalCorrupted + fileStats.numCriticalExposed + fileStats.numCriticalDropped;\n\n const registryStats = node.computeProtectionsTotals(\"Registry\");\n registryTotalCompromised += registryStats.numCriticalCorrupted + registryStats.numCriticalExposed + registryStats.numCriticalDropped;\n });\n\n // Return true if either total is greater than 0\n return fileTotalCompromised > 0 || registryTotalCompromised > 0;\n }\n\n getHasRegistryProtections(): boolean {\n let hasRegistryProtections = false;\n\n this.nodes.forEach((node) => {\n if (!node.protections) {\n return false;\n }\n const p = node.protections;\n if (!p.registryKeyList) {\n return false;\n }\n if (p.registryKeyList.length > 0) {\n hasRegistryProtections = true;\n }\n });\n return hasRegistryProtections;\n }\n\n\n getProtectionsFilter(): string {\n\n let filter: string[] = [];\n let hasFileProtections = false;\n let hasRegistryProtections = false;\n let hasObjectProtections = false;\n\n this.nodes.forEach((node) => {\n const p = node.protections;\n if (p?.protectedFolders?.length > 0) {\n hasFileProtections = true;\n }\n if (p?.registryKeyList?.length > 0) {\n hasRegistryProtections = true;\n }\n if (p?.objectList?.length > 0) {\n hasObjectProtections = true;\n }\n });\n\n // return f,r,o for folders, registry, objects\n if (hasFileProtections) {\n filter.push('file');\n }\n if (hasRegistryProtections) {\n filter.push('reg');\n }\n if (hasObjectProtections) {\n filter.push('obj');\n }\n return `[${filter.join(', ')}]`;\n }\n\n getRansomwareName(): string | null {\n for (const node of this.nodes) {\n const ransomwareName = node.ransomwareInfo?.[0]?.name || null;\n if (ransomwareName) {\n return ransomwareName;\n }\n }\n return null;\n }\n\n ransomwareDetected(): boolean {\n return this.nodes.some(node => {\n const name = node.ransomwareInfo?.[0]?.name ?? '';\n const cleaned = name.trim().toLowerCase(); // \u2190 normalise\n return cleaned !== '' && cleaned !== 'unknown' && cleaned !== 'not detected';\n });\n }\n\n getNodeVersion(): string {\n for (const node of this.nodes) {\n const nodeVersion = node.nodeVersion\n if (nodeVersion != '') {\n return nodeVersion;\n }\n }\n return \"\";\n }\n\n getOSType(): string {\n if (this.nodes.length === 0) {\n return \"windows\"; // Default case when there are no nodes\n }\n\n const osTypes = new Set(this.nodes.map(node => node.osType));\n\n if (osTypes.size === 1) {\n return osTypes.has(\"linux\") ? \"linux\" : \"windows\";\n }\n\n return \"mixed\";\n }\n\n\n getTimeToDetect(): number | null {\n\n let numNonNull = 0.0;\n let total = 0.0;\n for (const node of this.nodes) {\n const timeToDetect = node.getTimeToDetect();\n if (timeToDetect) {\n total += timeToDetect;\n numNonNull++;\n }\n }\n\n if (numNonNull > 0) {\n return total / numNonNull;\n }\n return null;\n }\n\n getTotalBehaviorRecords(): number {\n return this.nodes.reduce((acc, node) => acc + node.getTotalBehaviorRecords(), 0);\n }\n\n getTotalKernelRecords(): number {\n let totalKernelRecords = 0;\n for (const node of this.nodes) {\n totalKernelRecords += node.getTotalKernelRecords();\n }\n return totalKernelRecords;\n }\n\n /** Sum of all Signal events across every node in this incident */\n getTotalSignals(): number {\n let totalSignals = 0;\n for (const node of this.nodes) {\n // assumes Node model already exposes getTotalSignals()\n totalSignals += node.getTotalSignals();\n }\n return totalSignals;\n }\n\n /** Sum of all Hallmark events across every node in this incident */\n getTotalHallmarks(): number {\n let totalHallmarks = 0;\n for (const node of this.nodes) {\n // assumes Node model already exposes getTotalHallmarks()\n totalHallmarks += node.getTotalHallmarks();\n }\n return totalHallmarks;\n }\n\n\n getTotalDeflections(): number {\n let totalDeflections = 0;\n for (const node of this.nodes) {\n totalDeflections += node.getTotalDeflections();\n }\n return totalDeflections;\n }\n\n getNumberCorruptedFiles(): number {\n let totalCorruptedFiles = 0;\n for (const node of this.nodes) {\n totalCorruptedFiles += node.numberCorruptedFiles;\n }\n return totalCorruptedFiles;\n }\n\n getNumberExposedFiles(): number {\n let totalExposedFiles = 0;\n for (const node of this.nodes) {\n totalExposedFiles += node.numberExposedFiles;\n }\n return totalExposedFiles;\n }\n\n getWebSiteTestDetails(): any {\n for (const node of this.nodes) {\n const result = node.getWebSiteTestDetails()\n if (result) {\n return result;\n }\n }\n return '---';\n }\n\n\n getWebSiteIconTestDetails(): any {\n for (const node of this.nodes) {\n const result = node.getWebSiteIconTestDetails()\n if (result) {\n return result;\n }\n }\n return null;\n }\n\n sortNodesByUnauthorizedExecutables(): void {\n this.nodes.sort((a, b) => {\n const firstStartA = a.getFirstUnauthorizedExecutableStartTime();\n const firstStartB = b.getFirstUnauthorizedExecutableStartTime();\n\n if (firstStartA === null && firstStartB === null) return 0;\n if (firstStartA === null) return 1; // Move nulls to the end\n if (firstStartB === null) return -1; // Move nulls to the end\n\n return firstStartA - firstStartB;\n });\n }\n\n\n}\n", "import { Incident, IIncident } from '../models/Incident';\nimport { DeleteStats } from '../models/DeleteStats';\nimport { apiFetch } from '../utils/Api';\nimport { REPORTS_LOADED } from '../managers/NavigationManager';\nimport { WebSocketHandler } from '../handlers/WebSocketHandler';\nimport { ImportStatus } from '../models/ImportInfo';\nimport { storage } from '../utils/Storage';\nimport StoreManager from '../managers/StoreManager';\n\n/** The import refused nodes for their virtual patches (422): `changed` when\n * their patches changed after the report start (`v4_virtual_patch_changed`),\n * else a report does not read them (`v4_virtual_patch_unsupported`). */\nexport class ImportVirtualPatchRefusedError extends Error {\n constructor(public readonly hosts: string[], public readonly changed: boolean) {\n super(changed ? 'virtual patches changed after the report start' : 'virtual patches not read');\n this.name = 'ImportVirtualPatchRefusedError';\n }\n}\n\n/** Called once the incidents load for the current scope settles; `error` is set when it failed. */\ntype LoadWaiter = (error?: Error) => void;\n\nexport default class ReportsStore {\n private fullReportsMap: Map = new Map();\n private _loaded = false;\n // The last load failed and no load has started since: the store is not\n // loaded (get() retries), but it is settled, so a page gate waiting on\n // REPORTS_LOADED, which that failure already fired, renders now.\n private _failed = false;\n // Loads are numbered; clear() moves the number on too. Only the newest\n // load writes the map and the flags, fires REPORTS_LOADED and answers\n // callers: an older reply (another sub-tenant's, or one from before a\n // clear()) is dropped, and its callers wait for the newest load instead.\n private loadGen = 0;\n private loadWaiters: LoadWaiter[] = [];\n // The number of the load in flight for the current state, 0 when none:\n // a read while it runs joins it instead of sending the request again.\n private inFlightGen = 0;\n // Bumped by clear() (a scope change): a single-incident reply started\n // under an earlier scope must not write into this scope's map.\n private scopeEpoch = 0;\n private incidentUpdateCallbacks: Array<(updatedIncident: Incident) => void> = [];\n private incidentDeleteCallbacks: Array<(incidentId: string) => void> = [];\n private webSocketHandler: WebSocketHandler;\n private onImportStatus: ((status: ImportStatus) => void) | undefined;\n\n public setWebSocketHandler(wsh: WebSocketHandler) {\n this.webSocketHandler = wsh;\n }\n\n /**\n * Allows UI classes to register a callback that fires whenever an incident updates.\n */\n public onIncidentUpdated(cb: (updatedIncident: Incident) => void): void {\n this.incidentUpdateCallbacks.push(cb);\n }\n\n public onIncidentDeleted(cb: (incidentId: string) => void) {\n this.incidentDeleteCallbacks.push(cb);\n }\n\n public get isLoaded(): boolean { return this._loaded; }\n\n /** True once a load has finished, loaded or failed, with none started\n * since: what a gate waiting for REPORTS_LOADED reads. */\n public get isSettled(): boolean { return this._loaded || this._failed; }\n\n /**\n * Fetch one incident. Calls back with it, or with null and `failed`: false\n * when the server says there is no such incident (404), true when the\n * read itself failed (another status, a network error, a bad body).\n */\n private loadIncident(incidentId: string, callback: (incident: Incident | null, failed: boolean) => void): void {\n const tenantId = storage.get('selectedTenantId')\n const epoch = this.scopeEpoch;\n let notFound = false;\n apiFetch(`/api/tenants/${tenantId}/incidents/${incidentId}`)\n .then(response => {\n if (!response.ok) {\n notFound = response.status === 404;\n throw new Error('Failed to fetch incident details');\n }\n return response.json();\n })\n .then(data => {\n const inc = new Incident(data.incident);\n inc.sortNodesByUnauthorizedExecutables();\n // Still the caller's incident, but not this scope's list\n // once clear() has run since the request started.\n if (epoch === this.scopeEpoch) this.fullReportsMap.set(inc.id, inc);\n callback(inc, false);\n })\n .catch(error => {\n console.error('Failed to load incident:', error);\n callback(null, !notFound);\n });\n }\n\n /**\n * Waits for the incidents of the current state: joins the load already\n * in flight for it, or starts one. The waiter gets the error when the\n * read failed; the store then stays not-loaded, so the next get() retries\n * instead of serving an empty list as the tenant's incidents. A\n * superseded load's waiters are answered by the load that superseded it.\n */\n private load(waiter?: LoadWaiter): void {\n const tenantId = storage.get(\"selectedTenantId\");\n if (waiter) this.loadWaiters.push(waiter);\n if (this.inFlightGen !== 0 && this.inFlightGen === this.loadGen) return; // joins the load in flight\n const gen = ++this.loadGen;\n this.inFlightGen = gen;\n this._failed = false; // a request starts: not settled until it ends\n // The store's state is final before anyone hears of it: the waiters\n // are taken, the flags set, then REPORTS_LOADED fires, then those\n // waiters are answered. A listener's get() during the event so sees\n // a settled store (loaded, or failed with nothing in flight, so it\n // starts a retry) and is never answered with this load's result.\n const settle = (err?: Error) => {\n const waiters = this.loadWaiters;\n this.loadWaiters = [];\n this.inFlightGen = 0;\n if (err === undefined) {\n this._loaded = true;\n this._failed = false;\n } else {\n this._failed = true;\n }\n window.dispatchEvent(new Event(REPORTS_LOADED));\n for (const cb of waiters) {\n try { cb(err); } catch (cbErr) { console.error('[ReportsStore] a load callback threw:', cbErr); }\n }\n };\n\n // The server resolves the caller's subtenant entitlement either way\n // (ENG-3971); these params only narrow or widen within it.\n let url = `/api/tenants/${tenantId}/incidents`;\n if (storage.get('reportsSubtenantFilter')) {\n url += `?allSubtenants=true`;\n } else {\n const subtenantId = StoreManager.getInstance().subtenantStore.getSelectedId(tenantId);\n if (subtenantId) {\n url += `?subtenantID=${subtenantId}`;\n }\n }\n\n apiFetch(url)\n .then(response => {\n if (!response.ok) {\n throw new Error('Failed to fetch incident summaries');\n }\n return response.json();\n })\n .then(data => {\n if (gen !== this.loadGen) return; // superseded: the newest load answers\n this.fullReportsMap.clear();\n if (data.incidents) {\n data.incidents.forEach((incidentData: IIncident) => {\n const inc = new Incident(incidentData);\n inc.sortNodesByUnauthorizedExecutables();\n this.fullReportsMap.set(inc.id, inc);\n });\n }\n settle();\n })\n .catch(error => {\n if (gen !== this.loadGen) return; // superseded: not this view's failure\n console.error('Failed to load incidents:', error);\n // The store stays not-loaded (the next get() retries), but a\n // page waiting on REPORTS_LOADED still renders: its get()\n // then reports the failure. Without the event the Reports\n // page stayed on its blank fragment with no error.\n settle(error instanceof Error ? error : new Error(String(error ?? 'Failed to load incidents')));\n });\n }\n\n public handleWebSocketLiveIncident(updatedIncident: any) {\n const incoming = new Incident(updatedIncident);\n this.fullReportsMap.set(incoming.id, incoming);\n // Fire callbacks\n this.incidentUpdateCallbacks.forEach(cb => cb(incoming));\n }\n\n public handleWebSocketLiveIncidentDelete(incidentId: string) {\n this.incidentDeleteCallbacks.forEach(cb => cb(incidentId));\n }\n\n public deleteIncident(incidentId: string, callback: (stats: DeleteStats[] | null) => void) {\n const tenantId = storage.get('selectedTenantId')\n apiFetch(`/api/tenants/${tenantId}/incidents/${incidentId}/delete`, {\n method: 'DELETE',\n headers: {\n 'Content-Type': 'application/json',\n },\n })\n .then((response) => {\n if (!response.ok) {\n return response.text().then((text) => {\n throw new Error(`HTTP error! status: ${response.status}, message: ${text}`);\n });\n }\n return response.json();\n })\n .then((data) => {\n const stats: DeleteStats[] = data.incidentDelete;\n callback(stats);\n })\n .catch((error) => {\n console.error('Error deleting incident:', error);\n callback(null);\n });\n }\n\n public deleteIncidentAsync(incidentId: string): Promise {\n return new Promise((resolve) => {\n this.deleteIncident(incidentId, (stats) => {\n resolve(stats);\n });\n });\n }\n\n // Returns all reports currently loaded\n public getAllLoaded(): Incident[] {\n return [...this.fullReportsMap.values()];\n }\n\n /**\n * Calls back with the loaded incidents (null when there are none). When\n * the load fails, calls back with null and the error: `null` incidents\n * then mean \"unknown\", not \"none\", so the page can say the load failed.\n */\n public get(callback: (incidents: Incident[] | null, err?: Error) => void): void {\n if (this._loaded) {\n // If already loaded, return the current incident summaries (can be empty)\n const incidents = [...this.fullReportsMap.values()];\n callback(incidents.length > 0 ? incidents : null);\n } else {\n // Otherwise, load the incidents and then return them\n this.load((err) => {\n if (err) {\n // Unknown, not \"none\": nothing from the map.\n callback(null, err);\n return;\n }\n const incidents = [...this.fullReportsMap.values()];\n callback(incidents.length > 0 ? incidents : null);\n });\n }\n }\n\n /**\n * New async/await-friendly method:\n * - Returns a Promise that resolves to the (possibly empty) list of incidents.\n * - If already loaded, resolves immediately.\n * - Otherwise, waits until the load finishes; rejects when it fails.\n */\n public async getAsync(): Promise {\n if (this._loaded) {\n // Already loaded, so return them immediately\n return [...this.fullReportsMap.values()];\n }\n // Not loaded yet, so wrap `this.load(...)` in a Promise\n return new Promise((resolve, reject) => {\n this.load((error) => {\n if (error) {\n reject(error);\n return;\n }\n resolve([...this.fullReportsMap.values()]);\n });\n });\n }\n\n /**\n * Returns a Promise that resolves to the requested Incident, or null if not found.\n * If incidents have already been loaded, it returns immediately.\n * Otherwise, it waits for the load to complete.\n */\n public async getIncidentAsync(incidentId: string, forceLoad: boolean = false): Promise {\n\n if (!forceLoad && this.fullReportsMap.has(incidentId)) {\n return this.fullReportsMap.get(incidentId)!;\n }\n\n return (await this.lookupIncidentAsync(incidentId, forceLoad)).incident;\n }\n\n /**\n * As getIncidentAsync, and says whether a null is \"no such incident\"\n * (`failed` false) or a read that failed (`failed` true), so a caller\n * does not report a 500 or a 403 as \"not found\".\n */\n public async lookupIncidentAsync(incidentId: string, forceLoad: boolean = false): Promise<{ incident: Incident | null; failed: boolean }> {\n if (!forceLoad && this.fullReportsMap.has(incidentId)) {\n return { incident: this.fullReportsMap.get(incidentId)!, failed: false };\n }\n return new Promise((resolve) => {\n this.loadIncident(incidentId, (incident, failed) => resolve({ incident, failed }));\n });\n }\n\n public clear(): void {\n this.fullReportsMap.clear();\n this._loaded = false; // Reset the loaded flag to allow reloading\n this._failed = false;\n // A load in flight now is for the state being cleared: drop its reply.\n // Its callers, and a page waiting on REPORTS_LOADED because a load\n // was in flight, are answered by a load of the current state, started\n // here so they never wait on a read nobody makes (a clear() from a\n // page that is not showing reports reads nothing itself).\n const wasInFlight = this.inFlightGen !== 0;\n this.loadGen++;\n this.inFlightGen = 0;\n // A single-incident reply started under the cleared state must not\n // write into the new one's map.\n this.scopeEpoch++;\n if (this.loadWaiters.length || wasInFlight) this.load();\n }\n\n // same logic as found in node store\n public async importFromMP(body: string) {\n const tenantId = storage.get('selectedTenantId');\n const res = await apiFetch(`/api/tenants/${tenantId}/import-from-mp`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body,\n });\n\n if (!res.ok) {\n // The error body is not always JSON (a proxy 502, say): read text\n // and parse it ourselves so the caller still gets a message.\n const text = await res.text();\n let message = '';\n let parsed: { error?: unknown; code?: unknown; hosts?: unknown } | undefined;\n try { parsed = JSON.parse(text); message = typeof parsed?.error === 'string' ? parsed.error : ''; } catch { /* not JSON */ }\n if (res.status === 422 && (parsed?.code === 'v4_virtual_patch_unsupported' || parsed?.code === 'v4_virtual_patch_changed')) {\n const hosts = Array.isArray(parsed.hosts) ? parsed.hosts.filter((h: unknown): h is string => typeof h === 'string') : [];\n throw new ImportVirtualPatchRefusedError(hosts, parsed.code === 'v4_virtual_patch_changed');\n }\n throw new Error(message || text || res.statusText);\n }\n\n return res.json();\n }\n\n public importStatus(tenantId: string, operationId: string, onImportStatus: (status: ImportStatus) => void) {\n this.onImportStatus = onImportStatus\n this.webSocketHandler.connectWebSocket(tenantId, \"import-status\", operationId);\n }\n\n /** Stop following an import: close its status socket and drop the\n * callback, so a late message reaches no page. */\n public stopImportStatus(): void {\n this.onImportStatus = undefined;\n this.webSocketHandler?.disconnectWebSocket(\"import-status\", \"Import page left\");\n }\n\n public handleWebSocketImportStatus(status: ImportStatus) {\n this.onImportStatus?.(status);\n }\n}\n", "// static/ts/utils/DeepLink.ts\n\nimport ReportsStore from '../stores/ReportsStore';\nimport DriftReportStore from '../stores/DriftReportStore';\nimport { NavigationManager } from '../managers/NavigationManager';\nimport { Incident } from '../models/Incident';\nimport { AllPages, MAIN_PAGES, MARKETING_PAGES, KNOWLEDGE_PAGES, NODE_PAGES, ACL_SIMULATION_PAGES, onPage, DRIFT_REPORT_PAGES, CHANGE_REPORT_PAGES, SMB_REPORT_PAGES, TENANT_AD_PAGES, TENANT_SERVER_ACTIVITY_PAGES, isRetiredTrackerPage } from './Page';\nimport StoreManager from '../managers/StoreManager';\nimport { hasFlag, ReportFlags } from './ReportUtils';\nimport { storage } from './Storage';\nimport { apiFetch } from './Api';\nimport { setEvidenceContext } from './EvidenceBanner';\nimport { capturedAllSubtenants, capturedSubtenantScope } from './EvidenceScope';\nimport { resolveHomePage } from './HomePageSetting';\nimport { writeOverlapSeeds } from './OverlapSeeds';\nimport { carryParams } from './UrlParams';\nimport { t } from '../i18n/I18n';\n\n// The window a mimic.com evidence link's figures were read over, as the\n// server resolved it: the handlers take a whole number of days (or runs)\n// from 1 to 365 and answer anything else with their default. The page is\n// seeded with the same number the server used, so a link's totals match.\nfunction serverWindow(v: unknown, fallback: number): number {\n const n = Number(v);\n return Number.isInteger(n) && n >= 1 && n <= 365 ? n : fallback;\n}\n\nexport interface DeepLinkParams {\n id?: string;\n name?: string;\n page?: string;\n tenantId?: string;\n subtenantId?: string;\n nodeId?: string;\n nodeIds?: string;\n simulationId?: string;\n reportId?: string;\n view?: string;\n}\n\n/**\n * Retrieve the query parameters (?id=, ?name=, ?page=) from the current URL.\n */\nexport function getQueryParams(): DeepLinkParams {\n const params = new URLSearchParams(window.location.search);\n return {\n id: params.get('id') ?? undefined,\n name: params.get('name') ?? undefined,\n page: params.get('page') ?? undefined,\n tenantId: params.get('tenantId') ?? undefined,\n subtenantId: params.get('subtenantId') ?? undefined,\n nodeId: params.get('nodeId') ?? undefined,\n nodeIds: params.get('nodeIds') ?? undefined,\n simulationId: params.get('simulationId') ?? undefined,\n reportId: params.get('reportId') ?? undefined,\n view: params.get('view') ?? undefined,\n };\n}\n\n\n/**\n * Main function to handle deep-link logic.\n * - Reads ?id= or ?name= from the query string\n * - Looks up the corresponding Incident\n * - Updates localStorage & NavigationManager accordingly\n */\nexport async function handleDeepLink(\n reportsStore: ReportsStore,\n navigationManager: NavigationManager,\n defaultPage: string\n): Promise {\n let {\n id: queryIncidentId,\n name: queryIncidentName,\n page: queryPage,\n nodeId: queryNodeId,\n nodeIds: queryNodeIds,\n simulationId: querySimulationId,\n reportId: queryReportId,\n subtenantId: querySubtenantId,\n view: queryView,\n } = getQueryParams();\n\n console.debug('Query parameters:', queryIncidentId, queryPage);\n\n // Retired: the Progress Tracker page and the two epic trackers that had\n // merged into it. Normalize BEFORE any content/page resolution so old\n // ?page= bookmarks land on the home page (NavigationManager mirrors this\n // for stored currentPage values).\n if (isRetiredTrackerPage(queryPage)) {\n queryPage = resolveHomePage();\n const p = new URLSearchParams(location.search);\n p.set('page', queryPage);\n p.delete('tracker');\n history.replaceState(history.state, '', `${location.pathname}?${p}`);\n }\n\n // ?subtenantId= is applied by handleTenantIdOnLoad, which main() calls\n // before it starts the bootstrap node crawl \u2014 doing it here would be too\n // late for that crawl. The evidence-link branches below still set the scope\n // explicitly, because they derive it from a resolved token rather than the URL.\n\n // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Evidence deeplinks (?view=, ENG-2931): resolve the token to\n // the query an MCP tool captured, translate it into the Events page's\n // own URL params, and land there with the filter applied.\n // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n if (queryView) {\n const handled = await resolveEvidenceView(queryView, navigationManager);\n if (handled) return;\n console.warn('[DeepLink] evidence view token failed to resolve:', queryView);\n // fall through to default handling (home page) on a bad/expired token\n }\n\n // Special case to avoid removing params for reports page\n if (queryPage === 'reports') {\n console.debug('[DeepLink] Page is \"reports\". Skipping deep link handling.');\n storage.set('currentPage', 'reports');\n storage.set('sidebarState', 'main');\n navigationManager.updateSidebar(\"main\", true);\n return;\n }\n\n // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Drift report detail pages: keep existing params\n // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n if (queryPage && onPage(DRIFT_REPORT_PAGES as any, queryPage)) {\n console.debug('[DeepLink] Drift report detail page; preserving query params.');\n\n if (queryReportId) {\n storage.set('selectedDriftReportId', queryReportId);\n\n // Looked up across every sub-tenant the viewer may see: the link\n // carries no scope, and a root-only list misses a report on\n // sub-tenant nodes. null = not resolved (the lookup failed, or the\n // report is not in the list): the URL's own page key then stands,\n // since a guess would rewrite a diversity link to the drift renderer.\n let resolvedKind: 'drift' | 'diversity' | null = null;\n try {\n const tenantId = storage.get('selectedTenantId');\n if (tenantId) {\n const driftStore: DriftReportStore = StoreManager.getInstance().driftReportStore;\n const reports = await driftStore.getReportsAsync(tenantId, false, { allSubtenants: true });\n const r = (reports ?? []).find((x: any) => x?.id === queryReportId);\n if (r) resolvedKind = r.reportType === 'diversity' ? 'diversity' : 'drift';\n }\n } catch (e) {\n console.warn('[DeepLink] drift report kind lookup failed; keeping the page key:', e);\n }\n const reportKind = resolvedKind ?? (queryPage === 'diversity-report' ? 'diversity' : 'drift');\n storage.setScoped('selectedDriftReportKind', reportKind);\n // The two report kinds render different UIs; follow the resolved kind\n // rather than trusting the page key in the URL (evidence deeplinks\n // can't know the kind at mint time, and stale hand-written links\n // shouldn't open the wrong renderer either). Only a resolved kind\n // swaps the page.\n if (resolvedKind === 'diversity' && queryPage === 'drift-report-time-range') {\n queryPage = 'diversity-report';\n } else if (resolvedKind === 'drift' && queryPage === 'diversity-report') {\n queryPage = 'drift-report-time-range';\n }\n // The dispatcher reads ?page= from the URL, so the swap must land\n // there too \u2014 storage alone splits the router (diversity content\n // loads while the drift UI runs against it, rendering blank).\n const u = new URLSearchParams(window.location.search);\n if (u.get('page') !== queryPage) {\n u.set('page', queryPage);\n window.history.replaceState(null, '', `${window.location.pathname}?${u.toString()}`);\n }\n }\n\n storage.set('currentPage', queryPage as AllPages);\n storage.set('sidebarState', 'drift-reports');\n navigationManager.updateSidebar('drift-reports', /* force= */ true);\n\n // IMPORTANT: keep query params; do NOT call navigate() here\n return;\n }\n\n // Change report detail page: keep existing params\n if (queryPage && onPage(CHANGE_REPORT_PAGES as any, queryPage)) {\n if (queryReportId) storage.set('selectedChangeReportId', queryReportId);\n storage.set('currentPage', queryPage as AllPages);\n storage.set('sidebarState', 'change-reports');\n navigationManager.updateSidebar('change-reports', /* force= */ true);\n return;\n }\n\n // SMB report detail pages: keep existing params\n if (queryPage && onPage(SMB_REPORT_PAGES as any, queryPage)) {\n console.debug('[DeepLink] SMB report detail page; preserving query params.');\n storage.set('currentPage', queryPage as AllPages);\n storage.set('sidebarState', 'smb-reports');\n navigationManager.updateSidebar('smb-reports', /* force= */ true);\n return;\n }\n\n // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // ACL simulation subpages (?page=acl-simulation-... & ?simulationId=...)\n // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n if (queryPage && onPage(ACL_SIMULATION_PAGES as any, queryPage)) {\n // Require a simulationId; if missing, just land on ACL main\n if (querySimulationId) {\n storage.set('selectedAclSimulationId', querySimulationId);\n }\n storage.set('currentPage', (queryPage as AllPages));\n storage.set('sidebarState', 'acl-simulations');\n navigationManager.updateSidebar('acl-simulations', /*force=*/true);\n // Keep existing params (page + simulationId) in the URL, only normalize tenantId\n const params = new URLSearchParams();\n params.set('page', queryPage);\n if (querySimulationId) params.set('simulationId', querySimulationId);\n navigate({ params });\n return;\n }\n\n\n // localStorage.selectedTenantId always set by Main.ts \n const tenantId = storage.get(\"selectedTenantId\");\n\n // go to home page\n //\n // Bare root, no params \u2014 where an OAuth login and a plain bookmark both\n // arrive. This deliberately overrides the sticky `currentPage`, so it is the\n // one site the per-user home-page setting actually acts on. resolveHomePage\n // re-checks the stored choice against what this user can see, so a revoked\n // capability lands them on the default rather than on Forbidden.\n if (!queryIncidentId && !queryIncidentName && !queryPage && !queryNodeId && !queryNodeIds) {\n\n storage.set(\"selectedTenantId\", tenantId);\n storage.set(\"currentPage\", resolveHomePage());\n storage.set(\"sidebarState\", \"main\");\n storage.set(\"incidentType\", \"arena\");\n navigationManager.updateSidebar(\"main\", /* force= */ true);\n navigate();\n return;\n }\n\n // create report: open the page and keep the params. The page starts an\n // import only for the click that built this URL (takePendingImport), never\n // from link params alone, so a link opened here POSTs nothing.\n if (queryNodeIds && (queryPage === \"create-report\")) {\n storage.set('currentPage', queryPage);\n navigationManager.updateSidebar(\"main\", true);\n return;\n }\n\n // drift-reports with node ids: open the page. DriftReportsUI no longer\n // starts a report from link params (a link-triggered write), so they are\n // left in the URL unread.\n if (queryPage === \"drift-reports\" && queryNodeIds) {\n storage.set('currentPage', 'drift-reports');\n storage.set('sidebarState', 'drift-reports');\n navigationManager.updateSidebar('drift-reports', /* force= */ true);\n return;\n }\n\n // If we have a page param (like \"?page=admin\"), \n // skip incident logic, set localStorage, update sidebar, etc.\n console.debug('[DeepLink] Checking main page:', queryPage);\n if (onPage(MAIN_PAGES, queryPage)) {\n console.debug(`Deep link to main page: ${queryPage}`);\n // Marketing's and the Knowledge Base's pages take the main-page path for\n // its query handling, and their own sidebar view.\n const mainView = mainPageView(queryPage);\n storage.set('currentPage', queryPage as AllPages);\n storage.set('sidebarState', mainView);\n\n // Force the side panel to build the view's items:\n navigationManager.updateSidebar(mainView, /* force= */ true);\n\n // Preserve every query param the user landed with \u2014 `from`, `to`,\n // `nodes`, `ops`, etc. \u2014 so deep-links to filtered views (Events\n // page from the Directory Tree's \"Open in Events\" link, Logs with\n // a `filter`, anything else we might add) survive the initial\n // navigate() rewrite. Without this, only `page=` made it through\n // and the destination page would re-init at its defaults.\n // tenantId/subtenantId are handled by navigate() and intentionally\n // left out here so they don't double-encode.\n const params = new URLSearchParams(window.location.search);\n params.set('page', queryPage);\n params.delete('tenantId');\n params.delete('subtenantId');\n navigate({ params });\n\n return;\n }\n\n console.debug(\"[DeepLink] Checking nodeId:\", queryNodeId)\n if (queryNodeId) {\n storage.set(\"selectedNodeId\", queryNodeId);\n\n let page = queryPage;\n if (!onPage(NODE_PAGES, queryPage)) {\n storage.set(\"currentPage\", NODE_PAGES[0]);\n page = NODE_PAGES[0];\n } else {\n storage.set(\"currentPage\", queryPage as AllPages);\n }\n\n navigationManager.updateSidebar(\"node\", true);\n const params = new URLSearchParams({ nodeId: queryNodeId, page });\n // Carry per-page time-window params across the deep-link rewrite\n // so a refresh of e.g. Directory Tree / Live Events doesn't lose\n // the user's selected window. Without this, navigate() builds\n // the URL from `params` only and strips everything else.\n //\n // `toLatest` is the live-tail flag the per-node tree pages set when\n // the user ticks \"Latest\" \u2014 pairs with `from` and replaces `to`.\n // Forgetting to forward it here was the ENG-1754 reload bug: the\n // URL got rewritten without toLatest before NodeTreescanUI could\n // hydrate, so the checkbox always reverted to unchecked on reload.\n //\n // `configId`/`rev` are the Node Configs drill-in an evidence deeplink\n // lands on. They arrive in the URL this branch rewrites, so leaving them\n // out here means the drawer opens on the first hop and then vanishes on\n // refresh or when the URL is copied out of the address bar \u2014 the exact\n // ENG-1754 shape, one page over.\n //\n // `window`/`windowFrom`/`windowTo` are the Constellations page's window\n // (see NodeConstellationWindow) and the X-Ray page's \u2014 same\n // reason: dropped here, a shared link would open on whatever window the\n // viewer last used.\n //\n // `seed` is the X-Ray page's executable or folder, `path`\n // its \"who touches this folder or key\" mode, `overlap` its overlap of\n // two or three components, `lolbins` its open LOLBins section, `lolbin`\n // the binary whose drawer opens once that section loads, and\n // `candidates`/`candidatesQuery` its open, filtered executables picker.\n const currentParams = new URLSearchParams(window.location.search);\n // Every value of each key: overlap repeats, one value per component.\n carryParams(currentParams, params, ['from', 'to', 'toLatest', 'configId', 'rev', 'window', 'windowFrom', 'windowTo', 'seed', 'path', 'overlap', 'lolbins', 'lolbin', 'candidates', 'candidatesQuery']);\n navigate({ params });\n return;\n }\n\n // Wait until the incident store has data. A failed load continues with\n // none: an ?id= link still resolves through getIncidentAsync below, the\n // page-only branches still run, and the Reports page reports the failure\n // when it renders (its own get() retries the load). Rejecting here would\n // stop boot before any page loads. A link whose report is then missing is\n // reported as a failed load, not as \"not found\".\n let incidents: Incident[] = [];\n let listLoadError: Error | null = null;\n try {\n incidents = await reportsStore.getAsync();\n } catch (err) {\n console.error('[DeepLink] incidents load failed; continuing without them', err);\n listLoadError = err instanceof Error ? err : new Error(String(err));\n }\n\n // Handle conflicting params: ?id= AND ?name=\n console.debug('[DeepLink] Checking ID & name conflict:', queryIncidentId, queryIncidentName);\n if (queryIncidentId && queryIncidentName) {\n console.warn('Cannot specify both \"id\" and \"name\" in query params');\n // Set before navigate(): the Reports page reads it as it renders.\n storage.set(\"pendingReportMessage\", 'Cannot specify both \"id\" and \"name\".');\n storage.set(\"pendingReportMessageType\", \"error\");\n storage.set('currentPage', 'reports');\n navigationManager.updateSidebar(\"main\", true);\n navigate();\n return;\n }\n\n // If ?id= was provided\n console.debug('[DeepLink] Checking if we have ?id=', queryIncidentId);\n if (queryIncidentId) {\n let foundIncident = incidents.find(i => i.id === queryIncidentId);\n let lookupFailed = false;\n if (!foundIncident) {\n // The incidents list only returns detected reports; fetch by id so\n // deeplinks (incl. evidence links) to not-detected/imported incidents\n // resolve too. The lookup inserts the incident into the store, so the\n // page dispatch (which reads getAllLoaded()) finds its nodes.\n const lookup = await reportsStore.lookupIncidentAsync(queryIncidentId);\n foundIncident = lookup.incident ?? undefined;\n lookupFailed = lookup.failed;\n }\n if (foundIncident) {\n handleValidIncident(foundIncident, queryPage as AllPages, navigationManager);\n } else if (lookupFailed) {\n // Only a 404 says the report does not exist; a 500 or a 403 does not.\n handleIncidentLoadFailed(listLoadError?.message || t('body.unknownError'), navigationManager);\n } else {\n handleInvalidIncidentId(queryIncidentId, navigationManager);\n }\n return;\n }\n\n // If ?name= was provided\n console.debug('[DeepLink] Checking if we have ?name=', queryIncidentName);\n if (queryIncidentName) {\n const foundIncident = incidents.find(i => i.name === queryIncidentName);\n if (foundIncident) {\n handleValidIncident(foundIncident, queryPage as AllPages, navigationManager);\n } else if (listLoadError) {\n // The list did not load, so a name missing from it is not \"not found\".\n handleIncidentLoadFailed(listLoadError.message, navigationManager);\n } else {\n handleInvalidIncidentName(queryIncidentName, navigationManager);\n }\n return;\n }\n\n // If we have a \"page\" but NO id/name, treat it as a main-page deep link\n if (!queryIncidentId && !queryIncidentName && queryPage) {\n console.debug('[DeepLink] page-only deep link:', queryPage);\n // Tenant-scoped Active Directory pages aren't in MAIN_PAGES but are valid\n // page-only deep-link targets. Without this they'd fall through to \"nodes\"\n // and a direct load / refresh of e.g. ?page=ad-usn-change would\n // stick on the Nodes table. They render in the 'tenant-ad' sidebar view;\n // the ad-summary landing intentionally uses the main sidebar.\n if (onPage(TENANT_AD_PAGES, queryPage)) {\n storage.set('currentPage', queryPage as AllPages);\n navigationManager.updateSidebar(queryPage === 'ad-summary' ? 'main' : 'tenant-ad', true);\n return;\n }\n // Server Activity pages (Service/Session Changes) render in their own\n // 'server-activity' sidebar view \u2014 same treatment as the AD report pages.\n if (onPage(TENANT_SERVER_ACTIVITY_PAGES, queryPage)) {\n storage.set('currentPage', queryPage as AllPages);\n navigationManager.updateSidebar('server-activity', true);\n return;\n }\n if (onPage(MAIN_PAGES, queryPage)) {\n storage.set('currentPage', queryPage as AllPages);\n } else {\n storage.set('currentPage', resolveHomePage());\n }\n\n navigationManager.updateSidebar(mainPageView(queryPage), true);\n return;\n }\n}\n\n/** The sidebar view a main page opens in: its section's own, or the main one. */\nfunction mainPageView(page: string): 'marketing' | 'knowledge' | 'main' {\n if (onPage(MARKETING_PAGES, page)) return 'marketing';\n if (onPage(KNOWLEDGE_PAGES, page)) return 'knowledge';\n return 'main';\n}\n\n/**\n * Handle scenario where we find a valid incident from ID or name.\n */\nfunction handleValidIncident(\n foundIncident: Incident,\n queryPage: AllPages | undefined,\n navigationManager: NavigationManager\n): void {\n console.debug('[DeepLink] handleValidIncident for:', foundIncident.name);\n // Set localStorage as if the user manually selected this incident\n storage.set('selectedReportId', foundIncident.id);\n\n storage.set('selectedReportName', foundIncident.name);\n\n const page: AllPages = queryPage && queryPage.length > 0 ? queryPage : 'exec-summary';\n console.debug('Setting current page to:', page);\n storage.set('currentPage', page);\n\n navigationManager.updateSidebar(\"report\", /* force= */ true);\n}\n\n/**\n * Handle scenario where the incident ID from the query params doesn't match any real incident.\n */\nfunction handleInvalidIncidentId(\n queryIncidentId: string,\n navigationManager: NavigationManager\n): void {\n console.warn('Invalid Report ID from the query parameters:', queryIncidentId);\n // Set before navigate(): the Reports page reads it as it renders.\n storage.set(\"pendingReportMessage\", `Report ID \"${queryIncidentId}\" not found.`);\n storage.set(\"pendingReportMessageType\", \"error\");\n storage.set('currentPage', 'reports');\n console.warn('[DeepLink] Forcing fallback to /nodes because name is invalid');\n navigationManager.updateSidebar(\"main\", true);\n navigate();\n}\n\n/**\n * The report a link names could not be read (the list or the lookup\n * failed): land on Reports, whose own load says so, with the failure as the\n * pending message rather than \"not found\".\n */\nfunction handleIncidentLoadFailed(error: string, navigationManager: NavigationManager): void {\n storage.set(\"pendingReportMessage\", t('body.failedToLoad', { error }));\n storage.set(\"pendingReportMessageType\", \"error\");\n storage.set('currentPage', 'reports');\n navigationManager.updateSidebar(\"main\", true);\n navigate();\n}\n\n/**\n * Handle scenario where the incident name from the query params doesn't match any real incident.\n */\nfunction handleInvalidIncidentName(\n queryIncidentName: string,\n navigationManager: NavigationManager\n): void {\n console.warn('Invalid report name from the query parameters:', queryIncidentName);\n // Set before navigate(): the Reports page reads it as it renders.\n storage.set(\"pendingReportMessage\", `Report name \"${queryIncidentName}\" not found.`);\n storage.set(\"pendingReportMessageType\", \"error\");\n storage.set('currentPage', 'reports');\n navigationManager.updateSidebar(\"main\", true);\n navigate();\n}\n\n// Maps the server-side blob-filter op back to the BlobExpr grammar token\n// (the inverse of BlobExpr.ts's OPERATORS table).\nconst BLOB_OP_TOKEN: Record = {\n eq: '=', ne: '!=', contains: '~', ncontains: '!~', basename: '@=',\n gt: '>', lt: '<', gte: '>=', lte: '<=',\n};\n\n// Maps the MCP AD report_type enum to its tenant AD page key (the inverse of\n// mcpserver/tools_ad.go's adReportPath, targeting Page.ts TENANT_AD_PAGES).\nconst AD_REPORT_PAGE: Record = {\n 'event-catalog': 'ad-event-hourly',\n 'scheduled-task': 'ad-scheduled-task',\n 'kerberos-ticket': 'ad-kerberos-ticket',\n 'service-change': 'ad-service-change',\n 'session-change': 'ad-session-change',\n 'dcsync': 'ad-dc-sync',\n 'domain-controllers': 'ad-domain-controllers',\n 'ldap-change': 'ad-ldap-change',\n 'group-change': 'ad-group-change',\n 'usn-change': 'ad-usn-change',\n};\n\n// blobFiltersToExpr rebuilds a BlobExpr string from structured filters so a\n// resolved evidence view populates the Details filter box with the exact\n// predicate the MCP tool ran (round-trips through parseBlobExpr).\nfunction blobFiltersToExpr(filters: { path?: string; op?: string; value?: string }[]): string {\n const clauses: string[] = [];\n for (const f of filters ?? []) {\n const tok = BLOB_OP_TOKEN[f.op ?? ''];\n if (!tok || !f.path) continue;\n const v = String(f.value ?? '');\n const needsQuotes = /[\\s\"~=!<>]/.test(v) || v === '';\n const val = needsQuotes ? `\"${v.replace(/\\\\/g, '\\\\\\\\').replace(/\"/g, '\\\\\"')}\"` : v;\n clauses.push(`${f.path}${tok}${val}`);\n }\n return clauses.join(' ');\n}\n\n/**\n * Resolve an evidence-link token (ENG-2931) onto the page that renders the\n * captured query \u2014 Events, an AD report, a drift report, or an incident\n * process tree. The token references the exact query an MCP tool ran; the\n * resolve endpoint is tenant-scoped, so the viewer's own entitlement decides\n * access. Returns false when the token is unknown/foreign or the kind isn't\n * mapped yet \u2014 the caller falls back to default navigation.\n */\nasync function resolveEvidenceView(\n token: string,\n navigationManager: NavigationManager\n): Promise {\n const tenantId = storage.get('selectedTenantId');\n\n // Two resolvers exist, and which one holds a token cannot be known before\n // resolving it. Tenant-scoped tokens live behind the tenant route, where\n // RequireTenant plus a TenantID match decide access; fleet-global kinds (the\n // KEV catalog) carry no tenant at all and answer only on the global route.\n //\n // So try tenant-scoped first when a tenant is selected, then fall back to\n // global. The fallback is the load-bearing half: handleTenantIdOnLoad\n // backfills selectedTenantId from the user's profile, so a global ?view=\n // almost always arrives WITH a tenant selected, and the tenant-scoped lookup\n // 404s on it \u2014 an empty-tenant guard alone would never fire.\n //\n // No entitlement is widened by trying both: the global resolver refuses any\n // token carrying a tenant, so a tenant-scoped token cannot be read through it.\n let link: any;\n const resolveAt = async (path: string): Promise => {\n try {\n const resp = await apiFetch(path);\n if (!resp.ok) return null;\n return await resp.json();\n } catch {\n return null;\n }\n };\n\n if (tenantId) {\n link = await resolveAt(\n `/api/tenants/${encodeURIComponent(tenantId)}/evidence-links/${encodeURIComponent(token)}`\n );\n }\n if (!link) {\n link = await resolveAt(`/api/evidence-links/${encodeURIComponent(token)}`);\n }\n if (!link) return false;\n\n const q = link?.query ?? {};\n const params = new URLSearchParams();\n const evidence = (noun: string, forPage: string) => setEvidenceContext({\n token,\n resultCount: link.resultCount,\n createdAt: link.createdAt,\n createdBy: link.createdBy,\n noun,\n forPage,\n });\n\n switch (link?.kind) {\n case 'events_explorer': {\n params.set('page', 'events');\n if (q.from) params.set('from', q.from);\n if (q.to) params.set('to', q.to);\n if (Array.isArray(q.nodeIds) && q.nodeIds.length > 0) params.set('nodes', q.nodeIds.join(','));\n if (Array.isArray(q.operationNames) && q.operationNames.length > 0) params.set('ops', q.operationNames.join(','));\n const expr = blobFiltersToExpr(q.blobFilters ?? []);\n if (expr) params.set('blobExpr', expr);\n // Free-text search captured from search_events's `search` arg\n // (ENG-2947) \u2014 seeds the leaf table's global search box.\n if (q.search) params.set('q', String(q.search));\n // The captured sub-tenant scope: the tools ran it under the\n // subtenantId / allSubtenants they were given, and no scope is the\n // tenant's root scope, so a page left on another scope (or with the\n // all-sub-tenants toggle on) would count different events than the\n // banner's captured count. navigate() carries the selection into the\n // URL; EventsUI re-reads the toggle on render.\n const scope = capturedSubtenantScope(q);\n storage.set('eventsSubtenantFilter', scope.allSubtenants);\n StoreManager.getInstance().subtenantStore.setSelectedId(tenantId, scope.subtenantId);\n // Signal EventsUI to drill straight to the event list when the\n // captured query had no node pin (see pendingEvidenceDrill).\n try { sessionStorage.setItem('evidenceViewDrill', '1'); } catch { /* banner-only loss */ }\n break;\n }\n case 'events_by_ids': {\n params.set('page', 'events');\n if (q.nodeId) params.set('nodes', q.nodeId);\n if (Array.isArray(q.eventIDs) && q.eventIDs.length > 0) params.set('eventIds', q.eventIDs.join(','));\n // The node and ids pin the rows, but every Events query is also\n // scoped, and these links capture no scope: a page left on the root\n // or on another sub-tenant would scope a sub-tenant node's events\n // away. Open across every sub-tenant the viewer may see (the server\n // still decides entitlement), and drop a selection left from before.\n storage.set('eventsSubtenantFilter', true);\n StoreManager.getInstance().subtenantStore.setSelectedId(tenantId, null);\n try { sessionStorage.setItem('evidenceViewDrill', '1'); } catch { /* banner-only loss */ }\n break;\n }\n case 'ad_report': {\n const pageKey = AD_REPORT_PAGE[String(q.reportType ?? '')];\n if (!pageKey) return false;\n params.set('page', pageKey);\n // Scope + window live in the AD pages' sticky localStorage keys, not\n // the URL \u2014 seed them so the page renders the captured slice.\n if (q.nodeId) {\n params.set('adNodeId', String(q.nodeId));\n storage.set('tenantAdNodeFilter', String(q.nodeId));\n } else {\n storage.set('tenantAdNodeFilter', '');\n }\n // The captured table search: the report seeds its search box from it\n // (adNodeColumnStateLoad), so the rows match the linked count.\n if (typeof q.search === 'string' && q.search.trim()) params.set('adSearch', q.search);\n const fromMs = Number(q.windowFromMs ?? 0);\n const toMs = Number(q.windowToMs ?? 0);\n if (fromMs || toMs) {\n storage.set('tenantAdWindowPreset', 'custom');\n storage.set('tenantAdWindowFromMs', fromMs);\n storage.set('tenantAdWindowToMs', toMs);\n } else {\n storage.set('tenantAdWindowPreset', 'all');\n }\n storage.set('tenantAdSubtenantFilter', capturedAllSubtenants(q, q.allSubtenants));\n // One sub-tenant rides SubtenantStore, as on the network kinds; a\n // link with none clears a selection left from earlier, which the AD\n // pages would otherwise read with the toggle off.\n StoreManager.getInstance().subtenantStore.setSelectedId(\n tenantId, q.subtenantId ? String(q.subtenantId) : null);\n evidence('record', pageKey);\n storage.set('currentPage', pageKey as AllPages);\n navigationManager.updateSidebar('tenant-ad', /* force= */ true);\n navigate({ params });\n return true;\n }\n case 'drift_report': {\n if (!q.reportId) return false;\n params.set('page', 'drift-report-time-range');\n params.set('reportId', String(q.reportId));\n evidence('path', 'drift-report-time-range,diversity-report');\n // Full reload: the existing drift-report deep-link branch resolves the\n // report kind (drift vs diversity) and sidebar on a fresh load.\n navigate({ params, replaceWindow: true });\n return true;\n }\n case 'change_report': {\n if (!q.reportId) return false;\n params.set('page', 'change-report');\n params.set('reportId', String(q.reportId));\n // The filters and sort the tool read under: the page seeds its header\n // controls from them, so the rows are the ones counted.\n params.set('changeSeed', JSON.stringify(q));\n evidence('change', 'change-report');\n navigate({ params, replaceWindow: true });\n return true;\n }\n case 'process_tree_search': {\n if (!q.incidentId) return false;\n params.set('page', 'process-tree');\n params.set('id', String(q.incidentId));\n // Seed via sessionStorage, not URL params: the reload hand-off can\n // rebuild the URL from storage before the page reads it, dropping\n // params; the storage seed survives (consumed once by ProcessTreeUI).\n if (q.term) {\n try {\n sessionStorage.setItem('ptSearchSeed', JSON.stringify({\n field: String(q.field ?? 'exe'), term: String(q.term),\n limit: Number(q.limit) || undefined,\n // The node incident the search ran on (not always the report's\n // first) and the page of hits it returned.\n nodeIncidentId: q.nodeIncidentId ? String(q.nodeIncidentId) : undefined,\n offset: Number(q.offset) || undefined,\n }));\n } catch { /* seed lost = full tree, banner still honest */ }\n }\n evidence('process', 'process-tree');\n navigate({ params, replaceWindow: true });\n return true;\n }\n case 'process_tree':\n case 'directory_tree':\n case 'registry_tree':\n case 'node_artifacts':\n case 'ransomware_info':\n case 'incident':\n case 'incident_timeline': {\n // Incident-scoped kinds all ride the ?id= flow (full reload: it owns\n // report lookup, sidebar, and page routing). The report pages render\n // every node-incident in the report, so no node key needs seeding.\n if (!q.incidentId) return false;\n const pageFor: Record = {\n process_tree: 'process-tree',\n directory_tree: 'directory-tree',\n registry_tree: 'registry-tree',\n node_artifacts: 'artifacts',\n ransomware_info: 'ransomware-info',\n incident: 'exec-summary',\n incident_timeline: 'timeline',\n };\n const nounFor: Record = {\n process_tree: 'process',\n directory_tree: 'path',\n registry_tree: 'key',\n node_artifacts: 'artifact',\n ransomware_info: 'file',\n incident: 'node',\n incident_timeline: 'event',\n };\n params.set('page', pageFor[link.kind]);\n params.set('id', String(q.incidentId));\n evidence(nounFor[link.kind], pageFor[link.kind]);\n navigate({ params, replaceWindow: true });\n return true;\n }\n case 'kev_search': {\n // Fleet-global: CISA's catalog belongs to no tenant, so no tenant is\n // seeded and none is needed. The minted query fields map 1:1 onto the\n // KEV page's own URL params (mcpserver/tools_kev.go mints\n // {cve, q, class, ransomware, tenant_id, source, limit, subtenant_id,\n // all_subtenants}), so the page renders precisely the slice the tool\n // answered from. tenant_id is the one optional tenant: it selects the\n // app tenant so the page's sources[] cross-reference (ENG-4292)\n // matches what the tool saw.\n params.set('page', 'kev');\n if (q.cve) params.set('cve', String(q.cve));\n if (q.q) params.set('q', String(q.q));\n if (q.class) params.set('class', String(q.class));\n if (q.ransomware) params.set('ransomware', 'true');\n // The sub-tenant scope sources[] was taken from: one sub-tenant rides\n // SubtenantStore (KevUI sends it as subtenantID), every sub-tenant the\n // page's ?allSubtenants=1. navigate() writes the URL's tenant itself,\n // so the link's tenant is passed to it \u2014 when the viewer holds it;\n // otherwise the page stays on their own tenant.\n const kevTenant = q.tenant_id ? String(q.tenant_id) : '';\n const entitled = StoreManager.getInstance().userProfileStore.user?.tenants ?? [];\n const linkTenant = kevTenant && entitled.some(tn => tn.id === kevTenant) ? kevTenant : undefined;\n if (linkTenant) {\n StoreManager.getInstance().subtenantStore.setSelectedId(\n linkTenant, q.subtenant_id ? String(q.subtenant_id) : null);\n if (capturedAllSubtenants(q, q.all_subtenants)) params.set('allSubtenants', '1');\n // sources[] is the link tenant's cross-reference, so ?source= only\n // means what the tool answered when that tenant is the one shown.\n if (q.source) params.set('source', String(q.source));\n }\n evidence('CVE', 'kev');\n storage.set('currentPage', 'kev' as AllPages);\n if (linkTenant && linkTenant !== storage.get('selectedTenantId')) {\n // A different tenant: reload into it, as the tenant switcher does, so\n // the sidebar, brand and tenant name are that tenant's rather than\n // the one this page was opened on.\n storage.set('selectedTenantId', linkTenant);\n navigate({ params, tenantId: linkTenant, replaceWindow: true });\n return true;\n }\n navigationManager.updateSidebar('main', /* force= */ true);\n navigate({ params, tenantId: linkTenant });\n return true;\n }\n case 'cve_exposure': {\n // Tenant-scoped: the deeplink URL carries tenantId, which the resolver\n // context has already seeded. The tool mints {tenant_id, kev_only,\n // source, limit, offset, subtenant_id, all_subtenants}; kev_only maps onto the\n // page's own URL param, so the resolved view is the slice the tool\n // answered from \u2014 widenable by unchecking the toggle.\n params.set('page', 'missing-patches');\n if (q.kev_only) params.set('kevOnly', 'true');\n // The source filter (ENG-4292) rides the same way: the page reads\n // ?source=a,b and narrows every lane to those ids.\n if (typeof q.source === 'string' && q.source.trim()) params.set('source', q.source.trim());\n // Reproduce the captured sub-tenant scope: the page's Include All\n // Sub-Tenants toggle reads its storage key, and one sub-tenant rides\n // SubtenantStore, as on the network kinds.\n storage.set('missingPatchesSubtenantFilter', capturedAllSubtenants(q, q.all_subtenants));\n StoreManager.getInstance().subtenantStore.setSelectedId(\n tenantId, q.subtenant_id ? String(q.subtenant_id) : null);\n evidence('exposure row', 'missing-patches');\n storage.set('currentPage', 'missing-patches' as AllPages);\n navigationManager.updateSidebar('main', /* force= */ true);\n navigate({ params });\n return true;\n }\n case 'vuln_sources': {\n // Tenant-scoped (ENG-4345): an import_vendor_findings call lands on the\n // Admin page's Vulnerability Sources tab for the tenant it wrote to.\n // The tab reads the app's selected tenant, which the tenant-scoped\n // resolve above has already aligned with the link's tenant; the file\n // ledger there shows the import with route \"mcp\".\n params.set('page', 'admin');\n params.set('tab', 'vulnSources');\n evidence('imported file', 'admin');\n storage.set('currentPage', 'admin' as AllPages);\n storage.set('sidebarState', 'main');\n navigationManager.updateSidebar('main', /* force= */ true);\n navigate({ params });\n return true;\n }\n case 'cve_fleet_stats': {\n // Fleet-global like kev_search: resolution lands on the Missing\n // Patches page's All Tenants view \u2014 the fleet view, which renders the\n // same /api/cve-fleet-stats payload the tool answered from, and the\n // per-tenant table get_cve_fleet_tenants reads. Only a\n // get_cve_fleet_tenants link carries population:\"tenants\": for it,\n // fleetTenants replaces the table's default presets (production, the\n // reader's subscriptions) with the population the tool answered for \u2014\n // the tenant_ids it was given, or every tenant. A get_cve_fleet_stats\n // link carries no marker and keeps the page's own filters, since its\n // figures are the published production cohort, not a tenant table.\n params.set('page', 'missing-patches');\n params.set('allTenants', '1');\n if (q.population === 'tenants') {\n const ids = Array.isArray(q.tenant_ids) ? q.tenant_ids.map(String).filter(Boolean) : [];\n params.set('fleetTenants', ids.length > 0 ? ids.join(',') : 'all');\n }\n evidence('figure', 'missing-patches');\n storage.set('currentPage', 'missing-patches' as AllPages);\n navigationManager.updateSidebar('main', /* force= */ true);\n navigate({ params });\n return true;\n }\n case 'ai_visibility_traffic': {\n // Fleet-global like the audit kinds below: the subject is mimic.com.\n // Page views are a rolling window over the site's own ledger, not a\n // property of an audit run, so they seed no run and land on Site\n // Traffic, where that card moved (ENG-4695).\n params.set('page', 'site-traffic');\n // The window the figures were read over, so the page shows the totals\n // the link cites rather than its default 30 days.\n params.set('days', String(serverWindow(q.days, 30)));\n evidence('page view', 'site-traffic');\n storage.set('currentPage', 'site-traffic' as AllPages);\n navigationManager.updateSidebar('marketing', /* force= */ true);\n navigate({ params });\n return true;\n }\n case 'ai_visibility_summary':\n case 'ai_visibility_findings':\n case 'ai_visibility_run':\n case 'ai_visibility_crawlers': {\n // Fleet-global, and global for an unusual reason: the subject is\n // mimic.com \u2014 Mimic's OWN marketing site \u2014 so there is no tenant\n // anywhere in the data. All four land on the AI Visibility page; the\n // minted fields map onto that page's own controls, so the resolved\n // view is the slice the tool answered from.\n params.set('page', 'ai-visibility');\n // The three audit kinds capture the run they were read at \u2014 a summary's\n // headline numbers and a findings search's default scope both belong to\n // one run \u2014 so all three land on it. Without that a link minted last\n // week silently shows tonight's numbers under last week's claim. The\n // page validates the id against the runs it has and falls back to the\n // newest, so an id from a pruned run is harmless.\n // A crawler link seeds no run: crawler visits are a rolling window,\n // not a property of one audit run.\n if (q.run_id && link.kind !== 'ai_visibility_crawlers') {\n params.set('avRun', String(q.run_id));\n }\n // A crawler link carries its window instead; the page's window select\n // takes it, so the crawler card shows the totals the link cites.\n if (link.kind === 'ai_visibility_crawlers') {\n params.set('days', String(serverWindow(q.days, 30)));\n }\n // A summary link carries the trend window it was read over (limit, in\n // runs \u2014 the same number the page's window select sends as limit), so\n // the trend charts and tiles cover what the link cites.\n // A link minted with no (or an out-of-range) limit read the server's\n // default, 60 runs (api/ai_visibility.go trendLimit), not the page's 30.\n if (link.kind === 'ai_visibility_summary') {\n params.set('days', String(serverWindow(q.limit, 60)));\n }\n // A findings link deliberately seeds no filters. The page's findings\n // table is the latest run's findings \u2014 the same default scope the tool\n // answers from \u2014 and its filtering is client-side in the DataTable, so\n // there is no URL param to honour. Setting one the page ignored would\n // promise a narrowed view it does not render; the captured query is\n // still on the link itself for anyone resolving it.\n const noun = link.kind === 'ai_visibility_findings' ? 'finding'\n : link.kind === 'ai_visibility_run' ? 'audit run'\n : link.kind === 'ai_visibility_crawlers' ? 'crawler visit' : 'figure';\n evidence(noun, 'ai-visibility');\n storage.set('currentPage', 'ai-visibility' as AllPages);\n navigationManager.updateSidebar('marketing', /* force= */ true);\n navigate({ params });\n return true;\n }\n case 'logs_search': {\n // The Logs page reads repeatable ?filter= params (column:value\n // expressions; a bare token becomes the global search).\n params.set('page', 'logs');\n if (q.nodeId) params.append('filter', `node:${q.nodeId}`);\n if (Array.isArray(q.logTypes) && q.logTypes.length > 0) params.append('filter', `type:${q.logTypes.join(',')}`);\n if (q.search) params.append('filter', String(q.search));\n evidence('log', 'logs');\n storage.set('currentPage', 'logs');\n storage.set('sidebarState', 'main');\n navigationManager.updateSidebar('main', /* force= */ true);\n navigate({ params });\n return true;\n }\n case 'tenant_network':\n case 'node_network': {\n // The network page's window is sticky localStorage, not URL \u2014 seed it\n // to the captured look-back (days before capture time) so the page\n // reproduces the slice the tool measured.\n const capturedMs = link.createdAt ? Date.parse(link.createdAt) : Date.now();\n const days = Number(q.days) > 0 ? Number(q.days) : 7;\n storage.set('tenantNetWindowPreset', 'custom');\n storage.set('tenantNetWindowFromMs', capturedMs - days * 86400e3);\n storage.set('tenantNetWindowToMs', capturedMs);\n params.set('page', 'network-profiler');\n // Reproduce the captured subtenant scope: network-profiling nodes often\n // live in one subtenant, and no-scope means root-only server-side.\n storage.set('tenantNetSubtenantFilter', capturedAllSubtenants(q, q.allSubtenants));\n StoreManager.getInstance().subtenantStore.setSelectedId(\n tenantId, q.subtenantId ? String(q.subtenantId) : null);\n // netNodeId (NOT nodeId \u2014 that param is owned by the node-tree deeplink\n // branch) selects the single-node view; nodeIds narrows the fleet view.\n if (link.kind === 'node_network' && q.nodeId) params.set('netNodeId', String(q.nodeId));\n if (link.kind === 'tenant_network' && Array.isArray(q.nodeIds) && q.nodeIds.length > 0) {\n params.set('nodeIds', q.nodeIds.join(','));\n }\n // Captured row filter (ENG-2947): seeds the page's filter box so the\n // link lands narrowed to exactly the rows the tool counted.\n if (q.filter) params.set('ip', String(q.filter));\n // The banner noun matches the language of the card that backs the\n // count (a fleet egress link counts destinations, not \"flows\"), and\n // the banner's bold count becomes a click-to-reveal link to that\n // card \u2014 otherwise the reader has to guess which table to check.\n const view = String(q.view || '');\n const nounFor: Record = {\n egress: link.kind === 'tenant_network' ? 'destination' : 'flow',\n peers: 'peer flow',\n drift: 'new-peer flow',\n exposed_ports: 'exposed port',\n exposed_services: 'exposed port',\n lateral: 'lateral-movement row',\n };\n // The node egress card lists internet-bound flows only (it fetches\n // with external=1), so it backs the count only of a link minted with\n // external set; any other node egress count includes internal flows\n // and names no card.\n const cardsFor: Record = link.kind === 'node_network' ? {\n ...(q.external === true ? { egress: ['npNodeEgress'] } : {}), drift: ['npNodeDrift'],\n peers: ['npNodeOutPeers', 'npNodeInPeers'], exposed_services: ['npExposedServices'],\n } : {\n egress: ['npFleetEgress'], drift: ['npFleetDrift'],\n peers: ['npFleetOutPeers', 'npFleetInPeers'],\n exposed_ports: ['npExposedPorts'], lateral: ['npLateral'],\n };\n setEvidenceContext({\n token,\n resultCount: link.resultCount,\n createdAt: link.createdAt,\n createdBy: link.createdBy,\n noun: nounFor[view] || 'flow',\n forPage: 'network-profiler',\n targetIds: (cardsFor[view] || []).join(','),\n });\n storage.set('currentPage', 'network-profiler');\n storage.set('sidebarState', 'main');\n navigationManager.updateSidebar('main', /* force= */ true);\n navigate({ params });\n return true;\n }\n case 'node_config': {\n // Node Configs is a NODE-scoped page: it reads selectedNodeId from\n // storage and lives under the node sidebar, so this branch primes the\n // node the way handleDeepLink's own nodeId path does rather than\n // relying on params alone. configId/rev are the drill-in the page\n // added for these links \u2014 they open that revision's drawer once the\n // stores resolve. Without a nodeId there is no page to land on.\n if (!q.nodeId) return false;\n const nodeId = String(q.nodeId);\n storage.set('selectedNodeId', nodeId);\n params.set('page', 'node-configs');\n params.set('nodeId', nodeId);\n if (q.configId) params.set('configId', String(q.configId));\n if (Number(q.revision) > 0) params.set('rev', String(Number(q.revision)));\n evidence('applied configuration', 'node-configs');\n storage.set('currentPage', 'node-configs' as AllPages);\n storage.set('sidebarState', 'node');\n navigationManager.updateSidebar('node', /* force= */ true);\n navigate({ params });\n return true;\n }\n case 'component_profile': {\n // The X-Ray page filtered to the captured node, on its\n // window and mode: the page reads node/seed/path/overlap/lolbins/lolbin/candidates and the window from\n // the URL (WindowFilter's custom range), so the link lands on exactly\n // what get_component_profile read.\n if (!q.nodeId) return false;\n const nodeId = String(q.nodeId);\n params.set('page', 'component-profile');\n params.set('node', nodeId);\n if (Number(q.windowFromMs) > 0 && Number(q.windowToMs) > 0) {\n params.set('window', 'custom');\n params.set('windowFrom', String(Number(q.windowFromMs)));\n params.set('windowTo', String(Number(q.windowToMs)));\n }\n const view = String(q.view || '');\n if (view === 'profile' && q.seed) params.set('seed', String(q.seed));\n if (view === 'path_actors' && q.path) params.set('path', String(q.path));\n if (view === 'overlap' && Array.isArray(q.seeds)) writeOverlapSeeds(params, q.seeds);\n if (view === 'lolbins') params.set('lolbins', '1');\n // One binary: its section open and its drawer on it.\n if (view === 'lolbin' && q.basename) {\n params.set('lolbins', '1');\n params.set('lolbin', String(q.basename));\n }\n // The picker opens on the tool's filter, which the page applies to its\n // candidates request before the cap, as the tool's read did.\n if (view === 'candidates') {\n params.set('candidates', '1');\n if (q.query) params.set('candidatesQuery', String(q.query));\n }\n const nounFor: Record = {\n candidates: 'executable', profile: 'process', path_actors: 'executable',\n // overlap's count sums every region (single-component ones and\n // network flows too), so it is not a count of shared paths.\n overlap: 'record', lolbins: 'LOLBin', lolbin: 'process',\n };\n evidence(nounFor[view] || 'record', 'component-profile');\n storage.set('currentPage', 'component-profile' as AllPages);\n storage.set('sidebarState', 'main');\n navigationManager.updateSidebar('main', /* force= */ true);\n navigate({ params });\n return true;\n }\n case 'node_health': {\n // node-health reads range/from/to/tenantID natively from the URL.\n params.set('page', 'node-health');\n if (q.from) params.set('from', String(q.from));\n if (q.to) params.set('to', String(q.to));\n params.set('tenantID', String(q.tenantId ?? ''));\n // Every node_health link counted all the sub-tenants its creator saw\n // (the tool reads all of them; a link minted before sub-tenant scope,\n // the whole tenant): open the page in that scope, never the viewer's\n // sidebar sub-tenant or root. The server holds a viewer to the same\n // (evidenceScopeQueryFor).\n params.set('allSubtenants', 'true');\n evidence('node', 'node-health');\n storage.set('currentPage', 'node-health');\n storage.set('sidebarState', 'main');\n navigationManager.updateSidebar('main', /* force= */ true);\n navigate({ params });\n return true;\n }\n case 'top_consumers': {\n // The Node Health page's consumer cards render this exact ranking \u2014\n // same rows, same statistic, same order as the tool's endpoint\n // (HandleNodeHealthConsumers ranks the dashboard's own rows). Land\n // there with the captured window and point the banner's count at the\n // captured metric's card. The tool ranks all sub-tenants (a link from\n // before sub-tenant scope, the whole tenant), so the page opens in All\n // Sub-Tenants rather than the viewer's sidebar sub-tenant or root.\n params.set('page', 'node-health');\n if (q.from) params.set('from', String(q.from));\n if (q.to) params.set('to', String(q.to));\n // The captured query carries no tenant (the token itself is\n // tenant-bound); scope the page to the tenant the link resolved under.\n params.set('tenantID', String(tenantId ?? ''));\n params.set('allSubtenants', 'true');\n // Accept the same metric spellings the API does (nhParseMetric);\n // no entry for broker \u2014 its card was retired, banner stays unlinked.\n const cardFor: Record = {\n '': 'nh-top-cpu-table', cpu: 'nh-top-cpu-table',\n procCpu: 'nh-top-proccpu-table', proc_cpu: 'nh-top-proccpu-table', processCpu: 'nh-top-proccpu-table',\n memory: 'nh-top-mem-table', mem: 'nh-top-mem-table',\n };\n setEvidenceContext({\n token,\n resultCount: link.resultCount,\n createdAt: link.createdAt,\n createdBy: link.createdBy,\n noun: 'node',\n forPage: 'node-health',\n targetIds: cardFor[String(q.metric ?? '')] ?? '',\n });\n storage.set('currentPage', 'node-health');\n storage.set('sidebarState', 'main');\n navigationManager.updateSidebar('main', /* force= */ true);\n navigate({ params });\n return true;\n }\n case 'notifications_search': {\n // The window (?notifStart/?notifEnd), the statuses (?notifStatuses)\n // and the search text (?notifSearch) are URL-seedable. The closure\n // filters are not (the table has no column to preselect), so the\n // banner says so when the link carries any.\n params.set('page', 'notifications');\n // Resolved ms bounds (new links); RFC3339 `from` is the legacy shape.\n if (Number(q.fromMs) > 0) {\n params.set('notifStart', String(Number(q.fromMs)));\n if (Number(q.toMs) > Number(q.fromMs)) params.set('notifEnd', String(Number(q.toMs)));\n } else if (q.from) {\n const t = new Date(String(q.from));\n if (!isNaN(t.getTime())) params.set('notifStart', String(t.getTime()));\n }\n // Carry the captured statuses: the page defaults to open+triage,\n // which would silently re-filter a query that searched all statuses\n // and make the banner count look wrong. Empty statuses on the tool\n // side means \"all\".\n const statuses = Array.isArray(q.statuses) && q.statuses.length > 0\n ? q.statuses : ['open', 'triage', 'closed'];\n params.set('notifStatuses', statuses.join(','));\n if (q.search) params.set('notifSearch', String(q.search));\n const hasList = (v: unknown) => Array.isArray(v) && v.length > 0;\n setEvidenceContext({\n token,\n resultCount: link.resultCount,\n createdAt: link.createdAt,\n createdBy: link.createdBy,\n noun: 'notification',\n forPage: 'notifications',\n closureFiltersNotReproduced:\n hasList(q.closureReasons) || hasList(q.closureSources) || hasList(q.closedByRuleIds),\n });\n storage.set('currentPage', 'notifications');\n storage.set('sidebarState', 'main');\n navigationManager.updateSidebar('main', /* force= */ true);\n navigate({ params });\n return true;\n }\n default: {\n // A kind this UI version has no page for (server newer than frontend,\n // or a future tool family): don't dead-end the link. Land on the home\n // page with the fallback banner carrying the capture metadata and the\n // raw captured query, so the evidence is still inspectable.\n if (!link?.kind) return false;\n setEvidenceContext({\n token,\n resultCount: link.resultCount,\n createdAt: link.createdAt,\n createdBy: link.createdBy,\n noun: 'record',\n forPage: 'nodes',\n unsupportedKind: String(link.kind),\n queryJson: JSON.stringify(q),\n });\n params.set('page', 'nodes');\n storage.set('currentPage', 'nodes');\n storage.set('sidebarState', 'main');\n navigationManager.updateSidebar('main', /* force= */ true);\n navigate({ params });\n return true;\n }\n }\n\n // Events kinds land here: banner context + main-sidebar navigation.\n evidence('event', 'events');\n storage.set('currentPage', 'events');\n storage.set('sidebarState', 'main');\n navigationManager.updateSidebar('main', /* force= */ true);\n navigate({ params });\n return true;\n}\n\nexport function handleTenantIdOnLoad(url: URL): string {\n /* ------------------------------------------------------------------\n Priority for picking a tenant on first load:\n 1. ?tenantId= in the URL\n 2. previously-chosen tenant stored in localStorage\n 3. user\u2019s default tenant (profile.tenantID)\n -------------------------------------------------------------------*/\n let selectedTenantId = url.searchParams.get(\"tenantId\");\n\n if (!selectedTenantId || selectedTenantId === \"null\") {\n const storedTenantId = storage.get(\"selectedTenantId\");\n if (storedTenantId) {\n selectedTenantId = storedTenantId;\n }\n }\n\n var userProfileStore = StoreManager.getInstance().userProfileStore;\n if (userProfileStore.user) {\n // if they have no presets yet, bring them to their default tenantId\n if (!selectedTenantId || selectedTenantId === \"null\") {\n selectedTenantId = userProfileStore.user.tenantID;\n }\n\n // check if user has access to tenantId, else default to user's tenantId\n const validTenant = userProfileStore.user.tenants?.some(tenant => tenant.id === selectedTenantId);\n if (!validTenant) {\n // The deep-linked / stored tenantId isn't one this user belongs to\n // (e.g. a stale bookmark or a cross-env link). Fall back to their home\n // tenant. Only dead-end on the Forbidden page when there's genuinely no\n // accessible tenant; otherwise drop the user on their own tenant's\n // default page rather than a Forbidden wall they can't act on.\n const hasAccessibleTenant = (userProfileStore.user.tenants ?? []).length > 0;\n selectedTenantId = userProfileStore.user.tenantID;\n if (hasAccessibleTenant) {\n navigate({ tenantId: selectedTenantId });\n } else {\n navigate({ params: new URLSearchParams({ page: \"forbidden\" }), tenantId: selectedTenantId });\n }\n }\n\n // set default for later use in fallbacks\n storage.set(\"defaultTenantId\", userProfileStore.user.tenantID);\n }\n\n storage.set('selectedTenantId', selectedTenantId);\n\n // Reconcile the active subtenant against the URL *here*, not in\n // handleDeepLink: SubtenantStore is the source of truth for the subtenant\n // scope, and NodeStore.fetchPage turns it into `&subtenantID=` on every\n // request. Main() kicks off nodeStore.loadAll() before it awaits\n // handleDeepLink, so reconciling there left the bootstrap node crawl \u2014\n // whose result every page then reuses \u2014 scoped to the *previous* visit's\n // subtenant. No ?subtenantId= means tenant-wide, which also retires any\n // stale pin (see the getScoped/removeScoped note in Storage.ts).\n StoreManager.getInstance().subtenantStore.setSelectedId(\n selectedTenantId,\n url.searchParams.get('subtenantId'),\n );\n\n return selectedTenantId;\n}\n\n/** Ensures the param of tenantId is always in the url when navigating */\nexport function navigate(options?: {\n data?: Record;\n params?: URLSearchParams;\n tenantId?: string; // override current tenantId\n pushState?: boolean;\n replaceWindow?: boolean; // causes full reload\n}) {\n const params = options?.params ?? new URLSearchParams();\n\n // get the tenantId, fallback to profile's default tenantId\n let tenantId: string;\n if (options?.tenantId) {\n tenantId = options.tenantId;\n } else {\n const { tenantId: queryTenantId } = getQueryParams();\n const defaultTenantId = storage.get(\"defaultTenantId\");\n if (!queryTenantId || queryTenantId === \"null\") {\n // Prefer the last chosen tenant (if any) over the profile default\n tenantId = storage.get(\"selectedTenantId\") || defaultTenantId;\n } else {\n tenantId = queryTenantId;\n }\n }\n\n storage.set(\"selectedTenantId\", tenantId);\n\n // Preserve ?subtenantId across navigate() rewrites. SubtenantStore is\n // the canonical store, but keeping it in the URL means refresh +\n // copy-pasted links land on the correct subtenant scope without\n // needing localStorage to already be primed.\n let subtenantQuery = '';\n const activeSubtenant = StoreManager.getInstance().subtenantStore.getSelectedId(tenantId);\n if (activeSubtenant) {\n subtenantQuery = `&subtenantId=${encodeURIComponent(activeSubtenant)}`;\n }\n\n // re-order params so that tenantId is always first\n params.delete(\"tenantId\");\n params.delete(\"subtenantId\");\n const data = options?.data ?? {};\n const tenantQuery = `tenantId=${encodeURIComponent(tenantId)}`;\n const query = params.toString();\n const url = `?${tenantQuery}${subtenantQuery}${query ? \"&\" : \"\"}${query}`; // this is a single page app, so the path is always /\n\n if (options?.replaceWindow) {\n console.debug(\"[NAVIGATE] Full Reload\");\n window.location.replace(url);\n } else {\n // A push to the URL we are already on would create a duplicate history\n // entry, so Back appears to do nothing (the user presses it and lands on\n // the same page). Replace instead \u2014 the entry's state still updates.\n if (options?.pushState && url !== location.search) {\n history.pushState(data, \"\", url);\n } else {\n history.replaceState(data, \"\", url);\n }\n }\n}\n", "// static/ts/utils/EvidenceBanner.ts\n// Provenance banner for evidence deeplinks (ENG-2931).\n//\n// When DeepLink resolves a ?view= link, it stashes the captured\n// query's metadata (matched count + capture time) in sessionStorage.\n// EventHandlers.loadContent calls mountEvidenceBannerIfPresent() right\n// after it injects the destination page's HTML, so every page an evidence\n// link can land on (Events, the AD reports, drift detail, process tree)\n// shows the same \"captured N then / live data now\" integrity banner with\n// no per-page wiring. Consume-once: the sessionStorage item is removed at\n// mount, so the banner shows only on the page the link resolved to \u2014 the\n// element itself dies with the page when #content is next cleared.\n\nimport { t as t2 } from \"../i18n/I18n\";\nimport { formatNumber } from \"./FormatUtils\";\n\nconst INFO_KEY = \"evidenceViewInfo\";\nconst BANNER_ID = \"evidence-banner\";\n\nexport interface EvidenceViewInfo {\n token?: string;\n resultCount?: number;\n createdAt?: string;\n createdBy?: string;\n // What one matched record is called on the destination page (\"event\",\n // \"record\", \"path\", \"process\") \u2014 set by DeepLink per link kind.\n noun?: string;\n // The page= key the resolver navigated to. Full-reload resolutions\n // (incident/drift kinds) pass through interim content loads before the\n // destination page renders; the banner mounts only on the matching page\n // so an interim load can't consume it (found by the deployed-qa link\n // walk: all six incident-page links lost their banner).\n forPage?: string;\n // Stamp for staleness: a context whose page never loads (mis-mapped\n // kind, user navigated away mid-resolve) must not pop up minutes later.\n storedAt?: number;\n // Fallback fields for a link kind this UI version has no page for:\n // the banner then carries the capture metadata plus the raw captured\n // query so the link still shows its evidence instead of dead-ending.\n unsupportedKind?: string;\n queryJson?: string;\n // Comma-separated DOM ids of the table(s) that back the captured count\n // (set by the resolver when it knows, e.g. a network link's view). The\n // bold count then renders as a link: clicking it centers the first\n // backing card and pulses them all \u2014 click-time, so page reflow during\n // slow loads can't undo it the way an automatic scroll would.\n targetIds?: string;\n // The captured query had filters the destination page cannot reproduce\n // (a notifications link's closure filters): the banner says the page\n // may list more than the captured count.\n closureFiltersNotReproduced?: boolean;\n}\n\nexport function setEvidenceContext(info: EvidenceViewInfo): void {\n try {\n sessionStorage.setItem(INFO_KEY, JSON.stringify({ ...info, storedAt: Date.now() }));\n } catch { /* private-mode storage failure only costs the banner */ }\n}\n\nexport function clearEvidenceContext(): void {\n sessionStorage.removeItem(INFO_KEY);\n document.getElementById(BANNER_ID)?.remove();\n}\n\n// mountEvidenceBannerIfPresent renders the banner at the top of the loaded\n// page content and consumes the stored context. No-op when no evidence\n// link was just resolved.\nexport function mountEvidenceBannerIfPresent(host?: HTMLElement | null, page?: string): void {\n const raw = sessionStorage.getItem(INFO_KEY);\n if (!raw) return;\n\n let info: EvidenceViewInfo;\n try {\n info = JSON.parse(raw);\n } catch {\n sessionStorage.removeItem(INFO_KEY);\n return;\n }\n // Stale context: discard rather than surprising some later page.\n if (info.storedAt && Date.now() - info.storedAt > 5 * 60 * 1000) {\n sessionStorage.removeItem(INFO_KEY);\n return;\n }\n // Not our destination yet (interim load during a full-reload\n // resolution): leave the context for the page it belongs to. forPage\n // may list alternatives (comma-separated) when the resolver can't know\n // which of two sibling pages will render (drift vs diversity report).\n if (info.forPage && page && !info.forPage.split(',').includes(page)) return;\n sessionStorage.removeItem(INFO_KEY);\n const target = host ?? document.getElementById(\"content\");\n if (!target || document.getElementById(BANNER_ID)) return;\n ensureStyles();\n\n const noun = info.noun || t2('body.recordLower');\n const captured = info.createdAt ? new Date(info.createdAt).toLocaleString() : t2('body.anUnknownTime');\n const banner = document.createElement(\"div\");\n banner.id = BANNER_ID;\n banner.className = \"evidence-banner\";\n\n // The captured count is THE number the reader compares against the page,\n // so it renders bold \u2014 easy to spot in the sentence and match to the UI.\n // When the resolver named the backing table(s), the count is clickable\n // and reveals them (centered + pulsed).\n const countStrong = (count: number): HTMLElement => {\n const b = document.createElement(\"strong\");\n b.textContent = t2('body.countNounPlural', { n: formatNumber(count), noun });\n const ids = (info.targetIds || \"\").split(\",\").map(x => x.trim()).filter(Boolean);\n if (ids.length) {\n b.style.cursor = \"pointer\";\n b.style.textDecoration = \"underline\";\n b.title = t2('body.showTheTableThisCountCameFrom');\n b.addEventListener(\"click\", () => {\n const cards = ids\n .map(id => document.getElementById(id)?.closest(\".card\") as HTMLElement | null)\n .filter((el): el is HTMLElement => !!el);\n if (!cards.length) return;\n cards[0].scrollIntoView({ behavior: \"smooth\", block: \"center\" });\n for (const card of cards) {\n const prev = card.style.boxShadow;\n card.style.transition = \"box-shadow .4s\";\n card.style.boxShadow = \"0 0 0 3px rgba(47,127,209,.75)\";\n setTimeout(() => { card.style.boxShadow = prev; }, 2600);\n }\n });\n }\n return b;\n };\n\n const text = document.createElement(\"span\");\n if (info.unsupportedKind) {\n text.append(\n t2('body.evidenceLinkNoPageForKind', { kind: info.unsupportedKind, captured }));\n if (typeof info.resultCount === \"number\" && info.resultCount >= 0) {\n text.append(t2('body.andMatchedInfix'), countStrong(info.resultCount), t2('body.atCaptureSuffix'));\n } else {\n text.append(`.`);\n }\n if (info.queryJson) {\n const details = document.createElement(\"details\");\n const summary = document.createElement(\"summary\");\n summary.textContent = t2('body.showCapturedQuery');\n const pre = document.createElement(\"pre\");\n pre.className = \"evidence-banner-query\";\n try {\n pre.textContent = JSON.stringify(JSON.parse(info.queryJson), null, 2);\n } catch {\n pre.textContent = info.queryJson;\n }\n details.append(summary, pre);\n text.appendChild(details);\n }\n } else if (typeof info.resultCount === \"number\" && info.resultCount >= 0) {\n text.append(\n t2('body.evidenceLinkCapturedMatched', { captured }),\n countStrong(info.resultCount),\n t2('body.evidenceCountsDifferSuffix'),\n );\n } else {\n text.textContent = t2('body.evidenceLinkCurrentData', { captured });\n }\n if (info.closureFiltersNotReproduced) {\n text.append(` ${t2('body.evidenceClosureFiltersNotReproduced')}`);\n }\n\n const dismiss = document.createElement(\"button\");\n dismiss.type = \"button\";\n dismiss.className = \"evidence-banner-dismiss\";\n dismiss.setAttribute(\"aria-label\", t2('body.dismissEvidenceBanner'));\n dismiss.textContent = \"\u2715\";\n dismiss.onclick = () => banner.remove();\n\n banner.append(text, dismiss);\n target.prepend(banner);\n}\n\nfunction ensureStyles(): void {\n if (document.getElementById(\"evidence-banner-styles\")) return;\n const style = document.createElement(\"style\");\n style.id = \"evidence-banner-styles\";\n style.textContent = `\n .evidence-banner {\n display: flex;\n align-items: flex-start;\n gap: 10px;\n margin: 0 0 12px 0;\n padding: 8px 12px;\n border: 1px solid #b8d4f0;\n border-left: 4px solid #2f7fd1;\n border-radius: 6px;\n background: #eef6fd;\n color: #1d4f7c;\n font-size: 0.85rem;\n line-height: 1.35;\n }\n .evidence-banner strong {\n font-weight: 700;\n white-space: nowrap;\n }\n .evidence-banner-dismiss {\n margin-left: auto;\n border: none;\n background: transparent;\n color: inherit;\n cursor: pointer;\n font-size: 0.9rem;\n line-height: 1;\n padding: 2px 4px;\n }\n .evidence-banner details {\n margin-top: 6px;\n }\n .evidence-banner summary {\n cursor: pointer;\n }\n .evidence-banner-query {\n margin: 6px 0 0 0;\n padding: 8px;\n max-height: 240px;\n overflow: auto;\n background: rgba(255, 255, 255, 0.6);\n border-radius: 4px;\n font-size: 0.78rem;\n }\n `;\n document.head.appendChild(style);\n}\n", "// static/ts/utils/EvidenceScope.ts\n//\n// The sub-tenant scope an evidence link's captured query ran under, read\n// back for the page that reproduces it (DeepLink.resolveEvidenceView). Pure,\n// so Node can test it without a DOM (scripts/evidence-scope.test.mjs).\n\nexport interface CapturedSubtenantScope {\n /** The page's \"Include all sub-tenants\" toggle. */\n allSubtenants: boolean;\n /** The one sub-tenant to select; null is the tenant's root scope. */\n subtenantId: string | null;\n}\n\n/**\n * The scope in a captured query's `subtenantId` / `allSubtenants`, with the\n * MCP tools' precedence: all sub-tenants wins over a named one, as\n * subtenantScopeSuffix sends it. A query with neither ran in the tenant's\n * root scope, which is what the page shows with no sub-tenant selected and\n * the toggle off \u2014 so an older link without the fields still matches.\n */\nexport function capturedSubtenantScope(q: unknown): CapturedSubtenantScope {\n const rec = (q && typeof q === 'object') ? q as Record : {};\n if (rec.allSubtenants === true) return { allSubtenants: true, subtenantId: null };\n // An unscoped query whose creator saw no root node ran over their own\n // sub-tenants (the server stamps capturedRoot false at mint): the page\n // shows those under all sub-tenants, as far as the viewer may see them.\n if (rec.capturedRoot === false) return { allSubtenants: true, subtenantId: null };\n const id = typeof rec.subtenantId === 'string' ? rec.subtenantId.trim() : '';\n return { allSubtenants: false, subtenantId: id || null };\n}\n\n/**\n * Whether a captured query's page should show all sub-tenants: the query's\n * own all-sub-tenants flag (whatever its spelling), or the server's\n * capturedRoot false stamp, which marks an unscoped count that ran over its\n * creator's own sub-tenants (DASH-1902).\n */\nexport function capturedAllSubtenants(q: unknown, all: unknown): boolean {\n const rec = (q && typeof q === 'object') ? q as Record : {};\n return all === true || rec.capturedRoot === false;\n}\n", "// The X-Ray's ?overlap= components, shared by the page and the\n// evidence-link resolver so a link opens the comparison the tool read.\n// Each component is its own repeated `overlap` value, verbatim: a POSIX\n// path may contain \"|\". A single value is an older link, whose components\n// were joined by \"|\" (no Windows path, file name or sha256 contains it).\n\nexport const URL_OVERLAP = \"overlap\";\nconst LEGACY_SEP = \"|\";\n\n/** Replace params' overlap components with `seeds`, one value each. */\nexport function writeOverlapSeeds(params: URLSearchParams, seeds: readonly unknown[]): void {\n params.delete(URL_OVERLAP);\n for (const s of seeds) params.append(URL_OVERLAP, String(s));\n}\n\n/** The overlap components params carries, trimmed, blanks dropped. */\nexport function readOverlapSeeds(params: URLSearchParams): string[] {\n const vals = params.getAll(URL_OVERLAP);\n const seeds = vals.length > 1 ? vals : (vals[0] ?? \"\").split(LEGACY_SEP);\n return seeds.map(x => x.trim()).filter(Boolean);\n}\n", "// Copy query parameters from one URL to another with every value kept: a\n// key may repeat (the X-Ray's ?overlap=, one value per\n// component), and URLSearchParams.get() would keep only the first.\n\n/** Copy each key's non-empty values from `from` to `to`, replacing what\n * `to` held for it; a key with no non-empty value leaves `to` as it is. */\nexport function carryParams(from: URLSearchParams, to: URLSearchParams, keys: readonly string[]): void {\n for (const k of keys) {\n const vals = from.getAll(k).filter(Boolean);\n if (!vals.length) continue;\n to.delete(k);\n for (const v of vals) to.append(k, v);\n }\n}\n", "// static/ts/utils/HistoryIndex.ts\n//\n// Each history entry this app writes holds its place in the tab's history in\n// its state, so a popstate can tell which way and how far Back or Forward went:\n// delta = the index left - the index arrived at (1 for one step Back, -1 for\n// one Forward). NavigationManager installs it over history.pushState and\n// replaceState, so every writer's entries carry it, the pages' own included.\n// An entry with no index (written before this, by an older tab, or by a\n// fragment link) makes the move unknown, never a guess.\n\n/** The history.state key that holds an entry's index. */\nexport const HISTORY_INDEX_KEY = 'dcHistoryIndex';\n\n/** The index state holds, or null when it holds none that could be one. */\nexport function historyIndexOf(state: unknown): number | null {\n if (state === null || typeof state !== 'object' || Array.isArray(state)) return null;\n const v = (state as Record)[HISTORY_INDEX_KEY];\n return typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 ? v : null;\n}\n\n/**\n * state with index recorded (a plain object or none), or with any index it\n * carries removed when index is null. Any other state, which no page uses, is\n * left as it is.\n */\nexport function withHistoryIndex(state: unknown, index: number | null): unknown {\n if (state === null || state === undefined) return index === null ? state : { [HISTORY_INDEX_KEY]: index };\n if (typeof state !== 'object' || Array.isArray(state)) return state;\n const copy: Record = { ...(state as Record) };\n if (index === null) delete copy[HISTORY_INDEX_KEY];\n else copy[HISTORY_INDEX_KEY] = index;\n return copy;\n}\n\n/**\n * The index of the entry this load opened on. A reload or Back/Forward keeps\n * the state it had; a new load starts the count, since every entry before it\n * belongs to another document, which no popstate here compares with. A reload\n * or Back/Forward to an entry without one cannot be placed.\n */\nexport function initialHistoryIndex(state: unknown, navType: string | undefined): number | null {\n const kept = historyIndexOf(state);\n if (kept !== null) return kept;\n return navType === 'navigate' ? 0 : null;\n}\n\n/**\n * How far a popstate moved, old - new, or null when it cannot be told: either\n * entry has no index, or both claim the same one (an unindexed entry between\n * them, so 0 would reload the page in place of a move).\n */\nexport function historyDelta(from: number | null, to: number | null): number | null {\n if (from === null || to === null || from === to) return null;\n return from - to;\n}\n\n/** The index tracker NavigationManager owns: where the tab is now. */\nexport class HistoryIndex {\n constructor(public current: number | null) {}\n\n /** A popstate arrived on an entry with state: how far it moved. */\n moved(state: unknown): number | null {\n const to = historyIndexOf(state);\n const delta = historyDelta(this.current, to);\n this.current = to;\n return delta;\n }\n}\n\n/**\n * Stamps the current entry with tracker's index and wraps history.pushState\n * and replaceState so every later entry is stamped too: a push one past the\n * entry it leaves, a replace with the index the entry has. Both read the\n * entry's own state, so one the app did not write (a fragment link's) gives\n * its successors none rather than a wrong one.\n */\nexport function installHistoryIndex(h: History, tracker: HistoryIndex): void {\n const push = h.pushState.bind(h);\n const replace = h.replaceState.bind(h);\n replace(withHistoryIndex(h.state, tracker.current), '');\n h.pushState = (state, unused, url) => {\n const here = historyIndexOf(h.state);\n const next = here === null ? null : here + 1;\n push(withHistoryIndex(state, next), unused, url);\n // Counted once the entry exists: a push that throws adds none.\n tracker.current = next;\n };\n h.replaceState = (state, unused, url) => {\n const here = historyIndexOf(h.state);\n replace(withHistoryIndex(state, here), unused, url);\n tracker.current = here;\n };\n}\n", "import ReportsStore from '../stores/ReportsStore';\nimport { Incident, IncidentStatus } from '../models/Incident'\nimport UserProfileStore from '../stores/UserProfileStore';\nimport { EventHandlerManager } from '../handlers/EventHandlers';\nimport { navigate } from '../utils/DeepLink';\nimport { HistoryIndex, initialHistoryIndex, installHistoryIndex } from '../utils/HistoryIndex';\nimport { apiFetch, redirectToLogin } from '../utils/Api';\nimport { clearSession } from '../utils/Auth';\nimport { tenantNamesVisible } from '../utils/TenantNameDisplay';\nimport { t, SUPPORTED_LOCALES, LOCALE_LABELS, PUBLIC_TAGS, getLocale, setLocale, clearLocale, hasStoredLocale } from '../i18n/I18n';\nimport { escapeHtml, tipAttr } from '../utils/FormatUtils';\nimport { forgetPendingTheme, mountThemePicker } from '../utils/ThemeSetting';\nimport {\n AllPages,\n isRetiredTrackerPage,\n REPORT_PAGES,\n NODE_PAGES,\n TENANT_AD_PAGES,\n TENANT_SERVER_ACTIVITY_PAGES,\n MARKETING_PAGES,\n KNOWLEDGE_PAGES,\n AI_VISIBILITY_PAGES,\n ANALYTICS_PAGES,\n ACL_SIMULATION_PAGES,\n onPage,\n SidebarViews,\n ACL_SIMULATION_DELTA_PAGES,\n DRIFT_REPORT_PAGES,\n CHANGE_REPORT_PAGES,\n SMB_REPORT_PAGES,\n ZAMMAD_PAGES\n} from '../utils/Page';\nimport StoreManager from './StoreManager';\nimport UIManager from './UIManager';\nimport { PageUI } from './UIManager';\nimport ClusterStatsChart from '../utils/ClusterStatsChart';\nimport { Header } from '../utils/Header';\nimport { brandedProductName, clearTenantChromeCache, prefetchTenantChrome } from '../utils/AppBranding';\nimport { hasFlag } from '../utils/ReportUtils';\nimport { ReportFlags } from '../utils/ReportUtils';\nimport { storage } from '../utils/Storage';\nimport { homePageCandidates, resolveHomePage, setMainMenuPages } from '../utils/HomePageSetting';\nimport { mainMenuTarget } from '../utils/SidebarOrder';\nimport { isRetiredPage, RETIRED_PAGE_PARAMS } from '../utils/RetiredPages';\nimport {\n hiddenSidebarEntries, isAlwaysShown, orderSidebar, orderSidebarOver, sidebarEntryKey,\n SIDEBAR_AVAILABILITY_EVENT, SIDEBAR_HIDDEN_EVENT, SidebarMenuItem, SiteMapEntry,\n} from '../utils/SidebarVisibility';\nimport SubtenantStore from '../stores/SubtenantStore';\nimport EnvironmentStore from '../stores/EnvironmentStore';\nimport { presenceStore } from '../stores/PresenceStore';\nimport { userStatusEnabled } from '../utils/UserStatusPref';\nimport { carryParams } from '../utils/UrlParams';\nimport { SMB_REPORT_LINK_PARAMS } from '../utils/SmbReportParams';\nimport { renderWhenStoreReady } from '../utils/PageLoadGuards';\nimport { knowledgeSubPages, KnowledgeSubPage } from '../utils/KnowledgeView';\n\nconst MIMIC_BLACK = '#191919'; // Mimic brand black\nconst AVATAR_FG = '#bbbbbb'; // rgb(187, 187, 187)\nconst AVATAR_BG = '#5b5b5b'; // rgb( 91, 91, 91)\nconst AVATAR_BORDER = '1px solid rgba(251,251,251,.13)';\n// Badge letter color follows the branding primary (tenant-switcher tenant\n// badges, all levels); the fallback is the Mimic gold so unbranded chrome is\n// unchanged. --brand-badge-letter is the primary clamped readable against the\n// dark chip (AppBranding.readableOn) \u2014 a raw dark accent was illegible here.\nconst BADGE_LETTER_COLOR = 'var(--brand-badge-letter, rgb(244, 207, 113))';\n\nconst ENV_BADGE: Record = {\n dev: { bg: '#0079b5', fg: '#fbfbfb' }, // blue\n qa: { bg: '#7c54bb', fg: '#fbfbfb' }, // purple\n staging: { bg: '#337001', fg: '#fbfbfb' }, // green\n uat: { bg: \"#d4b15a\", fg: '#191919' }, // primary-gold surface / ink-10 text\n // prod / experimental \u2192 no badge\n};\n\n/* ------------------------------------------------------------------\n * Page-name \u2192 store \u201Cloaded\u201D event mapping\n * -------------------------------------------\n * Each data-driven page fires a single custom-event when its primary\n * store finishes loading. NavigationManager waits for that event\n * before calling the corresponding UI\u2019s updateUI().\n * ----------------------------------------------------------------- */\n\n// Pages that draw with another page's stylesheet rather than one of their\n// own. Page CSS is loaded by filename, one sheet per page, and every other\n// page's sheet is unloaded on navigation, so a page that shares a design\n// with its sibling names the sibling's sheet here. Site Traffic and Careers\n// were split out of AI Visibility (ENG-4695): same cards, same av-* rules.\nconst PAGE_STYLESHEET: Partial> = {\n 'site-traffic': 'ai-visibility',\n careers: 'ai-visibility',\n};\n\n/** Custom events emitted by stores once their initial load completes. */\nexport const NODES_LOADED = 'nodes-loaded';\n// Dispatched when a ReportsStore load settles, failed or not: a page waiting\n// on it renders either way, and reads the outcome from the store's get().\nexport const REPORTS_LOADED = 'reports-loaded';\nexport const LOGS_LOADED = 'logs-loaded';\nexport const CALLERS_LOADED = 'callers-loaded';\nexport const CLUSTER_LOADED = 'cluster-stats-loaded';\n\n/**\n * Given the route / page key we\u2019re navigating to, return the name of the\n * \u201Cstore loaded\u201D event NavigationManager should wait for. If the page\n * doesn\u2019t require any async store warm-up, return undefined so the UI can\n * be rendered immediately.\n */\nexport function pageToStoreEvent(page: string): string | undefined {\n switch (page) {\n /* \u2500\u2500 data-heavy main pages \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n // case 'nodes': return NODES_LOADED; // NodeStore.loadAll()\n case 'reports': return REPORTS_LOADED; // ReportsStore.load()\n case 'cluster-stats': return CLUSTER_LOADED; // ClusterStatsStore.load()\n /* Logs fetches incrementally via DataTables \u2013 no gating */\n\n /* \u2500\u2500 light pages (profile, about, admin, etc.) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n /* they fetch what they need inside their own UI classes */\n default: return undefined;\n }\n}\n\nexport class NavigationManager {\n private reportsStore: ReportsStore;\n private userProfileStore: UserProfileStore;\n private uiManager: UIManager;\n private page: AllPages;\n private sidebarChart: ClusterStatsChart | null = null;\n private eventHandlerManager: EventHandlerManager | null = null;\n private breadcrumbEl: HTMLDivElement | null = null;\n private header = new Header(\"none\");\n private subtenantStore: SubtenantStore;\n private environmentStore: EnvironmentStore;\n private presenceUnsubscribers: Array<() => void> = [];\n\n constructor(\n uiManager: UIManager,\n page: AllPages,\n ) {\n const storeManager = StoreManager.getInstance();\n this.reportsStore = storeManager.reportsStore;\n this.userProfileStore = storeManager.userProfileStore;\n this.subtenantStore = storeManager.subtenantStore;\n this.environmentStore = storeManager.environmentStore;\n this.uiManager = uiManager;\n this.page = page;\n this.updateCurrentPageUI = this.updateCurrentPageUI.bind(this);\n const navType = (performance.getEntriesByType?.('navigation')[0] as PerformanceNavigationTiming | undefined)?.type;\n this.historyIndex = new HistoryIndex(initialHistoryIndex(history.state, navType));\n try {\n installHistoryIndex(history, this.historyIndex);\n } catch {\n // A history that cannot be written holds no index: every move\n // reads as one whose distance is unknown.\n this.historyIndex.current = null;\n }\n // Home is the first landing page the main sidebar shows: hand the\n // home page's reckoning the menu itself (HomePageSetting).\n setMainMenuPages(() => this.buildMenuItems('main', false, true).map(i => i.page));\n\n this.setupHamburgerMenu();\n\n // Entries shown or hidden on Profile's Sidebar panel.\n window.addEventListener(SIDEBAR_HIDDEN_EVENT, () => {\n const view = storage.get('sidebarState');\n this.updateSidebar(view, /*force=*/true);\n });\n\n // In-place tenant switch (e.g. from the Tenant Profiling\n // dashboard's row-click handler). Re-renders the sidebar so\n // the active tenant chip reflects the new selection without\n // a full page reload.\n window.addEventListener('tenantSwitched', () => {\n const view = storage.get('sidebarState');\n this.updateSidebar(view, /*force=*/true);\n // Re-check network availability for the newly selected tenant.\n void this.refreshNetworkAvailability();\n });\n\n // Re-render the sidebar when subtenant selection changes anywhere.\n window.addEventListener('subtenantChanged', () => {\n const view = storage.get('sidebarState');\n this.updateSidebar(view, /*force=*/true);\n });\n\n // NOTE: the dashcam:navigate listener lives in EventHandlerManager\n // (setupDashcamNavigateHandler) \u2014 do NOT add a second one here. Two\n // listeners each ran gotoPage(push=true), so every cross-page link\n // pushed TWO identical history entries and the browser Back button\n // landed on the duplicate instead of the page the user came from.\n\n\n // This tells the store: \"Whenever an incident is updated, call these methods\"\n storeManager.reportsStore.onIncidentUpdated(updatedIncident => {\n uiManager.reportsUI.onIncidentUpdated(updatedIncident);\n });\n\n storeManager.reportsStore.onIncidentDeleted(incidentId => {\n uiManager.reportsUI.onIncidentDeleted(incidentId);\n })\n }\n\n public getUI(page: string): PageUI | undefined {\n return this.uiManager.getUI(page);\n }\n\n /** A page holding unsaved work: asked before every navigation, it answers\n * true when it has taken the navigation over, and calls proceed if the\n * user leaves after all. fromHistory is Back or Forward: the URL has\n * already moved, by delta entries (HistoryIndex: 1 is one step Back, -1\n * one Forward), or null when that cannot be told. */\n private leaveGuard: ((proceed: () => void, fromHistory: boolean, delta: number | null) => boolean) | null = null;\n\n public setLeaveGuard(guard: ((proceed: () => void, fromHistory: boolean, delta: number | null) => boolean) | null): void {\n this.leaveGuard = guard;\n }\n\n /** Where this tab is in its history; every entry written is stamped with it. */\n private readonly historyIndex: HistoryIndex;\n\n /** Back or Forward arrived on an entry holding state: how many entries it\n * moved, or null when that cannot be told. Called on every popstate. */\n public historyMoved(state: unknown): number | null {\n return this.historyIndex.moved(state);\n }\n\n /** let Main.ts inject the EventHandlerManager once it exists */\n public setEventHandlerManager(h: EventHandlerManager) { this.eventHandlerManager = h; }\n\n // Session-scoped cache-buster for page CSS. Page stylesheets change in\n // place on deploys, and browsers that cached them under the bare URL may\n // serve the stale copy for days (heuristic freshness \u2014 the files carry\n // only Last-Modified). A user whose app.mjs updated but whose page CSS\n // didn't got a broken, unstyled page (seen in prod on the drift report).\n // Stamping the query string makes every full page load fetch a URL the\n // old cache has never seen; within an SPA session the stamp is stable so\n // revisiting a page still hits the browser cache.\n private static readonly PAGE_CSS_STAMP = Date.now();\n\n private loadCssIfNeeded(url: string): void {\n const href = `${url}?v=${NavigationManager.PAGE_CSS_STAMP}`;\n if (!document.querySelector(`link[href=\"${href}\"]`)) {\n const link = document.createElement('link');\n link.rel = 'stylesheet';\n link.href = href;\n document.head.appendChild(link);\n }\n };\n\n // Removes all links to page css from the head of the DOM, preventing css overlap between pages\n private unloadPageCss(): void {\n const links = document.querySelectorAll('link[rel=\"stylesheet\"]');\n links.forEach(link => {\n const href = link.getAttribute('href');\n if (href && href.startsWith('/static/css/pages/')) {\n link.remove();\n }\n });\n }\n\n public updateCurrentPageUI(): void {\n const urlPage = new URLSearchParams(location.search).get(\"page\") as AllPages | null;\n this.page = urlPage ?? storage.get(\"currentPage\") ?? resolveHomePage();\n\n // Legacy alias: the Fleet Patch Status page (?page=cve-fleet) merged\n // into Missing Patches as its All Tenants view. Normalize before\n // dispatch/CSS resolution and reflect it on the URL so old bookmarks\n // and evidence links keep resolving to the fleet view.\n if (this.page === ('cve-fleet' as AllPages)) {\n this.page = 'missing-patches';\n const p = new URLSearchParams(location.search);\n p.set('page', 'missing-patches');\n p.set('allTenants', '1');\n history.replaceState(history.state, '', `${location.pathname}?${p}`);\n }\n\n // Retired: the Progress Tracker page and the two epic trackers that\n // had merged into it. Old bookmarks and stored currentPage values\n // land on the home page instead of a page that no longer exists.\n if (isRetiredTrackerPage(this.page)) {\n this.page = resolveHomePage();\n const p = new URLSearchParams(location.search);\n p.set('page', this.page);\n p.delete('tracker');\n history.replaceState(history.state, '', `${location.pathname}?${p}`);\n }\n // Legacy alias: the node X-Ray moved to the tenant level,\n // where the node is the page's filter (?node=). An old link keeps its\n // node, seed, window and the rest.\n if (this.page === 'node-component-profile') {\n this.page = 'component-profile';\n const p = new URLSearchParams(location.search);\n const nodeId = (p.get('nodeId') || storage.get('selectedNodeId') || '').trim();\n p.set('page', 'component-profile');\n p.delete('nodeId');\n if (nodeId) p.set('node', nodeId);\n history.replaceState(history.state, '', `${location.pathname}?${p}`);\n }\n // Legacy alias: Confirmed Attacks was ?page=confirmed-ransomware.\n // Old links, bookmarks and stored pages land on the renamed page,\n // keeping the rest of the query.\n if (this.page === ('confirmed-ransomware' as AllPages)) {\n this.page = 'confirmed-attacks';\n const p = new URLSearchParams(location.search);\n if (p.has('page')) {\n p.set('page', 'confirmed-attacks');\n history.replaceState(history.state, '', `${location.pathname}?${p}`);\n }\n }\n // Retired pages (utils/RetiredPages): an old bookmark or stored page\n // lands on the home page instead of an empty one, and its page and\n // id params leave the URL.\n if (isRetiredPage(this.page)) {\n this.page = resolveHomePage();\n const p = new URLSearchParams(location.search);\n for (const k of ['page', ...RETIRED_PAGE_PARAMS]) p.delete(k);\n const qs = p.toString();\n history.replaceState(history.state, '', qs ? `${location.pathname}?${qs}` : location.pathname);\n }\n storage.set(\"currentPage\", this.page);\n\n // \u2705 rehydrate selectedNodeId from URL for refresh/deep-link\n const params = new URLSearchParams(location.search);\n const urlNodeId = (params.get(\"nodeId\") || \"\").trim();\n if (urlNodeId) storage.set(\"selectedNodeId\", urlNodeId);\n\n const storedView = storage.get('sidebarState') as SidebarViews | null;\n\n let view = storedView;\n if (!view || !this.viewMatchesPage(view, this.page)) {\n view = this.inferViewFromPage(this.page, storedView);\n storage.set('sidebarState', view);\n }\n\n // now that state is correct, update breadcrumb + selection\n this.updateBreadcrumb();\n this.updateSidebarSelection();\n\n this.unloadPageCss();\n // The page comes from the URL (?page=): encoded, it can only name a file.\n this.loadCssIfNeeded(`/static/css/pages/${encodeURIComponent(PAGE_STYLESHEET[this.page] ?? this.page)}.css`);\n\n const selectedReportId = storage.get('selectedReportId') || '';\n const reportSummaries = this.reportsStore.getAllLoaded()\n\n const selectedReport: Incident | undefined = reportSummaries.find(reportSummary => reportSummary.id === selectedReportId);\n if (selectedReport) {\n storage.set('selectedReport', selectedReport.id);\n }\n\n const title = selectedReport?.name || '';\n const startTime = selectedReport?.startTime || 0;\n const endTime = selectedReport?.endTime || 0;\n let nodeIncidents = selectedReport?.nodes || [];\n\n this.updateSidebar(view, /*force=*/true);\n // Kick the Network Profiler availability check for the current tenant;\n // it re-renders the sidebar once the flag is known.\n void this.refreshNetworkAvailability();\n // Same idea for the selected node's OS, which gates Registry Tree.\n void this.refreshSelectedNodeOS();\n\n if (view === \"report\") {\n const params = new URLSearchParams();\n params.set(\"id\", selectedReportId);\n\n // Then add page=\n if (this.page) {\n params.set(\"page\", this.page);\n }\n\n navigate({\n params,\n data: {\n page: this.page,\n selectedReportId,\n view\n }\n });\n } else if (view === \"main\") {\n const url = new URL(window.location.href);\n const page = storage.get('currentPage') || resolveHomePage();\n\n // The home page owns the bare root URL: land on it and the query\n // string goes away entirely. Every other page carries ?page=, so a\n // refresh returns to it rather than bouncing to the home page.\n if (page === resolveHomePage()) {\n url.search = ''; // clears the entire query string, e.g. \u201C?page=nodes\n } else {\n // Not the home page \u2014 remove leftover param(s) only if you wish\n // e.g. remove command=, id=, name=, etc. if page !== admin\n if (page !== 'admin') {\n url.searchParams.delete('id');\n url.searchParams.delete('name');\n url.searchParams.delete('command');\n }\n // Then set page\n url.searchParams.set('page', page);\n }\n\n navigate({ params: url.searchParams, data: { page: this.page, view: \"main\" } })\n }\n\n console.debug('Updating UI for current page:', this.page);\n\n this.header.clear();\n\n // ACL Simulator is a super-admin-only internal tool. Block deep-links\n // to any of its pages without hasAclSimulator (the nav entry is already\n // hidden; the backend tenant/node super-admin route groups are the real\n // boundary). Nothing runs after the switch, so this early return is\n // equivalent to a per-case forbidden + break.\n if ((this.page as string).startsWith('acl-simulation') && !this.userProfileStore.user?.hasAclSimulator) {\n this.uiManager.forbiddenUI.updateUI();\n return;\n }\n\n // Node Configs is internal-only for the same reason (applied-config\n // provenance is staff detail). The nav entry is hidden, and the\n // nodeInternal route group is the real boundary \u2014 this stops a\n // deep-linked or evidence-link URL from rendering an empty shell that\n // then 403s on its fetch.\n if (this.page === 'node-configs' && !this.userProfileStore.user?.hasNodeConfigSource) {\n this.uiManager.forbiddenUI.updateUI();\n return;\n }\n\n switch (this.page) {\n case 'acl-simulation-event-summary':\n this.uiManager.aclSimulationEventSummaryUI.updateUI();\n break;\n case 'acl-simulation-rule-summary':\n this.uiManager.aclSimulationRuleSummaryUI.updateUI();\n break;\n case 'acl-simulation-rule-tree':\n this.uiManager.aclSimulationRuleTreeUI.updateUI();\n break;\n case 'reports':\n this.uiManager.reportsUI.updateUI();\n break;\n case 'events':\n void this.uiManager.eventsUI.updateUI();\n break;\n case 'drift-reports':\n this.uiManager.driftReportsUI.updateUI();\n break;\n case 'change-reports':\n void this.uiManager.changeReportsUI.updateUI();\n break;\n case 'change-report':\n void this.uiManager.changeReportUI.updateUI();\n break;\n case 'knowledge-edit':\n // The routes need RoleDocumentation and RoleInternal\n // (hasKnowledgeEditor); this keeps a deep link from an\n // empty shell that 403s.\n if (!this.userProfileStore.user?.hasKnowledgeEditor) {\n this.uiManager.forbiddenUI.updateUI();\n break;\n }\n void this.uiManager.knowledgeEditorUI.updateUI();\n break;\n case 'smb-network-report':\n this.uiManager.smbNetworkReportUI.updateUI();\n break;\n case 'smb-file-io-report':\n this.uiManager.smbFileIOReportUI.updateUI();\n break;\n case 'knowledge':\n void this.uiManager.knowledgeUI.updateUI();\n break;\n case 'knowledge-sources':\n case 'knowledge-feedback':\n // RoleDocumentation and RoleInternal routes, as the editor's.\n if (!this.userProfileStore.user?.hasKnowledgeEditor) {\n this.uiManager.forbiddenUI.updateUI();\n break;\n }\n if (this.page === 'knowledge-sources') void this.uiManager.knowledgeSourcesUI.updateUI();\n else void this.uiManager.knowledgeFeedbackUI.updateUI();\n break;\n case 'drift-report-time-range':\n this.uiManager.driftReportTimeRangeUI.updateUI();\n break;\n case 'diversity-report':\n this.uiManager.diversityReportUI.updateUI();\n break;\n case 'nodes':\n this.uiManager.nodesUI.updateUI();\n break;\n case 'notifications':\n if (!this.userProfileStore.user?.hasNotificationsPage) {\n this.uiManager.forbiddenUI.updateUI();\n break;\n }\n this.uiManager.notificationsUI.updateUI();\n break;\n case 'incidents':\n if (!this.userProfileStore.user?.hasSupportTools || !this.environmentStore.isIncidentioEnabled) {\n this.uiManager.forbiddenUI.updateUI();\n break;\n }\n this.uiManager.incidentsUI.updateUI();\n break;\n case 'zammad':\n if (!this.userProfileStore.user?.hasSupportTools || !this.environmentStore.isZammadEnabled) {\n this.uiManager.forbiddenUI.updateUI();\n break;\n }\n this.uiManager.zammadUI.updateUI();\n break;\n case 'zammad-organizations':\n if (!this.userProfileStore.user?.hasSupportTools || !this.environmentStore.isZammadEnabled) {\n this.uiManager.forbiddenUI.updateUI();\n break;\n }\n this.uiManager.zammadOrganizationsUI.updateUI();\n break;\n case 'zammad-organization-details':\n if (!this.userProfileStore.user?.hasSupportTools || !this.environmentStore.isZammadEnabled) {\n this.uiManager.forbiddenUI.updateUI();\n break;\n }\n this.uiManager.zammadOrganizationDetailsUI.updateUI();\n break;\n case 'zammad-tickets':\n if (!this.userProfileStore.user?.hasSupportTools || !this.environmentStore.isZammadEnabled) {\n this.uiManager.forbiddenUI.updateUI();\n break;\n }\n this.uiManager.zammadTicketsUI.updateUI();\n break;\n case 'zammad-ticket-details':\n if (!this.userProfileStore.user?.hasSupportTools || !this.environmentStore.isZammadEnabled) {\n this.uiManager.forbiddenUI.updateUI();\n break;\n }\n this.uiManager.zammadTicketDetailsUI.updateUI();\n break;\n case 'cluster-stats':\n if (!this.userProfileStore.user?.hasSupportTools) {\n this.uiManager.forbiddenUI.updateUI();\n break;\n }\n this.uiManager.clusterStatsUI.updateUI();\n break;\n case 'time-to-detect':\n this.uiManager.timeToDetectUI.updateUI();\n break;\n case 'timeline':\n this.uiManager.timelineUI.updateUI(title, selectedReport.id);\n break;\n case 'directory-tree':\n this.uiManager.directoryTreeUI.updateUI(title, nodeIncidents);\n break;\n case 'registry-tree':\n this.uiManager.registryTreeUI.updateUI(title, nodeIncidents);\n break;\n case 'process-tree':\n this.uiManager.processTreeUI.updateUI(title, nodeIncidents);\n break;\n case 'acl-simulations':\n this.uiManager.aclSimulationsUI.updateUI();\n break;\n case 'config-delta':\n this.uiManager.configDeltaUI.updateUI();\n break;\n case 'acl-simulation-event-summary-delta':\n this.uiManager.aclSimulationEventSummaryDeltaUI.updateUI();\n break;\n case 'acl-simulation-rule-summary-delta':\n this.uiManager.aclSimulationRuleSummaryDeltaUI.updateUI();\n break;\n case 'acl-simulation-rule-tree-delta':\n this.uiManager.aclSimulationRuleTreeDeltaUI.updateUI();\n break;\n case 'node-executables':\n this.uiManager.nodeExecutablesUI.updateUI();\n break;\n // \u2500\u2500 Active Directory (tenant scoped) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n case 'ad-summary':\n this.uiManager.adSummaryUI.updateUI();\n break;\n case 'ad-event-hourly':\n this.uiManager.adEventHourlyUI.updateUI();\n break;\n case 'ad-scheduled-task':\n this.uiManager.adScheduledTaskUI.updateUI();\n break;\n case 'ad-kerberos-ticket':\n this.uiManager.adKerberosTicketUI.updateUI();\n break;\n case 'ad-service-change':\n this.uiManager.adServiceChangeHourlyUI.updateUI();\n break;\n case 'ad-session-change':\n this.uiManager.adSessionChangeHourlyUI.updateUI();\n break;\n case 'ad-dc-sync':\n this.uiManager.adDCSyncHourlyUI.updateUI();\n break;\n case 'ad-domain-controllers':\n this.uiManager.adDomainControllersUI.updateUI();\n break;\n case 'ad-inventory':\n this.uiManager.adInventoryUI.updateUI();\n break;\n case 'ad-permissions':\n this.uiManager.adPermissionsUI.updateUI();\n break;\n case 'ad-ldap-change':\n this.uiManager.adLdapChangeHourlyUI.updateUI();\n break;\n case 'ad-group-change':\n this.uiManager.adGroupChangeHourlyUI.updateUI();\n break;\n case 'ad-usn-change':\n this.uiManager.adUSNChangeHourlyUI.updateUI();\n break;\n case 'ransomware-info':\n this.uiManager.ransomwareUI.updateUI(title, nodeIncidents);\n break;\n case 'artifacts':\n this.uiManager.artifactsUI.updateUI(title, nodeIncidents);\n break;\n case 'exec-summary':\n this.uiManager.execSummaryUI.updateUI(title, startTime, endTime, nodeIncidents);\n break;\n\n case 'protections':\n this.uiManager.protectionsUI.updateUI(title, nodeIncidents);\n break;\n case 'callers':\n this.uiManager.callersUI.updateUI();\n break;\n case 'logs':\n this.uiManager.logsUI.updateUI();\n break;\n case 'disk-usage':\n this.uiManager.diskUsageUI.updateUI();\n break;\n case 'backups':\n if (!this.userProfileStore.user?.isDashcamAdmin) {\n this.uiManager.forbiddenUI.updateUI();\n break;\n }\n this.uiManager.backupsUI.updateUI();\n break;\n case 'tenant-profiling':\n // Gated by the hasTenantProfiling capability (mirrors the\n // server's finance-or-internal read gate).\n if (!this.userProfileStore.user?.hasTenantProfiling) {\n this.uiManager.forbiddenUI.updateUI();\n break;\n }\n this.uiManager.tenantProfilingUI.updateUI();\n break;\n case 'network-profiler':\n this.uiManager.networkProfilerUI.updateUI();\n break;\n case 'sql':\n this.uiManager.sqlUI.updateUI();\n break;\n case 'insights':\n if (!this.environmentStore.isInsightsEnabled\n || !this.userProfileStore.user?.hasInsightsPage) {\n this.uiManager.forbiddenUI.updateUI();\n break;\n }\n this.uiManager.insightsUI.updateUI();\n break;\n case 'onyx':\n if (!this.userProfileStore.user?.hasSupportTools) {\n this.uiManager.forbiddenUI.updateUI();\n break;\n }\n this.uiManager.onyxUI.updateUI();\n break;\n case 'support':\n if (!this.userProfileStore.user?.hasSupportTools) {\n this.uiManager.forbiddenUI.updateUI();\n break;\n }\n this.uiManager.supportUI.updateUI();\n break;\n case 'release-status':\n // No role gate \u2014 the handler scopes nodes to the caller's\n // entitled tenants. Still gated on the env having a\n // provisioned NODE_RELEASES_TOKEN; deep-link attempts in\n // disabled envs land on the Forbidden page.\n if (!this.environmentStore.isNodeReleasesEnabled) {\n this.uiManager.forbiddenUI.updateUI();\n break;\n }\n this.uiManager.releaseStatusUI.updateUI();\n break;\n case 'node-health':\n // (defensive) gotoPage already redirects callers without the\n // node-health capability to forbidden; this case just\n // dispatches updateUI when the page is opened legitimately.\n if (!this.userProfileStore.user?.hasNodeHealth) {\n this.uiManager.forbiddenUI.updateUI();\n break;\n }\n this.uiManager.nodeHealthUI.updateUI();\n break;\n case 'tenant-health':\n // Internal-only while the feature bakes (ENG-3786): the\n // backing route carries RoleInternal, and hasTenantHealth\n // mirrors that gate for the client \u2014 same contract as\n // missing-patches below.\n if (!this.userProfileStore.user?.hasTenantHealth) {\n this.uiManager.forbiddenUI.updateUI();\n break;\n }\n this.uiManager.tenantHealthUI.updateUI();\n break;\n case 'discover':\n // Discover (ENG-4722): the analytics role, which every SQL\n // role implies; the server limits analytics-only callers to\n // the data views, read-only.\n if (!this.userProfileStore.user?.hasAnalytics) {\n this.uiManager.forbiddenUI.updateUI();\n break;\n }\n void this.uiManager.discoverUI.updateUI();\n break;\n case 'dashboards':\n // Dashboards (ENG-4722): the analytics role, as Discover.\n if (!this.userProfileStore.user?.hasAnalytics) {\n this.uiManager.forbiddenUI.updateUI();\n break;\n }\n void this.uiManager.dashboardsUI.updateUI();\n break;\n case 'saved-objects':\n // Saved objects (ENG-4722): the BI objects' own list, same gate.\n if (!this.userProfileStore.user?.hasAnalytics) {\n this.uiManager.forbiddenUI.updateUI();\n break;\n }\n void this.uiManager.savedObjectsUI.updateUI();\n break;\n case 'visualize':\n // Visualize (ENG-4722): the analytics role, as Discover.\n if (!this.userProfileStore.user?.hasAnalytics) {\n this.uiManager.forbiddenUI.updateUI();\n break;\n }\n void this.uiManager.visualizeUI.updateUI();\n break;\n case 'benchmark-compliance':\n // NOT the tenant-health contract any more (ENG-3835): the\n // page's read routes are RoleBenchmark (which RoleInternal\n // carries), and hasBenchmarkCompliance mirrors that. Keying this off\n // hasTenantHealth would bounce the customer-side benchmark\n // readers the role was minted for.\n if (!this.userProfileStore.user?.hasBenchmarkCompliance) {\n this.uiManager.forbiddenUI.updateUI();\n break;\n }\n void this.uiManager.benchmarkComplianceUI.updateUI();\n break;\n case 'admin':\n // Admins get the full page; TAMs reach it for the MSSPs tab,\n // marketing for Site Access and support for Performance\n // (AdminUI hides the rest).\n // Anyone else is bounced.\n if (!this.canReachAdminPage()) {\n this.uiManager.forbiddenUI.updateUI();\n break;\n }\n this.uiManager.adminUI.updateUI();\n break;\n case 'developer':\n // (defensive) If user somehow arrives here without the role, bounce them.\n if (!this.userProfileStore.user?.hasDeveloperTools) {\n this.uiManager.forbiddenUI.updateUI();\n break;\n }\n this.uiManager.developerUI.updateUI();\n break;\n case 'create-report':\n this.uiManager.createReportUI.updateUI();\n break;\n case 'about':\n this.uiManager.aboutUI.updateUI();\n break;\n case 'ai-visibility':\n case 'site-traffic':\n case 'careers':\n // One capability for the three (AI_VISIBILITY_PAGES): the\n // same endpoints, the same RequireRole(Marketing) group.\n if (!this.userProfileStore.user?.hasAiVisibility) {\n this.uiManager.forbiddenUI.updateUI();\n break;\n }\n if (this.page === 'ai-visibility') this.uiManager.aiVisibilityUI.updateUI();\n else if (this.page === 'site-traffic') this.uiManager.siteTrafficUI.updateUI();\n else this.uiManager.careersUI.updateUI();\n break;\n case 'linkedin-posts':\n if (!this.userProfileStore.user?.hasLinkedInPosts) {\n this.uiManager.forbiddenUI.updateUI();\n break;\n }\n this.uiManager.linkedInPostsUI.updateUI();\n break;\n case 'confirmed-attacks':\n if (!this.userProfileStore.user?.hasConfirmedAttacks) {\n this.uiManager.forbiddenUI.updateUI();\n break;\n }\n this.uiManager.confirmedRansomwareUI.updateUI();\n break;\n case 'kev':\n // Staff + vulnimport, over public data. The catalog itself\n // is CISA's, so /api/kev stays plain-authenticated (search_kev\n // serves non-internal callers on both MCP surfaces); what the\n // capability gates is the SIDEBAR ENTRY, which sits beside\n // tenant-scoped pages and reads as if it were about the\n // selected tenant.\n if (!this.userProfileStore.user?.hasKevCatalog) {\n this.uiManager.forbiddenUI.updateUI();\n break;\n }\n this.uiManager.kevUI.updateUI();\n break;\n case 'missing-patches':\n // GA \u2014 hasMissingPatches is true for every authenticated user\n // (the gate survives for clients running against an older\n // server, where a missing flag means the routes still 403).\n // Tenant scope is enforced server-side on every backing route.\n // ?page=cve-fleet is a legacy alias normalized onto this page\n // before dispatch ever sees it.\n if (!this.userProfileStore.user?.hasMissingPatches) {\n this.uiManager.forbiddenUI.updateUI();\n break;\n }\n this.uiManager.missingPatchesUI.updateUI();\n break;\n case 'profile':\n this.uiManager.profileUI.updateUI();\n break;\n case 'node-applications':\n this.uiManager.nodeApplicationsUI.updateUI();\n break;\n case \"node-services\":\n this.uiManager.nodeServicesUI.updateUI();\n break;\n case \"node-directory-tree\":\n this.uiManager.nodeDirectoryTreeUI.updateUI();\n break;\n case \"node-registry-tree\":\n this.uiManager.nodeRegistryTreeUI.updateUI();\n break;\n case \"node-events\":\n this.uiManager.nodeEventsUI.updateUI();\n break;\n case \"node-configs\":\n this.uiManager.nodeConfigUI.updateUI();\n break;\n case \"node-constellation\":\n this.uiManager.nodeConstellationUI.updateUI();\n break;\n case \"component-profile\":\n void this.uiManager.componentProfileUI.updateUI();\n break;\n case \"system-info\":\n this.uiManager.systemInfoUI.updateUI();\n break;\n case \"applications\":\n this.uiManager.applicationsUI.updateUI();\n break;\n case \"executables\":\n this.uiManager.executablesUI.updateUI();\n break;\n case \"services\":\n this.uiManager.servicesUI.updateUI();\n break;\n case \"treescans\":\n this.uiManager.treescansUI.updateUI();\n break;\n case \"forbidden\":\n this.uiManager.forbiddenUI.updateUI();\n break;\n case \"constellations\":\n this.uiManager.constellationsUI.updateUI(title, nodeIncidents);\n break;\n default:\n console.error(\"No such page to update:\", this.page);\n break;\n }\n }\n\n /** Where every \"Back to Main menu\" goes: the first home page that opens\n * the main sidebar (mainMenuTarget), so the way out of a sub-sidebar never\n * lands in another one, as it would with Documentation as home. */\n private mainMenuPage(): AllPages {\n return mainMenuTarget(homePageCandidates(), p => this.inferViewFromPage(p));\n }\n\n private knowledgeMenuItem(page: KnowledgeSubPage): SidebarMenuItem {\n switch (page) {\n case 'knowledge': return { name: t('nav.documentation'), icon: 'library_books', page, classList: 'material-icons' };\n case 'knowledge-edit': return { name: t('nav.knowledgeEditor'), icon: 'edit_note', page, classList: 'material-icons' };\n case 'knowledge-sources': return { name: t('nav.knowledgeSources'), icon: 'source', page, classList: 'material-icons' };\n case 'knowledge-feedback': return { name: t('nav.knowledgeFeedback'), icon: 'thumbs_up_down', page, classList: 'material-icons' };\n }\n }\n\n // The Marketing page a reader lands on from the main menu: the first of\n // its pages they may open, or null when they may open none.\n private marketingLanding(): AllPages | null {\n if (this.userProfileStore.user?.hasAiVisibility) return 'ai-visibility';\n if (this.userProfileStore.user?.hasLinkedInPosts) return 'linkedin-posts';\n if (this.userProfileStore.user?.hasConfirmedAttacks) return 'confirmed-attacks';\n return null;\n }\n\n private viewMatchesPage(view: SidebarViews, page: AllPages): boolean {\n if (onPage(REPORT_PAGES, page)) return view === 'report' || view === 'explorer-report';\n if (page !== 'ad-summary' && onPage(TENANT_AD_PAGES, page)) return view === 'tenant-ad';\n if (onPage(TENANT_SERVER_ACTIVITY_PAGES, page)) return view === 'server-activity';\n if (onPage(MARKETING_PAGES, page)) return view === 'marketing';\n if (onPage(KNOWLEDGE_PAGES, page)) return view === 'knowledge';\n if (page !== 'sql' && onPage(ANALYTICS_PAGES, page)) return view === 'analytics';\n if (onPage(NODE_PAGES, page)) return view === 'node';\n if (onPage(DRIFT_REPORT_PAGES, page)) return view === 'drift-reports';\n if (onPage(CHANGE_REPORT_PAGES, page)) return view === 'change-reports';\n if (onPage(ACL_SIMULATION_PAGES, page)) return view === 'acl-simulations';\n if (onPage(ACL_SIMULATION_DELTA_PAGES, page)) return view === 'acl-simulation-delta';\n if (onPage(SMB_REPORT_PAGES, page)) return view === 'smb-reports';\n // Detail pages render the context sub-sidebar (view 'zammad'); the unified\n // list page itself lives under the main sidebar.\n if (page === 'zammad-ticket-details' || page === 'zammad-organization-details') return view === 'zammad';\n if (page === 'sql') return true; // sql can live under any sidebar view\n return view === 'main';\n }\n\n private inferViewFromPage(page: AllPages, fallback?: SidebarViews | null): SidebarViews {\n if (onPage(REPORT_PAGES, page)) return (fallback === 'explorer-report') ? 'explorer-report' : 'report';\n // The tenant AD summary (tiles landing) lives under the MAIN sidebar\n // (its main-menu entry highlights); only the report pages get the\n // tenant-ad sub-sidebar with the \"Back to Active Directory\" item.\n if (page !== 'ad-summary' && onPage(TENANT_AD_PAGES, page)) return 'tenant-ad';\n if (onPage(TENANT_SERVER_ACTIVITY_PAGES, page)) return 'server-activity';\n if (onPage(MARKETING_PAGES, page)) return 'marketing';\n if (onPage(KNOWLEDGE_PAGES, page)) return 'knowledge';\n if (page !== 'sql' && onPage(ANALYTICS_PAGES, page)) return 'analytics';\n if (onPage(NODE_PAGES, page)) return 'node';\n if (onPage(DRIFT_REPORT_PAGES, page)) return 'drift-reports';\n if (onPage(CHANGE_REPORT_PAGES, page)) return 'change-reports';\n if (onPage(ACL_SIMULATION_PAGES, page)) return 'acl-simulations';\n if (onPage(ACL_SIMULATION_DELTA_PAGES, page)) return 'acl-simulation-delta';\n if (onPage(SMB_REPORT_PAGES, page)) return 'smb-reports';\n // Detail pages \u2192 the 'zammad' section view (context sub-sidebar). The\n // unified list page ('zammad') falls through to the main sidebar.\n if (page === 'zammad-ticket-details' || page === 'zammad-organization-details') return 'zammad';\n if (page === 'sql' && fallback) return fallback;\n if (page === 'sql') return 'analytics';\n return 'main';\n }\n\n\n /** Create / refresh the single breadcrumb that sits above page headers. */\n private updateBreadcrumb(): void {\n /* ---------------------------------------------------------------\n 1. Decide whether we need the breadcrumb at all\n ----------------------------------------------------------------*/\n const view = storage.get(\"sidebarState\");\n const onMainPage = view === 'main';\n // Marketing, Knowledge Base and Analytics have no list page to go\n // back to; their sidebars carry the way out, so a breadcrumb would\n // only repeat it.\n const needBreadcrumb = !onMainPage && view !== 'marketing' && view !== 'knowledge' && view !== 'analytics';\n\n /* ---------------------------------------------------------------\n 2. Lazily create (once) the element\n ----------------------------------------------------------------*/\n if (!this.breadcrumbEl) {\n this.breadcrumbEl = document.createElement('div');\n this.breadcrumbEl.id = 'global-breadcrumb';\n this.breadcrumbEl.style.cssText = `\n display:flex; align-items:center;\n font-weight:bold; margin-bottom: 1rem;\n cursor:pointer; user-select:none\n `;\n }\n\n /* ---------------------------------------------------------------\n 3. Find a stable root to prepend it to\n (first try the
that exists in index.html,\n otherwise fall back to the legacy id, otherwise keep quiet)\n ----------------------------------------------------------------*/\n const root =\n document.getElementById('page-header') ??\n document.getElementById('content-wrapper');\n\n if (!root) { // nothing suitable \u2013 give up\n console.warn('[breadcrumb] root node not found');\n return;\n }\n\n /* ---------------------------------------------------------------\n 4. Inject or hide\n ----------------------------------------------------------------*/\n if (needBreadcrumb) {\n /* insert only if it is not already in the DOM */\n if (!this.breadcrumbEl.isConnected) root.prepend(this.breadcrumbEl);\n\n const backArrow = ``;\n\n if (view === \"report\") {\n /* \u2190 Back to Reports */\n this.breadcrumbEl.innerHTML =\n `${backArrow}\n ${t('nav.backTo', { name: t('body.reports') })}`;\n\n this.breadcrumbEl.onclick = () => this.gotoPage('reports');\n } else if (view === \"node\") {\n this.breadcrumbEl.innerHTML =\n `${backArrow}\n ${t('nav.backTo', { name: t('nav.nodes') })}`;\n\n this.breadcrumbEl.onclick = () => this.gotoPage('nodes');\n } else if (view === \"drift-reports\") {\n /* \u2190 Back to Drift Reports */\n this.breadcrumbEl.innerHTML =\n `${backArrow}\n ${t('nav.backTo', { name: t('nav.driftReports') })}`;\n this.breadcrumbEl.onclick = () => this.gotoPage('drift-reports');\n } else if (view === 'change-reports') {\n /* \u2190 Back to Change Reports */\n this.breadcrumbEl.innerHTML =\n `${backArrow}\n ${t('nav.backTo', { name: t('nav.changeReports') })}`;\n this.breadcrumbEl.onclick = () => this.gotoPage('change-reports');\n } else if (view === 'acl-simulations') {\n /* \u2190 Back to ACL Simulations */\n this.breadcrumbEl.innerHTML =\n `${backArrow}\n ${t('nav.backTo', { name: t('nav.aclSimulations') })}`;\n this.breadcrumbEl.onclick = () => this.gotoPage('acl-simulations');\n } else if (view === 'zammad') {\n // Tickets + Orgs share one page; label by which detail we're on.\n // The active tab is restored from storage when 'zammad' loads.\n const cp = (storage.get('currentPage') || 'zammad') as AllPages;\n const label = cp === 'zammad-organization-details' ? t('nav.backToZammadOrgs') : t('nav.backToZammadTickets');\n this.breadcrumbEl.innerHTML =\n `${backArrow}\n ${label}`;\n this.breadcrumbEl.onclick = () => this.gotoPage('zammad');\n }\n\n this.breadcrumbEl.style.display = 'flex';\n } else {\n this.breadcrumbEl.style.display = 'none';\n }\n }\n\n\n public updateSidebarSelection(): void {\n const currentPage = storage.get('currentPage') || resolveHomePage();\n const sidebar = $('#sidebar-menu'); // Using jQuery to select the sidebar\n\n // Use jQuery's .each() method to iterate over all anchor tags\n sidebar.find('a[data-page]').each(function () {\n const link = $(this); // 'this' is the current DOM element wrapped as a jQuery object\n if (link.data('page') === currentPage) {\n link.addClass('sidebar-active').removeClass('bg-light');\n } else {\n link.removeClass('sidebar-active').addClass('bg-light');\n link.trigger('blur');\n }\n });\n }\n\n // Function to update the sidebar menu\n // Network Profiler availability for the selected tenant, fetched from the\n // nodes API (hasNetworkData / networkDataNodeIds). Gates the \"Network\"\n // sidebar item and the Nodes-page multi-select bulk action \u2014 so neither\n // appears for tenants that emit no RECORD_TYPE_NETWORK_PROFILER data.\n private networkHasData = false;\n private networkNodeIds: Set = new Set();\n private networkCheckedTenantId: string | null = null;\n // The tenant whose availability check last succeeded; null while one is\n // in flight or after one failed. Until it names the selected tenant the\n // gated entries below are unknown, not absent, so the Sidebar panel\n // keeps reordering off (a group order saved then would drop them).\n private availabilityKnownTenantId: string | null = null;\n // Counts availability checks; a reply applies only while its check is\n // still the latest (a switch A \u2192 B \u2192 A starts a newer check for A).\n private availabilityCheck = 0;\n // Whether the selected tenant has any AD node: an AD engine in its config,\n // or AD records in the last 7 days (backend hasAdEngine \u2014 OR of\n // has_ad_engine across the tenant's nodes).\n // Gates the tenant-wide Active Directory sidebar surface, same way\n // networkHasData gates Network. Resolved in the same nodes call.\n private tenantHasADEngine = false;\n // The Forest Inventory entry is data-gated: shown only when the tenant has\n // at least one active_directory_cron record (nodes endpoint hasAdInventory).\n private tenantHasADInventory = false;\n // The Permissions entry is gated the same way, on its own plugin's\n // records (active_directory_permissions_cron; nodes endpoint hasAdPermissions).\n private tenantHasADPermissions = false;\n // Data-driven gates for the \"Server Activity\" sidebar group. These record\n // types (SCM service changes / userland session events) fire on any\n // monitored Windows server, so they gate on data presence rather than\n // hasAdEngine \u2014 same pattern as networkHasData. Resolved in the same nodes\n // call, each shown only when the tenant actually has that record type.\n private tenantHasServiceChangeData = false;\n private tenantHasSessionChangeData = false;\n\n /** Whether the selected tenant's page availability is known: its check\n * succeeded (or there is no tenant to check). */\n public tenantAvailabilityKnown(): boolean {\n const tenantId = storage.get('selectedTenantId') || this.userProfileStore.user?.tenantID || '';\n return !tenantId || this.availabilityKnownTenantId === tenantId;\n }\n\n /** Whether the current tenant has any network-profiler data. */\n public hasNetworkData(): boolean { return this.networkHasData; }\n /** Node IDs in the current tenant that have network-profiler data. */\n public getNetworkDataNodeIds(): Set { return this.networkNodeIds; }\n\n // Node whose OS we have already resolved, so a cold deep-link fetches\n // once rather than on every navigation.\n private nodeOsCheckedId: string | null = null;\n\n /**\n * OS type of the node backing the node-detail sidebar, read straight from\n * the nodes store \u2014 both the list and the single-node fetch populate\n * systemProfile.osType. Warm on the normal path, so updateSidebar() stays\n * synchronous. Returns '' when the node isn't in the store yet.\n */\n /** Constellations are built from activity records (profiler Behavior or\n * access patterns), so a node with neither can only ever render an\n * empty graph \u2014 hide the entry for it. While the\n * node is still unresolved the entry stays, matching the Registry Tree\n * gate: briefly showing a dead page beats hiding a real one. */\n private selectedNodeHasActivity(): boolean {\n const nodeId = (storage.get('selectedNodeId') || '').trim();\n if (!nodeId) return true;\n const node = StoreManager.getInstance().nodeStore.getById(nodeId);\n if (!node) return true;\n return node.hasActivityRecords();\n }\n\n private selectedNodeOsType(): string {\n const nodeId = (storage.get('selectedNodeId') || '').trim();\n if (!nodeId) return '';\n const node = StoreManager.getInstance().nodeStore.getById(nodeId);\n return (node?.systemProfile?.osType || '').toLowerCase().trim();\n }\n\n /**\n * Warms the nodes store for the selected node so selectedNodeOsType() can\n * answer, then re-renders. No-ops on the normal path (the Nodes list has\n * already populated the store); only a cold deep-link straight onto a node\n * page pays a fetch. Best-effort: an unresolved OS leaves the Registry Tree\n * entry visible, which is the pre-gate behaviour \u2014 better than hiding a\n * real page.\n */\n public async refreshSelectedNodeOS(): Promise {\n const nodeId = (storage.get('selectedNodeId') || '').trim();\n if (!nodeId) {\n this.nodeOsCheckedId = null;\n return;\n }\n if (this.selectedNodeOsType()) return; // already resolvable\n if (nodeId === this.nodeOsCheckedId) return; // fetched once, still unknown\n this.nodeOsCheckedId = nodeId;\n\n const tenantId = storage.get('selectedTenantId') || '';\n if (!tenantId) return;\n try {\n await StoreManager.getInstance().nodeStore.loadOne(tenantId, nodeId);\n } catch (e) {\n console.warn('[NavigationManager] node OS check failed', e);\n return;\n }\n this.updateSidebar(storage.get('sidebarState'), /*force=*/true);\n }\n\n /**\n * Re-checks Network Profiler availability for the selected tenant via a\n * cheap nodes call (pageSize=1, counts=false \u2014 the backend computes the\n * flag regardless of counts), then re-renders the sidebar. Best-effort:\n * a failure leaves the surfaces hidden rather than erroring.\n */\n public async refreshNetworkAvailability(): Promise {\n const tenantId = storage.get('selectedTenantId') || this.userProfileStore.user?.tenantID || '';\n // Resolve once per tenant \u2014 updateCurrentPageUI() calls this on every\n // navigation, so skip the fetch when we already know this tenant.\n if (tenantId && tenantId === this.networkCheckedTenantId) return;\n this.networkCheckedTenantId = tenantId || null;\n const check = ++this.availabilityCheck;\n this.availabilityKnownTenantId = null;\n this.networkHasData = false;\n this.networkNodeIds = new Set();\n this.tenantHasADEngine = false;\n this.tenantHasADInventory = false;\n this.tenantHasADPermissions = false;\n this.tenantHasServiceChangeData = false;\n this.tenantHasSessionChangeData = false;\n // After the reset: the panel redraws without the last tenant's rows.\n window.dispatchEvent(new Event(SIDEBAR_AVAILABILITY_EVENT));\n if (tenantId) {\n let known = false;\n try {\n const resp = await apiFetch(`/api/tenants/${encodeURIComponent(tenantId)}/nodes?pageSize=1&counts=false`);\n // A newer check started meanwhile: it owns the flags and the\n // redraw.\n if (check !== this.availabilityCheck) return;\n if (resp.ok) {\n const body = await resp.json() as { hasNetworkData?: boolean; networkDataNodeIds?: string[]; hasAdEngine?: boolean; hasAdInventory?: boolean; hasAdPermissions?: boolean; hasServiceChangeData?: boolean; hasSessionChangeData?: boolean };\n if (check !== this.availabilityCheck) return;\n this.networkHasData = body.hasNetworkData === true;\n this.networkNodeIds = new Set(Array.isArray(body.networkDataNodeIds) ? body.networkDataNodeIds : []);\n this.tenantHasADEngine = body.hasAdEngine === true;\n this.tenantHasADInventory = body.hasAdInventory === true;\n this.tenantHasADPermissions = body.hasAdPermissions === true;\n this.tenantHasServiceChangeData = body.hasServiceChangeData === true;\n this.tenantHasSessionChangeData = body.hasSessionChangeData === true;\n known = true;\n }\n } catch (e) {\n if (check !== this.availabilityCheck) return;\n console.warn('[NavigationManager] tenant availability check failed', e);\n }\n // A failed check is tried again on the next navigation.\n if (known) this.availabilityKnownTenantId = tenantId;\n else this.networkCheckedTenantId = null;\n }\n this.updateSidebar(storage.get('sidebarState'), /*force=*/true);\n window.dispatchEvent(new Event(SIDEBAR_AVAILABILITY_EVENT));\n }\n\n /** Every entry the given sidebar view offers this user, before the\n * Sidebar panel's hidden entries are taken out. siteMap lists what the\n * view can hold rather than what the current selection shows (every\n * report page, both detail pages of Zammad and Drift, a node's Registry\n * Tree and Constellation), for Profile's Sidebar panel. */\n private buildMenuItems(view: SidebarViews, siteMap: boolean, forHome = false): SidebarMenuItem[] {\n const user = this.userProfileStore.user;\n const hasSupport = user?.hasSupportTools ?? false;\n const canCluster = hasSupport;\n const canCallers = user?.hasCallers ?? false;\n const canLogs = user?.hasLogs ?? false;\n const canDiskUsage = user?.hasSupportTools ?? false;\n const canSql = user?.hasSqlAccess ?? false;\n const canDeveloper = user?.hasDeveloperTools ?? false;\n const selectedReportId = storage.get('selectedReportId') || '';\n const selectedIncident: Incident | undefined = this.reportsStore.getAllLoaded()\n .find(incidentSummary => incidentSummary.id === selectedReportId);\n\n let menuItems: SidebarMenuItem[] = [];\n if (view === \"report\") {\n menuItems = [\n {\n name: t('nav.backTo', { name: t('nav.incidentReports') }),\n icon: 'arrow_back',\n page: 'reports',\n classList: 'material-icons'\n },\n ];\n // Use report flags to dynamically determine which pages to show\n // The site map lists every page a report can have.\n const flags = siteMap ? ~0 : (selectedIncident?.reportFlags ?? 0);\n\n if (hasFlag(flags, ReportFlags.ExecutiveSummary)) {\n menuItems.push({\n name: t('nav.execSummary'),\n icon: '',\n page: 'exec-summary',\n classList: 'icon-margin-right fa-solid fa-briefcase'\n });\n }\n\n if (hasFlag(flags, ReportFlags.Timeline)) {\n menuItems.push({\n name: t('nav.timeline'),\n icon: '',\n page: 'timeline',\n classList: 'icon-margin-right fa-solid fa-bars-staggered'\n });\n }\n\n if (hasFlag(flags, ReportFlags.Protections)) {\n menuItems.push({\n name: t('nav.protections'),\n icon: '',\n page: 'protections',\n classList: 'icon-margin-right fa-solid fa-lock'\n });\n }\n\n if (hasFlag(flags, ReportFlags.DirectoryTree)) {\n menuItems.push({\n name: t('nav.directoryTree'),\n icon: 'folder',\n page: 'directory-tree',\n classList: 'material-icons'\n });\n }\n\n if (hasFlag(flags, ReportFlags.RegistryTree)) {\n menuItems.push({\n name: t('nav.registryTree'),\n icon: '',\n page: 'registry-tree',\n classList: 'icon-margin-right fas fa-solid fa-key'\n });\n }\n\n if (hasFlag(flags, ReportFlags.ProcessTree)) {\n menuItems.push({\n name: t('nav.processTree'),\n icon: '',\n page: 'process-tree',\n classList: 'icon-margin-right fas fa-cog'\n });\n }\n\n if (hasFlag(flags, ReportFlags.Constellations)) {\n menuItems.push({\n name: t('nav.constellations'),\n icon: \"\",\n page: \"constellations\",\n classList: \"icon-margin-right custom-icon-nav custom-icon-constellation\",\n })\n }\n\n if (hasFlag(flags, ReportFlags.RansomwareInfo)) {\n menuItems.push({\n name: t('nav.ransomwareInfo'),\n icon: 'bug_report',\n page: 'ransomware-info',\n classList: 'material-icons'\n });\n }\n\n if (hasFlag(flags, ReportFlags.Artifacts)) {\n menuItems.push({\n name: t('nav.artifacts'),\n icon: 'history',\n page: 'artifacts',\n classList: 'material-icons'\n });\n }\n\n } else if (view === \"main\") {\n // show an immediate result based on loaded incidents\n const incidents = this.reportsStore.getAllLoaded();\n const { globalTimes: timesToDetect } = this.uiManager.timeToDetectUI.getTimeToDetect(incidents);\n // The home page's reckoning (forHome) lists every data-driven\n // entry (Time to Detect, Active Directory, Server Activity,\n // Network) whether or not the tenant's data is in yet: none can be\n // home, but each is the default neighbour a page the stored order\n // doesn't name is placed after, so a menu that grew when the\n // availability check answered would move home under a page\n // already landed on.\n const stable = forHome;\n // Data-driven; the site map lists it whether or not the data is in yet.\n const showTimeToDetect = siteMap || stable || timesToDetect.length !== 0;\n\n // Not for the home page's reckoning (forHome), which runs on\n // every resolveHomePage and must not fetch.\n if (showTimeToDetect === false && !forHome) {\n // start background check to see if any data was missed\n this.reportsStore.get((incidents) => {\n const { globalTimes: completeTimesToDetect } = this.uiManager.timeToDetectUI.getTimeToDetect(incidents);\n const completeShowTimeToDetect = completeTimesToDetect.length !== 0;\n if (completeShowTimeToDetect !== showTimeToDetect) {\n // if so, update the sidebar to reflect new data\n this.updateSidebar(\"main\", true);\n }\n })\n }\n\n // Only compared with create-report below, never home: no\n // resolveHomePage here, which reads this menu (forHome).\n const currentPage = storage.get('currentPage');\n\n\n // Main options when no report is selected\n // Labels via t() \u2014 resolved at menu-build time, which re-runs on\n // navigation / tenant change, so a page-load locale is honoured.\n menuItems = [\n { name: t('nav.nodes'), icon: '', page: 'nodes', classList: 'icon-margin-right fas fa-network-wired' },\n { name: t('nav.incidentReports'), icon: 'description', page: 'reports', classList: 'material-icons' },\n { name: t('nav.driftReports'), icon: 'compare_arrows', page: 'drift-reports', classList: 'material-icons' },\n { name: t('nav.changeReports'), icon: 'published_with_changes', page: 'change-reports', classList: 'material-icons' },\n { name: t('nav.events'), icon: 'insights', page: 'events', classList: 'material-icons' },\n { name: t('nav.aclSimulations'), icon: 'science', page: 'acl-simulations', classList: 'material-icons' },\n // X-Ray (DASH-1808): what the nodes ran, the\n // node a filter on the page, All Tenants for a viewer\n // with more than one tenant.\n { name: t('nav.componentProfile'), icon: '', page: 'component-profile', classList: 'icon-margin-right custom-icon-nav custom-icon-xray' },\n { name: t('nav.applications'), icon: '', page: 'applications', classList: 'icon-margin-right fas fa-solid fa-boxes' },\n { name: t('nav.executables'), icon: '', page: 'executables', classList: 'icon-margin-right bi bi-filetype-exe' },\n { name: t('nav.services'), icon: '', page: 'services', classList: 'icon-margin-right fas fa-cog' }\n ];\n\n // Tenant CVE exposure in patching terms (ENG-3594 data);\n // sits with the software-inventory pages it derives from.\n // GA \u2014 hasMissingPatches is true for every user on current\n // servers; the check survives for older-server compat.\n if (this.userProfileStore.user?.hasMissingPatches) {\n menuItems.push({\n name: t('nav.missingPatches'),\n icon: '',\n page: 'missing-patches',\n classList: 'icon-margin-right fa-solid fa-bandage'\n });\n }\n\n if (this.environmentStore.isInsightsEnabled\n && this.userProfileStore.user?.hasInsightsPage) {\n menuItems.splice(6, 0, { name: t('nav.insights'), icon: 'auto_awesome', page: 'insights', classList: 'material-icons' });\n // Onyx is pushed later \u2014 after Zammad, before Callers.\n }\n\n // ACL Simulations is an internal-only debugging tool \u2014 hide it\n // without hasAclSimulator (customers, and impersonated customer\n // sessions). The backend tenant/node super-admin route groups\n // are the real boundary; this just keeps it out of their nav.\n if (!this.userProfileStore.user?.hasAclSimulator) {\n const aclIdx = menuItems.findIndex(m => m.page === 'acl-simulations');\n if (aclIdx !== -1) menuItems.splice(aclIdx, 1);\n }\n\n // Active Directory \u2014 tenant-wide AD reports across every node in\n // the selected subtenant scope. Placed directly under Services.\n // Shown only when the tenant (incl. its subtenants) has at least\n // one AD-engine node (backend hasAdEngine), so it never dead-ends.\n if (stable || this.tenantHasADEngine) {\n const svcIdx = menuItems.findIndex(m => m.page === 'services');\n const adItem = { name: 'Active Directory', icon: 'domain', page: 'ad-summary' as AllPages, classList: 'material-icons' };\n if (svcIdx !== -1) menuItems.splice(svcIdx + 1, 0, adItem);\n else menuItems.push(adItem);\n }\n\n // Server Activity \u2014 general Windows-server telemetry (Service\n // Control Manager changes + userland session events) that used to\n // sit under Active Directory. Data-driven gate: shown only when the\n // tenant actually emits either record type, independent of\n // hasAdEngine (these fire on any monitored server, not just DCs).\n // Lands on whichever of the two the tenant has. Placed right after\n // Active Directory (or after Services when AD isn't present).\n if (stable || this.tenantHasServiceChangeData || this.tenantHasSessionChangeData) {\n const saLanding: AllPages = this.tenantHasServiceChangeData ? 'ad-service-change' : 'ad-session-change';\n const saItem = { name: t('nav.serverActivity'), icon: 'dns', page: saLanding, classList: 'material-icons' };\n const adIdx = menuItems.findIndex(m => m.page === 'ad-summary');\n const svcIdx2 = menuItems.findIndex(m => m.page === 'services');\n if (adIdx !== -1) menuItems.splice(adIdx + 1, 0, saItem);\n else if (svcIdx2 !== -1) menuItems.splice(svcIdx2 + 1, 0, saItem);\n else menuItems.push(saItem);\n }\n\n if (canCluster) {\n menuItems.push({ name: t('nav.clusterStats'), icon: '', page: 'cluster-stats', classList: 'icon-margin-right fas fa-chart-line' });\n }\n\n if (showTimeToDetect) {\n menuItems.push({ name: t('nav.timeToDetect'), icon: '', page: 'time-to-detect', classList: 'icon-margin-right fas fa-solid fa-stopwatch' });\n }\n\n // Notifications: gated by RoleNotifications (Support\n // inherits it). Customers no longer see the page unless the\n // role is explicitly granted. Admin-only actions inside the\n // page are still gated client-side in NotificationsUI.\n if (this.userProfileStore.user?.hasNotificationsPage) {\n menuItems.push({\n name: t('nav.notifications'),\n icon: '',\n page: 'notifications',\n classList: 'icon-margin-right fa-solid fa-bell'\n });\n }\n\n if (this.userProfileStore.user?.hasSupportTools && this.environmentStore.isIncidentioEnabled) {\n menuItems.push({\n name: t('nav.incidents'),\n icon: '',\n page: 'incidents',\n classList: 'icon-margin-right fa-solid fa-fire'\n });\n }\n\n if (this.userProfileStore.user?.hasSupportTools) {\n menuItems.push({\n name: t('nav.support'),\n icon: '',\n page: 'support',\n classList: 'icon-margin-right fa-solid fa-heart-pulse'\n });\n }\n\n if (this.userProfileStore.user?.hasSupportTools && this.environmentStore.isZammadEnabled) {\n // Single top-level entry; Tickets + Organizations are in-page tabs.\n menuItems.push({\n name: 'Zammad',\n icon: '',\n page: 'zammad',\n classList: 'icon-margin-right fa-solid fa-ticket'\n });\n }\n\n // Onyx \u2014 after Zammad, before Callers (support-gated, same\n // as Insights access). Matches the iOS user-persona order.\n if (this.environmentStore.isInsightsEnabled\n && this.userProfileStore.user?.hasInsightsPage\n && this.userProfileStore.user?.hasSupportTools) {\n menuItems.push({ name: 'Onyx', icon: 'menu_book', page: 'onyx', classList: 'material-icons' });\n }\n\n if (canCallers) {\n menuItems.push({\n name: t('nav.callers'),\n icon: '',\n page: 'callers',\n classList: 'icon-margin-right fas fa-solid fa-phone'\n });\n }\n\n if (canLogs) {\n menuItems.push({\n name: t('nav.logs'),\n icon: '',\n page: 'logs',\n classList: 'icon-margin-right fas fa-solid fa-table-list'\n });\n }\n\n if (canDiskUsage) {\n menuItems.push({\n name: t('nav.diskUsage'),\n icon: '',\n page: 'disk-usage',\n classList: 'icon-margin-right fas fa-solid fa-hdd'\n });\n }\n\n // CH Backups (admin-only). Always visible to admins \u2014\n // no toggle, this is operational tooling that admins\n // need at hand. Server-side RBAC is the gate that\n // matters; this just hides it from non-admins.\n if (this.userProfileStore.user?.isDashcamAdmin) {\n menuItems.push({\n name: t('nav.backups'),\n icon: '',\n page: 'backups',\n classList: 'icon-margin-right fas fa-solid fa-database'\n });\n }\n\n // Tenant Profiling \u2014 gated by the hasTenantProfiling\n // capability (mirrors the server's finance-or-internal read\n // gate). The page still scopes its\n // data to the caller's entitled tenants.\n if (this.userProfileStore.user?.hasTenantProfiling) {\n menuItems.push({\n name: t('nav.tenantProfiling'),\n icon: '',\n page: 'tenant-profiling',\n classList: 'icon-margin-right fas fa-solid fa-shield-halved'\n });\n }\n\n // Releases \u2014 gated to support so customers don't see it.\n // Also requires the env to have a provisioned NODE_RELEASES_TOKEN\n // (any env where the secret was destroyed via the seed script\n // returns false here and the entry stays hidden). Placed\n // under Tenant Profiling since both are cross-tenant ops views.\n if (this.environmentStore.isNodeReleasesEnabled\n && this.userProfileStore.user?.hasSupportTools) {\n menuItems.push({\n name: t('nav.releases'),\n icon: '',\n page: 'release-status',\n classList: 'icon-margin-right fa-solid fa-tag'\n });\n }\n\n // Network \u2014 fleet network-flow rollups\n // (RECORD_TYPE_NETWORK_PROFILER). Shown only when the selected\n // tenant actually has data (backend hasNetworkData), so it\n // doesn't dead-end on an empty page. Placed after Releases to\n // match the iOS tab order.\n if (stable || this.networkHasData) {\n menuItems.push({\n name: t('nav.network'),\n icon: 'lan',\n page: 'network-profiler',\n classList: 'material-icons'\n });\n }\n\n\n // Node Monitoring \u2014 per-node CPU/memory gauges pulled from\n // mgmt-plane GMP (ENG-2248). Gated on the server-computed\n // hasNodeHealth capability, which mirrors the /api/node-health\n // role gate: RoleNodeHealth, which RoleInternal and every staff\n // role carry. The handler additionally scopes to the caller's\n // entitled tenants, so reaching the page is not reaching more\n // data.\n //\n // Its default place; a user can move it on Profile's Sidebar\n // tab (orderSidebar); the first landing page in that order is home.\n if (this.userProfileStore.user?.hasNodeHealth) {\n menuItems.push({\n name: t('nav.nodeHealth'),\n icon: '',\n page: 'node-health',\n classList: 'icon-margin-right fa-solid fa-heart-pulse'\n });\n }\n\n // Tenant Health \u2014 per-tenant composite score, event/restart\n // trends with anomaly flags, patch posture (ENG-3786).\n // Internal-only while the feature bakes (hasTenantHealth\n // mirrors the RoleInternal gate on its route, same contract\n // as Missing Patches above).\n if (this.userProfileStore.user?.hasTenantHealth) {\n menuItems.push({\n name: t('nav.tenantHealth'),\n icon: '',\n page: 'tenant-health',\n classList: 'icon-margin-right fa-solid fa-stethoscope'\n });\n }\n\n // Analytics (ENG-4722): one entry that opens the BI pages'\n // own sub-list (Discover, Visualize, Dashboards, SQL), the way\n // Marketing does, landing on Discover. The analytics role opens\n // it; the SQL page inside it needs sqlviewer as well. Its default\n // place is right under Events, the data it queries.\n if (this.userProfileStore.user?.hasAnalytics) {\n const analyticsItem = {\n name: t('nav.analytics'),\n icon: '',\n page: 'discover' as AllPages,\n classList: 'icon-margin-right fa-solid fa-chart-pie'\n };\n const eventsIdx = menuItems.findIndex(m => m.page === 'events');\n if (eventsIdx !== -1) menuItems.splice(eventsIdx + 1, 0, analyticsItem);\n else menuItems.push(analyticsItem);\n }\n\n // Benchmark Compliance \u2014 per-control standing against every\n // imported DISA STIG / CIS catalog (ENG-3835). Its own\n // capability, not Tenant Health's: RoleBenchmark opens the\n // page to customer-side readers without RoleInternal.\n if (this.userProfileStore.user?.hasBenchmarkCompliance) {\n menuItems.push({\n name: t('nav.benchmarkCompliance'),\n icon: '',\n page: 'benchmark-compliance',\n classList: 'icon-margin-right fa-solid fa-list-check'\n });\n }\n\n // Known Exploited Vulnerabilities \u2014 CISA's catalog enriched\n // with an exploitation class. Staff, plus the customer\n // accounts running vendor vulnerability feeds with us: the\n // data is public, and the per-tenant cross-reference the\n // entry reads as is bounded to the caller's own tenant.\n if (this.userProfileStore.user?.hasKevCatalog) {\n menuItems.push({\n name: t('nav.kev'),\n icon: '',\n page: 'kev',\n classList: 'icon-margin-right fa-solid fa-bug'\n });\n }\n\n // (Fleet Patch Status folded into Missing Patches \u2014 the\n // fleet figures are its All Tenants view now, so the page\n // keeps a single sidebar entry up in the software-inventory\n // group.)\n\n // Marketing \u2014 the pages about the company's own public\n // presence, not tenant data: AI Visibility (ENG-4037), Site\n // Traffic and Careers (ENG-4695), and LinkedIn Posts (ENG-4687). One entry that opens its own\n // sub-page list, the way Nodes does. Shown to anyone who may\n // open at least one of its pages, and it lands on the first\n // they may: each page keeps its own gate, matching the\n // RequireRole(Marketing) gates on the two API groups.\n // Placed directly under Missing Patches rather than pushed to\n // the end, where AI Visibility sat. Spliced by index, not\n // moved up in this file, so it lands after Missing Patches\n // whatever else has been inserted above it by now.\n const marketingLanding = this.marketingLanding();\n if (marketingLanding) {\n const avItem = {\n name: t('nav.marketing'),\n icon: '',\n page: marketingLanding,\n classList: 'icon-margin-right fa-solid fa-bullhorn'\n };\n const mpIdx = menuItems.findIndex(m => m.page === 'missing-patches');\n if (mpIdx !== -1) menuItems.splice(mpIdx + 1, 0, avItem);\n else menuItems.push(avItem);\n }\n\n // Developer (RoleDeveloper only)\n if (canDeveloper) {\n menuItems.push({\n name: t('nav.developer'),\n icon: '',\n page: 'developer',\n classList: 'icon-margin-right fas fa-code'\n });\n }\n\n // Knowledge Base: every signed-in user. One entry that opens its\n // own sub-page list, the way Marketing does, landing on\n // Documentation; what each reader reads there is the server's\n // visibility rule. The editors' pages are listed inside it.\n menuItems.push({\n name: t('nav.knowledgeBase'),\n icon: 'library_books',\n page: 'knowledge',\n classList: 'material-icons'\n });\n\n if (!siteMap && currentPage === 'create-report') {\n menuItems.push({\n name: t('nav.creatingReport'),\n icon: 'post_add',\n page: 'create-report',\n classList: 'material-icons'\n });\n }\n\n\n } else if (view === \"zammad\") {\n // Detail pages only (the list page lives under the main sidebar).\n // \"Back to Zammad\" returns to the unified page, which restores the\n // tab the detail was opened from.\n const currentPage = (storage.get('currentPage') || 'zammad') as AllPages;\n const orgDetail = { name: t('nav.organizationDetails'), icon: '', classList: 'icon-margin-right fa-solid fa-building', page: 'zammad-organization-details' as AllPages };\n const ticketDetail = { name: t('nav.ticketDetails'), icon: '', classList: 'icon-margin-right fa-solid fa-ticket', page: 'zammad-ticket-details' as AllPages };\n // The site map lists both; the sidebar, the one being shown.\n const details = siteMap ? [ticketDetail, orgDetail]\n : [currentPage === 'zammad-organization-details' ? orgDetail : ticketDetail];\n menuItems = [\n {\n name: t('nav.backTo', { name: 'Zammad' }),\n icon: 'arrow_back',\n page: 'zammad',\n classList: 'material-icons'\n },\n ...details,\n ];\n } else if (view === \"node\") {\n const nodeOsType = this.selectedNodeOsType();\n const registryCapable = siteMap || nodeOsType === '' || nodeOsType === 'windows';\n const constellationCapable = siteMap || this.selectedNodeHasActivity();\n menuItems = [\n {\n name: t('nav.backTo', { name: t('nav.nodes') }), // label\n icon: 'arrow_back', // Material-icons arrow\n page: 'nodes', // go to list\n classList: 'material-icons'\n },\n {\n name: t('nav.applications'),\n icon: '',\n page: 'node-applications',\n classList: 'icon-margin-right fas fa-solid fa-boxes'\n },\n {\n name: t('nav.executables'),\n icon: \"\",\n page: \"node-executables\",\n classList: \"icon-margin-right bi bi-filetype-exe\"\n },\n {\n name: t('nav.services'),\n icon: '',\n page: 'node-services',\n classList: 'icon-margin-right fas fa-cog'\n },\n // Node-level tree views \u2014 same icons as the\n // incident-report sidebar (Directory Tree =\n // material-icons \"folder\", Registry Tree =\n // material-icons \"build\"). These show the full CH\n // treescan tree of the node, optionally with an\n // event-activity overlay scoped to a time window.\n {\n name: t('nav.directoryTree'),\n icon: 'folder',\n page: 'node-directory-tree',\n classList: 'material-icons'\n },\n // Registry Tree is Windows-only: a Linux node has no\n // registry, so the page could only ever render an empty\n // \\REGISTRY root. While the OS is still unresolved the\n // entry stays \u2014 hiding a real page is worse than briefly\n // showing a dead one.\n ...(registryCapable ? [{\n name: t('nav.registryTree'),\n icon: '',\n page: 'node-registry-tree' as AllPages,\n classList: 'icon-margin-right fas fa-solid fa-key'\n }] : []),\n {\n name: t('nav.liveEvents'),\n icon: 'cell_tower',\n page: 'node-events',\n classList: 'material-icons'\n },\n // Node Configs is internal-only: applied-config provenance\n // is staff detail, and the route now sits in the\n // nodeInternal group (RequireRole(RoleInternal) on top of\n // RequireTenant). Hide the entry rather than leave a link\n // that 403s. hasNodeConfigSource is the same RoleInternal\n // the route checks, so nav and backend agree.\n ...(this.userProfileStore.user?.hasNodeConfigSource ? [{\n name: t('nav.nodeConfigs'),\n icon: '',\n page: 'node-configs' as AllPages,\n classList: 'icon-margin-right fas fa-solid fa-sliders'\n }] : []),\n {\n name: t('nav.treeScans'),\n icon: '',\n page: 'treescans',\n classList: 'icon-margin-right fas fa-solid fa-folder-tree'\n },\n // Same icon classes as the report sidebar's Constellations\n // entry: custom-icon-nav is the mask-based variant that\n // picks up the sidebar's active/inactive filters.\n // color-icon-constellation is the header's background-image\n // variant and renders nothing here.\n ...(constellationCapable ? [{\n name: t('nav.constellations'),\n icon: '',\n page: 'node-constellation' as AllPages,\n classList: 'icon-margin-right custom-icon-nav custom-icon-constellation'\n }] : []),\n {\n name: t('nav.systemInfo'),\n icon: '',\n page: 'system-info',\n classList: 'icon-margin-right fas fa-solid fa-circle-info'\n }\n ]\n } else if (view === \"tenant-ad\") {\n // Tenant-wide Active Directory: every report aggregated across\n // the selected subtenant scope (with an optional per-node\n // filter). Back goes to the tenant AD tiles landing.\n menuItems = [\n {\n name: t('nav.backTo', { name: 'Active Directory' }),\n icon: 'arrow_back',\n page: 'ad-summary',\n classList: 'material-icons'\n },\n { name: t('nav.adEventCatalog'), icon: 'policy', page: 'ad-event-hourly', classList: 'material-icons' },\n { name: t('nav.adScheduledTask'), icon: 'schedule', page: 'ad-scheduled-task', classList: 'material-icons' },\n { name: t('nav.adKerberos'), icon: 'vpn_key', page: 'ad-kerberos-ticket', classList: 'material-icons' },\n { name: t('nav.adDcSync'), icon: 'sync', page: 'ad-dc-sync', classList: 'material-icons' },\n { name: t('nav.adDomainControllers'), icon: 'dns', page: 'ad-domain-controllers', classList: 'material-icons' },\n // Forest Inventory only once a DC's active_directory_cron has reported (data-gated, like Server Activity).\n ...(this.tenantHasADInventory ? [{ name: t('nav.adInventory'), icon: 'account_tree', page: 'ad-inventory' as AllPages, classList: 'material-icons' }] : []),\n // Permissions only once a DC's active_directory_permissions_cron has reported (data-gated, like Forest Inventory).\n ...(this.tenantHasADPermissions ? [{ name: t('nav.adPermissions'), icon: 'admin_panel_settings', page: 'ad-permissions' as AllPages, classList: 'material-icons' }] : []),\n { name: t('nav.adLdapChanges'), icon: 'edit_note', page: 'ad-ldap-change', classList: 'material-icons' },\n { name: t('nav.adGroupChanges'), icon: 'groups', page: 'ad-group-change', classList: 'material-icons' },\n { name: t('nav.adUsnChanges'), icon: 'track_changes', page: 'ad-usn-change', classList: 'material-icons' }\n ];\n } else if (view === \"analytics\") {\n // The BI pages (ENG-4722): the analytics role opens them, and\n // SQL is listed only to sqlviewer. The way out goes to the\n // main menu's first page (mainMenuPage), as for Marketing.\n menuItems = [\n {\n name: t('nav.backTo', { name: t('nav.mainMenu') }),\n icon: 'arrow_back',\n page: this.mainMenuPage(),\n classList: 'material-icons'\n },\n ];\n if (this.userProfileStore.user?.hasAnalytics) {\n menuItems.push(\n { name: t('nav.discover'), icon: '', page: 'discover', classList: 'icon-margin-right fa-solid fa-compass' },\n { name: t('nav.visualize'), icon: '', page: 'visualize', classList: 'icon-margin-right fa-solid fa-chart-simple' },\n { name: t('nav.dashboards'), icon: '', page: 'dashboards', classList: 'icon-margin-right fa-solid fa-table-cells-large' },\n { name: t('nav.savedObjects'), icon: '', page: 'saved-objects', classList: 'icon-margin-right fa-solid fa-box-archive' },\n );\n }\n if (this.userProfileStore.user?.hasSqlAccess) {\n menuItems.push({ name: 'SQL', icon: '', page: 'sql', classList: 'icon-margin-right fas fa-solid fa-database' });\n }\n } else if (view === \"marketing\") {\n // Marketing's sub-pages. Each is listed only to someone who\n // may open it. The way out goes to the main menu's first page\n // (mainMenuPage): there is no Marketing list page to return to.\n menuItems = [\n {\n name: t('nav.backTo', { name: t('nav.mainMenu') }),\n icon: 'arrow_back',\n page: this.mainMenuPage(),\n classList: 'material-icons'\n },\n ];\n if (this.userProfileStore.user?.hasAiVisibility) {\n menuItems.push({\n name: t('nav.aiVisibility'), icon: '', page: 'ai-visibility',\n classList: 'icon-margin-right fa-solid fa-magnifying-glass-chart'\n });\n menuItems.push({\n name: t('nav.siteTraffic'), icon: '', page: 'site-traffic',\n classList: 'icon-margin-right fa-solid fa-chart-line'\n });\n menuItems.push({\n name: t('nav.careers'), icon: '', page: 'careers',\n classList: 'icon-margin-right fa-solid fa-briefcase'\n });\n }\n if (this.userProfileStore.user?.hasLinkedInPosts) {\n menuItems.push({\n name: t('nav.linkedinPosts'), icon: '', page: 'linkedin-posts',\n classList: 'icon-margin-right fa-brands fa-linkedin'\n });\n }\n if (this.userProfileStore.user?.hasConfirmedAttacks) {\n menuItems.push({\n name: t('nav.confirmedAttacks'), icon: '', page: 'confirmed-attacks',\n classList: 'icon-margin-right fa-solid fa-shield-halved'\n });\n }\n } else if (view === \"knowledge\") {\n // Documentation for everyone; the Editor, Sources and Feedback\n // to the knowledge base's editors (hasKnowledgeEditor, the\n // RoleDocumentation and RoleInternal on their routes, off in Demo mode). The way out\n // goes to the main menu's first page (mainMenuPage), never back\n // to Documentation when that is home.\n menuItems = [\n {\n name: t('nav.backTo', { name: t('nav.mainMenu') }),\n icon: 'arrow_back',\n page: this.mainMenuPage(),\n classList: 'material-icons'\n },\n ...knowledgeSubPages(!!this.userProfileStore.user?.hasKnowledgeEditor).map(p => this.knowledgeMenuItem(p)),\n ];\n } else if (view === \"server-activity\") {\n // Server Activity \u2014 general Windows-server telemetry pulled out of\n // Active Directory. Each item is shown per data presence (a tenant\n // can have one record type but not the other \u2014 e.g. sessions but no\n // service changes). If the availability flags haven't resolved yet\n // (cold deep-link before the probe returns), fall back to showing\n // both so the section is never empty.\n menuItems = [\n {\n name: t('nav.backTo', { name: t('nav.nodes') }),\n icon: 'arrow_back',\n page: 'nodes',\n classList: 'material-icons'\n },\n ];\n const flagsUnknown = !this.tenantHasServiceChangeData && !this.tenantHasSessionChangeData;\n if (this.tenantHasServiceChangeData || flagsUnknown) {\n menuItems.push({ name: t('nav.serviceChanges'), icon: 'settings', page: 'ad-service-change', classList: 'material-icons' });\n }\n if (this.tenantHasSessionChangeData || flagsUnknown) {\n menuItems.push({ name: t('nav.sessionChanges'), icon: 'meeting_room', page: 'ad-session-change', classList: 'material-icons' });\n }\n } else if (view === \"drift-reports\") {\n const currentPage = (storage.get('currentPage') || 'drift-reports') as AllPages;\n\n if (siteMap || onPage(DRIFT_REPORT_PAGES, currentPage)) {\n const diversity: SidebarMenuItem = {\n name: t('nav.diversityReport'),\n icon: '',\n page: 'diversity-report',\n classList: 'icon-margin-right fas fa-network-wired'\n };\n const overTime: SidebarMenuItem = {\n name: t('nav.driftOverTime'),\n icon: 'schedule',\n page: 'drift-report-time-range',\n classList: 'material-icons'\n };\n // The site map lists both; the sidebar, the selected report's kind.\n const details = siteMap ? [overTime, diversity]\n : [storage.getScoped('selectedDriftReportKind') === 'diversity' ? diversity : overTime];\n menuItems = [\n {\n name: t('nav.backTo', { name: t('nav.driftReports') }),\n icon: 'arrow_back',\n page: 'drift-reports',\n classList: 'material-icons'\n },\n ...details,\n ];\n } else {\n menuItems = [\n {\n name: t('nav.driftReports'),\n icon: 'compare_arrows',\n page: 'drift-reports',\n classList: 'material-icons'\n },\n ];\n }\n } else if (view === 'change-reports') {\n menuItems = [\n {\n name: t('nav.backTo', { name: t('nav.changeReports') }),\n icon: 'arrow_back',\n page: 'change-reports',\n classList: 'material-icons'\n },\n ];\n } else if (view === 'acl-simulations') {\n // Minimal sidebar for acl simulation subpages\n menuItems = [\n {\n name: t('nav.backTo', { name: t('nav.aclSimulations') }), // label\n icon: 'arrow_back',\n page: 'acl-simulations',\n classList: 'material-icons'\n },\n // Optional quick-links for the three subpages:\n { name: t('nav.aclSummary'), icon: 'assignment', page: 'acl-simulation-event-summary', classList: 'material-icons' },\n { name: t('nav.ruleSummary'), icon: 'list_alt', page: 'acl-simulation-rule-summary', classList: 'material-icons' },\n { name: t('nav.ruleTree'), icon: 'account_tree', page: 'acl-simulation-rule-tree', classList: 'material-icons' },\n ];\n } else if (view === 'acl-simulation-delta') {\n // Minimal sidebar for acl simulation delta page\n menuItems = [\n {\n name: t('nav.backTo', { name: t('nav.aclSimulations') }), // label\n icon: 'arrow_back',\n page: 'acl-simulations',\n classList: 'material-icons'\n },\n { name: t('nav.configDelta'), icon: 'compare_arrows', page: 'config-delta', classList: 'material-icons' },\n { name: t('nav.eventSummaryDelta'), icon: 'compare_arrows', page: 'acl-simulation-event-summary-delta', classList: 'material-icons' },\n { name: t('nav.ruleSummaryDelta'), icon: 'compare_arrows', page: 'acl-simulation-rule-summary-delta', classList: 'material-icons' },\n { name: t('nav.ruleTreeDelta'), icon: 'compare_arrows', page: 'acl-simulation-rule-tree-delta', classList: 'material-icons' },\n ];\n } else if (view === 'smb-reports') {\n // The SMB Reports list page was removed; both detail pages\n // are now opened directly from the Nodes page (kebab +\n // multi-select). The sidebar still groups them under the\n // 'smb-reports' view name so navigation between Network\n // and File I/O works.\n menuItems = [\n {\n name: t('nav.backTo', { name: t('nav.nodes') }),\n icon: 'arrow_back',\n page: 'nodes',\n classList: 'material-icons'\n },\n {\n name: t('nav.networkReport'),\n icon: '',\n page: 'smb-network-report',\n classList: 'icon-margin-right fas fa-solid fa-network-wired'\n },\n {\n name: t('nav.fileIoReport'),\n icon: '',\n page: 'smb-file-io-report',\n classList: 'icon-margin-right fas fa-solid fa-file-lines'\n }\n ];\n }\n\n /* \u2500\u2500 SQL show in all menus and submenus if authorized \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n // Not in the main menu, where Analytics now holds it, nor in\n // Analytics itself, which lists it already, nor in the Knowledge\n // Base's, which holds only its own pages.\n if (canSql && view !== 'main' && view !== 'analytics' && view !== 'knowledge') {\n menuItems.push({\n name: 'SQL',\n icon: '',\n page: 'sql',\n classList: 'icon-margin-right fas fa-solid fa-database'\n });\n }\n\n return menuItems;\n }\n\n /** The entries the sidebar shows for a view: buildMenuItems less the\n * entries hidden on Profile's Sidebar panel. */\n private visibleMenuItems(view: SidebarViews): SidebarMenuItem[] {\n const hidden = hiddenSidebarEntries();\n const home = resolveHomePage();\n return this.orderedMenuItems(view, false).filter(item =>\n isAlwaysShown(view, item, home) || !hidden.has(sidebarEntryKey(view, item.page)));\n }\n\n /** buildMenuItems in the user's order (orderSidebar). Home is the first\n * landing page in that order (resolveHomePage); only a legacy home page\n * is hoisted (orderedEntries). */\n private orderedMenuItems(view: SidebarViews, siteMap: boolean): SidebarMenuItem[] {\n const items = this.buildMenuItems(view, siteMap);\n if (view !== 'main') return orderSidebar(view, items);\n // Placed as in the menu the home page is worked out over (forHome),\n // so the page at the top is the page landed on.\n return orderSidebarOver(view, this.buildMenuItems(view, siteMap, true), items);\n }\n\n /** The sub-sidebars a main-sidebar entry leads into. */\n private subViewsOf(page: AllPages): SidebarViews[] {\n switch (page) {\n case 'reports': return ['report', 'explorer-report'];\n case 'nodes': return ['node', 'smb-reports'];\n case 'drift-reports': return ['drift-reports'];\n case 'change-reports': return ['change-reports'];\n case 'acl-simulations': return ['acl-simulations', 'acl-simulation-delta'];\n case 'ad-summary': return ['tenant-ad'];\n // The detail pages render their own views (gotoPage), SQL only.\n case 'zammad': return ['zammad', 'zammad-organizations', 'zammad-tickets'];\n }\n // The entries that land on a page inside their own sub-sidebar.\n if (onPage(TENANT_SERVER_ACTIVITY_PAGES, page)) return ['server-activity'];\n if (onPage(MARKETING_PAGES, page)) return ['marketing'];\n if (onPage(KNOWLEDGE_PAGES, page)) return ['knowledge'];\n if (onPage(ANALYTICS_PAGES, page)) return ['analytics'];\n return [];\n }\n\n /** Every sidebar entry this user may open, as the Sidebar panel on\n * Profile lists it, in the user's order: the main sidebar's entries,\n * each with the sub-sidebars it leads into. \"Back to \u2026\" entries are left out: they\n * are never hidden. */\n public sidebarSiteMap(): SiteMapEntry[] {\n const home = resolveHomePage();\n // The home page is locked on only when it is the last page that could\n // be home: hiding it would leave the sidebar nowhere to land.\n const lastHome = homePageCandidates().every(p => p === home);\n const withoutBack = (items: SidebarMenuItem[]) => items.filter(i => i.icon !== 'arrow_back');\n return this.orderedMenuItems('main', true).map(item => {\n const children: SiteMapEntry[] = [];\n for (const sub of this.subViewsOf(item.page)) {\n for (const child of withoutBack(this.orderedMenuItems(sub, true))) {\n const key = sidebarEntryKey(sub, child.page);\n const same = children.find(c => c.item.page === child.page);\n if (same) same.keys.push(key);\n else children.push({ keys: [key], item: child, locked: false, home: false, children: [] });\n }\n }\n return {\n keys: [sidebarEntryKey('main', item.page)],\n item,\n locked: lastHome && isAlwaysShown('main', item, home),\n home: item.page === home,\n children,\n };\n });\n }\n\n public updateSidebar(view: SidebarViews, force = false): void {\n const sidebar = document.getElementById('sidebar-menu');\n\n // The small cluster chart under the sidebar goes with the Cluster\n // Stats entry: shown to support when that entry isn't hidden.\n const showCluster = (this.userProfileStore.user?.hasSupportTools ?? false)\n && !hiddenSidebarEntries().has(sidebarEntryKey('main', 'cluster-stats'));\n\n if (!sidebar) {\n console.error(\"Sidebar element not found!\");\n return;\n }\n\n this.page = storage.get('currentPage') || resolveHomePage();\n const selectedReportId = storage.get('selectedReportId') || '';\n const incidentSummaries = this.reportsStore.getAllLoaded()\n\n const selectedIncident: Incident | undefined = incidentSummaries.find(incidentSummary => incidentSummary.id === selectedReportId);\n if (selectedIncident) {\n storage.set('selectedReportName', selectedIncident.name);\n }\n\n console.debug('selectedIncident', selectedIncident);\n\n // Store the current state\n const previousState = storage.get('sidebarState');\n\n // Only update if the state has changed\n if (view !== previousState || force) {\n storage.set('sidebarState', view);\n sidebar.innerHTML = ''; // Clear existing sidebar content\n\n this.addEnvBadge(); // Add environment badge\n this.addMaskModeBadge();\n this.addTenantSwitcher();\n\n const menuItems = this.visibleMenuItems(view);\n\n menuItems.forEach(item => {\n const link = document.createElement('a');\n // Intentionally no href: navigation is driven by the data-page\n // click handler in EventHandlers. A bare href=\"#\" resolves\n // against the current SPA URL, so the browser status bar shows\n // the full ?tenantId=...&page=... query on hover. Dropping the\n // href removes that; role/tabindex/keydown preserve the link's\n // focusability and keyboard activation.\n link.className = 'list-group-item list-group-item-action bg-light';\n link.style.cursor = 'pointer';\n link.setAttribute('role', 'button');\n link.tabIndex = 0;\n link.setAttribute('data-page', item.page);\n link.addEventListener('keydown', (e: KeyboardEvent) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n this.gotoPage(item.page);\n }\n });\n link.innerHTML = `\n \n ${item.icon}\n \n ${item.name}\n `;\n sidebar.appendChild(link);\n });\n\n this.addProfileElement();\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n Sidebar chart (create once, survive sidebar rebuilds)\n ----------------------------------------------------------------*/\n const slot = document.getElementById('sidebarClusterStats');\n if (slot) {\n if (showCluster) { // \u2705 admin \u2192 ensure chart exists\n if (!this.sidebarChart) {\n this.sidebarChart = new ClusterStatsChart(\n slot,\n StoreManager.getInstance().clusterStatsStore\n );\n }\n } else { // \uD83D\uDEAB non-admin \u2192 remove it\n this.sidebarChart?.destroy();\n this.sidebarChart = null;\n slot.innerHTML = ''; // clear the DOM node\n }\n }\n\n this.updateSidebarSelection();\n }\n }\n\n public handleFilterButton(target: Element): void {\n // Handle filter button actions here\n console.log('Filter button clicked');\n\n // Extract the ID of the dropdown to toggle from the clicked button's data attribute\n const filterId = target.closest('.filter-btn').getAttribute('data-filter');\n }\n\n /** ------------------------------------------------------------------\n * Light\u2011weight SPA navigation helper.\n * \u2013 Calculates the sidebar \u201Cview\u201D\n * \u2013 Pushes/updates URL (unless push === false)\n * \u2013 Injects the HTML fragment\n * \u2013 Waits until the page\u2019s primary store signals \u201Cloaded\u201D\n * \u2013 Then calls UIManager..updateUI() + updateCurrentPageUI()\n * ------------------------------------------------------------------ */\n /** The Admin page is reachable by anyone holding at least one of its\n * tabs, not by admins alone: a TAM comes for MSSPs or Vulnerability\n * Sources, a marketing user for Site Access, support for Performance. The flags already fold in\n * RoleAdmin server-side, so this\n * stays a pure capability question \u2014 AdminUI then hides every tab the\n * user does not hold. */\n private canReachAdminPage(): boolean {\n const u = this.userProfileStore.user;\n return (u?.hasMsspAdmin ?? false) || (u?.hasSiteAccess ?? false)\n || (u?.hasTenantSubscriptions ?? false) || (u?.hasVulnSources ?? false)\n || (u?.hasApiPerf ?? false);\n }\n\n /** `params` are set on the new page's URL (pushed navigations only):\n * how a link opens a page on a given item without writing that item\n * into the page it leaves (whose history entry would keep it).\n * historyDelta is how far Back or Forward moved (historyMoved), for the\n * leave guard; a navigation that is not one leaves it null. */\n public gotoPage(page: AllPages, push = true, view?: SidebarViews, params?: Record, historyDelta: number | null = null): void {\n if (this.leaveGuard?.(() => this.gotoPage(page, push, view, params), !push, historyDelta)) return;\n\n // Legacy alias (see updateCurrentPageUI): cve-fleet is the merged\n // Missing Patches page's All Tenants view now.\n if (page === ('cve-fleet' as AllPages)) page = 'missing-patches';\n // Legacy alias (see updateCurrentPageUI): Confirmed Attacks' old id.\n if (page === ('confirmed-ransomware' as AllPages)) page = 'confirmed-attacks';\n // Retired pages (see updateCurrentPageUI): a history entry for one,\n // reached by Back, opens the home page.\n if (isRetiredPage(page)) {\n page = resolveHomePage();\n view = undefined;\n params = undefined;\n }\n // Legacy alias: the node X-Ray is the tenant page now,\n // filtered to the node (a caller's own ?node= wins).\n if (page === 'node-component-profile') {\n page = 'component-profile';\n view = undefined;\n const nodeId = String(storage.get('selectedNodeId') ?? '').trim();\n params = { ...(nodeId ? { node: nodeId } : {}), ...(params ?? {}) };\n }\n // Retired tracker pages go to the home page (see updateCurrentPageUI).\n if (isRetiredTrackerPage(page)) page = resolveHomePage();\n\n // Enforce permission for support/developer-only pages.\n // Node Releases requires support-role so customers don't see it, plus\n // a provisioned NODE_RELEASES_TOKEN on the env.\n if (page === 'release-status'\n && (!this.environmentStore.isNodeReleasesEnabled\n || !this.userProfileStore.user?.hasSupportTools)) {\n storage.set('currentPage', 'forbidden');\n this.updateSidebar('main', /*force=*/ true);\n const url = new URL(location.origin + location.pathname);\n url.search = '';\n url.searchParams.set('page', 'forbidden');\n navigate({ params: url.searchParams, data: { page: 'forbidden', resolvedView: 'main' }, pushState: true });\n if (this.eventHandlerManager) {\n this.eventHandlerManager.loadContent('forbidden', () => this.updateCurrentPageUI());\n }\n return;\n }\n if (page === 'node-health' && !this.userProfileStore.user?.hasNodeHealth) {\n storage.set('currentPage', 'forbidden');\n this.updateSidebar('main', /*force=*/ true);\n const url = new URL(location.origin + location.pathname);\n url.search = '';\n url.searchParams.set('page', 'forbidden');\n navigate({ params: url.searchParams, data: { page: 'forbidden', resolvedView: 'main' }, pushState: true });\n if (this.eventHandlerManager) {\n this.eventHandlerManager.loadContent('forbidden', () => this.updateCurrentPageUI());\n }\n return;\n }\n if ((page === 'onyx' || page === 'support') && !this.userProfileStore.user?.hasSupportTools) {\n storage.set('currentPage', 'forbidden');\n this.updateSidebar('main', /*force=*/ true);\n const url = new URL(location.origin + location.pathname);\n url.search = '';\n url.searchParams.set('page', 'forbidden');\n navigate({ params: url.searchParams, data: { page: 'forbidden', resolvedView: 'main' }, pushState: true });\n if (this.eventHandlerManager) {\n this.eventHandlerManager.loadContent('forbidden', () => this.updateCurrentPageUI());\n }\n return;\n }\n if (page === 'insights'\n && (!this.environmentStore.isInsightsEnabled\n || !this.userProfileStore.user?.hasInsightsPage)) {\n storage.set('currentPage', 'forbidden');\n this.updateSidebar('main', /*force=*/ true);\n const url = new URL(location.origin + location.pathname);\n url.search = '';\n url.searchParams.set('page', 'forbidden');\n navigate({ params: url.searchParams, data: { page: 'forbidden', resolvedView: 'main' }, pushState: true });\n if (this.eventHandlerManager) {\n this.eventHandlerManager.loadContent('forbidden', () => this.updateCurrentPageUI());\n }\n return;\n }\n if (AI_VISIBILITY_PAGES.includes(page) && !this.userProfileStore.user?.hasAiVisibility) {\n storage.set('currentPage', 'forbidden');\n this.updateSidebar('main', /*force=*/ true);\n const url = new URL(location.origin + location.pathname);\n url.search = '';\n url.searchParams.set('page', 'forbidden');\n history.replaceState({}, '', url.toString());\n if (this.eventHandlerManager) {\n this.eventHandlerManager.loadContent('forbidden', () => this.updateCurrentPageUI());\n }\n return;\n }\n if ((page === 'linkedin-posts' && !this.userProfileStore.user?.hasLinkedInPosts)\n || (page === 'confirmed-attacks' && !this.userProfileStore.user?.hasConfirmedAttacks)) {\n storage.set('currentPage', 'forbidden');\n this.updateSidebar('main', /*force=*/ true);\n const url = new URL(location.origin + location.pathname);\n url.search = '';\n url.searchParams.set('page', 'forbidden');\n history.replaceState({}, '', url.toString());\n if (this.eventHandlerManager) {\n this.eventHandlerManager.loadContent('forbidden', () => this.updateCurrentPageUI());\n }\n return;\n }\n if (page === 'tenant-profiling' && !this.userProfileStore.user?.hasTenantProfiling) {\n storage.set('currentPage', 'forbidden');\n this.updateSidebar('main', /*force=*/ true);\n const url = new URL(location.origin + location.pathname);\n url.search = '';\n url.searchParams.set('page', 'forbidden');\n navigate({ params: url.searchParams, data: { page: 'forbidden', resolvedView: 'main' }, pushState: true });\n if (this.eventHandlerManager) {\n this.eventHandlerManager.loadContent('forbidden', () => this.updateCurrentPageUI());\n }\n return;\n }\n if (page === 'notifications' && !this.userProfileStore.user?.hasNotificationsPage) {\n storage.set('currentPage', 'forbidden');\n this.updateSidebar('main', /*force=*/ true);\n const url = new URL(location.origin + location.pathname);\n url.search = '';\n url.searchParams.set('page', 'forbidden');\n navigate({ params: url.searchParams, data: { page: 'forbidden', resolvedView: 'main' }, pushState: true });\n if (this.eventHandlerManager) {\n this.eventHandlerManager.loadContent('forbidden', () => this.updateCurrentPageUI());\n }\n return;\n }\n if (page === 'developer' && !this.userProfileStore.user?.hasDeveloperTools) {\n // Route to forbidden instead\n storage.set('currentPage', 'forbidden');\n this.updateSidebar('main', /*force=*/ true);\n const url = new URL(location.origin + location.pathname);\n url.search = '';\n url.searchParams.set('page', 'forbidden');\n navigate({ params: url.searchParams, data: { page: 'forbidden', resolvedView: 'main' }, pushState: true });\n // Load forbidden fragment\n if (this.eventHandlerManager) {\n this.eventHandlerManager.loadContent('forbidden', () => this.updateCurrentPageUI());\n }\n return;\n }\n\n /* \u2500\u2500 decide which sidebar flavour we need if not given \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const currentView = storage.get('sidebarState')\n let resolvedView: SidebarViews = view ?? 'main';\n\n if (!view) {\n if (onPage(REPORT_PAGES, page)) resolvedView = 'report';\n else if (page !== 'ad-summary' && onPage(TENANT_AD_PAGES, page)) resolvedView = 'tenant-ad';\n else if (onPage(TENANT_SERVER_ACTIVITY_PAGES, page)) resolvedView = 'server-activity';\n else if (onPage(MARKETING_PAGES, page)) resolvedView = 'marketing';\n else if (onPage(KNOWLEDGE_PAGES, page)) resolvedView = 'knowledge';\n // SQL is in Analytics but keeps the view it was opened from (below).\n else if (page !== 'sql' && onPage(ANALYTICS_PAGES, page)) resolvedView = 'analytics';\n else if (onPage(NODE_PAGES, page)) resolvedView = 'node';\n else if (onPage(DRIFT_REPORT_PAGES, page)) resolvedView = 'drift-reports';\n else if (onPage(CHANGE_REPORT_PAGES, page)) resolvedView = 'change-reports';\n else if (onPage(ACL_SIMULATION_PAGES, page)) resolvedView = 'acl-simulations';\n else if (onPage(ACL_SIMULATION_DELTA_PAGES, page)) resolvedView = 'acl-simulation-delta';\n else if (onPage(SMB_REPORT_PAGES, page)) resolvedView = 'smb-reports';\n else if (page === 'zammad-organization-details') resolvedView = 'zammad-organizations';\n else if (page === 'zammad-ticket-details') resolvedView = 'zammad-tickets';\n else if (page === 'sql') {\n // Opened from a sidebar: stay there. Cold (a link, a reload):\n // Analytics, which lists it \u2014 the main menu no longer does.\n resolvedView = currentView || 'analytics';\n }\n }\n\n // Keep Reports sidebar when opening SQL from Reports (or Explorer-Reports)\n if ((currentView === 'report' || currentView === 'explorer-report') && page === 'sql') {\n resolvedView = currentView;\n }\n\n /* \u2500\u2500 persist + immediately re\u2011render sidebar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n storage.set('currentPage', page);\n this.updateSidebar(resolvedView, /*force=*/ true);\n\n /* \u2500\u2500 URL / history \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n if (push) {\n if (page === resolveHomePage()) {\n /* home page: root path (/) with clean query string */\n navigate({ data: { page, view: resolvedView, resolvedView }, pushState: true });\n } else {\n // Preserve some existing query params (e.g. deltaId for delta view)\n const currentUrl = new URL(location.href);\n const url = new URL(location.origin + location.pathname);\n url.search = ''; // wipe previous params\n\n // Preserve context\u2011specific identifiers --------------------------\n if (resolvedView === 'node') {\n const nodeId = (storage.get('selectedNodeId') || currentUrl.searchParams.get('nodeId') || '').trim();\n if (nodeId) {\n storage.set('selectedNodeId', nodeId);\n url.searchParams.set('nodeId', nodeId);\n }\n // Carry the per-page time window across node-sidebar\n // navigations (Directory Tree \u2194 Registry Tree \u2194 Live\n // Events \u2194 AD) so the user's selected from/to\n // survives. NodeTreescanUI / NodeEventsUI write these\n // back into the URL on refresh; without this carry\n // the wipe at line 1366 strips them on every sidebar\n // click.\n carryParams(currentUrl.searchParams, url.searchParams, ['from', 'to']);\n } else if (resolvedView === 'report' || resolvedView === 'explorer-report') {\n const reportId = storage.get('selectedReportId');\n if (reportId) url.searchParams.set('id', reportId);\n // Carry the process-tree search params (evidence links,\n // ENG-2931) when the URL already holds them. DeepLink\n // hands its seed over in sessionStorage, read once, and\n // writes none of these to the URL: after such a link a\n // refresh shows the tree unsearched.\n const currentParams = new URLSearchParams(location.search);\n for (const k of ['ptField', 'ptTerm', 'ptLimit', 'ptNodeIncident', 'ptOffset']) {\n const v = currentParams.get(k);\n if (v) url.searchParams.set(k, v);\n }\n } else if (resolvedView === 'drift-reports') {\n // Robust: prefer storage, but fall back to current URL if we arrived via deep link\n const currentParams = new URLSearchParams(location.search);\n const driftReportId = storage.get('selectedDriftReportId') || currentParams.get('reportId') || '';\n if (driftReportId) {\n // Rehydrate storage so subsequent navigations work consistently\n storage.set('selectedDriftReportId', driftReportId);\n url.searchParams.set('reportId', driftReportId);\n }\n } else if (resolvedView === 'change-reports') {\n const reportId = storage.get('selectedChangeReportId') || new URLSearchParams(location.search).get('reportId') || '';\n if (reportId) url.searchParams.set('reportId', reportId);\n } else if (resolvedView === 'smb-reports') {\n // SMB report navigation: forward the link's nodeIds,\n // sub-tenant scope and time window from the current URL\n // into the next URL when toggling between Network \u2194\n // File I/O so the detail pages can stay query-driven\n // (no subtenantID stays none: a root report).\n carryParams(currentUrl.searchParams, url.searchParams, SMB_REPORT_LINK_PARAMS);\n } else if (resolvedView === 'acl-simulations') {\n const simulationId = storage.get('selectedAclSimulationId');\n if (simulationId) url.searchParams.set('simulationId', simulationId);\n } else if (resolvedView === 'zammad-organizations') {\n const zammadOrganizationId = storage.get('selectedZammadOrganizationId');\n if (zammadOrganizationId) url.searchParams.set('zammadOrganizationId', zammadOrganizationId);\n } else if (resolvedView === 'zammad-tickets') {\n const zammadTicketId = storage.get('selectedZammadTicketId');\n if (zammadTicketId) url.searchParams.set('zammadTicketId', zammadTicketId);\n } else if (resolvedView === 'acl-simulation-delta') {\n // Keep the current deltaId across delta sub\u2011pages\n const deltaId = (currentUrl.searchParams.get('deltaId') || '').trim();\n console.log('Preserving deltaId in URL:', deltaId);\n if (deltaId) url.searchParams.set('deltaId', deltaId);\n } else if (resolvedView === 'tenant-ad') {\n // Carry the per-node AD scope (?adNodeId) across the\n // tenant-AD reports so sidebar navigation between them keeps\n // the node the user drilled in from. The detailed reports\n // read it to preset their Node ID column filter.\n const adNodeId = (currentUrl.searchParams.get('adNodeId') || '').trim();\n if (adNodeId) url.searchParams.set('adNodeId', adNodeId);\n }\n\n // ad-summary renders in the main sidebar (not 'tenant-ad'), but\n // still carries the per-node scope so \"Back to Active Directory\"\n // keeps the node context the reports were filtered to.\n if (page === 'ad-summary') {\n const adNodeId = (currentUrl.searchParams.get('adNodeId') || '').trim();\n if (adNodeId) url.searchParams.set('adNodeId', adNodeId);\n }\n\n for (const [k, v] of Object.entries(params ?? {})) url.searchParams.set(k, v);\n url.searchParams.set('page', page);\n navigate({ params: url.searchParams, data: { page, view: resolvedView, resolvedView }, pushState: true });\n }\n }\n\n /* \u2500\u2500 HTML fragment + store readiness \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n if (!this.eventHandlerManager) {\n console.warn('eventHandlerManager not set \u2013 cannot load', page);\n return;\n }\n\n this.eventHandlerManager.loadContent(page, () => {\n\n const storeEvent = pageToStoreEvent(page); // may be undefined\n const render = () => {\n\n console.debug(`Rendering page: ${page} (store event: ${storeEvent})`);\n\n this.header.clear(); // clear the header before updating it\n this.updateCurrentPageUI();\n };\n\n this.toggleSidebar(false);\n if (page === 'reports') {\n this.header.clear();\n this.uiManager.reportsUI.holdForStore();\n }\n // The store may load after the viewer has moved on: render only\n // if this is still the current page.\n renderWhenStoreReady({\n storeEvent,\n settled: (ev) => this.storeAlreadyLoaded(ev),\n page,\n currentPage: () => storage.get('currentPage'),\n render,\n });\n });\n }\n\n\n private storeAlreadyLoaded(eventName: string): boolean {\n switch (eventName) {\n case NODES_LOADED: return StoreManager.getInstance().nodeStore.isLoaded;\n // Settled, not loaded: a failed load fired REPORTS_LOADED already\n // (on a cold deep link, before this listener existed), and the\n // page's get() retries and reports it.\n case REPORTS_LOADED: return StoreManager.getInstance().reportsStore.isSettled;\n case LOGS_LOADED: return StoreManager.getInstance().logStore.isLoaded;\n case CLUSTER_LOADED: return StoreManager.getInstance().clusterStatsStore.isLoaded;\n default: return false;\n }\n }\n\n\n /** Show/hide the subtenant search row deterministically */\n private setSubtenantSearchVisible(host: HTMLElement, show: boolean) {\n const row = host.querySelector('#subtenant-search');\n const header = host.querySelector('#subtenant-current');\n if (!row) return;\n\n if (show) {\n row.classList.remove('d-none');\n row.classList.add('d-flex');\n // Win any cascade that previously set display:none\n row.style.setProperty('display', 'flex', 'important');\n // Hide the entire header (badge + label) while the list is open\n header?.classList.add('d-none');\n header?.classList.remove('d-flex');\n\n const input = row.querySelector('#subtenant-filter');\n if (input) {\n input.value = '';\n // Focus after layout and again after the list renders\n requestAnimationFrame(() => input.focus({ preventScroll: true }));\n setTimeout(() => input.focus({ preventScroll: true }), 0);\n }\n } else {\n row.classList.add('d-none');\n row.classList.remove('d-flex');\n row.style.setProperty('display', 'none', 'important');\n // Show the header again when the list closes\n header?.classList.remove('d-none');\n header?.classList.add('d-flex');\n }\n }\n\n\n /** Injects the tenant selector (incl. eye-toggle) */\n private addTenantSwitcher(): void {\n const host = document.getElementById(\"sidebar-switcher\");\n host.innerHTML = '';\n host.setAttribute('data-nav-ignore', '1');\n\n /* helper: build bullet mask ---------------------------------------*/\n const mask = (name: string) => '\u2022'.repeat(name.length);\n\n /* helper: show masked or plain ------------------------------------*/\n function showMasked(name: string) {\n input.type = 'text'; // stay in plain-text mode\n input.value = mask(name) // replace with ****\n }\n\n function showPlain(name: string) { input.type = 'text'; input.value = name; }\n\n // Respects the global \"Show Tenant Names\" preference \u2014 used by\n // initial render and by any flow that resets the chip after a\n // user action (tenant switch, edit-cancel). The masking toggle\n // (eye icon) still works as before for one-off flips.\n //\n // The blanket check is safe here (unlike a fleet-wide listing):\n // `name` always names one of the viewer's OWN p.tenants, drawn from\n // the same array the single-tenant exemption's length check reads \u2014\n // never an externally-fetched list another tenant could ride in on.\n function showAccordingToPreference(name: string) {\n if (tenantNamesVisible()) showPlain(name); else showMasked(name);\n }\n\n const p = this.userProfileStore.user;\n const tenants = p?.tenants ?? [];\n if (!p || tenants.length === 0) return;\n const singleTenant = tenants.length === 1;\n\n /* \u2500\u2500 pick active tenant \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const activeId =\n storage.get('selectedTenantId') ||\n p.tenantID\n const activeName =\n tenants.find(t => t.id === activeId)?.name ||\n tenants[0].name;\n\n /* \u2500\u2500 container \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const wrap = document.createElement('div');\n wrap.className =\n 'd-flex align-items-center tenant-switcher';\n wrap.style.setProperty('background-color', 'var(--sidebar-surface, #343434)', 'important');\n wrap.style.setProperty('padding', '12px 16px 12px 22px', 'important');\n wrap.style.position = \"relative\";\n\n /* badge */\n const badge = document.createElement('div');\n badge.className =\n 'd-flex align-items-center justify-content-center bg-warning rounded';\n badge.style.setProperty('background-color', MIMIC_BLACK, 'important');\n badge.style.setProperty('color', BADGE_LETTER_COLOR, 'important');\n badge.style.width = badge.style.height = '22px';\n badge.style.textAlign = 'center';\n badge.style.setProperty('font-size', '12px', 'important');\n badge.textContent = activeName.charAt(0).toUpperCase();\n wrap.appendChild(badge);\n\n let currentName = activeName;\n\n /* editable text field */\n const input = document.createElement('input');\n // Tell every password manager we know about that this is NOT a\n // credential field. The previous attempt assigned\n // autocomplete='new-password' AFTER autocomplete='off', which\n // does the literal opposite of what we want \u2014 it tells Chrome\n // \"this is a new password, please offer the generator\", which\n // is what was painting the key icon over the tenant name.\n //\n // We avoid the bare value \"off\" because Chrome ignores it on\n // password-looking inputs; a unique non-standard token instead\n // gets treated as \"off\" without that quirk firing.\n input.autocomplete = 'one-time-code';\n input.name = 'tenant-display'; // not credential-sounding\n input.setAttribute('data-form-type', 'other'); // 1Password hint\n input.setAttribute('data-lpignore', 'true'); // LastPass\n input.setAttribute('data-1p-ignore', 'true'); // 1Password\n input.setAttribute('data-keeper-lock', 'true'); // Keeper\n input.setAttribute('data-bwignore', 'true'); // Bitwarden\n input.setAttribute('aria-autocomplete', 'none');\n input.spellcheck = false;\n // The Tenant Profiling dashboard renders tenant names in the\n // table itself, so masking the chip is just noise. When the\n // current URL carries `?unmaskTenant=1` (set by the dashboard\n // when it switches the active tenant on row click), start in\n // plain mode. Also start in plain mode when the user has the\n // global \"Show Tenant Names\" preference on \u2014 at that point the\n // chip masking is redundant noise.\n const unmask = new URLSearchParams(window.location.search).get('unmaskTenant') === '1';\n if (unmask) {\n showPlain(activeName);\n } else {\n showAccordingToPreference(activeName);\n }\n input.readOnly = true;\n input.className = 'ml-2 p-0 border-0 bg-transparent text-truncate';\n input.style.maxWidth = '120px';\n input.style.outline = 'none';\n if (singleTenant) {\n // Don\u2019t allow entering edit mode when there\u2019s only one tenant\n input.style.cursor = 'default';\n }\n wrap.appendChild(input);\n\n /* type-ahead search & select */\n let list: HTMLDivElement | null = null; // dropdown container\n\n /* helper \u2013 build \u201CMim\u2022\u2022\u2022\u2022\u2022\u2022\u201D masking */\n function masked(name: string, typed: string): string {\n const lowerCaseName = name.toLowerCase();\n const lowerCaseTyped = typed.toLowerCase();\n\n const startIndex = lowerCaseName.indexOf(lowerCaseTyped);\n\n // If for some reason no match is found, mask the whole string.\n if (startIndex === -1) {\n return '\u2022'.repeat(name.length);\n }\n\n const endIndex = startIndex + typed.length;\n\n const prefix = '\u2022'.repeat(startIndex);\n // Extract the matching part from the *original* name to preserve its case.\n const matchedPart = name.substring(startIndex, endIndex);\n const suffix = '\u2022'.repeat(name.length - endIndex);\n\n return prefix + matchedPart + suffix;\n }\n\n /* eye icon */\n const eye = document.createElement('i');\n eye.id = 'eye-switcher';\n eye.className = 'material-icons md-16 ml-auto mr-2 cursor-pointer';\n eye.textContent = 'visibility'; // open eye by default\n eye.style.setProperty('cursor', 'pointer');\n wrap.appendChild(eye);\n\n /* clear-(X) button \u2013 hidden by default */\n const clr = document.createElement('i');\n clr.className = 'material-icons md-18 ml-auto mr-2 cursor-pointer d-none';\n clr.textContent = 'close';\n clr.style.cssText = eye.style.cssText;\n wrap.appendChild(clr);\n\n input.addEventListener('click', () => {\n if (singleTenant) return; // no editing when only one tenant\n input.type = 'text';\n input.readOnly = false;\n input.value = '';\n input.select();\n badge.textContent = '-';\n eye.classList.add('d-none'); /* hide \uD83D\uDC41 */\n clr.classList.remove('d-none');\n input.classList.add('tenant-editing');\n list?.remove();\n });\n\n const navigateToTenant = async (tenantId: string) => {\n // Seed the destination tenant's chrome into the boot cache first\n // (time-boxed) so a cross-brand switch paints the right brand\n // immediately instead of flashing the defaults for a beat.\n await prefetchTenantChrome(tenantId);\n storage.set('selectedTenantId', tenantId);\n storage.remove('selectedReportId');\n storage.remove('selectedReportName');\n\n const url = new URL(window.location.href);\n url.searchParams.delete('name');\n url.searchParams.delete('page');\n url.searchParams.delete(\"nodeId\");\n navigate({ params: url.searchParams, tenantId, replaceWindow: true });\n }\n\n input.addEventListener('keyup', () => {\n if (singleTenant) return; // ignore typing when only one tenant\n const typed = input.value.trim().toLowerCase();\n list?.remove();\n if (typed.length < 3) return;\n\n const matches = tenants.filter(t =>\n t.name.toLowerCase().includes(typed)\n );\n if (matches.length === 0) return;\n\n // Create absolute-positioned dropdown\n list = document.createElement('div');\n list.className = 'tenant-match-list';\n list.style.position = 'absolute';\n list.style.background = 'var(--sidebar-surface, #343434)';\n list.style.borderBottomLeftRadius = '4px';\n list.style.borderBottomRightRadius = \"4px\";\n list.style.paddingBottom = \"4px\";\n list.style.top = \"100%\";\n list.style.left = \"0\";\n list.style.minWidth = \"100%\";\n list.style.zIndex = '1000';\n\n const tenantId = storage.get(\"selectedTenantId\");\n let firstMatchRow: HTMLDivElement;\n matches.forEach((match, index) => {\n if (index >= 4) return;\n const row = document.createElement('div');\n row.className = 'd-flex align-items-center px-2 py-1 list-group-item';\n row.style.cursor = 'pointer';\n row.style.color = '#bbbbbb';\n row.style.setProperty('padding', '12px 16px 12px 16px', 'important');\n\n row.innerHTML = `\n
\n ${match.name[0].toUpperCase()}\n
\n ${masked(match.name, input.value)}\n `;\n\n if (index === 0) {\n firstMatchRow = row;\n }\n\n if (match.tenantId === tenantId) {\n row.classList.add(\"sidebar-active\");\n } else if (index === 0) {\n row.classList.add(\"sidebar-item-hover\");\n }\n\n row.onmouseenter = () => {\n if (index !== 0) {\n firstMatchRow.classList.remove(\"sidebar-item-hover\");\n }\n }\n row.onmouseleave = () => {\n row.classList.remove(\"sidebar-item-hover\");\n };\n\n row.addEventListener(\"click\", () => {\n currentName = match.name;\n showAccordingToPreference(currentName);\n input.readOnly = true;\n badge.textContent = match.name[0].toUpperCase();\n clr.classList.add('d-none');\n eye.classList.remove('d-none');\n input.classList.remove('tenant-editing');\n list?.remove();\n navigateToTenant(match.id);\n });\n\n list!.appendChild(row);\n });\n\n wrap.appendChild(list);\n });\n\n /* click X \u2192 cancel edit */\n clr.addEventListener('click', () => {\n list?.remove();\n input.readOnly = true;\n currentName = activeName;\n showAccordingToPreference(currentName);\n badge.textContent = activeName[0].toUpperCase();\n clr.classList.add('d-none');\n eye.classList.remove('d-none');\n eye.textContent = \"visibility\";\n input.classList.remove('tenant-editing');\n });\n\n /* click eye \u2192 toggle mask */\n eye.addEventListener('click', () => {\n if (!input.readOnly) return;\n const isMasked = input.value.startsWith('\u2022');\n isMasked ? showPlain(currentName) : showMasked(currentName);\n eye.textContent = isMasked ? 'visibility_off' : 'visibility';\n });\n\n input.addEventListener('keydown', ev => {\n if (singleTenant) return; // ignore Enter/Escape when only one tenant\n if (ev.key === 'Escape') {\n clr.click(); // cancel edit\n list?.remove();\n }\n if (ev.key !== 'Enter') return;\n if (input.value.startsWith('\u2022')) return;\n const typed = input.value.trim().toLowerCase();\n\n // First look for exact match\n let match = tenants.find(t => t.name.toLowerCase() === typed);\n\n if (!match) {\n match = tenants.find(t => t.name.toLowerCase().includes(typed));\n }\n\n if (!match || match.id === activeId) return;\n navigateToTenant(match.id);\n });\n\n host.appendChild(wrap);\n\n // --- Subtenant SEARCH row (hidden until expanded) ---\n const searchRow = document.createElement('div');\n searchRow.id = 'subtenant-search';\n // start WITHOUT d-flex so Bootstrap doesn't force it visible\n searchRow.className = 'align-items-center px-2 py-1 list-group-item';\n searchRow.style.setProperty('padding', '6px 16px', 'important');\n searchRow.style.background = 'var(--sidebar-surface, #343434)';\n searchRow.style.color = '#bbbbbb';\n // hide with Bootstrap utility that uses !important\n searchRow.classList.add('d-none');\n\n // small badge like your screenshots\n const searchBadge = document.createElement('div');\n searchBadge.className = 'd-flex align-items-center justify-content-center rounded';\n searchBadge.style.width = '22px';\n searchBadge.style.height = '22px';\n searchBadge.style.background = MIMIC_BLACK;\n searchBadge.style.setProperty('font-size', '12px', 'important');\n searchBadge.style.color = BADGE_LETTER_COLOR;\n searchBadge.textContent = '-';\n\n // the search input (NO placeholder)\n const filter = document.createElement('input');\n filter.type = 'text';\n filter.id = 'subtenant-filter';\n filter.className = 'ml-2 p-0 border-0 bg-transparent text-truncate';\n filter.style.outline = 'none';\n filter.style.color = '#bbbbbb';\n (filter.style as any).caretColor = '#bbbbbb';\n\n searchRow.appendChild(searchBadge);\n searchRow.appendChild(filter);\n host.appendChild(searchRow);\n\n // allow ESC to close and return focus to the header button\n filter.addEventListener('keydown', (e) => {\n if (e.key === 'Escape') {\n host.querySelector('#subtenant-list')?.remove();\n this.setSubtenantSearchVisible(host, false);\n // Invalidate any pending render so it doesn\u2019t repopulate after close\n (this as any)._stRenderGen = ((this as any)._stRenderGen ?? 0) + 1;\n (document.getElementById('subtenant-current') as HTMLElement)?.focus?.();\n }\n });\n\n // --- Subtenant HEADER (tenant-name row) ---\n const currentSubRow = document.createElement('div');\n currentSubRow.id = 'subtenant-current';\n currentSubRow.tabIndex = 0; // \u2190 allows focus()\n currentSubRow.setAttribute('role', 'button'); // \u2190 a11y hint\n currentSubRow.className = 'd-flex align-items-center px-2 py-1 list-group-item';\n currentSubRow.style.cursor = 'pointer';\n currentSubRow.style.setProperty('padding', '8px 16px', 'important');\n\n const selectedId = this.subtenantStore.getSelectedId(activeId);\n const cached = this.subtenantStore.getCached(activeId) || [];\n const selectedName = selectedId ? (cached.find(s => s.id === selectedId)?.name) : undefined;\n const headerName = selectedName ?? activeName;\n\n const subBadge = document.createElement('div');\n subBadge.className = 'd-flex align-items-center justify-content-center rounded';\n subBadge.style.width = '22px';\n subBadge.style.height = '22px';\n subBadge.style.background = MIMIC_BLACK;\n subBadge.style.setProperty('font-size', '12px', 'important');\n subBadge.style.color = BADGE_LETTER_COLOR;\n subBadge.textContent = (headerName || '-').charAt(0).toUpperCase();\n\n const label = document.createElement('span');\n label.id = 'subtenant-selected-label';\n label.className = 'ml-2';\n label.textContent = headerName;\n\n currentSubRow.appendChild(subBadge);\n currentSubRow.appendChild(label);\n host.appendChild(currentSubRow);\n\n // Hide the header until we confirm there ARE subtenants\n currentSubRow.classList.add('d-none');\n currentSubRow.classList.remove('d-flex');\n\n // Probe store; only show header if subtenants exist\n this.subtenantStore\n .list(activeId, { flatten: true })\n .then(({ items }) => {\n if (items && items.length > 0) {\n currentSubRow.classList.remove('d-none');\n currentSubRow.classList.add('d-flex');\n } else {\n // No subtenants \u2192 ensure selection is cleared\n this.subtenantStore.setSelectedId(activeId, null);\n }\n })\n .catch(() => {\n // On error, keep it hidden (acts like \"no subtenants\")\n });\n\n // \u2500\u2500 Lazy-resolve header on first load if we have an id but no cached name \u2500\u2500\n if (selectedId && !selectedName) {\n this.subtenantStore\n .list(activeId, { flatten: true })\n .then(({ items }) => {\n const found = items?.find((s: any) => s.id === selectedId);\n if (!found) return;\n const lbl = host.querySelector('#subtenant-selected-label');\n const badgeEl = host.querySelector('#subtenant-current div');\n if (lbl) lbl.textContent = found.name || '';\n if (badgeEl) badgeEl.textContent = (found.name || '-').charAt(0).toUpperCase();\n })\n .catch(() => { /* noop: leave tenant name if fetch fails */ });\n }\n\n // Toggle open/close on click (show/hide search row and focus input)\n currentSubRow.onclick = async () => {\n // If we just closed/selected, ignore accidental re-open from the same click cycle\n if ((this as any)._stPreventNextToggleUntil && Date.now() < (this as any)._stPreventNextToggleUntil) {\n return;\n }\n const listEl = host.querySelector('#subtenant-list');\n if (listEl) {\n listEl.remove();\n this.setSubtenantSearchVisible(host, false); // collapse: hide the input row too\n // Cancel any in-flight renderSubtenantList so it can't append after close\n (this as any)._stRenderGen = ((this as any)._stRenderGen ?? 0) + 1;\n return;\n }\n\n this.setSubtenantSearchVisible(host, true); // expand: show input + focus\n await this.renderSubtenantList(host, activeId, filter, activeName);\n };\n }\n\n /**\n * Render a flat list of subtenants for a tenant under the tenant switcher.\n * - Shows name\n * - Highlights currently selected subtenant (per tenant)\n * - Persists selection via SubtenantStore (localStorage)\n */\n private async renderSubtenantList(\n host: HTMLElement,\n tenantId: string,\n filterEl?: HTMLInputElement,\n tenantName?: string\n ): Promise {\n // Remove any previous list (avoid duplicates if toggled fast)\n host.querySelectorAll('#subtenant-list').forEach(el => el.remove());\n host.style.position = host.style.position || 'relative';\n\n // Render-generation guard: only the last call that completes should append/update\n (this as any)._stRenderGen = ((this as any)._stRenderGen ?? 0) + 1;\n const myGen = (this as any)._stRenderGen;\n\n // Create and insert the list container *immediately* so the user sees it\n const listHost = document.createElement('div');\n listHost.id = 'subtenant-list';\n // Overlay dropdown styling\n listHost.style.position = 'absolute';\n listHost.style.left = '0';\n listHost.style.right = '0';\n listHost.style.top = 'calc(100% + 2px)'; // drops under the header row\n listHost.style.zIndex = '2000';\n listHost.style.background = 'var(--sidebar-surface, #343434)';\n listHost.style.borderRadius = '4px';\n listHost.style.boxShadow = '0 8px 16px rgba(0,0,0,.25)';\n const anchor = host.querySelector('#subtenant-current');\n if (anchor && anchor.nextSibling) {\n host.insertBefore(listHost, anchor.nextSibling);\n } else {\n host.appendChild(listHost);\n }\n\n // Loading state while we fetch\n const loadingRow = document.createElement('div');\n loadingRow.className = 'px-2 py-1 list-group-item';\n loadingRow.style.setProperty('padding', '12px 16px', 'important');\n loadingRow.textContent = t('dt.loading');\n listHost.appendChild(loadingRow);\n\n // Fetch flattened list (sorted by API). Uses cache/in-flight guard in store.\n let items: any[] = [];\n try {\n const { items: fetched } = await this.subtenantStore.list(tenantId, { flatten: true });\n items = fetched;\n } catch (err) {\n listHost.remove();\n this.setSubtenantSearchVisible(host, false);\n host.querySelector('#subtenant-current')?.classList.add('d-none');\n host.querySelector('#subtenant-current')?.classList.remove('d-flex');\n return;\n }\n\n listHost.innerHTML = '';\n if (!items || items.length === 0) {\n // No subtenants: remove dropdown entirely and restore header/search state\n listHost.remove();\n this.setSubtenantSearchVisible(host, false);\n host.querySelector('#subtenant-current')?.classList.add('d-none');\n host.querySelector('#subtenant-current')?.classList.remove('d-flex');\n return;\n }\n\n\n const topRow = document.createElement('div');\n topRow.className = 'd-flex align-items-center px-2 py-1 list-group-item list-group-item-action bg-light';\n topRow.style.cursor = 'pointer';\n topRow.style.setProperty('padding', '12px 16px 12px 16px', 'important');\n\n const topBadge = document.createElement('div');\n topBadge.className = 'd-flex align-items-center justify-content-center rounded';\n topBadge.style.width = '22px';\n topBadge.style.height = '22px';\n topBadge.style.background = MIMIC_BLACK;\n topBadge.style.color = BADGE_LETTER_COLOR;\n topBadge.style.fontSize = '12px';\n topBadge.textContent = (tenantName || '-').charAt(0).toUpperCase();\n\n const topName = document.createElement('span');\n topName.className = 'ml-2';\n topName.textContent = tenantName || t('col.tenant');\n\n topRow.appendChild(topBadge);\n topRow.appendChild(topName);\n\n topRow.onmouseenter = () => topRow.classList.add('sidebar-item-hover');\n topRow.onmouseleave = () => topRow.classList.remove('sidebar-item-hover');\n topRow.onclick = (ev: MouseEvent) => {\n ev.preventDefault();\n ev.stopPropagation();\n // Block header toggle from the same pointer cycle\n (this as any)._stPreventNextToggleUntil = Date.now() + 250;\n // Clear selection\n this.subtenantStore.setSelectedId(tenantId, null);\n\n // Update header label + badge to TenantName\n const lbl = host.querySelector('#subtenant-selected-label') as HTMLElement | null;\n if (lbl) lbl.textContent = tenantName || t('col.tenant');\n const badgeEl = host.querySelector('#subtenant-current div') as HTMLElement | null;\n if (badgeEl) badgeEl.textContent = (tenantName || '-').charAt(0).toUpperCase();\n\n\n // Header remains tenant (no change), just close list + notify\n host.querySelector('#subtenant-list')?.remove();\n this.setSubtenantSearchVisible(host, false);\n // Invalidate any pending render so it doesn\u2019t re-append\n (this as any)._stRenderGen = ((this as any)._stRenderGen ?? 0) + 1;\n };\n\n listHost.appendChild(topRow);\n\n console.debug('[Navigation] added \"Tenant\" row to subtenant list');\n\n // --- list container ----------------------------------------------\n const itemsWrap = document.createElement('div');\n listHost.appendChild(itemsWrap);\n\n const renderRows = (rows: any[]) => {\n console.debug(`[Navigation] rendering ${rows.length} subtenants for tenant ${tenantId}`);\n itemsWrap.innerHTML = '';\n const selectedId = this.subtenantStore.getSelectedId(tenantId);\n rows.forEach(st => {\n const row = document.createElement('div');\n row.className = 'd-flex align-items-center px-2 py-1 list-group-item list-group-item-action bg-light';\n row.style.cursor = 'pointer';\n row.style.setProperty('padding', '12px 16px 12px 16px', 'important');\n\n const badge = document.createElement('div');\n badge.className = 'd-flex align-items-center justify-content-center rounded';\n badge.style.width = '22px';\n badge.style.height = '22px';\n badge.style.setProperty('font-size', '12px', 'important');\n badge.style.background = MIMIC_BLACK;\n badge.style.color = BADGE_LETTER_COLOR;\n badge.textContent = (st.name || '').charAt(0).toUpperCase();\n\n const name = document.createElement('span');\n name.className = 'ml-2';\n name.textContent = st.name || '';\n\n row.appendChild(badge);\n row.appendChild(name);\n\n if (st.id === selectedId) row.classList.add('sidebar-active');\n row.onmouseenter = () => row.classList.add('sidebar-item-hover');\n row.onmouseleave = () => row.classList.remove('sidebar-item-hover');\n row.onclick = (ev: MouseEvent) => {\n ev.preventDefault();\n ev.stopPropagation();\n // Block header toggle from the same pointer cycle\n (this as any)._stPreventNextToggleUntil = Date.now() + 250;\n // Persist selection\n this.subtenantStore.setSelectedId(tenantId, st.id);\n\n // Update header label + badge\n const lbl = host.querySelector('#subtenant-selected-label') as HTMLElement | null;\n if (lbl) lbl.textContent = st.name || '';\n const badgeEl = host.querySelector('#subtenant-current div') as HTMLElement | null;\n if (badgeEl) badgeEl.textContent = (st.name || '-').charAt(0).toUpperCase();\n\n // Collapse list\n host.querySelector('#subtenant-list')?.remove();\n this.setSubtenantSearchVisible(host, false);\n // Invalidate any pending render so it doesn\u2019t re-append\n (this as any)._stRenderGen = ((this as any)._stRenderGen ?? 0) + 1;\n };\n\n itemsWrap.appendChild(row);\n });\n };\n\n // initial render (all)\n renderRows(items);\n\n // hook up filtering to the provided input\n if (filterEl) {\n // Each expand creates a fresh input, so simple handler is fine\n filterEl.oninput = () => {\n const q = filterEl.value.trim().toLowerCase();\n if (!q) { renderRows(items); return; }\n // simple substring match (supports '-' etc.)\n renderRows(items.filter(st => (st.name || '').toLowerCase().includes(q)));\n };\n }\n }\n\n /** Draw / refresh the profile dropdown in #sidebar-user */\n private addProfileElement(): void {\n const host = document.getElementById('sidebar-user');\n if (!host) return;\n\n host.innerHTML = ''; // wipe old markup\n this.presenceUnsubscribers.forEach(fn => fn());\n this.presenceUnsubscribers = [];\n const p = this.userProfileStore.user;\n if (!p) {\n // no profile \u2192 hide the container & remove the divider class\n host.classList.add('d-none');\n host.classList.remove('border-top');\n return;\n }\n\n // we *do* have a profile \u2192 make sure the slot is visible\n host.classList.remove('d-none');\n host.classList.remove('border-top'); // keep divider off even when logged in\n\n /* \u2500\u2500 derive name & initial \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const fullName =\n p.displayName ||\n [p.firstName, p.lastName].filter(Boolean).join(' ') ||\n p.email;\n\n const initial = fullName.charAt(0).toUpperCase();\n\n /* \u2500\u2500 avatar: always an initial badge (no photos in this app) \u2500 */\n const avatar = document.createElement('span');\n avatar.dataset.testid = 'user-avatar';\n avatar.className = 'ant-avatar ant-avatar-circle';\n avatar.style.cssText = `\n width:24px; height:24px; min-width:24px;\n font-size:12px; display:flex; align-items:center; justify-content:center;\n color:${AVATAR_FG};\n background-color:${AVATAR_BG};\n border:${AVATAR_BORDER};\n border-radius:50%;\n `;\n\n const avatarTxt = document.createElement('span');\n avatarTxt.textContent = initial;\n avatar.appendChild(avatarTxt);\n\n /* \u2500\u2500 dropdown skeleton \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const dd = document.createElement('div');\n dd.id = \"sidebar-user-profile\"\n dd.className = 'dropdown w-100';\n\n const toggle = document.createElement('button');\n toggle.type = 'button';\n toggle.className =\n 'dropdown-toggle d-flex align-items-center justify-content-between w-100';\n // remove the browser\u2019s default focus ring\n toggle.style.outline = 'none';\n toggle.style.boxShadow = 'none';\n toggle.dataset.toggle = 'dropdown';\n\n const left = document.createElement('div');\n left.className = 'd-flex align-items-center';\n left.appendChild(avatar);\n\n const nameSpan = document.createElement('span');\n nameSpan.className = 'ml-2';\n nameSpan.textContent = fullName;\n // The rail's text tone: a brand on a light surface sets --sidebar-fg\n // to its ink; the stock dark rail keeps the muted grey.\n nameSpan.style.setProperty('color', 'var(--sidebar-fg, #bbb)', 'important');\n left.appendChild(nameSpan);\n\n toggle.appendChild(left);\n\n const chevron = document.createElement('i');\n chevron.className = 'fas fa-chevron-right text-muted';\n toggle.appendChild(chevron);\n\n dd.appendChild(toggle);\n\n /* \u2500\u2500 dropdown menu \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n const menu = document.createElement('div');\n menu.className = 'dropdown-menu dropdown-menu-end border shadow-sm';\n\n const hdr = document.createElement('div'); // avatar + name\n hdr.className = 'd-flex align-items-center px-3 py-2 border-bottom';\n hdr.appendChild(avatar.cloneNode(true));\n hdr.innerHTML += `\n
\n ${fullName}
\n ${p.email}\n
`;\n menu.appendChild(hdr);\n\n if (p.email) {\n presenceStore.start();\n } else {\n presenceStore.stop();\n }\n\n if (userStatusEnabled()) {\n const presenceWrap = document.createElement('div');\n presenceWrap.className = 'px-3 py-2 border-bottom';\n presenceWrap.style.cssText = 'min-width: 240px; max-width: 320px;';\n presenceWrap.innerHTML = `\n
\n ${t('body.userStatus')}\n \n
\n
\n
${t('col.online')}
\n
\n
${t('body.away')}
\n
\n
\n `;\n menu.appendChild(presenceWrap);\n\n const escPresence = (s: string) => String(s ?? '')\n .replace(/&/g, '&')\n .replace(//g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n // Online = green dot, Away = crescent moon (matches the Cockpit convention).\n const dot = (color: string) =>\n ``;\n const moon = (color: string) =>\n ``;\n const clientIcon = (clientType?: string) => {\n if (clientType === 'phone') {\n return ``;\n }\n if (clientType === 'mcp') {\n return `auto_awesome`;\n }\n return '';\n };\n const row = (entry: { email: string; clientType?: string }, marker: string) => `\n
\n ${marker}\n ${clientIcon(entry.clientType)}\n ${escPresence(entry.email)}\n
`;\n const renderPresence = () => {\n const self = presenceWrap.querySelector('#presenceSelfPill') as HTMLElement | null;\n const online = presenceWrap.querySelector('#presenceOnlineList') as HTMLElement | null;\n const away = presenceWrap.querySelector('#presenceAwayList') as HTMLElement | null;\n if (!self || !online || !away) return;\n const selfState = presenceStore.selfState();\n const selfMarker = selfState === 'online' ? dot('#22863a')\n : selfState === 'away' ? moon('#b7791f')\n : dot('#9ca3af');\n const selfLabel = selfState === 'online' ? t('col.online')\n : selfState === 'away' ? t('body.away')\n : t('body.statusOffline');\n self.innerHTML = `${selfMarker}${selfLabel}`;\n\n // While the socket is down we know nothing about anyone, which is\n // not the same as nobody being here \u2014 say so rather than showing\n // an empty roster next to an \"Online\" pill.\n const empty = selfState === 'offline' ? t('body.notConnected') : t('body.noUsers');\n const onlineRows = presenceStore.online();\n const awayRows = presenceStore.away();\n online.innerHTML = onlineRows.length\n ? onlineRows.map(e => row(e, dot('#22863a'))).join('')\n : `${empty}`;\n away.innerHTML = awayRows.length\n ? awayRows.map(e => row(e, moon('#b7791f'))).join('')\n : `${empty}`;\n };\n renderPresence();\n this.presenceUnsubscribers = [\n presenceStore.subscribe(renderPresence),\n presenceStore.subscribeSelf(renderPresence),\n ];\n }\n\n // Appearance (System / Light / Dark) sits above Language: the quick\n // switch in the account menu, as in YouTube's avatar menu; the same\n // control is on the Profile page. See utils/ThemeSetting.ts.\n menu.insertAdjacentHTML('beforeend', `\n
\n ${escapeHtml(t('common.appearance'))}\n
\n
`);\n\n // Language picker \u2014 overrides the browser locale and persists the\n // choice (setLocale), then reloads so every catalog-driven string\n // re-renders in the new language. Options show each language in its own\n // script so a reader can find theirs without reading the current one.\n // Custom dark dropdown rather than a native /\n
\n
\n \n \n ${p.commentary !== p.originalCommentary\n ? `` : \"\"}\n
`;\n // A save of this post still running: its editor waits for it.\n if (this.saving.has(p.id)) this.editFields().forEach(f => { f.readOnly = true; });\n this.markWaiting(p.id, host);\n this.renderCount();\n }\n\n private editFields(): (HTMLInputElement | HTMLTextAreaElement)[] {\n return [\"lpEditText\"]\n .map(id => this.el(id))\n .filter((f): f is HTMLInputElement | HTMLTextAreaElement => !!f);\n }\n\n private renderCount(): void {\n const c = this.el(\"lpCount\");\n const ta = this.el(\"lpEditText\");\n if (!c || !ta) return;\n const max = this.settings?.maxCommentary || 3000;\n const n = Array.from(ta.value.trim()).length;\n // The post spec's limit is in words (\u00A74.10), counted as the server\n // counts them: whitespace-separated, the link as one.\n const maxWords = this.settings?.maxWords || 120;\n const words = ta.value.trim() ? ta.value.trim().split(/\\s+/).length : 0;\n c.textContent = t(\"linkedinPosts.chars\", { n: n.toLocaleString(getLocale()), max: max.toLocaleString(getLocale()) })\n + \" \u00B7 \" + t(\"linkedinPosts.words\", { n: words.toLocaleString(getLocale()), max: maxWords.toLocaleString(getLocale()) });\n c.classList.toggle(\"lp-status-bad\", n > max || n === 0 || words > maxWords);\n }\n\n private async onAction(action: string, btn: HTMLButtonElement): Promise {\n // Nothing acts for a page that is no longer on screen.\n if (!this.el(\"linkedinPostsPage\")) return;\n const visit = this.visit;\n // The connection's own buttons act on no post.\n if (action === \"connect\") return this.connect(btn);\n if (action === \"disconnect\") {\n if (!(await askConfirm({\n title: t(\"linkedinPosts.disconnectTitle\"),\n message: t(\"linkedinPosts.disconnectConfirm\"),\n confirmLabel: t(\"linkedinPosts.disconnectLinkedIn\"),\n })) || !this.sameVisit(visit)) return;\n return this.post(btn, \"/api/linkedin-posts/disconnect\", {});\n }\n const p = this.selected();\n if (!p) return;\n // One request per post at a time: buttons drawn again while one runs\n // (the drawer closed and reopened) must not start a second.\n if (CHANGES_POST.has(action) && this.saving.has(p.id)) return;\n // Asks in the page's dialog; while it is open the page may be left (and\n // come back to) or another request for this post started, so both are\n // checked again.\n const ask = async (title: string, message: string, confirmLabel: string) =>\n await askConfirm({ title, message, confirmLabel })\n && this.sameVisit(visit) && !(CHANGES_POST.has(action) && this.saving.has(p.id));\n const live = (confirmLabel: string) => ask(t(\"linkedinPosts.publishTitle\"), t(\"linkedinPosts.publishConfirm\", {\n page: this.settings?.pageName || \"\", n: (this.settings?.pageFollowers || 0).toLocaleString(getLocale()),\n }), confirmLabel);\n switch (action) {\n case \"approve\": {\n // Connected and inside the window, Approve publishes: say so\n // before it goes live, and send the confirmation. The server\n // publishes only with it, so a click at the window's edge\n // never goes live unconfirmed.\n const publish = this.settings?.publishing === \"api\" && this.inWindow(p);\n if (publish && !(await live(t(\"linkedinPosts.approvePublish\")))) return;\n return this.post(btn, `/api/linkedin-posts/${p.id}/action`, { status: \"approved\", publish });\n }\n case \"publish\":\n if (!(await live(t(p.status === \"failed\" ? \"linkedinPosts.retryPublish\" : \"linkedinPosts.publishNow\")))) return;\n return this.post(btn, `/api/linkedin-posts/${p.id}/publish`, {});\n case \"not-on-linkedin\":\n if (!(await ask(t(\"linkedinPosts.notOnLinkedInTitle\"), t(\"linkedinPosts.notOnLinkedInConfirm\"), t(\"linkedinPosts.notOnLinkedIn\")))) return;\n return this.post(btn, `/api/linkedin-posts/${p.id}/not-on-linkedin`, {});\n case \"unapprove\":\n case \"restore\": return this.post(btn, `/api/linkedin-posts/${p.id}/action`, { status: \"proposed\" });\n case \"skip\":\n if (!(await ask(t(\"linkedinPosts.skipTitle\"), t(\"linkedinPosts.skipConfirm\"), t(\"linkedinPosts.skip\")))) return;\n return this.post(btn, `/api/linkedin-posts/${p.id}/action`, { status: \"skipped\" });\n case \"pick\": return this.post(btn, \"/api/linkedin-posts/pick-another\", {});\n case \"edit\":\n this.editing = true; this.expanded = true;\n this.renderActions(); this.renderEditor(); this.renderPreview();\n this.el(\"lpEditText\")?.focus();\n return;\n case \"cancel\":\n this.editing = false; this.expanded = false;\n this.renderEditor(); this.renderActions(); this.renderPreview();\n return;\n case \"revert\": {\n const ta = this.el(\"lpEditText\");\n if (ta) ta.value = p.originalCommentary;\n this.renderPreview(); this.renderCount();\n return;\n }\n case \"save\":\n return this.post(btn, `/api/linkedin-posts/${p.id}/edit`, {\n commentary: this.el(\"lpEditText\")?.value || \"\",\n }, t(\"body.saved\"));\n case \"copy\":\n try {\n await navigator.clipboard.writeText(p.commentary);\n this.postStatus(p, t(\"linkedinPosts.copied\"));\n } catch (e) {\n this.postStatus(p, `${t(\"linkedinPosts.actionFailed\")}: ${e}`, true);\n }\n return;\n case \"posted\": {\n const box = this.el(\"lpPosted\");\n if (box) box.hidden = false;\n this.el(\"lpPostedUrl\")?.focus();\n return;\n }\n case \"posted-cancel\": {\n const box = this.el(\"lpPosted\");\n if (box) box.hidden = true;\n return;\n }\n case \"posted-save\":\n return this.post(btn, `/api/linkedin-posts/${p.id}/published`, {\n url: this.el(\"lpPostedUrl\")?.value.trim() || \"\",\n });\n }\n }\n\n // Connect: the server stores a one-time state and answers with LinkedIn's\n // sign-in address; LinkedIn brings the admin back to this page.\n private async connect(btn: HTMLButtonElement): Promise {\n btn.disabled = true;\n try {\n const res = await apiFetch(\"/api/linkedin-posts/connect\", { method: \"POST\" });\n const body = await res.json().catch(() => ({}));\n if (!res.ok || !body?.url) throw new Error(body?.error || `HTTP ${res.status}`);\n window.location.assign(body.url);\n } catch (e) {\n this.setStatus(`${t(\"linkedinPosts.actionFailed\")}: ${e instanceof Error ? e.message : e}`, true);\n btn.disabled = false;\n }\n }\n\n // Every change goes through here: send it, take the post back as the\n // server now holds it, and redraw from that.\n // done: what to say in the drawer once it has worked (a saved edit), said\n // only while the post it is about is still the one on screen.\n private async post(btn: HTMLButtonElement, url: string, payload: unknown, done = \"\"): Promise {\n const original = btn.textContent;\n btn.disabled = true;\n btn.textContent = t(\"linkedinPosts.working\");\n // The post this was sent for. A publish can take seconds, and the\n // person may have closed the drawer and opened another post, even\n // started editing it, by the time the answer comes back.\n const forId = this.selectedId;\n const forPost = this.selected();\n const stillHere = () => this.selectedId === forId && !!this.drawer?.isOpen();\n // What the editor held when this was sent: an edit typed on since\n // (the drawer closed and reopened while the save ran) is not ended.\n // What an edit sent, in the kept edit's shape (the text only).\n const sent = this.editing ? JSON.stringify({ commentary: this.draft()?.commentary ?? \"\" }) : \"\";\n const untouched = () => !this.editing || JSON.stringify(this.draft()) === sent;\n const note = this.el(\"lpPostStatus\");\n if (note && stillHere()) note.textContent = \"\";\n // The editor is read-only while the request and the reload after it\n // run: text typed in that window would be neither sent nor kept.\n // An editor drawn again while this runs (the drawer closed and the\n // post reopened) is read-only too: renderEditor checks `saving`.\n this.saving.set(forId, (this.saving.get(forId) || 0) + 1);\n this.editFields().forEach(f => { f.readOnly = true; });\n try {\n const res = await apiFetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify(payload),\n });\n const body = await res.json().catch(() => ({}));\n if (!res.ok) {\n // A refused publish still changed the post (it is now failed,\n // with LinkedIn's reason): redraw before saying what went wrong.\n if (body?.post?.id) {\n if (stillHere() && untouched()) this.editing = false;\n await this.load(true);\n }\n throw new Error(body?.error || `HTTP ${res.status}`);\n }\n const settle = stillHere() && untouched();\n if (settle) {\n // The post address box has done its job (Mark as posted).\n const box = this.el(\"lpPosted\");\n if (box) box.hidden = true;\n if (body?.post?.id) this.selectedId = body.post.id;\n }\n // The edit ends only once the reload has drawn the saved post: a\n // reload that fails leaves the editor open and in edit mode, so\n // nothing typed after it is lost, and Save can be pressed again.\n const drawn = await this.load(true);\n // A kept edit (the drawer closed while this ran) is dropped once\n // the reload shows the saved text, unless typed on after it was\n // sent; a failed reload keeps it, as the list still has the old.\n if (drawn && this.unsaved?.id === forId) {\n const { id: _id, ...kept } = this.unsaved;\n if (!sent || JSON.stringify(kept) === sent) this.unsaved = null;\n }\n if (settle && drawn && this.editing && untouched() && this.drawer?.isOpen()\n && this.selectedId === (body?.post?.id || forId)) {\n this.editing = false;\n this.expanded = false;\n this.renderPost();\n }\n // \"Saved\" only once the reload has shown it: a failed reload has\n // said so already and is not written over.\n if (done && drawn && this.selectedId === forId && this.drawer?.isOpen()) this.setStatus(done);\n } catch (e) {\n // A failure for a post the person has moved on from is said on\n // the page, naming that post, not in the drawer of another.\n this.postStatus(forPost, `${t(\"linkedinPosts.actionFailed\")}: ${e instanceof Error ? e.message : e}`, true);\n } finally {\n // Whatever editor is on screen now for this post (the save failed,\n // or it was reopened meanwhile) takes typing again.\n const left = (this.saving.get(forId) || 1) - 1;\n if (left > 0) this.saving.set(forId, left);\n else this.saving.delete(forId);\n if (left === 0 && this.selectedId === forId) {\n this.editFields().forEach(f => { f.readOnly = false; });\n // Buttons drawn while the request ran (the reload, a reopen)\n // were drawn waiting: they work again now.\n this.drawerBody?.querySelectorAll(\"button[data-lp-waiting]\").forEach(b => {\n b.disabled = false;\n delete b.dataset.lpWaiting;\n });\n }\n // A redraw replaces the button; one it skipped (typing went on\n // through a save) is still on screen and must work again.\n if (btn.isConnected) {\n btn.disabled = false;\n if (original) btn.textContent = original;\n }\n }\n }\n\n // \u2500\u2500 why this group \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n private renderWhy(): void {\n const p = this.selected();\n const score = this.el(\"lpScore\");\n const facts = this.el(\"lpFacts\");\n const source = this.el(\"lpSource\");\n const ranked = this.el(\"lpRanked\");\n if (!p) return;\n const b = p.scoreBreakdown || {};\n\n const confirmed = isConfirmed(p);\n const labels = confirmed ? COMPONENT_KEY_CONFIRMED : COMPONENT_KEY;\n if (score) {\n const rows = byComponentOrder(b.components || []).map(c => this.componentRow(c, confirmed)).join(\"\");\n score.innerHTML = `
${fmt1(p.score)} ${esc(t(\"linkedinPosts.scoreOutOf\"))}
${rows}`;\n }\n\n if (facts && confirmed) {\n facts.innerHTML = this.confirmedFacts(p);\n const nvd = this.el(\"lpNvd\");\n if (nvd) { nvd.hidden = true; nvd.textContent = \"\"; }\n } else if (facts) {\n const f = p.facts || {};\n const row = (k: string, v: string) => v ? `
${esc(t(k))}
${v}
` : \"\";\n // Every advisory the count spans (LockBit's two); drafts made\n // before the list was stored carry only the first.\n const advs = f.advisories?.length\n ? f.advisories\n : (f.advisoryId ? [{ id: f.advisoryId, url: f.advisoryUrl }] : []);\n const advisory = advs.map(a => a.url\n ? `${esc(a.id)}`\n : esc(a.id)).join(\", \");\n const latest = [f.entrySector, f.entryCountryName].filter(Boolean).join(\", \");\n facts.innerHTML =\n row(\"linkedinPosts.factAdvisory\", advisory)\n + row(\"linkedinPosts.factProtectable\", f.protectable != null\n ? esc(t(\"linkedinPosts.factProtectableOf\", { n: f.protectable, total: f.advisoryCves ?? 0 })) : \"\")\n + row(\"linkedinPosts.factServer\", f.serverPct != null\n ? esc(t(\"linkedinPosts.factServerValue\", {\n n: f.protectable ?? 0, s: f.serverCves ?? 0, pct: f.serverPct,\n p: fmt1(f.riskServerProtected ?? 0), t: fmt1(f.riskServerTotal ?? 0) }))\n : f.serverCves\n ? esc(t(\"linkedinPosts.factServerCount\", { n: f.protectable ?? 0, s: f.serverCves }))\n : \"\")\n + row(\"linkedinPosts.factWeighted\", f.weightedPct != null\n ? esc(t(\"linkedinPosts.factWeightedValue\", {\n pct: f.weightedPct, p: fmt1(f.riskProtected ?? 0), t: fmt1(f.riskTotal ?? 0) })) : \"\")\n + row(\"linkedinPosts.factListings\", esc(t(\"linkedinPosts.factListingsValue\", {\n d1: f.listings24h ?? 0, d7: f.listings7d ?? 0, d30: f.listings30d ?? 0 })))\n + row(\"linkedinPosts.factLastListing\", f.lastListing ? esc(formatWhen(f.lastListing)) : \"\")\n + row(\"linkedinPosts.factLatest\", esc(latest))\n + row(\"linkedinPosts.factFeed\", f.feedFetched ? esc(formatWhen(f.feedFetched)) : \"\")\n + row(\"linkedinPosts.factCampaign\", `${esc(p.campaign)}`);\n // The weighted share is built from NVD's CVSS scores; NVD's terms\n // require its notice wherever they appear.\n const nvd = this.el(\"lpNvd\");\n if (nvd) {\n const scored = f.weightedPct != null || f.serverPct != null;\n nvd.hidden = !scored;\n nvd.textContent = scored ? t(\"body.nvdAttribution\") : \"\";\n }\n }\n\n if (source) {\n const e = p.sourceEntry;\n const card = this.el(\"lpSourceCard\");\n if (card) card.hidden = !e;\n if (e) {\n const bits = [e.sector, e.country, e.seen ? formatWhen(e.seen) : \"\"].filter(Boolean).join(\" \u00B7 \");\n source.innerHTML = `
${esc(e.victim || \"\")}
${esc(bits)}
`;\n }\n }\n\n if (ranked) {\n const others = (b.ranked || []).filter(r => r.slug !== p.groupSlug);\n const passed = b.passed || [];\n const card = this.el(\"lpRankedCard\");\n if (card) card.hidden = others.length === 0 && passed.length === 0;\n ranked.innerHTML = others.map(r => {\n const parts = byComponentOrder(r.components || []).map(c =>\n `${esc(t(labels[c.key] || c.key))} ${signed(c.points)}`).join(\" \u00B7 \");\n return `
${esc(r.name)} ${fmt1(r.total)}
${parts}
`;\n }).join(\"\")\n // The groups the post spec's conditions left out, and why.\n + (passed.length ? `
${esc(t(\"linkedinPosts.passedTitle\"))}
` : \"\")\n + passed.map(r => `
${esc(r.name)}
${esc(passReason(r))}
`).join(\"\");\n }\n }\n\n // What a confirmed post was written from: the event, its primary\n // source, and what the coverage line rests on.\n private confirmedFacts(p: LinkedInPost): string {\n const f = p.facts || {};\n const row = (k: string, v: string) => v ? `
${esc(t(k))}
${v}
` : \"\";\n const lane = LANE_KEY[f.lane || \"\"];\n const source = f.sourceUrl\n ? `${esc(f.title || f.subject || f.eventId || \"\")}`\n : esc(f.title || \"\");\n let claim = \"\";\n if (f.lane === \"kev\" && f.class) {\n claim = esc(t(f.classReviewed ? \"linkedinPosts.factClassReviewed\" : \"linkedinPosts.factClassInferred\", { class: f.class }));\n } else if ((f.lane === \"advisory\" || f.lane === \"dfir\") && f.groupProtectable != null) {\n claim = esc(t(\"linkedinPosts.factProtectableOf\", { n: f.groupProtectable, total: f.groupCves ?? 0 }));\n } else if (f.lane === \"sec\") {\n claim = esc(t(\"linkedinPosts.factNoClaim\"));\n }\n return row(\"linkedinPosts.factEvent\", lane ? esc(t(lane)) : \"\")\n + row(\"linkedinPosts.factSubject\", esc(f.subject || \"\"))\n + row(\"linkedinPosts.factConfirmedOn\", f.eventDate ? esc(formatDay(f.eventDate)) : \"\")\n + row(\"linkedinPosts.factPrimarySource\", source)\n + row(f.lane === \"kev\" ? \"linkedinPosts.factClass\" : \"linkedinPosts.factProtectable\", claim)\n + row(\"linkedinPosts.factRead\", f.pulled ? esc(formatWhen(f.pulled)) : \"\")\n + row(\"linkedinPosts.factCampaign\", `${esc(p.campaign)}`);\n }\n\n private componentRow(c: ScoreComponent, confirmed: boolean): string {\n const label = t((confirmed ? COMPONENT_KEY_CONFIRMED : COMPONENT_KEY)[c.key] || c.key);\n const neg = c.points < 0;\n // A deduction is drawn against the largest one the score can make,\n // so a small one does not fill the bar.\n const scale = neg ? MAX_DEDUCTION : c.max;\n const pct = scale > 0 ? Math.max(0, Math.min(100, (Math.abs(c.points) / scale) * 100)) : 0;\n return `
\n
${esc(label)}${signed(c.points)}${neg ? \"\" : ` / ${fmt1(c.max)}`}
\n
\n
${esc(confirmed ? this.confirmedWhy(c) : this.componentWhy(c))}
\n
`;\n }\n\n // The reason behind a confirmed post's component, from the numbers\n // stored with it.\n private confirmedWhy(c: ScoreComponent): string {\n const v = c.values || {};\n switch (c.key) {\n case \"source\": {\n const key = LANE_KEY[LANE_ORDER[v.lane ?? -1] || \"\"];\n return key ? t(key) : \"\";\n }\n case \"fresh\":\n if (v.daysOld == null) return \"\";\n return v.daysOld === 0 ? t(\"linkedinPosts.whyFreshToday\") : t(\"linkedinPosts.whyFreshDays\", { days: v.daysOld });\n case \"protection\":\n if (v.noClaim) return t(\"linkedinPosts.factNoClaim\");\n if (v.protectable != null) return t(\"linkedinPosts.whyProtection\", { n: v.protectable, total: v.advisoryCves ?? 0 });\n return t(v.reviewed ? \"linkedinPosts.whyClassReviewed\" : \"linkedinPosts.whyClassInferred\");\n case \"variety\":\n return this.componentWhy(c);\n }\n return \"\";\n }\n\n // The reason behind a component, from the numbers stored with it.\n private componentWhy(c: ScoreComponent): string {\n const v = c.values || {};\n switch (c.key) {\n case \"fresh\":\n return v.hoursSinceListing != null\n ? t(\"linkedinPosts.whyFresh\", { hours: fmt1(v.hoursSinceListing) }) : \"\";\n case \"momentum\":\n return t(\"linkedinPosts.whyMomentum\", { d7: v.listings7d ?? 0, d1: v.listings24h ?? 0 });\n case \"protection\": {\n const count = t(\"linkedinPosts.whyProtection\", { n: v.protectable ?? 0, total: v.advisoryCves ?? 0 });\n if (v.serverPct != null) {\n return `${count} \u00B7 ${t(\"linkedinPosts.whyProtectionServer\", {\n s: v.serverCves ?? 0, pct: v.serverPct, p: fmt1(v.riskProtected ?? 0), t: fmt1(v.riskTotal ?? 0) })}`;\n }\n // Scoped, but a server CVE has no CVSS score: the count alone.\n if (v.serverCves != null) {\n return `${count} \u00B7 ${t(\"linkedinPosts.whyProtectionServerCount\", { s: v.serverCves })}`;\n }\n return v.weightedPct != null\n ? `${count} \u00B7 ${t(\"linkedinPosts.whyProtectionWeighted\", {\n pct: v.weightedPct, p: fmt1(v.riskProtected ?? 0), t: fmt1(v.riskTotal ?? 0) })}`\n : count;\n }\n case \"story\": {\n const bits: string[] = [];\n if (v.sectorKnown) bits.push(t(\"linkedinPosts.whyStorySector\"));\n if (v.countryKnown) bits.push(t(\"linkedinPosts.whyStoryCountry\"));\n if (v.listedToday) bits.push(t(\"linkedinPosts.whyStoryToday\"));\n return bits.length ? bits.join(\" \u00B7 \") : t(\"linkedinPosts.whyStoryNone\");\n }\n case \"variety\": {\n const bits: string[] = [];\n if (v.notPostedInDays != null) bits.push(t(\"linkedinPosts.whyVarietyNew\", { days: v.notPostedInDays }));\n if (v.daysSincePosted != null) bits.push(t(\"linkedinPosts.whyVarietyPosted\", { days: v.daysSincePosted }));\n if (v.daysSinceSkipped != null) bits.push(t(\"linkedinPosts.whyVarietySkipped\", { days: v.daysSinceSkipped }));\n if (v.sameSectorRecently) bits.push(t(\"linkedinPosts.whyVarietySector\"));\n return bits.join(\" \u00B7 \");\n }\n }\n return \"\";\n }\n\n // \u2500\u2500 summary \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n // Collapse wiring for the summary shell, as on AI Visibility. Open by\n // default; charts drawn while collapsed come out 0x0, so opening redraws.\n private wireSummary(): void {\n const root = this.el(\"lpSummary\");\n const toggle = root?.querySelector(\".sc-summary-toggle\");\n if (!root || !toggle) return;\n const apply = (collapsed: boolean) => {\n root.classList.toggle(\"collapsed\", collapsed);\n toggle.setAttribute(\"aria-expanded\", String(!collapsed));\n };\n let collapsed = false;\n try { collapsed = localStorage.getItem(SUMMARY_KEY) === \"1\"; } catch { /* open by default */ }\n apply(collapsed);\n toggle.onclick = () => {\n const next = !root.classList.contains(\"collapsed\");\n try { localStorage.setItem(SUMMARY_KEY, next ? \"1\" : \"0\"); } catch { /* the choice lasts the visit */ }\n apply(next);\n if (!next) this.renderSummary();\n };\n }\n\n private destroyCharts(): void {\n this.charts.forEach(destroyChart);\n this.charts = [];\n }\n\n private renderSummary(): void {\n const root = this.el(\"lpSummary\");\n if (!root || !this.posts.length) return;\n const oldestFirst = [...this.posts].sort((a, b) =>\n a.postDate.localeCompare(b.postDate) || a.id - b.id);\n const first = oldestFirst[0];\n\n const sub = this.el(\"lpSummarySub\");\n if (sub) {\n sub.textContent = t(this.posts.length === 1 ? \"linkedinPosts.sumSpanOne\" : \"linkedinPosts.sumSpan\",\n { n: this.posts.length, day: formatDay(first.postDate) });\n }\n if (root.classList.contains(\"collapsed\")) return;\n this.destroyCharts();\n\n // One point per calendar day from the first post to today, for the\n // sparklines: a day with no post is a zero, not a gap.\n // Days are UTC dates, as a post day and the server's visit days are,\n // ending at UTC today: a local calendar would drop the newest UTC\n // day's visits every evening west of UTC. UTC has no clock changes,\n // so a day is 24h. At most the last 400 (the visits the server keeps).\n const days: string[] = [];\n const now = new Date();\n const todayUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());\n const start = Math.max(Date.parse(first.postDate.slice(0, 10) + \"T00:00:00Z\"), todayUtc - 399 * 86400000);\n for (let ms = start; ms <= todayUtc; ms += 86400000) {\n days.push(new Date(ms).toISOString().slice(0, 10));\n }\n const perDay = (f: (p: LinkedInPost) => number) => {\n const m = new Map();\n for (const p of this.posts) m.set(p.postDate.slice(0, 10), (m.get(p.postDate.slice(0, 10)) ?? 0) + f(p));\n return days.map(d => m.get(d) ?? 0);\n };\n\n const waiting = oldestFirst.filter(p => p.status === \"proposed\");\n const published = this.posts.filter(p => p.status === \"published\");\n const visitsOf = (p: LinkedInPost) => Math.max(0, this.visits.get(p.id) ?? 0);\n // The card and its chart count the same thing: visits that happened\n // in the range, from the server's days.\n const visitsByDay = days.map(d => this.visitsByDay.get(d) ?? 0);\n const visits = visitsByDay.reduce((a, n) => a + n, 0);\n const top = [...this.posts].sort((a, b) => visitsOf(b) - visitsOf(a))[0];\n const stats = [...this.liStats.values()];\n const impressions = stats.reduce((a, st) => a + (st.impressions || 0), 0);\n\n const cards: SCStatCard[] = [\n {\n label: statusLabel(\"proposed\"),\n value: waiting.length,\n color: waiting.length ? STATUS_COLOR.proposed : \"#374151\",\n sub: waiting.length ? t(\"linkedinPosts.sumOldest\", { day: formatDay(waiting[0].postDate) }) : undefined,\n // Opens the one that has waited longest.\n onClick: waiting.length ? () => this.openPost(waiting[0].id) : undefined,\n ariaLabel: waiting.length ? t(\"linkedinPosts.sumOpenOldest\") : undefined,\n },\n {\n label: statusLabel(\"published\"),\n value: published.length,\n color: STATUS_COLOR.published,\n series: perDay(p => p.status === \"published\" ? 1 : 0),\n sub: published.length\n ? t(\"linkedinPosts.sumLatest\", { day: formatDay(published.map(p => p.postDate).sort().pop() || \"\") })\n : undefined,\n },\n {\n label: t(\"linkedinPosts.colVisits\"),\n value: visits,\n // Visits that could not be read are a dash, not a zero.\n valueText: this.visitsRead ? undefined : \"\u2014\",\n color: \"#0f766e\",\n series: this.visitsRead ? visitsByDay : undefined,\n sub: top && visitsOf(top) > 0\n ? t(\"linkedinPosts.sumTopVisits\", { group: top.groupName, day: formatDay(top.postDate), n: visitsOf(top) })\n : undefined,\n },\n {\n label: t(\"linkedinPosts.colImpressions\"),\n value: impressions,\n // Unread is not zero: a dash until LinkedIn's figures arrive.\n valueText: stats.length ? undefined : \"\u2014\",\n color: \"#374151\",\n series: stats.length ? perDay(p => this.liStats.get(p.id)?.impressions ?? 0) : undefined,\n // Connected but not read yet is not \"not connected\".\n sub: stats.length || this.connection?.connected ? undefined : t(\"linkedinPosts.sumNotConnected\"),\n },\n ];\n const cardBox = this.el(\"lpCards\");\n if (cardBox) renderStatCards(cardBox, cards);\n\n const donutHost = this.el(\"lpStatusDonut\");\n if (donutHost) {\n donutHost.innerHTML = `
${esc(t(\"linkedinPosts.sumByStatus\"))}
\n
\n
\n
\n
`;\n const segs = STATUS_ORDER\n .map(k => ({ label: statusLabel(k), value: this.posts.filter(p => p.status === k).length, color: STATUS_COLOR[k] }))\n .filter(x => x.value > 0);\n this.charts.push(renderDonut(\n donutHost.querySelector(\"canvas\") as HTMLCanvasElement,\n donutHost.querySelector(\".sc-donut-center\"),\n donutHost.querySelector(\".sc-donut-legend\"),\n segs, { centerLabel: t(\"linkedinPosts.sumPosts\") }));\n }\n\n // Each day's score, the skipped ones apart, so a run of skips shows\n // whether the pick is choosing well.\n const scoreCanvas = this.el(\"lpScoreChart\");\n if (scoreCanvas) {\n const pts = (keep: (p: LinkedInPost) => boolean) =>\n oldestFirst.filter(keep).map(p => ({ x: dayMs(p.postDate), y: Math.round(p.score * 10) / 10 }));\n const series = [{ label: t(\"linkedinPosts.sumKept\"), color: STATUS_COLOR.published, points: pts(p => p.status !== \"skipped\") }];\n const skipped = pts(p => p.status === \"skipped\");\n if (skipped.length) series.push({ label: statusLabel(\"skipped\"), color: STATUS_COLOR.skipped, points: skipped });\n this.charts.push(renderTrendLine(scoreCanvas, series, { timeUnit: \"day\", tooltipFormat: \"PP\" }));\n }\n\n const visitsCanvas = this.el(\"lpVisitsChart\");\n const visitsWrap = visitsCanvas?.parentElement;\n visitsWrap?.querySelector(\".sc-donut-empty\")?.remove();\n if (visitsCanvas) visitsCanvas.hidden = !this.visitsRead;\n if (visitsCanvas && !this.visitsRead) {\n const none = document.createElement(\"div\");\n none.className = \"sc-donut-empty\";\n none.textContent = t(\"body.noData\");\n visitsWrap?.appendChild(none);\n } else if (visitsCanvas) {\n this.charts.push(renderTrendLine(visitsCanvas, [{\n label: t(\"linkedinPosts.colVisits\"), color: \"#0f766e\", fill: true,\n points: days.map(d => ({ x: dayMs(d), y: this.visitsByDay.get(d) ?? 0 })),\n }], { timeUnit: \"day\", tooltipFormat: \"PP\" }));\n }\n }\n\n // \u2500\u2500 history \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n // One page needs no pager, \"Showing 1 to 2 of 2\" or page-length menu:\n // hidden until there is a second page. Run on every draw, and once more\n // after setupDataTableUI, which moves the length menu into the card's\n // toolbar after the first draw.\n private togglePager(api: any): void {\n const one = !api || api.page.info().pages <= 1;\n const wrap = document.getElementById(\"lpHistoryTable_wrapper\");\n const len = document.querySelector(\"#lpHistoryCard select.dt-input\")?.parentElement;\n [...(wrap?.querySelectorAll(\".dt-paging, .dt-info\") ?? []), len]\n // !important: the length menu's d-inline-flex is itself\n // !important and would win over a plain inline style.\n .forEach(e => {\n if (!e) return;\n if (one) e.style.setProperty(\"display\", \"none\", \"important\");\n else e.style.removeProperty(\"display\");\n });\n }\n\n // Every row the table has drawn, on every page, not only the page shown.\n private highlightRows(): void {\n const nodes: HTMLTableRowElement[] = this.historyTable ? this.historyTable.rows().nodes().toArray() : [];\n nodes.forEach(tr => tr?.classList.toggle(\"lp-history-on\", Number(tr.dataset.id) === this.selectedId));\n }\n\n private renderHistory(): void {\n const el = this.el(\"lpHistoryTable\");\n if (!el || typeof $ === \"undefined\") return;\n\n const rows = this.posts.map(p => ({\n ...p,\n statusLabel: statusLabel(p.status),\n decided: p.actedBy || \"\",\n visits: this.visits.get(p.id) ?? -1,\n visitors: this.visitDetail.get(p.id)?.visitors ?? -1,\n lastVisit: this.visitDetail.get(p.id)?.lastVisitAt || \"\",\n impressions: this.liStats.get(p.id)?.impressions ?? -1,\n clicks: this.liStats.get(p.id)?.clicks ?? -1,\n reactions: this.liStats.get(p.id)?.reactions ?? -1,\n opening: firstLine(p.commentary),\n }));\n\n // After an action on this mount, the rows are swapped in place so the\n // table keeps its order, filters and page. It is built afresh only on\n // a new mount: paging is always on (its controls hidden while there is\n // one page), so no row count forces a rebuild.\n if (this.historyTable && this.historyTable.table().node() === el) {\n this.historyTable.clear().rows.add(rows).draw(false);\n this.highlightRows();\n return;\n }\n if (this.historyTable) {\n try { this.historyTable.destroy(); } catch { /* fragment was re-rendered */ }\n this.historyTable = null;\n }\n $(el).find(\"tbody\").empty();\n\n const count = (d: number, type: string) =>\n type === \"display\" ? (d < 0 ? \"\u2014\" : d.toLocaleString(getLocale())) : d;\n this.historyTable = $(el).DataTable({\n data: rows,\n paging: true,\n searching: true,\n info: true,\n autoWidth: false,\n pageLength: 25,\n dom: DOM_STRING,\n order: [[0, \"desc\"]],\n columns: [\n { data: \"postDate\", title: t(\"linkedinPosts.colDay\"), width: \"14ch\",\n render: (d: string, type: string) => type === \"display\" ? esc(formatDay(d)) : d },\n { data: \"groupName\", title: t(\"linkedinPosts.colGroup\"), width: \"16ch\",\n render: (d: string) => esc(d) },\n { data: \"statusLabel\", title: t(\"linkedinPosts.colStatus\"), width: \"20ch\",\n render: (d: string, type: string, row: LinkedInPost) =>\n type === \"display\" ? this.statusPill(row) : d },\n { data: \"score\", title: t(\"linkedinPosts.colScore\"), width: \"9ch\", className: \"text-end\",\n render: (d: number, type: string) => type === \"display\" ? fmt1(d) : d },\n { data: \"visits\", title: t(\"linkedinPosts.colVisits\"), width: \"9ch\", className: \"text-end\",\n render: count },\n { data: \"visitors\", title: t(\"linkedinPosts.colVisitors\"), width: \"9ch\", className: \"text-end\",\n render: count },\n // The instant sorts; the search box matches the date as shown.\n { data: \"lastVisit\", title: t(\"linkedinPosts.colLastVisit\"), width: \"18ch\",\n render: (d: string, type: string) => type === \"sort\" || type === \"type\"\n ? (d ? new Date(d).getTime() : 0)\n : type === \"display\" ? (d ? esc(formatWhen(d)) : \"\u2014\")\n : (d ? formatWhen(d) : \"\") },\n // LinkedIn's own figures (ENG-4692); a dash until read.\n { data: \"impressions\", title: t(\"linkedinPosts.colImpressions\"), width: \"11ch\", className: \"text-end\",\n render: count },\n { data: \"clicks\", title: t(\"linkedinPosts.colClicks\"), width: \"8ch\", className: \"text-end\",\n render: count },\n { data: \"reactions\", title: t(\"linkedinPosts.colReactions\"), width: \"10ch\", className: \"text-end\",\n render: count },\n { data: \"decided\", title: t(\"linkedinPosts.colDecidedBy\"), width: \"24ch\",\n render: (d: string) => esc(d) },\n // One line: the whole post is in the drawer, and the full\n // opening is on hover.\n { data: \"opening\", title: t(\"linkedinPosts.colOpening\"), className: \"lp-opening\",\n render: (d: string, type: string) => type === \"display\"\n ? `${esc(d)}` : d },\n ],\n columnDefs: [\n { targets: [1, 2, 10], columnControl: [[\"orderAsc\", \"orderDesc\", \"searchList\"]] },\n { targets: [0, 3, 4, 5, 6, 7, 8, 9], columnControl: [[\"orderAsc\", \"orderDesc\"]] },\n { targets: [11], columnControl: [[\"orderAsc\", \"orderDesc\", \"searchText\"]] },\n ],\n language: { emptyTable: t(\"linkedinPosts.empty\"), search: \"\" },\n headerCallback: (thead: any) => {\n (thead as HTMLTableSectionElement).classList.add(\"custom-thead\");\n },\n // From this draw's settings: on the first draw the table handle\n // is not assigned yet.\n drawCallback: (settings: any) => this.togglePager(new $.fn.dataTable.Api(settings)),\n createdRow: (row: HTMLTableRowElement, data: LinkedInPost) => {\n row.classList.add(\"lp-history-row\");\n row.classList.toggle(\"lp-history-on\", data.id === this.selectedId);\n row.dataset.id = String(data.id);\n // Tab reaches every post; Enter or Space opens it (below).\n row.tabIndex = 0;\n },\n });\n\n setupDataTableUI(\"lpHistoryTable\", this.historyTable, {\n columnControl: {\n dropdownMaterialIcon: \"filter_list\", scrollHeadThClass: \"filter-right\",\n fixHeaderLayout: true, cascadeSearchLists: true,\n },\n autoResize: { namespace: \"linkedin-posts-history\" },\n // Bound on the tbody, so the selector is matched inside it: a\n // leading \"tbody\" never matches and the click is lost.\n domEvents: [{\n event: \"click\",\n selector: \"tr.lp-history-row\",\n handler: (ev: Event) => {\n const tr = (ev.target as HTMLElement).closest(\"tr.lp-history-row\");\n this.openPost(Number(tr?.dataset.id || 0));\n },\n }, {\n event: \"keydown\",\n selector: \"tr.lp-history-row\",\n handler: (ev: KeyboardEvent) => {\n if (ev.key !== \"Enter\" && ev.key !== \" \") return;\n const tr = (ev.target as HTMLElement).closest(\"tr.lp-history-row\");\n // Only the row itself: a key in a link or control inside it is that control's.\n if (!tr || ev.target !== tr) return;\n ev.preventDefault();\n this.openPost(Number(tr.dataset.id || 0));\n },\n }],\n });\n this.togglePager(this.historyTable);\n }\n}\n\n// \u2500\u2500 helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nfunction esc(s: string): string {\n return String(s ?? \"\").replace(/[&<>\"']/g, c =>\n ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", \"\\\"\": \""\", \"'\": \"'\" }[c] as string));\n}\n\nfunction escAttr(s: string): string {\n return esc(s);\n}\n\n// data-tooltip text goes through escAttr(esc(\u2026)): the attribute is read back\n// unescaped and CustomTooltips sets it as innerHTML, so one escape would let\n// post text (written from source names: a filer, a group) run as markup.\n\nfunction fmt1(n: number): string {\n return (Math.round((n || 0) * 10) / 10).toLocaleString(getLocale(), { maximumFractionDigits: 1 });\n}\n\nfunction signed(n: number): string {\n return (n > 0 ? \"+\" : n < 0 ? \"\u2212\" : \"\") + fmt1(Math.abs(n));\n}\n\nfunction firstLine(s: string): string {\n return (s || \"\").split(\"\\n\")[0];\n}\n\n// A post day as local midnight, for chart axes that draw in local time.\nfunction dayMs(d: string): number {\n const m = /^(\\d{4})-(\\d{2})-(\\d{2})/.exec(d || \"\");\n return m ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).getTime() : NaN;\n}\n\n// A post day is a calendar date; drawn without a time zone shift.\nfunction formatDay(d: string): string {\n const m = /^(\\d{4})-(\\d{2})-(\\d{2})/.exec(d || \"\");\n if (!m) return d || \"\";\n const date = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));\n return date.toLocaleDateString(getLocale(), { year: \"numeric\", month: \"short\", day: \"numeric\", timeZone: \"UTC\" });\n}\n\nfunction formatWhen(iso: string): string {\n const d = new Date(iso);\n if (isNaN(d.getTime())) return iso;\n return d.toLocaleString(getLocale(), {\n year: \"numeric\", month: \"short\", day: \"numeric\", hour: \"2-digit\", minute: \"2-digit\",\n timeZoneName: \"short\",\n });\n}\n\n// The age LinkedIn prints on a post: minutes, hours, days, weeks.\nfunction relativeTime(iso: string): string {\n const mins = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 60000));\n if (!isFinite(mins) || mins < 1) return t(\"linkedinPosts.liNow\");\n if (mins < 60) return t(\"linkedinPosts.liMinutes\", { n: mins });\n if (mins < 60 * 24) return t(\"linkedinPosts.liHours\", { n: Math.round(mins / 60) });\n if (mins < 60 * 24 * 7) return t(\"linkedinPosts.liDays\", { n: Math.round(mins / 1440) });\n return t(\"linkedinPosts.liWeeks\", { n: Math.round(mins / 10080) });\n}\n\n// The text as LinkedIn draws it: hashtags and links in its blue, and the\n// post's own link shown the way LinkedIn shortens it. Escaped first; the only\n// markup is what is added here.\nfunction renderCommentary(text: string, linkUrl: string): string {\n const parts = String(text || \"\").split(/(https?:\\/\\/[^\\s]+|#[\\p{L}\\p{N}_]+)/u);\n return parts.map(part => {\n if (/^https?:\\/\\//.test(part)) {\n const shown = part === linkUrl || part.length > 26 ? \"https://lnkd.in/\u2026\" : part; // i18n-ignore\n return `${esc(shown)}`;\n }\n if (/^#[\\p{L}\\p{N}_]+$/u.test(part)) return `${esc(part)}`;\n return esc(part);\n }).join(\"\");\n}\n", "// Drawer \u2014 shared base for the slide-out side panels we use across the\n// app (PathEventsDrawer, EventDetailDrawer, and the upcoming\n// Applications / Services / Executables drawers).\n//\n// Why this exists: the original two drawers each duplicated ~150 lines\n// of mount + style + close + Esc + backdrop logic, and they hard-coded\n// stacking via a brittle \"if a higher-z drawer is open, skip Esc\" check.\n// As we add more drawers (and start nesting them three deep) that\n// approach falls apart.\n//\n// What this provides:\n// - `Drawer` class \u2014 owns the
\");\n return (div.textContent || \"\").replace(/\\s+/g, \" \").trim();\n}\n// Gap between the end of a bar and its pill.\nconst GEO_PILL_GAP_PX = 8;\n\n// Puts each pill just past the end of its bar, centred on the bar's row.\n// Reads the chart's scales, so it runs after layout; the pill clamps to\n// the plot's right edge rather than spilling out of the card when the\n// longest bar is a tagged one.\nfunction placeGeoPills(\n chart: any, overlay: HTMLElement | null,\n rows: Array<{ views: number; signals?: string[] }>,\n): void {\n if (!overlay) return;\n const x = chart.scales?.x, y = chart.scales?.y;\n if (!x || !y) return;\n overlay.querySelectorAll(\".av-geo-pill-row\").forEach(tag => {\n const i = Number(tag.dataset.row);\n const r = rows[i];\n if (!r) return;\n const left = Math.min(x.getPixelForValue(r.views) + GEO_PILL_GAP_PX,\n chart.chartArea.right - tag.offsetWidth);\n tag.style.left = `${Math.max(chart.chartArea.left, left)}px`;\n tag.style.top = `${y.getPixelForValue(i)}px`;\n });\n}\n\n// One bar of the location or network chart, whatever grain produced it.\ninterface GeoRow {\n label: string; views: number; visitors: number; aiViews: number;\n signals?: string[]; timezone?: string; clientTimezones?: string[];\n // Views under this bar that carried a tag, when the grain has\n // folded the tagged rows in rather than showing them.\n flaggedViews?: number;\n // The tooltip's heading: the place spelled out \u2014 \"Virginia, United\n // States\" over a bar labelled \"VA, US\" \u2014 so a code never has to be\n // looked up. Falls back to the label.\n title?: string;\n}\n\n// File downloads, from GET /api/ai-visibility/downloads. Two senders feed the\n// one ledger: the site's worker records each whitepaper PDF it serves\n// (via \"served\"), and the page beacon reports clicks on file links the worker\n// does not serve (via \"click\"). Visitors are visitor-days, as on page views.\ninterface DownloadFileRow {\n file: string; url: string; downloads: number; prevDownloads: number;\n visitors: number; aiDownloads: number; topPlace?: string; topSource?: string;\n lastAt: string;\n}\ninterface DownloadRow {\n file: string; url: string; sourcePath?: string; referrerHost?: string;\n channel: string; aiSource?: string; via: string; place?: string; seenAt: string;\n}\n\ninterface DownloadSummary {\n windowDays: number; downloads: number; prevDownloads: number; visitors: number;\n aiDownloads: number; prevAiDownloads: number;\n firstAt?: string; days: { day: string; downloads: number; aiDownloads: number }[];\n files: DownloadFileRow[]; recent: DownloadRow[];\n}\n\nexport class SiteTrafficUI {\n // Whole-ledger records. Loaded once per page mount, before the charts\n // they mark \u2014 see updateUI.\n private records: SiteRecordsResponse | null = null;\n private traffic: PageTrafficSummary | null = null;\n private trafficChart: any = null;\n private visitorsChart: any = null;\n private placesChart: any = null;\n private networksChart: any = null;\n private placesGrain: PlaceGrain = loadPlaceGrain();\n private trafficTable: any = null;\n private downloads: DownloadSummary | null = null;\n private downloadFilesTable: any = null;\n private downloadsChart: any = null;\n private downloadsTable: any = null;\n // The latest completed audit run, for the page-views table's two audit\n // columns. Null until it loads, or when there is no completed run; the\n // columns then read as not measured rather than as zero.\n private detail: LatestRunDetail | null = null;\n private readonly donuts = new Donuts();\n // The newest loadAll generation; see loadAll.\n private loadGen = 0;\n // The newest visit; see updateUI.\n private mountGen = 0;\n // True while the newest generation is in flight: the two ledgers may\n // still hold the previous window, so the page is not exported then.\n private loading = false;\n // Whether the newest ledger load had a request fail; see loadAll.\n private loadFailed = false;\n private readonly gate = new PageLoadGate(\"stContent\", () => t(\"common.loading\"),\n () => [document.getElementById(\"siteTrafficExportWrap\")]);\n private readonly header = new Header(\"site-traffic\");\n\n public async updateUI(): Promise {\n // Same icon as the sidebar entry \u2014 the page a nav item opens should\n // wear the mark the reader just clicked.\n // Navigation's Header.clear() hides the message bar, and showMessage\n // throws on a hidden bar: this page's export notices need it.\n this.header.enableMessageBar();\n this.header.set({\n title: t(\"siteTraffic.title\"),\n icon: { fontAwesome: \"fa-chart-line\" },\n });\n carryColumnLayouts(MOVED_TABLES, \"site-traffic\");\n // This object outlives the fragment, so on a return visit it still\n // holds the last visit's payloads. Export stays blocked from before\n // the menu is mounted until this mount's first load commits.\n this.loading = true;\n // Each visit is a generation of its own, apart from the window's: a\n // previous visit's preloads still in flight must not write into this\n // fragment, but a window change during this visit's preloads must not\n // cancel them either \u2014 records and the audit run do not depend on\n // the window, and nothing would load them again.\n const mount = ++this.mountGen;\n // Supersedes a previous visit's ledger loads still in flight, and\n // drops its payloads: expanding the summary before this visit's\n // loads land must not draw the last visit's window.\n ++this.loadGen;\n this.traffic = null;\n this.downloads = null;\n this.records = null;\n this.detail = null;\n this.wire();\n this.gate.begin(); // after wire(), which mounts the Export\n // Before the two ledgers: their daily charts mark the days that hold\n // a record, so the marks are on the first paint rather than arriving\n // a beat later.\n await this.loadRecords(mount);\n if (mount !== this.mountGen) return;\n await this.loadLatestRun(mount);\n if (mount !== this.mountGen) return;\n // Also after a window change during the preloads: that load drew\n // without the records and the audit run, and this one draws with\n // them, on the window the select now holds.\n await this.loadAll();\n }\n\n private el(id: string): T | null {\n return document.getElementById(id) as T | null;\n }\n\n // Every card follows the window, except the records board, which is\n // whole-ledger by definition. The two ledgers are fetched side by side\n // and committed together.\n //\n // Each call is a generation. Switching the window twice quickly starts\n // two overlapping sets of loads; only the newest generation may commit\n // its answers, so an older response landing last can neither replace\n // the newer window nor leave the two ledgers on different windows.\n private async loadAll(): Promise {\n const gen = ++this.loadGen;\n const days = this.windowDays();\n this.loading = true;\n this.gate.begin();\n // Both fetched, then committed and drawn together: drawn as each\n // landed, the tiles and summary line could pair one window's views\n // with the other window's downloads until the slower one arrived.\n const [traffic, downloads] = await Promise.all([\n this.fetchTraffic(days),\n this.fetchDownloads(days),\n ]);\n if (gen !== this.loadGen) return;\n this.traffic = traffic;\n this.downloads = downloads;\n // A fetch that failed answers null (an empty ledger answers a\n // summary with zeros), and an export without it would drop that\n // section without saying so.\n this.loadFailed = !traffic || !downloads;\n this.loading = false;\n if (!traffic && !downloads && this.gate.fail(t(\"siteTraffic.ledgerFailed\"))) return;\n this.gate.open();\n this.renderTraffic();\n this.renderDownloads();\n this.renderTiles();\n this.renderSummarySub();\n }\n\n // Handlers are attached once per mounted fragment. The SPA replaces the\n // fragment on navigation, so the marker lives on the fragment itself:\n // a fresh mount is wired again, and a refresh of the same mount is not\n // wired twice.\n private wire(): void {\n const root = this.el(\"stPage\");\n if (!root || root.dataset.wired) return;\n root.dataset.wired = \"1\";\n seedWindowFromLink(\"stWindowSelect\");\n this.el(\"stWindowSelect\")?.addEventListener(\"change\", () => void this.loadAll());\n const jumps: Array<[string, string]> = [\n [\"avTrafficSummaryCard\", \"avTrafficCard\"],\n [\"avVisitorsSummaryCard\", \"avTrafficCard\"],\n [\"avPlacesCard\", \"avTrafficCard\"],\n [\"avNetworksCard\", \"avTrafficCard\"],\n [\"avDownloadsTrendCard\", \"avDownloadsCard\"],\n [\"avDownloadsDonutCard\", \"avDownloadsCard\"],\n ];\n for (const [id, target] of jumps) wireJump(id, target);\n // The grain control lives inside the location jump card; wireJump\n // leaves a click on a control inside the card alone.\n const grain = this.el(\"avPlacesGrain\");\n grain?.addEventListener(\"click\", (e: MouseEvent) => {\n const btn = (e.target as HTMLElement).closest(\"[data-grain]\");\n const g = btn?.dataset.grain as PlaceGrain | undefined;\n if (!g || !PLACE_GRAINS.includes(g) || g === this.placesGrain) return;\n this.placesGrain = g;\n savePlaceGrain(g);\n this.syncGrainButtons();\n this.renderTrafficGeography();\n });\n this.syncGrainButtons();\n const exportHost = this.el(\"stWindowSelect\")?.parentElement;\n if (exportHost) mountFullExportMenu(exportHost, \"siteTrafficExport\", (a) => this.runExport(a));\n setupCollapse(\"stSummary\", \"stSummaryCollapsed\", () => this.renderCharts());\n focusableTooltips(root);\n }\n\n // The window select drives every card but the records board.\n private windowDays(): number {\n const v = Number(this.el(\"stWindowSelect\")?.value);\n return Number.isFinite(v) && v > 0 ? v : DEFAULT_WINDOW_DAYS;\n }\n\n // The latest completed audit run, for the two audit columns in the\n // page-views table. Never fatal: the table renders without them, and the\n // columns then read as not measured.\n //\n // The summary's own default window, not limit=1: latest is the newest\n // COMPLETED run among those it reads, and with one run read it is null\n // for the half hour the nightly audit is in flight and all day after a\n // run fails \u2014 while a perfectly good completed run sits one row back.\n private async loadLatestRun(mount: number): Promise {\n let detail: LatestRunDetail | null = null;\n try {\n const res = await apiFetch(\"/api/ai-visibility/summary\");\n if (!res.ok) throw new Error(`HTTP ${res.status}`);\n const summary = await res.json();\n const id = summary?.latest?.id;\n if (id) {\n const run = await apiFetch(`/api/ai-visibility/runs/${id}`);\n if (!run.ok) throw new Error(`HTTP ${run.status}`);\n detail = await run.json();\n }\n } catch (e) {\n console.warn(\"[SiteTrafficUI] latest audit run unavailable\", e);\n }\n if (mount !== this.mountGen) return;\n this.detail = detail;\n }\n\n // Expanding the summary re-draws every chart in it: a chart sized while\n // its card was collapsed paints 0x0.\n // Charts only: the tables below keep the reader's search, filters, sort\n // and page when the summary opens.\n private renderCharts(): void {\n this.renderTrafficChart();\n this.renderVisitorsChart();\n this.renderTrafficGeography();\n this.renderDownloadCharts();\n }\n\n // Four headline numbers, each a provenance link to the table it is\n // computed from.\n private renderTiles(): void {\n const box = this.el(\"stTiles\");\n if (!box) return;\n const toTraffic = {\n caption: t(\"aiVisibility.fromTraffic\"),\n ariaLabel: t(\"aiVisibility.jumpTraffic\"),\n onClick: () => revealSource(\"avTrafficCard\"),\n };\n const toDownloads = {\n caption: t(\"aiVisibility.fromDownloads\"),\n ariaLabel: t(\"aiVisibility.jumpDownloads\"),\n onClick: () => revealSource(\"avDownloadsCard\"),\n };\n const traffic = this.traffic;\n const aiShare = traffic && traffic.views > 0\n ? Math.round((100 * traffic.aiViews) / traffic.views) : 0;\n const dl = this.downloads;\n const dlAIShare = dl && dl.downloads > 0\n ? Math.round((100 * dl.aiDownloads) / dl.downloads) : 0;\n\n const cards: SCStatCard[] = [\n {\n ...toTraffic,\n label: t(\"aiVisibility.traffic\"),\n value: traffic?.views ?? 0,\n valueText: traffic && traffic.views > 0 ? traffic.views.toLocaleString(getLocale()) : \"\u2014\",\n // Views alone would be a number with no scale on it. The\n // visitor count says how many people that was, and the delta\n // says which way it is going.\n sub: traffic && traffic.views > 0\n ? t(\"aiVisibility.pageViewsSub\", {\n visitors: traffic.visitors.toLocaleString(getLocale()),\n delta: pctDelta(traffic.views, traffic.prevViews),\n days: String(traffic.windowDays),\n }) : \"\",\n color: INK,\n series: (traffic?.days || []).map(d => d.views),\n info: t(\"aiVisibility.tipPageViews\"),\n },\n {\n // The same figure AI Visibility shows beside its score. There\n // it lands on the ai_referred check the number decides; here\n // it lands on the rows it is counted from.\n ...toTraffic,\n label: t(\"aiVisibility.aiReferred\"),\n value: traffic?.aiViews ?? 0,\n valueText: traffic && traffic.views > 0 ? traffic.aiViews.toLocaleString(getLocale()) : \"\u2014\",\n sub: traffic && traffic.views > 0\n ? t(\"aiVisibility.aiReferredShareDelta\", {\n pct: String(aiShare),\n delta: pctDelta(traffic.aiViews, traffic.prevAiViews),\n days: String(traffic.windowDays),\n }) : \"\",\n color: (traffic?.aiViews ?? 0) > 0 ? GOLD : MUTED,\n series: (traffic?.days || []).map(d => d.ai),\n info: t(\"siteTraffic.tipAIReferred\"),\n },\n {\n ...toDownloads,\n label: t(\"aiVisibility.downloads\"),\n value: dl?.downloads ?? 0,\n valueText: dl && dl.downloads > 0 ? dl.downloads.toLocaleString(getLocale()) : \"\u2014\",\n sub: dl && dl.downloads > 0\n ? t(\"aiVisibility.dlTileSub\", {\n visitors: dl.visitors.toLocaleString(getLocale()),\n delta: pctDelta(dl.downloads, dl.prevDownloads),\n days: String(dl.windowDays),\n }) : \"\",\n color: INK,\n series: (dl?.days || []).map(d => d.downloads),\n info: t(\"aiVisibility.tipDownloads\"),\n },\n {\n ...toDownloads,\n label: t(\"aiVisibility.dlAITile\"),\n value: dl?.aiDownloads ?? 0,\n valueText: dl && dl.downloads > 0 ? dl.aiDownloads.toLocaleString(getLocale()) : \"\u2014\",\n sub: dl && dl.downloads > 0\n ? t(\"aiVisibility.dlAIShareDelta\", {\n pct: String(dlAIShare),\n delta: pctDelta(dl.aiDownloads, dl.prevAiDownloads),\n days: String(dl.windowDays),\n }) : \"\",\n color: (dl?.aiDownloads ?? 0) > 0 ? GOLD : MUTED,\n series: (dl?.days || []).map(d => d.aiDownloads),\n info: t(\"aiVisibility.tipDLAI\"),\n },\n ];\n renderStatCards(box, cards);\n }\n\n // Collapsed, the shell is the only thing left on screen, so it carries the\n // headline numbers itself \u2014 the same contract every summary shell keeps.\n private renderSummarySub(): void {\n const sub = this.el(\"stSummary\")?.querySelector(\".sc-summary-sub\");\n if (!sub) return;\n // The two ledgers are independent: the worker records a direct file\n // download even in a window with no page views, so the line shows\n // whenever either has something to say.\n // A ledger that failed to load is not a ledger of zero: say so\n // rather than print \"0 views\".\n if (this.loadFailed) { sub.textContent = `\u00B7 ${t(\"siteTraffic.ledgerFailed\")}`; return; }\n const views = this.traffic?.views ?? 0;\n const downloads = this.downloads?.downloads ?? 0;\n if (views === 0 && downloads === 0) { sub.textContent = \"\"; return; }\n sub.textContent = t(\"siteTraffic.summarySub\", {\n views: views.toLocaleString(getLocale()),\n visitors: (this.traffic?.visitors ?? 0).toLocaleString(getLocale()),\n downloads: downloads.toLocaleString(getLocale()),\n });\n }\n\n // \u2500\u2500 page export (PDF / PowerPoint / Word) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n // Whether the page may be exported now; says why not when it may not.\n // Asked before the export starts and again after awaiting the branding,\n // because a window change in between can start loads or fail one.\n private exportBlocked(): boolean {\n if (this.loading || (!this.traffic && !this.downloads)) {\n this.header.showMessage(t(\"body.nothingToExport\"), \"warning\", 5);\n return true;\n }\n if (this.loadFailed) {\n this.header.showMessage(t(\"body.exportIncomplete\"), \"warning\", 6);\n return true;\n }\n return false;\n }\n\n private async runExport(action: (doc: ReportDoc) => Promise): Promise {\n if (this.exportBlocked()) return;\n this.header.showLoading(t(\"body.buildingReport\"));\n try {\n // This page measures mimic.com itself, so the report carries the\n // product's own mark rather than a tenant's.\n const branding = await productBranding();\n if (this.exportBlocked()) return;\n await action(this.buildReportDoc(branding));\n } catch (err) {\n console.error(\"[SiteTrafficUI] export failed\", err);\n this.header.showMessage(\n t(\"body.exportFailed\", { error: escapeHtml(err instanceof Error ? err.message : String(err)) }), \"error\", 8);\n } finally {\n this.header.hideLoading();\n }\n }\n\n // The same order as the page: page views, then downloads.\n // Each section's note is the card's own tooltip, so the report carries\n // the method caveat the page does and says it in the reader's language.\n private buildReportDoc(branding: any): ReportDoc {\n // The window the exported payloads were read over \u2014 both ledgers are\n // on the same one once loading has finished \u2014 not the live select.\n const days = this.traffic?.windowDays ?? this.downloads?.windowDays ?? this.windowDays();\n const sections: ReportSection[] = [];\n\n const tr = this.traffic;\n if (tr && tr.views > 0) {\n const top = tr.pages.slice(0, 25); // copilot-lint-ignore: the report states the total (totalRows below)\n sections.push({\n heading: t(\"aiVisibility.traffic\"),\n subtitle: `${t(\"aiVisibility.trafficSummary\", {\n views: tr.views.toLocaleString(getLocale()),\n visitors: tr.visitors.toLocaleString(getLocale()),\n ai: tr.aiViews.toLocaleString(getLocale()),\n pct: String(Math.round((100 * tr.aiViews) / tr.views)),\n })} \u00B7 ${t(\"aiVisibility.trafficPrior\", {\n days: String(tr.windowDays),\n views: pctDelta(tr.views, tr.prevViews),\n ai: pctDelta(tr.aiViews, tr.prevAiViews),\n })}`,\n table: {\n headers: [t(\"aiVisibility.trafficPage\"), t(\"aiVisibility.trafficViews\"),\n t(\"aiVisibility.trafficViewsDelta\"), t(\"aiVisibility.trafficVisitors\"),\n t(\"aiVisibility.trafficAI\"), t(\"aiVisibility.trafficAIDelta\"),\n t(\"aiVisibility.trafficTopAI\"), t(\"aiVisibility.trafficCrawlerHits\")],\n totalRows: tr.pages.length,\n rows: top.map(p => [\n p.path, String(p.views), signedDelta(p.views, p.prevViews),\n String(p.visitors), String(p.aiViews), signedDelta(p.aiViews, p.prevAiViews),\n p.topAiSource ? aiSourceLabel(p.topAiSource) : \"\u2014\", String(p.crawlerHits),\n ]),\n },\n note: `${t(\"aiVisibility.tipPageViews\")} ${t(\"aiVisibility.trafficScope\")}`,\n });\n }\n\n const dl = this.downloads;\n if (dl && dl.downloads > 0) {\n sections.push({\n heading: t(\"aiVisibility.downloads\"),\n subtitle: `${t(\"aiVisibility.dlSummary\", {\n downloads: dl.downloads.toLocaleString(getLocale()),\n visitors: dl.visitors.toLocaleString(getLocale()),\n days: String(dl.windowDays),\n })} \u00B7 ${t(\"aiVisibility.dlPrior\", {\n delta: pctDelta(dl.downloads, dl.prevDownloads),\n days: String(dl.windowDays),\n })}`,\n table: {\n headers: [t(\"aiVisibility.dlFile\"), t(\"aiVisibility.dlDownloads\"),\n t(\"aiVisibility.dlDelta\"), t(\"aiVisibility.trafficAI\"),\n t(\"aiVisibility.trafficTopPlace\")],\n totalRows: dl.files.length,\n rows: dl.files.slice(0, 25).map(f => [\n f.file, String(f.downloads), signedDelta(f.downloads, f.prevDownloads),\n String(f.aiDownloads), placeCountryNamed(f.topPlace || \"\") || \"\u2014\",\n ]),\n },\n note: t(\"aiVisibility.tipDownloads\"),\n });\n }\n\n const today = new Date();\n return {\n title: t(\"siteTraffic.title\"),\n subtitle: `mimic.com \u2014 ${t(\"body.lastNDaysWindow\", { n: String(days) })}`,\n generatedAt: today,\n branding,\n sections,\n filenameBase: `site-traffic-${days}d-${today.toISOString().slice(0, 10)}`,\n };\n }\n\n // Page views and daily visitors share the first summary row. Both are\n // drawn from the same days, so they appear and disappear together; when\n // one card is left it takes the whole width rather than half a row with\n // dead space beside it.\n private layoutSeriesRow(): void {\n const ids = [\"avTrafficSummaryCard\", \"avVisitorsSummaryCard\"];\n const live = ids.filter(id => {\n const el = this.el(id);\n return Boolean(el) && el!.style.display !== \"none\";\n });\n for (const id of ids) {\n const el = this.el(id);\n if (!el) continue;\n el.classList.toggle(\"av-wide-card\", live.length === 1);\n el.classList.toggle(\"av-half-card\", live.length === 2);\n }\n }\n\n // \u2500\u2500 Site records \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n // Never fatal, like the two ledgers: the board is a second reading of\n // rows the cards already draw, and a page that failed to open because a\n // record query timed out would be a poor trade.\n private async loadRecords(mount: number): Promise {\n const data = await loadSiteRecords(\"SiteTrafficUI\");\n if (mount !== this.mountGen) return;\n this.records = data;\n this.renderRecords();\n }\n\n // Three columns: what the best day was, what happened first, and where the\n // site has been read. The card hides itself when it has nothing to show \u2014\n // an empty records board reads as a broken card rather than as a quiet\n // week. The score record comes from the audit runs, so a board on an empty\n // ledger can still show it.\n private renderRecords(): void {\n const card = this.el(\"avRecordsCard\");\n const box = this.el(\"avRecords\");\n const rec = this.records;\n const has = Boolean(rec && (rec.bests.length || rec.firsts.length || rec.stamps.length));\n if (card) card.style.display = has ? \"\" : \"none\";\n if (!box || !rec || !has) return;\n\n // Every record on this board is \"since the beacon started\", and on a\n // ledger this young that qualifier is most of the meaning.\n const since = this.el(\"avRecordsSince\");\n if (since) {\n since.textContent = rec.since\n ? t(\"aiVisibility.recordsSince\", { date: this.recordDate(rec.since) })\n : \"\";\n }\n\n box.innerHTML = [\n this.recordColumn(t(\"aiVisibility.recordsBests\"),\n rec.bests.map(r => this.recordRow(r)).join(\"\")),\n this.recordColumn(t(\"aiVisibility.recordsFirsts\"),\n rec.firsts.map(r => this.recordRow(r)).join(\"\")),\n this.recordColumn(t(\"aiVisibility.recordsPassport\"), this.passportRows(rec)),\n ].join(\"\");\n }\n\n private recordColumn(title: string, rows: string): string {\n return `

${esc(title)}

${rows}
`;\n }\n\n // One line: the number, what it is, and when it was set. A record with no\n // count \u2014 a first \u2014 is the same line without the number, so the two\n // columns align down the page.\n private recordRow(r: SiteRecord): string {\n // The score record is a percentage, to one decimal (the gauge rounds\n // to a whole number); every other record is a count.\n const value = typeof r.pct === \"number\"\n ? `${esc(r.pct.toLocaleString(getLocale(),\n { minimumFractionDigits: 1, maximumFractionDigits: 1 }))}%`\n : r.value > 0\n ? `${esc(r.value.toLocaleString(getLocale()))}` : \"\";\n // The path the page record belongs to, under its label: the record is\n // about that page, and on most days that page is the home page. The\n // score record names the audit run that set it.\n const detail = r.key === \"pageDay\" && r.detail\n ? `${esc(r.detail)}`\n : r.key === \"scoreRun\" && r.detail\n ? `${esc(t(\"aiVisibility.recordScoreRunDetail\", { run: r.detail }))}`\n : \"\";\n return `
${value}`\n + `${esc(recordLabel(r))}${detail}`\n + `${esc(this.recordWhen(r))}`\n + `${this.recordNote(r)}
`;\n }\n\n // The footnote on a day one visitor ran away with. It does not correct the\n // number \u2014 the record is what the ledger holds \u2014 it says what the number\n // is made of, which is the next question anyone asks.\n private recordNote(r: SiteRecord): string {\n if (!r.topShare) return \"\";\n const tip = t(\"aiVisibility.recordSweep\", { pct: String(r.topShare) });\n return `info`;\n }\n\n // When a record was set: the hour for an hour record, the instant for a\n // first, the day otherwise \u2014 plus how many days share it, because a\n // record held by two days is marked on two bars and the line should say\n // why.\n private recordWhen(r: SiteRecord): string {\n if (r.key === \"viewsHour\" && r.at) return this.recordHour(r.at);\n if (r.at && !r.days?.length) return this.recordDate(r.at);\n const days = r.days || [];\n if (!days.length) return \"\";\n const when = this.recordDate(days[0]);\n return days.length > 1\n ? `${when} \u00B7 ${t(\"aiVisibility.recordTied\", { n: String(days.length) })}`\n : when;\n }\n\n // Every date on this board is read in UTC, firsts included \u2014 see\n // bucketDate. The records are UTC day buckets and cannot be anything\n // else, so a first rendered in the reader's own zone would land a day\n // before the ledger it opened: \"first view Sep 8\" above a chart whose\n // first bar is Sep 9. One frame for the whole board, named in the tip.\n private recordDate(iso: string): string {\n return bucketDate(iso, { weekday: \"short\", month: \"short\", day: \"numeric\" });\n }\n\n // The hour record is the one row that shows a clock, so it is the one row\n // that has to say which clock: the zone is spelled out rather than left\n // to be assumed local.\n private recordHour(iso: string): string {\n const d = new Date(iso);\n return `${this.recordDate(iso)} `\n + `${d.toLocaleTimeString(getLocale(), { hour: \"numeric\", timeZone: \"UTC\", timeZoneName: \"short\" })}`;\n }\n\n // The passport: two counts, then the newest stamps. Newest rather than\n // biggest, because the news is the country that arrived this morning \u2014\n // that the site is read from the United States needs no re-telling.\n private passportRows(rec: SiteRecordsResponse): string {\n const count = (n: number, label: string) =>\n `
${esc(n.toLocaleString(getLocale()))}`\n + `${esc(label)}
`;\n const stamps = rec.stamps.map(st => {\n const name = countryName(st.detail || \"\");\n const flag = flagEmoji(st.detail || \"\");\n return ``\n + `${esc(flag)}${esc(name)}`\n + `${esc(st.at ? this.recordDate(st.at) : \"\")}`;\n }).join(\"\");\n return count(rec.countries, t(\"aiVisibility.recordCountries\"))\n + count(rec.cities, t(\"aiVisibility.recordCities\"))\n + (stamps\n ? `
${esc(t(\"aiVisibility.recordNewest\"))}
`\n + `
${stamps}
`\n : \"\");\n }\n\n // The record marks on this page's daily charts read this page's records.\n private marksFor(days: Array<{ day: string }>, key: string): Set {\n return recordMarks(this.records, days, key);\n }\n\n private footerFor(marks: Set, key: string): (items: any[]) => string {\n return recordFooter(this.records, marks, key);\n }\n\n // \u2500\u2500 Page views \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n // Never fatal: the page renders its other cards without it.\n private async fetchTraffic(days: number): Promise {\n let data: PageTrafficSummary | null = null;\n try {\n // Every page in the window (all=true): the Page views table\n // filters, searches and exports what it holds, so a capped load\n // would leave rows out of all three.\n const res = await apiFetch(`/api/ai-visibility/traffic?days=${days}&all=true`);\n if (!res.ok) throw new Error(`HTTP ${res.status}`);\n data = await res.json();\n } catch (e) {\n console.warn(\"[SiteTrafficUI] page views unavailable\", e);\n }\n return data;\n }\n\n private renderTraffic(): void {\n const note = this.el(\"avTrafficNote\");\n const sources = this.el(\"avTrafficSources\");\n const tr = this.traffic;\n const hasData = Boolean(tr && tr.views > 0);\n\n // The method travels with the number: a beacon count read as an\n // analytics figure is wrong in a known direction, and the note says\n // which.\n if (note) {\n note.textContent = tr && hasData\n ? `${t(\"aiVisibility.trafficSummary\", {\n views: tr.views.toLocaleString(getLocale()),\n visitors: tr.visitors.toLocaleString(getLocale()),\n ai: tr.aiViews.toLocaleString(getLocale()),\n pct: String(tr.views > 0 ? Math.round((100 * tr.aiViews) / tr.views) : 0),\n })} \u00B7 ${t(\"aiVisibility.trafficPrior\", {\n days: String(tr.windowDays),\n views: pctDelta(tr.views, tr.prevViews),\n ai: pctDelta(tr.aiViews, tr.prevAiViews),\n })}`\n // A failed load is not an empty ledger.\n : t(tr ? \"aiVisibility.trafficNone\" : \"siteTraffic.ledgerFailed\");\n }\n // The beacon's method caveat \u2014 a count is a floor, not an analytics\n // figure \u2014 belongs with the number it qualifies, and the title's info\n // icon is where every other card on this page keeps that kind of\n // sentence. As a paragraph above a table it read as a finding.\n const info = this.el(\"avTrafficSummaryCard\")?.querySelector(\".sc-stat-info\");\n if (info) {\n const tip = `${t(\"aiVisibility.tipPageViews\")} ${t(\"aiVisibility.trafficScope\")}`;\n info.setAttribute(\"data-tooltip\", tip);\n info.setAttribute(\"aria-label\", tip);\n }\n if (sources) {\n const list = (tr?.aiSources || []).map(s =>\n `${aiSourceLabel(s.source)} ${s.views.toLocaleString(getLocale())}`);\n sources.textContent = list.length\n ? `${t(\"aiVisibility.trafficBySource\")}: ${list.join(\" \u00B7 \")}` : \"\";\n sources.style.display = list.length ? \"\" : \"none\";\n }\n this.renderTrafficGeography();\n\n // The chart lives in the summary, in a card of its own; with no views\n // yet there is nothing to draw, and an empty plot in the middle of the\n // summary reads as a broken chart rather than as a quiet ledger. The\n // whole card goes, and the note above the table says why.\n const card = this.el(\"avTrafficSummaryCard\");\n if (card) card.style.display = hasData ? \"\" : \"none\";\n // Visitors are drawn from the same days, so the card appears and\n // disappears with the page-views card beside it.\n const vCard = this.el(\"avVisitorsSummaryCard\");\n if (vCard) vCard.style.display = hasData ? \"\" : \"none\";\n // The window's own visitor count, and how many views each of them\n // accounted for. Views-per-visitor is the one thing this pair says\n // that neither chart does alone: 1.2 is a fly-by, 4 is a reader.\n const vNote = this.el(\"avVisitorsNote\");\n if (vNote) {\n vNote.textContent = tr && hasData\n ? t(\"aiVisibility.visitorsSummary\", {\n visitors: tr.visitors.toLocaleString(getLocale()),\n ai: tr.aiVisitors.toLocaleString(getLocale()),\n per: fixed(tr.views / Math.max(tr.visitors, 1), 1),\n })\n : \"\";\n }\n this.layoutSeriesRow();\n\n // Called with or without data: each releases its old chart first.\n this.renderTrafficChart();\n this.renderVisitorsChart();\n this.renderTrafficTable();\n }\n\n // Where readers were and whose network they came off, as the two charts\n // in the summary. This was a pair of text lines above the table as well;\n // the lines are gone, because a bar chart and a sentence saying the same\n // thing in the same window is the reader doing the reconciling.\n private renderTrafficGeography(): void {\n const tr = this.traffic;\n const placeRows = this.placeRowsAt(tr?.places || [], this.placesGrain);\n const networkRows = (tr?.networks || []).map(n => ({\n label: n.organization\n || t(\"aiVisibility.trafficSmallNetworks\", { n: String(n.networks) }),\n views: n.views, visitors: n.visitors, aiViews: n.aiViews,\n }));\n this.renderGeoBars(\"avPlacesChart\", \"avPlacesCard\", placeRows, \"places\");\n this.renderGeoBars(\"avNetworksChart\", \"avNetworksCard\", networkRows, \"networks\");\n\n // The pair shares a row, but either half can be empty \u2014 networks are\n // empty until the edge starts reporting an ASN, and both are empty on\n // a ledger written before any of this. A lone card takes the whole\n // row instead of sitting in half of one with dead space beside it.\n const alone = (placeRows.length > 0) !== (networkRows.length > 0);\n for (const id of [\"avPlacesCard\", \"avNetworksCard\"]) {\n this.el(id)?.classList.toggle(\"av-wide-card\", alone);\n }\n\n }\n\n // The pressed state of the grain control follows the chosen grain.\n private syncGrainButtons(): void {\n for (const btn of document.querySelectorAll(\"#avPlacesGrain [data-grain]\")) {\n const on = btn.dataset.grain === this.placesGrain;\n btn.classList.toggle(\"active\", on);\n btn.setAttribute(\"aria-pressed\", on ? \"true\" : \"false\");\n }\n }\n\n // The location rows at a grain. At city grain every server row is its\n // own bar, tags and all. At region or country grain the rows roll up on\n // the client. Views and AI views sum exactly; distinct visitors do not \u2014\n // a visitor whose views split between a place's clean and flagged rows\n // is in both \u2014 so a rolled-up bar takes its visitor count from the\n // server's per-grain distinct counts (countryVisitors / regionVisitors),\n // falling back to the sum only on a response that predates them. A\n // tagged row merges into its parent like any other \u2014 the tag is a fact\n // about one reader's address and would paint a whole state at a coarser\n // grain \u2014 and the count of views that carried a tag rides along for the\n // tooltip, so the fact is folded, not lost. A row the edge placed at\n // country only stays a country row at every grain: those views have no\n // finer home.\n private placeRowsAt(places: PageTrafficPlace[], grain: PlaceGrain): GeoRow[] {\n if (grain === \"city\") {\n return places.map(p => ({\n label: this.placeLabel(p), views: p.views,\n visitors: p.visitors, aiViews: p.aiViews,\n signals: p.signals || [],\n timezone: p.timezone || \"\", clientTimezones: p.clientTimezones || [],\n title: [p.city, p.regionName || p.region, countryName(p.country)].filter(Boolean).join(\", \"),\n }));\n }\n const distinct = grain === \"region\" ? this.traffic?.regionVisitors : this.traffic?.countryVisitors;\n const groups = new Map();\n for (const p of places) {\n const region = grain === \"region\" ? this.regionLabel(p) : \"\";\n // Keyed on the raw region code, the key the server's distinct\n // counts use; the label is only what the bar shows.\n const key = grain === \"region\" ? `${p.country}|${p.region || \"\"}` : p.country;\n const label = region ? `${region}, ${p.country}` : p.country;\n const title = [region ? (p.regionName || region) : \"\", countryName(p.country)].filter(Boolean).join(\", \");\n const g = groups.get(key) || { label, title, views: 0, visitors: 0, aiViews: 0, flaggedViews: 0 };\n g.views += p.views;\n g.visitors += p.visitors;\n g.aiViews += p.aiViews;\n if (p.signals?.length) g.flaggedViews = (g.flaggedViews || 0) + p.views;\n groups.set(key, g);\n }\n if (distinct) {\n for (const [key, g] of groups) {\n if (typeof distinct[key] === \"number\") g.visitors = distinct[key];\n }\n }\n return [...groups.values()].sort((a, b) => b.views - a.views || a.label.localeCompare(b.label));\n }\n\n // Views by location and by network: two single-series magnitude charts,\n // so both are horizontal bars sorted long-to-short, and both are ONE\n // colour. Nothing here is a category with an identity to preserve \u2014 a bar\n // is a count of views, and painting each bar differently would imply the\n // places mean something to each other. That also keeps the channel hues\n // (blue is AI-referred on the plot above) from being borrowed for a\n // dimension they have nothing to do with. One series needs no legend; the\n // card title names it, and the tooltip carries the numbers.\n //\n // A folded network row arrives with no name and is rendered as a count,\n // never as a place. It is the last bar because it is the smallest by\n // construction, and the sort puts it there without special-casing.\n private renderGeoBars(\n canvasID: string, cardID: string,\n rows: GeoRow[],\n which: \"places\" | \"networks\",\n ): void {\n const card = this.el(cardID);\n if (card) card.style.display = rows.length ? \"\" : \"none\";\n // Released before any early return: a window with no rows must not\n // keep the last window's chart alive on a hidden canvas.\n const existing = which === \"places\" ? this.placesChart : this.networksChart;\n if (existing) existing.destroy();\n if (which === \"places\") this.placesChart = null;\n else this.networksChart = null;\n const el = this.el(canvasID);\n if (!el || typeof Chart === \"undefined\" || !rows.length) {\n el?.parentElement?.querySelector(\".av-geo-pills\")?.remove();\n return;\n }\n\n // At most the cap. Every recorded place is a row, so once the edge\n // names cities the list runs to hundreds; the chart shows the\n // largest and sums the rest into one trailing \"Other\" bar rather than\n // dropping them, so the bars still add up to the ledger. Networks\n // may already carry the server's folded row, which is just another\n // small row here and sums into Other like any other.\n //\n // Tagged rows \u2014 views that tripped a detection \u2014 are kept apart:\n // the cap and the Other fold apply to the clean rows, and the tagged\n // rows follow as their own block. A tagged row is never folded into\n // Other, because the tag is the point of it. The bar itself is drawn\n // like any other; the tag is a pill laid over the canvas at the end\n // of the bar (see placeGeoPills), with an info icon whose tooltip\n // shows the reasoning \u2014 the same pill-and-tooltip the nodes table\n // uses for CVEs, not a second series with a legend to decode.\n const clean = rows.filter(r => !(r.signals?.length));\n const tagged = rows.filter(r => r.signals?.length);\n const cap = which === \"places\" ? PLACE_BARS : NETWORK_BARS;\n const top = clean.slice(0, clean.length > cap ? cap - 1 : cap);\n const rest = clean.slice(top.length);\n if (rest.length) {\n // The fold names what it folded at the grain showing: places,\n // regions or countries for the location chart, networks for\n // the other.\n const otherKey = which !== \"places\" ? \"aiVisibility.trafficOtherNetworks\"\n : this.placesGrain === \"country\" ? \"aiVisibility.trafficOtherCountries\"\n : this.placesGrain === \"region\" ? \"aiVisibility.trafficOtherRegions\"\n : \"aiVisibility.trafficOtherPlaces\";\n top.push({\n label: t(otherKey, { n: String(rest.length) }),\n views: rest.reduce((a, r) => a + r.views, 0),\n visitors: rest.reduce((a, r) => a + r.visitors, 0),\n aiViews: rest.reduce((a, r) => a + r.aiViews, 0),\n flaggedViews: rest.reduce((a, r) => a + (r.flaggedViews || 0), 0),\n });\n }\n top.push(...tagged);\n // The canvas takes the height its rows need, never less than the\n // card's floor: fifty places in the space that fit eight would be\n // bars a few pixels tall with every other label dropped.\n const box = el.parentElement;\n if (box) box.style.minHeight = `${Math.max(190, top.length * GEO_BAR_PX + GEO_AXIS_PX)}px`;\n const overlay = this.geoPillOverlay(box, top);\n const chart = new Chart(el.getContext(\"2d\"), {\n type: \"bar\",\n data: {\n labels: top.map(r => r.label),\n datasets: [{\n label: t(\"aiVisibility.trafficViews\"),\n data: top.map(r => r.views),\n backgroundColor: GOLD,\n borderColor: GOLD,\n borderWidth: 0,\n // Rounded at the value end only; the baseline end stays\n // square so every bar starts on the same hard line.\n borderRadius: 4,\n borderSkipped: \"start\",\n categoryPercentage: 0.8,\n barPercentage: 0.9,\n // One country is the whole ledger today, and a single\n // category left to fill the card drew a bar as tall as\n // the plot \u2014 a block, not a measurement. Cap it so a\n // one-row chart still reads as a bar chart.\n maxBarThickness: 30,\n }],\n },\n // The pills live in the DOM, not on the canvas, so they follow\n // the bars by being re-placed whenever the chart lays itself\n // out \u2014 first paint and every resize.\n plugins: [{\n id: \"avGeoPills\",\n afterLayout: (c: any) => placeGeoPills(c, overlay, top),\n }],\n options: {\n indexAxis: \"y\",\n responsive: true, maintainAspectRatio: false,\n // Axis ticks and default labels in the app's language.\n locale: getLocale(),\n plugins: {\n legend: { display: false },\n datalabels: { display: false },\n tooltip: {\n callbacks: {\n title: (items: any[]) => {\n const r = items[0] ? top[items[0].dataIndex] : undefined;\n return r?.title || items[0]?.label || \"\";\n },\n label: (i: any) => {\n const r = top[i.dataIndex];\n const line = t(\"aiVisibility.geoTooltip\", {\n views: r.views.toLocaleString(getLocale()),\n visitors: r.visitors.toLocaleString(getLocale()),\n ai: r.aiViews.toLocaleString(getLocale()),\n });\n // At a coarser grain the tags are folded in;\n // the count of tagged views is the trace.\n return r.flaggedViews\n ? [line, t(\"aiVisibility.geoTooltipFlagged\", { n: r.flaggedViews.toLocaleString(getLocale()) })]\n : line;\n },\n },\n },\n },\n // No padding on the right and the last tick label kept\n // inside the plot, so the plot ends at the card's content\n // edge \u2014 the same edge the grain toggle in the title sits\n // on. With the default outer alignment Chart.js reserved\n // half a label's width past the last gridline, and the\n // toggle ended a few pixels to the right of where the\n // chart did, which read as two different margins.\n layout: { padding: { right: 0 } },\n scales: {\n x: { beginAtZero: true, grid: { color: \"#E8E8E8\" }, ticks: { precision: 0, align: \"inner\" } },\n y: { grid: { display: false } },\n },\n },\n });\n if (which === \"places\") this.placesChart = chart;\n else this.networksChart = chart;\n }\n\n // The overlay that holds a tagged row's pill and info icon, rebuilt on\n // every render (the chart is too). One absolutely-positioned child per\n // tagged row; where each goes is decided by placeGeoPills once the\n // chart has laid out its scales. The overlay ignores the pointer so the\n // canvas tooltip still works between pills; each pill takes it back.\n //\n // The tooltip is the reasoning: the headline claim, why the detection\n // means that, and for a clock detection the two clocks it rests on \u2014\n // the zone the edge placed the address in against the zone the browser\n // reported \u2014 so the reader can judge the call, not just receive it.\n private geoPillOverlay(\n box: HTMLElement | null,\n rows: Array<{ signals?: string[]; timezone?: string; clientTimezones?: string[] }>,\n ): HTMLElement | null {\n if (!box) return null;\n box.querySelector(\".av-geo-pills\")?.remove();\n if (!rows.some(r => r.signals?.length)) return null;\n const overlay = document.createElement(\"div\");\n overlay.className = \"av-geo-pills\";\n rows.forEach((r, i) => {\n const signals = signalsByStrength(r.signals || []);\n if (!signals.length) return;\n const lines = signals.map(n =>\n `
${esc(signalBadge(n))}
${esc(signalDetail(n))}
`);\n if (r.timezone && r.clientTimezones?.length) {\n lines.push(`
${esc(t(\"aiVisibility.signalClocksLine\", {\n edge: r.timezone, client: r.clientTimezones.join(\", \"),\n }))}
`);\n }\n const tag = document.createElement(\"div\");\n tag.className = \"av-geo-pill-row\";\n tag.dataset.row = String(i);\n tag.innerHTML =\n `${esc(signalPill(signals[0]))}` +\n // Focusable, so the reasoning in the tooltip is reachable from\n // the keyboard, not only by hovering.\n `info`;\n overlay.appendChild(tag);\n });\n box.appendChild(overlay);\n return overlay;\n }\n\n // The label for a place: city, region, country, dropping whatever the edge\n // could not resolve. The region code is the short form readers know only\n // where it is letters (CA, TX, ON); ISO gives many countries numeric\n // subdivision codes, and \"Taipei, 04, TW\" says less than \"Taipei, Taiwan,\n // TW\", so those fall back to the name.\n private placeLabel(p: PageTrafficPlace): string {\n return [p.city, this.regionLabel(p), p.country].filter(Boolean).join(\", \");\n }\n\n // The region as the label shows it, or \"\" when the edge gave none.\n private regionLabel(p: PageTrafficPlace): string {\n const code = p.region && /^[A-Z]{1,3}$/.test(p.region) ? p.region : \"\";\n return code || p.regionName || p.region || \"\";\n }\n\n private renderTrafficChart(): void {\n // Released first, on every path: a window with no views, or a\n // fragment that is gone, must not keep the last chart alive.\n if (this.trafficChart) { this.trafficChart.destroy(); this.trafficChart = null; }\n const el = this.el(\"avTrafficChart\");\n if (!el || typeof Chart === \"undefined\") return;\n const days = this.traffic?.days || [];\n if (!days.length) return;\n\n const labels = days.map(d => bucketDate(d.day, { month: \"numeric\", day: \"numeric\" }));\n\n // Stacked by channel. AI-referred takes the first slot \u2014 it is the\n // series this card exists for. Direct, internal and other referrals\n // fold into one tail series rather than inventing three more hues.\n const bar = (label: string, pick: (d: PageTrafficDay) => number, color: string) => ({\n label, backgroundColor: color, borderColor: color, borderWidth: 0,\n data: days.map(pick),\n });\n\n // The days on this chart that hold the site's views record, marked on\n // the bar and named in its tooltip.\n const marks = this.marksFor(days, \"viewsDay\");\n\n this.trafficChart = new Chart(el.getContext(\"2d\"), {\n type: \"bar\",\n plugins: [recordMarkPlugin(marks)],\n data: {\n labels,\n datasets: [\n bar(t(\"aiVisibility.trafficAI\"), d => d.ai, SERIES_1),\n bar(t(\"aiVisibility.trafficSearch\"), d => d.search, SERIES_2),\n bar(t(\"aiVisibility.trafficSocial\"), d => d.social, SERIES_3),\n bar(t(\"aiVisibility.trafficOther\"), d => d.direct + d.internal + d.referral, MUTED),\n // The visitors line used to run over these bars. It has\n // its own card now (renderVisitorsChart), because sharing\n // a stacked views axis flattened it: a hundred visitors\n // under six hundred views is a line along the baseline.\n ],\n },\n options: {\n responsive: true, maintainAspectRatio: false,\n // Axis ticks and default labels in the app's language.\n locale: getLocale(),\n interaction: { mode: \"index\", intersect: false },\n plugins: {\n // Inside a jump card: a legend click is a click on the\n // card, which jumps to the table, so the legend does not\n // also hide a series.\n legend: { onClick: () => undefined, labels: { boxWidth: 10, font: { size: 11 } } },\n datalabels: { display: false },\n tooltip: {\n callbacks: {\n title: (items: any[]) => items[0]?.label ?? \"\",\n label: (i: any) => `${i.dataset.label}: ${Number(i.parsed.y).toLocaleString(getLocale())}`,\n footer: this.footerFor(marks, \"viewsDay\"),\n },\n },\n },\n scales: {\n x: { stacked: true, grid: { display: false } },\n y: {\n stacked: true, beginAtZero: true,\n // Headroom for the record mark above the tallest\n // bar. Padding around the chart would not do it:\n // the legend occupies that strip, so the bar still\n // runs to the top of the plot and the mark lands on\n // the legend \u2014 which is exactly what a seven-day\n // window did. Grace lifts the axis maximum instead,\n // so the gap is inside the plot.\n grace: marks.size ? RECORD_MARK_GRACE : 0,\n grid: { color: \"#E8E8E8\" },\n ticks: { precision: 0 },\n },\n },\n },\n });\n }\n\n // Distinct visitors per day, with the AI-referred subset drawn inside\n // them. The metric is the daily unique count \u2014 what a web analytics tool\n // calls users \u2014 and NOT a daily-active-user figure in the product sense:\n // the beacon has no login and no cookie, so \"the same person tomorrow\" is\n // not a question the data can answer. That is also why there is no\n // running total and no weekly or monthly unique line: the hash rotates at\n // midnight, so a week of visitors can only be summed into visitor-days,\n // and a \"12,000 monthly uniques\" drawn from these rows would be a\n // fabrication. Every honest number this ledger holds is on this chart.\n //\n // AI-referred visitors is a SUBSET, not a slice: someone who arrives from\n // ChatGPT and comes back directly that afternoon is one visitor and one\n // AI visitor. So it is a line inside the bar rather than a second stack \u2014\n // stacking would draw a total that does not exist.\n private renderVisitorsChart(): void {\n if (this.visitorsChart) { this.visitorsChart.destroy(); this.visitorsChart = null; }\n const el = this.el(\"avVisitorsChart\");\n if (!el || typeof Chart === \"undefined\") return;\n const days = this.traffic?.days || [];\n if (!days.length) return;\n\n const labels = days.map(d => bucketDate(d.day, { month: \"numeric\", day: \"numeric\" }));\n\n const marks = this.marksFor(days, \"visitorsDay\");\n\n this.visitorsChart = new Chart(el.getContext(\"2d\"), {\n type: \"bar\",\n plugins: [recordMarkPlugin(marks)],\n data: {\n labels,\n datasets: [\n {\n label: t(\"aiVisibility.trafficVisitors\"),\n data: days.map(d => d.visitors),\n backgroundColor: GOLD, borderColor: GOLD, borderWidth: 0,\n order: 1,\n },\n {\n type: \"line\", label: t(\"aiVisibility.visitorsAI\"),\n data: days.map(d => d.aiVisitors),\n borderColor: SERIES_1, backgroundColor: SERIES_1,\n // No dot on a day nobody came from an assistant:\n // twenty-five of them along the baseline read as a\n // dotted line rather than as zeros.\n borderWidth: 2, pointHoverRadius: 4,\n pointRadius: (ctx: any) => (ctx.raw ? 2 : 0),\n tension: 0.25, fill: false, order: 0,\n },\n ],\n },\n options: {\n responsive: true, maintainAspectRatio: false,\n // Axis ticks and default labels in the app's language.\n locale: getLocale(),\n interaction: { mode: \"index\", intersect: false },\n plugins: {\n legend: {\n // Inside a jump card; see the page-views chart.\n onClick: () => undefined,\n labels: {\n boxWidth: 10, font: { size: 11 },\n // Chart.js orders the legend by draw order, which\n // puts the line first because it draws on top.\n // The whole comes before its part in the legend.\n sort: (a: any, b: any) => a.datasetIndex - b.datasetIndex,\n },\n },\n datalabels: { display: false },\n tooltip: {\n callbacks: {\n title: (items: any[]) => items[0]?.label ?? \"\",\n label: (i: any) => `${i.dataset.label}: ${Number(i.parsed.y).toLocaleString(getLocale())}`,\n footer: this.footerFor(marks, \"visitorsDay\"),\n },\n },\n },\n scales: {\n // Not stacked, in either axis: the two series overlap by\n // construction and a stack would invent a sum.\n x: { grid: { display: false } },\n y: {\n beginAtZero: true,\n // Headroom for the record mark above the tallest\n // bar. Padding around the chart would not do it:\n // the legend occupies that strip, so the bar still\n // runs to the top of the plot and the mark lands on\n // the legend \u2014 which is exactly what a seven-day\n // window did. Grace lifts the axis maximum instead,\n // so the gap is inside the plot.\n grace: marks.size ? RECORD_MARK_GRACE : 0,\n grid: { color: \"#E8E8E8\" },\n ticks: { precision: 0 },\n },\n },\n },\n });\n }\n\n // One row per page: human views on the left, the crawler ledger's fetches\n // for the same path on the right, and two columns read from the latest\n // completed audit run \u2014 how many of the verbatim queries cited the page,\n // and whether its meta check passed \u2014 so \"cited but never clicked\" and\n // \"visited but not citable\" are both visible in one place.\n private renderTrafficTable(): void {\n const el = this.el(\"avTrafficTable\");\n if (!el || typeof $ === \"undefined\") return;\n this.trafficTable = destroyTable(this.trafficTable);\n $(el).find(\"tbody\").empty();\n\n // Citations by path, from this run's queries.\n const cited = new Map();\n for (const q of this.detail?.queries || []) {\n let urls: string[] = [];\n try { urls = JSON.parse(q.citedUrls || \"[]\"); } catch { /* as recorded */ }\n for (const u of new Set(urls.map(x => shortURL(String(x)).replace(/\\/$/, \"\") || \"/\"))) {\n cited.set(u, (cited.get(u) ?? 0) + 1);\n }\n }\n const meta = new Map();\n for (const o of this.detail?.observations || []) {\n if (o.checkType === \"meta\") meta.set(shortURL(o.url).replace(/\\/$/, \"\") || \"/\", o.passed);\n }\n\n const rows = (this.traffic?.pages || []).map(p => {\n const key = p.path.replace(/\\/$/, \"\") || \"/\";\n const m = meta.get(key);\n return {\n ...p,\n viewsDelta: p.views - p.prevViews,\n aiDelta: p.aiViews - p.prevAiViews,\n topAiLabel: p.topAiSource ? aiSourceLabel(p.topAiSource) : \"\",\n topPlace: p.topPlace || \"\",\n // Not measured, rather than zero, when no audit run loaded.\n citedCount: this.detail ? (cited.get(key) ?? 0) : null,\n metaLabel: m === undefined ? \"\" : m ? t(\"aiVisibility.pass\") : t(\"aiVisibility.fail\"),\n metaPassed: m,\n lastSeen: p.lastAt ? new Date(p.lastAt).toLocaleString(getLocale()) : \"\",\n lastSeenSort: p.lastAt ? new Date(p.lastAt).getTime() : 0,\n };\n });\n\n this.trafficTable = $(el).DataTable({\n data: rows,\n paging: rows.length > 15,\n searching: true,\n info: rows.length > 15,\n autoWidth: false,\n pageLength: 25,\n dom: DOM_STRING,\n order: [[1, \"desc\"]],\n columns: [\n { data: \"path\", title: t(\"aiVisibility.trafficPage\"), width: \"30ch\",\n render: (d: string) => esc(d) },\n { data: \"views\", title: t(\"aiVisibility.trafficViews\"), width: \"8ch\",\n className: \"text-end\", render: numCell },\n { data: \"viewsDelta\", title: t(\"aiVisibility.trafficViewsDelta\"), width: \"9ch\",\n className: \"text-end\",\n render: (v: number, type: string, row: any) =>\n type === \"display\" ? deltaCell(row.views, row.prevViews)\n : type === \"sort\" || type === \"type\" ? v : signedDelta(row.views, row.prevViews) },\n // The one column on this page whose number means something\n // other than what its name suggests, so the name carries the\n // explanation: a visitor is per DAY, not per window.\n { data: \"visitors\",\n title: thWithInfo(t(\"aiVisibility.trafficVisitors\"), t(\"aiVisibility.trafficVisitorsTip\")),\n width: \"11ch\",\n className: \"text-end\", render: numCell },\n { data: \"aiViews\", title: t(\"aiVisibility.trafficAI\"), width: \"10ch\",\n className: \"text-end\",\n render: (v: number, type: string) =>\n type === \"display\" && v > 0\n ? `${v.toLocaleString(getLocale())}` : numCell(v, type) },\n { data: \"aiDelta\", title: t(\"aiVisibility.trafficAIDelta\"), width: \"9ch\",\n className: \"text-end\",\n render: (v: number, type: string, row: any) =>\n type === \"display\" ? deltaCell(row.aiViews, row.prevAiViews)\n : type === \"sort\" || type === \"type\" ? v : signedDelta(row.aiViews, row.prevAiViews) },\n { data: \"topAiLabel\", title: t(\"aiVisibility.trafficTopAI\"), width: \"12ch\",\n render: (d: string) => esc(d || \"\u2014\") },\n // The page's modal place at the finest level the edge recorded.\n // Empty only on rows written before the edge reported one, so\n // the em dash means \"not recorded\", never \"nowhere\".\n { data: \"topPlace\",\n title: thWithInfo(t(\"aiVisibility.trafficTopPlace\"), t(\"aiVisibility.trafficTopPlaceTip\")),\n width: \"20ch\",\n render: (d: string) => esc(placeCountryNamed(d) || \"\u2014\") },\n { data: \"citedCount\",\n title: thWithInfo(t(\"aiVisibility.trafficCited\"), t(\"aiVisibility.trafficCitedTip\")),\n width: \"11ch\", className: \"text-end\",\n render: (v: number | null, type: string) =>\n type === \"sort\" || type === \"type\" ? (v ?? -1) : (v === null ? \"\u2014\" : String(v)) },\n { data: \"metaLabel\", title: t(\"aiVisibility.trafficMeta\"), width: \"9ch\",\n render: (d: string, type: string, row: any) =>\n type === \"display\"\n ? (d ? `${esc(d)}` : \"\u2014\")\n : d },\n { data: \"crawlerHits\", title: t(\"aiVisibility.trafficCrawlerHits\"), width: \"10ch\",\n className: \"text-end\", render: numCell },\n { data: \"lastSeen\", title: t(\"aiVisibility.trafficLastSeen\"), width: \"18ch\",\n render: (d: string, type: string, row: any) =>\n type === \"sort\" || type === \"type\" ? row.lastSeenSort : (d || \"\u2014\") },\n ],\n columnDefs: [\n { targets: [0], columnControl: [[\"orderAsc\", \"orderDesc\", \"searchText\"]] },\n { targets: [6, 7, 9], columnControl: [[\"orderAsc\", \"orderDesc\", \"searchList\"]] },\n { targets: [1, 2, 3, 4, 5, 8, 10, 11], columnControl: [[\"orderAsc\", \"orderDesc\"]] },\n ],\n language: { emptyTable: t(this.traffic ? \"aiVisibility.trafficNone\" : \"siteTraffic.ledgerFailed\"), search: \"\" },\n headerCallback: (thead: any) => {\n (thead as HTMLTableSectionElement).classList.add(\"custom-thead\");\n },\n });\n\n setupDataTableUI(\"avTrafficTable\", this.trafficTable, {\n columnControl: {\n dropdownMaterialIcon: \"filter_list\", scrollHeadThClass: \"filter-right\",\n fixHeaderLayout: true, cascadeSearchLists: true,\n },\n autoResize: { namespace: \"ai-visibility-traffic\" },\n });\n installCsvActionBar(\"avTrafficTable\", () => this.exportTrafficCsv());\n }\n\n private async fetchDownloads(days: number): Promise {\n let data: DownloadSummary | null = null;\n try {\n // The whole log for the window (all=true), for the same reason as\n // the Page views table.\n const res = await apiFetch(`/api/ai-visibility/downloads?days=${days}&all=true`);\n if (!res.ok) throw new Error(`HTTP ${res.status}`);\n data = await res.json();\n } catch (e) {\n console.warn(\"[SiteTrafficUI] downloads unavailable\", e);\n }\n return data;\n }\n\n private renderDownloads(): void {\n const note = this.el(\"avDownloadsNote\");\n const d = this.downloads;\n if (note) {\n note.textContent = d && d.downloads > 0\n ? `${t(\"aiVisibility.dlSummary\", {\n downloads: d.downloads.toLocaleString(getLocale()),\n visitors: d.visitors.toLocaleString(getLocale()),\n days: String(d.windowDays),\n })} \u00B7 ${t(\"aiVisibility.dlPrior\", {\n delta: pctDelta(d.downloads, d.prevDownloads),\n days: String(d.windowDays),\n })}`\n : t(d ? \"aiVisibility.dlNone\" : \"siteTraffic.ledgerFailed\");\n }\n this.renderDownloadCharts();\n this.renderDownloadFilesTable();\n this.renderDownloadsTable();\n }\n\n // The two download charts in the summary: the daily trend and the\n // by-file donut. Both hide with nothing in the window \u2014 thirty flat days\n // and an empty ring say less than the table's empty row does \u2014 and the\n // trend takes the whole row when the donut has nothing to draw.\n private renderDownloadCharts(): void {\n const d = this.downloads;\n const hasData = !!d && d.downloads > 0;\n const trendCard = this.el(\"avDownloadsTrendCard\");\n const donutCard = this.el(\"avDownloadsDonutCard\");\n if (trendCard) trendCard.style.display = hasData ? \"\" : \"none\";\n if (donutCard) donutCard.style.display = hasData ? \"\" : \"none\";\n const note = this.el(\"avDownloadsTrendNote\");\n if (note) {\n note.textContent = hasData\n ? t(\"aiVisibility.dlTrendNote\", {\n downloads: d!.downloads.toLocaleString(getLocale()),\n ai: d!.aiDownloads.toLocaleString(getLocale()),\n files: String(d!.files.length),\n }) : \"\";\n }\n if (this.downloadsChart) { this.downloadsChart.destroy(); this.downloadsChart = null; }\n if (!hasData) {\n // The by-file donut goes too: its card is hidden, and a ring left\n // drawn there would outlive the window it counted.\n this.donuts.empty(\"avDonutDownloads\", t(\"aiVisibility.dlNone\"));\n return;\n }\n\n // Trend: every download, and the AI-referred subset under it. Lines\n // rather than the page-views card's stacked bars, because the\n // question here is the shape over time, and at a handful a day\n // stacked bars read as noise. The subset is drawn as its own line\n // rather than stacked, so the top line is always the total.\n const el = this.el(\"avDownloadsChart\");\n if (el && typeof Chart !== \"undefined\") {\n const days = d!.days;\n const line = (label: string, data: number[], color: string, fill: boolean) => ({\n label, data, borderColor: color, backgroundColor: fill ? color + \"22\" : color,\n borderWidth: 2, pointRadius: days.length > 45 ? 0 : 2, pointHoverRadius: 4,\n tension: 0.25, fill,\n });\n this.downloadsChart = new Chart(el.getContext(\"2d\"), {\n type: \"line\",\n data: {\n labels: days.map(x => bucketDate(x.day, { month: \"numeric\", day: \"numeric\" })),\n datasets: [\n line(t(\"aiVisibility.downloads\"), days.map(x => x.downloads), SERIES_1, true),\n line(t(\"aiVisibility.dlAITile\"), days.map(x => x.aiDownloads), GOLD, false),\n ],\n },\n options: {\n responsive: true, maintainAspectRatio: false,\n // Axis ticks and default labels in the app's language.\n locale: getLocale(),\n interaction: { mode: \"index\", intersect: false },\n plugins: {\n // Inside a jump card: a legend click is a click on the\n // card, which jumps to the table, so the legend does not\n // also hide a series.\n legend: { onClick: () => undefined, labels: { boxWidth: 10, font: { size: 11 } } },\n datalabels: { display: false },\n },\n scales: {\n x: { grid: { display: false } },\n y: { beginAtZero: true, grid: { color: \"#E8E8E8\" }, ticks: { precision: 0 } },\n },\n },\n });\n }\n\n // Donut: which files, the top five by downloads and the rest folded\n // into one slice, so the ring never turns into a fan of slivers.\n // Categorical slots in order; \"Other\" takes the muted grey.\n // Six or fewer files are all named: an \"Other\" slice standing for one\n // file hides a name for no space saved.\n const files = [...d!.files].sort((a, b) => b.downloads - a.downloads);\n const keep = files.length <= 6 ? files.length : 5;\n const top = files.slice(0, keep);\n const rest = files.slice(keep).reduce((n, f) => n + f.downloads, 0);\n const labels = top.map(f => f.file);\n const data = top.map(f => f.downloads);\n const palette = [SERIES_1, SERIES_2, SERIES_3, GOLD, INK, \"#7a5cc4\"];\n const colors = top.map((_, i) => palette[i % palette.length]);\n if (rest > 0) {\n labels.push(t(\"aiVisibility.dlOtherFiles\", { n: String(files.length - keep) }));\n data.push(rest);\n colors.push(MUTED);\n }\n this.donuts.draw(\"avDonutDownloads\", labels, data, colors);\n }\n\n // One row per file over the window, with the prior window beside it.\n private renderDownloadFilesTable(): void {\n const el = this.el(\"avDownloadFilesTable\");\n if (!el || typeof $ === \"undefined\") return;\n this.downloadFilesTable = destroyTable(this.downloadFilesTable);\n $(el).find(\"tbody\").empty();\n\n const rows = (this.downloads?.files || []).map(f => ({\n ...f,\n delta: f.downloads - f.prevDownloads,\n topPlace: placeCountryNamed(f.topPlace || \"\"),\n topSource: f.topSource || \"\",\n lastSeen: f.lastAt ? new Date(f.lastAt).toLocaleString(getLocale()) : \"\",\n lastSeenSort: f.lastAt ? new Date(f.lastAt).getTime() : 0,\n }));\n this.downloadFilesTable = $(el).DataTable({\n data: rows,\n paging: rows.length > 15,\n searching: true,\n info: rows.length > 15,\n autoWidth: false,\n pageLength: 25,\n dom: DOM_STRING,\n order: [[1, \"desc\"]],\n columns: [\n { data: \"file\", title: t(\"aiVisibility.dlFile\"), width: \"34ch\",\n render: (d: string, type: string, row: any) => type === \"display\"\n ? `${esc(d)}`\n : d },\n { data: \"downloads\", title: t(\"aiVisibility.dlDownloads\"), width: \"10ch\",\n className: \"text-end\", render: numCell },\n { data: \"delta\", title: t(\"aiVisibility.dlDelta\"), width: \"9ch\", className: \"text-end\",\n render: (v: number, type: string, row: any) =>\n type === \"display\" ? deltaCell(row.downloads, row.prevDownloads)\n : type === \"sort\" || type === \"type\" ? v : signedDelta(row.downloads, row.prevDownloads) },\n { data: \"visitors\",\n title: thWithInfo(t(\"aiVisibility.trafficVisitors\"), t(\"aiVisibility.trafficVisitorsTip\")),\n width: \"10ch\", className: \"text-end\", render: numCell },\n { data: \"aiDownloads\", title: t(\"aiVisibility.trafficAI\"), width: \"10ch\",\n className: \"text-end\",\n render: (v: number, type: string) => type === \"display\" && v > 0\n ? `${v.toLocaleString(getLocale())}` : numCell(v, type) },\n { data: \"topPlace\", title: t(\"aiVisibility.trafficTopPlace\"), width: \"20ch\",\n render: (d: string) => esc(d || \"\u2014\") },\n { data: \"topSource\", title: t(\"aiVisibility.dlTopSource\"), width: \"24ch\",\n render: (d: string) => esc(d || \"\u2014\") },\n { data: \"lastSeen\", title: t(\"aiVisibility.dlLast\"), width: \"18ch\",\n render: (d: string, type: string, row: any) =>\n type === \"sort\" || type === \"type\" ? row.lastSeenSort : (d || \"\u2014\") },\n ],\n columnDefs: [\n { targets: [0, 6], columnControl: [[\"orderAsc\", \"orderDesc\", \"searchText\"]] },\n { targets: [5], columnControl: [[\"orderAsc\", \"orderDesc\", \"searchList\"]] },\n { targets: [1, 2, 3, 4, 7], columnControl: [[\"orderAsc\", \"orderDesc\"]] },\n ],\n language: { emptyTable: t(this.downloads ? \"aiVisibility.dlNone\" : \"siteTraffic.ledgerFailed\"), search: \"\" },\n headerCallback: (thead: any) => {\n (thead as HTMLTableSectionElement).classList.add(\"custom-thead\");\n },\n });\n tableUI(\"avDownloadFilesTable\", this.downloadFilesTable, \"ai-visibility-download-files\", () => {\n const out = filteredRows(this.downloadFilesTable).map((f: any) => [\n f.file, f.url, f.downloads, f.prevDownloads, f.visitors, f.aiDownloads,\n f.topPlace, f.topSource, f.lastSeen,\n ]);\n triggerCsvDownload(\n toCsv([\"File\", \"URL\", \"Downloads\", \"Downloads prior window\", \"Visitors\", \"AI-referred\",\n \"Top location\", \"Top source page\", \"Last download\"], out),\n `site-traffic-download-files-${this.downloads?.windowDays ?? DEFAULT_WINDOW_DAYS}d-${new Date().toISOString().slice(0, 10)}.csv`);\n });\n }\n\n // Every download, newest first: when, which file, where from, and how the\n // reader got there.\n private renderDownloadsTable(): void {\n const el = this.el(\"avDownloadsTable\");\n if (!el || typeof $ === \"undefined\") return;\n this.downloadsTable = destroyTable(this.downloadsTable);\n $(el).find(\"tbody\").empty();\n\n const channelKey: Record = {\n ai: \"aiVisibility.dlChannelAi\", search: \"aiVisibility.dlChannelSearch\",\n social: \"aiVisibility.dlChannelSocial\", direct: \"aiVisibility.dlChannelDirect\",\n internal: \"aiVisibility.dlChannelInternal\", referral: \"aiVisibility.dlChannelReferral\",\n };\n const rows = (this.downloads?.recent || []).map(r => ({\n ...r,\n when: new Date(r.seenAt).toLocaleString(getLocale()),\n whenSort: new Date(r.seenAt).getTime(),\n location: placeCountryNamed(r.place || \"\"),\n // The page it was taken from when it was one of ours; otherwise the\n // site that sent the reader straight to the file.\n from: r.sourcePath || r.referrerHost || \"\",\n channelLabel: r.channel === \"ai\" && r.aiSource\n ? aiSourceLabel(r.aiSource)\n : (channelKey[r.channel] ? t(channelKey[r.channel]) : r.channel),\n viaLabel: r.via === \"click\" ? t(\"aiVisibility.dlViaClick\") : t(\"aiVisibility.dlViaServed\"),\n }));\n this.downloadsTable = $(el).DataTable({\n data: rows,\n paging: rows.length > 15,\n searching: true,\n info: rows.length > 15,\n autoWidth: false,\n pageLength: 25,\n dom: DOM_STRING,\n order: [[0, \"desc\"]],\n columns: [\n { data: \"when\", title: t(\"aiVisibility.dlTime\"), width: \"18ch\",\n render: (d: string, type: string, row: any) =>\n type === \"sort\" || type === \"type\" ? row.whenSort : d },\n { data: \"file\", title: t(\"aiVisibility.dlFile\"), width: \"34ch\",\n render: (d: string, type: string, row: any) => type === \"display\"\n ? `${esc(d)}`\n : d },\n { data: \"location\", title: t(\"aiVisibility.dlLocation\"), width: \"22ch\",\n render: (d: string) => esc(d || \"\u2014\") },\n { data: \"from\", title: t(\"aiVisibility.dlFrom\"), width: \"24ch\",\n render: (d: string) => esc(d || \"\u2014\") },\n { data: \"channelLabel\", title: t(\"aiVisibility.dlChannel\"), width: \"12ch\",\n render: (d: string, type: string, row: any) => type === \"display\" && row.channel === \"ai\"\n ? `${esc(d)}` : esc(d) },\n { data: \"viaLabel\", title: t(\"aiVisibility.dlVia\"), width: \"14ch\",\n render: (d: string) => esc(d) },\n ],\n columnDefs: [\n { targets: [0], columnControl: [[\"orderAsc\", \"orderDesc\"]] },\n { targets: [1, 3], columnControl: [[\"orderAsc\", \"orderDesc\", \"searchText\"]] },\n { targets: [2, 4, 5], columnControl: [[\"orderAsc\", \"orderDesc\", \"searchList\"]] },\n ],\n language: { emptyTable: t(this.downloads ? \"aiVisibility.dlNone\" : \"siteTraffic.ledgerFailed\"), search: \"\" },\n headerCallback: (thead: any) => {\n (thead as HTMLTableSectionElement).classList.add(\"custom-thead\");\n },\n });\n tableUI(\"avDownloadsTable\", this.downloadsTable, \"ai-visibility-downloads\", () => {\n const out = filteredRows(this.downloadsTable).map((r: any) => [\n r.when, r.file, r.url, r.location, r.from, r.channelLabel, r.viaLabel,\n ]);\n triggerCsvDownload(\n toCsv([\"Time\", \"File\", \"URL\", \"Location\", \"From\", \"Channel\", \"Recorded by\"], out),\n `site-traffic-downloads-${this.downloads?.windowDays ?? DEFAULT_WINDOW_DAYS}d-${new Date().toISOString().slice(0, 10)}.csv`);\n });\n }\n\n\n private exportTrafficCsv(): void {\n const rows = filteredRows(this.trafficTable).map((p: any) => [\n p.path, p.views, p.prevViews, p.visitors, p.aiViews, p.prevAiViews, p.topAiLabel || \"\",\n placeCountryNamed(p.topPlace || \"\"),\n p.citedCount ?? \"\", p.metaLabel || \"\", p.crawlerHits, p.crawlerBots, p.lastSeen || \"\",\n ]);\n triggerCsvDownload(\n toCsv([\"Page\", \"Views\", \"Views prior window\", \"Visitors\", \"AI-referred\",\n \"AI-referred prior window\", \"Top assistant\", \"Top location\",\n \"Cited by queries\", \"Meta\", \"Crawler fetches\", \"Crawlers\", \"Last view\"], rows),\n `site-traffic-page-views-${this.traffic?.windowDays ?? DEFAULT_WINDOW_DAYS}d-${new Date().toISOString().slice(0, 10)}.csv`);\n }\n}\n", "import { apiFetch } from \"../utils/Api\";\nimport { getLocale, t } from \"../i18n/I18n\";\nimport { Header } from \"../utils/Header\";\nimport { PageLoadGate } from \"../utils/PageLoadGate\";\nimport { renderStatCards, SCStatCard } from \"../utils/SummaryCharts\";\nimport {\n mountFullExportMenu, productBranding, ReportDoc, ReportSection,\n} from \"../utils/ReportExport\";\nimport { DOM_STRING, toCsv, triggerCsvDownload } from \"../utils/DataTables\";\nimport {\n aiSourceLabel, bucketDate, carryColumnLayouts, destroyTable, esc, filteredRows, GOLD,\n DEFAULT_WINDOW_DAYS, focusableTooltips, INK, MUTED, numCell, pctDelta, pctText, SERIES_1,\n SERIES_3, setupCollapse, tableUI, tipAttr,\n} from \"./AIVisibilityShared\";\nimport { escapeHtml } from \"../utils/FormatUtils\";\n\ndeclare const $: any;\n\ndeclare const Chart: any;\n\n/**\n * Careers (ENG-4695).\n *\n * The careers pages on mimic.com and the Lever sync behind them: whether the\n * site's role list still follows Lever, how the list and each role page are\n * read, and how often a reader clicks through to Lever to apply. This was a\n * card on AI Visibility; it is its own page because its reader is\n * Recruiting, its failure mode (a posting held off the site, a sync that\n * stopped) is its own, and it has its own alerts.\n *\n * The findings the audit raises about held postings (recruiting_required)\n * stay in AI Visibility's findings table, where every finding is decided;\n * the sync line here names the same postings.\n *\n * Nothing here is tenant-scoped. Access is the marketing capability\n * (hasAiVisibility): the endpoint sits in the same RequireRole group.\n */\n\n// Careers on mimic.com, from GET /api/ai-visibility/careers: readership of\n// /careers and the role pages, apply clicks through to Lever, and the site's\n// Lever sync as it stands. Visitors are visitor-days, as on page views.\ninterface CareersRoleRow {\n postingId: string; role: string; team?: string; location?: string; url?: string;\n path?: string; onSite: boolean; views: number; visitors: number; aiViews: number;\n clicks: number; clickVisitors: number; aiClicks: number;\n clickThroughPct: number | null; lastClickAt?: string;\n}\n\ninterface CareersSummary {\n windowDays: number;\n careersViews: number; prevCareersViews: number; careersVisitors: number;\n postingViews: number; prevPostingViews: number; postingVisitors: number;\n aiViews: number; prevAiViews: number;\n clicks: number; prevClicks: number; clickVisitors: number; aiClicks: number; prevAiClicks: number;\n sectionVisitors: number; clickThroughPct: number | null; prevClickThroughPct: number | null;\n days: { day: string; views: number; postingViews: number; clicks: number; aiClicks: number }[];\n roles: CareersRoleRow[];\n channels: { channel: string; aiSource?: string; clicks: number }[];\n sync: {\n reachable: boolean; configured: boolean; rolesOnSite: number; lastSyncAt?: string;\n syncOk: boolean; syncError?: string; healthy: boolean; problem?: string;\n held?: { name: string; leverId: string; reason: string }[];\n };\n}\n\nexport class CareersUI {\n private careers: CareersSummary | null = null;\n private careersChart: any = null;\n private careersRolesTable: any = null;\n private loadSeq = 0;\n // True while the newest load is in flight: the payload may still be the\n // previous window's (or the previous visit's), so it is not exported.\n private loading = false;\n private readonly header = new Header(\"careers\");\n private readonly gate = new PageLoadGate(\"crContent\", () => t(\"common.loading\"),\n () => [this.el(\"careersExportWrap\")]);\n\n public async updateUI(): Promise {\n // Same icon as the sidebar entry.\n this.header.set({\n title: t(\"careers.title\"),\n icon: { fontAwesome: \"fa-briefcase\" },\n });\n carryColumnLayouts([\"avCareersRolesTable\"], \"careers\");\n // This object outlives the fragment, so on a return visit it still\n // holds the last visit's payload. Export stays blocked from before\n // the menu is mounted until this mount's load commits.\n this.loading = true;\n // Nor may expanding the trend card draw the last visit's chart.\n this.careers = null;\n this.wire();\n await this.loadCareers();\n }\n\n private el(id: string): T | null {\n return document.getElementById(id) as T | null;\n }\n\n // Handlers are attached once per mounted fragment; the marker lives on\n // the fragment, so a fresh mount is wired again.\n private wire(): void {\n const root = this.el(\"crPage\");\n if (!root || root.dataset.wired) return;\n root.dataset.wired = \"1\";\n this.el(\"crWindowSelect\")?.addEventListener(\"change\", () => void this.loadCareers());\n const exportHost = this.el(\"crWindowSelect\")?.parentElement;\n if (exportHost) mountFullExportMenu(exportHost, \"careersExport\", (a) => this.runExport(a));\n focusableTooltips(root);\n // The two cards fold from their headings like every card on the\n // Marketing pages; the page has no summary shell to wire.\n setupCollapse(null, null, () => undefined, (id) => {\n if (id === \"avCareersTrend\") this.renderCareersChart();\n });\n }\n\n private windowDays(): number {\n const v = Number(this.el(\"crWindowSelect\")?.value);\n return Number.isFinite(v) && v > 0 ? v : DEFAULT_WINDOW_DAYS;\n }\n\n private async loadCareers(): Promise {\n // Only the newest request may render: switching the window twice in\n // quick succession must not let the first answer land last.\n const seq = ++this.loadSeq;\n this.loading = true;\n this.gate.begin();\n const spin = this.header.showLoading();\n let data: CareersSummary | null = null;\n let error = \"\";\n try {\n const res = await apiFetch(`/api/ai-visibility/careers?days=${this.windowDays()}`);\n if (!res.ok) throw new Error(`HTTP ${res.status}`);\n data = await res.json();\n } catch (e) {\n console.warn(\"[CareersUI] careers unavailable\", e);\n error = e instanceof Error ? e.message : String(e);\n } finally {\n this.header.hideLoading(spin);\n }\n if (seq !== this.loadSeq) return;\n this.careers = data;\n this.loading = false;\n if (!data && this.gate.fail(t(\"body.failedToLoad\", { error }))) return;\n this.gate.open();\n this.renderCareers();\n }\n\n // The page: the sync line first, because every number under it\n // is only as good as the role list it describes; then the tiles, the\n // trend, and one row per role.\n private renderCareers(): void {\n const c = this.careers;\n const sync = this.el(\"avCareersSync\");\n if (sync) {\n const held = c?.sync.held || [];\n sync.classList.toggle(\"av-careers-sync-warn\", !!c && (!c.sync.healthy || held.length > 0));\n if (!c) {\n sync.textContent = t(\"aiVisibility.crUnavailable\");\n } else if (!c.sync.healthy) {\n // A stopped sync does not unhold a posting: say both.\n const problem = t(\"aiVisibility.crSyncProblem\", { problem: c.sync.problem || \"\" });\n sync.textContent = held.length > 0\n ? `${problem} ${t(held.length === 1 ? \"aiVisibility.crHeldOne\" : \"aiVisibility.crHeldMany\", {\n n: String(held.length),\n roles: held.map(h => `${h.name} \\u2014 ${h.reason}`).join(\"; \"),\n })}`\n : problem;\n } else {\n const when = c.sync.lastSyncAt ? new Date(c.sync.lastSyncAt).toLocaleString(getLocale()) : \"\";\n const base = c.sync.rolesOnSite > 0\n ? t(\"aiVisibility.crSyncOk\", { roles: String(c.sync.rolesOnSite), when })\n : t(\"aiVisibility.crSyncNone\", { when });\n // A Public posting the site held back is a gap a reader of\n // this page should see first; each one is also a finding.\n sync.textContent = held.length > 0\n ? `${t(held.length === 1 ? \"aiVisibility.crHeldOne\" : \"aiVisibility.crHeldMany\", {\n n: String(held.length),\n roles: held.map(h => `${h.name} \\u2014 ${h.reason}`).join(\"; \"),\n })} ${base}`\n : base;\n }\n }\n this.renderCareersTiles();\n this.renderCareersChart();\n this.renderCareersRolesTable();\n }\n\n private renderCareersTiles(): void {\n const box = this.el(\"avCareersTiles\");\n const c = this.careers;\n if (!box) return;\n if (!c) { box.innerHTML = \"\"; return; }\n const days = String(c.windowDays);\n const channelLine = c.channels.length\n ? c.channels.slice(0, 3).map(ch => `${ch.channel === \"ai\" && ch.aiSource\n ? aiSourceLabel(ch.aiSource) : this.channelLabel(ch.channel)} ${ch.clicks}`).join(\" \u00B7 \")\n : \"\";\n const cards: SCStatCard[] = [\n {\n label: t(\"aiVisibility.crPageViews\"),\n value: c.careersViews,\n sub: t(\"aiVisibility.crTileSub\", {\n visitors: c.careersVisitors.toLocaleString(getLocale()),\n delta: pctDelta(c.careersViews, c.prevCareersViews), days,\n }),\n color: INK,\n series: c.days.map(d => d.views),\n info: t(\"aiVisibility.tipCrPageViews\"),\n },\n {\n label: t(\"aiVisibility.crPostingViews\"),\n value: c.postingViews,\n sub: t(\"aiVisibility.crTileSub\", {\n visitors: c.postingVisitors.toLocaleString(getLocale()),\n delta: pctDelta(c.postingViews, c.prevPostingViews), days,\n }),\n color: INK,\n series: c.days.map(d => d.postingViews),\n info: t(\"aiVisibility.tipCrPostingViews\"),\n },\n {\n label: t(\"aiVisibility.crClicks\"),\n value: c.clicks,\n sub: [t(\"aiVisibility.crTileSub\", {\n visitors: c.clickVisitors.toLocaleString(getLocale()),\n delta: pctDelta(c.clicks, c.prevClicks), days,\n }), channelLine].filter(Boolean).join(\" \u00B7 \"),\n color: c.clicks > 0 ? SERIES_1 : MUTED,\n series: c.days.map(d => d.clicks),\n info: t(\"aiVisibility.tipCrClicks\"),\n },\n {\n label: t(\"aiVisibility.crCTR\"),\n value: c.clickThroughPct ?? 0,\n valueText: pctText(c.clickThroughPct),\n sub: t(\"aiVisibility.crCTRSub\", {\n clickers: c.clickVisitors.toLocaleString(getLocale()),\n visitors: c.sectionVisitors.toLocaleString(getLocale()),\n prev: pctText(c.prevClickThroughPct),\n }),\n color: INK,\n info: t(\"aiVisibility.tipCrCTR\"),\n },\n {\n label: t(\"aiVisibility.crAI\"),\n value: c.aiViews,\n sub: t(\"aiVisibility.crAISub\", {\n clicks: c.aiClicks.toLocaleString(getLocale()),\n delta: pctDelta(c.aiViews, c.prevAiViews), days,\n }),\n color: c.aiViews > 0 ? GOLD : MUTED,\n info: t(\"aiVisibility.tipCrAI\"),\n },\n ];\n renderStatCards(box, cards);\n }\n\n private channelLabel(channel: string): string {\n const key: Record = {\n ai: \"aiVisibility.dlChannelAi\", search: \"aiVisibility.dlChannelSearch\",\n social: \"aiVisibility.dlChannelSocial\", direct: \"aiVisibility.dlChannelDirect\",\n internal: \"aiVisibility.dlChannelInternal\", referral: \"aiVisibility.dlChannelReferral\",\n };\n return key[channel] ? t(key[channel]) : channel;\n }\n\n // Views of the careers pages (the list and the role pages together) and\n // apply clicks per day, as lines: at a handful a day the shape is the\n // point, as on the downloads trend.\n private renderCareersChart(): void {\n if (this.careersChart) { this.careersChart.destroy(); this.careersChart = null; }\n const el = this.el(\"avCareersChart\");\n const c = this.careers;\n const wrap = this.el(\"avCareersTrend\");\n const hasData = !!c && (c.careersViews + c.postingViews + c.clicks) > 0;\n if (wrap) wrap.style.display = hasData ? \"\" : \"none\";\n if (!el || !hasData || typeof Chart === \"undefined\") return;\n const days = c!.days;\n const line = (label: string, data: number[], color: string, fill: boolean) => ({\n label, data, borderColor: color, backgroundColor: fill ? color + \"22\" : color,\n borderWidth: 2, pointRadius: days.length > 45 ? 0 : 2, pointHoverRadius: 4,\n tension: 0.25, fill,\n });\n this.careersChart = new Chart(el.getContext(\"2d\"), {\n type: \"line\",\n data: {\n labels: days.map(x => bucketDate(x.day, { month: \"numeric\", day: \"numeric\" })),\n datasets: [\n line(t(\"aiVisibility.crPageViews\"), days.map(x => x.views), INK, false),\n line(t(\"aiVisibility.crPostingViews\"), days.map(x => x.postingViews), SERIES_3, false),\n line(t(\"aiVisibility.crClicks\"), days.map(x => x.clicks), SERIES_1, true),\n ],\n },\n options: {\n responsive: true, maintainAspectRatio: false,\n // Axis ticks and default labels in the app's language.\n locale: getLocale(),\n interaction: { mode: \"index\", intersect: false },\n plugins: {\n legend: { labels: { boxWidth: 10, font: { size: 11 } } },\n datalabels: { display: false },\n },\n scales: {\n x: { grid: { display: false } },\n y: { beginAtZero: true, grid: { color: \"#E8E8E8\" }, ticks: { precision: 0 } },\n },\n },\n });\n }\n\n // One row per role the site lists, plus any role clicked in the window\n // that has since closed: its page's readership beside its apply clicks.\n private renderCareersRolesTable(): void {\n const el = this.el(\"avCareersRolesTable\");\n if (!el || typeof $ === \"undefined\") return;\n this.careersRolesTable = destroyTable(this.careersRolesTable);\n $(el).find(\"tbody\").empty();\n\n const rows = (this.careers?.roles || []).map(r => ({\n ...r,\n team: r.team || \"\",\n ctr: r.clickThroughPct,\n lastClick: r.lastClickAt ? new Date(r.lastClickAt).toLocaleString(getLocale()) : \"\",\n lastClickSort: r.lastClickAt ? new Date(r.lastClickAt).getTime() : 0,\n }));\n this.careersRolesTable = $(el).DataTable({\n data: rows,\n paging: rows.length > 15,\n searching: true,\n info: rows.length > 15,\n autoWidth: false,\n pageLength: 25,\n dom: DOM_STRING,\n order: [[3, \"desc\"]],\n columns: [\n { data: \"role\", title: t(\"aiVisibility.crRole\"), width: \"34ch\",\n render: (d: string, type: string, row: any) => {\n if (type !== \"display\") return d;\n const page = row.path\n ? `${esc(d)}`\n : esc(d);\n const closed = row.onSite ? \"\" : ` ${esc(t(\"aiVisibility.crNotOnSite\"))}`;\n return page + closed;\n } },\n { data: \"team\", title: t(\"aiVisibility.crTeam\"), width: \"18ch\",\n render: (d: string) => esc(d || \"\u2014\") },\n { data: \"views\", title: t(\"aiVisibility.crViews\"), width: \"9ch\", className: \"text-end\",\n render: numCell },\n { data: \"clicks\", title: t(\"aiVisibility.crClicks\"), width: \"10ch\", className: \"text-end\",\n render: (v: number, type: string, row: any) => type === \"display\" && v > 0 && row.url\n ? `${v.toLocaleString(getLocale())}`\n : numCell(v, type) },\n { data: \"ctr\", title: t(\"aiVisibility.crCTRCol\"), width: \"10ch\", className: \"text-end\",\n render: (v: number | null, type: string) => type === \"sort\" || type === \"type\"\n ? (v ?? -1) : pctText(v) },\n { data: \"aiViews\", title: t(\"aiVisibility.trafficAI\"), width: \"10ch\", className: \"text-end\",\n render: (v: number, type: string, row: any) => {\n const n = v + row.aiClicks;\n if (type === \"sort\" || type === \"type\") return n;\n const text = n > 0 ? `${v.toLocaleString(getLocale())} / ${row.aiClicks.toLocaleString(getLocale())}` : \"0\";\n return type === \"display\" && n > 0 ? `${text}` : text;\n } },\n { data: \"lastClick\", title: t(\"aiVisibility.crLastClick\"), width: \"18ch\",\n render: (d: string, type: string, row: any) =>\n type === \"sort\" || type === \"type\" ? row.lastClickSort : (d || \"\u2014\") },\n ],\n columnDefs: [\n { targets: [0], columnControl: [[\"orderAsc\", \"orderDesc\", \"searchText\"]] },\n { targets: [1], columnControl: [[\"orderAsc\", \"orderDesc\", \"searchList\"]] },\n { targets: [2, 3, 4, 5, 6], columnControl: [[\"orderAsc\", \"orderDesc\"]] },\n ],\n language: { emptyTable: t(\"aiVisibility.crNone\"), search: \"\" },\n headerCallback: (thead: any) => {\n (thead as HTMLTableSectionElement).classList.add(\"custom-thead\");\n },\n });\n tableUI(\"avCareersRolesTable\", this.careersRolesTable, \"ai-visibility-careers-roles\", () => {\n const out = filteredRows(this.careersRolesTable).map((r: any) => [\n r.role, r.team, r.location || \"\", r.onSite ? \"yes\" : \"no\", r.path || \"\", r.url || \"\",\n r.views, r.visitors, r.clicks, r.clickVisitors,\n r.ctr === null ? \"\" : r.ctr.toFixed(1), r.aiViews, r.aiClicks, r.lastClick, // copilot-lint-ignore: CSV cell, machine-readable decimal\n ]);\n triggerCsvDownload(\n toCsv([\"Role\", \"Team\", \"Location\", \"Listed on mimic.com\", \"Page\", \"Lever posting\",\n \"Views\", \"Visitors\", \"Apply clicks\", \"Clicking visitors\", \"Click-through %\",\n \"AI-referred views\", \"AI-referred apply clicks\", \"Last click\"], out),\n `careers-roles-${this.careers?.windowDays ?? DEFAULT_WINDOW_DAYS}d-${new Date().toISOString().slice(0, 10)}.csv`);\n });\n }\n\n // \u2500\u2500 page export (PDF / PowerPoint / Word) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n // Whether the page may be exported now; says why not when it may not.\n // Asked before the export starts and again after awaiting the branding,\n // because a window change in between can start a load or fail one.\n private exportBlocked(): boolean {\n if (this.loading) {\n this.header.showMessage(t(\"body.nothingToExport\"), \"warning\", 5);\n return true;\n }\n // Loaded and still empty means the request failed.\n if (!this.careers) {\n this.header.showMessage(t(\"body.exportIncomplete\"), \"warning\", 6);\n return true;\n }\n return false;\n }\n\n private async runExport(action: (doc: ReportDoc) => Promise): Promise {\n if (this.exportBlocked()) return;\n this.header.showLoading(t(\"body.buildingReport\"));\n try {\n const branding = await productBranding();\n if (this.exportBlocked()) return;\n await action(this.buildReportDoc(branding));\n } catch (err) {\n console.error(\"[CareersUI] export failed\", err);\n this.header.showMessage(\n t(\"body.exportFailed\", { error: escapeHtml(err instanceof Error ? err.message : String(err)) }), \"error\", 8);\n } finally {\n this.header.hideLoading();\n }\n }\n\n private buildReportDoc(branding: any): ReportDoc {\n const cr = this.careers!;\n const days = cr.windowDays;\n const sections: ReportSection[] = [{\n heading: t(\"careers.title\"),\n subtitle: this.el(\"avCareersSync\")?.textContent || \"\",\n kpis: [\n { label: t(\"aiVisibility.crPageViews\"), value: cr.careersViews.toLocaleString(getLocale()),\n note: pctDelta(cr.careersViews, cr.prevCareersViews) },\n { label: t(\"aiVisibility.crPostingViews\"), value: cr.postingViews.toLocaleString(getLocale()),\n note: pctDelta(cr.postingViews, cr.prevPostingViews) },\n { label: t(\"aiVisibility.crClicks\"), value: cr.clicks.toLocaleString(getLocale()),\n note: pctDelta(cr.clicks, cr.prevClicks) },\n { label: t(\"aiVisibility.crCTR\"), value: pctText(cr.clickThroughPct) },\n ],\n }];\n if (cr.roles.length) {\n sections.push({\n heading: t(\"aiVisibility.crByRole\"),\n table: {\n headers: [t(\"aiVisibility.crRole\"), t(\"aiVisibility.crTeam\"), t(\"aiVisibility.crViews\"),\n t(\"aiVisibility.crClicks\"), t(\"aiVisibility.crCTRCol\")],\n totalRows: cr.roles.length,\n rows: cr.roles.slice(0, 50).map(r => [\n r.role, r.team || \"\u2014\", r.views.toLocaleString(getLocale()), r.clicks.toLocaleString(getLocale()),\n pctText(r.clickThroughPct),\n ]),\n },\n note: t(\"aiVisibility.tipCrClicks\"),\n });\n }\n const today = new Date();\n return {\n title: t(\"careers.title\"),\n subtitle: `mimic.com \u2014 ${t(\"body.lastNDaysWindow\", { n: String(days) })}`,\n generatedAt: today,\n branding,\n sections,\n filenameBase: `careers-${days}d-${today.toISOString().slice(0, 10)}`,\n };\n }\n}\n", "import { t, t as t2 } from '../i18n/I18n';\nimport { apiFetch } from '../utils/Api';\nimport { DOM_STRING, setupDataTableUI } from '../utils/DataTables';\nimport { Header } from '../utils/Header';\nimport { PageLoadGate } from '../utils/PageLoadGate';\nimport { KevSummary, KevSummaryFilter, KEV_OUT_OF_SCOPE } from './KevSummary';\nimport StoreManager from '../managers/StoreManager';\nimport { storage } from '../utils/Storage';\nimport { renderSourcesCell, sourceLabel, sortSources, anyOfSourcePredicate } from './VulnSourceChips';\nimport { escapeHtml } from \"../utils/FormatUtils\";\n\n// CISA's Known Exploited Vulnerabilities catalog, enriched with an\n// exploitation class (ai/prompts/kevgpt_system_prompt.md).\n//\n// Tenant-independent by construction: every row is CISA's public record plus\n// our own classification of it, identical for every viewer. There is no tenant\n// in the API path and no role gate. The one tenant-scoped addition (ENG-4292)\n// is the Source column: with a selected tenant, /api/kev?tenantId= tags each\n// row with the lanes that saw that CVE on the tenant's servers \u2014 the Mimic\n// node's own observations or a third-party scanner's export \u2014 and the page\n// offers a Source filter over them. The fleet view carries no tenant, so the\n// column is hidden there rather than shown empty.\n//\n// The load-bearing distinction this page has to keep visible, because getting\n// it wrong is how a demo turns into a false claim: `primitives` is a statement\n// about the exploitation CLASS \u2014 the node-observable event classes an exploit\n// of that kind must produce. It is never a coverage verdict on the individual\n// CVE. Two columns exist rather than one precisely so adjacency does no\n// unearned work: \"what it must do next\" is the exploit's obligation, \"how\n// Mimic protects\" is our capability. Neither says this CVE was stopped; that\n// claim requires a real detonation with a recorded event tree.\n\ninterface KevRow {\n cveID: string;\n vendor: string;\n product: string;\n name: string;\n description: string;\n dateAdded: string;\n dueDate: string;\n ransomware: boolean;\n cwes: string[] | null;\n class: string;\n summary: string;\n nextStep: string;\n commonName: string;\n primitives: string[] | null;\n defenses: string[] | null;\n platforms: string[] | null;\n platformConfidence: string | null;\n /** Tenant cross-reference (ENG-4292): the lanes that saw this CVE on the\n * selected tenant. Absent on rows nothing saw, and on the fleet view. */\n sources?: string[] | null;\n}\n\ninterface KevResponse {\n entries: KevRow[] | null;\n total: number;\n matched: number;\n truncated: boolean;\n classified: number;\n /** With ?tenantId: the distinct source ids present on the tenant,\n * computed before the ?source filter \u2014 what the Source filter offers. */\n sources?: string[] | null;\n tenantId?: string;\n}\n\n// The filter set is not a design choice \u2014 it is the payload an evidence-link\n// token carries (mcpserver/tools_kev.go mints {cve, q, class, ransomware,\n// limit}). Anything search_kev can express, this page must be able to show, or\n// a resolved token lands on a table that cannot render what the token described\n// and the reader concludes there were no results.\ninterface KevFilters {\n cve: string;\n q: string;\n class: string;\n ransomware: boolean;\n}\n\n// Defense category, derived from the row's RESOLVED primitives rather than\n// from its class. Deriving both columns from one value is what keeps them from\n// contradicting each other: a class default that has been overridden must not\n// leave one column describing the default and the other the override.\n//\n// Only capabilities with evidence in the node/plugin repos appear here.\n// Network-flow enforcement is deliberately absent \u2014 there is no rule_id on\n// network events and no shipped NETWORK rule, so claiming it would be\n// fabrication.\nconst DEFENSE = {\n exec: { label: 'kev.def.exec', tip: 'kev.def.exec.tip' },\n write: { label: 'kev.def.write', tip: 'kev.def.write.tip' },\n object: { label: 'kev.def.object', tip: 'kev.def.object.tip' },\n net: { label: 'kev.def.net', tip: 'kev.def.net.tip' },\n // Not a primitive: the API returns this for classes that yield position\n // rather than action. The entry is not denied; the next step is.\n after: { label: 'kev.def.after', tip: 'kev.def.after.tip' },\n read: { label: 'kev.def.read', tip: 'kev.def.read.tip' },\n // Not a capability statement: the controls exist, they are just not on\n // that kind of host. Distinct from \"not constrained\".\n // Four separate reasons a row is on no host a node runs on. Which one\n // matters: three of the four are a deliberate product boundary, not a gap.\n appliance: { label: 'kev.def.appliance', tip: 'kev.def.appliance.tip' },\n endpoint: { label: 'kev.def.endpoint', tip: 'kev.def.endpoint.tip' },\n noos: { label: 'kev.def.noos', tip: 'kev.def.noos.tip' },\n hosted: { label: 'kev.def.hosted', tip: 'kev.def.hosted.tip' },\n none: { label: 'kev.def.none', tip: 'kev.def.none.tip' },\n // Not a claim in either direction: CISA published no mechanism, so there is\n // nothing to reason from. Distinct from `none`, which IS an answer \u2014 that\n // class is understood and yields nothing to refuse.\n nomech: { label: 'kev.def.nomech', tip: 'kev.def.nomech.tip' },\n // The same kind of abstention, for the host rather than the mechanism:\n // the platform could not be placed, so no claim either way.\n undetermined: { label: 'kev.def.undetermined', tip: 'kev.def.undetermined.tip' },\n} as const;\n\n// Which primitives are enforceable is decided by the API (kevEnforceable in\n// api/kev.go), not here. This page and mimic.com each used to derive it, and\n// drifted the moment network enforcement landed on one of them. The ids below\n// map to copy; the set and its order come from the server.\n\nexport class KevUI {\n private dataTable: DataTables.Api | null = null;\n private header = new Header('kev');\n private rows: KevRow[] = [];\n private filters: KevFilters = { cve: '', q: '', class: '', ransomware: false };\n private summary: KevSummary | null = null;\n /** The tenant the Source column describes; null on the fleet view. */\n private tenantId: string | null = null;\n /** Source ids present on the tenant (server-computed, pre-filter). */\n private sourcesPresent: string[] = [];\n /** ?allSubtenants=1 as this load read it (a search_kev link taken across\n * every sub-tenant). The sub-tenant switcher clears it: a pick there is\n * the reader's newer choice, and the page must follow it. */\n private allSubtenants = false;\n private subtenantListenerWired = false;\n /** Newest-load-wins: each updateUI takes a number, and a load that is no\n * longer the newest writes nothing after its awaits. */\n private loadGen = 0;\n private readonly gate = new PageLoadGate('kevContent', () => t('common.loading'));\n\n constructor() {\n this.updateUI = this.updateUI.bind(this);\n }\n\n public async updateUI(): Promise {\n this.header.set({\n title: t('nav.kev'),\n icon: { fontAwesome: 'fa-bug' },\n });\n this.header.enableMessageBar();\n\n const gen = ++this.loadGen;\n this.gate.begin();\n this.readFiltersFromUrl();\n this.allSubtenants = new URLSearchParams(window.location.search).get('allSubtenants') === '1';\n this.resolveTenant();\n if (!this.subtenantListenerWired) {\n window.addEventListener('subtenantChanged', this.onSubtenantChanged as EventListener); // copilot-lint-ignore: added once, behind subtenantListenerWired\n this.subtenantListenerWired = true;\n }\n\n this.header.showLoading();\n this.initializeDataTable([]);\n this.setEmptyTableMessage(t2('dt.loading'));\n\n try {\n // Always load the whole catalog. The token's filters are then\n // applied AS COLUMN FILTERS below, so a deeplinked view arrives\n // with the funnel icons lit and the filters clearable \u2014 rather\n // than as a silently pre-narrowed table the reader cannot widen.\n // The Source filter is the one exception: it narrows server-side\n // (the same contract as Missing Patches, so the two pages agree\n // on what ?source=a,b means) and shows itself as the chip row\n // above the table, cleared from there.\n const seenKeyP = this.seenScopeKey();\n const resp = await apiFetch(`/api/kev${this.tenantQuery()}`);\n if (gen !== this.loadGen) return;\n if (!resp.ok) {\n throw new Error(`${resp.status}`);\n }\n const body = (await resp.json()) as KevResponse;\n const seenKey = await seenKeyP;\n if (gen !== this.loadGen) return;\n this.rows = body.entries ?? [];\n this.sourcesPresent = sortSources(body.sources ?? []);\n this.header.hideLoading();\n this.setEmptyTableMessage(t2('kev.empty'));\n this.gate.open();\n this.destroy();\n this.initializeDataTable(this.rows);\n this.renderSourceFilter();\n this.updateHeader(body, seenKey);\n this.initializeSummary();\n } catch (err: any) {\n if (gen !== this.loadGen) return;\n this.header.hideLoading();\n if (!this.gate.fail(t2('kev.loadFailed', { error: err?.message ?? '' }))) {\n this.header.showMessage(t2('kev.loadFailed', { error: escapeHtml(err?.message ?? '') }), 'error', 12);\n }\n this.setEmptyTableMessage(t2('kev.loadFailed', { error: err?.message ?? '' }));\n this.destroy();\n this.initializeDataTable([]);\n }\n }\n\n // Filters ride the URL so a resolved evidence link is a plain navigation\n // and the view is shareable/reloadable, matching how the Events page\n // consumes its own deeplink params.\n private readFiltersFromUrl(): void {\n const p = new URLSearchParams(window.location.search);\n this.filters = {\n cve: (p.get('cve') ?? '').trim(),\n q: (p.get('q') ?? '').trim(),\n class: (p.get('class') ?? '').trim(),\n ransomware: p.get('ransomware') === 'true',\n };\n }\n\n // The tenant the Source column describes. Same URL contract as Missing\n // Patches: ?tenantId=X names it (a search_kev deeplink minted with\n // tenant_id), ?allTenants=1 is the fleet view and carries none, and\n // otherwise it is the app's selected tenant. The catalog itself is the\n // same either way; only the cross-reference needs a tenant.\n private resolveTenant(): void {\n const p = new URLSearchParams(window.location.search);\n const urlTenant = (p.get('tenantId') || '').trim();\n if (urlTenant) storage.set('selectedTenantId', urlTenant);\n this.tenantId = p.get('allTenants') === '1' ? null : (storage.get('selectedTenantId') || null);\n }\n\n // Query string for the tenant cross-reference: the tenant, its selected\n // subtenant (the same narrowing the tenant pages apply), and the Source\n // filter. Empty on the fleet view, which keeps the request byte-identical\n // to the plain catalog call.\n private tenantQuery(): string {\n if (!this.tenantId) return '';\n const q = new URLSearchParams();\n q.set('tenantId', this.tenantId);\n // ?allSubtenants=1 (a search_kev link taken across every sub-tenant)\n // widens the cross-reference; otherwise the selected sub-tenant, if\n // any, narrows it.\n const subId = StoreManager.getInstance().subtenantStore.getSelectedId(this.tenantId);\n if (this.allSubtenants) q.set('allSubtenants', 'true');\n else if (subId) q.set('subtenantID', subId);\n const sources = this.sourcesFromURL();\n if (sources.length) q.set('source', sources.join(','));\n return `?${q}`;\n }\n\n /** The Source filter (ENG-4292) rides the URL as ?source=a,b \u2014 empty\n * means every source, exactly like the server's contract. */\n private sourcesFromURL(): string[] {\n const raw = new URLSearchParams(window.location.search).get('source') || '';\n return sortSources(raw.split(',').map(s => s.trim()).filter(Boolean));\n }\n\n private setSourcesInURL(ids: string[]): void {\n const u = new URLSearchParams(window.location.search);\n if (ids.length) u.set('source', ids.join(',')); else u.delete('source');\n if (!this.allSubtenants) u.delete('allSubtenants');\n history.replaceState(history.state, '', `${window.location.pathname}?${u}`);\n }\n\n // A sub-tenant pick in the switcher supersedes a link's ?allSubtenants=1:\n // drop it from state and the URL, then reload in the picked scope.\n private onSubtenantChanged = (ev: Event) => {\n if (!this.tenantId || !document.getElementById('kevTable')) return;\n const detail = (ev as CustomEvent<{ tenantId?: string }>).detail;\n if (detail?.tenantId && detail.tenantId !== this.tenantId) return;\n this.allSubtenants = false;\n const u = new URLSearchParams(window.location.search);\n if (u.has('allSubtenants')) {\n u.delete('allSubtenants');\n history.replaceState(history.state, '', `${window.location.pathname}?${u}`);\n }\n void this.updateUI();\n };\n\n /** One toggle chip per source present on the tenant; multi-select. The\n * same control as Missing Patches: it appears once there is a choice to\n * make (two or more sources, or a selection to clear), writes the\n * selection to the URL and reloads the rows from the server. Hidden on\n * the fleet view, where there is no tenant to cross-reference. */\n private renderSourceFilter(): void {\n const host = document.getElementById('kevSourceFilter');\n if (!host) return;\n const selected = new Set(this.tenantId ? this.sourcesFromURL() : []);\n // A selected id no longer present (a stale link) still renders, so\n // the reader can see why the table is empty and clear it.\n const ids = this.tenantId ? sortSources([...this.sourcesPresent, ...selected]) : [];\n if (ids.length < 2 && !selected.size) { host.hidden = true; host.innerHTML = ''; return; }\n host.hidden = false;\n host.innerHTML = `${this.escape(t2('vulnSources.filter.label'))}`\n + ids.map(id => ``).join('')\n + (selected.size ? `` : '');\n host.querySelectorAll('.vs-filter-chip').forEach(btn => {\n btn.onclick = () => {\n const id = btn.dataset.source || '';\n const next = new Set(this.sourcesFromURL());\n if (next.has(id)) next.delete(id); else next.add(id);\n this.setSourcesInURL(sortSources([...next]));\n void this.updateUI();\n };\n });\n const clear = host.querySelector('.vs-filter-clear');\n if (clear) {\n clear.onclick = () => {\n this.setSourcesInURL([]);\n void this.updateUI();\n };\n }\n }\n\n\n\n // The scope the \"seen\" count covers, as tenantQuery() sent it: the\n // server (resolveSubtenantFilter) narrows to the selected sub-tenant,\n // widens on ?allSubtenants, and with neither reads the root tenant's own\n // nodes (every node when none sit at the root, which seenRoot says too;\n // a tenant with no sub-tenants is that case). The caller's own sub-tenant\n // list is not the tenant's (a root-only caller is listed none), so it\n // never decides the whole tenant.\n // Read before the fetch, as tenantQuery() reads the same state.\n private async seenScopeKey(): Promise {\n const tenantId = this.tenantId;\n if (!tenantId) return 'kev.subtitle.seen';\n if (this.allSubtenants) return 'kev.subtitle.seenAllSubtenants';\n const store = StoreManager.getInstance().subtenantStore;\n if (store.getSelectedId(tenantId)) return 'kev.subtitle.seenSubtenant';\n return 'kev.subtitle.seenRoot';\n }\n\n // The count has to follow the TABLE, not the fetch. Filtering is applied to\n // the loaded rows, so reporting the payload size would claim the whole\n // catalog is on screen while the reader is looking at ten filtered rows.\n private updateHeader(body: KevResponse, seenKey: string): void {\n const sources = this.tenantId ? this.sourcesFromURL() : [];\n const render = () => {\n let shown = this.rows.length;\n try {\n const info: any = this.dataTable?.page.info();\n if (info && typeof info.recordsDisplay === 'number') shown = info.recordsDisplay;\n } catch { /* pre-init; the payload size is the honest fallback */ }\n\n const parts: string[] = [];\n // With ?source= the server returns only the rows those sources\n // saw, so body.total is that count, not the catalog's size: say\n // whose entries they are rather than \"of N catalog entries\".\n if (sources.length) {\n parts.push(t2('kev.subtitle.showingSources', { shown, total: body.total, sources: sources.map(id => sourceLabel(id)).join(', ') }));\n } else {\n parts.push(t2('kev.subtitle.showing', { shown, total: body.total }));\n }\n if (body.classified < body.total) {\n parts.push(t2('kev.subtitle.classified', { n: body.classified }));\n }\n // With a tenant: how many of the loaded rows some lane saw on\n // its servers. Counted from the rows, like everything else here.\n if (this.tenantId) {\n const seen = this.rows.filter(r => (r.sources?.length ?? 0) > 0).length;\n parts.push(t2(seenKey, { n: seen }));\n }\n this.header.setSubtitle(escapeHtml(parts.join(' \u00B7 ')));\n };\n render();\n // Keep it truthful as the reader filters, searches, or clears.\n $('#kevTable').off('draw.dt.kevheader').on('draw.dt.kevheader', render);\n }\n\n // Build the summary panel and connect its clicks to the table.\n //\n // The panel counts the same rows the table holds, so there is no second\n // request and no way for the two to disagree.\n private initializeSummary(): void {\n if (!this.summary) this.summary = new KevSummary('kevSummary');\n this.summary.onFilter = (spec) => this.applySummaryFilter(spec);\n this.summary.render(this.rows);\n }\n\n // A summary click filters through a NAMED fixed search rather than through\n // ColumnControl.\n //\n // ColumnControl 1.1.1 exposes no way to set a searchList SELECTION\n // programmatically (only its option list, and searchClear). Faking it by\n // writing the column's own search would leave the dropdown showing nothing\n // ticked while the table was visibly narrowed \u2014 a filter the reader can\n // see the effect of but not the cause of, and cannot clear from the\n // control that appears to own it.\n //\n // A separately-named fixed search sidesteps that entirely: it ANDs with\n // whatever the dropdowns hold, touches none of their state, and is cleared\n // by name. The cost is that no funnel icon lights for it, which is why the\n // panel renders its own clearable chip.\n private applySummaryFilter(spec: KevSummaryFilter | null): void {\n const table: any = this.dataTable;\n if (!table) return;\n try {\n if (!spec) {\n table.search.fixed('kev-summary', '');\n } else {\n table.search.fixed('kev-summary', (_s: string, data: any, idx: number) => {\n // Prefer the row object the callback hands us; fall back to\n // the index into the source array, which is 1:1 with it\n // because the table is initialised from `this.rows`.\n const row: KevRow | undefined =\n data && typeof data === 'object' && 'cveID' in data ? data : this.rows[idx];\n if (!row) return true;\n switch (spec.kind) {\n case 'outcome': return KevSummary.outcomeOf(row) === spec.value;\n // The only multi-bucket filter: the three \"no node runs\n // on that kind of machine\" outcomes, selected together.\n case 'scope': return KEV_OUT_OF_SCOPE.includes(KevSummary.outcomeOf(row));\n case 'class': return row.class === spec.value;\n case 'platform': return (row.platforms ?? []).includes(spec.value);\n default: return true;\n }\n });\n }\n table.draw();\n } catch { /* table torn down mid-flight; the next load re-applies */ }\n }\n\n // Resolve a row's primitives, falling back to nothing rather than guessing.\n // An unclassified row says so; it does not silently render as \"not\n // constrained\", which would be a claim we have not earned.\n private primitivesOf(row: KevRow): string[] {\n return (row.primitives ?? []).filter((p) => typeof p === 'string' && p.length > 0);\n }\n\n // Every control that applies, in chain order \u2014 earliest first. Returns an\n // empty array when the class produces nothing the node constrains, and\n // null when the row is unclassified (which is not the same claim).\n private defensesFor(row: KevRow): { label: string; tip: string }[] | null {\n if (!row.class) return null;\n // Server-resolved and already in chain order.\n const prims = (row.defenses ?? []).filter((p) => (DEFENSE as any)[p]);\n return prims.map((p) => (DEFENSE as any)[p] as { label: string; tip: string });\n }\n\n // What the filter matches against. Every applicable control is present, so\n // filtering on one picks up rows where it is a later fallback rather than\n // the headline \u2014 a row constrained at execution AND at the network should\n // appear under either.\n private defenseFilterText(row: KevRow): string {\n const d = this.defensesFor(row);\n if (d === null) return t2('kev.unclassified');\n if (d.length === 0) return t2('kev.def.none');\n return d.map((x) => t2(x.label)).join(', ');\n }\n\n private escape(s: string): string {\n return String(s ?? '')\n .replace(/&/g, '&')\n .replace(//g, '>')\n .replace(/\"/g, '"');\n }\n\n private initializeDataTable(data: KevRow[]): void {\n const tableId = 'kevTable';\n const tableElement = $(`#${tableId}`);\n\n this.dataTable = tableElement.DataTable({\n data,\n language: {\n emptyTable: t2('kev.empty'),\n zeroRecords: t2('kev.noMatch'),\n search: '',\n },\n columns: [\n {\n data: 'cveID',\n render: (d: string, type: string) => {\n if (type !== 'display') return d ?? '';\n const id = this.escape(d);\n return `${id}`;\n },\n },\n { data: 'vendor' },\n { data: 'product' },\n { data: 'name' },\n {\n data: 'dateAdded',\n render: (d: string, type: string) => {\n // A date, not a timestamp. Formatting it as a datetime\n // shifts it into local time and renders the day BEFORE\n // the one CISA published.\n if (type !== 'display') return d ?? '';\n return d ? String(d).slice(0, 10) : '';\n },\n },\n {\n data: 'ransomware',\n render: (d: boolean, type: string) => {\n // 'sort'/'type' want the boolean so ordering is\n // true-before-false; 'filter' wants the rendered label,\n // because that is what a column filter matches against.\n if (type === 'filter') return d ? t2('kev.ransomware.yes') : t2('kev.ransomware.no');\n if (type !== 'display') return d ? 1 : 0;\n return d\n ? `${this.escape(t2('kev.ransomware.yes'))}`\n : `${this.escape(t2('kev.ransomware.no'))}`;\n },\n },\n {\n // Tenant cross-reference (ENG-4292): which lanes saw this\n // CVE on the selected tenant. Hidden on the fleet view.\n data: 'sources',\n visible: !!this.tenantId,\n render: (d: string[] | null | undefined, type: string) => renderSourcesCell(d, type),\n },\n {\n data: 'class',\n render: (d: string, type: string, row: KevRow) => {\n if (type !== 'display') return d ?? '';\n if (!d) return `${this.escape(t2('kev.unclassified'))}`;\n // No tooltip here. Only \"How Mimic protects\" is\n // hoverable, so a hover always answers the same\n // question rather than meaning something different\n // column to column.\n return `${this.escape(d)}`;\n },\n },\n {\n data: 'primitives',\n render: (_d: string[], type: string, row: KevRow) => {\n const prims = this.primitivesOf(row);\n if (type !== 'display') return prims.join(',');\n if (!row.class) return `${this.escape(t2('kev.unclassified'))}`;\n if (prims.length === 0) return `${this.escape(t2('kev.none'))}`;\n // Every primitive renders identically, and none is\n // hoverable. This column states what the EXPLOIT must\n // do; what Mimic constrains is the next column, which\n // owns the only tooltip on the row.\n return prims\n .map((p) => `${this.escape(p)}`)\n .join(' ');\n },\n },\n {\n // Raw value, like the class column \u2014 these are ids the\n // reader learns once, not prose. Confidence rides in the\n // tooltip so the column stays narrow.\n data: 'platforms',\n render: (_d: string[], type: string, row: KevRow) => {\n // A list, like the controls column \u2014 a product running on\n // Linux and Windows says so, rather than hiding behind a\n // word that makes the reader ask \"both what?\".\n const ps = row.platforms ?? [];\n // Already resolved by the API: a host class for\n // anything that is not a server (\"appliance\",\n // \"client\"), the assessed operating systems when it is.\n if (type !== 'display') return ps.join(', ');\n if (ps.length === 0) return `${this.escape(t2('kev.unclassified'))}`;\n const tip = this.escape(t2('kev.platform.tip', { confidence: row.platformConfidence || '' }));\n return ps\n .map((p) => `${this.escape(p)}`)\n .join(' ');\n },\n },\n {\n data: 'class',\n orderable: true,\n render: (_d: string, type: string, row: KevRow) => {\n if (type !== 'display') return this.defenseFilterText(row);\n const defs = this.defensesFor(row);\n if (defs === null) return `${this.escape(t2('kev.unclassified'))}`;\n if (defs.length === 0) {\n const tip = this.escape(t2('kev.def.none.tip'));\n return `${this.escape(t2('kev.def.none'))}`;\n }\n // One chip per control, earliest first. The order is the\n // claim: the first is what ends the attempt, the rest\n // are fallbacks if it is not configured.\n return defs\n .map((d) => {\n const tip = this.escape(t2(d.tip, { nextStep: row.nextStep || '' }));\n const cls = d.label.startsWith('kev.def.appliance')\n || d.label.startsWith('kev.def.endpoint')\n || d.label.startsWith('kev.def.noos')\n || d.label.startsWith('kev.def.hosted')\n ? 'kev-defense kev-defense-nohost' : 'kev-defense';\n return `${this.escape(t2(d.label))}`;\n })\n .join(' ');\n },\n },\n ],\n dom: DOM_STRING,\n pageLength: 25,\n scrollCollapse: true,\n scrollY: '60vh',\n order: [[4, 'desc']],\n responsive: true,\n searching: true,\n // Deep-link preset only \u2014 stateSave is on so stateLoadCallback is\n // consulted, and the save callback is a no-op so nothing persists\n // between visits and yesterday's filter never resurrects.\n stateSave: true,\n stateLoadCallback: () => this.buildDeepLinkState(),\n stateSaveCallback: () => { /* deep-link preset only */ },\n columnDefs: [\n { targets: [0, 4], className: 'kev-nowrap' },\n // Product names are short (\"SFOS\", \"FTA\"); the vulnerability\n // title is a full sentence \u2014 at 23% it was wrapping to four\n // lines and setting the row height for the whole table. Take\n // the width from Product and Vendor, which have it spare.\n { targets: [1], width: '8%' },\n { targets: [2], width: '11%' },\n { targets: [3], width: '32%' },\n { targets: [0, 1, 2], columnControl: [['orderAsc', 'orderDesc', 'searchList']] },\n { targets: [5, 6, 7, 8, 9, 10], columnControl: [['orderAsc', 'orderDesc', 'searchList']] },\n // searchText, not sort-only. A column offering ONLY sort\n // buttons is treated as \"active\" whenever it is the sorted\n // column (DataTables.ts branch 5), which lit this funnel gold\n // beside genuinely filtered ones and read as a filter that was\n // never applied. Giving it a search control means gold means\n // filtered, uniformly, across every column here.\n { targets: [4], columnControl: [['orderAsc', 'orderDesc', 'searchText']] },\n ],\n });\n\n this.enableAnyOfListMatching(tableId);\n\n setupDataTableUI(tableId, this.dataTable, {\n columnControl: {\n dropdownMaterialIcon: 'filter_list',\n scrollHeadThClass: 'filter-right',\n fixHeaderLayout: true,\n },\n autoResize: { namespace: 'kev' },\n });\n }\n\n // Column indices, in the order declared above. Named so reordering the\n // columns cannot silently point a filter at the wrong one.\n private static readonly COL = { cve: 0, ransomware: 5, source: 6, class: 7, platform: 9, protect: 10 };\n\n // Seed the token's filters as real ColumnControl searchList state.\n //\n // Returned from stateLoadCallback rather than applied after init, because\n // searchList reads its initial selection from dt.state.loaded() and\n // re-applies it from there \u2014 a post-init seed gets wiped on the next\n // reload of the column's options. This is the same route the AD reports\n // and Logs deeplinks take (utils/AdNodeFilter.ts, LogsUI).\n //\n // It buys three things a pre-narrowed server query cannot: the funnel icon\n // shades (syncColumnControlActiveIcons reads this state), the dropdown's\n // ticks are already correct when the reader opens it, and they can clear\n // the filter to widen back to the full catalog.\n //\n // Returns null when nothing is filtered, so the table keeps its normal\n // unfiltered default.\n private buildDeepLinkState(): any | null {\n const f = this.filters;\n if (!f.cve && !f.q && !f.class && !f.ransomware) return null;\n\n const columnControl: Record = {};\n if (f.cve) columnControl[KevUI.COL.cve] = { searchList: [f.cve] };\n if (f.class) columnControl[KevUI.COL.class] = { searchList: [f.class] };\n // Matches the rendered label, which is what the searchList options are\n // built from \u2014 see the 'filter' branch of the ransomware renderer.\n if (f.ransomware) columnControl[KevUI.COL.ransomware] = { searchList: [t2('kev.ransomware.yes')] };\n\n return {\n time: Date.now(),\n start: 0,\n length: 25,\n order: [[4, 'desc']],\n // Free text spans vendor/product/name/description, so it belongs in\n // the table-wide box where the reader can see and edit it.\n search: { search: f.q ?? '', regex: false, smart: true, caseInsensitive: true },\n columnControl,\n };\n }\n\n // ColumnControl's searchList filters with `selected.includes(cellValue)` \u2014\n // strict equality against the WHOLE cell. A row carrying three controls\n // would therefore only match if the reader selected that exact\n // combination, so picking \"Network control\" would miss every row where it\n // sits alongside another control.\n //\n // Rather than drop the dropdown for a text box, override that one\n // predicate for this column with an any-of test. It has to be re-applied\n // on preDraw because ColumnControl rewrites it whenever the selection\n // changes; preDraw fires before the filter pass, so there is no lag.\n private enableAnyOfListMatching(tableId: string): void {\n const table = this.dataTable;\n if (!table) return;\n $(`#${tableId}`).on('preDraw.dt.kevany', () => {\n try {\n const st: any = (table as any).state();\n // Same override for the Source column: a cell listing two\n // vendors must match the reader picking either one.\n for (const idx of [KevUI.COL.protect, KevUI.COL.source]) {\n const sel: string[] = st?.columnControl?.[idx]?.searchList ?? [];\n if (!Array.isArray(sel) || sel.length === 0) continue;\n const col: any = table.column(idx);\n col.search.fixed('dtcc-list', anyOfSourcePredicate(sel));\n }\n } catch { /* leave ColumnControl's own predicate in place */ }\n });\n }\n\n private setEmptyTableMessage(msg: string): void {\n if (!this.dataTable) return;\n const settings = this.dataTable.settings()[0];\n settings.oLanguage.sEmptyTable = msg;\n this.dataTable.draw(false);\n }\n\n public destroy(): void {\n this.summary?.destroy();\n if (this.dataTable) {\n // destroy() alone \u2014 it restores the original from\n // kev.html. Emptying the table here would delete those \n // elements, and the re-init would render a headerless table.\n this.dataTable.destroy();\n this.dataTable = null;\n }\n }\n}\n", "// static/ts/ui/KevSummary.ts\n// Collapsible summary panel for the Exploited Vulnerabilities page.\n//\n// Everything here is counted from the rows already on the client \u2014 the page\n// loads the whole catalog in one request, so the panel adds no server work and\n// cannot disagree with the table beneath it.\n//\n// The panel exists to replace an argument with a tally. The claim this page\n// makes is \"most of what an exploit has to do next is something the node can\n// deny\", and that claim is far better made by the outcome breakdown than by a\n// paragraph asserting it. Two of the buckets \u2014 appliance and client device \u2014\n// are product boundaries rather than gaps, which is exactly the distinction a\n// prose summary blurs and a labelled segment does not.\n//\n// Collapse state persists in localStorage and defaults to OPEN. Chart.js\n// canvases laid out while hidden render 0x0, so charts are (re)built on expand.\nimport { storage } from \"../utils/Storage\";\nimport { escapeHtml } from \"../utils/FormatUtils\";\nimport { t } from '../i18n/I18n';\nimport {\n renderStatCards, renderDonut, renderTrendLine, topNWithOther, destroyChart,\n SCStatCard, SCDonutSegment,\n} from \"../utils/SummaryCharts\";\n\n// The row shape this panel counts. Deliberately structural rather than an\n// import of KevUI's interface: the panel needs four fields, and depending on\n// the whole row type would make it look like it needs the rest.\nexport interface KevSummaryRow {\n class: string;\n defenses: string[] | null;\n platforms: string[] | null;\n ransomware: boolean;\n dateAdded: string;\n}\n\n// What a summary click asks the table to show. The value is matched against\n// row DATA, never against rendered cell text, so a label change cannot quietly\n// break a filter.\nexport interface KevSummaryFilter {\n // 'scope' is the only one that is not a single value: it selects the three\n // out-of-scope outcomes together.\n kind: 'outcome' | 'class' | 'platform' | 'scope';\n value: string;\n label: string;\n}\n\n// The outcome buckets, in the order they appear in the donut.\n//\n// Derived from the server's `defenses` ids and nothing else. This page and\n// mimic.com each used to derive their own version of this and drifted the\n// moment network enforcement landed on one of them; the rule now is that the\n// API decides and every surface reads its answer.\nexport type KevOutcome =\n | 'constrained' | 'after' | 'none'\n | 'appliance' | 'endpoint' | 'noos' | 'hosted'\n | 'undetermined' | 'nomech' | 'unclassified';\n\n// The buckets that are a product boundary rather than a capability gap: no\n// Mimic node runs on that kind of machine at all. Distinct from 'none', which\n// IS on a machine we protect and simply yields no action to refuse.\nexport const KEV_OUT_OF_SCOPE: KevOutcome[] = ['appliance', 'endpoint', 'noos', 'hosted'];\n\n// The buckets that answer nothing, and so belong outside any protectable\n// denominator rather than inside it as a loss. 'unclassified' has not been\n// through the classifier; 'nomech' has, and the classifier's answer was that\n// CISA states no mechanism; 'undetermined' is a row whose platform could not\n// be placed. All three mean \"we cannot say\", which is not the same claim as\n// 'none' \u2014 that one IS an answer, and the honest one for self-leak.\nexport const KEV_NO_ANSWER: KevOutcome[] = ['undetermined', 'nomech', 'unclassified'];\n\n// The ids that represent a control the node actually applies. `after` is not\n// among them: it says the entry itself is not denied, only what follows.\nconst ENFORCEABLE = ['exec', 'write', 'object', 'net', 'read'];\n\n// Greys for the three out-of-scope buckets, saturated colors for the three\n// that are a statement about capability. The reader should be able to see at a\n// glance which part of the ring is a claim and which part is a boundary.\nconst OUTCOME_COLOR: Record = {\n constrained: '#10B981',\n after: '#F59E0B',\n none: '#EF4444',\n appliance: '#9CA3AF',\n endpoint: '#B0B7C3',\n noos: '#CBD2DA',\n hosted: '#C2C9D2',\n undetermined: '#DDE2E8',\n nomech: '#D9DEE5',\n unclassified: '#E5E7EB',\n};\n\nconst OUTCOME_LABEL: Record = {\n constrained: 'kev.sum.outcome.constrained',\n after: 'kev.def.after',\n none: 'kev.def.none',\n appliance: 'kev.def.appliance',\n endpoint: 'kev.def.endpoint',\n noos: 'kev.def.noos',\n hosted: 'kev.def.hosted',\n undetermined: 'kev.def.undetermined',\n nomech: 'kev.def.nomech',\n unclassified: 'kev.unclassified',\n};\n\nconst OUTCOME_ORDER: KevOutcome[] = [\n 'constrained', 'after', 'none', 'appliance', 'endpoint', 'noos', 'hosted',\n 'undetermined', 'nomech', 'unclassified',\n];\n\nexport class KevSummary {\n private static readonly COLLAPSED_KEY = \"kevSummaryCollapsed\" as const;\n\n public static readonly CARD_COLORS = {\n total: \"#3B82F6\",\n ransomware: \"#EC4899\",\n classified: \"#06B6D4\",\n constrained: \"#10B981\",\n outofscope: \"#6B7280\",\n } as const;\n\n private root: HTMLElement | null;\n private charts: any[] = [];\n private rows: KevSummaryRow[] = [];\n private toggleWired = false;\n private active: KevSummaryFilter | null = null;\n\n // Set by KevUI: applies (or clears, on null) the clicked filter against the\n // DataTable. The panel deliberately knows nothing about DataTables.\n public onFilter?: (spec: KevSummaryFilter | null) => void;\n\n constructor(rootId: string) {\n this.root = document.getElementById(rootId);\n this.setupToggle();\n }\n\n // Which bucket a row falls in. Single definition, used by the donut, the\n // tiles and the filter predicate alike \u2014 so a row can never be counted\n // under one bucket and filtered under another.\n public static outcomeOf(row: KevSummaryRow): KevOutcome {\n if (!row.class) return 'unclassified';\n const d = row.defenses ?? [];\n if (d.includes('appliance')) return 'appliance';\n if (d.includes('endpoint')) return 'endpoint';\n if (d.includes('noos')) return 'noos';\n if (d.includes('hosted')) return 'hosted';\n // Like 'nomech' below: the platform could not be placed, so this row\n // makes no claim either way.\n if (d.includes('undetermined')) return 'undetermined';\n // Before the capability checks: this row makes no claim either way, and\n // counting it as 'none' read an absence of evidence as a failure.\n if (d.includes('nomech')) return 'nomech';\n if (d.some((x) => ENFORCEABLE.includes(x))) return 'constrained';\n if (d.includes('after')) return 'after';\n return 'none';\n }\n\n private collapsed(): boolean {\n return storage.get(KevSummary.COLLAPSED_KEY) === true;\n }\n\n private setupToggle(): void {\n if (!this.root || this.toggleWired) return;\n const toggle = this.root.querySelector(\".sc-summary-toggle\");\n if (!toggle) return;\n this.toggleWired = true;\n this.applyCollapsed(this.collapsed());\n toggle.onclick = () => {\n const next = !this.root!.classList.contains(\"collapsed\");\n storage.set(KevSummary.COLLAPSED_KEY, next);\n this.applyCollapsed(next);\n if (!next && this.rows.length) this.renderCharts();\n };\n }\n\n private applyCollapsed(collapsed: boolean): void {\n this.root?.classList.toggle(\"collapsed\", collapsed);\n this.root?.querySelector(\".sc-summary-toggle\")?.setAttribute(\"aria-expanded\", String(!collapsed));\n }\n\n public render(rows: KevSummaryRow[]): void {\n if (!this.root) return;\n this.rows = rows;\n this.root.hidden = rows.length === 0;\n if (!rows.length) return;\n\n const counts = this.outcomeCounts();\n const sub = this.root.querySelector(\".sc-summary-sub\");\n if (sub) {\n sub.textContent = t('kev.sum.sub', {\n total: rows.length,\n constrained: counts.constrained ?? 0,\n });\n }\n\n this.renderCards();\n this.renderActiveChip();\n if (!this.collapsed()) this.renderCharts();\n }\n\n private outcomeCounts(): Partial> {\n const out: Partial> = {};\n for (const r of this.rows) {\n const o = KevSummary.outcomeOf(r);\n out[o] = (out[o] ?? 0) + 1;\n }\n return out;\n }\n\n // Additions per month across the last 24 months the catalog covers, for the\n // tile sparklines. CISA publishes in bursts, so the shape is genuinely\n // informative rather than decorative.\n private monthlySeries(pick: (r: KevSummaryRow) => boolean): number[] {\n const byMonth = new Map();\n for (const r of this.rows) {\n const m = (r.dateAdded || '').slice(0, 7); // YYYY-MM\n if (m.length !== 7) continue;\n if (!byMonth.has(m)) byMonth.set(m, 0);\n if (pick(r)) byMonth.set(m, byMonth.get(m)! + 1);\n }\n return [...byMonth.keys()].sort().slice(-24).map((k) => byMonth.get(k) ?? 0);\n }\n\n // RUNNING TOTAL per month, not per-month arrivals.\n //\n // Arrivals put CISA's initial publication \u2014 one bulk seeding of ~290 in\n // Nov 2021 \u2014 against months that add twenty or thirty, so the axis is set\n // by an event that is not a month of discovery at all and the ongoing\n // cadence flattens to nothing. The cumulative curve carries the same data\n // with no artifact, and it states the claim better: the distance between\n // the two lines IS the share of the catalog a control applies to.\n //\n // Bucket edges are built in UTC. dateAdded is a plain date, and\n // constructing it in local time lands a 1st-of-the-month entry in the\n // previous month anywhere west of UTC.\n private cumulativePoints(pick: (r: KevSummaryRow) => boolean): { x: number; y: number }[] {\n const byMonth = new Map();\n for (const r of this.rows) {\n const m = (r.dateAdded || '').slice(0, 7);\n if (m.length !== 7) continue;\n if (!byMonth.has(m)) byMonth.set(m, 0);\n if (pick(r)) byMonth.set(m, byMonth.get(m)! + 1);\n }\n let running = 0;\n return [...byMonth.keys()].sort().map((k) => {\n const [y, mo] = k.split('-').map(Number);\n running += byMonth.get(k) ?? 0;\n return { x: Date.UTC(y, mo - 1, 1), y: running };\n });\n }\n\n private renderCards(): void {\n const cardsEl = this.root?.querySelector(\".sc-summary-cards\");\n if (!cardsEl) return;\n const C = KevSummary.CARD_COLORS;\n const counts = this.outcomeCounts();\n const classified = this.rows.filter((r) => !!r.class).length;\n const constrained = counts.constrained ?? 0;\n const outOfScope = KEV_OUT_OF_SCOPE.reduce((a, o) => a + (counts[o] ?? 0), 0);\n\n const cards: SCStatCard[] = [\n {\n label: t('kev.sum.card.total'), value: this.rows.length, color: C.total,\n series: this.monthlySeries(() => true),\n },\n {\n label: t('kev.sum.card.ransomware'), value: this.rows.filter((r) => r.ransomware).length,\n color: C.ransomware, series: this.monthlySeries((r) => r.ransomware),\n info: t('kev.sum.card.ransomware.info'),\n },\n {\n label: t('kev.sum.card.classified'), value: classified, color: C.classified,\n info: t('kev.sum.card.classified.info'),\n },\n {\n label: t('kev.sum.card.constrained'), value: constrained, color: C.constrained,\n info: t('kev.sum.card.constrained.info'),\n onClick: constrained > 0\n ? () => this.toggleFilter({ kind: 'outcome', value: 'constrained', label: t(OUTCOME_LABEL.constrained) })\n : undefined,\n },\n // The out-of-scope total, not the 73 \"nothing to block\" rows.\n //\n // Those two get confused constantly, and only one of them is about\n // the platform: 73 sit ON a server we protect and simply produce no\n // action to refuse, while these 893 are firewalls, laptops and\n // operating systems a node does not run on. The 893 is the larger\n // fact about the catalog \u2014 54% of it \u2014 and it was visible only in\n // the donut. \"Not constrained\" stays there, where the two sit side\n // by side and cannot be mistaken for each other.\n {\n label: t('kev.sum.card.outofscope'), value: outOfScope, color: C.outofscope,\n info: t('kev.sum.card.outofscope.info'),\n onClick: outOfScope > 0\n ? () => this.toggleFilter({ kind: 'scope', value: 'out', label: t('kev.sum.card.outofscope') })\n : undefined,\n },\n ];\n renderStatCards(cardsEl, cards);\n this.markActiveCards(cardsEl);\n }\n\n // renderStatCards has no notion of a selected tile, so the ring is applied\n // here rather than by adding a field to a util eight other pages share.\n private markActiveCards(cardsEl: HTMLElement): void {\n const idx = this.active?.kind === 'outcome' && this.active.value === 'constrained' ? 3\n : this.active?.kind === 'scope' ? 4\n : -1;\n cardsEl.querySelectorAll('.sc-stat-card').forEach((el, i) => {\n el.classList.toggle('kev-card-active', i === idx);\n });\n }\n\n // Toggle semantics: clicking the active selection clears it. Without this a\n // reader who clicks a slice has no way back except reloading the page,\n // because the summary filter is deliberately not a ColumnControl dropdown.\n private toggleFilter(spec: KevSummaryFilter): void {\n const same = this.active\n && this.active.kind === spec.kind && this.active.value === spec.value;\n this.active = same ? null : spec;\n this.onFilter?.(this.active);\n this.renderActiveChip();\n const cardsEl = this.root?.querySelector(\".sc-summary-cards\");\n if (cardsEl) this.markActiveCards(cardsEl);\n }\n\n // The summary filter is not a column filter, so no funnel icon lights for\n // it. That makes a visible, clearable chip mandatory rather than nice to\n // have \u2014 otherwise the reader is looking at a narrowed table with no\n // on-screen evidence of why.\n private renderActiveChip(): void {\n const host = this.root?.querySelector(\".kev-sum-active\");\n if (!host) return;\n if (!this.active) {\n host.innerHTML = '';\n host.hidden = true;\n return;\n }\n host.hidden = false;\n host.innerHTML =\n `` +\n `${escapeHtml(t('kev.sum.filtered', { label: this.active.label }))}` +\n `` +\n ``;\n host.querySelector('.kev-sum-chip-x')?.addEventListener('click', () => {\n this.active = null;\n this.onFilter?.(null);\n this.renderActiveChip();\n const cardsEl = this.root?.querySelector(\".sc-summary-cards\");\n if (cardsEl) this.markActiveCards(cardsEl);\n });\n }\n\n private renderCharts(): void {\n this.clearCharts();\n if (!this.root || !this.rows.length) return;\n\n // The catalog as it grew, with the ransomware-linked share underneath.\n //\n // A \"\u2026with a control that applies\" line was tried here first and\n // dropped: the donut beside it already answers that, so the second\n // curve restated a neighbour. Ransomware does not \u2014 it is a single\n // number everywhere else on the page, and only here can you see\n // whether that share is growing faster than the catalog.\n const trend = this.root.querySelector(\"#kevTrend\");\n if (trend) {\n this.charts.push(renderTrendLine(trend, [\n {\n label: t('kev.sum.trend.added'), color: KevSummary.CARD_COLORS.total,\n fill: true, points: this.cumulativePoints(() => true),\n },\n {\n label: t('kev.sum.trend.ransomware'), color: KevSummary.CARD_COLORS.ransomware,\n fill: true, points: this.cumulativePoints((r) => r.ransomware),\n },\n ], { timeUnit: 'month' }));\n }\n\n const counts = this.outcomeCounts();\n const outcomeSegs: SCDonutSegment[] = OUTCOME_ORDER\n .filter((o) => (counts[o] ?? 0) > 0)\n .map((o) => ({\n label: t(OUTCOME_LABEL[o]),\n value: counts[o] ?? 0,\n color: OUTCOME_COLOR[o],\n filterValue: o,\n }));\n this.renderDonutInto('kevOutcomeDonut', t('kev.sum.donut.outcome'), outcomeSegs,\n t('kev.sum.center.entries'), 'outcome');\n\n // Class and platform use the raw ids the table shows. They are ids the\n // reader learns once, and translating them here would stop the donut\n // label matching the cell it filters to.\n this.renderDonutInto('kevClassDonut', t('kev.sum.donut.class'),\n topNWithOther(this.tally((r) => (r.class ? [r.class] : [])), 7),\n t('kev.sum.center.entries'), 'class');\n\n this.renderDonutInto('kevPlatformDonut', t('kev.sum.donut.platform'),\n topNWithOther(this.tally((r) => r.platforms ?? []), 7),\n t('kev.sum.center.entries'), 'platform');\n }\n\n // Count rows per key. A row contributing several keys (a product on Linux\n // AND Windows) counts once under each, so these donuts total more than the\n // catalog \u2014 which is why their center label says \"entries\" rather than\n // implying a partition.\n private tally(keys: (r: KevSummaryRow) => string[]): { label: string; count: number }[] {\n const m = new Map();\n for (const r of this.rows) {\n for (const k of keys(r)) m.set(k, (m.get(k) ?? 0) + 1);\n }\n return [...m.entries()].map(([label, count]) => ({ label, count }));\n }\n\n private renderDonutInto(\n containerId: string, title: string, segs: SCDonutSegment[],\n centerLabel: string, kind: KevSummaryFilter['kind'],\n ): void {\n const host = this.root?.querySelector(\"#\" + containerId);\n if (!host) return;\n host.innerHTML = `\n
${escapeHtml(title)}
\n ${segs.length === 0\n ? `
${escapeHtml(t('body.noData'))}
`\n : `
\n
\n \n
\n
\n
\n
`}`;\n if (!segs.length) return;\n const canvas = host.querySelector(\"canvas\") as HTMLCanvasElement;\n const center = host.querySelector(\".sc-donut-center\") as HTMLElement;\n const legend = host.querySelector(\".sc-donut-legend\") as HTMLElement;\n this.charts.push(renderDonut(canvas, center, legend, segs, {\n centerLabel,\n onSelect: (s) => this.toggleFilter({\n kind, value: s.filterValue ?? '', label: s.label,\n }),\n }));\n }\n\n private clearCharts(): void {\n for (const c of this.charts) destroyChart(c);\n this.charts = [];\n }\n\n public destroy(): void {\n this.clearCharts();\n this.rows = [];\n this.active = null;\n if (this.root) this.root.hidden = true;\n }\n}\n", "// VulnSourceChips \u2014 the Source column shared by the Missing Patches and\n// Exploited Vulnerabilities pages (ENG-4292, docs/vuln-sources.md \u00A75).\n//\n// A row's sources[] says which lane reported it: Dashcam's own lanes\n// (mimic, osv, msrc, wua) or a third-party scanner's id (crowdstrike, wiz,\n// tenable, \u2026). Labels come from the catalog under vulnSources.source.;\n// an id with no label (a vendor added server-side before the catalog\n// caught up) renders as itself rather than as a missing-key placeholder.\n//\n// Page-agnostic on purpose: no DataTables, no page CSS. The chip classes\n// live in css/common/tables.css so every page stylesheet sees them \u2014\n// NavigationManager loads exactly one page stylesheet at a time.\nimport { t } from '../i18n/I18n';\n\n/** Source ids the catalog carries labels for, in the contract's order:\n * Dashcam's lanes first, then the vendors alphabetically. */\nexport const KNOWN_SOURCE_IDS = [\n 'mimic', 'osv', 'msrc', 'wua',\n 'axonius', 'crowdstrike', 'defender', 'grype', 'openscap', 'qualys', 'rapid7', 'recordedfuture', 'tenable', 'trivy', 'wiz',\n] as const;\n\nconst KNOWN = new Set(KNOWN_SOURCE_IDS);\n\nfunction esc(s: unknown): string {\n return String(s ?? '').replace(/&/g, '&').replace(//g, '>')\n .replace(/\"/g, '"');\n}\n\n/** Human label for a source id; the raw id when the catalog has none. */\nexport function sourceLabel(id: string): string {\n const key = String(id ?? '').trim().toLowerCase();\n return KNOWN.has(key) ? t(`vulnSources.source.${key}`) : key;\n}\n\n/** Sort ids into the contract's order, unknown ids last alphabetically. */\nexport function sortSources(ids: readonly string[]): string[] {\n const rank = new Map(KNOWN_SOURCE_IDS.map((id, i) => [id, i]));\n return [...new Set(ids.map(s => String(s ?? '').trim().toLowerCase()).filter(Boolean))]\n .sort((a, b) => (rank.get(a) ?? 999) - (rank.get(b) ?? 999) || a.localeCompare(b));\n}\n\n/** One chip. `own` marks a Dashcam lane (mimic/osv/msrc/wua) so the eye can\n * tell \"we observed it\" from \"a vendor told us\" at a glance. */\nexport function sourceChip(id: string): string {\n const key = String(id ?? '').trim().toLowerCase();\n const own = key === 'mimic' || key === 'osv' || key === 'msrc' || key === 'wua';\n return `${esc(sourceLabel(key))}`;\n}\n\n/**\n * DataTables-style renderer for a sources[] cell.\n * display \u2192 one chip per lane (two chips already say \"two lanes agree\",\n * so there is no separate multi-source tag \u2014 ENG-4374);\n * filter \u2192 the labels joined by ', ' (what a searchList matches);\n * sort \u2192 the primary label, so the column orders by name.\n */\nexport function renderSourcesCell(sources: readonly string[] | null | undefined, type: string): string | number {\n const ids = sortSources(sources ?? []);\n if (type === 'filter') return ids.map(sourceLabel).join(', ');\n if (type !== 'display') return ids.length ? sourceLabel(ids[0]) : '';\n if (!ids.length) return '';\n return `${ids.map(sourceChip).join('')}`;\n}\n\n/**\n * ColumnControl's searchList compares the whole cell string, so a cell\n * \"CrowdStrike, Wiz\" would only match the reader picking that exact pair.\n * Split the cell on ', ' and match any \u2014 the KEV page's Mimic-can-protect\n * column uses the same override. Returns the predicate to install with\n * `column.search.fixed('dtcc-list', \u2026)` on preDraw.\n */\nexport function anyOfSourcePredicate(selected: readonly string[]): (cell: string) => boolean {\n return (cell: string) => selected.some(v => String(cell ?? '').split(', ').includes(v));\n}\n\n/** The \"No Mimic node\" badge for a vendor row the asset ladder could not\n * tie to a node (contract \u00A74 rung 5). */\nexport function noNodeBadge(): string {\n return `${esc(t('vulnSources.noNode'))}`;\n}\n", "import { t, t as t2, tn, getLocale } from '../i18n/I18n';\nimport { apiFetch, postJson } from '../utils/Api';\nimport { readOsOverlay, osOverlayQualified, osOverlayNoticeText, OsOverlayAnswer, OsOverlayState } from '../utils/OsOverlay';\nimport { Header } from '../utils/Header';\nimport { PageLoadGate } from '../utils/PageLoadGate';\nimport {\n DOM_STRING, SCROLL_DOM_STRING, setupDataTableUI, installCsvActionBar, triggerCsvDownload, downloadCsvNative, clientTableFilterState,\n enableSemanticVersioningSorting,\n} from '../utils/DataTables';\nimport {\n renderStatCards, renderDonut, topNWithOther, destroyChart, donutSvg,\n SCStatCard, SCDonutSegment,\n} from '../utils/SummaryCharts';\nimport { storage } from '../utils/Storage';\nimport { includeAllShown, dropIncludeAllCheckbox } from '../stores/SubtenantStore';\nimport { escapeHtml, tipAttr } from '../utils/FormatUtils';\nimport { csvCell, csvFormulaSafe } from '../utils/CsvFormula';\nimport { patchVersionsNote } from '../utils/PatchVersionsNote';\nimport { asOfRender, asOfCsv, asOfFull, relativeTime } from '../utils/AsOf';\nimport StoreManager from '../managers/StoreManager';\nimport {\n fetchReportBranding, donutImageFromRows, mountFullExportMenu, captureSvgMarkupPng,\n ReportDoc, ReportImage, ReportSection,\n} from '../utils/ReportExport';\nimport { TenantScopeToggle } from '../utils/TenantScopeToggle';\nimport { allTenantsScopeDefault, persistShowAllTenants } from '../utils/TenantScopeSetting';\nimport { CveFleetUI, TenantRow } from './CveFleetUI';\nimport { Drawer } from '../utils/Drawer';\nimport { openVirtualPatchWorkflow } from '../utils/VirtualPatchWorkflowModal';\nimport { addCopyButton } from '../utils/ButtonUtils';\nimport {\n renderSourcesCell, sourceLabel, sortSources, noNodeBadge, anyOfSourcePredicate, KNOWN_SOURCE_IDS,\n} from './VulnSourceChips';\nimport { archivedNodesVisible, archivedNodesTipHTML } from '../utils/ArchivedNodesSetting';\nimport {\n displayTenantNameHTML, tenantNameRevealed, ensureTenantMaskedChipStyles,\n} from '../utils/TenantNameDisplay';\n\n// Missing Patches \u2014 patch posture at either scope, switched by the shared\n// All Tenants toggle (Tenant Profiling pattern). This class owns the page\n// (header, toggle, view switch, URL contract) and the single-tenant detail\n// view; the All-Tenants fleet view (formerly the separate Fleet Patch Status\n// page \u2014 summary strip, per-tenant breakdown, top KEV product/CVE tables,\n// fleet report export) is rendered by the embedded CveFleetUI, whose tenant\n// rows drill back into the detail view here in place.\n//\n// The detail view is the tenant's CVE exposure in the frame an operator\n// patches in. A collapsible summary dashboard (same shell as Node Health /\n// the AD reports / KEV) over two tables straight off the exposure APIs\n// (ENG-3594/3612): software with known CVEs per (node, product, version),\n// and the estimated Windows cumulative-update gap per node \u2014 plus the\n// virtual-patch column (availability + request flow, ENG-3679) where the\n// management plane serves the virtual-patch APIs. This view is the landing\n// target for cve_exposure evidence deeplinks, so what it renders must be\n// what /api/tenants/:id/cve-exposure returns. Its narrowings are kev_only\n// (the server applies it to every lane: software, Linux packages, vendor\n// findings and the rollups; the OS lane is not narrowed), the source filter\n// and the sub-tenant scope. kev_only and source ride the URL and the scope\n// rides SubtenantStore and the Include All Sub-Tenants toggle, so a resolved\n// link reproduces the exact slice the tool answered from. A failed lane is\n// stated above the tables (renderPartialNotice).\n//\n// URL contract: ?allTenants=1 \u2192 fleet view (what ?page=cve-fleet used to\n// be \u2014 that page id now aliases here); ?tenantId=X \u2192 X's detail view;\n// neither \u2192 the persisted persona default (allTenantsScopeDefault).\n\ninterface ExposureRow {\n node_id: string;\n hostname?: string;\n /** The node's Mimic node build, from the roster (set on load). */\n node_version?: string;\n product_name: string;\n company_name?: string;\n file_version: string;\n cve_count: number;\n /** A scanner folded into a node count at or above the NVD storage cap:\n * cve_count is a floor (\"N+\"), not exact. */\n cve_count_at_least?: boolean;\n max_cvss: number;\n critical_count: number;\n known_exploited_cve_count: number;\n known_exploited: boolean;\n ransomware: boolean;\n top_cves: string[] | null;\n /** Provenance (ENG-4292): [\"mimic\"] for the node's own observation;\n * a scanner's id joins it when its row was folded in (ENG-4374), or\n * stands alone for a row nothing on the node's inventory explains. */\n sources?: string[];\n /** The vendor's fix version, when a scanner's row was folded in. */\n fix?: string;\n /** The vendor rows folded into this one \u2014 the drawer asks\n * /vendor-finding-cves for each. */\n vendor_refs?: { product: string; version: string }[] | null;\n /** The early view: CVEs a vulnerability feed reports for this software\n * that Dashcam's NVD lane has no entry for yet. Badged beside the CVE\n * count; the badge clears itself once NVD catches up. */\n early_cve_count?: number;\n early_cves?: string[] | null;\n /** The newest evidence behind the row: the last time the node ran this\n * version, or a folded scanner's newer sighting. */\n last_seen?: string;\n // Derived client-side: whether the node is in the current visible\n // roster. Exposure rows are real observations either way \u2014 a row for an\n // absent node is most likely a recently archived one, and is badged\n // rather than silently rendered under a raw node id.\n inRoster?: boolean;\n}\n\ninterface ExposureResponse {\n items: ExposureRow[] | null;\n /** The Linux half of the page: per-OS source selection happens server-\n * side \u2014 Windows nodes produce items, Linux nodes produce these. */\n linux_packages?: LinuxPkgRow[] | null;\n /** The vendor lane (ENG-4292): what third-party scanners reported, per\n * (node or unmatched asset, product, version). */\n vendor_findings?: VendorRow[] | null;\n /** Every source id present in the scope BEFORE the source filter \u2014 the\n * filter's choices. */\n sources?: string[] | null;\n /** The OS lane over the same roster the rollups were counted from\n * (source filter applied), and that roster's hostnames \u2014 the page's\n * own Nodes store can be narrower than the server's live roster. */\n os_exposure?: Record | null;\n hostnames?: Record | null;\n /** The same roster's Mimic node build per node (absent when unknown). */\n node_versions?: Record | null;\n nodes_affected: number;\n nodes_known_exploited: number;\n /** Live nodes in the sub-tenant scope the request resolved to \u2014 the tile\n * denominator, counted from the same population as nodes_affected. */\n nodes_in_scope?: number;\n hardening?: HardeningPayload;\n attribution?: string;\n /** Lanes that failed to load (linux_packages, roster, vendor_findings,\n * vendor_overlaps, os_estimates): empty there means unknown. */\n degraded?: string[] | null;\n}\n\n// The /cve-exposure degraded lane ids, as the notice names them.\nconst PARTIAL_LANE_KEYS: Record = {\n linux_packages: 'missingPatches.partial.lane.linux_packages',\n os_estimates: 'missingPatches.partial.lane.os_estimates',\n roster: 'missingPatches.partial.lane.roster',\n vendor_findings: 'missingPatches.partial.lane.vendor_findings',\n vendor_overlaps: 'missingPatches.partial.lane.vendor_overlaps',\n};\n// An id with no entry above (a lane added server-side first) reads as the\n// generic missingPatches.partial.lane.other, never as a raw server id.\n\n/** The partial notice's sentences for a response: every degraded lane (the\n * fallback's failed OS read included, as os_estimates). Empty for a whole\n * answer. The page notice and the exported report both read it, so neither\n * shows a failed lane as complete. */\nfunction partialNoticeLines(body: ExposureResponse | null): string[] {\n const lines: string[] = [];\n const lanes = [...new Set((body?.degraded ?? []).map(id => t2(PARTIAL_LANE_KEYS[id] ?? 'missingPatches.partial.lane.other')))];\n if (lanes.length > 0) {\n lines.push(t2('missingPatches.partial.degraded', {\n lanes: new Intl.ListFormat(getLocale(), { type: 'conjunction' }).format(lanes),\n }));\n }\n return lines;\n}\n\n/** Path to Green v1 (ENG-3786 PR 8) \u2014 server-computed heuristic. */\ninterface HardeningStep {\n label: string;\n kind: string; // \"app\" | \"windows_updates\"\n nodesCleared: number;\n projectedLikelihood: number;\n required?: boolean; // the fleet cannot be green without this one\n /** Why it is required: the floor reasons this step lifts. */\n floorReasons?: ('os_ransomware_kev' | 'os_months_behind')[];\n}\n\n/** A severe finding that holds the index up, and by how much (ENG-3946). */\ninterface HardeningSeverityFloor {\n reason: 'os_ransomware_kev' | 'os_months_behind';\n nodes: number;\n monthsBehind?: number; // worst node, on the staleness reason\n points: number; // this finding's own contribution to the floor\n}\n\ninterface HardeningPayload {\n likelihood: number; // 0-100 estimated attack-likelihood index\n score: number; // 100 - likelihood (Tenant Health component)\n greenTarget: number; // path stops at or below this likelihood\n steps: HardeningStep[] | null;\n nodesTotal: number;\n green?: boolean; // the server's verdict: likelihood <= greenTarget\n severityFloors?: HardeningSeverityFloor[];\n severityFloor?: number; // the minimum those findings impose\n}\n\ninterface NodeCveSummary {\n os_build?: number;\n os_ubr?: number;\n os_missing_cves?: number;\n os_missing_kev?: number;\n os_months_behind?: number;\n os_max_cvss?: number;\n os_ransomware?: boolean;\n os_patch_source?: 'reported' | 'estimated' | 'wua' | 'vendor';\n /** Windows Update Agent lane only: the KBs the agent lists as missing. */\n os_missing_kbs?: string[];\n /** The vendor OS lane (ENG-4374): what third-party scanners reported\n * against the node's Windows build itself. Present beside the Mimic\n * lane's figures; when os_patch_source is 'vendor' they ARE the row. */\n os_vendor_sources?: string[];\n os_vendor_product?: string;\n os_vendor_version?: string;\n os_vendor_cves?: number;\n os_vendor_kev?: number;\n os_vendor_max_cvss?: number;\n os_vendor_ransomware?: boolean;\n os_vendor_months_behind?: number;\n os_vendor_fix?: string;\n /** How old the row's figures are: the patch level's read, the Windows\n * Update Agent's search, or the scanners' newest sighting. */\n os_data_at?: string;\n /** The agent's last search listed updates the node has installed since;\n * those are not counted. */\n os_wua_stale?: boolean;\n os_wua_searched_at?: string;\n os_vendor_last_seen?: string;\n}\n\ninterface VirtualPatchItem {\n vulnerabilityId: string;\n cve: string;\n title: string;\n severity?: string;\n official: boolean;\n officialPatches: number;\n customPatches: number;\n}\n\ninterface VirtualPatchResponse {\n available: boolean;\n reason?: string;\n items?: VirtualPatchItem[];\n}\n\n/** LLM upgrade analysis (ENG-3837). Snapshot facts are server-computed; the\n * result is the model's prose. */\ninterface PatchAnalysisVersion {\n version: string;\n nodes: number;\n cveCount: number;\n kevCveIds?: string[];\n maxCvss: number;\n clearedByUpgradeTo?: string;\n}\n\ninterface PatchAnalysisSnapshot {\n priorityRank?: number;\n priorityRankOf?: number;\n nodesWithProduct: number;\n minClearingVersion?: string;\n versions: PatchAnalysisVersion[] | null;\n /** How many versions are installed; `versions` holds the newest of them. */\n versionCount?: number;\n sideBySide?: {\n nodes: number;\n newestVersion?: string;\n staleVersions?: string[];\n kevOnlyOnStale: boolean;\n };\n}\n\ninterface PatchAnalysisResult {\n headline: string;\n action: 'upgrade' | 'remove_stale' | 'upgrade_and_remove';\n insights?: string[];\n caveats?: string[];\n confidence: number;\n}\n\ninterface PatchAnalysis {\n productKey: string;\n productName: string;\n status: 'pending' | 'running' | 'completed' | 'failed';\n requestedBy?: string;\n snapshot?: PatchAnalysisSnapshot | null;\n result?: PatchAnalysisResult | null;\n error?: string;\n model?: string;\n generatedAt?: string;\n}\n\ninterface OsGapRow {\n nodeId: string;\n hostname: string;\n /** The node's Mimic node build, from the roster (set on load). */\n nodeVersion?: string;\n build: string;\n // The raw (build, UBR) pair behind the display string \u2014 what the CVE\n // drawer's windows-gap-cves fetch is keyed by.\n buildNum: number;\n ubr: number;\n /** Where the gap came from: the node's own registry report\n * (\"reported\", newer Mimic node versions), the OS-binary file-version\n * estimator (\"estimated\"), or the Windows Update Agent's own\n * missing-updates verdict (\"wua\", ENG-4292 \u2014 exact KBs). */\n source: 'reported' | 'estimated' | 'wua' | 'vendor';\n /** wua rows only: the KBs the agent lists as missing. */\n missingKbs: string[];\n /** The vendor OS lane (ENG-4374): scanners that reported CVEs against\n * this node's Windows build, with their own figures. Empty when none\n * did. source 'vendor' means no Mimic lane claims a gap and the row's\n * figures are these. */\n vendorSources: string[];\n vendorProduct: string;\n vendorVersion: string;\n vendorCves: number;\n vendorKev: number;\n vendorMaxCvss: number;\n vendorMonthsBehind: number;\n vendorFix: string;\n monthsBehind: number;\n missingCves: number;\n missingKev: number;\n maxCvss: number;\n ransomware: boolean;\n /** The As of column: how old the row's figures are (NodeCveSummary\n * os_data_at, or the scanners' sighting on a vendor row). */\n asOf?: string;\n /** The Windows Update Agent's last search predates the node's patch\n * level (os_wua_stale), and when it ran. */\n wuaStale: boolean;\n wuaSearchedAt?: string;\n}\n\n// One Linux (node, source package) with known distro advisories \u2014 the\n// linux_packages half of the /cve-exposure payload (ENG-3964). The row unit\n// is the INSTALLABLE UPDATE, mirroring how the OS table treats a cumulative\n// update: fixed_version is the remedy and the CVEs are what it addresses.\n//\n// max_cvss / critical_count cover EXACT rows only: a CVE that arrives via a\n// module erratum (advisory attribution) belongs to some member of that\n// erratum, not necessarily this package, so scoring it here would fabricate\n// severity. max_cvss 0 with exact_count 0 renders as \"\u2014\", never as \"safe\".\ninterface LinuxPkgRow {\n node_id: string;\n hostname?: string;\n /** The node's Mimic node build, from the roster (set on load). */\n node_version?: string;\n /** Provenance (ENG-4292): this lane is always [\"osv\"]. */\n sources?: string[];\n ecosystem: string;\n package: string;\n // installed is the OLDEST build on the node \u2014 what the gap is measured\n // from. installed_versions lists every build this one update clears,\n // oldest first: normally one, but the RHEL family keeps several kernels\n // installed at once and they are one thing to do, not one row each.\n installed: string;\n installed_versions?: string[];\n fixed_version: string;\n cve_count: number;\n exact_count: number;\n advisory_count: number;\n known_exploited_cve_count: number;\n known_exploited: boolean;\n ransomware: boolean;\n max_cvss: number;\n critical_count: number;\n top_cves: string[];\n /** The node's newest completed package inventory (or its confirmation). */\n scanned_at?: string;\n}\n\n// One vendor-reported group (ENG-4292): the vendor_findings half of the\n// /cve-exposure payload. node_id is '' when the asset ladder tied the\n// finding to no Mimic node \u2014 those rows keep the vendor's own hostname and\n// render with the \"No Mimic node\" badge. sources lists every vendor that\n// reported the group; multi_source_cves how many of its CVEs more than one\n// of them agreed on. known_exploited is CISA's verdict on the CVE ids;\n// vendor_exploited is the vendor's own flag.\ninterface VendorRow {\n node_id: string;\n asset_key: string;\n hostname?: string;\n /** The matched node's Mimic node build; '' for a row tied to no node. */\n node_version?: string;\n asset_hostname: string;\n asset_fqdn?: string;\n match_method: string;\n product: string;\n version: string;\n sources: string[];\n multi_source_cves: number;\n cve_count: number;\n early_cve_count?: number;\n early_cves?: string[] | null;\n max_cvss: number;\n severity: string;\n vendor_exploited: boolean;\n known_exploited_cve_count: number;\n known_exploited: boolean;\n ransomware: boolean;\n top_cves: string[];\n fix: string;\n remediation?: string;\n last_seen?: string;\n}\n\n// One CVE behind a vendor row \u2014 the /vendor-finding-cves payload.\ninterface VendorCveItem {\n cve_id: string;\n sources: string[];\n cvss_score: number;\n severity: string;\n vendor_exploited: boolean;\n kev_listed: boolean;\n ransomware: boolean;\n fixed_version: string;\n title: string;\n remediation: string;\n /** Dashcam's NVD lane has no entry for this CVE yet (the early view). */\n early?: boolean;\n}\n\n// One update the Windows Update Agent listed as missing \u2014 the updates[]\n// half of /windows-gap-cves?nodeId= for the wua lane.\ninterface WuaUpdateItem {\n kb: string;\n title: string;\n product: string;\n reboot_required: boolean;\n}\n\n// One stored distro advisory row for a package tuple \u2014 the /package-cves\n// payload behind the CVE drawer for Linux rows. attribution says whether the\n// CVE is pinned to this package (\"exact\") or arrived advisory-wide from a\n// module erratum (\"advisory\") \u2014 the drawer labels the latter.\ninterface PackageCveItem {\n cve_id: string;\n osv_id: string;\n cvss_score: number;\n fixed_version: string;\n attribution: string;\n summary: string;\n kev_listed: boolean;\n ransomware: boolean;\n}\n\n// One stored CVE affecting a (product, version) the tenant runs \u2014 the\n// /cve-exposure/product-cves payload behind the CVE drawer. KEV/ransomware\n// are per-CVE truths (kev_entries), not the row-level product verdict.\ninterface ProductCveItem {\n cve_id: string;\n cvss_score: number;\n kev_listed: boolean;\n ransomware: boolean;\n fixed_version: string;\n description: string;\n}\n\n// One fix MSRC shipped above a node's (build, UBR) \u2014 the /windows-gap-cves\n// payload behind the CVE drawer for Windows-gap rows.\ninterface WindowsFixItem {\n cve_id: string;\n fixed_ubr: number;\n kb: string;\n cvss_score: number;\n released_at: string;\n kev_listed: boolean;\n ransomware: boolean;\n}\n\n// The CVE drawer's unified row: both payloads reduced to the same columns so\n// one table renders either kind. `fixed` is the remedy column (fix-boundary\n// version / servicing update), `detail` the trailing context (description /\n// release date).\ninterface CveDrawerRow {\n cveId: string;\n cvss: number;\n kev: boolean;\n ransomware: boolean;\n fixed: string;\n detail: string;\n /** Linux rows only: the CVE came from a module erratum that rebuilt\n * several packages, so it cannot be pinned to this one \u2014 the drawer\n * labels it rather than presenting it as this package's own. */\n advisoryWide?: boolean;\n /** Vendor rows: which vendors reported this CVE (chips in the detail cell). */\n sources?: string[];\n /** A feed reported this CVE before NVD lists it: the early badge. */\n early?: boolean;\n}\n\n// What the CVE drawer was opened from \u2014 the source row decides the fetch,\n// the header, and the product/version context a virtual-patch request\n// carries.\ntype CveDrawerSource =\n | { kind: 'app'; app: ExposureRow }\n | { kind: 'os'; os: OsGapRow }\n | { kind: 'linux'; pkg: LinuxPkgRow }\n | { kind: 'vendor'; vendor: VendorRow };\n\n// cutVersion keeps a request's version within the server's 200 characters\n// (requestText), counted in code points, marking a cut with \"\u2026\".\nfunction cutVersion(v: string): string {\n const cps = Array.from(v);\n return cps.length <= 200 ? v : `${cps.slice(0, 199).join('')}\u2026`;\n}\n\nexport class MissingPatchesUI {\n // Same key shape as the Applications / Executables / Services pages, and\n // deliberately a separate key: the scope is a per-page filter there too.\n private static readonly SUBTENANT_FILTER_KEY = 'missingPatchesSubtenantFilter' as const;\n\n private header = new Header('missing-patches');\n private appsTable: DataTables.Api | null = null;\n private osTable: DataTables.Api | null = null;\n private linuxTable: DataTables.Api | null = null;\n private vendorTable: DataTables.Api | null = null;\n private tenantId: string | null = null;\n private charts: any[] = [];\n private toggleWired = false;\n private lastRender: (() => void) | null = null;\n // Last-loaded payloads, kept for the export path so the report is built\n // from the same rows the page renders \u2014 never scraped off the DOM.\n private lastBody: ExposureResponse | null = null;\n private lastItems: ExposureRow[] = [];\n private lastOsRows: OsGapRow[] = [];\n private lastLinuxRows: LinuxPkgRow[] = [];\n private lastVendorRows: VendorRow[] = [];\n // The exposure query the tables were loaded with (kev_only, source,\n // scope) and the hostnames they were labelled with, so the CSV exports\n // ask the server for exactly those rows, uncapped.\n private lastExposureQuery = '';\n private lastHostnames = new Map();\n private lastNodeVersions = new Map();\n /** Source ids present in the scope (server-computed, pre-filter) \u2014 what\n * the Source filter offers. */\n private sourcesPresent: string[] = [];\n private rosterTotal = 0;\n // CVE id \u2192 virtual-patch catalog entry, or null when this environment's\n // management plane doesn't serve the virtual-patch APIs \u2014 the column\n // hides entirely and the page renders as it did before they existed.\n // It is read again when the virtual patch workflow modal closes.\n private vpByCve: Map | null = null;\n /** Path to Green v1 payload from the last exposure fetch (PR 8). */\n private hardening: HardeningPayload | null = null;\n // LLM upgrade analyses (ENG-3837), keyed by BOTH the server's product_key\n // and the lowercased product name so table rows resolve their product\n // without re-deriving the server's key normalization.\n private analyses = new Map();\n // The last load's analyses read failed: the panel says so rather than\n // \"No analyses yet\", which would present existing analyses as never run.\n private analysesFailed = false;\n private analysisDrawer: Drawer | null = null;\n private analysisPollTimer: number | null = null;\n // Bumped by every analysis open and by closing the drawer: a request or\n // poll begun for an earlier open drops its answer.\n private analysisSeq = 0;\n\n // \u2500\u2500 Sub-tenant scope (ENG-3971) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n // Everything on the detail view \u2014 exposure rows, the OS-gap roster, the\n // tile denominators, the Path to Green projection \u2014 describes the same\n // node set: the sub-tenant the switcher selects, or every sub-tenant the\n // caller may see when the toggle is on. The management plane resolves\n // that set from the caller's own token, so the toggle widens reach only\n // as far as their entitlement.\n private showAllSubtenants = !!storage.get(MissingPatchesUI.SUBTENANT_FILTER_KEY);\n private hasSubtenants = false;\n private subtenantListenerWired = false;\n /** Bumped by clearSubtenantToggle: a toggle render whose listing lands\n * after a newer tenant, view or drawer close installs nothing. */\n private subtenantToggleGen = 0;\n\n // Scope state (Tenant Profiling pattern): true \u2192 the fleet view rendered\n // by the embedded CveFleetUI, false \u2192 the selected tenant's detail view.\n private allTenants = false;\n // Whether the profile is entitled to more than one tenant. With a single\n // tenant both scopes describe the same estate, so the toggle doesn't\n // mount and the page pins to the detail view.\n private multiTenant = true;\n private scopeToggle?: TenantScopeToggle;\n private readonly fleet: CveFleetUI;\n\n // The tenant detail markup (#mpTenantView) is ONE DOM subtree that lives\n // in one of three homes: the page (the toggle-off view), the tenant\n // drawer over the fleet view (row drill-through), or inline inside the\n // fleet view where the one-row breakdown table would be (single entitled\n // tenant). Moving the subtree \u2014 rather than duplicating the two tables,\n // their filters, and the virtual-patch flow per host \u2014 keeps one source\n // of truth; DataTables and the delegated handlers survive the move.\n private detailHome: 'page' | 'drawer' | 'inline' = 'page';\n private drawer: Drawer | null = null;\n // The per-row CVE drawer: every stored CVE behind a table row, with its\n // own search + filters and per-CVE virtual-patch requests. Stacks above\n // the tenant drawer when the detail view lives there.\n private cveDrawer: Drawer | null = null;\n /** Bumped on every CVE drawer open and close; a load checks it after each await. */\n private cveDrawerGen = 0;\n private cveTable: DataTables.Api | null = null;\n\n constructor() {\n this.updateUI = this.updateUI.bind(this);\n // The Node version columns sort as 'semver'; registered here so a\n // direct load sorts them without another page having done it.\n enableSemanticVersioningSorting();\n this.fleet = new CveFleetUI({\n onDrillToTenant: (row) => void this.openTenantDrawer(row.tenantId, row.tenantName),\n onTenantScope: (single) => this.inlineSingleTenant(single),\n });\n }\n\n public async updateUI(): Promise {\n this.header.set({\n title: t('nav.missingPatches'),\n icon: { fontAwesome: 'fa-bandage' },\n });\n this.header.enableMessageBar();\n\n // Scope resolution: explicit URL intent wins \u2014 ?allTenants=1 \u2192 fleet\n // view (cve_fleet_stats deeplinks, the legacy ?page=cve-fleet alias),\n // ?tenantId=X \u2192 X's detail view (cve_exposure deeplinks, drill-through\n // reloads) \u2014 then the persisted persona default. With no selected\n // tenant at all only the fleet view can render.\n const params = new URLSearchParams(window.location.search);\n const urlTenant = (params.get('tenantId') || '').trim();\n if (urlTenant) storage.set('selectedTenantId', urlTenant);\n this.tenantId = storage.get('selectedTenantId');\n if (params.get('allTenants') === '1') this.allTenants = true;\n else if (urlTenant) this.allTenants = false;\n else this.allTenants = allTenantsScopeDefault() || !this.tenantId;\n\n // Single entitled tenant \u2192 no All Tenants toggle, detail view only:\n // both scopes would describe the same estate (the fleet view's\n // one-row table inlines the detail anyway). URL intent (?allTenants=1\n // and the legacy cve-fleet alias) is deliberately overridden \u2014 with\n // the toggle hidden there would be no way back out of the fleet\n // shell. Same tenant-count source as the top-nav tenant switcher.\n const entitled = StoreManager.getInstance().userProfileStore.user?.tenants ?? [];\n this.multiTenant = entitled.length > 1;\n if (!this.multiTenant) {\n this.allTenants = false;\n if (!this.tenantId) {\n this.tenantId = entitled[0]?.id\n ?? StoreManager.getInstance().userProfileStore.user?.tenantID ?? null;\n if (this.tenantId) storage.set('selectedTenantId', this.tenantId);\n }\n }\n\n // This UI is a singleton: re-read the sub-tenant toggle's key on every\n // visit, so a scope seeded since construction (an evidence link) is\n // the one the next load sends.\n this.showAllSubtenants = !!storage.get(MissingPatchesUI.SUBTENANT_FILTER_KEY);\n this.toggleWired = false;\n this.mountScopeToggle();\n await this.showView();\n }\n\n private mountScopeToggle(): void {\n // Re-mounted on every visit, not memoised: this UI is a singleton but\n // the page's markup is re-injected on each navigation, so a cached\n // toggle would point at a detached host and silently disappear.\n const host = document.getElementById('mpScope');\n if (!host) return;\n host.textContent = '';\n // The archived-nodes scope is a user setting (ENG-4129); the note\n // states which way it is set and points at Profile, stacked under\n // the tenant toggle exactly as Tenant Profiling lays it out, because\n // this page now counts the same nodes the Nodes page shows.\n const note = document.createElement('span');\n note.id = 'mpArchivedScopeNote';\n note.className = 'mp-archived-note';\n note.innerHTML = `${this.esc(archivedNodesVisible()\n ? t2('body.archivedNodesShown') : t2('body.archivedNodesHidden'))}${archivedNodesTipHTML()}`;\n if (!this.multiTenant) { // single entitled tenant: no toggle, the note alone\n host.appendChild(note);\n return;\n }\n const applyScope = (allTenants: boolean): void => {\n this.allTenants = allTenants;\n if (!allTenants && !this.tenantId) {\n this.tenantId = storage.get('selectedTenantId')\n || StoreManager.getInstance().userProfileStore.user?.tenantID || null;\n if (this.tenantId) storage.set('selectedTenantId', this.tenantId);\n }\n // The scope rides the URL so the view is shareable and a\n // reload reproduces it.\n const u = new URLSearchParams(window.location.search);\n if (allTenants) {\n u.set('allTenants', '1');\n u.delete('tenantId');\n } else {\n u.delete('allTenants');\n if (this.tenantId) u.set('tenantId', this.tenantId);\n }\n history.replaceState(history.state, '', `${window.location.pathname}?${u}`);\n void this.showView();\n };\n this.scopeToggle = new TenantScopeToggle({\n allTenants: this.allTenants,\n id: 'mpTenantScope',\n onChange: (allTenants) => {\n persistShowAllTenants(allTenants, (stored) => {\n this.scopeToggle?.setChecked(stored);\n applyScope(stored);\n this.header.showMessage(t2('body.saveFailedDot'), 'error', 8);\n });\n applyScope(allTenants);\n },\n });\n this.scopeToggle.mount(host);\n host.appendChild(note);\n }\n\n /** Reveal the view the current scope calls for and render it. */\n private async showView(): Promise {\n const fleetEl = document.getElementById('mpFleetView');\n const tenantEl = document.getElementById('mpTenantView');\n if (fleetEl) fleetEl.hidden = !this.allTenants;\n\n if (this.allTenants) {\n if (this.detailHome === 'page' && tenantEl) tenantEl.hidden = true;\n // An open drawer's tenant keeps its toggle and its start; closing\n // the drawer clears them.\n if (this.detailHome !== 'drawer') this.clearSubtenantToggle();\n await this.fleet.updateUI();\n // A reload with ?openTenant=X re-opens the drawer the link was\n // shared from (Tenant Profiling contract).\n const openTenant = new URLSearchParams(window.location.search).get('openTenant');\n if (openTenant && this.detailHome !== 'drawer') {\n await this.openTenantDrawer(openTenant,\n this.fleet.tenantRow(openTenant)?.tenantName ?? openTenant);\n }\n return;\n }\n\n // Tenant detail view: reclaim the detail subtree from wherever the\n // fleet view left it, then render into the page slot.\n if (this.detailHome === 'drawer') this.drawer?.close(); // close() restores\n else if (this.detailHome === 'inline') this.restoreTenantView();\n if (tenantEl) tenantEl.hidden = false;\n // The meta line belongs to the fleet table.\n const meta = document.getElementById('cveFleetMeta');\n if (meta) meta.textContent = '';\n this.placeExportControl();\n await this.startTenantDetail();\n }\n\n /** Wire the KEV-only toggle and load the detail tables for the selected\n * tenant \u2014 shared by all three homes of the detail subtree. */\n private async startTenantDetail(): Promise {\n this.tenantId = storage.get('selectedTenantId');\n if (!this.tenantId) return;\n // Whatever the subtree last drew is another visit's, perhaps another\n // tenant's (the drawer reuses it): held until this tenant's rows land.\n // An earlier load still in flight is dropped here, not at the load()\n // below: it would land during the sub-tenant await and reopen the gate.\n this.gate.restart();\n this.loadGen++;\n const spin = this.header.showLoading();\n try {\n // A superseded start stops here: the newer one owns the load, and\n // this one would send it without that tenant's sub-tenant scope.\n if (!await this.renderSubtenantToggle()) return;\n const kevOnly = new URLSearchParams(window.location.search).get('kevOnly') === 'true';\n const toggle = document.getElementById('mpKevOnly') as HTMLInputElement | null;\n if (toggle) {\n toggle.checked = kevOnly;\n toggle.onchange = () => {\n // The filter rides the URL so the view is shareable and a\n // reload reproduces it \u2014 same contract as the KEV page.\n const u = new URLSearchParams(window.location.search);\n if (toggle.checked) u.set('kevOnly', 'true'); else u.delete('kevOnly');\n history.replaceState(history.state, '', `${window.location.pathname}?${u}`);\n void this.load(toggle.checked);\n };\n }\n await this.load(kevOnly);\n } finally {\n this.header.hideLoading(spin);\n }\n }\n\n // \u2500\u2500 Sub-tenant scope \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n /**\n * Mount the Include-all-sub-tenants toggle, only when the caller sees 2+\n * of the tenant's sub-tenants or it is on (includeAllShown): with fewer,\n * \"all\" offers no choice the sidebar doesn't. Any earlier tenant's\n * toggle is dropped first: the drawer changes tenant without clearing\n * the header. Mirrors ApplicationsUI so the two pages behave identically.\n * Returns false when a later tenant, view or drawer close superseded it.\n */\n private async renderSubtenantToggle(): Promise {\n this.hasSubtenants = false;\n this.clearSubtenantToggle();\n // clearSubtenantToggle took a new generation: a later tenant, view or\n // drawer close cancels this render while its listing is in flight.\n const gen = this.subtenantToggleGen;\n const tenantId = this.tenantId;\n if (!tenantId) return false;\n const current = () => gen === this.subtenantToggleGen && tenantId === this.tenantId;\n\n const store = StoreManager.getInstance().subtenantStore;\n let subtenants = store.getCached(tenantId);\n if (!subtenants) {\n try {\n subtenants = (await store.list(tenantId)).items;\n } catch { return current(); }\n if (!current()) return false;\n }\n if (!subtenants?.length) return true;\n this.hasSubtenants = true;\n\n // The checkbox only with 2+ sub-tenants, or while it is on; the\n // sidebar listener below is wired either way.\n if (includeAllShown(subtenants, !!storage.get(MissingPatchesUI.SUBTENANT_FILTER_KEY))) {\n this.header.options({\n checkboxes: [\n {\n id: 'mpAllSubtenantsToggle',\n label: t2('body.includeAllSubTenants'),\n localStorageName: MissingPatchesUI.SUBTENANT_FILTER_KEY,\n callback: () => {\n if (!includeAllShown(subtenants, !!storage.get(MissingPatchesUI.SUBTENANT_FILTER_KEY))) dropIncludeAllCheckbox('mpAllSubtenantsToggle');\n this.showAllSubtenants = !!storage.get(MissingPatchesUI.SUBTENANT_FILTER_KEY);\n void this.load(this.kevOnlyFromURL());\n },\n },\n ],\n });\n }\n\n if (!this.subtenantListenerWired) {\n window.addEventListener('subtenantChanged', this.onSubtenantChanged as EventListener);\n this.subtenantListenerWired = true;\n }\n return true;\n }\n\n private onSubtenantChanged = (ev: Event) => {\n // Only a tenant's view tracks the switcher (the page view, or the\n // fleet view's drawer or inline detail, not the fleet list itself);\n // with the toggle on it already shows every sub-tenant the caller may\n // see. With the toggle hidden (one sub-tenant) this is the only way\n // to change the detail's scope.\n if (!this.tenantId || (this.allTenants && this.detailHome === 'page') || this.showAllSubtenants) return;\n // The switcher names its tenant: a change for another tenant than\n // the one in view leaves this view's scope as it was.\n const changed = (ev as CustomEvent<{ tenantId?: string }>).detail?.tenantId;\n if (changed && changed !== this.tenantId) return;\n if (!document.getElementById('mpTenantView')) return;\n void this.load(this.kevOnlyFromURL());\n };\n\n /** Drop the toggle when the fleet view takes over \u2014 its rows are whole\n * tenants, so a sub-tenant scope has nothing to act on there. */\n private clearSubtenantToggle(): void {\n this.subtenantToggleGen++;\n document.getElementById('mpAllSubtenantsToggle')?.closest('label')?.remove();\n document.getElementById('mpAllSubtenantsToggle')?.remove();\n }\n\n private kevOnlyFromURL(): boolean {\n return new URLSearchParams(window.location.search).get('kevOnly') === 'true';\n }\n\n /** The Source filter (ENG-4292) rides the URL as ?source=a,b \u2014 empty\n * means every source, exactly like the server's contract. */\n private sourcesFromURL(): string[] {\n const raw = new URLSearchParams(window.location.search).get('source') || '';\n return sortSources(raw.split(',').map(s => s.trim()).filter(Boolean));\n }\n\n private setSourcesInURL(ids: string[]): void {\n const u = new URLSearchParams(window.location.search);\n if (ids.length) u.set('source', ids.join(',')); else u.delete('source');\n history.replaceState(history.state, '', `${window.location.pathname}?${u}`);\n }\n\n /** One toggle chip per source present in the scope; multi-select. The\n * control only appears once there is a choice to make (two or more\n * sources), and a selection is written to the URL and reloads the\n * page's rows from the server so every lane, rollup and drawer agrees. */\n private renderSourceFilter(): void {\n const host = document.getElementById('mpSourceFilter');\n if (!host) return;\n const present = sortSources(this.sourcesPresent);\n const selected = new Set(this.sourcesFromURL());\n // A selected id that is no longer present (a stale link) still\n // renders, so the reader can see why the page is empty and clear it.\n const ids = sortSources([...present, ...selected]);\n if (ids.length < 2 && !selected.size) { host.hidden = true; host.innerHTML = ''; return; }\n host.hidden = false;\n host.innerHTML = `${this.esc(t2('vulnSources.filter.label'))}`\n + ids.map(id => ``).join('')\n + (selected.size ? `` : '');\n host.querySelectorAll('.vs-filter-chip').forEach(btn => {\n btn.onclick = () => {\n const id = btn.dataset.source || '';\n const next = new Set(this.sourcesFromURL());\n if (next.has(id)) next.delete(id); else next.add(id);\n this.setSourcesInURL(sortSources([...next]));\n void this.load(this.kevOnlyFromURL());\n };\n });\n const clear = host.querySelector('.vs-filter-clear');\n if (clear) {\n clear.onclick = () => {\n this.setSourcesInURL([]);\n void this.load(this.kevOnlyFromURL());\n };\n }\n }\n\n /** ColumnControl's searchList matches the whole cell, so a Source cell\n * listing two vendors would only match the reader picking that exact\n * pair. Re-install an any-of predicate on preDraw (ColumnControl\n * rewrites its own on every selection change) \u2014 the KEV page does the\n * same for its controls column. */\n private wireSourceAnyOf(tableId: string, table: DataTables.Api, col: number): void {\n $(`#${tableId}`).off('preDraw.dt.vsany').on('preDraw.dt.vsany', () => {\n try {\n const st: any = (table as any).state();\n const sel: string[] = st?.columnControl?.[col]?.searchList ?? [];\n if (!Array.isArray(sel) || sel.length === 0) return;\n (table.column(col) as any).search.fixed('dtcc-list', anyOfSourcePredicate(sel));\n } catch { /* leave ColumnControl's own predicate in place */ }\n });\n }\n\n /**\n * The sub-tenant scope as query params, in the shape every scoped\n * endpoint reads (`resolveSubtenantFilter`). Empty when the tenant has no\n * sub-tenants, which the server reads as the whole tenant.\n */\n private scopeParams(): string {\n if (!this.hasSubtenants) return '';\n if (this.showAllSubtenants) return '&allSubtenants=true';\n const subId = StoreManager.getInstance().subtenantStore.getSelectedId(this.tenantId!);\n return subId ? `&subtenantID=${encodeURIComponent(subId)}` : '';\n }\n\n /**\n * The query string for a drawer fetch that names a single node.\n *\n * `vendor-finding-cves` clamps the node id to the caller's scope so a\n * hand-built URL cannot read a sibling sub-tenant's findings. That clamp\n * reads the scope from the request, so a call that omits scopeParams()\n * is judged against the root tenant alone \u2014 and a row the table only\n * showed because \"Include all sub-tenants\" is on gets clamped out. The\n * handler then answers 200 with an empty list, which the drawer renders\n * as \"No stored CVE detail for this row\": a scope miss and a genuinely\n * empty row look identical (ENG-4391). Every node-scoped drawer fetch\n * must carry the same scope the table was drawn with.\n */\n private nodeScopedQuery(params: Record): string {\n const q = Object.entries(params)\n .map(([k, v]) => `${k}=${encodeURIComponent(v)}`)\n .join('&');\n return `?${q}${this.scopeParams()}`;\n }\n\n /** The fleet scope matching scopeParams(), for the roster denominator. */\n private fleetScope(): { allSubtenants?: boolean } {\n return { allSubtenants: this.hasSubtenants && this.showAllSubtenants };\n }\n\n // \u2500\u2500 Detail-subtree homes (drawer / inline / page) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n private restoreTenantView(): void {\n const view = document.getElementById('mpTenantView');\n const wrap = document.querySelector('.mp-wrap');\n if (view && wrap) {\n wrap.appendChild(view); // page slot is last in .mp-wrap\n view.hidden = this.allTenants;\n }\n this.detailHome = 'page';\n }\n\n /** Tenant-row drill-through from the fleet view \u2014 the Tenant Profiling\n * row-click contract: adopt the tenant globally so every tenant-scoped\n * page follows and the sidebar chip re-renders, then open the tenant's\n * detail tables in a drawer over the fleet view, with ?openTenant on\n * the URL so a reload reproduces it. Deliberately NOT persisted via\n * setShowAllTenants: a drill-in is navigation, not a preference change. */\n private async openTenantDrawer(tenantId: string, tenantName: string): Promise {\n storage.set('selectedTenantId', tenantId);\n this.tenantId = tenantId;\n window.dispatchEvent(new Event('tenantSwitched'));\n\n if (!this.drawer) {\n this.drawer = new Drawer({\n id: 'missing-patches-drawer',\n classPrefix: 'missing-patches-drawer',\n ariaLabel: t('nav.missingPatches'),\n width: 'min(1600px, 95vw)',\n });\n this.drawer.onClose(() => {\n this.restoreTenantView();\n // The fleet view takes over: no tenant's sub-tenant toggle.\n this.clearSubtenantToggle();\n // Clear ?openTenant when the drawer is closed (Esc / backdrop\n // / X), so a refresh doesn't reopen a stale drawer.\n const url = new URL(window.location.href);\n if (url.searchParams.has('openTenant')) {\n url.searchParams.delete('openTenant');\n window.history.replaceState({}, '', url.toString());\n }\n });\n }\n\n // Header: tenant name (honoring the Show Tenant Names masking) + id.\n ensureTenantMaskedChipStyles();\n const nameHTML = tenantNameRevealed(tenantId)\n ? this.esc(tenantName) : displayTenantNameHTML(tenantName, tenantId);\n this.drawer.headerEl.innerHTML = `\n
\n
${nameHTML}
\n
\n ${this.esc(tenantId)}\n
\n
`;\n const idRow = this.drawer.headerEl.querySelector('#mpDrawerIdRow') as HTMLElement | null;\n if (idRow) addCopyButton(idRow, tenantId, t2('col.tenantId'), 'tenant_id');\n\n const view = document.getElementById('mpTenantView');\n if (view) {\n this.drawer.bodyEl.textContent = '';\n this.drawer.bodyEl.appendChild(view);\n view.hidden = false;\n this.detailHome = 'drawer';\n }\n this.drawer.open();\n\n const u = new URLSearchParams(window.location.search);\n u.set('openTenant', tenantId);\n // Keep tenantId in step: the drawer adopted this tenant globally, and\n // a URL naming one tenant in tenantId and another in openTenant would\n // reproduce a different state than it shows.\n u.set('tenantId', tenantId);\n history.replaceState(history.state, '', `${window.location.pathname}?${u}`);\n await this.startTenantDetail();\n }\n\n /** Single entitled tenant: the fleet view's one-row breakdown table is\n * replaced by that tenant's detail tables, inlined where the table card\n * sits \u2014 same click-saving move as Tenant Profiling. */\n private inlineSingleTenant(single: TenantRow | null): void {\n if (!single) {\n if (this.detailHome === 'inline') this.restoreTenantView();\n return;\n }\n if (this.detailHome === 'drawer') return; // drawer wins; restored on close\n storage.set('selectedTenantId', single.tenantId);\n this.tenantId = single.tenantId;\n const card = document.getElementById('cveFleetTenantsCard');\n const view = document.getElementById('mpTenantView');\n if (!card?.parentElement || !view) return;\n card.parentElement.insertBefore(view, card);\n view.hidden = false;\n this.detailHome = 'inline';\n void this.startTenantDetail();\n }\n\n /** Newest-load-wins: a toggle, a source chip or the sub-tenant switcher\n * can start a load while one is in flight; only the newest renders. */\n private loadGen = 0;\n // The tenant detail's body, and its Export when the page slot shows it\n // (the drawer and the inline view sit under the fleet's Export).\n private readonly gate = new PageLoadGate('mpTenantBody', () => t('common.loading'),\n () => [document.getElementById('missingPatchesExportBtn')?.parentElement]);\n\n private async load(kevOnly: boolean): Promise {\n const gen = ++this.loadGen;\n const spin = this.header.showLoading();\n this.gate.begin();\n try {\n const nodeStore = StoreManager.getInstance().nodeStore;\n // One scope, read once, for both halves: the exposure rows and the\n // roster they are counted against. Reading it twice would let the\n // toggle land between them and produce a numerator and denominator\n // from different node sets \u2014 which is exactly the \"14 / 12\" this\n // page used to show.\n const scope = this.scopeParams();\n // The Source filter narrows every lane server-side (items, Linux\n // packages, vendor findings, and the priority fixes' OS entry);\n // the OS-gap rows come from the per-node call below and are\n // narrowed here by lane.\n const sourceSel = this.sourcesFromURL();\n const sourceQ = sourceSel.length ? `&source=${encodeURIComponent(sourceSel.join(','))}` : '';\n const exposureQuery = `${kevOnly ? '&kev_only=true' : ''}${sourceQ}${scope}`;\n const [resp, visibleFleet, vpByCve, analyses] = await Promise.all([\n apiFetch(`/api/tenants/${this.tenantId}/cve-exposure?${exposureQuery.replace(/^&/, '')}`),\n nodeStore.loadVisibleFleet(this.tenantId!, this.fleetScope()),\n this.fetchVirtualPatches(),\n this.fetchPatchAnalyses(),\n ]);\n if (gen !== this.loadGen) return;\n this.vpByCve = vpByCve;\n // Assigned only here, past the gen check: a slower read for an\n // earlier tenant or scope never fills this load's map.\n this.analyses = analyses ?? new Map();\n this.analysesFailed = analyses === null;\n // The visible fleet already applies the archived-nodes setting;\n // the server scoped the rows and the roster by the same setting,\n // so every count on this page matches the Nodes page.\n const nodes = visibleFleet;\n if (!resp.ok) throw new Error(`${resp.status}`);\n const body = (await resp.json()) as ExposureResponse;\n if (gen !== this.loadGen) return;\n this.hardening = body.hardening ?? null;\n\n // The server's roster first (it can carry live nodes the Nodes\n // store does not), the client's on top for any it names better.\n const hostnames = new Map(Object.entries(body.hostnames ?? {}));\n for (const n of nodes) hostnames.set(n.id, n.hostname || hostnames.get(n.id) || n.id);\n // Each node's Mimic node build, for the Node version column: the\n // server roster's, with the Nodes store's on top where it has one.\n const nodeVersions = new Map(Object.entries(body.node_versions ?? {}));\n for (const n of nodes) if (n.nodeVersion) nodeVersions.set(n.id, n.nodeVersion);\n // The server counted the same scope from the live-node roster, so\n // prefer its number: the client-side fleet can be narrower for\n // reasons that have nothing to do with sub-tenants (the Nodes\n // page's telemetry-visibility rule), and a denominator smaller\n // than the numerator is the bug this page was reported for.\n this.rosterTotal = Math.max(nodes.length, body.nodes_in_scope ?? 0);\n const items = (body.items ?? []).map(r => ({\n ...r,\n hostname: r.hostname || hostnames.get(r.node_id) || r.node_id,\n node_version: nodeVersions.get(r.node_id) ?? '',\n inRoster: hostnames.has(r.node_id),\n }));\n // The Linux half rides the same response \u2014 the server selected\n // the source per node (CPE tables for Windows, distro advisories\n // for Linux), so this list is already only Linux-node rows.\n const linuxRows: LinuxPkgRow[] = (body.linux_packages ?? []).map(r => ({\n ...r,\n hostname: r.hostname || hostnames.get(r.node_id) || r.node_id,\n node_version: nodeVersions.get(r.node_id) ?? '',\n }));\n // The vendor lane (ENG-4292). A matched row takes the roster's\n // hostname like every other lane; an unmatched one keeps what\n // the vendor's file said, and the badge says why.\n const vendorRows: VendorRow[] = (body.vendor_findings ?? []).map(r => ({\n ...r,\n sources: r.sources ?? [],\n top_cves: r.top_cves ?? [],\n hostname: r.node_id\n ? (r.hostname || hostnames.get(r.node_id) || r.node_id)\n : (r.asset_fqdn || r.asset_hostname || r.asset_key),\n node_version: r.node_id ? nodeVersions.get(r.node_id) ?? '' : '',\n }));\n this.sourcesPresent = body.sources ?? [];\n\n // OS gaps come from the per-node summaries endpoint over the whole\n // visible roster \u2014 nodes with an OS gap but no app exposure would\n // otherwise be invisible. Only a whole answer shows plain values\n // (utils/OsOverlay): a partial or failed fallback read names the\n // Windows update gap in the page notice, so an empty OS table\n // never reads as \"fully patched\".\n let osRows: OsGapRow[] = [];\n // The exposure response carries the OS lane over the server's\n // own roster (its degraded list already names a partial lane);\n // the per-node call is only the fallback for a server that\n // predates it.\n const os: OsOverlayAnswer = body.os_exposure\n ? { exposure: body.os_exposure, state: 'whole' }\n : await readOsOverlay(() => postJson(\n // The server clamps the posted ids to the request's\n // scope: with none it is the root-only list, so a\n // sub-tenant's nodes would come back empty.\n `/api/tenants/${this.tenantId}/nodes/cve-exposure${scope.replace(/^&/, '?')}`,\n { nodeIds: nodes.map(n => n.id) },\n ));\n let osState: OsOverlayState = os.state;\n try {\n // A node with a Windows Update Agent verdict is a row when\n // the agent lists anything missing at all \u2014 MSRC may have no\n // CVE for a missing SQL Server or .NET update, and \"missing\"\n // is still the fact.\n osRows = Object.entries(os.exposure)\n .map(([nodeId, s]) => this.osRowFromSummary(nodeId, hostnames.get(nodeId) || nodeId, s, sourceSel))\n .filter((r): r is OsGapRow => r !== null)\n .map(r => ({ ...r, nodeVersion: nodeVersions.get(r.nodeId) ?? '' }));\n } catch (err) {\n console.warn('[MissingPatches] OS gap rows failed:', err);\n osRows = [];\n osState = 'failed';\n }\n if (osOverlayQualified(osState) && !(body.degraded ?? []).includes('os_estimates')) {\n body.degraded = [...(body.degraded ?? []), 'os_estimates'];\n }\n if (gen !== this.loadGen) return;\n\n this.lastBody = body;\n this.lastExposureQuery = exposureQuery;\n this.lastHostnames = hostnames;\n this.lastNodeVersions = nodeVersions;\n this.lastItems = items;\n this.lastOsRows = osRows;\n this.lastLinuxRows = linuxRows;\n this.lastVendorRows = vendorRows;\n this.gate.open();\n this.renderSourceFilter();\n this.renderPartialNotice(body);\n this.renderSummary(body, items, osRows, linuxRows, vendorRows);\n this.renderAppsTable(items);\n this.renderOsTable(osRows);\n this.renderLinuxTable(linuxRows);\n this.renderVendorTable(vendorRows);\n const attr = document.getElementById('mpAttribution');\n if (attr) attr.textContent = body.attribution ?? '';\n } catch (err: any) {\n if (gen !== this.loadGen) return;\n // Drop the previous load's rows: they belong to whichever tenant\n // or scope loaded last, and Export would brand them as this one.\n this.lastBody = null;\n this.lastExposureQuery = '';\n this.lastHostnames = new Map();\n this.lastNodeVersions = new Map();\n this.lastItems = [];\n this.lastOsRows = [];\n this.lastLinuxRows = [];\n this.lastVendorRows = [];\n this.lastRender = null;\n const summary = document.getElementById('mpSummary');\n if (summary) summary.hidden = true;\n this.renderPartialNotice(null);\n if (this.gate.fail(t2('missingPatches.loadFailed', { error: err?.message ?? '' }))) return;\n this.renderAppsTable([]);\n this.renderOsTable([]);\n this.renderLinuxTable([]);\n this.renderVendorTable([]);\n this.header.showMessage(t2('missingPatches.loadFailed', { error: escapeHtml(err?.message ?? '') }), 'error', 12);\n } finally {\n this.header.hideLoading(spin);\n }\n }\n\n /** States, above the tables, that a lane failed \u2014 otherwise the page\n * would show a failed lane as complete. Hidden for a complete response\n * (or none). Text only. */\n private renderPartialNotice(body: ExposureResponse | null): void {\n const el = document.getElementById('mpPartialNotice');\n if (!el) return;\n const lines = partialNoticeLines(body);\n el.textContent = lines.join(' ');\n el.hidden = lines.length === 0;\n }\n\n // \u2500\u2500 Virtual patches \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n // Best-effort: the virtual-patch catalog exists only where the\n // management plane serves the vulnerability APIs (and our M2M client\n // holds the scopes), so the server answers available:false there and any\n // failure here degrades the same way \u2014 no column, no error.\n private async fetchVirtualPatches(): Promise | null> {\n try {\n const r = await apiFetch(`/api/tenants/${this.tenantId}/virtual-patches`);\n if (!r.ok) return null;\n const body = (await r.json()) as VirtualPatchResponse;\n if (!body.available) return null;\n // One entry per CVE. A CVE can be listed twice (an official row and\n // the tenant's own): the patch counts add up, and the entry is\n // official only when every row is \u2014 a tenant row is the one the\n // workflow uses (listedVulnerability on the server).\n const m = new Map();\n for (const it of body.items ?? []) {\n const key = it.cve.toUpperCase();\n const prev = m.get(key);\n m.set(key, prev ? {\n ...prev,\n // The tenant row's id, whichever row came first.\n vulnerabilityId: prev.official && !it.official ? it.vulnerabilityId : prev.vulnerabilityId,\n official: prev.official && it.official,\n officialPatches: prev.officialPatches + it.officialPatches,\n customPatches: prev.customPatches + it.customPatches,\n } : it);\n }\n return m;\n } catch (err) {\n console.warn('[MissingPatches] virtual patches fetch failed:', err);\n return null;\n }\n }\n\n // Join the row's top CVEs against the catalog: an entry with patches is\n // available; an entry with none is an open request; the rest have none.\n // startable lists the CVEs a workflow can start (or resume) for: any but\n // an official-only entry, whose patches are deployed from the dashboard\n // (the server's request step refuses it).\n private vpState(row: ExposureRow): { avail: string[]; requested: string[]; requestable: string[]; startable: string[] } {\n const avail: string[] = []; const requested: string[] = []; const requestable: string[] = []; const startable: string[] = [];\n if (!this.vpByCve) return { avail, requested, requestable, startable };\n for (const id of row.top_cves ?? []) {\n const key = id.toUpperCase();\n const entry = this.vpByCve.get(key);\n if (entry && entry.officialPatches + entry.customPatches > 0) avail.push(id);\n else if (entry) requested.push(id);\n else requestable.push(id);\n if (this.vpStartable(entry)) startable.push(id);\n }\n return { avail, requested, requestable, startable };\n }\n\n // A workflow can start, or resume, for a CVE the catalog doesn't list or\n // lists as the tenant's own: an open workflow stays reachable after its\n // patch is published (the CVE shows Available from then on).\n private vpStartable(entry: VirtualPatchItem | undefined): boolean {\n return !entry || !entry.official;\n }\n\n // The workflow modal for one CVE on one node. The tenant is captured at\n // the click: the page can move to another tenant while the modal is open,\n // and the catalog is read again (and the tables redrawn) only while the\n // page still shows the tenant the workflow ran in.\n private openVpWorkflow(btn: HTMLButtonElement): void {\n const tenantId = this.tenantId;\n const cve = btn.dataset.cve || '';\n const nodeId = btn.dataset.node || '';\n const product = btn.dataset.product || '';\n if (!tenantId || !cve || !nodeId || !product) return;\n const vulnerabilityId = this.vpByCve?.get(cve.toUpperCase())?.vulnerabilityId;\n openVirtualPatchWorkflow({ tenantId, cve, nodeId, product, version: btn.dataset.version || '', hostname: btn.dataset.host || nodeId, vulnerabilityId }, () => {\n void (async () => {\n const vpByCve = await this.fetchVirtualPatches();\n if (this.tenantId !== tenantId) return;\n // A failed read keeps what the page shows: null would read\n // as \"no virtual patches\" in a column that stays visible.\n if (!vpByCve) {\n this.header.showMessage(t2('missingPatches.vpw.catalogRefreshFailed'), 'info', 6);\n return;\n }\n this.vpByCve = vpByCve;\n this.appsTable?.rows().invalidate('data').draw(false);\n this.cveTable?.rows().invalidate('data').draw(false);\n })();\n });\n }\n\n // \u2500\u2500 LLM upgrade analyses (ENG-3837) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n // The current tenant and scope's analyses, as a new map, or null when\n // the read failed (unknown, not \"none\"). The caller assigns it only\n // while the page still shows the tenant and scope it was read for\n // (analysisScopeKey), so a late answer never fills another view's map.\n private async fetchPatchAnalyses(): Promise | null> {\n try {\n // Scoped like the tables: the analyses are stored per tenant, and\n // the server drops the ones whose product no node in scope runs.\n const scope = this.scopeParams();\n const r = await apiFetch(`/api/tenants/${this.tenantId}/patch-analyses`\n + (scope ? `?${scope.slice(1)}` : ''));\n if (!r.ok) return null;\n const body = (await r.json()) as { analyses?: PatchAnalysis[] };\n const m = new Map();\n for (const a of body.analyses ?? []) this.storeAnalysis(a, m);\n return m;\n } catch (err) {\n console.warn('[MissingPatches] patch analyses fetch failed:', err);\n return null;\n }\n }\n\n /** The tenant and scope the analyses describe: an answer read under\n * another key belongs to a view the page has left. */\n private analysisScopeKey(): string {\n return `${this.tenantId ?? ''}|${this.scopeParams()}`;\n }\n\n private storeAnalysis(a: PatchAnalysis, m: Map = this.analyses): void {\n m.set(a.productKey, a);\n m.set(a.productName.trim().toLowerCase(), a);\n }\n\n private analysisFor(productName: string): PatchAnalysis | undefined {\n return this.analyses.get(productName.trim().toLowerCase());\n }\n\n /** Distinct analyses (the map is double-keyed), completed first, in the\n * page's own priority order. */\n private analysisList(): PatchAnalysis[] {\n const seen = new Set();\n for (const a of this.analyses.values()) seen.add(a);\n return [...seen].sort((a, b) => {\n const done = Number(b.status === 'completed') - Number(a.status === 'completed');\n if (done) return done;\n const ra = a.snapshot?.priorityRank || Number.MAX_SAFE_INTEGER;\n const rb = b.snapshot?.priorityRank || Number.MAX_SAFE_INTEGER;\n return ra - rb || a.productName.localeCompare(b.productName);\n });\n }\n\n private renderUpgradeAnalyses(): void {\n const host = document.getElementById('mpUpgradeAnalyses');\n if (!host) return;\n const tip = t2('missingPatches.ana.tip');\n const title = `
${this.esc(t2('missingPatches.ana.title'))}`\n + `info_outline
`;\n const list = this.analysisList().filter(a => a.status === 'completed' && a.result);\n if (!list.length) {\n // A failed read is unknown, not \"No analyses yet\".\n const empty = this.analysesFailed\n ? t2('missingPatches.partial.degraded', { lanes: t2('missingPatches.ana.title') })\n : t2('missingPatches.ana.empty');\n host.innerHTML = `${title}
${this.esc(empty)}
`; // copilot-lint-ignore: title is markup built above from esc() and tipAttr()\n return;\n }\n const rows = list.slice(0, 5).map(a => {\n const rank = a.snapshot?.priorityRank\n ? `${a.snapshot.priorityRank}`\n : '';\n const when = (a.generatedAt ?? '').slice(0, 10);\n return ``;\n }).join('');\n host.innerHTML = title + `
${rows}
`;\n host.querySelectorAll('.mp-ana-row').forEach(btn => {\n btn.addEventListener('click', () => void this.openPatchAnalysis(btn.dataset.product || ''));\n });\n }\n\n private actionPill(action: string): string {\n const key = action === 'remove_stale' ? 'missingPatches.ana.action.remove'\n : action === 'upgrade_and_remove' ? 'missingPatches.ana.action.both'\n : 'missingPatches.ana.action.upgrade';\n const cls = action === 'upgrade' ? 'mp-pill-ana-upgrade' : 'mp-pill-ana-remove';\n return `${this.esc(t2(key))}`;\n }\n\n /** The apps-table Analysis cell: view when one exists, request otherwise. */\n private analysisCellState(row: ExposureRow): 'analyzed' | 'running' | 'failed' | 'none' {\n return this.analysisState(this.analysisFor(row.product_name ?? ''));\n }\n\n private analysisState(a: PatchAnalysis | undefined): 'analyzed' | 'running' | 'failed' | 'none' {\n if (!a) return 'none';\n if (a.status === 'completed' && a.result) return 'analyzed';\n if (a.status === 'running' || a.status === 'pending') return 'running';\n return a.status === 'failed' ? 'failed' : 'none';\n }\n\n // again: this open re-asks for an analysis a poll saw handed back without\n // one; its own poll does not ask a second time.\n private async openPatchAnalysis(product: string, force = false, again = false): Promise {\n if (!product || !this.tenantId) return;\n const drawer = this.ensureAnalysisDrawer();\n // This open supersedes any earlier one: its request or poll must not\n // retitle the drawer to its own product.\n const seq = ++this.analysisSeq;\n const key = this.analysisScopeKey();\n const tenantId = this.tenantId;\n const scope = this.scopeParams();\n const sameView = () => key === this.analysisScopeKey();\n const current = () => seq === this.analysisSeq && sameView();\n const existing = this.analysisFor(product);\n if (!force && existing?.status === 'completed' && existing.result) {\n this.renderAnalysisDrawer(existing);\n return;\n }\n this.setAnalysisDrawerContent(product, '',\n `
\n
${this.esc(t2('missingPatches.ana.generating'))}
`);\n drawer.open();\n try {\n // The page's scope rides the request: the server generates and\n // files the analysis under it, and the list read polls that key.\n const r = await apiFetch(`/api/tenants/${tenantId}/patch-analyses/request`\n + (scope ? `?${scope.slice(1)}` : ''), {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ product, force }),\n });\n if (!current()) return;\n if (r.status === 429) {\n this.setAnalysisDrawerContent(product, '',\n `
${this.esc(t2('missingPatches.ana.budget'))}
`);\n return;\n }\n if (!r.ok) throw new Error(String(r.status));\n const body = await r.json() as { status: string; analysis?: PatchAnalysis };\n if (!sameView()) return;\n if (body.status === 'fresh' && body.analysis) {\n this.storeAnalysis(body.analysis);\n // The cell, the panel and the export read the map: draw it.\n this.appsTable?.rows().invalidate('data').draw(false);\n this.renderUpgradeAnalyses();\n if (seq === this.analysisSeq) this.renderAnalysisDrawer(body.analysis);\n return;\n }\n // Started or already running: every row of the product reads\n // Running at once, so none offers Analyze again (each request\n // spends the tenant's daily budget). A completed analysis being\n // re-run keeps its view until the new one lands.\n const prev = this.analysisFor(product);\n if (this.analysisState(prev) !== 'analyzed') {\n this.storeAnalysis({\n productKey: prev?.productKey ?? product.trim().toLowerCase(),\n productName: prev?.productName ?? product,\n status: 'running',\n });\n this.appsTable?.rows().invalidate('data').draw(false);\n }\n if (seq !== this.analysisSeq) return;\n this.pollAnalysis(product, seq, again);\n } catch (err: any) {\n if (!current()) return;\n this.setAnalysisDrawerContent(product, '',\n `
${this.esc(t2('missingPatches.ana.failed', { error: err?.message ?? '' }))}
`);\n }\n }\n\n // Poll the list endpoint until the product's row leaves 'running'\n // (~3 minutes max; closing the modal stops the poll, the run continues\n // server-side and lands on the next page load).\n // A tick whose open was superseded, or whose tenant or scope the page\n // has left, stops this poll (never a newer one) and drops its answer;\n // a failed read keeps the map as it is and the next tick retries.\n // A scheduled run that held the row can hand it back without an analysis\n // (completed, result withheld under an earlier prompt): the user's request\n // was never run, so the poll asks once more rather than show a failure.\n private pollAnalysis(product: string, seq: number, again = false): void {\n this.stopAnalysisPoll();\n const key = this.analysisScopeKey();\n let ticks = 0;\n let inFlight = false;\n const timer = window.setInterval(async () => {\n const stop = () => {\n window.clearInterval(timer);\n if (this.analysisPollTimer === timer) this.analysisPollTimer = null;\n };\n const stale = () => seq !== this.analysisSeq || key !== this.analysisScopeKey();\n if (inFlight) return;\n if (++ticks > 72 || stale()) { stop(); return; }\n inFlight = true;\n let fetched: Map | null;\n try {\n fetched = await this.fetchPatchAnalyses();\n } finally {\n inFlight = false;\n }\n if (stale()) { stop(); return; }\n if (!fetched) return;\n // The map is replaced only with a terminal answer, and then drawn\n // at once, so the table, the panel and the export read one state.\n const a = fetched.get(product.trim().toLowerCase());\n if (!a || a.status === 'running' || a.status === 'pending') return;\n stop();\n this.analyses = fetched;\n this.analysesFailed = false;\n // Every row of this product flips to its new state, and the\n // summary panel picks the analysis up. Drawn before a second ask,\n // so Analyze stays offered should that ask fail.\n this.appsTable?.rows().invalidate('data').draw(false);\n this.renderUpgradeAnalyses();\n if (a.status === 'completed' && a.result) this.renderAnalysisDrawer(a);\n else if (a.status === 'completed' && !again) void this.openPatchAnalysis(product, false, true);\n else {\n this.setAnalysisDrawerContent(product, '',\n `
${this.esc(t2('missingPatches.ana.failed', { error: a.error ?? '' }))}
`);\n }\n }, 2500);\n this.analysisPollTimer = timer;\n }\n\n private stopAnalysisPoll(): void {\n if (this.analysisPollTimer !== null) {\n window.clearInterval(this.analysisPollTimer);\n this.analysisPollTimer = null;\n }\n }\n\n // The analysis reads as a document (verdict, insights, a table, caveats),\n // so it lives in a wide slide-out drawer \u2014 the shared Drawer base used by\n // the fleet drill-in \u2014 not a centered modal that crushes the prose.\n private ensureAnalysisDrawer(): Drawer {\n if (!this.analysisDrawer) {\n this.analysisDrawer = new Drawer({\n id: 'mpAnalysisDrawer',\n classPrefix: 'mp-ana-drawer',\n ariaLabel: t2('missingPatches.ana.title'),\n width: 'min(900px, 92vw)',\n });\n this.analysisDrawer.onClose(() => {\n // A request still in flight for the closed drawer is dropped.\n this.analysisSeq++;\n this.stopAnalysisPoll();\n });\n }\n return this.analysisDrawer;\n }\n\n private setAnalysisDrawerContent(product: string, metaText: string, bodyHtml: string): void {\n const drawer = this.ensureAnalysisDrawer();\n drawer.headerEl.innerHTML = `\n
\n
${this.esc(t2('missingPatches.ana.modalTitle', { product }))}
\n ${metaText ? `
${this.esc(metaText)}
` : ''}\n
`;\n drawer.bodyEl.innerHTML = `
${bodyHtml}
`;\n }\n\n private renderAnalysisDrawer(a: PatchAnalysis): void {\n const snap = a.snapshot;\n const res = a.result;\n\n // What the reader can act on, and nothing else. Who asked for the\n // analysis is an internal email address, and the model name is our\n // implementation detail \u2014 neither belongs on a tenant's page, and\n // together they read as machine text leaking through (ENG-4021).\n // Both stay in the API payload for support and debugging.\n const meta: string[] = [];\n if (a.generatedAt) meta.push(t2('missingPatches.ana.generated', { date: a.generatedAt.slice(0, 10) }));\n if (snap?.priorityRank) meta.push(t2('missingPatches.ana.rank', { rank: snap.priorityRank, of: snap.priorityRankOf ?? snap.priorityRank }));\n\n let html = '';\n if (res) {\n html += `
${this.actionPill(res.action)}\n ${this.esc(res.headline)}
`;\n if (snap?.minClearingVersion) {\n html += `
${this.esc(t2('missingPatches.ana.minClear', { version: snap.minClearingVersion }))}
`;\n }\n if (res.insights?.length) {\n html += `
    ${res.insights.map(i => `
  • ${this.esc(i)}
  • `).join('')}
`;\n }\n }\n const versions = snap?.versions ?? [];\n if (versions.length) {\n const rows = versions.map(v => `\n ${this.esc(v.version)}\n ${v.nodes}\n ${v.cveCount}\n ${(v.kevCveIds ?? []).map(id => this.esc(id)).join(', ') || '\u2014'}\n ${(v.maxCvss ?? 0).toFixed(1)}\n ${this.clearedByCell(v)}\n `).join('') + this.targetVersionRow(snap);\n // The estate's real size, said before the table rather than left\n // to be inferred from a column that cannot sum to it: one node\n // carrying two installed versions appears in both rows.\n if (snap?.nodesWithProduct) {\n html += `
${this.esc(t2('missingPatches.ana.nodesWithProduct',\n { nodes: snap.nodesWithProduct }))}
`;\n }\n // The snapshot keeps the newest versions only: say so when it cut,\n // or, for one stored before it carried the total, when it may have.\n const cut = patchVersionsNote(snap?.versionCount, versions.length);\n if (cut?.kind === \"cut\") {\n html += `
${this.esc(t2('missingPatches.ana.versionsShown',\n { shown: cut.shown, total: cut.total }))}
`;\n } else if (cut?.kind === \"mayBeCut\") {\n html += `
${this.esc(t2('missingPatches.ana.versionsMayBeCut',\n { shown: cut.shown }))}
`;\n }\n html += `
\n \n \n \n \n \n \n \n ${rows}
${this.esc(t2('missingPatches.col.version'))}${this.thWithInfo(t2('missingPatches.ana.col.nodes'),\n t2('missingPatches.ana.col.nodes.tip'))}${this.esc(t2('missingPatches.col.cves'))}${this.esc(t2('missingPatches.col.kev'))}${this.esc(t2('missingPatches.col.maxCvss'))}${this.thWithInfo(t2('missingPatches.ana.col.clearedBy'),\n t2('missingPatches.ana.col.clearedBy.tip'))}
`;\n }\n if (snap?.sideBySide && snap.sideBySide.nodes > 0) {\n const s = snap.sideBySide;\n html += `
${this.esc(t2('missingPatches.ana.sideBySide', {\n nodes: s.nodes,\n newest: s.newestVersion ?? '',\n stale: (s.staleVersions ?? []).join(', '),\n }))}${s.kevOnlyOnStale ? ' ' + this.esc(t2('missingPatches.ana.kevOnStale')) : ''}
`;\n }\n if (res?.caveats?.length) {\n html += `
    ${res.caveats.map(c => `
  • ${this.esc(c)}
  • `).join('')}
`;\n }\n html += `
${this.esc(t2('missingPatches.ana.disclaimer'))}
`;\n this.setAnalysisDrawerContent(a.productName, meta.join(' \u00B7 '), html);\n this.ensureAnalysisDrawer().open();\n }\n\n /** The version this analysis recommends upgrading to, as its own row at\n * the foot of the table.\n *\n * The table otherwise lists only versions the tenant actually runs, so\n * the target \u2014 the one version the reader is being told to move to \u2014\n * appeared nowhere in it, and a reader scanning the Version column for\n * the number in the headline did not find it. It carries 0 servers\n * because that is a real count of this estate, and a \"target\" chip so\n * the row is not read as an observation.\n *\n * The CVE columns are dashes, NOT zeros. No server runs this version, so\n * nothing about it was assessed \u2014 and a 0 here would claim we checked\n * and found it clean, which is the exact claim this page's own footnote\n * forbids (\"absence means not assessed, never not vulnerable\"). The\n * version is a fix boundary read from NVD, not a scanned install.\n *\n * Omitted when the estate already runs the target (it then has a real\n * row of its own) or when no boundary was published. */\n private targetVersionRow(snap?: PatchAnalysisSnapshot): string {\n const target = snap?.minClearingVersion;\n if (!target) return '';\n if ((snap?.versions ?? []).some(v => v.version === target)) return '';\n const tip = t2('missingPatches.ana.targetRow.tip');\n const na = t2('missingPatches.ana.targetRow.notAssessed');\n const dash = (cls: string) => `\u2014`;\n return `\n ${this.esc(target)}${this.esc(t2('missingPatches.ana.targetRow'))}\n 0\n ${dash('text-right')}\n ${dash('')}\n ${dash('text-right')}\n ${dash('')}\n `;\n }\n\n /** Header label + info icon, the .mp-th pattern the static table headers\n * use \u2014 spelled out here because this table is built at runtime, so the\n * data-i18n-tooltip pass never sees it. */\n private thWithInfo(label: string, tip: string): string {\n return `${this.esc(label)}`\n // An image named by the tip (its ligature is not read), focusable\n // so the tooltip is not mouse-only.\n + `info`;\n }\n\n /** \"Cleared by upgrading to \u2265\" cell. Three states that must stay\n * distinguishable: a fix boundary; nothing to clear (the version carries\n * no CVEs at all, which is most rows on a side-by-side product); and\n * CVEs whose NVD records publish no upper bound \u2014 \"no boundary\n * published\", never \"no fix exists\". */\n private clearedByCell(v: PatchAnalysisVersion): string {\n if (v.clearedByUpgradeTo) return this.esc(v.clearedByUpgradeTo);\n const key = (v.cveCount ?? 0) === 0\n ? 'missingPatches.ana.noCves' : 'missingPatches.ana.noBoundary';\n return ``\n + `${this.esc(t2(key))}`;\n }\n\n // \u2500\u2500 CVE drawer (per-row drill-down) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n // Clicking a row in either detail table opens every stored CVE behind\n // that row's counts in a drawer: searchable, filterable (severity /\n // known-exploited / ransomware), with a per-CVE virtual-patch Request.\n // Stacks above the tenant drawer when the detail view lives there.\n\n private ensureCveDrawer(): Drawer {\n if (!this.cveDrawer) {\n this.cveDrawer = new Drawer({\n id: 'mpCveDrawer',\n classPrefix: 'mp-cve-drawer',\n ariaLabel: t2('missingPatches.cved.aria'),\n width: 'min(1150px, 92vw)',\n });\n this.cveDrawer.onClose(() => {\n // A load still in flight for the closed drawer is dropped.\n this.cveDrawerGen++;\n this.cveTable?.destroy();\n this.cveTable = null;\n });\n }\n return this.cveDrawer;\n }\n\n private async openCveDrawer(src: CveDrawerSource): Promise {\n if (!this.tenantId) return;\n const drawer = this.ensureCveDrawer();\n // Each open supersedes the last: a slower load for an earlier row\n // must not render its rows (and their workflow buttons, which carry\n // that row's node) under this row's header.\n const gen = ++this.cveDrawerGen;\n const stale = () => gen !== this.cveDrawerGen;\n this.cveTable?.destroy();\n this.cveTable = null;\n\n const titleHtml = src.kind === 'app'\n ? `${this.esc(src.app.product_name)}${src.app.company_name ? ` ${this.esc(src.app.company_name)}` : ''} ${renderSourcesCell(src.app.sources ?? ['mimic'], 'display')}`\n : src.kind === 'linux'\n ? `${this.esc(src.pkg.package)} ${this.esc(src.pkg.ecosystem)}`\n : src.kind === 'vendor'\n ? `${this.esc(src.vendor.product)} ${renderSourcesCell(src.vendor.sources, 'display')}`\n : `${this.esc(t2('missingPatches.sum.fixes.windows'))} ${renderSourcesCell(this.osLanes(src.os), 'display')}`;\n const subText = src.kind === 'app'\n ? `${src.app.file_version} \u00B7 ${src.app.hostname || src.app.node_id}`\n : src.kind === 'linux'\n ? `${(src.pkg.installed_versions ?? [src.pkg.installed]).join(', ')} \u00B7 ${src.pkg.hostname || src.pkg.node_id}`\n : src.kind === 'vendor'\n ? `${src.vendor.version} \u00B7 ${src.vendor.hostname || src.vendor.asset_hostname}${src.vendor.node_id ? '' : ` \u00B7 ${t2('vulnSources.noNode')}`}`\n : t2('missingPatches.cved.subOs', { build: src.os.build, node: src.os.hostname });\n drawer.headerEl.innerHTML = `\n
\n
${titleHtml}
\n
${this.esc(subText)}
\n
\n \n
`;\n drawer.bodyEl.innerHTML = `
\n
${this.esc(t2('missingPatches.cved.loading'))}
`;\n drawer.open();\n\n try {\n let rows: CveDrawerRow[];\n let attribution = '';\n let notice = '';\n if (src.kind === 'app') {\n const lanes = src.app.sources ?? ['mimic'];\n const refs = src.app.vendor_refs ?? [];\n rows = [];\n if (lanes.includes('mimic')) {\n const r = await apiFetch(`/api/tenants/${this.tenantId}/cve-exposure/product-cves`\n + `?product=${encodeURIComponent(src.app.product_name ?? '')}`\n + `&version=${encodeURIComponent(src.app.file_version ?? '')}`\n // Same scope as the row the drawer opened from, so the CVE\n // list can't describe installs on out-of-scope nodes.\n + this.scopeParams());\n if (!r.ok) throw new Error(String(r.status));\n const body = await r.json() as { items?: ProductCveItem[]; attribution?: string };\n if (stale()) return;\n attribution = body.attribution ?? '';\n rows = (body.items ?? []).map(it => ({\n cveId: it.cve_id,\n cvss: it.cvss_score ?? 0,\n kev: it.kev_listed,\n ransomware: it.ransomware,\n fixed: it.fixed_version ?? '',\n detail: it.description ?? '',\n sources: refs.length ? ['mimic'] : undefined,\n }));\n }\n // The scanners' lists for every vendor row folded into this\n // one (ENG-4374), reconciled per CVE: a CVE both saw carries\n // both chips; one only a scanner reported joins with the\n // scanner's fix and title.\n const byCve = new Map(rows.map(r => [r.cveId.toUpperCase(), r]));\n for (const ref of refs) {\n const r = await apiFetch(`/api/tenants/${this.tenantId}/vendor-finding-cves`\n + this.nodeScopedQuery({\n nodeId: src.app.node_id, assetKey: '',\n product: ref.product, version: ref.version,\n }));\n if (!r.ok) throw new Error(String(r.status));\n const body = await r.json() as { items?: VendorCveItem[] };\n if (stale()) return;\n for (const it of body.items ?? []) {\n const have = byCve.get(it.cve_id.toUpperCase());\n if (have) {\n have.sources = sortSources([...(have.sources ?? []), ...(it.sources ?? [])]);\n if (!have.fixed && it.fixed_version) have.fixed = it.fixed_version;\n continue;\n }\n const row: CveDrawerRow = {\n cveId: it.cve_id,\n cvss: it.cvss_score ?? 0,\n kev: it.kev_listed,\n ransomware: it.ransomware,\n fixed: it.fixed_version ?? '',\n detail: it.title || it.remediation || '',\n sources: it.sources ?? [],\n early: !!it.early,\n };\n rows.push(row);\n byCve.set(row.cveId.toUpperCase(), row);\n }\n }\n if (refs.length) {\n rows.sort((a, b) => Number(b.kev) - Number(a.kev) || b.cvss - a.cvss || a.cveId.localeCompare(b.cveId));\n }\n } else if (src.kind === 'linux') {\n // Every build the row merged, so the drawer lists what the\n // row's CVE count actually counted.\n const builds = src.pkg.installed_versions?.length\n ? src.pkg.installed_versions : [src.pkg.installed];\n const r = await apiFetch(`/api/tenants/${this.tenantId}/package-cves`\n + `?ecosystem=${encodeURIComponent(src.pkg.ecosystem)}`\n + `&package=${encodeURIComponent(src.pkg.package)}`\n + builds.map(v => `&version=${encodeURIComponent(v)}`).join('')\n + this.scopeParams());\n if (!r.ok) throw new Error(String(r.status));\n const body = await r.json() as { items?: PackageCveItem[]; attribution?: string };\n if (stale()) return;\n attribution = body.attribution ?? '';\n rows = (body.items ?? []).map(it => ({\n cveId: it.cve_id,\n cvss: it.cvss_score ?? 0,\n kev: it.kev_listed,\n ransomware: it.ransomware,\n fixed: it.fixed_version ?? '',\n detail: it.summary ?? '',\n // Advisory-wide CVEs get labeled in the detail cell: the\n // update addresses them, but the distro never said THIS\n // package carries them.\n advisoryWide: it.attribution === 'advisory',\n }));\n } else if (src.kind === 'vendor') {\n const v = src.vendor;\n const r = await apiFetch(`/api/tenants/${this.tenantId}/vendor-finding-cves`\n + this.nodeScopedQuery({\n nodeId: v.node_id, assetKey: v.asset_key,\n product: v.product, version: v.version,\n }));\n if (!r.ok) throw new Error(String(r.status));\n const body = await r.json() as { items?: VendorCveItem[] };\n if (stale()) return;\n rows = (body.items ?? []).map(it => ({\n cveId: it.cve_id,\n cvss: it.cvss_score ?? 0,\n kev: it.kev_listed,\n ransomware: it.ransomware,\n fixed: it.fixed_version ?? '',\n detail: it.title || it.remediation || '',\n sources: it.sources ?? [],\n }));\n } else {\n // The wua lane asks by node: the agent's own list plus the\n // CVEs MSRC attributes to those KBs. The msrc lanes ask by\n // patch level as before. Either way the node id rides along\n // so the scanners' findings against the build come back as\n // vendor[] (ENG-4374); a vendor-only row asks for those alone.\n const lane = src.os.source === 'vendor' ? 'vendor' : src.os.source === 'wua' ? 'wua' : 'msrc';\n const r = await apiFetch(`/api/tenants/${this.tenantId}/windows-gap-cves`\n + `?nodeId=${encodeURIComponent(src.os.nodeId)}&lane=${lane}`\n + (lane === 'msrc' ? `&build=${src.os.buildNum}&ubr=${src.os.ubr}` : ''));\n if (!r.ok) throw new Error(String(r.status));\n const body = await r.json() as {\n items?: WindowsFixItem[]; updates?: WuaUpdateItem[]; vendor?: VendorCveItem[];\n /** \"vendor_findings\" when the scanners' lookup failed:\n * vendor[] is then empty for want of data, not findings. */\n degraded?: string[] | null;\n };\n if (stale()) return;\n if (body.degraded?.includes('vendor_findings')) {\n notice = t2('missingPatches.cved.vendorDegraded');\n }\n const kbsEl = drawer.headerEl.querySelector('#mpCveKbs');\n if (kbsEl && body.updates?.length) {\n // Every missing update the agent named, MSRC row or not:\n // a missing SQL Server update is still a missing update.\n kbsEl.hidden = false;\n kbsEl.textContent = `${t2('missingPatches.cved.updates', { n: body.updates.length })}: `\n + body.updates.map(u => `${/^\\d+$/.test(u.kb) ? `KB${u.kb}` : u.kb || u.title}`).join(', ');\n }\n rows = (body.items ?? []).map(it => ({\n cveId: it.cve_id,\n cvss: it.cvss_score ?? 0,\n kev: it.kev_listed,\n ransomware: it.ransomware,\n // MSRC stores the bare KB number; label it the way an\n // operator searches for it.\n fixed: `${it.kb ? `${/^\\d+$/.test(it.kb) ? 'KB' : ''}${it.kb} \u00B7 ` : ''}UBR ${it.fixed_ubr}`,\n detail: (it.released_at ?? '').slice(0, 10),\n sources: lane === 'vendor' ? [] : [lane],\n }));\n // Reconcile the scanners' list with the Mimic lane's per\n // CVE: a CVE both saw carries both chips; one only a scanner\n // reported joins the list with the scanner's fix and title.\n const byCve = new Map(rows.map(r => [r.cveId.toUpperCase(), r]));\n for (const it of body.vendor ?? []) {\n const have = byCve.get(it.cve_id.toUpperCase());\n if (have) {\n have.sources = sortSources([...(have.sources ?? []), ...(it.sources ?? [])]);\n continue;\n }\n const row: CveDrawerRow = {\n cveId: it.cve_id,\n cvss: it.cvss_score ?? 0,\n kev: it.kev_listed,\n ransomware: it.ransomware,\n fixed: it.fixed_version ?? '',\n detail: it.title || it.remediation || '',\n sources: it.sources ?? [],\n };\n rows.push(row);\n byCve.set(row.cveId.toUpperCase(), row);\n }\n // Worst first across both lists, the way every drawer orders.\n rows.sort((a, b) => Number(b.kev) - Number(a.kev) || b.cvss - a.cvss || a.cveId.localeCompare(b.cveId));\n }\n if (stale()) return;\n this.renderCveDrawerTable(src, rows, attribution, notice);\n } catch (err: any) {\n if (stale()) return;\n drawer.bodyEl.innerHTML = `
${this.esc(t2('missingPatches.cved.loadFailed', { error: err?.message ?? '' }))}
`;\n }\n }\n\n /** notice, when set, is a warning above the table: a lane of the list\n * failed to load, so a CVE missing from it is unknown, not absent. */\n private renderCveDrawerTable(src: CveDrawerSource, rows: CveDrawerRow[], attribution: string, notice = ''): void {\n const drawer = this.ensureCveDrawer();\n const isApp = src.kind === 'app';\n // App, Linux and vendor rows name a fix VERSION and carry prose;\n // only the Windows gap speaks in servicing updates and release dates.\n // A vendor-only Windows row (ENG-4374) speaks the scanner's language:\n // a fix version and a title, like the software rows.\n const hasDesc = src.kind !== 'os' || src.os.source === 'vendor';\n\n const counts = drawer.headerEl.querySelector('#mpCveCounts');\n if (counts) {\n counts.textContent = t2('missingPatches.cved.counts', {\n n: rows.length, kev: rows.filter(r => r.kev).length,\n });\n }\n\n // The stored roster is capped at the sweep's per-version limit; when\n // the row's true total exceeds what is stored, say so rather than\n // letting the list read as complete.\n const capNote = src.kind === 'app' && src.app.cve_count > rows.length\n ? t2('missingPatches.cved.capNote', { stored: rows.length, total: src.app.cve_count })\n : '';\n\n drawer.bodyEl.innerHTML = `\n ${notice ? `
${this.esc(notice)}
` : ''}\n ${this.drawerAsOfHtml(src)}\n
\n \n \n \n \n \n \n \n \n \n \n \n \n
${this.esc(t2('missingPatches.cved.col.cve'))}${this.esc(t2('missingPatches.cved.col.cvss'))}${this.esc(t2('missingPatches.cved.col.severity'))}${this.esc(t2('missingPatches.col.kev'))}${this.esc(t2('missingPatches.col.ransomware'))}${this.esc(hasDesc ? t2('missingPatches.cved.col.fixedIn') : t2('missingPatches.cved.col.update'))}${this.esc(hasDesc ? t2('missingPatches.cved.col.description') : t2('missingPatches.cved.col.released'))}${this.esc(t2('missingPatches.col.vp'))}
\n
\n ${capNote ? `

${this.esc(capNote)}

` : ''}\n ${attribution ? `

${this.esc(attribution)}

` : ''}`;\n\n // The virtual patch workflow's target: one node and the product the\n // CVE is on there (the package for a Linux row, Windows for an OS-gap\n // row). A vendor row tied to no Mimic node has no node to patch.\n const vpProduct = src.kind === 'app' ? src.app.product_name ?? ''\n : src.kind === 'linux' ? src.pkg.package\n : src.kind === 'vendor' ? src.vendor.product : 'Windows';\n const vpNode = src.kind === 'app' ? src.app.node_id\n : src.kind === 'linux' ? src.pkg.node_id\n : src.kind === 'vendor' ? src.vendor.node_id : src.os.nodeId;\n // The version on the node, for the request's description: every\n // installed build of a package, as the drawer shows them, the OS\n // build for an OS row; any kind within the server's 200 characters.\n const vpVersion = cutVersion(src.kind === 'app' ? src.app.file_version ?? ''\n : src.kind === 'linux' ? (src.pkg.installed_versions ?? [src.pkg.installed ?? '']).join(', ')\n : src.kind === 'vendor' ? src.vendor.version ?? '' : src.os.build ?? '');\n const vpHost = src.kind === 'app' ? src.app.hostname\n : src.kind === 'linux' ? src.pkg.hostname\n : src.kind === 'vendor' ? src.vendor.hostname : src.os.hostname;\n\n this.cveTable = $('#mpCveTable').DataTable({\n data: rows,\n dom: DOM_STRING,\n order: [], // server order is the verdict order: KEV first, then severity\n pageLength: 20,\n lengthMenu: [[10, 20, 50, 100, -1], [10, 20, 50, 100, 'All']],\n autoWidth: false,\n language: {\n emptyTable: t2('missingPatches.cved.empty'),\n zeroRecords: t2('dt.zeroRecords'), // a filter that hides every row is not the empty state\n search: '',\n },\n headerCallback: (thead) => {\n (thead as HTMLTableSectionElement).classList.add('custom-thead');\n },\n columnDefs: [\n {\n targets: [2, 5],\n columnControl: [['orderAsc', 'orderDesc', 'searchList']],\n },\n {\n // Pill columns: searchList over the 'filter' strings, not\n // the rendered pill markup.\n targets: [3, 4, 7],\n columnControl: [['orderAsc', 'orderDesc',\n { extend: 'searchList', orthogonal: 'filter' }]],\n },\n {\n targets: [0, 1, 6],\n columnControl: [['orderAsc', 'orderDesc']],\n },\n ],\n columns: [\n {\n data: 'cveId',\n render: (d: string, type: string) => (type === 'display'\n ? `${this.esc(d)}`\n : d ?? ''),\n },\n {\n data: 'cvss', className: 'text-right',\n render: (d: number, type: string) => (type === 'display'\n ? ((d ?? 0) > 0 ? (d ?? 0).toFixed(1) : '\u2014') : d ?? 0),\n },\n {\n // Severity band from the score \u2014 the same cut the summary\n // donut uses, so the filter and the donut agree. Sorts by\n // the underlying score, not the label.\n data: 'cvss',\n render: (d: number, type: string) => (\n type === 'sort' || type === 'type' ? d ?? 0 : this.sevBand(d ?? 0)),\n },\n {\n data: 'kev', className: 'mp-center',\n render: (d: boolean, type: string) => this.pill(\n d, 'mp-pill-kev', t2('missingPatches.kev.yes'), t2('missingPatches.kev.no'), type),\n },\n {\n data: 'ransomware', className: 'mp-center',\n render: (d: boolean, type: string) => this.pill(\n d, 'mp-pill-ransom', t2('missingPatches.ransomware.yes'), t2('missingPatches.ransomware.no'), type),\n },\n {\n data: 'fixed',\n render: (d: string, type: string) => {\n if (type !== 'display') return d || t2('missingPatches.ana.noBoundary');\n return d ? this.esc(d)\n : `${this.esc(t2('missingPatches.ana.noBoundary'))}`;\n },\n },\n {\n data: 'detail',\n render: (d: string, type: string, row: CveDrawerRow) => {\n if (type !== 'display') return d ?? '';\n // Windows rows: the release date, prefixed by the\n // lane chips once more than one lane is in play.\n if (!hasDesc) {\n // A CVE only a scanner reported carries its title\n // here instead of a date \u2014 clamp it like a description.\n const chips = row.sources && row.sources.length && src.kind === 'os' && this.osLanes(src.os).length > 1\n ? `${renderSourcesCell(row.sources, 'display')} `\n : '';\n const short = (d ?? '').length > 500 ? `${d.slice(0, 500)}\u2026` : d ?? '';\n const early = row.early ? `${this.earlyPill()} ` : '';\n return `${early}${chips}${this.esc(d)}`;\n }\n // Descriptions are NVD/advisory prose \u2014 clamp the\n // cell, carry the (bounded) full text on the tooltip.\n const tip = (d ?? '').length > 500 ? `${d.slice(0, 500)}\u2026` : d ?? '';\n // Advisory-wide marker (Linux): the update addresses\n // this CVE, but the distro never said THIS package\n // carries it \u2014 the label keeps that distinction.\n const marker = row.advisoryWide\n ? `${this.esc(t2('missingPatches.attr.advisoryWide'))} `\n : row.early ? `${this.earlyPill()} ` : '';\n // Vendor rows: which vendors reported THIS CVE \u2014 the\n // row-level chips say who reported the group.\n const reported = row.sources?.length\n ? `${renderSourcesCell(row.sources, 'display')} `\n : '';\n return `${marker}${reported}${this.esc(d)}`;\n },\n },\n {\n data: null, className: 'mp-center',\n render: (_d: unknown, type: string, row: CveDrawerRow) =>\n this.vpCell(row.cveId, vpProduct, vpNode ?? '', vpHost || vpNode || '', type, String(vpVersion)),\n },\n ],\n });\n this.cveTable.column(7).visible(!!this.vpByCve);\n $('#mpCveTable tbody').off('click.mpcvedvp').on('click.mpcvedvp', 'button.mp-vp-start', (ev: any) => {\n this.openVpWorkflow(ev.currentTarget as HTMLButtonElement);\n });\n setupDataTableUI('mpCveTable', this.cveTable, {\n columnControl: {\n dropdownMaterialIcon: 'filter_list',\n scrollHeadThClass: 'filter-right',\n fixHeaderLayout: true,\n cascadeSearchLists: true,\n },\n autoResize: { namespace: 'missing-patches-cved' },\n });\n }\n\n /** Severity band label for a CVSS score; '\u2014' when no metric is stored. */\n private sevBand(cvss: number): string {\n if (cvss <= 0) return '\u2014';\n return cvss >= 9 ? t2('missingPatches.sev.critical')\n : cvss >= 7 ? t2('missingPatches.sev.high')\n : cvss >= 4 ? t2('missingPatches.sev.medium')\n : t2('missingPatches.sev.low');\n }\n\n // The workflow controls are for MP platform staff: the MP allows the\n // vulnerability create to staff only (mgmt-plane #3636), the dashboard's\n // research and Generate Policy are staff only, and the workflow routes\n // gate on the same rule. The flag mirrors that gate; everyone else keeps\n // the pills.\n private canRequestVp(): boolean {\n return !!StoreManager.getInstance().userProfileStore.user?.hasVirtualPatchRequest;\n }\n\n // Per-CVE virtual-patch cell \u2014 the single-CVE version of the table\n // column's join: same catalog, one control. Request, Requested or\n // Available; for staff, on a row tied to a Mimic node, for a CVE a\n // workflow can start or resume for (vpStartable), it is a button that\n // opens the workflow modal, which also links to the dashboard's entry.\n private vpCell(cveId: string, product: string, nodeId: string, hostname: string, type: string, version = ''): string | number {\n if (!this.vpByCve) return type === 'display' || type === 'filter' ? '' : 0;\n const key = cveId.toUpperCase();\n const entry = this.vpByCve.get(key);\n const state = entry && entry.officialPatches + entry.customPatches > 0 ? 'available'\n : entry ? 'requested' : 'requestable';\n if (type === 'filter') {\n return state === 'available' ? t2('missingPatches.vp.available')\n : state === 'requested' ? t2('missingPatches.vp.requested')\n : t2('missingPatches.vp.none');\n }\n if (type !== 'display') return state === 'available' ? 2 : state === 'requested' ? 1 : 0;\n const clickable = this.canRequestVp() && !!nodeId && !!product && this.vpStartable(entry);\n if (!clickable) return this.vpPill(state, cveId);\n return this.vpControl(state, 'mp-vp-start', state === 'requestable'\n ? t2('missingPatches.vpw.startTip', { cve: cveId, host: hostname })\n : this.vpTip(state, cveId),\n ` data-cve=\"${this.esc(cveId)}\"`\n + ` data-product=\"${this.esc(product)}\"`\n + ` data-version=\"${this.esc(version)}\"`\n + ` data-node=\"${this.esc(nodeId)}\"`\n + ` data-host=\"${this.esc(hostname)}\"`);\n }\n\n // The column's status as a pill: Available, Requested, or nothing.\n private vpPill(state: 'available' | 'requested' | 'requestable', cves: string): string {\n if (state === 'requestable') return '';\n const cls = state === 'available' ? 'mp-pill-vp' : 'mp-pill-muted';\n return `${this.esc(this.vpLabel(state))}`;\n }\n\n // The column's status as the one button: Request (outlined) or the\n // status pill itself. kind is the click handler's class; attrs are the\n // escaped data attributes it reads.\n private vpControl(state: 'available' | 'requested' | 'requestable', kind: string, tip: string, attrs = ''): string {\n const cls = state === 'requestable' ? 'mp-vp-request'\n : `mp-pill mp-pill-btn ${state === 'available' ? 'mp-pill-vp' : 'mp-pill-muted'}`;\n return ``;\n }\n\n private vpLabel(state: 'available' | 'requested' | 'requestable'): string {\n return state === 'available' ? t2('missingPatches.vp.available')\n : state === 'requested' ? t2('missingPatches.vp.requested')\n : t2('missingPatches.vp.request');\n }\n\n private vpTip(state: 'available' | 'requested', cves: string): string {\n return state === 'available' ? t2('missingPatches.vp.availableTip', { cves })\n : t2('missingPatches.vp.requestedTip', { cves });\n }\n\n // \u2500\u2500 Summary dashboard \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n private renderSummary(body: ExposureResponse, items: ExposureRow[], osRows: OsGapRow[], linuxRows: LinuxPkgRow[], vendorRows: VendorRow[] = []): void {\n const root = document.getElementById('mpSummary');\n if (!root) return;\n root.hidden = false;\n this.setupToggle(root);\n\n // Re-render closure: canvases sized while collapsed come out 0\u00D70, so\n // expanding must repaint from the same data (AD-summary pattern).\n this.lastRender = () => this.renderSummaryBody(root, body, items, osRows, linuxRows, vendorRows);\n if (!root.classList.contains('collapsed')) this.lastRender();\n\n const sub = root.querySelector('.sc-summary-sub');\n if (sub) {\n const u = this.exposureUnions(items, osRows, linuxRows, vendorRows);\n sub.textContent = t2('missingPatches.sum.sub', {\n affected: u.affected,\n total: this.rosterTotal,\n kev: u.kev,\n win: osRows.length,\n });\n }\n }\n\n // Distinct nodes with any exposure / any known-exploited exposure, from\n // BOTH tables the page shows. These are deliberately the same unions the\n // Nodes page's CVEs column uses for its pill and its known-exploited\n // verdict (app exposure OR Windows update gap; app KEV OR missing KEV\n // fixes), so the two surfaces can never disagree about how many nodes\n // are exposed.\n /** Rows a scanner reported, wherever they now live (ENG-4374): software\n * rows a vendor folded into or stands alone in, Windows-lane rows with\n * a vendor chip, and the vendor card's unmatched assets. */\n private vendorReportedCount(items: ExposureRow[], osRows: OsGapRow[], vendorRows: VendorRow[]): number {\n return items.filter(r => (r.sources ?? []).some(s => s !== 'mimic')).length\n + osRows.filter(r => r.vendorSources.length > 0).length\n + vendorRows.length;\n }\n\n private exposureUnions(items: ExposureRow[], osRows: OsGapRow[], linuxRows: LinuxPkgRow[], vendorRows: VendorRow[] = []): { affected: number; kev: number } {\n const affected = new Set();\n const kev = new Set();\n for (const r of items) {\n affected.add(r.node_id);\n if (r.known_exploited) kev.add(r.node_id);\n }\n // Vendor rows count only when tied to a Mimic node: the tiles are\n // \"N / roster\", and an unmatched asset is not in the roster.\n for (const r of vendorRows) {\n if (!r.node_id) continue;\n affected.add(r.node_id);\n if (r.known_exploited) kev.add(r.node_id);\n }\n for (const r of osRows) {\n affected.add(r.nodeId);\n if (r.missingKev > 0) kev.add(r.nodeId);\n }\n for (const r of linuxRows) {\n affected.add(r.node_id);\n if (r.known_exploited) kev.add(r.node_id);\n }\n return { affected: affected.size, kev: kev.size };\n }\n\n private renderSummaryBody(root: HTMLElement, body: ExposureResponse, items: ExposureRow[], osRows: OsGapRow[], linuxRows: LinuxPkgRow[], vendorRows: VendorRow[] = []): void {\n const cardsEl = root.querySelector('.sc-summary-cards');\n if (cardsEl) {\n const ransomwareRows = items.filter(r => r.ransomware).length\n + osRows.filter(r => r.ransomware).length\n + linuxRows.filter(r => r.ransomware).length\n + vendorRows.filter(r => r.ransomware).length;\n // The roster total gives the tiles their denominator; when the\n // roster is empty (nothing visible) the bare count stays honest.\n const ofTotal = (n: number): string =>\n this.rosterTotal > 0 ? `${n} / ${this.rosterTotal}` : String(n);\n const kevProducts = this.kevProductRows(items).length;\n const u = this.exposureUnions(items, osRows, linuxRows, vendorRows);\n // Card click jumps to the section it counts (Tenant Health\n // pattern): the tile is the headline, the table below is the\n // detail behind it.\n const jumpTo = (tableId: string): void => {\n document.getElementById(tableId)?.closest('.sc-chart-card')\n ?.scrollIntoView({ behavior: 'smooth', block: 'start' });\n };\n const cards: SCStatCard[] = [\n {\n label: t('missingPatches.card.nodesAffected'), value: u.affected,\n valueText: ofTotal(u.affected),\n color: '#F59E0B', info: t('missingPatches.card.nodesAffected.info'),\n onClick: () => jumpTo('mpAppsTable'),\n },\n {\n label: t('missingPatches.card.nodesKev'), value: u.kev,\n valueText: ofTotal(u.kev),\n color: '#EF4444', info: t('missingPatches.card.nodesKev.info'),\n onClick: () => jumpTo('mpAppsTable'),\n },\n {\n label: t('missingPatches.card.kevProducts'), value: kevProducts,\n color: '#DC2626', info: t('missingPatches.card.kevProducts.info'),\n onClick: () => jumpTo('mpAppsTable'),\n },\n {\n label: t('missingPatches.card.windowsBehind'), value: osRows.length,\n color: '#8B5CF6', info: t('missingPatches.card.windowsBehind.info'),\n onClick: () => jumpTo('mpOsTable'),\n },\n // Like the Linux table and donut, the tile only exists when\n // there is inventory to count \u2014 a \"0\" here would read as \"no\n // Linux exposure\" for tenants whose nodes are not assessed.\n ...(linuxRows.length ? [{\n label: t('missingPatches.card.linuxBehind'),\n value: new Set(linuxRows.map(r => r.node_id)).size,\n color: '#14B8A6', info: t('missingPatches.card.linuxBehind.info'),\n onClick: () => jumpTo('mpLinuxTable'),\n } satisfies SCStatCard] : []),\n // Same rule for the vendor lane: the tile exists only once a\n // vendor has reported something, so \"0\" never reads as \"no\n // scanner found anything\".\n ...(this.vendorReportedCount(items, osRows, vendorRows) ? [{\n label: t('missingPatches.card.vendor'),\n value: this.vendorReportedCount(items, osRows, vendorRows),\n color: '#0EA5E9', info: t('missingPatches.card.vendor.info'),\n onClick: () => jumpTo(vendorRows.length ? 'mpVendorTable' : 'mpAppsTable'),\n } satisfies SCStatCard] : []),\n {\n label: t('missingPatches.card.ransomware'), value: ransomwareRows,\n color: '#EC4899', info: t('missingPatches.card.ransomware.info'),\n onClick: () => jumpTo('mpAppsTable'),\n },\n ];\n renderStatCards(cardsEl, cards);\n }\n\n for (const c of this.charts) destroyChart(c);\n this.charts = [];\n\n // The product segments overlap on nodes (one node can carry several\n // KEV products), so the center sum counts product exposures, not\n // distinct nodes \u2014 label it accordingly.\n this.renderDonutInto('mpKevProductsDonut', t2('missingPatches.sum.kevProducts'),\n topNWithOther(this.kevProductRows(items), 6),\n t2('missingPatches.sum.centerKevProducts'));\n this.renderDonutInto('mpSeverityDonut', t2('missingPatches.sum.severity'),\n this.severitySegs(items, osRows, linuxRows, vendorRows));\n this.renderDonutInto('mpWinGapDonut', t2('missingPatches.sum.winGap'),\n this.gapSegs(osRows));\n // The Linux donut appears only when there is inventory to describe \u2014\n // an empty donut would read as \"no Linux exposure\" for the many\n // tenants whose Linux nodes simply are not assessed yet.\n const linuxDonut = document.getElementById('mpLinuxDonut');\n if (linuxDonut) {\n linuxDonut.hidden = linuxRows.length === 0;\n if (linuxRows.length) {\n // Center label says what the segments count \u2014 package\n // UPDATES, not nodes \u2014 and must fit the donut hole.\n this.renderDonutInto('mpLinuxDonut', t2('missingPatches.sum.linux'),\n this.linuxSegs(linuxRows), t2('missingPatches.sum.centerUpdates'));\n }\n }\n this.renderPriorityFixes(items, osRows, linuxRows, vendorRows);\n this.renderPathToGreen();\n this.renderUpgradeAnalyses();\n }\n\n // Path to Green v1 (ENG-3786 PR 8): the server-computed attack-likelihood\n // index plus the projected descent as the priority fixes are applied\n // fleet-wide. Explicitly labeled an ESTIMATE derived from KEV/CVE/OS-gap\n // exposure \u2014 it is a heuristic index, not a measured probability.\n private renderPathToGreen(): void {\n const host = document.getElementById('mpPathToGreen');\n if (!host) return;\n const h = this.hardening;\n // The tooltip answers the questions the panel raises: what the\n // number means (an exposure index, NOT a probability of being\n // exploited), the actual weighted formula behind it, why the green\n // target sits where it does, and how to read the path. It carries\n // markup (CustomTooltips assigns data-tooltip via innerHTML), so the\n // aria-label gets a tag-stripped copy rather than the raw HTML.\n const tipHTML = t2('missingPatches.ptg.tip', { target: Number(h?.greenTarget ?? 50) || 50 });\n const tip = this.esc(tipHTML);\n const tipText = this.esc(tipHTML.replace(/<[^>]+>/g, ' ').replace(/\\s+/g, ' ').trim());\n const title = `
${this.esc(t2('missingPatches.ptg.title'))}`\n + `info_outline
`; // copilot-lint-ignore: catalog markup by design; its one parameter is a number\n if (!h || h.nodesTotal === 0) {\n host.innerHTML = `${title}
${this.esc(t2('missingPatches.ptg.empty'))}
`;\n return;\n }\n // One rule: the index against the target. Severe findings are already\n // inside the index (they set its floor \u2014 ENG-3946), so the number and\n // the band can never disagree.\n const isGreen = h.green ?? (h.likelihood <= h.greenTarget);\n const band = isGreen ? 'mp-ptg-green'\n : (h.likelihood >= 75 ? 'mp-ptg-red' : 'mp-ptg-amber');\n const headline = `\n
\n ${h.likelihood}%\n ${this.esc(t2('missingPatches.ptg.likelihood'))}\n
`;\n const steps = h.steps ?? [];\n // A finding holding the number up is explained on the required step\n // that lifts it (badged with the reason). Only when no offered fix\n // can lift a floor is the finding named on its own \u2014 otherwise the\n // panel would say the same thing twice.\n const floors = h.severityFloors ?? [];\n const floored = floors.length && !steps.some(s => s.required)\n ? `
`\n + floors.map(f => `
${this.esc(this.severityFloorText(f))}
`).join('')\n + `
`\n : '';\n let body: string;\n if (isGreen) {\n body = `
${this.esc(t2('missingPatches.ptg.alreadyGreen', { target: h.greenTarget }))}
`;\n } else if (!steps.length) {\n body = `
${this.esc(t2('missingPatches.ptg.noSteps'))}
`;\n } else {\n const rows = steps.map((s, i) => `\n
\n ${i + 1}\n ${this.esc(s.kind === 'windows_updates'\n ? t2('missingPatches.sum.fixes.windows') : s.label)}${s.required\n ? `${this.esc(t2('missingPatches.ptg.required'))}` : ''}${this.floorBadges(s)}\n ${this.esc(tn('missingPatches.sum.fixes.clears', s.nodesCleared))}\n \u2192 ${s.projectedLikelihood}%\n
`).join('');\n body = rows + `
${this.esc(\n t2('missingPatches.ptg.target', { target: h.greenTarget }))}
`;\n }\n host.innerHTML = title + headline + floored + body;\n }\n\n /** Why a step is required, as badges: the floor reasons it lifts. */\n private floorBadges(s: HardeningStep): string {\n return (s.floorReasons ?? []).map(r => (r === 'os_ransomware_kev'\n ? ` ${this.esc(t2('missingPatches.sum.fixes.ransom'))}`\n : ` ${this.esc(t2('missingPatches.ptg.badge.stale'))}`)).join('');\n }\n\n // One finding holding the index up. The node count travels with it \u2014\n // \"some servers\" is not something anyone can act on.\n private severityFloorText(f: HardeningSeverityFloor): string {\n if (f.reason === 'os_ransomware_kev') {\n return tn('missingPatches.ptg.floor.ransomware', f.nodes);\n }\n return tn('missingPatches.ptg.floor.monthsBehind', f.nodes,\n { months: f.monthsBehind ?? 0 });\n }\n\n // App-product fixes plus, when Windows nodes are missing KEV fixes, a\n // \"Windows cumulative updates\" entry \u2014 ranked together by nodes cleared.\n private allFixes(items: ExposureRow[], osRows: OsGapRow[], linuxRows: LinuxPkgRow[], vendorRows: VendorRow[] = []): {\n label: string; nodes: number; versions: string[]; maxCvss: number; ransomware: boolean;\n }[] {\n const fixes = this.priorityFixes(items);\n // Every configured feed counts (ENG-4374): a known-exploited product a\n // scanner reported on a Mimic node is an action like any other, ranked\n // by the nodes patching it clears. Unmatched assets have no node to\n // clear and stay out.\n const vendorKev = new Map; versions: Set; maxCvss: number; ransomware: boolean }>();\n for (const r of vendorRows) {\n if (!r.node_id || !r.known_exploited) continue;\n let p = vendorKev.get(r.product);\n if (!p) {\n p = { nodes: new Set(), versions: new Set(), maxCvss: 0, ransomware: false };\n vendorKev.set(r.product, p);\n }\n p.nodes.add(r.node_id);\n if (r.version) p.versions.add(r.version);\n p.maxCvss = Math.max(p.maxCvss, r.max_cvss ?? 0);\n p.ransomware = p.ransomware || r.ransomware;\n }\n for (const [product, p] of vendorKev) {\n const have = fixes.find(f => f.label === product);\n if (have) {\n have.nodes = Math.max(have.nodes, p.nodes.size);\n have.versions = [...new Set([...have.versions, ...p.versions])].sort().slice(0, 6);\n have.maxCvss = Math.max(have.maxCvss, p.maxCvss);\n have.ransomware = have.ransomware || p.ransomware;\n continue;\n }\n fixes.push({\n label: product, nodes: p.nodes.size, versions: [...p.versions].sort().slice(0, 6),\n maxCvss: p.maxCvss, ransomware: p.ransomware,\n });\n }\n const osKev = osRows.filter(r => r.missingKev > 0);\n if (osKev.length) {\n fixes.push({\n label: t2('missingPatches.sum.fixes.windows'),\n nodes: new Set(osKev.map(r => r.nodeId)).size,\n versions: [...new Set(osKev.map(r => r.build))].sort().slice(0, 6),\n maxCvss: Math.max(...osKev.map(r => r.maxCvss)),\n ransomware: osKev.some(r => r.ransomware),\n });\n }\n // Known-exploited Linux packages join the same action list: one entry\n // per package, ranked by nodes cleared like everything else. The\n // \"version\" an operator schedules is the update boundary, not the\n // installed one.\n const linuxKev = new Map; versions: Set; maxCvss: number; ransomware: boolean }>();\n for (const r of linuxRows) {\n if (!r.known_exploited) continue;\n let p = linuxKev.get(r.package);\n if (!p) {\n p = { nodes: new Set(), versions: new Set(), maxCvss: 0, ransomware: false };\n linuxKev.set(r.package, p);\n }\n p.nodes.add(r.node_id);\n if (r.fixed_version) p.versions.add(r.fixed_version);\n p.maxCvss = Math.max(p.maxCvss, r.max_cvss ?? 0);\n p.ransomware = p.ransomware || r.ransomware;\n }\n for (const [pkg, p] of linuxKev) {\n fixes.push({\n label: pkg, nodes: p.nodes.size, versions: [...p.versions].sort(),\n maxCvss: p.maxCvss, ransomware: p.ransomware,\n });\n }\n fixes.sort((a, b) => b.nodes - a.nodes || a.label.localeCompare(b.label));\n\n // The panel's frame is known-exploited first \u2014 but a tenant with few\n // or no KEV findings still deserves an action list, so remaining\n // slots (the panel shows 5) fill with the highest-severity non-KEV\n // fixes across both sources, always AFTER every KEV entry. Linux rows\n // qualify on exactly-attributed severity only; an all-advisory row\n // has no honest score to rank by.\n if (fixes.length < 5) {\n type cand = { label: string; nodes: Set; versions: Set; maxCvss: number };\n const byLabel = new Map();\n const add = (label: string, node: string, version: string, cvss: number): void => {\n let c = byLabel.get(label);\n if (!c) { c = { label, nodes: new Set(), versions: new Set(), maxCvss: 0 }; byLabel.set(label, c); }\n c.nodes.add(node);\n if (version) c.versions.add(version);\n c.maxCvss = Math.max(c.maxCvss, cvss);\n };\n for (const r of items) {\n if (!r.known_exploited && (r.max_cvss ?? 0) > 0) {\n add(r.product_name, r.node_id, r.file_version, r.max_cvss ?? 0);\n }\n }\n for (const r of vendorRows) {\n if (r.node_id && !r.known_exploited && (r.max_cvss ?? 0) > 0) {\n add(r.product, r.node_id, r.version, r.max_cvss ?? 0);\n }\n }\n for (const r of linuxRows) {\n if (!r.known_exploited && r.exact_count > 0 && (r.max_cvss ?? 0) > 0) {\n add(r.package, r.node_id, r.fixed_version, r.max_cvss ?? 0);\n }\n }\n const fill = [...byLabel.values()]\n .sort((a, b) => b.maxCvss - a.maxCvss || b.nodes.size - a.nodes.size || a.label.localeCompare(b.label))\n .slice(0, 5 - fixes.length)\n .map(c => ({\n label: c.label, nodes: c.nodes.size,\n versions: [...c.versions].sort().slice(0, 6),\n maxCvss: c.maxCvss, ransomware: false,\n }));\n fixes.push(...fill);\n }\n return fixes;\n }\n\n // Priority fixes \u2014 the KEV products ranked by how many nodes patching\n // each one clears, with the versions actually observed. This is the\n // summary's action list: the same reduction as the products donut, in\n // the frame an operator schedules work in.\n private priorityFixes(items: ExposureRow[]): {\n label: string; nodes: number; versions: string[]; maxCvss: number; ransomware: boolean;\n }[] {\n const byProduct = new Map; versions: Set; maxCvss: number; ransomware: boolean }>();\n for (const r of items) {\n if (!r.known_exploited) continue;\n let p = byProduct.get(r.product_name);\n if (!p) {\n p = { nodes: new Set(), versions: new Set(), maxCvss: 0, ransomware: false };\n byProduct.set(r.product_name, p);\n }\n p.nodes.add(r.node_id);\n if (r.file_version) p.versions.add(r.file_version);\n p.maxCvss = Math.max(p.maxCvss, r.max_cvss ?? 0);\n p.ransomware = p.ransomware || r.ransomware;\n }\n return [...byProduct.entries()]\n .map(([label, p]) => ({\n label, nodes: p.nodes.size, versions: [...p.versions].sort(),\n maxCvss: p.maxCvss, ransomware: p.ransomware,\n }))\n .sort((a, b) => b.nodes - a.nodes || a.label.localeCompare(b.label));\n }\n\n private renderPriorityFixes(items: ExposureRow[], osRows: OsGapRow[], linuxRows: LinuxPkgRow[], vendorRows: VendorRow[] = []): void {\n const host = document.getElementById('mpPriorityFixes');\n if (!host) return;\n const fixes = this.allFixes(items, osRows, linuxRows, vendorRows);\n const title = `
${this.esc(t2('missingPatches.sum.fixes.title'))}
`;\n if (!fixes.length) {\n host.innerHTML = `${title}
${this.esc(t2('missingPatches.sum.fixes.empty'))}
`;\n return;\n }\n const rows = fixes.slice(0, 5).map((f, i) => {\n const versions = f.versions.slice(0, 3).join(', ')\n + (f.versions.length > 3 ? ` +${f.versions.length - 3}` : '');\n const ransom = f.ransomware\n ? ` ${this.esc(t2('missingPatches.sum.fixes.ransom'))}`\n : '';\n return `
\n ${i + 1}\n ${this.esc(f.label)}${ransom}\n ${this.esc(tn('missingPatches.sum.fixes.clears', f.nodes))}\n ${this.esc(versions)} \u00B7 CVSS ${this.esc(f.maxCvss.toFixed(1))}\n
`;\n }).join('');\n host.innerHTML = title + rows;\n }\n\n // Donut datasets, shared by the on-screen summary and the export so the\n // report shows exactly the charts the page does.\n\n // Known-exploited products by distinct node.\n private kevProductRows(items: ExposureRow[]): { label: string; count: number }[] {\n const kevNodes = new Map>();\n for (const r of items) {\n if (!r.known_exploited) continue;\n if (!kevNodes.has(r.product_name)) kevNodes.set(r.product_name, new Set());\n kevNodes.get(r.product_name)!.add(r.node_id);\n }\n return [...kevNodes.entries()].map(([label, s]) => ({ label, count: s.size }));\n }\n\n // Nodes by their worst severity band, across BOTH exposure kinds \u2014 app\n // rows and the Windows update gap \u2014 so the donut covers the same node\n // union as the \"nodes with CVE exposure\" card and their totals agree.\n private severitySegs(items: ExposureRow[], osRows: OsGapRow[], linuxRows: LinuxPkgRow[], vendorRows: VendorRow[] = []): SCDonutSegment[] {\n const worst = new Map();\n for (const r of items) {\n worst.set(r.node_id, Math.max(worst.get(r.node_id) ?? 0, r.max_cvss ?? 0));\n }\n // Vendor scores are the vendor's own scale; they tint the node's\n // band only when tied to a Mimic node, like the unions above.\n for (const r of vendorRows) {\n if (!r.node_id) continue;\n worst.set(r.node_id, Math.max(worst.get(r.node_id) ?? 0, r.max_cvss ?? 0));\n }\n for (const r of osRows) {\n worst.set(r.nodeId, Math.max(worst.get(r.nodeId) ?? 0, r.maxCvss ?? 0));\n }\n // Linux max_cvss is computed from exactly-attributed CVEs only, so a\n // module erratum's borrowed 9.8 can never tint a node critical here.\n for (const r of linuxRows) {\n worst.set(r.node_id, Math.max(worst.get(r.node_id) ?? 0, r.max_cvss ?? 0));\n }\n const sev = { critical: 0, high: 0, medium: 0, low: 0 };\n for (const v of worst.values()) {\n if (v >= 9) sev.critical++; else if (v >= 7) sev.high++;\n else if (v >= 4) sev.medium++; else sev.low++;\n }\n return [\n { label: t2('missingPatches.sev.critical'), value: sev.critical, color: '#EF4444' },\n { label: t2('missingPatches.sev.high'), value: sev.high, color: '#F59E0B' },\n { label: t2('missingPatches.sev.medium'), value: sev.medium, color: '#FBBF24' },\n { label: t2('missingPatches.sev.low'), value: sev.low, color: '#10B981' },\n ];\n }\n\n // Linux package updates by severity band. The band comes from\n // exactly-attributed CVEs only; rows whose CVEs all arrived advisory-wide\n // get their own segment rather than a fabricated severity \u2014 on a module-\n // heavy fleet that segment is the honest majority, not noise.\n private linuxSegs(linuxRows: LinuxPkgRow[]): SCDonutSegment[] {\n const sev = { critical: 0, high: 0, medium: 0, low: 0, advisory: 0 };\n for (const r of linuxRows) {\n if (r.exact_count === 0) { sev.advisory++; continue; }\n const v = r.max_cvss ?? 0;\n if (v >= 9) sev.critical++; else if (v >= 7) sev.high++;\n else if (v >= 4) sev.medium++; else sev.low++;\n }\n return [\n { label: t2('missingPatches.sev.critical'), value: sev.critical, color: '#EF4444' },\n { label: t2('missingPatches.sev.high'), value: sev.high, color: '#F59E0B' },\n { label: t2('missingPatches.sev.medium'), value: sev.medium, color: '#FBBF24' },\n { label: t2('missingPatches.sev.low'), value: sev.low, color: '#10B981' },\n { label: t2('missingPatches.sev.advisoryScoped'), value: sev.advisory, color: '#94A3B8' },\n ];\n }\n\n // Windows nodes bucketed by how far behind the estimate puts them.\n private gapSegs(osRows: OsGapRow[]): SCDonutSegment[] {\n const buckets = [0, 0, 0, 0];\n for (const r of osRows) {\n if (r.monthsBehind <= 3) buckets[0]++;\n else if (r.monthsBehind <= 6) buckets[1]++;\n else if (r.monthsBehind <= 12) buckets[2]++;\n else buckets[3]++;\n }\n return [\n { label: t2('missingPatches.gap.short'), value: buckets[0], color: '#FBBF24' },\n { label: t2('missingPatches.gap.medium'), value: buckets[1], color: '#F59E0B' },\n { label: t2('missingPatches.gap.long'), value: buckets[2], color: '#EF4444' },\n { label: t2('missingPatches.gap.severe'), value: buckets[3], color: '#991B1B' },\n ];\n }\n\n private renderDonutInto(containerId: string, title: string, segments: SCDonutSegment[], centerLabel?: string): void {\n const host = document.getElementById(containerId);\n if (!host) return;\n const segs = segments.filter(s => s.value > 0);\n host.innerHTML = `\n
${this.esc(title)}
\n ${segs.length === 0\n ? `
${this.esc(t2('body.noData'))}
`\n : `
\n
\n \n
\n
\n
\n
`}`;\n if (!segs.length) return;\n const canvas = host.querySelector('canvas') as HTMLCanvasElement;\n const center = host.querySelector('.sc-donut-center') as HTMLElement;\n const legend = host.querySelector('.sc-donut-legend') as HTMLElement;\n this.charts.push(renderDonut(canvas, center, legend, segs, {\n centerLabel: centerLabel ?? t2('missingPatches.sum.centerNodes'),\n }));\n }\n\n // \u2500\u2500 Export \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n private placeExportControl(): void {\n const container = document.getElementById('page-options');\n if (!container) return;\n // One export menu at a time: the page swaps between the tenant report\n // (this one) and the fleet report as the All Tenants toggle flips.\n document.getElementById('cveFleetExportBtn')?.parentElement?.remove();\n mountFullExportMenu(container, 'missingPatchesExport', (a) => this.runExport(a));\n // Export sits on its own row above the scope controls: first in the\n // options bar, followed by a flex line break so the checkboxes wrap\n // under it, right-aligned like everything else in the bar.\n container.querySelector('[data-mp-export-break]')?.remove();\n const wrap = document.getElementById('missingPatchesExportBtn')?.parentElement;\n if (wrap && wrap.parentElement === container) {\n container.prepend(wrap);\n const br = document.createElement('div');\n br.setAttribute('data-mp-export-break', '1');\n br.style.flexBasis = '100%';\n br.style.height = '0';\n wrap.after(br);\n }\n }\n\n private async runExport(action: (doc: ReportDoc) => Promise): Promise {\n if (!this.tenantId || !this.lastBody) {\n this.header.showMessage(t2('body.nothingToExportYet'), 'warning', 4);\n return;\n }\n const spin = this.header.showLoading(t2('body.buildingReport'));\n try {\n const branding = await fetchReportBranding(this.tenantId);\n const now = new Date();\n const doc: ReportDoc = {\n title: t('nav.missingPatches'),\n subtitle: t2('missingPatches.sum.sub', {\n affected: this.exposureUnions(this.lastItems, this.lastOsRows, this.lastLinuxRows, this.lastVendorRows).affected,\n total: this.rosterTotal,\n kev: this.exposureUnions(this.lastItems, this.lastOsRows, this.lastLinuxRows, this.lastVendorRows).kev,\n win: this.lastOsRows.length,\n }),\n generatedAt: now,\n branding,\n sections: await this.reportSections(this.lastBody, this.lastItems, this.lastOsRows, this.lastLinuxRows, this.lastVendorRows),\n filenameBase: `missing-patches-report-${now.toISOString().slice(0, 10)}`,\n };\n await action(doc);\n } catch (err) {\n console.error('[MissingPatchesUI] export failed', err);\n this.header.showMessage(t2('body.exportFailed', { error: escapeHtml(err instanceof Error ? err.message : String(err)) }), 'error', 8);\n } finally {\n this.header.hideLoading(spin);\n }\n }\n\n private async reportSections(body: ExposureResponse, items: ExposureRow[], osRows: OsGapRow[], linuxRows: LinuxPkgRow[], vendorRows: VendorRow[] = []): Promise {\n const sections: ReportSection[] = [];\n const ransomwareRows = items.filter(r => r.ransomware).length\n + osRows.filter(r => r.ransomware).length\n + linuxRows.filter(r => r.ransomware).length\n + vendorRows.filter(r => r.ransomware).length;\n const sourcesText = (ids: string[] | undefined): string => sortSources(ids ?? []).map(sourceLabel).join(', ');\n // The page's partial notice (degraded lanes), so a failed lane never\n // reads as a whole answer in the report either:\n // under the tiles, and ahead of the Windows table's coverage note.\n const partialLines = partialNoticeLines(body);\n const partial = partialLines.join(' ');\n\n // At a glance: the same four tiles the summary panel shows.\n sections.push({\n heading: t2('body.secAtAGlance'),\n kpis: [\n { label: t('missingPatches.card.nodesAffected'), value: String(this.exposureUnions(items, osRows, linuxRows, vendorRows).affected), valueColor: '#F59E0B' },\n { label: t('missingPatches.card.nodesKev'), value: String(this.exposureUnions(items, osRows, linuxRows, vendorRows).kev), valueColor: '#EF4444' },\n { label: t('missingPatches.card.kevProducts'), value: String(this.kevProductRows(items).length), valueColor: '#DC2626' },\n { label: t('missingPatches.card.windowsBehind'), value: String(osRows.length), valueColor: '#8B5CF6' },\n { label: t('missingPatches.card.linuxBehind'), value: String(new Set(linuxRows.map(r => r.node_id)).size), valueColor: '#14B8A6' },\n ...(this.vendorReportedCount(items, osRows, vendorRows) ? [{ label: t('missingPatches.card.vendor'), value: String(this.vendorReportedCount(items, osRows, vendorRows)), valueColor: '#0EA5E9' }] : []),\n { label: t('missingPatches.card.ransomware'), value: String(ransomwareRows), valueColor: '#EC4899' },\n ],\n // The page's partial notice: a failed lane must not export as a\n // complete one.\n ...(partialLines.length ? { note: partialLines.join(' ') } : {}),\n });\n\n // The three summary donuts, rebuilt from the same datasets that feed\n // the on-screen charts \u2014 never scraped off the page, so they export\n // identically whether the panel is expanded or collapsed. The\n // severity/gap donuts carry fixed status colors, so they render via\n // donutSvg directly rather than the palette-assigning helper.\n const centerLabel = t2('missingPatches.sum.centerNodes');\n const coloredDonut = async (segs: SCDonutSegment[], caption: string): Promise => {\n const live = segs.filter(s => s.value > 0);\n if (!live.length) return null;\n const svg = donutSvg(live, { centerLabel, size: 150 });\n if (!svg) return null;\n const shot = await captureSvgMarkupPng(svg, { scale: 3 });\n return shot ? { png: shot.png, aspect: shot.aspect, caption } : null;\n };\n const images = (await Promise.all([\n donutImageFromRows(this.kevProductRows(items),\n { caption: t2('missingPatches.sum.kevProducts'), centerLabel: t2('missingPatches.sum.centerKevProducts'), topN: 6 }),\n coloredDonut(this.severitySegs(items, osRows, linuxRows, vendorRows), t2('missingPatches.sum.severity')),\n coloredDonut(this.gapSegs(osRows), t2('missingPatches.sum.winGap')),\n linuxRows.length\n ? coloredDonut(this.linuxSegs(linuxRows), t2('missingPatches.sum.linux'))\n : Promise.resolve(null),\n ])).filter((i): i is ReportImage => !!i);\n if (images.length) {\n sections.push({ heading: t2('body.summary'), images });\n }\n\n // Priority fixes, same reduction as the on-screen panel.\n const fixes = this.allFixes(items, osRows, linuxRows, vendorRows);\n if (fixes.length) {\n sections.push({\n heading: t2('missingPatches.sum.fixes.title'),\n table: {\n headers: [\n t2('missingPatches.col.product'), t2('missingPatches.sum.fixes.nodesCol'),\n t2('missingPatches.col.version'), t2('missingPatches.col.maxCvss'),\n t2('missingPatches.col.ransomware'),\n ],\n rows: fixes.map(f => [\n f.label, String(f.nodes), f.versions.join(', '), f.maxCvss.toFixed(1),\n f.ransomware ? t2('missingPatches.ransomware.yes') : t2('missingPatches.ransomware.no'),\n ]),\n rightAlign: [1, 3],\n maxRows: 0,\n totalRows: fixes.length,\n noCsvExport: true,\n },\n });\n }\n\n // Upgrade analyses: the model's verdicts for the analyzed products,\n // same rows the on-screen panel lists.\n const analyzed = this.analysisList().filter(a => a.status === 'completed' && a.result);\n if (analyzed.length) {\n sections.push({\n heading: t2('missingPatches.ana.title'),\n table: {\n headers: [\n t2('missingPatches.col.product'), t2('missingPatches.ana.col.action'),\n t2('missingPatches.ana.col.verdict'), t2('missingPatches.ana.col.clearedBy'),\n ],\n maxRows: 0,\n noCsvExport: true,\n rows: analyzed.map(a => [\n a.productName,\n t2(`missingPatches.ana.action.${a.result!.action === 'remove_stale' ? 'remove'\n : a.result!.action === 'upgrade_and_remove' ? 'both' : 'upgrade'}`),\n a.result!.headline,\n a.snapshot?.minClearingVersion ?? '',\n ]),\n totalRows: analyzed.length,\n },\n });\n }\n\n // Every table, same columns and order as on screen, with every row in\n // the paged formats (maxRows: 0; the slide keeps its own cap and the\n // \"Top N of M\" note).\n sections.push({\n heading: t2('missingPatches.apps.title'),\n table: {\n headers: [\n t2('missingPatches.col.node'), t2('missingPatches.col.nodeVersion'), t2('missingPatches.col.product'),\n t2('missingPatches.col.version'), t2('vulnSources.col.source'), t2('missingPatches.col.cves'),\n t2('missingPatches.col.kev'), t2('missingPatches.col.maxCvss'),\n t2('missingPatches.col.topCves'), t2('missingPatches.col.ransomware'),\n ...(this.vpByCve ? [t2('missingPatches.col.vp')] : []),\n t2('missingPatches.col.asOf'),\n ],\n maxRows: 0,\n rows: items.map(r => [\n r.hostname || r.node_id,\n r.node_version ?? '',\n r.company_name ? `${r.product_name} (${r.company_name})` : r.product_name,\n r.fix ? `${r.file_version} (${t2('missingPatches.col.fix')} ${r.fix})` : r.file_version,\n sourcesText(r.sources ?? ['mimic']),\n String(r.cve_count),\n r.known_exploited\n ? `${r.known_exploited_cve_count || ''} ${t2('missingPatches.kev.yes')}`.trim()\n : t2('missingPatches.kev.no'),\n (r.max_cvss ?? 0).toFixed(1),\n (r.top_cves ?? []).join(', '),\n r.ransomware ? t2('missingPatches.ransomware.yes') : t2('missingPatches.ransomware.no'),\n ...(this.vpByCve ? [this.vpText(r)] : []),\n asOfFull(r.last_seen) || '\u2014',\n ]),\n rightAlign: [5, 7],\n totalRows: items.length,\n },\n note: t2('missingPatches.note.verdict'),\n });\n sections.push({\n heading: t2('missingPatches.os.title'),\n table: {\n headers: [\n t2('missingPatches.col.node'), t2('missingPatches.col.nodeVersion'),\n t2('missingPatches.col.build'), t2('vulnSources.col.source'),\n t2('missingPatches.col.monthsBehind'), t2('missingPatches.col.missingFixes'),\n t2('missingPatches.col.kevFixes'), t2('missingPatches.col.maxCvss'),\n t2('missingPatches.col.ransomware'), t2('missingPatches.col.asOf'),\n ],\n maxRows: 0,\n rows: osRows.map(r => [\n r.hostname,\n r.nodeVersion ?? '',\n r.source === 'estimated' ? `${r.build} ${t2('missingPatches.build.est')}` : r.build,\n this.osSourceText(r),\n String(r.monthsBehind), String(r.missingCves),\n String(r.missingKev), (r.maxCvss ?? 0).toFixed(1),\n r.ransomware ? t2('missingPatches.ransomware.yes') : t2('missingPatches.ransomware.no'),\n [asOfFull(r.asOf) || '\u2014', r.wuaStale ? `(${t2('missingPatches.asOf.wuaStalePill')})` : ''].filter(Boolean).join(' '),\n ]),\n rightAlign: [4, 5, 6, 7],\n totalRows: osRows.length,\n },\n // The coverage copy carries \\n (one sentence per line on screen);\n // the report note is a single paragraph.\n note: `${partial} ${t2('missingPatches.note.coverage').replace(/\\s*\\n\\s*/g, ' ')} ${body.attribution ?? ''}`.trim(),\n });\n if (linuxRows.length) {\n sections.push({\n heading: t2('missingPatches.linux.title'),\n table: {\n headers: [\n t2('missingPatches.col.node'), t2('missingPatches.col.nodeVersion'), t2('missingPatches.col.package'),\n t2('missingPatches.col.distribution'), t2('vulnSources.col.source'), t2('missingPatches.col.installed'),\n t2('missingPatches.col.updateTo'), t2('missingPatches.col.cves'),\n t2('missingPatches.col.kev'), t2('missingPatches.col.maxCvss'),\n t2('missingPatches.col.ransomware'), t2('missingPatches.col.asOf'),\n ],\n maxRows: 0,\n rows: linuxRows.map(r => [\n r.hostname || r.node_id, r.node_version ?? '', r.package, r.ecosystem, sourcesText(r.sources ?? ['osv']), r.installed,\n r.fixed_version || t2('missingPatches.ana.noBoundary'),\n String(r.cve_count),\n r.known_exploited\n ? `${r.known_exploited_cve_count || ''} ${t2('missingPatches.kev.yes')}`.trim()\n : t2('missingPatches.kev.no'),\n r.exact_count > 0 ? (r.max_cvss ?? 0).toFixed(1) : '\u2014',\n r.ransomware ? t2('missingPatches.ransomware.yes') : t2('missingPatches.ransomware.no'),\n asOfFull(r.scanned_at) || '\u2014',\n ]),\n rightAlign: [7, 9],\n totalRows: linuxRows.length,\n },\n note: t2('missingPatches.note.linux'),\n });\n }\n if (vendorRows.length) {\n sections.push({\n heading: t2('missingPatches.vendor.title'),\n table: {\n headers: [\n t2('missingPatches.col.node'), t2('missingPatches.col.nodeVersion'), t2('missingPatches.col.product'),\n t2('missingPatches.col.version'), t2('vulnSources.col.source'),\n t2('missingPatches.col.cves'), t2('missingPatches.col.kev'),\n t2('missingPatches.col.vendorScore'), t2('missingPatches.col.topCves'),\n t2('missingPatches.col.fix'), t2('missingPatches.col.ransomware'), t2('missingPatches.col.asOf'),\n ],\n maxRows: 0,\n rows: vendorRows.map(r => [\n r.node_id ? (r.hostname || r.node_id) : `${r.hostname} (${t2('vulnSources.noNode')})`,\n r.node_version ?? '',\n r.product, r.version, sourcesText(r.sources),\n String(r.cve_count),\n r.known_exploited\n ? `${r.known_exploited_cve_count || ''} ${t2('missingPatches.kev.yes')}`.trim()\n : t2('missingPatches.kev.no'),\n (r.max_cvss ?? 0) > 0 ? (r.max_cvss ?? 0).toFixed(1) : '\u2014',\n (r.top_cves ?? []).join(', '),\n r.fix || '',\n r.ransomware ? t2('missingPatches.ransomware.yes') : t2('missingPatches.ransomware.no'),\n asOfFull(r.last_seen) || '\u2014',\n ]),\n rightAlign: [5, 7],\n totalRows: vendorRows.length,\n },\n note: t2('missingPatches.note.vendor'),\n });\n }\n return sections;\n }\n\n /**\n * One Windows-lane row from a node's summary, or null when the node has\n * no claim under the page's source filter. Mirrors the server's\n * filterOSSources: the Mimic lane (msrc = the reported or estimated\n * patch-level gap, wua = the Windows Update Agent's verdict) and the\n * vendor OS lane (ENG-4374) are filtered separately, and when the Mimic\n * lane is filtered out a surviving vendor lane takes the row over. A wua\n * node is a row when the agent lists anything missing at all \u2014 MSRC may\n * have no CVE for a missing SQL Server or .NET update, and \"missing\" is\n * still the fact.\n */\n private osRowFromSummary(nodeId: string, hostname: string, s: NodeCveSummary, sourceSel: string[]): OsGapRow | null {\n const any = sourceSel.length === 0;\n const vendorSources = (s.os_vendor_sources ?? []).filter(id => any || sourceSel.includes(id));\n const vendorCves = vendorSources.length ? (s.os_vendor_cves ?? 0) : 0;\n const mimicLane = s.os_patch_source === 'wua' ? 'wua'\n : s.os_patch_source === 'reported' || s.os_patch_source === 'estimated' ? 'msrc' : '';\n const mimicClaims = mimicLane !== '' && (any || sourceSel.includes(mimicLane))\n && ((s.os_missing_cves ?? 0) > 0 || (s.os_months_behind ?? 0) > 0\n || (mimicLane === 'wua' && (s.os_missing_kbs?.length ?? 0) > 0));\n if (!mimicClaims && vendorCves === 0) return null;\n const vendorOnly = !mimicClaims;\n const build = s.os_build ?? 0;\n const buildText = build > 0 && (s.os_ubr ?? 0) > 0 ? `${build}.${s.os_ubr}`\n : build > 0 ? String(build)\n : s.os_vendor_product || '?';\n return {\n nodeId,\n hostname,\n build: vendorOnly ? buildText : `${s.os_build ?? '?'}.${s.os_ubr ?? '?'}`,\n buildNum: build,\n ubr: vendorOnly ? 0 : (s.os_ubr ?? 0),\n source: vendorOnly ? 'vendor'\n : mimicLane === 'wua' ? 'wua'\n : s.os_patch_source === 'reported' ? 'reported' : 'estimated',\n missingKbs: vendorOnly ? [] : (s.os_missing_kbs ?? []),\n monthsBehind: vendorOnly ? (s.os_vendor_months_behind ?? 0) : (s.os_months_behind ?? 0),\n missingCves: vendorOnly ? vendorCves : (s.os_missing_cves ?? 0),\n missingKev: vendorOnly ? (s.os_vendor_kev ?? 0) : (s.os_missing_kev ?? 0),\n maxCvss: vendorOnly ? (s.os_vendor_max_cvss ?? 0) : (s.os_max_cvss ?? 0),\n ransomware: vendorOnly ? !!s.os_vendor_ransomware : !!s.os_ransomware,\n vendorSources,\n vendorProduct: s.os_vendor_product ?? '',\n vendorVersion: s.os_vendor_version ?? '',\n vendorCves,\n vendorKev: vendorSources.length ? (s.os_vendor_kev ?? 0) : 0,\n vendorMaxCvss: vendorSources.length ? (s.os_vendor_max_cvss ?? 0) : 0,\n vendorMonthsBehind: vendorSources.length ? (s.os_vendor_months_behind ?? 0) : 0,\n vendorFix: vendorSources.length ? (s.os_vendor_fix ?? '') : '',\n asOf: vendorOnly ? s.os_vendor_last_seen : s.os_data_at,\n wuaStale: !vendorOnly && !!s.os_wua_stale,\n wuaSearchedAt: s.os_wua_searched_at,\n };\n }\n\n /** The OS row's As of tooltip: which read the time is, and, when the\n * agent's search predates the node's patch level, that its updates\n * installed since are not counted. */\n private osAsOfTip(r: OsGapRow): string {\n const label = r.source === 'wua' ? t2('missingPatches.asOf.wua')\n : r.source === 'vendor' ? t2('missingPatches.asOf.vendor')\n : r.source === 'reported' ? t2('missingPatches.asOf.reported') : '';\n const parts = [label && r.asOf ? t2('missingPatches.asOf.tip', { label, time: asOfFull(r.asOf) }) : asOfFull(r.asOf)];\n if (r.wuaStale) parts.push(this.wuaStaleText(r));\n return parts.filter(Boolean).join(' ');\n }\n\n private wuaStaleText(r: OsGapRow): string {\n return t2('missingPatches.asOf.wuaStale', { time: asOfFull(r.wuaSearchedAt) || '\u2014' });\n }\n\n /** The OS row's As of cell: the time, and a marker when the agent's\n * search predates the node's patch level. */\n private osAsOfCell(r: OsGapRow, type: string): string | number {\n const cell = asOfRender(r.asOf, type, this.osAsOfTip(r));\n if (type !== 'display' || !r.wuaStale) return cell;\n return `${cell} ${this.esc(t2('missingPatches.asOf.wuaStalePill'))}`;\n }\n\n /** The drawer's As of line for the row it was opened from. */\n private drawerAsOfHtml(src: CveDrawerSource): string {\n let iso: string | undefined;\n let label = '';\n let stale = '';\n switch (src.kind) {\n case 'app': {\n // last_seen is the node's run, a scanner's sighting (a row\n // with no Mimic lane), or the later of the two (folded).\n iso = src.app.last_seen;\n const lanes = src.app.sources ?? ['mimic'];\n label = !lanes.includes('mimic') ? t2('missingPatches.asOf.vendor')\n : lanes.length > 1 ? t2('missingPatches.asOf.newest') : t2('missingPatches.asOf.app');\n break;\n }\n case 'linux': iso = src.pkg.scanned_at; label = t2('missingPatches.asOf.linux'); break;\n case 'vendor': iso = src.vendor.last_seen; label = t2('missingPatches.asOf.vendor'); break;\n case 'os':\n iso = src.os.asOf;\n label = src.os.source === 'wua' ? t2('missingPatches.asOf.wua')\n : src.os.source === 'vendor' ? t2('missingPatches.asOf.vendor')\n : src.os.source === 'reported' ? t2('missingPatches.asOf.reported') : '';\n if (src.os.wuaStale) stale = this.wuaStaleText(src.os);\n break;\n }\n const when = iso && asOfFull(iso)\n ? t2('missingPatches.asOf.line', { label: label || t2('missingPatches.col.asOf'), time: asOfFull(iso), ago: relativeTime(iso) })\n : t2('missingPatches.asOf.none');\n return `

${this.esc(when)}

`\n + (stale ? `
${this.esc(stale)}
` : '');\n }\n\n /** Every lane that reported an OS-gap row, in the contract's order: the\n * Mimic lane (wua for exact verdicts, msrc for the reported and\n * estimated patch-level gaps \u2014 the \"(est.)\" marker on the build says\n * which) and then the scanners (ENG-4374). */\n private osLanes(r: OsGapRow): string[] {\n const own = r.source === 'vendor' ? [] : [r.source === 'wua' ? 'wua' : 'msrc'];\n return sortSources([...own, ...r.vendorSources]);\n }\n\n /** The OS-gap row's lanes as text (CSV and the report). */\n private osSourceText(r: OsGapRow): string {\n return this.osLanes(r).map(sourceLabel).join(', ');\n }\n\n /** Source cell for an OS-gap row: one chip per lane, with the missing\n * KBs on the tooltip for a Windows Update Agent verdict and the\n * scanners' own figures for the vendor lane. */\n private osSourceCell(r: OsGapRow, type: string): string | number {\n const cell = renderSourcesCell(this.osLanes(r), type);\n if (type !== 'display') return cell;\n const tips: string[] = [];\n if (r.source === 'wua' && r.missingKbs.length) {\n const kbs = r.missingKbs.map(k => (/^\\d+$/.test(k) ? `KB${k}` : k));\n tips.push(t2('missingPatches.os.wua.tip', { n: kbs.length, kbs: kbs.join(', ') }));\n }\n if (r.vendorSources.length) {\n tips.push(t2('missingPatches.os.vendor.tip', {\n sources: r.vendorSources.map(sourceLabel).join(', '),\n n: r.vendorCves, kev: r.vendorKev,\n product: r.vendorProduct || t2('missingPatches.sum.fixes.windows'),\n fix: r.vendorFix || '\u2014',\n }));\n }\n if (!tips.length) return cell;\n return `${cell}`;\n }\n\n private setupToggle(root: HTMLElement): void {\n if (this.toggleWired) return;\n const toggle = root.querySelector('.sc-summary-toggle');\n if (!toggle) return;\n this.toggleWired = true;\n this.applyCollapsed(root, storage.get('mpSummaryCollapsed') === true);\n toggle.onclick = () => {\n const next = !root.classList.contains('collapsed');\n storage.set('mpSummaryCollapsed', next);\n this.applyCollapsed(root, next);\n if (!next) this.lastRender?.();\n };\n }\n\n private applyCollapsed(root: HTMLElement, collapsed: boolean): void {\n root.classList.toggle('collapsed', collapsed);\n root.querySelector('.sc-summary-toggle')?.setAttribute('aria-expanded', String(!collapsed));\n }\n\n // \u2500\u2500 Tables \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n private esc(s: unknown): string {\n return String(s ?? '').replace(/&/g, '&').replace(//g, '>')\n .replace(/\"/g, '"');\n }\n\n /** The early-view badge for one CVE: a feed reports it before NVD lists it. */\n private earlyPill(): string {\n return `${this.esc(t2('missingPatches.early.one'))}`;\n }\n\n /** The early-view badge for a software row: how many of its CVEs a feed\n * reports ahead of NVD, the ids on the tooltip. Nothing when zero, so\n * the common row stays quiet. */\n private earlyCountPill(count: number | undefined, ids: string[] | null | undefined): string {\n if (!count) return '';\n const tip = `${t2('missingPatches.early.tip')} ${(ids ?? []).join(', ')}`.trim();\n return ` ${this.esc(t2('missingPatches.early.count', { count }))}`;\n }\n\n private pill(on: boolean, cls: string, yes: string, no: string, type: string): string | number {\n if (type === 'filter') return on ? yes : no;\n if (type !== 'display') return on ? 1 : 0;\n return on\n ? `${this.esc(yes)}`\n : `${this.esc(no)}`;\n }\n\n private renderAppsTable(items: ExposureRow[]): void {\n this.appsTable?.destroy();\n this.appsTable = $('#mpAppsTable').DataTable({\n data: items,\n dom: SCROLL_DOM_STRING,\n order: [], // server order is the verdict order: KEV first, then severity\n pageLength: 10,\n lengthMenu: [[10, 20, 50, 100, -1], [10, 20, 50, 100, 'All']],\n autoWidth: false,\n language: {\n emptyTable: t2('missingPatches.apps.empty'),\n zeroRecords: t2('dt.zeroRecords'), // a filter that hides every row is not the empty state\n search: '',\n },\n headerCallback: (thead) => {\n (thead as HTMLTableSectionElement).classList.add('custom-thead');\n },\n columnDefs: [\n {\n targets: [0, 1, 2, 3],\n columnControl: [['orderAsc', 'orderDesc', 'searchList']],\n },\n {\n // Pill columns (and the Source chips): searchList over\n // the 'filter' strings, not the rendered markup.\n targets: [4, 6, 9, 10, 11],\n columnControl: [['orderAsc', 'orderDesc',\n { extend: 'searchList', orthogonal: 'filter' }]],\n },\n {\n targets: [5, 7, 8, 12],\n columnControl: [['orderAsc', 'orderDesc']],\n },\n ],\n columns: [\n {\n data: 'hostname',\n render: (d: string, type: string, row: ExposureRow) => {\n if (type !== 'display') return d ?? '';\n const badge = row.inRoster === false\n ? ` ${this.esc(t2('missingPatches.notInRoster'))}`\n : '';\n return `${this.esc(d)}${badge}`;\n },\n },\n // The node's Mimic node build: a virtual patch's policy\n // compiles only for some builds, so this is what to filter\n // on. Sorted by number per part, like the Nodes page.\n {\n data: 'node_version', className: 'mp-nowrap', type: 'semver',\n render: (d: string | undefined, type: string) => (type === 'display' ? this.esc(d ?? '') : d ?? ''),\n },\n {\n data: 'product_name',\n render: (d: string, type: string, row: ExposureRow) => {\n if (type !== 'display') return d ?? '';\n const co = row.company_name ? ` ${this.esc(row.company_name)}` : '';\n return `${this.esc(d)}${co}`;\n },\n },\n {\n // The vendor's fix version rides beside the installed one\n // when a scanner's row was folded in (ENG-4374).\n data: 'file_version',\n render: (d: string, type: string, row: ExposureRow) => (\n type === 'display' && row.fix\n ? `${this.esc(d ?? '')} ${this.esc(t2('missingPatches.col.fix'))} ${this.esc(row.fix)}`\n : d ?? ''),\n },\n {\n // Provenance (ENG-4292): the Mimic node's own observation\n // and, since ENG-4374, every scanner whose row folded in.\n data: 'sources', className: 'mp-nowrap',\n render: (d: string[] | undefined, type: string) => renderSourcesCell(d ?? ['mimic'], type),\n },\n {\n // The sweep stores at most 200 CVE rows per product\n // version but persists NVD's true total alongside them\n // (cve_exposure.nvd_total_cves), so counts above the cap\n // are exact. EXACTLY 200 is the one ambiguous value: a row\n // the sweep has not re-visited since the total was added\n // sits at the cap with no total, so it still reads \"200+\".\n // Only on a Mimic-only row: a row a scanner folded into\n // (or reported alone) counts the union with the scanner's\n // CVEs, where 200 is an exact count, not the NVD cap \u2014\n // unless the node's part was at or above the cap, where\n // the overlap is unknown, which the server marks\n // (cve_count_at_least): then \"N+\". The CSV export's\n // cveCountText (api/cve_exposure_csv.go) is this rule.\n data: 'cve_count', className: 'text-right mp-nowrap',\n render: (d: number, type: string, row: ExposureRow) => {\n if (type === 'sort' || type === 'type') return d ?? 0;\n const mimicOnly = (row.sources ?? ['mimic']).every(id => id === 'mimic');\n const n = ((d ?? 0) === 200 && mimicOnly) || row.cve_count_at_least ? `${d ?? 0}+` : String(d ?? 0);\n if (type !== 'display') return n;\n return `${n}${this.earlyCountPill(row.early_cve_count, row.early_cves)}`;\n },\n },\n {\n data: 'known_exploited', className: 'mp-center',\n render: (d: boolean, type: string, row: ExposureRow) => this.pill(\n d, 'mp-pill-kev',\n type === 'display'\n ? `${row.known_exploited_cve_count || ''} ${t2('missingPatches.kev.yes')}`.trim()\n : t2('missingPatches.kev.yes'),\n t2('missingPatches.kev.no'), type),\n },\n {\n data: 'max_cvss', className: 'text-right',\n render: (d: number, type: string) => (type === 'display' ? (d ?? 0).toFixed(1) : d),\n },\n {\n data: 'top_cves',\n render: (d: string[] | null, type: string) => {\n const ids = d ?? [];\n if (type !== 'display') return ids.join(' ');\n return ids.map(id =>\n `${this.esc(id)}`,\n ).join(', ');\n },\n },\n {\n data: 'ransomware', className: 'mp-center',\n render: (d: boolean, type: string) => this.pill(\n d, 'mp-pill-ransom', t2('missingPatches.ransomware.yes'), t2('missingPatches.ransomware.no'), type),\n },\n {\n // Virtual patch: the row's top CVEs joined against the MP\n // catalog. Hidden below when the env doesn't serve it.\n // A data key no row carries, so the saved column layout\n // (keyed by data, else by position) follows the column.\n data: 'vp', defaultContent: '', className: 'mp-center',\n render: (_d: unknown, type: string, row: ExposureRow) => {\n const s = this.vpState(row);\n if (type === 'filter') {\n // top_cves is capped at five: past it, a row with\n // nothing in the catalog is \"none in the top\n // five\", not \"none\" (its CVE drawer joins them all).\n const capped = (row.cve_count ?? 0) > (row.top_cves?.length ?? 0);\n return s.avail.length ? t2('missingPatches.vp.available')\n : s.requested.length ? t2('missingPatches.vp.requested')\n : capped ? t2('missingPatches.vp.noneInTop') : t2('missingPatches.vp.none');\n }\n if (type !== 'display') return s.avail.length ? 2 : s.requested.length ? 1 : 0;\n const state = s.avail.length ? 'available' : s.requested.length ? 'requested' : 'requestable';\n const cves = (state === 'available' ? s.avail : s.requested).join(', ');\n // Staff: one control, and since the workflow runs per\n // CVE it opens the row's CVE drawer, where each CVE\n // has its own.\n if (s.startable.length && row.node_id && this.canRequestVp()) {\n return this.vpControl(state, 'mp-vp-open', state === 'requestable'\n ? t2('missingPatches.vpw.openTip') : this.vpTip(state, cves));\n }\n return this.vpPill(state, cves);\n },\n },\n {\n // LLM upgrade analysis (ENG-3837): per PRODUCT, so every\n // row of a product shares one state. View when one\n // exists; Analyze fires the on-demand run. Keyed like\n // the Virtual patch column above.\n data: 'analysis', defaultContent: '', className: 'mp-center',\n render: (_d: unknown, type: string, row: ExposureRow) => {\n // A scanner-only row has no executables behind it \u2014\n // nothing for the upgrade analysis to read.\n if (!(row.sources ?? ['mimic']).includes('mimic')) return '';\n const state = this.analysisCellState(row);\n if (type === 'filter') return t2(`missingPatches.ana.state.${state}`);\n if (type !== 'display') return state;\n const product = this.esc(row.product_name ?? '');\n switch (state) {\n case 'analyzed':\n return ``;\n case 'running':\n return ``\n + `hourglass_top`;\n case 'failed':\n return ``;\n default:\n return ``;\n }\n },\n },\n {\n // How old the claim is (utils/AsOf; the server CSV's\n // asOfCell and asOfSort match it).\n data: 'last_seen', type: 'num',\n render: (d: string | undefined, type: string) => asOfRender(d, type),\n },\n ],\n });\n this.appsTable.column(10).visible(!!this.vpByCve);\n this.wireSourceAnyOf('mpAppsTable', this.appsTable, 4);\n $('#mpAppsTable tbody').off('click.mpvp').on('click.mpvp', 'button.mp-vp-open', (ev: any) => {\n const tr = (ev.currentTarget as HTMLElement).closest('tr');\n const data = tr ? this.appsTable?.row(tr).data() as ExposureRow | undefined : undefined;\n if (data) void this.openCveDrawer({ kind: 'app', app: data });\n });\n $('#mpAppsTable tbody').off('click.mpana').on('click.mpana', 'button.mp-ana-open', (ev: any) => {\n const btn = ev.currentTarget as HTMLButtonElement;\n void this.openPatchAnalysis(btn.dataset.product || '', btn.classList.contains('mp-ana-retry'));\n });\n setupDataTableUI('mpAppsTable', this.appsTable, {\n columnControl: {\n dropdownMaterialIcon: 'filter_list',\n scrollHeadThClass: 'filter-right',\n fixHeaderLayout: true,\n cascadeSearchLists: true,\n },\n autoResize: { namespace: 'missing-patches-apps' },\n });\n // \"CSV File\" download, top-right of the table \u2014 exports the\n // currently-filtered rows, same as the other table pages.\n installCsvActionBar('mpAppsTable', () => void this.exportAppsCsv());\n // Whole-row drill-through into the CVE drawer (Tenant Profiling\n // row-click contract). Clicks landing on the row's own interactive\n // elements \u2014 CVE links, Virtual patch, Analyze \u2014 keep their meaning.\n $('#mpAppsTable tbody').off('click.mpcved').on('click.mpcved', 'tr', (ev: any) => {\n if ((ev.target as HTMLElement).closest('a, button, input, label')) return;\n const data = this.appsTable?.row(ev.currentTarget as HTMLElement).data() as ExposureRow | undefined;\n if (data) void this.openCveDrawer({ kind: 'app', app: data });\n });\n }\n\n // Textual virtual-patch state for exports \u2014 same join the column renders.\n private vpText(r: ExposureRow): string {\n const s = this.vpState(r);\n return s.avail.length ? t2('missingPatches.vp.available')\n : s.requested.length ? t2('missingPatches.vp.requested') : '';\n }\n\n // A cell's leading = + - @ is defused (csvFormulaSafe): product names\n // and hostnames come off the tenant's nodes.\n private csvEsc(v: string): string {\n return csvCell(csvFormulaSafe((v ?? '').replace(/\\r?\\n/g, ' ')));\n }\n\n /**\n * The Software table's CSV. The export is the server's: the same rows,\n * through this table's own filters, streamed straight to disk. The page sends\n * what only it knows \u2014 the column labels, the virtual-patch catalog and\n * the analyses the Virtual patch / Analysis columns filter on.\n */\n private async exportAppsCsv(): Promise {\n if (!this.appsTable) return;\n // NVD Terms of Use: the attribution sentence must accompany NVD data\n // wherever it appears, exports included \u2014 it closes the file.\n await this.exportExposureCsv('software', this.appsTable, [\n t2('missingPatches.col.node'), t2('missingPatches.col.nodeVersion'),\n t2('missingPatches.col.product'), t2('col.vendor'), t2('missingPatches.col.version'), t2('missingPatches.col.fix'), t2('vulnSources.col.source'),\n t2('missingPatches.col.cves'), t2('missingPatches.col.kev'), t2('missingPatches.col.maxCvss'),\n t2('missingPatches.col.topCves'), t2('missingPatches.col.ransomware'), t2('missingPatches.col.vp'),\n t2('missingPatches.col.asOf'),\n ], this.lastBody?.attribution ?? '');\n }\n\n private async exportExposureCsv(\n table: 'software' | 'vendor', dt: DataTables.Api, headers: string[], footer: string,\n ): Promise {\n if (!this.tenantId) return;\n const anaStates: Record = {};\n for (const st of ['analyzed', 'running', 'failed', 'none']) anaStates[st] = t2(`missingPatches.ana.state.${st}`);\n const sources: Record = {};\n for (const id of [...KNOWN_SOURCE_IDS, ...this.sourcesPresent]) sources[id] = sourceLabel(id);\n // Only the names the page overlays on the server's roster travel.\n const serverNames = this.lastBody?.hostnames ?? {};\n const hostnames: Record = {};\n for (const [id, h] of this.lastHostnames) if (h && h !== serverNames[id] && h !== id) hostnames[id] = h;\n // The build the page showed for every node it labelled, blank\n // included, so the export filters and sorts on what the table showed\n // even when a node was upgraded or first reported one since (the\n // server reads its roster again).\n const nodeVersions: Record = {};\n for (const id of this.lastHostnames.keys()) nodeVersions[id] = this.lastNodeVersions.get(id) ?? '';\n for (const [id, v] of this.lastNodeVersions) nodeVersions[id] = v;\n let vp: { available: string[]; requested: string[] } | null = null;\n if (this.vpByCve) {\n vp = { available: [], requested: [] };\n for (const [id, e] of this.vpByCve) (e.officialPatches + e.customPatches > 0 ? vp.available : vp.requested).push(id);\n }\n const analyses: Record = {};\n for (const [key, a] of this.analyses) {\n const st = this.analysisState(a);\n if (st !== 'none') analyses[key] = st;\n }\n const q = this.lastExposureQuery ? `?${this.lastExposureQuery.slice(1)}` : '';\n try {\n await downloadCsvNative(`/api/tenants/${this.tenantId}/cve-exposure/${table}/csv${q}`, {\n method: 'POST',\n body: {\n table: clientTableFilterState(dt, { anyOf: [4] }),\n // The columns the filter and sort indexes refer to; the\n // server refuses an export built for another layout.\n layout: (dt.columns().dataSrc() as any).toArray().map((d: unknown) => String(d)),\n headers, footer, hostnames, nodeVersions, vp, analyses,\n labels: {\n kevYes: t2('missingPatches.kev.yes'), kevNo: t2('missingPatches.kev.no'),\n ransomYes: t2('missingPatches.ransomware.yes'), ransomNo: t2('missingPatches.ransomware.no'),\n vendorExploited: t2('missingPatches.vendor.exploited'),\n vpAvailable: t2('missingPatches.vp.available'), vpRequested: t2('missingPatches.vp.requested'),\n vpNone: t2('missingPatches.vp.none'), vpNoneInTop: t2('missingPatches.vp.noneInTop'), anaStates, sources,\n noNode: t2('vulnSources.noNode'), noBoundary: t2('missingPatches.ana.noBoundary'),\n },\n },\n });\n this.header.showMessage(t2('body.csvDownloadStarted'), 'info', 5);\n } catch (err: any) {\n this.header.showMessage(t2('body.csvExportFailed', { error: escapeHtml(err?.message ?? String(err)) }), 'error', 8);\n }\n }\n\n private exportOsCsv(): void {\n if (!this.osTable) return;\n const rows = this.osTable.rows({ search: 'applied' }).data().toArray() as OsGapRow[];\n const cols: { header: string; pick: (r: OsGapRow) => string }[] = [\n { header: t2('missingPatches.col.node'), pick: r => r.hostname },\n { header: t2('missingPatches.col.nodeVersion'), pick: r => r.nodeVersion ?? '' },\n { header: t2('missingPatches.col.build'), pick: r => (r.source === 'estimated' ? `${r.build} ${t2('missingPatches.build.est')}` : r.build) },\n { header: t2('vulnSources.col.source'), pick: r => this.osSourceText(r) },\n { header: t2('missingPatches.col.monthsBehind'), pick: r => String(r.monthsBehind) },\n { header: t2('missingPatches.col.missingFixes'), pick: r => String(r.missingCves) },\n { header: t2('missingPatches.col.kevFixes'), pick: r => String(r.missingKev) },\n { header: t2('missingPatches.col.maxCvss'), pick: r => (r.maxCvss ?? 0).toFixed(1) },\n {\n header: t2('missingPatches.col.ransomware'),\n pick: r => (r.ransomware ? t2('missingPatches.ransomware.yes') : t2('missingPatches.ransomware.no')),\n },\n { header: t2('missingPatches.col.asOf'), pick: r => asOfCsv(r.asOf) },\n // The page's \"Installed since\" pill: the search that listed\n // updates installed since, by its time, or the pill's own text\n // when the report carried no search time.\n {\n header: t2('missingPatches.col.installedSinceSearch'),\n pick: r => (r.wuaStale ? asOfCsv(r.wuaSearchedAt) || t2('missingPatches.asOf.wuaStalePill') : ''),\n },\n ];\n const lines = [cols.map(c => c.header).join(',')];\n for (const r of rows) lines.push(cols.map(c => this.csvEsc(c.pick(r))).join(','));\n // The page notice's OS lane, in the export: a node missing from it\n // is unknown, not patched (utils/OsOverlay).\n if ((this.lastBody?.degraded ?? []).includes('os_estimates')) lines.push(this.csvEsc(osOverlayNoticeText()));\n const stamp = new Date().toISOString().slice(0, 10);\n triggerCsvDownload(lines.join('\\n'), `missing-patches-windows-${stamp}.csv`);\n }\n\n private renderOsTable(rows: OsGapRow[]): void {\n const empty = document.getElementById('mpOsEmpty');\n if (empty) empty.hidden = rows.length > 0;\n this.osTable?.destroy();\n this.osTable = $('#mpOsTable').DataTable({\n data: rows,\n dom: SCROLL_DOM_STRING,\n order: [[4, 'desc']],\n pageLength: 10,\n lengthMenu: [[10, 20, 50, 100, -1], [10, 20, 50, 100, 'All']],\n autoWidth: false,\n language: {\n emptyTable: t2('missingPatches.os.empty'),\n zeroRecords: t2('dt.zeroRecords'), // a filter that hides every row is not the empty state\n search: '',\n },\n headerCallback: (thead) => {\n (thead as HTMLTableSectionElement).classList.add('custom-thead');\n },\n columnDefs: [\n {\n targets: [0, 1, 2],\n columnControl: [['orderAsc', 'orderDesc', 'searchList']],\n },\n {\n targets: [3, 8],\n columnControl: [['orderAsc', 'orderDesc',\n { extend: 'searchList', orthogonal: 'filter' }]],\n },\n {\n targets: [4, 5, 6, 7, 9],\n columnControl: [['orderAsc', 'orderDesc']],\n },\n ],\n columns: [\n { data: 'hostname' },\n // The node's Mimic node build: a virtual patch's policy\n // compiles only for some builds, so this is what to filter\n // on. Sorted by number per part, like the Nodes page.\n {\n data: 'nodeVersion', className: 'mp-nowrap', type: 'semver',\n render: (d: string | undefined, type: string) => (type === 'display' ? this.esc(d ?? '') : d ?? ''),\n },\n {\n // Provenance rides the value: an \"(est.)\" suffix only on\n // estimator-derived rows; registry-reported levels and\n // Windows Update Agent verdicts show plain. Filter/sort\n // stay on the raw build string.\n data: 'build',\n render: (d: string, type: string, row: OsGapRow) => (\n type === 'display' && row.source === 'estimated'\n ? `${escapeHtml(d)} ${escapeHtml(t2('missingPatches.build.est'))}`\n : d),\n },\n {\n // The lane (ENG-4292): the patch-level gap (msrc) or the\n // Windows Update Agent's exact verdict (wua), whose\n // missing KBs ride the tooltip.\n data: 'source', className: 'mp-nowrap',\n render: (_d: string, type: string, row: OsGapRow) => this.osSourceCell(row, type),\n },\n { data: 'monthsBehind', className: 'text-right' },\n { data: 'missingCves', className: 'text-right' },\n { data: 'missingKev', className: 'text-right' },\n {\n data: 'maxCvss', className: 'text-right',\n render: (d: number, type: string) => (type === 'display' ? (d ?? 0).toFixed(1) : d),\n },\n {\n // Centred like the apps table's pill columns \u2014 the two\n // tables sit one above the other, so a badge aligned\n // differently in each reads as a mistake.\n data: 'ransomware', className: 'mp-center',\n render: (d: boolean, type: string) => this.pill(\n d, 'mp-pill-ransom', t2('missingPatches.ransomware.yes'), t2('missingPatches.ransomware.no'), type),\n },\n {\n // How old the figures are, and whether the Windows Update\n // Agent's search predates the patch level (utils/AsOf).\n data: 'asOf', type: 'num',\n render: (_d: string | undefined, type: string, row: OsGapRow) => this.osAsOfCell(row, type),\n },\n ],\n });\n setupDataTableUI('mpOsTable', this.osTable, {\n columnControl: {\n dropdownMaterialIcon: 'filter_list',\n scrollHeadThClass: 'filter-right',\n fixHeaderLayout: true,\n cascadeSearchLists: true,\n },\n autoResize: { namespace: 'missing-patches-os' },\n });\n this.wireSourceAnyOf('mpOsTable', this.osTable, 3);\n installCsvActionBar('mpOsTable', () => this.exportOsCsv());\n // Same whole-row drill-through as the apps table: the row's missing\n // fixes, one CVE per drawer row.\n $('#mpOsTable tbody').off('click.mpcved').on('click.mpcved', 'tr', (ev: any) => {\n if ((ev.target as HTMLElement).closest('a, button, input, label')) return;\n const data = this.osTable?.row(ev.currentTarget as HTMLElement).data() as OsGapRow | undefined;\n if (data) void this.openCveDrawer({ kind: 'os', os: data });\n });\n }\n\n // \u2500\u2500 Linux packages table (ENG-3964) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n private exportLinuxCsv(): void {\n if (!this.linuxTable) return;\n const rows = this.linuxTable.rows({ search: 'applied' }).data().toArray() as LinuxPkgRow[];\n const cols: { header: string; pick: (r: LinuxPkgRow) => string }[] = [\n { header: t2('missingPatches.col.node'), pick: r => r.hostname || r.node_id },\n { header: t2('missingPatches.col.nodeVersion'), pick: r => r.node_version ?? '' },\n { header: t2('missingPatches.col.package'), pick: r => r.package },\n { header: t2('missingPatches.col.distribution'), pick: r => r.ecosystem },\n { header: t2('vulnSources.col.source'), pick: r => sortSources(r.sources ?? ['osv']).map(sourceLabel).join('; ') },\n {\n header: t2('missingPatches.col.installed'),\n pick: r => (r.installed_versions ?? [r.installed]).join(' '),\n },\n { header: t2('missingPatches.col.updateTo'), pick: r => r.fixed_version || t2('missingPatches.ana.noBoundary') },\n { header: t2('missingPatches.col.cves'), pick: r => String(r.cve_count) },\n { header: t2('missingPatches.col.kev'), pick: r => String(r.known_exploited_cve_count) },\n {\n header: t2('missingPatches.col.maxCvss'),\n pick: r => (r.exact_count > 0 ? (r.max_cvss ?? 0).toFixed(1) : ''),\n },\n {\n header: t2('missingPatches.col.ransomware'),\n pick: r => (r.ransomware ? t2('missingPatches.ransomware.yes') : t2('missingPatches.ransomware.no')),\n },\n { header: t2('missingPatches.col.asOf'), pick: r => asOfCsv(r.scanned_at) },\n ];\n const lines = [cols.map(c => c.header).join(',')];\n for (const r of rows) lines.push(cols.map(c => this.csvEsc(c.pick(r))).join(','));\n // OSV/distro attribution accompanies the data in exports, same\n // contract as the NVD sentence on the software CSV.\n if (rows.length) lines.push(this.csvEsc(t2('missingPatches.note.linux')));\n // A failed Linux lane, in the export as on the page: a package missing\n // from it is unknown, not patched. Other lanes' sentences stay off\n // this export.\n if ((this.lastBody?.degraded ?? []).includes('linux_packages')) {\n lines.push(this.csvEsc(partialNoticeLines({ degraded: ['linux_packages'] } as ExposureResponse).join(' ')));\n }\n const stamp = new Date().toISOString().slice(0, 10);\n triggerCsvDownload(lines.join('\\n'), `missing-patches-linux-${stamp}.csv`);\n }\n\n private renderLinuxTable(rows: LinuxPkgRow[]): void {\n // The section only exists when there is something to say: unlike the\n // Windows gap (estimated for every Windows node), package inventory\n // needs a collector, and an empty table would read as \"no Linux node\n // is exposed\" when most report no inventory at all.\n const card = document.getElementById('mpLinuxCard');\n if (card) (card as HTMLElement).hidden = rows.length === 0;\n this.linuxTable?.destroy();\n if (!rows.length) { this.linuxTable = null; return; }\n this.linuxTable = $('#mpLinuxTable').DataTable({\n data: rows,\n dom: SCROLL_DOM_STRING,\n order: [], // server order is the verdict order: KEV first, then severity\n pageLength: 10,\n lengthMenu: [[10, 20, 50, 100, -1], [10, 20, 50, 100, 'All']],\n autoWidth: false,\n language: {\n emptyTable: t2('missingPatches.linux.empty'),\n zeroRecords: t2('dt.zeroRecords'), // a filter that hides every row is not the empty state\n search: '',\n },\n headerCallback: (thead) => {\n (thead as HTMLTableSectionElement).classList.add('custom-thead');\n },\n columnDefs: [\n {\n targets: [0, 1, 2, 3],\n columnControl: [['orderAsc', 'orderDesc', 'searchList']],\n },\n {\n targets: [4, 10],\n columnControl: [['orderAsc', 'orderDesc',\n { extend: 'searchList', orthogonal: 'filter' }]],\n },\n {\n targets: [5, 6, 7, 8, 9, 11],\n columnControl: [['orderAsc', 'orderDesc']],\n },\n ],\n columns: [\n { data: 'hostname' },\n // The node's Mimic node build: a virtual patch's policy\n // compiles only for some builds, so this is what to filter\n // on. Sorted by number per part, like the Nodes page.\n {\n data: 'node_version', className: 'mp-nowrap', type: 'semver',\n render: (d: string | undefined, type: string) => (type === 'display' ? this.esc(d ?? '') : d ?? ''),\n },\n { data: 'package' },\n { data: 'ecosystem' },\n {\n // Provenance (ENG-4292): distro advisories via OSV.\n data: 'sources', className: 'mp-nowrap',\n render: (d: string[] | undefined, type: string) => renderSourcesCell(d ?? ['osv'], type),\n },\n {\n // One row can cover several installed builds cleared by\n // the same update. The oldest is shown \u2014 it is how far\n // behind the node is \u2014 with the rest on the marker rather\n // than as rows that read as duplicates.\n data: 'installed',\n render: (d: string, type: string, row: LinuxPkgRow) => {\n const all = row.installed_versions ?? [];\n if (type !== 'display' || all.length < 2) return d;\n const tip = t2('missingPatches.linux.versions.tip',\n { count: all.length, versions: all.join(', ') });\n return `${this.esc(d)} `\n + `${this.esc(t2('missingPatches.linux.versions', { count: all.length }))}`;\n },\n },\n {\n // The remedy column: the lowest version that clears every\n // fixed advisory on the row. Empty means the distro has\n // published no fix \u2014 said out loud, never left blank.\n data: 'fixed_version',\n render: (d: string, type: string) => {\n if (type !== 'display') return d || t2('missingPatches.ana.noBoundary');\n return d ? this.esc(d)\n : `${this.esc(t2('missingPatches.ana.noBoundary'))}`;\n },\n },\n {\n // CVE count, with the exact/advisory split on the tooltip\n // when part of the row arrived advisory-wide.\n data: 'cve_count', className: 'text-right',\n render: (d: number, type: string, row: LinuxPkgRow) => {\n if (type !== 'display') return d ?? 0;\n if (!row.advisory_count) return String(d ?? 0);\n const tip = t2('missingPatches.linux.cvesSplit',\n { exact: row.exact_count, advisory: row.advisory_count });\n return `${d ?? 0}`;\n },\n },\n { data: 'known_exploited_cve_count', className: 'text-right' },\n {\n // Severity from exactly-attributed CVEs only; a row whose\n // CVEs all arrived advisory-wide shows \"\u2014\", not 0.0.\n data: 'max_cvss', className: 'text-right',\n render: (d: number, type: string, row: LinuxPkgRow) => (\n type === 'display'\n ? (row.exact_count > 0 ? (d ?? 0).toFixed(1) : '\u2014')\n : d ?? 0),\n },\n {\n data: 'ransomware', className: 'mp-center',\n render: (d: boolean, type: string) => this.pill(\n d, 'mp-pill-ransom', t2('missingPatches.ransomware.yes'), t2('missingPatches.ransomware.no'), type),\n },\n {\n data: 'scanned_at', type: 'num',\n render: (d: string | undefined, type: string) => asOfRender(d, type),\n },\n ],\n });\n setupDataTableUI('mpLinuxTable', this.linuxTable, {\n columnControl: {\n dropdownMaterialIcon: 'filter_list',\n scrollHeadThClass: 'filter-right',\n fixHeaderLayout: true,\n cascadeSearchLists: true,\n },\n autoResize: { namespace: 'missing-patches-linux' },\n });\n this.wireSourceAnyOf('mpLinuxTable', this.linuxTable, 4);\n installCsvActionBar('mpLinuxTable', () => this.exportLinuxCsv());\n // Same whole-row drill-through as the other two tables.\n $('#mpLinuxTable tbody').off('click.mpcved').on('click.mpcved', 'tr', (ev: any) => {\n if ((ev.target as HTMLElement).closest('a, button, input, label')) return;\n const data = this.linuxTable?.row(ev.currentTarget as HTMLElement).data() as LinuxPkgRow | undefined;\n if (data) void this.openCveDrawer({ kind: 'linux', pkg: data });\n });\n }\n\n // \u2500\u2500 Vendor-reported findings table (ENG-4292) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n /** The vendor-findings table's CSV \u2014 server-side and uncapped, like the\n * Software table's (exportExposureCsv). */\n private async exportVendorCsv(): Promise {\n if (!this.vendorTable) return;\n await this.exportExposureCsv('vendor', this.vendorTable, [\n t2('missingPatches.col.node'), t2('missingPatches.col.nodeVersion'), t2('missingPatches.col.product'),\n t2('missingPatches.col.version'), t2('vulnSources.col.source'), t2('missingPatches.col.cves'),\n t2('missingPatches.col.kev'),\n t2('missingPatches.col.vendorScore'), t2('missingPatches.col.topCves'), t2('missingPatches.col.fix'),\n t2('missingPatches.col.ransomware'), t2('missingPatches.col.asOf'),\n ], t2('missingPatches.note.vendor'));\n }\n\n private renderVendorTable(rows: VendorRow[]): void {\n // Like the Linux section: only present once a vendor has reported\n // something, so an empty table never reads as \"no scanner found\n // anything\" for a tenant that has not imported a file.\n const card = document.getElementById('mpVendorCard');\n if (card) (card as HTMLElement).hidden = rows.length === 0;\n this.vendorTable?.destroy();\n if (!rows.length) { this.vendorTable = null; return; }\n this.vendorTable = $('#mpVendorTable').DataTable({\n data: rows,\n dom: SCROLL_DOM_STRING,\n order: [], // server order: matched nodes first, then KEV, then score\n pageLength: 10,\n lengthMenu: [[10, 20, 50, 100, -1], [10, 20, 50, 100, 'All']],\n autoWidth: false,\n language: {\n emptyTable: t2('missingPatches.vendor.empty'),\n zeroRecords: t2('dt.zeroRecords'), // a filter that hides every row is not the empty state\n search: '',\n },\n headerCallback: (thead) => {\n (thead as HTMLTableSectionElement).classList.add('custom-thead');\n },\n columnDefs: [\n {\n targets: [0, 1, 2, 3],\n columnControl: [['orderAsc', 'orderDesc', 'searchList']],\n },\n {\n targets: [4, 6, 10],\n columnControl: [['orderAsc', 'orderDesc',\n { extend: 'searchList', orthogonal: 'filter' }]],\n },\n {\n targets: [5, 7, 8, 9, 11],\n columnControl: [['orderAsc', 'orderDesc']],\n },\n ],\n columns: [\n {\n // The roster hostname for a matched row; the vendor's\n // own name plus the badge when the ladder found no node.\n data: 'hostname',\n render: (d: string, type: string, row: VendorRow) => {\n if (type === 'filter') return row.node_id ? d ?? '' : `${d ?? ''} ${t2('vulnSources.noNode')}`;\n if (type !== 'display') return d ?? '';\n return `${this.esc(d)}${row.node_id ? '' : ` ${noNodeBadge()}`}`;\n },\n },\n // The node's Mimic node build: a virtual patch's policy\n // compiles only for some builds, so this is what to filter\n // on. Sorted by number per part, like the Nodes page.\n {\n data: 'node_version', className: 'mp-nowrap', type: 'semver',\n render: (d: string | undefined, type: string) => (type === 'display' ? this.esc(d ?? '') : d ?? ''),\n },\n { data: 'product' },\n { data: 'version' },\n {\n data: 'sources', className: 'mp-nowrap',\n render: (d: string[], type: string) => renderSourcesCell(d, type),\n },\n {\n data: 'cve_count', className: 'text-right mp-nowrap',\n render: (d: number, type: string, row: VendorRow) => (type === 'display'\n ? `${d ?? 0}${this.earlyCountPill(row.early_cve_count, row.early_cves)}` : d ?? 0),\n },\n {\n // CISA's verdict on the row's CVEs. When only the vendor\n // calls it exploited, say so as the vendor's claim.\n data: 'known_exploited', className: 'mp-center',\n render: (d: boolean, type: string, row: VendorRow) => {\n if (d || !row.vendor_exploited) {\n return this.pill(d, 'mp-pill-kev',\n type === 'display'\n ? `${row.known_exploited_cve_count || ''} ${t2('missingPatches.kev.yes')}`.trim()\n : t2('missingPatches.kev.yes'),\n t2('missingPatches.kev.no'), type);\n }\n if (type === 'filter') return t2('missingPatches.vendor.exploited');\n if (type !== 'display') return 0;\n return `${this.esc(t2('missingPatches.vendor.exploited'))}`;\n },\n },\n {\n // The vendor's own score, shown as reported; \"\u2014\" when the\n // vendor gave none rather than a fabricated 0.0.\n data: 'max_cvss', className: 'text-right',\n render: (d: number, type: string) => (type === 'display'\n ? ((d ?? 0) > 0 ? (d ?? 0).toFixed(1) : '\u2014') : d ?? 0),\n },\n {\n data: 'top_cves',\n render: (d: string[] | null, type: string) => {\n const ids = d ?? [];\n if (type !== 'display') return ids.join(' ');\n return ids.map(id =>\n `${this.esc(id)}`,\n ).join(', ');\n },\n },\n {\n data: 'fix',\n render: (d: string, type: string) => {\n if (type !== 'display') return d || t2('missingPatches.ana.noBoundary');\n return d ? this.esc(d)\n : `${this.esc(t2('missingPatches.ana.noBoundary'))}`;\n },\n },\n {\n data: 'ransomware', className: 'mp-center',\n render: (d: boolean, type: string) => this.pill(\n d, 'mp-pill-ransom', t2('missingPatches.ransomware.yes'), t2('missingPatches.ransomware.no'), type),\n },\n {\n data: 'last_seen', type: 'num',\n render: (d: string | undefined, type: string) => asOfRender(d, type),\n },\n ],\n });\n setupDataTableUI('mpVendorTable', this.vendorTable, {\n columnControl: {\n dropdownMaterialIcon: 'filter_list',\n scrollHeadThClass: 'filter-right',\n fixHeaderLayout: true,\n cascadeSearchLists: true,\n },\n autoResize: { namespace: 'missing-patches-vendor' },\n });\n this.wireSourceAnyOf('mpVendorTable', this.vendorTable, 4);\n installCsvActionBar('mpVendorTable', () => void this.exportVendorCsv());\n $('#mpVendorTable tbody').off('click.mpcved').on('click.mpcved', 'tr', (ev: any) => {\n if ((ev.target as HTMLElement).closest('a, button, input, label')) return;\n const data = this.vendorTable?.row(ev.currentTarget as HTMLElement).data() as VendorRow | undefined;\n if (data) void this.openCveDrawer({ kind: 'vendor', vendor: data });\n });\n }\n}\n", "// Which note the Missing Patches drawer shows above a patch analysis's\n// version table. The snapshot keeps the newest PATCH_ANALYSIS_MAX_VERSIONS\n// versions (jobs/patch_analysis.go patchAnalysisMaxVersions) and, since\n// DASH-1977, the installed total (versionCount). One stored before that kept\n// the oldest versions at the cap and no total, so a table at the cap may be\n// cut with nothing to say by how much.\n\nexport const PATCH_ANALYSIS_MAX_VERSIONS = 25;\n\nexport type PatchVersionsNote =\n | { kind: \"cut\"; shown: number; total: number }\n | { kind: \"mayBeCut\"; shown: number }\n | null;\n\nexport function patchVersionsNote(versionCount: number | undefined, shown: number): PatchVersionsNote {\n if (versionCount !== undefined && versionCount !== null) {\n return versionCount > shown ? { kind: \"cut\", shown, total: versionCount } : null;\n }\n return shown >= PATCH_ANALYSIS_MAX_VERSIONS ? { kind: \"mayBeCut\", shown } : null;\n}\n", "// utils/AsOf.ts: how old a row's data is, in the app's language.\n//\n// relativeTime moved here from DashboardGallery (which re-exports it) so the\n// Missing Patches As of columns share it. asOfRender is the DataTables\n// render for an As of column, and asOfCsv its CSV cell; the server's CSV\n// export (api/cve_exposure_csv.go asOfCell / asOfSort) writes and sorts the\n// same way, so the two exports of one table agree.\nimport { getLocale } from '../i18n/I18n';\nimport { escapeHtml, tipAttr } from './FormatUtils';\n\n/** \"3 days ago\", in the app's language (not the browser's). */\nexport function relativeTime(iso: string, now = Date.now()): string {\n const at = Date.parse(iso);\n if (!Number.isFinite(at)) return '';\n const secs = Math.round((at - now) / 1000);\n const rtf = new Intl.RelativeTimeFormat(getLocale(), { numeric: 'auto' });\n const steps: Array<[Intl.RelativeTimeFormatUnit, number]> = [['year', 31536000], ['month', 2592000], ['week', 604800], ['day', 86400], ['hour', 3600], ['minute', 60]];\n for (const [unit, size] of steps) {\n if (Math.abs(secs) >= size) return rtf.format(Math.round(secs / size), unit);\n }\n return rtf.format(0, 'minute');\n}\n\n/** The time as Unix milliseconds, 0 when absent or unparseable: the sort\n * value, so a row with no time sorts first ascending (the server's\n * asOfSort). */\nexport function asOfMs(iso: string | null | undefined): number {\n if (!iso) return 0;\n const at = Date.parse(iso);\n return Number.isFinite(at) ? at : 0;\n}\n\n/** The As of CSV cell: RFC 3339 in UTC without fractional seconds (Go's\n * time.RFC3339), empty when the row has no time. */\nexport function asOfCsv(iso: string | null | undefined): string {\n const at = asOfMs(iso);\n return at ? new Date(at).toISOString().replace(/\\.\\d{3}Z$/, 'Z') : '';\n}\n\n/** The full local date and time, for the cell's tooltip. */\nexport function asOfFull(iso: string | null | undefined): string {\n const at = asOfMs(iso);\n return at ? new Date(at).toLocaleString(getLocale(), { dateStyle: 'medium', timeStyle: 'short' }) : '';\n}\n\n/** DataTables render for an As of column. display: the relative time with\n * `tip` on its tooltip (the full date and time by default), an em dash\n * when the row has no time; sort: Unix ms; filter: '' (a date must not\n * match a search for a number, and the column has no filter). */\nexport function asOfRender(iso: string | null | undefined, type: string, tip = ''): string | number {\n if (type === 'sort' || type === 'type') return asOfMs(iso);\n if (type !== 'display') return '';\n const at = asOfMs(iso);\n if (!at) return '\u2014';\n return `${escapeHtml(relativeTime(new Date(at).toISOString()))}`;\n}\n", "// Shared \"All Tenants\" scope toggle.\n//\n// One control, one look, used by every page that scopes a table by tenant\n// (Insights, Notifications, Node Health, Tenant Profiling, Release Status\u2026),\n// replacing the assortment of bespoke per-tenant dropdowns and the confusing\n// \"Selected tenant / All tenants\" \n ${escapeHtml(t('missingPatches.vpw.confirmText', { name: w.deploy?.patchName ?? '', host: target.hostname }))}`;\n\n const renderPanel = (): string => {\n if (!wf) {\n // Nothing starts until the permissions answer says which flow.\n if (!perms && !permsFailed) return `

${escapeHtml(t('missingPatches.vpw.starting'))}

`;\n // No answer, a sign-in from before the scopes, or no step this\n // user may take: its note, and no start.\n if (!mayStart()) return permNote(false);\n return busy ? `

${escapeHtml(t('missingPatches.vpw.starting'))}

`\n : `
`;\n }\n const w = wf;\n if (conflict()) {\n // Only the way out: cancel that workflow (footer), then start one\n // for this product.\n return w.status === 'cancelled' || (w.status === 'failed' && !RETRYABLE_FAILED.has(w.step)) || w.status === 'done'\n ? startAgain()\n : `

${escapeHtml(t('missingPatches.vpw.conflict', { product: w.product }))}

`;\n }\n // The server's reason is English, and may quote the management\n // plane or the dashboard; the line around it, and what to do next,\n // are the page's own words.\n const failedAt = w.error\n ? `

${escapeHtml(t('missingPatches.vpw.stepFailed', { step: t(`missingPatches.vpw.step.${DISPLAY_STEPS[STEP_INDEX[w.step] ?? 0]}`), error: w.error }))}

`\n : '';\n const err = failedAt && w.status === 'failed'\n ? `${failedAt}

${escapeHtml(t('missingPatches.vpw.retryFailure'))}

` : failedAt;\n const polled = pollError ? `

${escapeHtml(t('missingPatches.vpw.pollError', { error: pollError }))}

` : '';\n const prog = progress ? `

${escapeHtml(progress)}

` : '';\n const deployOk = can('deployNode');\n const backtestAgain = ``;\n if (w.status === 'done') {\n return `
check_circle\n

${escapeHtml(t('missingPatches.vpw.done', { host: target.hostname }))}

`;\n }\n if (w.status === 'cancelled') return `

${escapeHtml(t('missingPatches.vpw.cancelled'))}

${startAgain()}`;\n if (w.status === 'failed' && !RETRYABLE_FAILED.has(w.step)) {\n return `${failedAt}

${escapeHtml(t('missingPatches.vpw.closedFailure'))}

${startAgain()}`;\n }\n if (w.status === 'running') return `${busyBanner(t('missingPatches.vpw.running'))}${polled}`;\n switch (w.step) {\n case 'request':\n case 'research':\n case 'vulnerability':\n if (w.step === 'research' && paused(w)) return runStep('research');\n return `${busyBanner(t('missingPatches.vpw.researching'))}${prog}${polled}`;\n case 'generate':\n if (paused(w)) return runStep('generate');\n return `${busyBanner(t('missingPatches.vpw.generating'))}${prog}${polled}`;\n case 'review':\n return renderReview('');\n case 'patch':\n return renderReview(err);\n case 'backtest':\n if (w.status === 'failed') {\n return `${err}${renderBacktestResult()}${renderReview('')}\n
${backtestAgain}
${permNote(deployOk)}`;\n }\n return `

${escapeHtml(t('missingPatches.vpw.published', { name: w.deploy?.patchName ?? '' }))}

\n

${escapeHtml(t('missingPatches.vpw.backtestIntro'))}

\n
${permNote(deployOk)}`;\n case 'confirm':\n return `${renderBacktestResult()}${confirmBox(w)}\n
${permNote(deployOk)}`;\n case 'deploy':\n // A retry deploys too, so it takes the same confirm.\n return `${err}${confirmBox(w)}
\n ${backtestAgain}
${permNote(deployOk)}`;\n case 'watch': {\n if (w.status === 'failed') {\n return `${err}
${backtestAgain}
${permNote(deployOk)}`;\n }\n const app = w.deploy?.application;\n const st = app?.status ? t('missingPatches.vpw.applicationStatus', { status: app.status }) : t('missingPatches.vpw.applicationWaiting');\n return `${busyBanner(t('missingPatches.vpw.watching', { host: target.hostname }))}

${escapeHtml(st)}

${polled}`;\n }\n }\n return err;\n };\n\n // A paused step's one control: start its dashboard job. It names its step,\n // so the server starts that step's job and no other (a later pause another\n // tab reached is refused).\n const runStep = (step: 'research' | 'generate') => `
`;\n\n const startAgain = () => `
`;\n\n const renderBacktestResult = (): string => {\n const bt = wf?.deploy?.backtest;\n if (!bt) return '';\n return `

${escapeHtml(bt.violations === 0\n ? t('missingPatches.vpw.backtestPassed', { version: bt.patchVersion })\n : t('missingPatches.vpw.backtestFailed', { count: bt.violations }))}

`;\n };\n\n const publishOk = () => can('createPatch') && can('publishPatch') && rulesToSend().length > 0 && nameOk();\n\n const renderReview = (err: string): string => {\n reviewShown = true;\n const safety = wf?.candidate?.report?.safety;\n const notes = [...(safety?.warnings ?? []), ...(safety?.risks ?? [])].filter(s => typeof s === 'string' && s.trim());\n const permitted = can('createPatch') && can('publishPatch');\n const rows = reviewRules().map(({ rule: r, file }) => {\n const id = escapeHtml(String(r.rule_id));\n const off = !file || excluded.has(r.rule_id);\n return `\n \n ${escapeHtml(r.actor_type)} ${escapeHtml(r.actor_descriptor)}\n ${escapeHtml(r.resource_descriptor)}\n ${escapeHtml(r.capability)}\n ${escapeHtml(r.permission)}${file ? '' : ` ${escapeHtml(t('missingPatches.vpw.notFileRule', { type: r.type ?? '' }))}`}\n `;\n }).join('');\n const sendable = rulesToSend().length > 0;\n return `${err}

${escapeHtml(t(publishedRules() ? 'missingPatches.vpw.reviewIntroPublished' : 'missingPatches.vpw.reviewIntro'))}

\n ${notes.length ? `
    ${notes.map(n => `
  • ${escapeHtml(n)}
  • `).join('')}
` : ''}\n \n \n \n ${rows}
${escapeHtml(t('missingPatches.vpw.col.actor'))}${escapeHtml(t('missingPatches.vpw.col.resource'))}${escapeHtml(t('missingPatches.vpw.col.capability'))}${escapeHtml(t('missingPatches.vpw.col.permission'))}
\n
\n \n

${escapeHtml(t('missingPatches.vpw.nameTooLong', { max: MAX_PATCH_NAME }))}

\n
\n ${sendable ? '' : `

${escapeHtml(t('missingPatches.vpw.noRules'))}

`}${permNote(permitted)}`;\n };\n\n const wirePanel = (root: HTMLElement) => {\n const wfId = wf?.id ?? '';\n const id = encodeURIComponent(wfId);\n root.querySelectorAll('.vpw-start').forEach(b => b.addEventListener('click', () => void start()));\n root.querySelectorAll('.vpw-continue').forEach(b => b.addEventListener('click', () => void act(`${base}/workflows/${id}/continue`, { step: b.dataset.step ?? '' })));\n root.querySelectorAll('.vpw-request').forEach(b => b.addEventListener('click', () => void request()));\n root.querySelectorAll('.vpw-rule').forEach(cb => cb.addEventListener('change', () => {\n const rid = Number(cb.dataset.rule);\n if (!Number.isFinite(rid)) return;\n if (cb.checked) excluded.delete(rid); else excluded.add(rid);\n render();\n }));\n const name = root.querySelector('#vpwName');\n const publish = root.querySelector('.vpw-publish');\n const long = root.querySelector('.vpw-name-long');\n name?.addEventListener('input', () => {\n patchName = name.value;\n if (publish) publish.disabled = busy || !publishOk();\n if (long) long.hidden = nameOk() || !patchName.trim();\n });\n // With Auto-continue on, a publish that lands goes on to its back-test.\n publish?.addEventListener('click', () => void act(`${base}/workflows/${id}/patch`, {\n name: patchName.trim(), rules: rulesToSend(),\n }).then(() => {\n if (!live() || !autoContinue || actionError || wf?.id !== wfId) return;\n if (wf.step === 'backtest' && wf.status === 'waiting' && can('deployNode')) void act(`${base}/workflows/${id}/backtest`);\n }));\n root.querySelectorAll('.vpw-backtest').forEach(b => b.addEventListener('click', () => void act(`${base}/workflows/${id}/backtest`)));\n const box = root.querySelector('.vpw-confirm-box');\n const deploy = root.querySelectorAll('.vpw-deploy');\n box?.addEventListener('change', () => deploy.forEach(b => { b.disabled = busy || !box.checked || !can('deployNode'); }));\n deploy.forEach(b => b.addEventListener('click', () => {\n if (!box?.checked) return;\n void act(`${base}/workflows/${id}/deploy`, { confirm: true });\n }));\n };\n\n // The Mimic dashboard's page for the CVE's entry once there is one, the\n // tenant's vulnerabilities before; empty where the dashboard is not\n // configured (or before the permissions answer says where it is).\n const dashboardHref = (): string => {\n const appUrl = perms?.dashboardUrl;\n if (!appUrl) return '';\n const id = requested?.vulnerabilityId || wf?.vulnerabilityId || target.vulnerabilityId;\n const tenant = `?tenant=${encodeURIComponent(target.tenantId)}`;\n return id ? `${appUrl}/vulnerabilities/${encodeURIComponent(id)}${tenant}` : `${appUrl}/vulnerabilities${tenant}`;\n };\n\n const renderFooter = () => {\n if (!modal) return;\n // Close first, on the left in the grey it has alone (.vpw-footer-close);\n // it waits for a step in flight, so its answer is seen.\n const buttons: ModalButtonOpts[] = [{\n label: t('common.close'), className: 'btn btn-secondary vpw-footer-close',\n onClick: () => { if (!busy) modal?.hide(); },\n afterRender: (b: HTMLButtonElement) => { b.disabled = busy; },\n }];\n const href = dashboardHref();\n if (href) {\n // Not btn-primary: Ctrl/Cmd+Enter presses the primary button.\n // \"Dashboard\" and the new-tab icon; the full name is the tooltip\n // and what a screen reader reads.\n buttons.push({\n label: t('missingPatches.vpw.dashboard'),\n className: 'btn btn-outline-primary vpw-dashboard',\n icon: { materialIcon: 'open_in_new', after: true },\n onClick: () => { window.open(href, '_blank', 'noopener,noreferrer'); },\n afterRender: (b: HTMLButtonElement) => {\n b.setAttribute('data-tooltip', t('missingPatches.vpw.openDashboard'));\n b.setAttribute('aria-label', t('missingPatches.vpw.openDashboard'));\n },\n });\n }\n // The Auto-continue switch, where it decides something: the full\n // workflow, one this user may start, for this product, until it is\n // done. Toggled in place, so focus stays on it; a render that rebuilds\n // the footer gives focus back to the new switch.\n const autoFocused = !!document.activeElement?.classList.contains('vpw-auto');\n let autoBtn: HTMLButtonElement | null = null;\n if (!requestOnly && mayStart() && !conflict() && wf?.status !== 'done') {\n buttons.push({\n label: t('missingPatches.vpw.autoContinue'),\n className: 'btn vpw-auto',\n onClick: () => {\n autoContinue = !autoContinue;\n autoBtn?.setAttribute('aria-checked', String(autoContinue));\n },\n afterRender: (b: HTMLButtonElement) => {\n autoBtn = b;\n b.setAttribute('role', 'switch');\n // Not Ctrl/Cmd+Enter's fallback target: it would flip silently.\n b.setAttribute('data-cmd-enter', 'skip');\n b.setAttribute('aria-checked', String(autoContinue));\n b.setAttribute('data-tooltip', t('missingPatches.vpw.autoContinueTip'));\n const track = document.createElement('span');\n track.className = 'vpw-switch';\n track.setAttribute('aria-hidden', 'true');\n b.prepend(track);\n },\n });\n }\n if (wf && cancellable(wf)) {\n const id = encodeURIComponent(wf.id);\n buttons.push({\n label: t('missingPatches.vpw.cancel'),\n className: 'btn btn-outline-danger vpw-cancel',\n onClick: () => void act(`${base}/workflows/${id}/cancel`),\n afterRender: (b: HTMLButtonElement) => { b.disabled = busy; },\n });\n }\n modal.setButtons(buttons);\n if (autoFocused) host.querySelector('.vpw-auto')?.focus();\n const x = host.querySelector('.vpw-close');\n if (x) x.disabled = busy;\n };\n\n modal.setTitle(escapeHtml(t('missingPatches.vpw.title')));\n modal.setDescription('');\n render();\n modal.show();\n\n void (async () => {\n try {\n const ans = await call('GET', `${base}/permissions`);\n if (!live()) return;\n if (ans.status >= 400) permsFailed = true;\n else perms = { createPatch: !!(ans as any).createPatch, publishPatch: !!(ans as any).publishPatch, deployNode: !!(ans as any).deployNode, signInAgain: !!(ans as any).signInAgain, research: (ans as any).research !== false, dashboardUrl: typeof (ans as any).dashboardUrl === 'string' ? (ans as any).dashboardUrl : '' };\n } catch {\n if (!live()) return;\n permsFailed = true;\n }\n // No research here: the request alone. An unanswered permissions\n // read starts nothing: its note asks to reopen.\n if (perms && !perms.research) {\n requestOnly = true;\n // Already requested (the page's catalog lists the entry): shown\n // as such, not asked for again.\n if (target.vulnerabilityId) {\n requested = { vulnerabilityId: target.vulnerabilityId, at: '' };\n render();\n return;\n }\n // The answer already says the create would be refused (a sign-in\n // from before the scopes, or a role without vulnerabilities:create):\n // its note shows, and no request is sent to fail.\n if (perms.signInAgain || !perms.createPatch) {\n render();\n return;\n }\n void request();\n return;\n }\n render();\n if (!mayStart()) return;\n void start();\n })();\n}\n\n/** A step's time: the time of day when it is today, the date too when not. */\nfunction fmtTime(iso: string): string {\n const d = new Date(iso);\n if (Number.isNaN(d.getTime())) return '';\n const now = new Date();\n if (d.toDateString() === now.toDateString()) {\n return new Intl.DateTimeFormat(getLocale(), { hour: 'numeric', minute: '2-digit', second: '2-digit' }).format(d);\n }\n return new Intl.DateTimeFormat(getLocale(), {\n year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric',\n month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit',\n }).format(d);\n}\n\nfunction defaultPatchName(cve: string, product: string): string {\n // By code point, as the server counts the name's length.\n return Array.from(`${cve} ${product}`).slice(0, MAX_PATCH_NAME).join('');\n}\n", "import AboutStore from '../stores/AboutStore';\nimport { t, t as t2 } from '../i18n/I18n';\nimport AdminStore, { BlockedDomain, ConnectorRedirect } from '../stores/AdminStore';\nimport { ConnectorRedirectFormDrawer } from '../utils/ConnectorRedirectFormDrawer';\nimport { setupDataTableUI } from '../utils/DataTables';\nimport StoreManager from '../managers/StoreManager';\nimport { Header } from '../utils/Header';\nimport EnvironmentStore from '../stores/EnvironmentStore';\nimport { MSSPAdminUI } from './MSSPAdminUI';\nimport { SiteAccessUI } from './SiteAccessUI';\nimport { VulnSourcesUI } from './VulnSourcesUI';\nimport { APIPerfAdminUI } from './APIPerfAdminUI';\nimport { TAMAdminUI } from './TAMAdminUI';\nimport { escapeHtml, formatNumber } from \"../utils/FormatUtils\";\nimport { secretsResyncOutcome } from \"../utils/SecretsResync\";\n// A failed status check is its own state: reporting it as \"Inactive\" would\n// tell an admin ingestion stopped when only the check failed.\nfunction pubSubStatusLabel(status: \"active\" | \"inactive\" | \"error\"): string {\n if (status === \"active\") return t2('col.active');\n if (status === \"inactive\") return t2('body.inactive');\n return t2('body.statusUnknown');\n}\n\n// The Admin page's tabs, in strip order. A user may hold a subset.\nexport type AdminTab = \"commands\" | \"domains\" | \"connectors\" | \"partners\" | \"siteAccess\" | \"myTenants\" | \"vulnSources\" | \"performance\";\nconst ALL_ADMIN_TABS: AdminTab[] = [\"commands\", \"domains\", \"connectors\", \"partners\", \"siteAccess\", \"myTenants\", \"vulnSources\", \"performance\"];\n\n// Tab ids that used to name this tab, kept so links minted before the rename\n// still land. The tab was TAM-only once; it now serves anyone who may hold\n// tenant subscriptions, so the id says what the tab is, not who it was for.\nconst LEGACY_ADMIN_TAB_IDS: Record = { tam: \"myTenants\" };\n\nexport enum AdminCommands {\n ResetDB = 'resetDB',\n ResetCallers = 'resetCallers',\n ResetLogs = 'resetLogs',\n ResetLifecycle = 'resetLifecycle',\n ResetBIEvents = 'resetBIEvents',\n ResetDeveloperFiles = 'resetDeveloperFiles',\n ResetACLSimulationTables = 'resetACLSimulationTables',\n ResetExecutables = 'resetExecutables',\n ResetDriftReportTables = 'resetDriftReportTables',\n ResetMPReleaseTables = 'resetMPReleaseTables',\n ResetNodeBuckets = 'resetNodeBuckets',\n\n ResetMPNotifications = 'resetMPNotifications',\n ResetServices = 'resetServices',\n\n ResetNodeBackups = 'resetNodeBackups',\n ResyncSecrets = 'resyncSecrets',\n}\n\nexport class AdminUI {\n private messageArea: HTMLElement;\n private progressBar: HTMLElement;\n private aboutStore: AboutStore;\n private adminStore: AdminStore;\n private environmentStore: EnvironmentStore;\n private header = new Header(\"admin\");\n private analyzeButton: HTMLButtonElement;\n private resyncSecretsButton: HTMLButtonElement;\n private runningLocally = false;\n // Active admin tab \u2014 same hand-rolled pattern as BackupsUI (no\n // Bootstrap tab JS): class + display toggles, ?tab= deep link.\n private activeTab: AdminTab = \"commands\";\n private msspAdminUI: MSSPAdminUI | null = null;\n private connectorDt: DataTables.Api | null = null;\n // #connectorRedirectsList is static markup that outlives each render, so\n // the delegated Remove listener is replaced, never stacked.\n private connectorListAbort: AbortController | null = null;\n private siteAccessUI: SiteAccessUI | null = null;\n private vulnSourcesUI: VulnSourcesUI | null = null;\n private apiPerfUI: APIPerfAdminUI | null = null;\n private tamAdminUI: TAMAdminUI | null = null;\n // Non-admins reach this page for a tab or two each: a TAM for MSSPs +\n // TAM / SE, a marketing user for Site Access, support for Performance.\n // Everything else is hidden and all admin-only setup (pubsub status, secrets) is\n // skipped so we never hit RoleAdmin endpoints that would 403 for them.\n private allowedTabs: AdminTab[] = ALL_ADMIN_TABS;\n // Restricted is the role, not the tab count: a full admin where Site\n // Access is not mounted (every env but prod) holds six of the seven\n // tabs and still needs the Commands wiring and the status block.\n private fullAdmin = false;\n private get restricted(): boolean { return !this.fullAdmin; }\n\n\n constructor() {\n this.updateUI = this.updateUI.bind(this);\n this.setupEventListeners = this.setupEventListeners.bind(this);\n const storeManager = StoreManager.getInstance()\n this.aboutStore = storeManager.aboutStore;\n this.adminStore = storeManager.adminStore;\n this.environmentStore = storeManager.environmentStore;\n }\n\n async updateUI() {\n this.header.set({\n title: t('nav.admin'),\n icon: {\n fontAwesome: \"fa-tools\"\n }\n });\n\n this.runningLocally = !this.environmentStore.isRunningInGCP;\n this.messageArea = document.getElementById('messageArea') as HTMLElement;\n this.progressBar = document.getElementById('progressBar') as HTMLElement;\n\n this.messageArea.style.width = '100%'; // Set the message area to full width\n\n const adminBody = document.getElementById('adminBody') as HTMLElement;\n\n if (!adminBody) {\n console.error('Admin body not found');\n return;\n }\n\n this.analyzeButton = adminBody.querySelector('#analyzeButton') as HTMLButtonElement;\n this.resyncSecretsButton = adminBody.querySelector('#resyncSecretsButton') as HTMLButtonElement;\n\n // A non-admin reaches this page only for the tabs their capability\n // flags grant (a TAM: MSSPs, Vulnerability Sources; support:\n // Performance).\n // isDashcamAdmin is the server-computed hasAdminTools flag.\n const user = StoreManager.getInstance().userProfileStore.user;\n this.fullAdmin = !!user?.isDashcamAdmin;\n if (user?.isDashcamAdmin) {\n // A full admin can still lack three tabs. Site Access: its routes\n // exist only where the research gate is configured (prod), which\n // the server reports through hasSiteAccess. Performance and\n // Vulnerability Sources: their routes refuse a profile pinned to\n // a sub-tenant, which hasApiPerf and hasVulnSources fold in.\n this.allowedTabs = ALL_ADMIN_TABS.filter(t =>\n (t !== \"siteAccess\" || !!user.hasSiteAccess)\n && (t !== \"performance\" || !!user.hasApiPerf)\n && (t !== \"vulnSources\" || !!user.hasVulnSources));\n } else {\n // Capability flags, not roles: the server decides who may see what.\n this.allowedTabs = [];\n if (user?.hasMsspAdmin) this.allowedTabs.push(\"partners\");\n if (user?.hasSiteAccess) this.allowedTabs.push(\"siteAccess\");\n if (user?.hasTenantSubscriptions) this.allowedTabs.push(\"myTenants\");\n if (user?.hasVulnSources) this.allowedTabs.push(\"vulnSources\");\n if (user?.hasApiPerf) this.allowedTabs.push(\"performance\");\n }\n\n this.ensureStyles();\n this.wireTabNav();\n\n if (this.restricted) {\n // Restricted view: the one permitted tab is already rendered by\n // wireTabNav \u2192 setActiveTab. Skip every admin-only surface below \u2014\n // those call RoleAdmin endpoints (pubsub, secrets)\n // that 403 for a non-admin and populate UI they can't see anyway.\n // The version / pubsub block is that same surface:\n // without those calls it is three empty labels, so a\n // restricted viewer (a customer's vulnimport holder, a TAM) does\n // not get it at all.\n const status = document.getElementById(\"adminStatusBlock\");\n if (status) status.hidden = true;\n return;\n }\n\n this.setupEventListeners(adminBody);\n\n const typeSelect = adminBody.querySelector('#typeSelect') as HTMLSelectElement;\n if (typeSelect) {\n typeSelect.dispatchEvent(new Event('change'));\n }\n\n // Fetch the version and show it in #versionLabel\n const version = await this.aboutStore.version;\n const versionValue = document.getElementById('versionValue') as HTMLSpanElement;\n if (versionValue) {\n versionValue.textContent = version || '';\n }\n\n // Fetch the Galactus PubSub status & update label\n this.displayGalactusPubSubStatus();\n this.displayLifecyclePubSubStatus();\n }\n\n // wireTabNav / setActiveTab \u2014 mirrors BackupsUI's hand-rolled nav-tabs:\n // click handlers + class/display toggles, ?tab= honored for deep links.\n private wireTabNav(): void {\n const nav = document.getElementById(\"adminTabNav\");\n if (!nav) return;\n // A restricted viewer may hold more than one tab (internal staff with\n // tenant subscriptions also see MSSPs), so the deep link is honored for\n // everyone \u2014 it just has to resolve to a tab this user actually holds.\n // Anything else falls back to their first tab rather than 403-ing on a\n // pane they cannot see. setActiveTab re-checks, so this is belt and\n // braces, not the only gate.\n const requested = this.requestedTabFromUrl();\n this.activeTab = requested && this.allowedTabs.includes(requested)\n ? requested\n : (this.allowedTabs[0] ?? \"commands\");\n // Hide every tab this user may not see (a full admin may lack Site\n // Access too, so this is not only for the restricted view).\n nav.querySelectorAll(\"[data-admin-tab]\").forEach((btn) => {\n if (!this.allowedTabs.includes(btn.dataset.adminTab as AdminTab)) {\n (btn.closest(\".nav-item\") as HTMLElement ?? btn).style.display = \"none\";\n }\n });\n nav.querySelectorAll(\"[data-admin-tab]\").forEach((btn) => {\n btn.addEventListener(\"click\", () => {\n this.setActiveTab((btn.dataset.adminTab as AdminTab) ?? \"commands\");\n });\n });\n this.setActiveTab(this.activeTab);\n }\n\n // The tab named by ?tab=, mapped through the legacy ids, or null when the\n // parameter is absent or names no tab that exists.\n private requestedTabFromUrl(): AdminTab | null {\n const raw = new URLSearchParams(location.search).get(\"tab\") ?? \"\";\n const mapped = LEGACY_ADMIN_TAB_IDS[raw] ?? raw;\n return ALL_ADMIN_TABS.includes(mapped as AdminTab) ? (mapped as AdminTab) : null;\n }\n\n private setActiveTab(tab: AdminTab): void {\n // Defense in depth: a restricted viewer can only ever land on a tab\n // they hold, even via a crafted ?tab= or a stray click handler.\n if (!this.allowedTabs.includes(tab)) tab = this.allowedTabs[0] ?? \"commands\";\n this.activeTab = tab;\n const nav = document.getElementById(\"adminTabNav\");\n nav?.querySelectorAll(\"[data-admin-tab]\").forEach((b) => {\n b.classList.toggle(\"active\", b.dataset.adminTab === tab);\n });\n const panes: Record = {\n commands: \"adminCommandsTabPane\",\n domains: \"adminDomainsTabPane\",\n connectors: \"adminConnectorsTabPane\",\n partners: \"adminPartnersTabPane\",\n siteAccess: \"adminSiteAccessTabPane\",\n myTenants: \"adminTamTabPane\",\n vulnSources: \"adminVulnSourcesTabPane\",\n performance: \"adminPerformanceTabPane\",\n };\n for (const [key, paneId] of Object.entries(panes)) {\n const pane = document.getElementById(paneId);\n if (pane) pane.style.display = key === tab ? \"\" : \"none\";\n }\n // The Performance tab's tables are wider than the page: there the\n // body fills the page instead of sizing to its content\n // (APIPerfAdminUI's styles), so they scroll inside their cards.\n document.getElementById(\"adminBody\")?.classList.toggle(\"admin-body-fill\", tab === \"performance\");\n // Keep reloads on the user's tab (matches BackupsUI).\n const url = new URL(location.href);\n url.searchParams.set(\"tab\", tab);\n history.replaceState(null, \"\", url.toString());\n\n if (tab === \"domains\") void this.renderBlockedDomainsPanel();\n if (tab === \"connectors\") void this.renderConnectorRedirectsPanel();\n if (tab === \"vulnSources\") {\n const root = document.getElementById(\"vulnSourcesRoot\");\n if (root) {\n this.vulnSourcesUI ??= new VulnSourcesUI(\n this.adminStore,\n (msg) => this.showMessage(\"failed\", escapeHtml(msg)),\n );\n void this.vulnSourcesUI.render(root);\n }\n }\n if (tab === \"performance\") {\n const root = document.getElementById(\"perfAdminRoot\");\n if (root) {\n this.apiPerfUI ??= new APIPerfAdminUI(this.adminStore);\n void this.apiPerfUI.render(root);\n }\n }\n if (tab === \"siteAccess\") {\n const root = document.getElementById(\"siteAccessRoot\");\n if (root) {\n this.siteAccessUI ??= new SiteAccessUI(\n this.adminStore,\n (msg) => this.showConfirmationDialog(msg),\n (msg) => this.showMessage(\"failed\", escapeHtml(msg)),\n );\n void this.siteAccessUI.render(root);\n }\n }\n if (tab === \"partners\") {\n const root = document.getElementById(\"msspAdminRoot\");\n if (root) {\n this.msspAdminUI ??= new MSSPAdminUI(\n this.adminStore,\n (msg) => this.showConfirmationDialog(msg),\n (msg) => this.showMessage(\"failed\", escapeHtml(msg)),\n );\n void this.msspAdminUI.render(root);\n }\n }\n if (tab === \"myTenants\") {\n const root = document.getElementById(\"tamAdminRoot\");\n if (root) {\n this.tamAdminUI ??= new TAMAdminUI(\n (msg) => this.showMessage(\"failed\", escapeHtml(msg)),\n );\n void this.tamAdminUI.render(root);\n }\n }\n }\n\n // Page-scoped styles for the tab strip + MSSP cards, injected once \u2014\n // same approach (and visual language) as BackupsUI's ensureStyles.\n private ensureStyles(): void {\n const STYLE_ID = \"admin-styles\";\n if (document.getElementById(STYLE_ID)) return;\n const style = document.createElement(\"style\");\n style.id = STYLE_ID;\n style.textContent = `\n /* Connector Redirects tab header: hint on the left, gold Allow\n button pinned to the top corner \u2014 same visual as the\n Notifications page's Rules/Alerts toolbar buttons. */\n .connector-redirects-head {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: 16px;\n }\n .connector-redirects-head .blocked-domains-copy { margin-bottom: 12px; }\n .connector-allow-btn {\n background: var(--brand-accent, #d4b25a);\n color: var(--brand-accent-fg, #1a1a1a);\n border: none;\n white-space: nowrap;\n }\n .connector-allow-btn:hover { filter: brightness(0.94); }\n /* Add + Remove read as one button family: same width, same\n label font, same 18px filled icon. */\n .connector-allow-btn,\n #connectorRedirectsList .remove-connector-btn {\n width: 110px;\n font-size: 14px;\n font-weight: 500;\n text-align: center;\n }\n .connector-allow-btn .material-icons,\n #connectorRedirectsList .remove-connector-btn .material-icons {\n font-size: 18px;\n vertical-align: middle;\n margin-right: 2px;\n }\n /* Site Access tab. Kept in the page's existing visual language \u2014\n same table/pill/hint idiom as the MSSPs tab above. */\n /* --sa-gutter is the tab's right-hand margin. The table already has\n it, as cell padding; the bar and the result banner are told to\n match, so every button on the tab ends on the same vertical line\n instead of three near-misses. */\n #siteAccessRoot { --sa-gutter: 12px; }\n #siteAccessRoot .sa-hint { color: #6c757d; font-size: 13px; margin: 0 0 12px; }\n #siteAccessRoot .sa-bar {\n display: flex; align-items: center; gap: 8px; margin-bottom: 12px;\n padding-right: var(--sa-gutter);\n }\n #siteAccessRoot .sa-spacer { flex: 1; }\n /* flex: none \u2014 a .form-control is width:100% and shrinkable, so in\n this bar it gets squeezed below its declared width and clips the\n placeholder. The width is set to fit \"name@company.com\" with\n room to spare, since a typed address is usually longer. */\n #siteAccessRoot .sa-invite-input { flex: none; width: 300px; }\n /* #adminBody is a flex row, and a flex item's default min-width is\n its content's \u2014 so a table wider than the page doesn't scroll\n inside the tab, it widens the tab and the whole admin page slides\n sideways (Delete disappears off the right edge). min-width: 0 is\n what lets the pane shrink to the page instead. */\n #adminSiteAccessTabPane { min-width: 0; }\n #siteAccessRoot .sa-table { font-size: 13px; width: 100%; }\n #siteAccessRoot .sa-table td { vertical-align: middle; }\n /* Addresses are long unbreakable strings; wrapping one is better\n than widening the table past the page for it. Only the address\n column \u2014 a blanket rule on td broke \"Pending\" into \"Pendin/g\". */\n /* min-width is a FLOOR the auto layout must honour \u2014 unlike\n max-width, which a ignores. 33ch is the longest address\n actually on file (sanderson@directiveconsulting.com), so the\n column never has to break one; the percentage width on the column\n then adds stretch on top of that floor rather than fighting it.\n overflow-wrap stays as the escape hatch for a pathological\n address, which has nowhere else to go. */\n #siteAccessRoot .sa-email { overflow-wrap: anywhere; min-width: 33ch; }\n /* Column titles and dates stay on one line; the header cells are\n narrow (title + filter button share the width) and \"Requested\"\n is the first thing to break when they aren't told not to. */\n #siteAccessRoot .sa-table th { white-space: nowrap; }\n #siteAccessRoot .sa-requested { white-space: nowrap; }\n /* The reviewer's address is supporting detail, not the subject of\n the row, so it is muted the way the Blocked Domains \"blocked by\"\n column is. That rule is scoped to #blockedDomainsPanel and does\n not reach here, hence the repeat rather than a shared class.\n Wraps like .sa-email: it is an address too, and a long one would\n otherwise push the table past the page.\n\n The tbody qualifier is load-bearing. DataTables copies\n columns.className onto the HEADER cell as well, and under\n ColumnControl the header cells are td inside thead \u2014 so neither a\n bare class selector nor td.sa-reviewed-by excludes them, and the\n \"Reviewed by\" title rendered greyed-out and out of step with\n every other column heading. Values are supporting detail; the\n heading is a heading. */\n /* nowrap, NOT overflow-wrap:anywhere. Once Email and Reason started\n claiming a share of the width, \"anywhere\" let the auto layout\n squeeze this column and hyphen-less-break the address mid-token \u2014\n \"kate@mimic.co / m\" \u2014 which is worse than a wide column. An\n address is one token you read as a unit; refusing to break it\n makes the layout give it the width it needs. */\n #siteAccessRoot tbody td.sa-reviewed-by { color: #6c757d; white-space: nowrap; }\n /* Same reasoning for the two other single-token columns: a person's\n name and a company are short, and stacking them over two lines to\n buy a few pixels just makes every row taller. */\n #siteAccessRoot .sa-table td:nth-child(2),\n #siteAccessRoot .sa-table td:nth-child(3) { white-space: nowrap; }\n /* DataTables' control rows (length + search on top, info + pager\n below) sit OUTSIDE the table, so they don't inherit the cell\n padding that gives the table its right-hand gutter. Pad them to\n match, or the search box overhangs the Invite button above it. */\n #siteAccessRoot .dt-layout-row { padding-right: var(--sa-gutter); }\n /* Reason WRAPS rather than clipping to one ellipsised line. It used\n to be clamped to 200px on the inner span, which kept rows short\n but meant the one field a reviewer has to actually read was the\n one field they could not see \u2014 \"Site Analysis and User Journey \u2026\"\n tells you nothing. The title attribute is still there for the\n occasional essay.\n\n normal, not anywhere: this is prose, so break it at spaces. Only\n .sa-email overrides that, because an address has no spaces to\n break at. */\n #siteAccessRoot .sa-reason { white-space: normal; overflow-wrap: normal; }\n #siteAccessRoot .sa-reason > span { display: inline; }\n #siteAccessRoot .sa-actions { white-space: nowrap; text-align: right; }\n #siteAccessRoot .sa-actions .btn + .btn { margin-left: 6px; }\n /* Delete is icon-only, matching the Notification Rules drawer\n (RulesDrawer's __delete): transparent, borderless, tinted red so\n the destructive one still reads as destructive without shouting.\n Dropping the label gives ~100px per row back to Email and Reason,\n which is the whole point \u2014 and Delete is the action you should\n have to aim at rather than the one that fills the row.\n\n Not a .btn, deliberately: .sa-actions .btn carries min-width:96px\n to keep the labelled buttons one size, and an icon button has no\n business being 96px wide. Its own class sidesteps that instead of\n fighting it with an override. */\n #siteAccessRoot .sa-icon-btn {\n background: transparent; border: 0; padding: 4px; margin: 0 2px;\n cursor: pointer; color: #555; line-height: 1; vertical-align: middle;\n }\n #siteAccessRoot .sa-icon-btn:hover { color: #000; }\n #siteAccessRoot .sa-delete { color: #d32f2f; }\n #siteAccessRoot .sa-delete:hover { color: #a02020; }\n /* 20px, the same as the drawer's. The tab's other icons are 16px\n and scoped to .btn, which this is not \u2014 left alone it rendered at\n the material default 24px and sat noticeably larger than the\n example. */\n #siteAccessRoot .sa-icon-btn .material-icons { font-size: 20px; }\n /* ONE width for every ACTION button on the tab \u2014 the row actions,\n the invite button in the bar, and Copy in the result banner. They\n are the same kind of control, so they should not be three sizes.\n 96px is the tightest number that actually binds: the widest\n label (\"Resend\") measures 94.3px with its icon at the trimmed\n padding below, so anything smaller stops being one width and\n goes ragged again. 80px was the target and does not fit \u2014 it\n would need the icons dropped.\n\n Scoped to the three regions rather than every .btn under the\n root: DataTables' own controls (the pager buttons and the\n per-column filter_list dropdowns) are .btn too, and a 96px\n minimum turns the pager into a row of slabs. */\n #siteAccessRoot .sa-bar .btn,\n #siteAccessRoot .sa-result .btn,\n #siteAccessRoot .sa-actions .btn { min-width: 96px; }\n /* Icons sit inside the label, matching the MSSPs tab. Padding and\n gap are trimmed from the button default to buy back the width\n the icon costs. */\n #siteAccessRoot .sa-bar .btn,\n #siteAccessRoot .sa-result .btn,\n #siteAccessRoot .sa-actions .btn {\n display: inline-flex; align-items: center; justify-content: center;\n gap: 4px; padding-left: 8px; padding-right: 8px;\n }\n #siteAccessRoot .btn .material-icons-outlined { font-size: 16px; line-height: 1; }\n #siteAccessRoot .sa-pill {\n display: inline-block; padding: 2px 9px; border-radius: 11px;\n font-size: 11.5px; font-weight: 700; white-space: nowrap;\n }\n #siteAccessRoot .sa-pending { background: #fff3cd; color: #856404; }\n #siteAccessRoot .sa-invited { background: #cfe2ff; color: #084298; }\n #siteAccessRoot .sa-accepted { background: #d1e7dd; color: #0f5132; }\n #siteAccessRoot .sa-declined { background: #e9ecef; color: #495057; }\n /* The 1px border sits outside the padding, so the right padding is\n a pixel short of the gutter to land Copy on the same line as the\n buttons in the bar and the table. */\n #siteAccessRoot .sa-result {\n border: 1px solid #dee2e6; border-radius: 6px; margin-bottom: 12px;\n padding: 10px calc(var(--sa-gutter) - 1px) 10px 12px;\n }\n #siteAccessRoot .sa-result.ok { background: #f6fff9; border-color: #abefc6; }\n #siteAccessRoot .sa-result.warn { background: #fffaf0; border-color: #ffe08a; }\n #siteAccessRoot .sa-result-note { font-size: 13px; margin-bottom: 8px; }\n #siteAccessRoot .sa-link-row { display: flex; gap: 8px; align-items: center; }\n /* The link fills the row so Copy lands on the same right edge as\n Invite in the bar above \u2014 the two buttons read as one column\n rather than two arbitrary positions. min-width: 0 is what lets a\n flex item shrink past its content; without it the long URL would\n push the button off the edge. */\n #siteAccessRoot .sa-link {\n flex: 1; min-width: 0; font-family: monospace; font-size: 12px;\n }\n\n #adminPage .admin-tabs {\n margin: 8px 0 16px;\n border-bottom: 1px solid #dee2e6;\n }\n #adminPage .admin-tabs .nav-link {\n background: none;\n border: 1px solid transparent;\n border-bottom: none;\n color: #495057;\n padding: 8px 16px;\n margin-bottom: -1px;\n cursor: pointer;\n }\n #adminPage .admin-tabs .nav-link:hover {\n color: #1a1a1a;\n border-color: #e9ecef #e9ecef #dee2e6;\n }\n #adminPage .admin-tabs .nav-link.active {\n color: #1a1a1a;\n background: #fff;\n border-color: #dee2e6 #dee2e6 #fff;\n font-weight: 600;\n }\n #adminPage .admin-tabs .nav-link:focus,\n #adminPage .admin-tabs .nav-link.active:focus {\n outline: none;\n box-shadow: none;\n }\n #adminPage .admin-tabs .nav-link:focus-visible {\n outline: 2px solid var(--brand-accent, #b8964a);\n outline-offset: -2px;\n }\n #adminPage .mssp-card {\n border: 1px solid #dee2e6;\n border-radius: 8px;\n padding: 14px 16px;\n margin-bottom: 14px;\n max-width: 900px;\n }\n #adminPage .mssp-form-row {\n display: flex;\n align-items: center;\n gap: 8px;\n flex-wrap: wrap;\n margin-bottom: 8px;\n }\n #adminPage .mssp-form-row label {\n margin: 0;\n font-size: 13px;\n color: #495057;\n min-width: fit-content;\n }\n #adminPage .mssp-form-row .form-control {\n width: auto;\n min-width: 180px;\n flex: 1 1 180px;\n }\n /* Form actions sit on their own right-aligned row: three labeled\n inputs fill the card width, so buttons on the same line would\n wrap unpredictably instead of reading as deliberate actions. */\n #adminPage .mssp-actions-row {\n display: flex;\n justify-content: flex-end;\n gap: 8px;\n margin-bottom: 8px;\n }\n #adminPage .partner-header {\n display: flex;\n align-items: center;\n gap: 10px;\n flex-wrap: wrap;\n margin-bottom: 8px;\n }\n #adminPage .partner-header .partner-name { margin: 0; }\n #adminPage .partner-badge {\n font-size: 12px;\n border-radius: 10px;\n padding: 1px 8px;\n border: 1px solid transparent;\n }\n #adminPage .partner-badge-stale { background: #fff3cd; border-color: #ffe69c; color: #664d03; }\n #adminPage .partner-badge-orphan { background: #f8d7da; border-color: #f1aeb5; color: #58151c; }\n #adminPage .partner-domain { font-family: monospace; font-size: 12px; color: #6c757d; }\n #adminPage .partner-logo {\n height: 28px;\n max-width: 160px;\n object-fit: contain;\n vertical-align: middle;\n margin-right: 6px;\n }\n #adminPage .partner-swatch {\n display: inline-block;\n width: 18px;\n height: 18px;\n border-radius: 4px;\n border: 1px solid rgba(0,0,0,0.15);\n vertical-align: middle;\n margin-right: 4px;\n }\n #adminPage .mssp-id-row {\n font-family: monospace;\n font-size: 11.5px;\n color: #8a8984;\n margin-bottom: 8px;\n }\n #adminPage .mssp-tenants-row {\n display: flex;\n align-items: center;\n gap: 6px;\n flex-wrap: wrap;\n margin-bottom: 8px;\n }\n #adminPage .mssp-tenants-label { font-size: 13px; color: #495057; margin-right: 4px; }\n #adminPage .mssp-tenant-chip {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n background: #f1f3f5;\n border: 1px solid #dee2e6;\n border-radius: 12px;\n padding: 2px 6px 2px 10px;\n font-size: 13px;\n }\n /* A managed tenant whose assignment grants standing admin access. */\n #adminPage .mssp-tenant-chip-standing { border-color: #b6d4fe; background: #e7f1ff; }\n #adminPage .mssp-tenant-chip-standing .material-icons-outlined { font-size: 14px; color: #0a58ca; }\n #adminPage .partner-legend { display: flex; gap: 8px; align-items: center; font-size: 12px; color: #6c757d; }\n #adminPage .mssp-chip-remove,\n #adminPage .mssp-domain-remove {\n border: none;\n background: none;\n cursor: pointer;\n font-size: 14px;\n line-height: 1;\n padding: 0 2px;\n color: #6c757d;\n }\n #adminPage .mssp-chip-remove:hover,\n #adminPage .mssp-domain-remove:hover { color: #d03b3b; }\n #adminPage .mssp-create-card { background: #fafbfc; }\n /* Icon-hint line \u2014 same visual language as the table scroll\n hint (small italic muted text beside an outlined icon). */\n #adminPage .mssp-branding-hint {\n display: flex;\n align-items: center;\n gap: 6px;\n color: #6c757d;\n font-style: italic;\n font-size: 13px;\n margin: -8px 0 16px;\n }\n #adminPage .mssp-branding-hint .material-icons-outlined { font-size: 17px; }\n /* Gold \"+ Create\" pill \u2014 matches the rules drawer's create\n button (RulesDrawer __create). */\n #adminPage .mssp-create-btn {\n background: var(--brand-accent, #d4b25a);\n /* Follow the branded accent foreground like every .btn does \u2014\n a hardcoded dark text was unreadable on MSSP themes whose\n accent needs a light foreground (e.g. T-Ones blue). */\n color: var(--brand-accent-fg, #1a1a1a);\n border: none;\n font-weight: 500;\n border-radius: 4px;\n padding: 5px 12px;\n font-size: 13px;\n cursor: pointer;\n white-space: nowrap;\n }\n #adminPage .mssp-create-btn:hover { filter: brightness(0.94); }\n /* Save / Delete carry a leading outlined icon; keep the icon\n sized to the 13px label and vertically centered. */\n /* Match the + Create pill's type + padding, and give every\n card button the same fixed width so the columns line up. */\n #adminPage .mssp-save-btn,\n #adminPage .mssp-delete-btn,\n #adminPage .mssp-add-btn,\n #adminPage .mssp-create-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: 5px;\n font-size: 13px;\n font-weight: 500;\n padding: 5px 0;\n width: 80px;\n }\n #adminPage .mssp-save-btn .material-icons-outlined,\n #adminPage .mssp-add-btn .material-icons-outlined {\n font-size: 16px;\n color: inherit;\n }\n #adminPage .mssp-delete-btn .material-icons {\n font-size: 16px;\n color: inherit;\n }\n\n /* TAM / SE tab \u2014 same visual language as the tabs above. */\n #tamAdminRoot .tam-toolbar {\n display: flex; align-items: center; gap: 12px; margin-bottom: 12px;\n }\n #tamAdminRoot #tamTenantFilter { max-width: 320px; }\n #tamAdminRoot .tam-count { color: #6c757d; font-size: 13px; white-space: nowrap; }\n /* View-As banner: these checkboxes belong to someone else. Gold\n left rule matches the Notifications page's advisory callouts. */\n #tamAdminRoot .tam-onbehalf {\n margin: 0 0 12px;\n padding: 8px 12px;\n border-left: 3px solid #c8a04a;\n background: #fdf7e8;\n color: #5a4a22;\n font-size: 13px;\n }\n #tamAdminRoot .tam-tenant-list {\n display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));\n gap: 2px 16px; max-width: 900px;\n }\n #tamAdminRoot .tam-tenant-row {\n display: flex; align-items: center; gap: 8px;\n padding: 4px 6px; border-radius: 4px; cursor: pointer;\n font-size: 13.5px; margin: 0;\n }\n #tamAdminRoot .tam-tenant-row:hover { background: #f1f3f5; }\n #tamAdminRoot .tam-tenant-check { margin: 0; cursor: pointer; }\n #tamAdminRoot .tam-tenant-name {\n overflow: hidden; text-overflow: ellipsis; white-space: nowrap;\n }\n `;\n document.head.appendChild(style);\n }\n\n private async displayLifecyclePubSubStatus(): Promise {\n const pubsubStatusValue = document.getElementById('pubsubLifecycleStatusValue') as HTMLSpanElement;\n if (!pubsubStatusValue) {\n console.warn(\"No pubsubLifecycleStatusValue element found\");\n return;\n }\n\n pubsubStatusValue.textContent = t2('body.checking');\n\n const status = await this.adminStore.pubSubLifecycleStatus;\n pubsubStatusValue.textContent = pubSubStatusLabel(status);\n }\n\n private async displayGalactusPubSubStatus(): Promise {\n const pubsubStatusValue = document.getElementById('pubsubGalactusStatusValue') as HTMLSpanElement;\n if (!pubsubStatusValue) {\n console.warn(\"No pubsubGalactusStatusValue element found\");\n return;\n }\n\n pubsubStatusValue.textContent = t2('body.checking');\n\n const status = await this.adminStore.pubSubGalactusStatus;\n pubsubStatusValue.textContent = pubSubStatusLabel(status);\n }\n\n private updateGalactusPubSubLabel(status: string): void {\n // If status === \"subscribed\" => text should say \"Active\"; else => \"Inactive\"\n const pubsubStatusValue = document.getElementById('pubsubGalactusStatusValue');\n if (!pubsubStatusValue) {\n console.warn(\"pubsubGalactusStatusValue element not found.\");\n return;\n }\n\n // Update the text\n pubsubStatusValue.textContent = (status === 'active') ? t2('col.active') : t2('body.inactive');\n\n // Flash animation\n pubsubStatusValue.classList.add('flash-update');\n setTimeout(() => {\n pubsubStatusValue.classList.remove('flash-update');\n }, 1500);\n }\n\n private subscribeGalactusPubSub(): void {\n this.showConfirmationDialog(t2('body.areYouSureYouWantTo'))\n .then((confirmed) => {\n if (!confirmed) {\n console.log('Subscribe cancelled.');\n\n const checkbox = document.getElementById(\"pubSubCheckbox\");\n if (checkbox) {\n checkbox.checked = false;\n }\n return;\n }\n\n this.header.showLoading();\n this.clearMessage();\n\n this.adminStore.subscribeGalactusPubSub().then((data) => {\n this.header.hideLoading();\n const { message, status } = data;\n this.showMessage(\"completed\", t2('body.pubSubStatus', { message: escapeHtml(message), status: escapeHtml(status) }));\n this.updateGalactusPubSubLabel(status);\n }).catch((error) => {\n this.header.hideLoading();\n console.error('Failed to subscribe:', error);\n this.showMessage(\"failed\", escapeHtml(error.message));\n // The subscribe did not happen: the box goes back.\n const checkbox = document.getElementById(\"pubSubCheckbox\");\n if (checkbox) checkbox.checked = false;\n })\n });\n }\n\n private unsubscribeGalactusPubSub(): void {\n this.showConfirmationDialog(t2('body.areYouSureYouWantTo2'))\n .then((confirmed) => {\n if (!confirmed) {\n console.log('Unsubscribe cancelled.');\n const checkbox = document.getElementById(\"pubSubCheckbox\");\n if (checkbox) {\n checkbox.checked = true;\n }\n return;\n }\n\n this.header.showLoading();\n this.clearMessage();\n\n this.adminStore.unsubscribeGalactusPubSub().then((data) => {\n this.header.hideLoading();\n const { message, status } = data;\n this.showMessage(\"completed\", t2('body.galactusPubSubStatus', { message: escapeHtml(message), status: escapeHtml(status) }));\n this.updateGalactusPubSubLabel(status);\n }).catch((error) => {\n this.header.hideLoading();\n console.error('Failed to unsubscribe:', error);\n this.showMessage(\"failed\", escapeHtml(error.message));\n // The unsubscribe did not happen: the box goes back.\n const checkbox = document.getElementById(\"pubSubCheckbox\");\n if (checkbox) checkbox.checked = true;\n })\n });\n }\n\n private async renderBlockedDomainsPanel() {\n const panel = document.getElementById(\"blockedDomainsPanel\") as HTMLElement;\n if (!panel) return;\n\n const list = document.getElementById(\"blockedDomainsList\") as HTMLElement;\n list.innerHTML = `${t2('dt.loading')}`; // copilot-lint-ignore: the only interpolation is our own t2() catalog string\n\n let domains: BlockedDomain[];\n try {\n domains = await this.adminStore.listBlockedDomains();\n } catch (err) {\n list.innerHTML = `${t2('body.failedToLoad', { error: escapeHtml((err as Error).message) })}`; // copilot-lint-ignore: the only param, err.message, is escapeHtml'd; the t2() string is ours\n return;\n }\n\n if (domains.length === 0) {\n list.innerHTML = `${t2('body.noBlockedDomainsDefaultEveryAuthenti')}`;\n return;\n }\n\n const rows = domains.map(d => {\n const by = d.blocked_by ? ` ${t2('body.blockedBy', { name: this.escapeHtml(d.blocked_by) })}` : \"\";\n const at = d.blocked_at ? new Date(d.blocked_at).toLocaleString() : \"\";\n return `\n \n ${this.escapeHtml(d.domain)}\n ${at}${by}\n ${this.escapeHtml(d.reason ?? \"\")}\n \n \n \n `;\n }).join(\"\");\n\n list.innerHTML = `\n \n \n ${rows}\n
${t2('col.domain')}${t2('body.blockedAt')}${t2('col.reason')}
`;\n\n list.querySelectorAll(\"button.unblock-btn\").forEach(btn => {\n btn.addEventListener(\"click\", async () => {\n const domain = btn.dataset.domain ?? \"\";\n if (!domain) return;\n const ok = await this.showConfirmationDialog(t2('body.unblockDomainConfirm', { domain }));\n if (!ok) return;\n try {\n await this.adminStore.unblockDomain(domain);\n await this.renderBlockedDomainsPanel();\n } catch (err) {\n this.showMessage(\"failed\", escapeHtml((err as Error).message));\n }\n });\n });\n }\n\n // Connector Redirects tab \u2014 a proper DataTable in the app's\n // common-table idiom (per-column filter_list controls, length/info/\n // paging in the top bar), matching the Notifications / Applications\n // tables. Every write destroys + re-creates the instance from fresh\n // server data.\n private async renderConnectorRedirectsPanel() {\n const panel = document.getElementById(\"connectorRedirectsPanel\") as HTMLElement;\n if (!panel) return;\n\n const list = document.getElementById(\"connectorRedirectsList\") as HTMLElement;\n if (this.connectorDt) {\n try { this.connectorDt.destroy(); } catch { /* noop */ }\n this.connectorDt = null;\n }\n list.innerHTML = `${t2('dt.loading')}`;\n\n let patterns: ConnectorRedirect[];\n try {\n patterns = await this.adminStore.listConnectorRedirects();\n } catch (err) {\n list.innerHTML = `${t2('body.failedToLoad', { error: escapeHtml((err as Error).message) })}`; // copilot-lint-ignore: the only param, err.message, is escapeHtml'd; the t2() string is ours\n return;\n }\n\n if (patterns.length === 0) {\n list.innerHTML = `${t2('body.adminConnectorNoRows')}`;\n return;\n }\n\n const TABLE_ID = \"connectorRedirectsTable\";\n list.innerHTML = `
`;\n\n this.connectorDt = ($(`#${TABLE_ID}`) as any).DataTable({\n data: patterns,\n order: [[3, 'asc']], // seeds first, admin additions below\n columns: [\n {\n data: 'pattern',\n title: t2('col.pattern'),\n render: (val: string, type: string) =>\n type === 'display' ? `${this.escapeHtml(val)}` : val,\n },\n // Label and notes are admin-entered text: a column without a\n // render goes in through innerHTML, so escape for display.\n {\n data: 'label', title: t2('col.label'), defaultContent: '\u2014',\n render: (val: string, type: string) => (type === 'display' ? this.escapeHtml(val ?? '\u2014') : val),\n },\n {\n data: 'notes', title: t2('col.notes'), defaultContent: '',\n render: (val: string, type: string) => (type === 'display' ? this.escapeHtml(val ?? '') : val),\n },\n {\n data: 'created_at',\n title: t2('col.created'),\n render: (val: string, type: string, row: ConnectorRedirect) => {\n if (type !== 'display') return val ?? '';\n const at = val ? new Date(val).toLocaleString() : '';\n // \"seed\" is the one-off migration marker, not a real\n // actor \u2014 attribute only rows an admin added by hand.\n const by = row.created_by && row.created_by !== 'seed'\n ? ` ${this.escapeHtml(row.created_by)}` : '';\n return `${at}${by}`;\n },\n },\n {\n data: 'pattern',\n title: '',\n orderable: false,\n className: 'blocked-domain-action-cell',\n render: (val: string, type: string) =>\n type === 'display'\n ? ``\n : val,\n },\n ],\n paging: true,\n pageLength: 20,\n lengthMenu: [[20, 50, 100, -1], [20, 50, 100, 'All']],\n searching: true,\n info: true,\n autoWidth: false,\n dom: \"<'dt-top d-flex align-items-center justify-content-between mb-2'lip>t\",\n columnDefs: [\n {\n // Pattern / Label / Notes \u2014 free-text substring search\n targets: [0, 1, 2],\n columnControl: [['orderAsc', 'orderDesc', 'searchText']],\n },\n {\n // Created \u2014 sort only\n targets: [3],\n columnControl: [['orderAsc', 'orderDesc']],\n },\n {\n // Actions \u2014 no controls\n targets: [4],\n columnControl: [],\n },\n ],\n });\n\n setupDataTableUI(TABLE_ID, this.connectorDt!, {\n columnControl: {\n dropdownMaterialIcon: 'filter_list',\n fixHeaderLayout: true,\n },\n });\n\n // Delegated: DataTables re-renders rows on page/filter changes, so\n // per-button listeners would be lost. Every render (tab switch, add,\n // remove) runs this again: abort the previous listener first, or one\n // Remove click would confirm and DELETE once per earlier render.\n this.connectorListAbort?.abort();\n this.connectorListAbort = new AbortController();\n list.addEventListener(\"click\", async (e) => {\n const btn = (e.target as HTMLElement).closest(\"button.remove-connector-btn\");\n if (!btn) return;\n const pattern = btn.dataset.pattern ?? \"\";\n if (!pattern) return;\n const ok = await this.showConfirmationDialog(t2('body.adminConnectorRemoveConfirm', { pattern }));\n if (!ok) return;\n try {\n await this.adminStore.removeConnectorRedirect(pattern);\n await this.renderConnectorRedirectsPanel();\n } catch (err) {\n this.showMessage(\"failed\", escapeHtml((err as Error).message));\n }\n }, { signal: this.connectorListAbort.signal });\n }\n\n private async submitBlockDomain() {\n const domainInput = document.getElementById(\"blockDomainInput\") as HTMLInputElement;\n const reasonInput = document.getElementById(\"blockReasonInput\") as HTMLInputElement;\n const domain = (domainInput.value ?? \"\").trim().toLowerCase();\n const reason = (reasonInput.value ?? \"\").trim();\n if (!domain) {\n this.showMessage(\"failed\", t2('body.domainIsRequired'));\n return;\n }\n try {\n await this.adminStore.blockDomain(domain, reason);\n domainInput.value = \"\";\n reasonInput.value = \"\";\n await this.renderBlockedDomainsPanel();\n } catch (err) {\n this.showMessage(\"failed\", escapeHtml((err as Error).message));\n }\n }\n\n private escapeHtml(s: string): string {\n return s.replace(/[&<>\"']/g, c =>\n ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" }[c]!)\n );\n }\n\n private resetDB() {\n\n this.showConfirmationDialog(t2('body.areYouSureYouWantTo6')).then((confirmImport) => {\n if (confirmImport) {\n this.performResetDB();\n } else {\n console.log('ResetDB cancelled');\n return;\n }\n });\n\n\n }\n\n private performResetDB() {\n this.header.showLoading();\n this.clearMessage();\n this.adminStore.resetDatabase()\n .then(() => {\n this.header.hideLoading();\n this.showMessage(\"completed\", t2('body.theDatabaseWasResetSuccessfully'));\n })\n .catch(error => {\n this.header.hideLoading();\n console.error('Failed to reset DB:', error);\n this.showMessage(\"failed\", escapeHtml(error.message));\n });\n }\n\n\n private resetCallers() {\n this.showConfirmationDialog(t2('body.areYouSureYouWantTo3')).then((confirmImport) => {\n if (confirmImport) {\n this.performResetCallers();\n } else {\n console.log('ResetCallers cancelled');\n return;\n }\n });\n\n\n }\n\n private resetLogs() {\n\n this.showConfirmationDialog(t2('body.areYouSureYouWantTo14')).then((confirmImport) => {\n if (confirmImport) {\n this.performResetLogs();\n } else {\n console.log('ResetLogs cancelled');\n return;\n }\n });\n\n\n }\n\n private resetLifecycle() {\n this.showConfirmationDialog(t2('body.areYouSureYouWantTo13')).then((confirmImport) => {\n if (confirmImport) {\n this.performResetLifecycle();\n } else {\n console.log('ResetLifecycle cancelled');\n return;\n }\n });\n }\n\n private resetBIEvents() {\n this.showConfirmationDialog(t2('body.areYouSureYouWantTo5')).then((confirmImport) => {\n if (confirmImport) {\n this.performResetBIEvents();\n } else {\n console.log('ResetBIEvents cancelled');\n return;\n }\n });\n }\n\n private resetDeveloperFiles() {\n this.showConfirmationDialog(t2('body.areYouSureYouWantTo7')).then((confirmImport) => {\n if (confirmImport) {\n this.performResetDeveloperFiles();\n } else {\n console.log('ResetDeveloperFiles cancelled');\n return;\n }\n });\n }\n\n private resetACLSimulationTables() {\n this.showConfirmationDialog(t2('body.areYouSureYouWantTo4')).then((confirmImport) => {\n if (confirmImport) {\n this.performResetACLSimulationTables();\n } else {\n console.log('ResetACLSimulationTables cancelled');\n return;\n }\n });\n }\n\n private resetExecutables() {\n this.showConfirmationDialog(t2('body.areYouSureYouWantTo12')).then((confirmImport) => {\n if (confirmImport) {\n this.performResetExecutables();\n } else {\n console.log('ResetExecutables cancelled');\n return;\n }\n });\n }\n\n private resetDriftReportTables() {\n this.showConfirmationDialog(t2('body.areYouSureYouWantTo11'))\n .then((confirm) => {\n if (confirm) {\n this.performResetDriftReportTables();\n } else {\n console.log('ResetDriftReportTables cancelled');\n return;\n }\n });\n }\n\n private resetMPReleaseTables() {\n this.showConfirmationDialog(t2('body.areYouSureYouWantTo9'))\n .then((confirm) => {\n if (confirm) {\n this.performResetMPReleaseTables();\n } else {\n console.log('ResetMPReleaseTables cancelled');\n return;\n }\n });\n }\n\n private resetNodeBuckets() {\n this.showConfirmationDialog(t2('body.areYouSureYouWantTo16'))\n .then((confirm) => {\n if (confirm) {\n this.performResetNodeBuckets();\n } else {\n console.log('ResetNodeBuckets cancelled');\n return;\n }\n });\n }\n\n private resetNodeBackups() {\n this.showConfirmationDialog(t2('body.areYouSureYouWantTo15'))\n .then((confirm) => {\n if (confirm) {\n this.performResetNodeBackups();\n } else {\n console.log('ResetNodeBackups cancelled');\n return;\n }\n });\n }\n\n\n private resetServices() {\n this.showConfirmationDialog(t2('body.areYouSureYouWantTo17'))\n .then((confirm) => confirm && this.performResetServices());\n }\n\n private resyncSecrets() {\n this.showConfirmationDialog(t2('body.areYouSureYouWantTo18')).then((confirmImport) => {\n if (confirmImport) {\n this.performResyncSecrets();\n } else {\n console.log('ResyncSecrets cancelled');\n return;\n }\n });\n }\n\n\n\n private performResetServices() {\n this.header.showLoading();\n this.clearMessage();\n this.adminStore.resetServices()\n .then(() => {\n this.header.hideLoading();\n this.showMessage(\"completed\", t2('body.theServicesDatabaseWasResetSuccessfu'));\n })\n .catch(error => {\n this.header.hideLoading();\n console.error('Failed to reset services database:', error);\n this.showMessage(\"failed\", escapeHtml(error.message));\n });\n }\n\n private resetMPNotifications() {\n this.showConfirmationDialog(\n t2('body.areYouSureYouWantTo8')\n ).then(ok => ok && this.performResetMPNotifications());\n }\n\n private performResetMPNotifications() {\n this.header.showLoading();\n this.clearMessage();\n this.adminStore.resetMPNotifications()\n .then(() => {\n this.header.hideLoading();\n this.showMessage('completed',\n t2('body.theMpNotificationsDatabaseWasReset'));\n })\n .catch(err => {\n this.header.hideLoading();\n this.showMessage('failed', escapeHtml(err.message));\n });\n }\n\n private performResetCallers() {\n this.header.showLoading();\n this.clearMessage();\n this.adminStore.resetCallers()\n .then(() => {\n this.header.hideLoading();\n this.showMessage(\"completed\", t2('body.theCallerAuthenticationTablesWereRes'));\n })\n .catch(error => {\n this.header.hideLoading();\n console.error('Failed to reset Caller Authentication tables:', error);\n this.showMessage(\"failed\", escapeHtml(error.message));\n });\n }\n\n private performResetLogs() {\n this.header.showLoading();\n this.clearMessage();\n this.adminStore.resetLogs()\n .then(() => {\n this.header.hideLoading();\n this.showMessage(\"completed\", t2('body.theLogsDatabaseWasResetSuccessfully'));\n })\n .catch(error => {\n this.header.hideLoading();\n console.error('Failed to reset logs database:', error);\n this.showMessage(\"failed\", escapeHtml(error.message));\n });\n }\n\n private performResetLifecycle() {\n this.header.showLoading();\n this.clearMessage();\n this.adminStore.resetLifecycle()\n .then(() => {\n this.header.hideLoading();\n this.showMessage(\"completed\", t2('body.theLifecycleDatabaseWasResetSuccessf'));\n })\n .catch(error => {\n this.header.hideLoading();\n console.error('Failed to reset lifecycle database:', error);\n this.showMessage(\"failed\", escapeHtml(error.message));\n });\n }\n\n private performResetBIEvents() {\n this.header.showLoading();\n this.clearMessage();\n this.adminStore.resetBIEvents()\n .then(() => {\n this.header.hideLoading();\n this.showMessage(\"completed\", t2('body.theBiEventsDatabaseWasReset'));\n })\n .catch(error => {\n this.header.hideLoading();\n console.error('Failed to reset BI events database:', error);\n this.showMessage(\"failed\", escapeHtml(error.message));\n });\n }\n\n private performResetDeveloperFiles() {\n this.header.showLoading();\n this.clearMessage();\n this.adminStore.resetDeveloperFiles()\n .then(() => {\n this.header.hideLoading();\n this.showMessage(\"completed\", t2('body.theDeveloperFileTablesWereReset'));\n })\n .catch(error => {\n this.header.hideLoading();\n console.error('Failed to reset Developer File Tables:', error);\n this.showMessage(\"failed\", escapeHtml(error.message));\n });\n }\n\n private performResetACLSimulationTables() {\n this.header.showLoading();\n this.clearMessage();\n this.adminStore.resetACLSimulationTables()\n .then(() => {\n this.header.hideLoading();\n this.showMessage(\"completed\", t2('body.theAclSimulationTablesDatabaseWas'));\n })\n .catch(error => {\n this.header.hideLoading();\n console.error('Failed to reset ACL tests database:', error);\n this.showMessage(\"failed\", escapeHtml(error.message));\n });\n }\n\n private performResetExecutables() {\n this.header.showLoading();\n this.clearMessage();\n this.adminStore.resetExecutables()\n .then(() => {\n this.header.hideLoading();\n this.showMessage(\"completed\", t2('body.theExecutablesDatabaseWasResetSucces'));\n })\n .catch(error => {\n this.header.hideLoading();\n console.error('Failed to reset executables database:', error);\n this.showMessage(\"failed\", escapeHtml(error.message));\n });\n }\n\n private performResetDriftReportTables() {\n this.header.showLoading();\n this.clearMessage();\n this.adminStore.resetDriftReportTables()\n .then(() => {\n this.header.hideLoading();\n this.showMessage(\"completed\", t2('body.theDriftReportTablesWereReset'));\n })\n .catch(error => {\n this.header.hideLoading();\n console.error('Failed to reset drift report tables:', error);\n this.showMessage(\"failed\", escapeHtml(error.message));\n });\n }\n\n private performResetMPReleaseTables() {\n this.header.showLoading();\n this.clearMessage();\n this.adminStore.resetMPReleaseTables()\n .then(() => {\n this.header.hideLoading();\n this.showMessage(\"completed\", t2('body.theReleaseTablesWereResetSuccessfull'));\n })\n .catch(error => {\n this.header.hideLoading();\n console.error('Failed to reset Release tables:', error);\n this.showMessage(\"failed\", escapeHtml(error.message));\n });\n }\n\n private performResetNodeBuckets() {\n this.header.showLoading();\n this.clearMessage();\n this.adminStore.resetNodeBuckets()\n .then(() => {\n this.header.hideLoading();\n this.showMessage(\"completed\", t2('body.theNodeBucketsWereResetSuccessfully'));\n })\n .catch(error => {\n this.header.hideLoading();\n console.error('Failed to reset Node Buckets:', error);\n this.showMessage(\"failed\", escapeHtml(error.message));\n });\n }\n\n private performResetNodeBackups() {\n this.header.showLoading();\n this.clearMessage();\n this.adminStore.resetNodeBackups()\n .then(() => {\n this.header.hideLoading();\n this.showMessage(\"completed\", t2('body.theNodeBackupsWereResetSuccessfully'));\n })\n .catch(error => {\n this.header.hideLoading();\n console.error('Failed to reset Node Backups:', error);\n this.showMessage(\"failed\", escapeHtml(error.message));\n });\n }\n\n\n private performResyncSecrets() {\n this.header.showLoading();\n this.clearMessage();\n this.adminStore.resyncSecrets()\n .then((body) => {\n this.header.hideLoading();\n // Say how far it reached: a pod-only resync leaves the other\n // replicas on the old secrets until a restart.\n const outcome = secretsResyncOutcome(body);\n if (outcome.kind === 'heard') {\n this.showMessage(\"completed\", escapeHtml(t2('body.secretsResyncedHeardBy', { count: formatNumber(outcome.receivers) })));\n } else {\n this.showMessage(\"warning\", escapeHtml(t2('body.secretsResyncedThisPodOnly')));\n }\n })\n .catch(error => {\n this.header.hideLoading();\n console.error('Failed to resync secrets:', error);\n this.showMessage(\"failed\", escapeHtml(error.message));\n });\n }\n\n private clearMessage(): void {\n console.log('clearMessage');\n // Check if the messageArea exists before manipulating it\n if (this.messageArea) {\n this.messageArea.textContent = \"\";\n this.messageArea.style.display = \"none\"; // Hide the message area\n this.messageArea.className = \"\"; // Remove any applied classes\n }\n\n // Check if the progressBar exists before manipulating it\n if (this.progressBar) {\n this.progressBar.style.width = '0%'; // Reset progress bar\n }\n }\n\n\n private showMessage(status: \"failed\" | \"completed\" | \"running\" | \"warning\", message: string, progress?: number): void {\n let messageType: string;\n\n // Determine the message type and style based on the status\n switch (status) {\n case \"completed\":\n messageType = \"alert-success\";\n break;\n case \"failed\":\n messageType = \"alert-danger\";\n break;\n case \"running\":\n messageType = \"alert-info\";\n break;\n case \"warning\":\n messageType = \"alert-warning\";\n break;\n default:\n messageType = \"alert-secondary\";\n }\n\n // Update the message area\n this.messageArea.innerHTML = message;\n\n // Remove the margin-left from any tags inside messageArea\n this.messageArea.querySelectorAll(\"i\").forEach((icon) => {\n icon.style.marginLeft = \"0px\";\n });\n\n this.messageArea.className = `alert ${messageType}`;\n this.messageArea.style.display = message ? 'block' : 'none';\n }\n\n\n\n private setupEventListeners(adminBody: HTMLElement) {\n const deleteButton = adminBody.querySelector('#deleteButton') as HTMLButtonElement;\n const pubSubLabel = adminBody.querySelector('#pubSubLabel') as HTMLLabelElement;\n const pubSubCheckbox = adminBody.querySelector('#pubSubCheckbox') as HTMLInputElement;\n const typeSelect = adminBody.querySelector('#typeSelect') as HTMLSelectElement;\n const blockDomainBtn = adminBody.querySelector('#blockDomainBtn') as HTMLButtonElement | null;\n\n if (typeSelect) {\n typeSelect.addEventListener('change', async () => {\n const selected = typeSelect.value as AdminCommands;\n\n // default state\n deleteButton.style.display = 'none';\n pubSubLabel.style.display = 'none';\n this.analyzeButton.style.display = 'none';\n this.resyncSecretsButton.style.display = 'none';\n\n switch (selected) {\n case AdminCommands.ResetDB:\n case AdminCommands.ResetLogs:\n case AdminCommands.ResetCallers:\n case AdminCommands.ResetLifecycle:\n case AdminCommands.ResetBIEvents:\n case AdminCommands.ResetDeveloperFiles:\n case AdminCommands.ResetACLSimulationTables:\n case AdminCommands.ResetExecutables:\n case AdminCommands.ResetDriftReportTables:\n case AdminCommands.ResetMPReleaseTables:\n case AdminCommands.ResetNodeBuckets:\n case AdminCommands.ResetNodeBackups:\n case AdminCommands.ResetServices:\n case AdminCommands.ResetMPNotifications:\n deleteButton.style.display = 'inline-block';\n break;\n\n case AdminCommands.ResyncSecrets:\n this.resyncSecretsButton.style.display = 'inline-block';\n break;\n\n default:\n console.warn(`Unhandled admin command in UI: ${selected}`);\n }\n });\n }\n\n\n if (deleteButton) {\n deleteButton.addEventListener('click', (e) => {\n e.stopPropagation();\n\n const selectedValue = typeSelect.value as AdminCommands;\n switch (selectedValue) {\n case AdminCommands.ResetDB:\n this.resetDB();\n break;\n\n case AdminCommands.ResetCallers:\n this.resetCallers();\n break;\n\n case AdminCommands.ResetLogs:\n this.resetLogs();\n break;\n\n case AdminCommands.ResetLifecycle:\n this.resetLifecycle();\n break;\n\n case AdminCommands.ResetBIEvents:\n this.resetBIEvents();\n break;\n\n case AdminCommands.ResetDeveloperFiles:\n this.resetDeveloperFiles();\n break;\n\n case AdminCommands.ResetACLSimulationTables:\n this.resetACLSimulationTables();\n break;\n\n case AdminCommands.ResetExecutables:\n this.resetExecutables();\n break;\n\n case AdminCommands.ResetDriftReportTables:\n this.resetDriftReportTables();\n break;\n\n case AdminCommands.ResetMPReleaseTables:\n this.resetMPReleaseTables();\n break;\n\n case AdminCommands.ResetNodeBuckets:\n this.resetNodeBuckets();\n break;\n\n case AdminCommands.ResetNodeBackups:\n this.resetNodeBackups();\n break;\n\n\n case AdminCommands.ResetServices:\n this.resetServices();\n break;\n\n case AdminCommands.ResetMPNotifications:\n this.resetMPNotifications();\n break;\n\n default:\n console.warn(`Unhandled admin command: ${selectedValue}`);\n }\n });\n }\n\n if (pubSubCheckbox) {\n pubSubCheckbox.addEventListener('change', () => {\n if (pubSubCheckbox.checked) {\n // If user checks \"Active\", subscribe\n this.subscribeGalactusPubSub();\n } else {\n // If user unchecks, unsubscribe\n this.unsubscribeGalactusPubSub();\n }\n });\n }\n\n if (blockDomainBtn) {\n blockDomainBtn.addEventListener('click', () => {\n this.submitBlockDomain();\n });\n }\n\n const addConnectorRedirectBtn = adminBody.querySelector('#addConnectorRedirectBtn') as HTMLButtonElement | null;\n if (addConnectorRedirectBtn) {\n addConnectorRedirectBtn.addEventListener('click', () => {\n ConnectorRedirectFormDrawer.open({\n store: this.adminStore,\n onSaved: () => void this.renderConnectorRedirectsPanel(),\n });\n });\n }\n\n if (this.resyncSecretsButton) {\n this.resyncSecretsButton.addEventListener('click', () => {\n this.resyncSecrets();\n });\n }\n\n typeSelect.dispatchEvent(new Event('change'));\n\n }\n\n\n private showConfirmationDialog(message: string): Promise {\n return new Promise((resolve) => {\n const modal = document.getElementById('confirm-modal');\n const messageElement = document.getElementById('confirm-message');\n const yesButton = document.getElementById('confirm-yes');\n const noButton = document.getElementById('confirm-no');\n\n console.log('Modal elements:', { modal, messageElement, yesButton, noButton });\n console.log('Confirmation message:', message);\n\n if (modal && messageElement && yesButton && noButton) {\n messageElement.textContent = message;\n // Show the modal\n modal.classList.add('show');\n\n // Prevent background scrolling\n document.body.style.overflow = 'hidden';\n\n const closeModal = () => {\n // Hide the modal\n modal.classList.remove('show');\n document.body.style.overflow = 'auto'; // Re-enable scrolling\n yesButton.removeEventListener('click', yesHandler);\n noButton.removeEventListener('click', noHandler);\n document.removeEventListener('keydown', keydownHandler);\n };\n\n const yesHandler = () => {\n closeModal();\n resolve(true);\n };\n\n const noHandler = () => {\n closeModal();\n resolve(false);\n };\n\n const keydownHandler = (event: KeyboardEvent) => {\n if (event.key === 'Escape') {\n closeModal();\n resolve(false);\n }\n };\n\n yesButton.addEventListener('click', yesHandler);\n noButton.addEventListener('click', noHandler);\n document.addEventListener('keydown', keydownHandler);\n } else {\n console.error('Modal elements not found');\n resolve(false);\n }\n });\n }\n\n}\n", "// ConnectorRedirectFormDrawer \u2014 slide-out form for allowing a new MCP\n// connector redirect pattern (Admin \u2192 Connector Redirects). Same Drawer base\n// as the Notifications RuleFormDrawer, sized down to the three fields this\n// form needs. Validation happens server-side (api.parseRedirectPattern); its\n// descriptive error is surfaced inline so the admin can fix the pattern\n// without losing what they typed.\n\nimport { Drawer } from './Drawer';\nimport { t as t2 } from '../i18n/I18n';\nimport { escapeHtml } from './FormatUtils';\nimport type AdminStore from '../stores/AdminStore';\n\ninterface OpenArgs {\n store: AdminStore;\n /** Called after a successful save so the page can re-render its list. */\n onSaved: () => void;\n}\n\nconst PREFIX = 'connector-redirect-form-drawer';\nconst ID = 'connector-redirect-form-drawer';\nconst STYLE_ID = 'connector-redirect-form-drawer-styles';\n\nlet drawer: Drawer | null = null;\n// True while a save's POST is in flight. The form is not rebuilt meanwhile\n// (open() just shows it again), so the reply always lands on the form that\n// sent it, and a second Save or Enter cannot send the pattern twice.\nlet saving = false;\n\nfunction ensureStyles(): void {\n if (document.getElementById(STYLE_ID)) return;\n const style = document.createElement('style');\n style.id = STYLE_ID;\n style.textContent = `\n .${PREFIX}__title { font-weight: 600; font-size: 15px; color: #111; }\n .${PREFIX}__subtitle { font-size: 12px; color: #666; margin-top: 2px; max-width: 520px; }\n .${PREFIX}__form { display: flex; flex-direction: column; gap: 14px; }\n .${PREFIX}__field { display: flex; flex-direction: column; gap: 4px; }\n .${PREFIX}__field > label { font-size: 12px; color: #666; }\n .${PREFIX}__field input { font-family: inherit; }\n .${PREFIX}__field input.mono { font-family: var(--font-mono, monospace); font-size: 13px; }\n .${PREFIX}__error { color: #b3261e; font-size: 13px; min-height: 18px; }\n .${PREFIX}__actions { display: flex; gap: 8px; margin-top: 6px; }\n .${PREFIX}__cancel { margin-right: auto; width: 80px; text-align: center; background: #5a6672; color: #fff; border: none; }\n .${PREFIX}__cancel:hover { background: #4a5560; color: #fff; }\n .${PREFIX}__save { min-width: 80px; text-align: center; background: var(--brand-accent, #d4b25a); color: var(--brand-accent-fg, #1a1a1a); border: none; font-weight: 500; }\n .${PREFIX}__save:hover { filter: brightness(0.94); }\n .${PREFIX}__save:disabled { opacity: 0.6; cursor: default; }\n `;\n document.head.appendChild(style);\n}\n\nexport const ConnectorRedirectFormDrawer = {\n open({ store, onSaved }: OpenArgs): void {\n ensureStyles();\n drawer ??= new Drawer({\n id: ID,\n classPrefix: PREFIX,\n ariaLabel: t2('body.adminConnectorDrawerTitle'),\n width: 'min(560px, 92vw)',\n keepWidthWhenStacked: true,\n });\n if (saving) {\n drawer.open();\n return;\n }\n\n drawer.headerEl.innerHTML = `\n
${escapeHtml(t2('body.adminConnectorDrawerTitle'))}
\n
${escapeHtml(t2('body.adminConnectorRedirectsHint'))}
\n `;\n drawer.bodyEl.innerHTML = `\n
\n
\n \n \n
\n
\n \n \n
\n
\n \n \n
\n
\n
\n \n \n
\n
\n `;\n\n const $id = (s: string) => drawer!.bodyEl.querySelector(s)!;\n const patternInput = $id('#crf-pattern') as HTMLInputElement;\n const labelInput = $id('#crf-label') as HTMLInputElement;\n const notesInput = $id('#crf-notes') as HTMLInputElement;\n const errorEl = $id('#crf-error');\n const saveBtn = $id('#crf-save') as HTMLButtonElement;\n\n $id('#crf-cancel').addEventListener('click', () => drawer!.close());\n\n const save = async () => {\n if (saving) return;\n const pattern = patternInput.value.trim();\n if (!pattern) {\n errorEl.textContent = t2('body.adminConnectorPatternRequired');\n patternInput.focus();\n return;\n }\n errorEl.textContent = '';\n saving = true;\n saveBtn.disabled = true;\n try {\n await store.addConnectorRedirect(pattern, labelInput.value.trim(), notesInput.value.trim());\n drawer!.close();\n onSaved();\n } catch (err) {\n errorEl.textContent = (err as Error).message;\n // Closed (Cancel, Esc) while saving: open it again, so the\n // failure is seen with what was typed still there to fix.\n if (!drawer!.isOpen()) drawer!.open();\n } finally {\n saving = false;\n saveBtn.disabled = false;\n }\n };\n saveBtn.addEventListener('click', () => void save());\n // Enter in any field submits \u2014 a three-field form should feel like one.\n for (const input of [patternInput, labelInput, notesInput]) {\n input.addEventListener('keydown', (e) => {\n if (e.key === 'Enter') void save();\n });\n }\n\n drawer.open();\n patternInput.focus();\n },\n};\n", "// MSSPAdminUI \u2014 the Admin page's Partners tab (ENG-3088; derived form under\n// the ENG-4123 follow-on). A READ-ONLY view of the partner registry the\n// server derives from the management plane:\n//\n// partner = the partner side of an active MP partner assignment\n// managed set = every tenant the partner's active assignments point at\n//\n// Dashcam never authors this \u2014 there is no create, no attach, no detach. The\n// tab shows what the last sweep found (partner tenant, its partner admins,\n// the tenants it brands \u2014 marked where the assignment grants standing admin\n// access), lets an operator re-run the sweep now, and edits the ONE attribute\n// the MP does not own: the node noun. Logos and colors stay on the\n// tenant-branding seed pipeline; the card previews them.\n//\n// Two flags matter on a card. `stale`: the last sweep ran and did not find\n// this partner (it has no active assignment left in the MP), so it keeps its\n// noun and branding but brands no tenant. `orphan`: a legacy hand-seeded row no sweep\n// has ever matched to an MP tenant; it brands nothing. Both kinds can be\n// deleted from the tab; a row the latest sweep confirmed cannot.\n//\n// Server surface: /api/mssps* on the Admin-OR-TAM `msspAdmin` group.\n\nimport { t as t2, brandNodeNoun } from '../i18n/I18n';\nimport AdminStore, { AdminTenant, PartnerInfo, PartnerSweepState } from '../stores/AdminStore';\nimport { escapeHtml } from '../utils/FormatUtils';\n\nexport class MSSPAdminUI {\n private root: HTMLElement | null = null;\n private partners: PartnerInfo[] = [];\n private sweep: PartnerSweepState | null = null;\n private tenants: AdminTenant[] = [];\n private tenantName = new Map();\n private sweeping = false;\n // Confirm helper supplied by AdminUI so we reuse the page's existing\n // #confirm-modal instead of owning a second modal.\n constructor(\n private readonly store: AdminStore,\n private readonly confirm: (message: string) => Promise,\n private readonly onError: (message: string) => void,\n ) { }\n\n async render(root: HTMLElement): Promise {\n this.root = root;\n root.innerHTML = `${t2('dt.loading')}`;\n try {\n const [list, tenants] = await Promise.all([\n this.store.listPartners(),\n this.store.listAdminTenants(),\n ]);\n this.partners = list.partners;\n this.sweep = list.sweep;\n this.tenants = tenants;\n } catch (err) {\n root.innerHTML = `${t2('body.failedToLoad', { error: escapeHtml((err as Error).message) })}`; // copilot-lint-ignore: the only param, err.message, is escapeHtml'd; the t2() string is ours\n return;\n }\n this.tenantName = new Map(this.tenants.map(tn => [tn.id, tn.name]));\n this.renderBody();\n }\n\n private async reload(): Promise {\n try {\n const list = await this.store.listPartners();\n this.partners = list.partners;\n this.sweep = list.sweep;\n } catch (err) {\n this.onError((err as Error).message);\n return;\n }\n this.renderBody();\n }\n\n private renderBody(): void {\n if (!this.root) return;\n const cards = this.partners.length\n ? this.partners.map(p => this.partnerCard(p)).join(\"\")\n : `

${t2('body.partnerNone')}

`;\n // One toolbar the width of the cards: the sweep status on the left,\n // the refresh icon on the right. The registry re-derives itself hourly\n // (jobs.StartPartnerSweep); the icon only kicks that off right now. The\n // derivation rule lives behind the info icon, not in a paragraph.\n const refreshLabel = this.sweeping ? t2('body.partnerSweepRunning') : t2('body.partnerSweepNow');\n const { line, note } = this.sweepStatus();\n // A legend for the two chip colours, shown once any partner has a\n // standing-admin assignment (otherwise every chip is the plain kind).\n const legend = this.partners.some(p => (p.standing_admin_tenant_ids ?? []).length > 0)\n ? `
\n ${esc(t2('body.partnerStandingAdmin'))}admin_panel_settings\n ${esc(t2('body.partnerLegendReach'))}\n
`\n : \"\";\n this.root.innerHTML = `\n
\n
\n \n info\n ${line}\n \n \n
\n ${note ? `
${note}
` : \"\"}\n ${legend}\n
\n ${cards}`;\n this.wire();\n }\n\n // The one-line status, plus an optional note that renders on its own\n // line beneath it (a truncated edge walk), so the status row stays one\n // line with the refresh icon centred on it.\n private sweepStatus(): { line: string; note: string } {\n const s = this.sweep;\n if (!s) return { line: t2('body.partnerSweepNever'), note: \"\" };\n const when = new Date(s.ran_at).toLocaleString();\n if (s.error) {\n return { line: `${t2('body.partnerSweepFailed', { when, error: esc(s.error) })}`, note: \"\" };\n }\n let line = t2('body.partnerStatusLine', {\n when, edges: String(s.edges_scanned), partners: String(s.partners_found),\n });\n if (s.tenant_errors > 0) {\n line += ` ${t2('body.partnerSweepErrors', { count: String(s.tenant_errors) })}`;\n }\n const note = s.truncated\n ? `${t2('body.partnerSweepTruncated')}`\n : \"\";\n return { line, note };\n }\n\n private partnerCard(p: PartnerInfo): string {\n // A managed tenant whose assignment grants standing admin access is\n // marked: the partner's admins administer it, not merely reach it.\n const standing = new Set(p.standing_admin_tenant_ids ?? []);\n const chips = p.tenant_ids.map(id => standing.has(id)\n ? `${esc(this.tenantName.get(id) ?? id)}admin_panel_settings`\n : `${esc(this.tenantName.get(id) ?? id)}`).join(\"\");\n // The partner_admin holders on the partner's own tenant.\n const admins = p.admins.map(a => `\n
\n ${esc(a.email)}\n
`).join(\"\");\n const badges = [\n p.orphan ? `${t2('body.partnerOrphan')}` : \"\",\n !p.orphan && p.stale ? `${t2('body.partnerStale')}` : \"\",\n ].join(\"\");\n const logo = p.branding.logo_data_url\n ? `\"${esc(t2('body.partnerLogoAlt',`\n : \"\";\n const swatches = [\n p.branding.primary_color_light, p.branding.primary_color_dark,\n p.branding.secondary_color_light, p.branding.secondary_color_dark,\n ].filter((c): c is string => !!c)\n .map(c => ``).join(\"\");\n const branding = (logo || swatches)\n ? `${logo}${swatches}`\n : `${t2('body.partnerNoBranding')}`;\n const partnerTenant = p.partner_tenant_id\n ? `${esc(this.tenantName.get(p.partner_tenant_id) ?? p.partner_tenant_id)}\n ${p.domain ? `${esc(p.domain)}` : \"\"}`\n : `${t2('body.partnerOrphanHint')}`;\n // Delete is offered only for rows the latest sweep did not confirm;\n // the server refuses the rest. It is disabled while this tab's sweep\n // runs: that sweep may adopt the orphan or re-confirm the stale row\n // after the card was drawn.\n const deleteBtn = (p.orphan || p.stale)\n ? ``\n : \"\";\n return `\n
\n
\n
${esc(p.name)}
\n ${badges}\n
\n
\n ${t2('body.partnerTenant')}\n ${partnerTenant}\n
\n
\n ${t2('body.partnerAdmins', { count: String(p.admins.length) })}\n ${admins ? `
${admins}
` : `${t2('body.partnerNoAdmins')}`}\n
\n
\n ${t2('body.msspManagedTenants', { count: String(p.tenant_ids.length) })}\n ${chips || `${t2('body.msspNoTenants')}`}\n
\n
\n ${t2('body.partnerBranding')}\n ${branding}\n
\n
\n ${t2('body.msspNodeNoun')}:\n ${p.node_noun\n ? `${esc(p.node_noun)}`\n : `${t2('body.partnerNounUnset', { noun: brandNodeNoun() })}`}\n \n \n
\n ${deleteBtn ? `
${deleteBtn}
` : \"\"}\n
${esc(p.mssp_id)}
\n
`;\n }\n\n private wire(): void {\n const root = this.root;\n if (!root) return;\n\n root.querySelector(\"#partnerSweepBtn\")?.addEventListener(\"click\", async () => {\n if (this.sweeping) return;\n this.sweeping = true;\n this.renderBody();\n try {\n await this.store.runPartnerSweep();\n } catch (err) {\n this.onError((err as Error).message);\n }\n this.sweeping = false;\n await this.reload();\n });\n\n // Node noun: a pencil swaps the value for an input. Enter or blur\n // commits, Escape cancels, and the whole tab re-renders from the\n // server either way.\n root.querySelectorAll(\".partner-noun-row\").forEach(row => {\n const id = row.dataset.mssp ?? \"\";\n const input = row.querySelector(\".partner-noun-input\");\n const pencil = row.querySelector(\".partner-noun-edit\");\n const shown = row.querySelector(\".partner-noun-value, .partner-noun-unset\");\n if (!input || !pencil) return;\n const original = input.value;\n let settled = false;\n const open = () => {\n if (shown) shown.hidden = true;\n pencil.hidden = true;\n input.hidden = false;\n input.focus();\n input.select();\n };\n const cancel = () => {\n if (settled) return;\n settled = true;\n input.value = original;\n input.hidden = true;\n pencil.hidden = false;\n if (shown) shown.hidden = false;\n settled = false;\n };\n const commit = async () => {\n if (settled) return;\n const noun = input.value.trim();\n if (noun === original.trim()) { cancel(); return; }\n settled = true;\n await this.run(() => this.store.updatePartnerNoun(id, noun));\n };\n pencil.addEventListener(\"click\", open);\n pencil.addEventListener(\"keydown\", e => { if (e.key === \"Enter\" || e.key === \" \") { e.preventDefault(); open(); } });\n input.addEventListener(\"keydown\", e => {\n if (e.key === \"Enter\") { e.preventDefault(); void commit(); }\n if (e.key === \"Escape\") { e.preventDefault(); cancel(); }\n });\n input.addEventListener(\"blur\", () => { void commit(); });\n });\n\n root.querySelectorAll(\".partner-delete-btn\").forEach(btn => {\n btn.addEventListener(\"click\", async () => {\n if (this.sweeping) return;\n const id = btn.dataset.mssp ?? \"\";\n const p = this.partners.find(x => x.mssp_id === id);\n const ok = await this.confirm(t2('body.partnerDeleteConfirm', { name: p?.name ?? id }));\n // A sweep started while the confirm was open may confirm\n // this row; the card is redrawn after it, so ask again then.\n if (!ok || this.sweeping) return;\n await this.run(() => this.store.deletePartner(id));\n });\n });\n }\n\n // Runs a mutation, surfaces its error via onError, and re-renders the\n // tab from a fresh list either way so the view stays truthful.\n private async run(fn: () => Promise): Promise {\n try {\n await fn();\n } catch (err) {\n this.onError((err as Error).message);\n }\n await this.reload();\n }\n}\n\nfunction esc(s: string): string {\n return s.replace(/[&<>\"']/g, c =>\n ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" }[c]!)\n );\n}\n\n", "// SiteAccessUI \u2014 the Admin page's Site Access tab.\n//\n// The review queue for mimic.com research access: who asked, and the two\n// actions worth taking (invite, decline). Gated on hasSiteAccess, which the\n// server computes from RoleAdmin OR RoleMarketing \u2014 deciding who reads the\n// research is a marketing call, not an engineering one.\n//\n// Server surface: /api/site-access/{requests,invite,decline}.\n//\n// The invite link is ALWAYS displayed, even on a successful send. Mimic has no\n// verified sending domain yet, so a \"sent\" that silently bounced would look\n// exactly like one that worked; showing the link means the reviewer can always\n// deliver it by hand. That is a deliberate UI choice, not a debug affordance.\n//\n// The queue is a DataTable in the app's common-table idiom \u2014 global search box\n// and page-length in the top row, info + pager below, per-column filter_list\n// controls in the headers. It is client-side: the queue is fetched once, one\n// list per status (the server caps each list at its OLDEST rows, so a single\n// status-less list would drop the newest pending ones; a status still cut at\n// the cap is named above the table) and every filter, sort and\n// page is then local, so narrowing by status costs no round trip. That is why\n// the standalone status \n \n \n
${this.renderInviteResult()}
\n ${this.truncatedStatuses.length === 0 ? '' : `

${this.esc(t2('body.siteAccessTruncated', {\n statuses: this.truncatedStatuses.map(st => this.statusLabel(st)).join(', '),\n n: this.truncatedLimit,\n }))}

`}\n
\n info\n ${t2('body.siteAccessActionsHint')}\n
\n
`;\n\n this.initTable();\n }\n\n private initTable(): void {\n this.dt = ($(`#${TABLE_ID}`) as any).DataTable({\n data: this.requests,\n // Newest first. The server hands the queue back oldest-first (the\n // order a queue is worked), but the reviewer's question here is\n // \"what came in since I last looked\" \u2014 and Pending is one click\n // away in the Status filter either way.\n order: [[COL_REQUESTED, 'desc']],\n columns: [\n {\n data: 'Email',\n title: t2('body.siteAccessEmail'),\n className: 'sa-email',\n // The address is the row's identity and the thing you scan\n // for, so bias the auto layout towards it \u2014 left to itself\n // it hands the width to Reason and the action buttons and\n // breaks every address over three lines.\n //\n // A PERCENTAGE, not a ch/px value: those are fixed, so on a\n // wide window every extra pixel went to the columns with no\n // width at all and the addresses stayed wrapped next to\n // empty space. A percentage of a width:100% table grows with\n // the viewport, which is what \"wider screen, wider column\"\n // actually requires.\n width: '24%',\n },\n { data: 'Name', title: t2('body.siteAccessName'), defaultContent: '' },\n { data: 'Company', title: t2('body.siteAccessCompany'), defaultContent: '' },\n {\n data: 'Reason',\n title: t2('body.siteAccessReason'),\n defaultContent: '',\n className: 'sa-reason',\n // Reason is why someone should be let in \u2014 the one field a\n // reviewer actually has to READ, so it wraps and shows in\n // full rather than being clipped to a single line. The title\n // attribute stays for the rare essay that still overflows.\n //\n // Shares the stretch with Email so the two free-text columns\n // take the slack on a wide window; everything else is\n // content-sized and stays put.\n width: '20%',\n render: (val: string, type: string) =>\n type === 'display'\n ? `${this.esc(val ?? '')}`\n : (val ?? ''),\n },\n {\n data: 'Status',\n title: t2('body.siteAccessStatus'),\n // Only 'display' gets the pill. Sorting, the global search\n // and the searchList all see the translated LABEL, so the\n // dropdown offers \"Pending\", not the raw 'pending'.\n render: (val: string, type: string) =>\n type === 'display'\n ? `${this.statusLabel(val)}`\n : this.statusLabel(val),\n },\n {\n // Who on our side acted, sitting next to Status so the pair\n // reads as one fact: \"Declined \u2014 by jim@mimic.com\".\n //\n // One column rather than two because a row only ever has one\n // answer: Status says WHICH action, this says who did it.\n // Declined rows read decided_by, everything else invited_by.\n // Accepted deliberately shows the INVITER \u2014 accepted is set\n // by the invitee creating their own account, so it is the\n // invite that was the decision worth attributing.\n //\n // Rows actioned before this shipped have neither, and render\n // blank on purpose: the actor was never recorded anywhere, so\n // there is nothing to show and nothing safe to infer.\n data: null,\n title: t2('body.siteAccessReviewedBy'),\n className: 'sa-reviewed-by',\n render: (_: unknown, type: string, row: SiteAccessRequest) => {\n const who = row.Status === 'declined'\n ? (row.DecidedBy ?? '')\n : (row.InvitedBy ?? '');\n if (type !== 'display') return who;\n return who ? `${this.esc(who)}` : '';\n },\n },\n {\n data: 'CreatedAt',\n title: t2('body.siteAccessRequested'),\n className: 'sa-requested',\n // Sort on the raw ISO string; only display is localized.\n render: (val: string, type: string) =>\n type === 'display' ? this.fmtDate(val) : (val ?? ''),\n },\n {\n data: null,\n title: '',\n orderable: false,\n searchable: false,\n className: 'sa-actions',\n render: (_: unknown, type: string, row: SiteAccessRequest) => {\n if (type !== 'display') return '';\n const email = this.esc(row.Email);\n const invited = row.Status === 'invited';\n // Decline only marks the request: an accepted prospect's\n // account keeps signing in, so it is not offered there.\n const canDecline = row.Status !== 'declined' && row.Status !== 'accepted';\n return `\n \n ${!canDecline ? '' :\n ``}\n `;\n },\n },\n ],\n dom: DOM_STRING,\n paging: true,\n pageLength: 10,\n lengthMenu: [[10, 25, 50, 100, -1], [10, 25, 50, 100, t2('body.all')]],\n searching: true,\n info: true,\n autoWidth: false,\n language: {\n emptyTable: t2('body.siteAccessNone'),\n zeroRecords: t2('body.siteAccessNoMatching'),\n search: '',\n },\n columnDefs: [\n {\n // Free-text identity columns \u2014 substring search.\n targets: [COL_EMAIL, COL_NAME, COL_COMPANY, COL_REASON],\n columnControl: [['orderAsc', 'orderDesc', 'searchText']],\n },\n {\n // Status is a closed set: pick from the values actually present.\n targets: [COL_STATUS],\n columnControl: [['orderAsc', 'orderDesc', 'searchList']],\n },\n {\n // A closed, small set in practice \u2014 the handful of people\n // who review these \u2014 so pick from the values present.\n targets: [COL_REVIEWED_BY],\n columnControl: [['orderAsc', 'orderDesc', 'searchList']],\n },\n {\n targets: [COL_REQUESTED],\n columnControl: [['orderAsc', 'orderDesc']],\n },\n {\n targets: [COL_ACTIONS],\n columnControl: [],\n },\n ],\n });\n\n setupDataTableUI(TABLE_ID, this.dt!, {\n columnControl: {\n dropdownMaterialIcon: 'filter_list',\n fixHeaderLayout: true,\n // Excel-style: a dropdown never offers a value whose selection\n // could only produce an empty table.\n cascadeSearchLists: true,\n },\n });\n\n this.seedStatusFilter();\n }\n\n // Open the tab on Pending \u2014 a review queue should show what needs a\n // decision \u2014 without the preset becoming a trap.\n //\n // The seed is a PLAIN REGEX COLUMN SEARCH, not a ColumnControl selection.\n // That is the seam utils/DataTables.ts's cascade is built around (\"the\n // preset seeds use a regex one\"): it reads the column's own plain search as\n // that column's predicate and EXCLUDES it when computing what the Status\n // dropdown may offer, so the list still shows every status present. Seeding\n // via ColumnControl's own state instead would cascade the dropdown down to\n // the one value already selected \u2014 the user could never get back out.\n //\n // The regex matches the LABEL because that is this column's filter data\n // (only 'display' renders the pill), and the label is translated \u2014 hence\n // building the alternation from statusLabel() rather than the raw status.\n private seedStatusFilter(): void {\n if (!this.dt || this.statusFilterSynced || !this.defaultStatusFilter.length) return;\n\n const alternation = this.defaultStatusFilter\n .map(s => this.escapeRegExp(this.statusLabel(s)))\n .join('|');\n this.dt.column(COL_STATUS).search(`^(${alternation})$`, true, false).draw(false);\n\n // Nothing pending, but there ARE requests: clear the preset rather than\n // opening on an empty table that reads as \"no requests at all\". Mirrors\n // PathsTable's default-operation-filter fallback.\n if (this.requests.length && this.dt.page.info().recordsDisplay === 0) {\n this.statusFilterSynced = true;\n this.dt.column(COL_STATUS).search('').draw(false);\n return;\n }\n\n this.watchStatusPopover();\n }\n\n // The moment the user first opens the Status dropdown, hand the filter over\n // to ColumnControl: drop our plain search (the two are ANDed, so leaving it\n // on would make \"Invited\" resolve to Pending AND Invited \u2014 no rows) and\n // pre-click the preset so the popover shows the filter that is actually\n // applied. Draws are suppressed across the swap: the net filter is\n // unchanged, so the table must not flicker.\n //\n // ColumnControl only builds the dropdown when it is opened, so a\n // MutationObserver is the only hook \u2014 same approach as NotificationsUI and\n // PathsTable. The clicking happens synchronously inside the observer\n // callback because the cascade's own observer defers its work to a\n // requestAnimationFrame specifically to land after preset handlers.\n private watchStatusPopover(): void {\n this.statusPopoverObserver?.disconnect();\n const container = (this.dt as any)?.table(0).container() as HTMLElement | undefined;\n if (!container) return;\n\n const wanted = new Set(this.defaultStatusFilter.map(s => this.statusLabel(s)));\n const known = new Set(\n ['pending', 'invited', 'accepted', 'declined'].map(s => this.statusLabel(s)));\n\n const observer = new MutationObserver((mutations) => {\n for (const m of mutations) {\n for (const added of Array.from(m.addedNodes)) {\n if (!(added instanceof HTMLElement)) continue;\n if (!added.classList.contains('dtcc-dropdown')) continue;\n\n const byLabel = new Map();\n added.querySelectorAll(\n '.dtcc-list-buttons button.dtcc-button').forEach(btn => {\n const label = (btn.querySelector('.dtcc-button-text')?.textContent ?? '').trim();\n if (known.has(label)) byLabel.set(label, btn);\n });\n // Status is the only searchList column, but require a\n // recognised option before claiming this dropdown.\n if (!byLabel.size) continue;\n\n this.statusFilterSynced = true;\n observer.disconnect();\n this.statusPopoverObserver = null;\n\n const dtAny = this.dt as any;\n const origDraw = dtAny?.draw;\n if (dtAny) dtAny.draw = () => this.dt;\n try {\n this.dt?.column(COL_STATUS).search('');\n for (const [label, btn] of byLabel) {\n if (wanted.has(label) && !btn.classList.contains('dtcc-button_active')) {\n btn.click();\n }\n }\n } finally {\n if (dtAny && origDraw) dtAny.draw = origDraw;\n }\n return;\n }\n }\n });\n\n observer.observe(container, { childList: true });\n this.statusPopoverObserver = observer;\n }\n\n private escapeRegExp(s: string): string {\n return s.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n }\n\n private async invite(email: string): Promise {\n const addr = email.trim();\n if (!addr) {\n this.onError(t2('body.siteAccessNeedEmail'));\n return;\n }\n if (!await this.confirm(t2('body.siteAccessConfirmInvite', { email: addr }))) return;\n\n try {\n const res = await this.store.inviteSiteUser(addr);\n this.lastInvite = { link: res.link, emailed: res.emailed, mailError: res.mail_error };\n // Reload AFTER recording it, so the banner survives the re-render:\n // without one, inviting looks like it erased the row, the link and\n // the confirmation all at once.\n await this.reload();\n } catch (err) {\n this.onError((err as Error).message);\n }\n }\n\n // The link is shown either way; only the surrounding sentence changes.\n private renderInviteResult(): string {\n const r = this.lastInvite;\n if (!r) return '';\n const note = r.emailed\n ? t2('body.siteAccessInviteSent')\n : t2('body.siteAccessInviteNotSent', { error: this.esc(r.mailError ?? '') });\n return `\n
\n
${note}
\n \n
`;\n }\n\n private async decline(email: string): Promise {\n if (!await this.confirm(t2('body.siteAccessConfirmDecline', { email }))) return;\n try {\n await this.store.declineSiteAccessRequest(email);\n this.lastInvite = null;\n await this.reload();\n } catch (err) {\n if (err instanceof SiteAccessAlreadyAcceptedError) {\n // Accepted since this table was drawn: say so and show it.\n this.onError(t2('body.siteAccessAlreadyAccepted', { email }));\n await this.reload();\n return;\n }\n this.onError((err as Error).message);\n }\n }\n\n // Delete, as distinct from decline: decline is a decision kept on file,\n // this erases the record that anyone ever asked.\n private async remove(email: string): Promise {\n if (!await this.confirm(t2('body.siteAccessConfirmDelete', { email }))) return;\n try {\n await this.store.deleteSiteAccessRequest(email);\n this.lastInvite = null;\n await this.reload();\n } catch (err) {\n this.onError((err as Error).message);\n }\n }\n\n private statusLabel(status: string): string {\n switch (status) {\n case 'all': return t2('body.siteAccessAll');\n case 'pending': return t2('body.siteAccessPending');\n case 'invited': return t2('body.siteAccessInvited');\n case 'accepted': return t2('body.siteAccessAccepted');\n case 'declined': return t2('body.siteAccessDeclined');\n default: return status;\n }\n }\n\n // Locale-aware, per the i18n rules \u2014 never a hardcoded date format.\n private fmtDate(iso: string): string {\n if (!iso) return '';\n const d = new Date(iso);\n return isNaN(d.getTime()) ? '' : d.toLocaleDateString(undefined, {\n year: 'numeric', month: 'short', day: 'numeric',\n });\n }\n\n private esc(s: string): string {\n return (s ?? '').replace(/[&<>\"']/g, (c) => ({\n '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''',\n }[c] as string));\n }\n}\n", "// VulnSourcesUI \u2014 the Admin page's Vulnerability Sources tab (ENG-4292).\n//\n// Third-party and OS-native vulnerability state for one tenant: which\n// sources have reported (derived from the file ledger \u2014 there is no\n// connection or credential table by design), the files received from Mimic\n// nodes or imported here, and the Mimic nodes reporting Windows update\n// state. The Import button opens the app's existing chunked UploadModal with\n// X-Input-Type vuln-findings; the server hands the assembled file to the\n// same vulnsrc.IngestFile the node route uses.\n//\n// Tenant comes from the app's selected tenant (storage.selectedTenantId),\n// switchable here because the Admin page carries no tenant chrome of its own.\nimport { t as t2 } from '../i18n/I18n';\nimport AdminStore, {\n AdminTenant, VulnConnector, VulnConnectorVendor,\n VulnSourceFile, VulnSourceNode, VulnSourcesOverview,\n} from '../stores/AdminStore';\nimport { showConfirmModal } from '../utils/ConfirmModal';\nimport { DOM_STRING, setupDataTableUI } from '../utils/DataTables';\nimport { addCopyButton } from '../utils/ButtonUtils';\nimport { shortShortId } from '../utils/ShortId';\nimport { storage } from '../utils/Storage';\nimport { UploadModal } from '../utils/UploadModal';\nimport { VulnConnectorDrawer } from '../utils/VulnConnectorDrawer';\nimport { vulnImportOutcome } from '../utils/VulnImportOutcome';\nimport { envLabel } from './TenantProfilingEconomicsUI';\nimport StoreManager from '../managers/StoreManager';\nimport { escapeHtml } from '../utils/FormatUtils';\nimport { FetchGeneration } from '../utils/FetchGeneration';\n\nconst SOURCES_TABLE_ID = 'vulnSrcSourcesTable';\nconst FILES_TABLE_ID = 'vulnSrcFilesTable';\nconst NODES_TABLE_ID = 'vulnSrcNodesTable';\nconst CONNECTORS_TABLE_ID = 'vulnSrcConnectorsTable';\n\n// Column-0 kebab, the Nodes / Notifications tables' \"More actions\" pattern:\n// a static ellipsis in the header, a button per row.\nconst COL0_BTN_STYLE = 'background:none;border:0;outline:none;box-shadow:none;width:100%;display:flex;align-items:center;justify-content:center;';\n// Functions, not constants: t2() must run after the catalog has loaded.\nconst kebabHeaderHtml = () => ``;\nconst kebabRowHtml = () => ``;\n\n// ColumnControl (ENG-4345): the same dropdown filters the Missing Patches and\n// KEV tables carry. Closed sets (source, format, route, status\u2026) get a\n// searchList; free text (file name, node id) a searchText; numbers and\n// dates order only. Excel-style cascade so a dropdown never offers a value\n// whose selection could only produce an empty table.\nconst CC_LIST = [['orderAsc', 'orderDesc', 'searchList']];\n// A searchList whose options are the column's filter value rather than what\n// the cell displays: the Node ID cell shows 019f\u202653be, the list offers the full id.\nconst CC_LIST_FULL = [['orderAsc', 'orderDesc', { extend: 'searchList', orthogonal: 'filter' }]];\nconst CC_TEXT = [['orderAsc', 'orderDesc', 'searchText']];\nconst CC_ORDER = [['orderAsc', 'orderDesc']];\nconst CC_UI = { columnControl: { dropdownMaterialIcon: 'filter_list', fixHeaderLayout: true, cascadeSearchLists: true } };\n\nexport class VulnSourcesUI {\n private root: HTMLElement | null = null;\n private tenantId = '';\n private tenants: AdminTenant[] = [];\n // Tenant picker state: the search text and the env filter survive a\n // reload so a narrowed list stays narrowed after picking a tenant.\n private tenantQuery = '';\n private tenantEnv = '';\n private pickerOpen = false;\n private overview: VulnSourcesOverview = { sources: [], formats: [], sourceIds: [] };\n private files: VulnSourceFile[] = [];\n private nodes: VulnSourceNode[] = [];\n // Vendor connectors (ENG-4346): the tenant's stored vendor credentials\n // and the daily pull's state. The secret never reaches the browser.\n private connectors: VulnConnector[] = [];\n private vendors: VulnConnectorVendor[] = [];\n private connectorsDt: DataTables.Api | null = null;\n private sourcesDt: DataTables.Api | null = null;\n private filesDt: DataTables.Api | null = null;\n private nodesDt: DataTables.Api | null = null;\n // The root the listeners are on: a page navigation replaces the admin\n // page's DOM, so a new root needs its own (the old one went with it).\n private boundRoot: HTMLElement | null = null;\n // The picker stays live while a reload is in flight: only the newest\n // reload may commit, or a slow load for the previous tenant would render\n // under the one just picked (and its connector actions would send the new\n // tenantId with the old tenant's connector ids).\n private readonly reloadGen = new FetchGeneration();\n // The tenant this.connectors (and the rows on screen) were loaded for:\n // a connector action sends it only while it is still the picked tenant.\n private loadedTenantId = '';\n\n constructor(\n private readonly store: AdminStore,\n private readonly onError: (message: string) => void,\n ) { }\n\n async render(root: HTMLElement): Promise {\n this.root = root;\n this.bindListeners();\n root.innerHTML = `${t2('dt.loading')}`;\n if (this.tenants.length === 0) {\n try {\n this.tenants = await this.loadTenants();\n } catch (err) {\n root.innerHTML = `${t2('body.failedToLoad', { error: escapeHtml((err as Error).message) })}`; // copilot-lint-ignore: the only param, err.message, is escapeHtml'd; the t2() string is ours\n return;\n }\n }\n if (!this.tenantId) {\n const remembered = storage.get('selectedTenantId');\n this.tenantId = this.tenants.some(t => t.id === remembered) ? remembered : (this.tenants[0]?.id ?? '');\n }\n await this.reload();\n }\n\n /**\n * The picker's roster. Admins and TAMs get the full admin roster (with\n * its customer_environment filter) \u2014 hasMsspAdmin is the server's mirror\n * of the /api/tenants gate. Anyone else, i.e. a customer user holding\n * `vulnimport`, is never shown other tenants: the picker holds only their\n * own entitled tenants from the profile, the same set the sidebar\n * switcher offers, and the admin roster is not even requested.\n */\n private async loadTenants(): Promise {\n const user = StoreManager.getInstance().userProfileStore.user;\n if (user?.hasMsspAdmin) {\n return this.store.listAdminTenants();\n }\n const own = (user?.tenants ?? [])\n .filter(t => !!t.id)\n .map(t => ({ id: t.id, name: t.name || t.id, customerEnvironment: '' }));\n if (own.length === 0) throw new Error(t2('body.vulnSrcNoTenant'));\n return own;\n }\n\n private async reload(): Promise {\n if (!this.root) return;\n this.destroyTables();\n const gen = this.reloadGen.begin();\n const tenantId = this.tenantId;\n if (!tenantId) {\n this.root.innerHTML = `${t2('body.vulnSrcNoTenant')}`;\n return;\n }\n try {\n const [overview, files, nodes, conns] = await Promise.all([\n this.store.listVulnSources(tenantId),\n this.store.listVulnSourceFiles(tenantId),\n this.store.listVulnSourceNodes(tenantId),\n this.store.listVulnConnectors(tenantId),\n ]);\n if (!this.reloadGen.isCurrent(gen) || tenantId !== this.tenantId) return;\n this.overview = overview;\n this.files = files;\n this.nodes = nodes;\n this.connectors = conns.connectors;\n this.vendors = conns.vendors;\n this.loadedTenantId = tenantId;\n } catch (err) {\n if (!this.reloadGen.isCurrent(gen) || tenantId !== this.tenantId) return;\n this.root.innerHTML = `${t2('body.failedToLoad', { error: escapeHtml((err as Error).message) })}`; // copilot-lint-ignore: the only param, err.message, is escapeHtml'd; the t2() string is ours\n return;\n }\n this.renderBody();\n }\n\n private destroyTables(): void {\n for (const dt of [this.sourcesDt, this.filesDt, this.nodesDt, this.connectorsDt]) {\n try { dt?.destroy(); } catch { /* the nodes are about to go anyway */ }\n }\n this.connectorsDt = null;\n this.sourcesDt = null;\n this.filesDt = null;\n this.nodesDt = null;\n }\n\n // One delegated listener set on the root; renderBody replaces innerHTML\n // on every reload (same reasoning as SiteAccessUI).\n private bindListeners(): void {\n if (!this.root || this.boundRoot === this.root) return;\n this.boundRoot = this.root;\n this.root.addEventListener('click', (e) => {\n const target = e.target as HTMLElement;\n // The row kebab (same \"More actions\" pattern as the Nodes table)\n // opens a context menu; a click anywhere else on the row opens\n // the drawer. Both delegated: DataTables re-renders rows on\n // paging/filtering, so per-row listeners would be lost.\n const kebab = target.closest('.vuln-src-conn-menu');\n if (kebab) {\n e.preventDefault();\n e.stopPropagation();\n const id = Number(kebab.closest('tr')?.getAttribute('data-conn-id'));\n if (id) this.openConnectorMenu(e as MouseEvent, id);\n return;\n }\n const row = target.closest(`#${CONNECTORS_TABLE_ID} tbody tr[data-conn-id]`);\n if (row && !target.closest('a, button')) {\n const id = Number(row.getAttribute('data-conn-id'));\n const conn = this.connectors.find(c => c.id === id);\n if (conn) this.openConnectorDrawer(conn);\n return;\n }\n const btn = target.closest('#vulnSrcImportBtn, #vulnSrcRefreshBtn, #vulnSrcConnAddBtn');\n if (!btn) return;\n if (btn.id === 'vulnSrcRefreshBtn') void this.reload();\n if (btn.id === 'vulnSrcImportBtn') this.openImport();\n if (btn.id === 'vulnSrcConnAddBtn') this.openConnectorDrawer(null);\n });\n this.root.addEventListener('change', (e) => {\n const sel = e.target as HTMLSelectElement;\n if (sel?.id !== 'vulnSrcTenantEnv') return;\n this.tenantEnv = sel.value;\n this.renderPickerList();\n });\n this.root.addEventListener('input', (e) => {\n const inp = e.target as HTMLInputElement;\n if (inp?.id !== 'vulnSrcTenantSearch') return;\n this.tenantQuery = inp.value;\n this.pickerOpen = true;\n this.renderPickerList();\n });\n this.root.addEventListener('focusin', (e) => {\n if ((e.target as HTMLElement).id !== 'vulnSrcTenantSearch') return;\n this.pickerOpen = true;\n this.renderPickerList();\n });\n this.root.addEventListener('keydown', (e) => {\n if ((e.target as HTMLElement).id !== 'vulnSrcTenantSearch') return;\n if (e.key === 'Escape') { this.pickerOpen = false; this.renderPickerList(); }\n if (e.key === 'Enter') {\n const first = this.filteredTenants()[0];\n if (first) { e.preventDefault(); this.pickTenant(first.id); }\n }\n });\n this.root.addEventListener('click', (e) => {\n const item = (e.target as HTMLElement).closest('.vuln-src-tenant-item');\n if (item?.dataset.tenantId) { this.pickTenant(item.dataset.tenantId); return; }\n if (!(e.target as HTMLElement).closest('#vulnSrcTenantPicker') && this.pickerOpen) {\n this.pickerOpen = false;\n this.renderPickerList();\n }\n });\n }\n\n private pickTenant(id: string): void {\n this.pickerOpen = false;\n if (id === this.tenantId) { this.renderPickerList(); return; }\n this.tenantId = id;\n storage.set('selectedTenantId', this.tenantId);\n void this.reload();\n }\n\n /** Distinct customer_environment tokens across the roster; \"\" is shown as the (none) bucket. */\n private envTokens(): string[] {\n const set = new Set();\n for (const t of this.tenants) set.add(t.customerEnvironment || '(none)');\n return [...set].sort((a, b) => (a === '(none)' ? 1 : b === '(none)' ? -1 : a.localeCompare(b)));\n }\n\n /** The roster narrowed by the env filter and the search text (name or id, case-insensitive). */\n private filteredTenants(): AdminTenant[] {\n const q = this.tenantQuery.trim().toLowerCase();\n return this.tenants.filter(t => {\n const env = t.customerEnvironment || '(none)';\n if (this.tenantEnv && env !== this.tenantEnv) return false;\n return !q || t.name.toLowerCase().includes(q) || t.id.toLowerCase().includes(q);\n });\n }\n\n private currentTenant(): AdminTenant | undefined {\n return this.tenants.find(t => t.id === this.tenantId);\n }\n\n /** Environments only mean something when the roster spans more than one. */\n private get multiEnv(): boolean { return this.envTokens().length > 1; }\n\n private renderPicker(): string {\n const cur = this.currentTenant();\n const current = `${esc(cur?.name ?? '')}${cur && this.multiEnv ? this.envBadge(cur) : ''}`;\n // One tenant: nothing to pick, nothing to filter \u2014 just say which.\n if (this.tenants.length <= 1) {\n return `
${current}
`;\n }\n // The env filter earns its place only when the roster spans several\n // environments; a customer whose tenants are all prod never sees it.\n const envSelect = this.multiEnv\n ? ``\n : '';\n return `\n
\n ${current}\n ${envSelect}\n \n
\n
`;\n }\n\n private envBadge(t: AdminTenant): string {\n const tok = t.customerEnvironment || '(none)';\n return ` ${esc(envLabel(tok))}`;\n }\n\n private renderPickerList(): void {\n const list = this.root?.querySelector('#vulnSrcTenantList');\n if (!list) return;\n list.hidden = !this.pickerOpen;\n if (!this.pickerOpen) return;\n const rows = this.filteredTenants().slice(0, 200);\n list.innerHTML = rows.length === 0\n ? `
${t2('body.vulnSrcNoTenantMatch')}
`\n : rows.map(t => `\n
\n ${esc(t.name)}${this.envBadge(t)}\n
`).join('');\n }\n\n private renderBody(): void {\n if (!this.root) return;\n const sourcesTable = this.overview.sources.length === 0\n ? `${t2('body.vulnSrcNoSources')}`\n : `
`;\n\n this.root.innerHTML = `\n
\n

${t2('body.vulnSrcHint')}

\n
\n \n ${this.renderPicker()}\n \n \n
\n
\n ${this.lastResult ? `
${esc(this.lastResult)}
` : ''}\n
\n
\n
${t2('body.vulnSrcConnTitle')}
\n \n
\n

${t2('body.vulnSrcConnHint')}

\n \n
${this.connectors.length === 0 ? `${t2('body.vulnSrcConnNone')}` : `
`}
\n
\n
\n
${t2('body.vulnSrcSourcesTitle')}
\n ${sourcesTable}\n
\n
\n
${t2('body.vulnSrcFilesTitle')}
\n
${this.files.length === 0 ? `${t2('body.vulnSrcNoFiles')}` : `
`}
\n
\n
\n
${t2('body.vulnSrcNodesTitle')}
\n
${this.nodes.length === 0 ? `${t2('body.vulnSrcNoNodes')}` : `
`}
\n
`;\n\n // The import result is shown once, on the reload the import triggers.\n this.lastResult = '';\n this.ensureStyles();\n this.renderPickerList();\n if (this.connectors.length > 0) this.initConnectorsTable();\n if (this.overview.sources.length > 0) this.initSourcesTable();\n if (this.files.length > 0) this.initFilesTable();\n if (this.nodes.length > 0) this.initNodesTable();\n }\n\n private initConnectorsTable(): void {\n this.connectorsDt = ($(`#${CONNECTORS_TABLE_ID}`) as any).DataTable({\n data: this.connectors,\n dom: DOM_STRING,\n order: [[2, 'asc']],\n pageLength: 25,\n createdRow: (row: HTMLElement, data: VulnConnector) => { row.setAttribute('data-conn-id', String(data.id)); row.classList.add('vuln-src-conn-row'); },\n columns: [\n {\n // Column 0: the kebab. Header is the same static icon the\n // Nodes table shows; the row button opens the context menu.\n data: 'id', title: kebabHeaderHtml(), orderable: false, searchable: false, width: '36px', className: 'vuln-src-col0',\n render: (_v: number, type: string) => type === 'display' ? kebabRowHtml() : '',\n },\n { data: 'vendor', title: t2('body.vulnSrcConnVendor'), render: (v: string, type: string) => type === 'display' ? `${esc(this.vendorName(v))}` : this.vendorName(v) },\n { data: 'label', title: t2('body.vulnSrcConnLabel'), defaultContent: '', render: (v: string, type: string) => type === 'display' ? esc(v ?? '') : (v ?? '') },\n { data: 'apiUrl', title: t2('body.vulnSrcConnApiUrl'), render: (v: string, type: string) => type === 'display' ? `${esc(v)}` : v },\n {\n data: 'enabled', title: t2('body.vulnSrcConnEnabled'),\n render: (v: boolean, type: string) => (type === 'display' || type === 'filter') ? (v ? t2('common.yes') : t2('common.no')) : (v ? 1 : 0),\n },\n { data: 'lastRunAt', title: t2('body.vulnSrcConnColLastRun'), defaultContent: '', render: (v: string, type: string) => type === 'display' ? fmtDate(v) : (v ?? '') },\n {\n data: 'lastStatus', title: t2('body.vulnSrcColStatus'),\n render: (v: string, type: string, row: VulnConnector) => {\n const label = row.running ? t2('body.vulnSrcConnStatusRunning') : (v || t2('body.vulnSrcConnStatusNever'));\n if (type !== 'display') return label;\n const cls = row.running ? 'vuln-src-muted' : v === 'ok' ? 'vuln-src-ok' : v === 'failed' ? 'vuln-src-failed' : 'vuln-src-muted';\n const err = row.lastError ? ` ${esc(row.lastError)}` : '';\n return `${esc(label)}${err}`;\n },\n },\n { data: 'lastFindings', title: t2('body.vulnSrcColFindings') },\n { data: 'lastAssets', title: t2('body.vulnSrcConnColAssets') },\n { data: 'nextRunAt', title: t2('body.vulnSrcConnColNextRun'), defaultContent: '', render: (v: string, type: string) => type === 'display' ? fmtDate(v) : (v ?? '') },\n ],\n columnDefs: [\n { targets: [0], columnControl: [] },\n { targets: [1, 4, 6], columnControl: CC_LIST },\n { targets: [2, 3], columnControl: CC_TEXT },\n { targets: [5, 7, 8, 9], columnControl: CC_ORDER },\n ],\n });\n setupDataTableUI(CONNECTORS_TABLE_ID, this.connectorsDt as DataTables.Api, CC_UI);\n }\n\n // The kebab's context menu: the Nodes table's openContextMenu, sized to\n // the connector actions. Fixed-position .dropdown-menu at the click,\n // dismissed by any click or Escape.\n private openConnectorMenu(ev: MouseEvent, id: number): void {\n const conn = this.connectors.find(c => c.id === id);\n if (!conn) return;\n document.getElementById('vulnSrcConnMenu')?.remove();\n const menu = document.createElement('div');\n menu.id = 'vulnSrcConnMenu';\n menu.className = 'dropdown-menu show';\n Object.assign(menu.style, { position: 'fixed', left: `${ev.clientX}px`, top: `${ev.clientY}px`, zIndex: '2000', display: 'block', padding: '8px' });\n const close = () => {\n menu.remove();\n document.removeEventListener('click', onDocClick);\n document.removeEventListener('keydown', onKeyDown);\n };\n const onDocClick = () => close();\n const onKeyDown = (k: KeyboardEvent) => { if (k.key === 'Escape' || k.key === 'Esc') { k.preventDefault(); close(); } };\n const addItem = (label: string, icon: string, action: () => void, cls = '') => {\n const a = document.createElement('a');\n a.href = '#';\n a.className = `dropdown-item d-flex align-items-center justify-content-start ${cls}`;\n a.innerHTML = `${icon}${esc(label)}`;\n a.onclick = (c) => { c.preventDefault(); close(); action(); };\n menu.appendChild(a);\n };\n addItem(t2('body.vulnSrcConnEdit'), 'edit', () => this.openConnectorDrawer(conn));\n addItem(t2('body.vulnSrcConnTest'), 'network_check', () => void this.connectorAction('test', id));\n addItem(t2('body.vulnSrcConnRun'), 'play_arrow', () => void this.connectorAction('run', id));\n addItem(t2('body.vulnSrcConnRuns'), 'history', () => this.openConnectorDrawer(conn));\n addItem(conn.enabled ? t2('body.vulnSrcConnDisable') : t2('body.vulnSrcConnEnable'), conn.enabled ? 'pause' : 'check_circle', () => void this.connectorAction('toggle', id));\n addItem(t2('body.vulnSrcConnDelete'), 'delete', () => void this.connectorAction('delete', id), 'text-danger');\n document.body.appendChild(menu);\n // Keep the menu on screen when opened near the right/bottom edge.\n const r = menu.getBoundingClientRect();\n if (r.right > window.innerWidth) menu.style.left = `${Math.max(0, window.innerWidth - r.width - 8)}px`;\n if (r.bottom > window.innerHeight) menu.style.top = `${Math.max(0, window.innerHeight - r.height - 8)}px`;\n setTimeout(() => {\n document.addEventListener('click', onDocClick);\n document.addEventListener('keydown', onKeyDown);\n }, 0);\n }\n\n private uploaderLabel(uploadedBy: string): string {\n const m = /^connector:(\\d+)$/.exec(uploadedBy ?? '');\n if (!m) return uploadedBy ?? '';\n const conn = this.connectors.find(c => c.id === Number(m[1]));\n return conn ? `${this.vendorName(conn.vendor)}: ${conn.label}` : uploadedBy;\n }\n\n private vendorName(id: string): string {\n return this.vendors.find(v => v.id === id)?.name ?? id;\n }\n\n private connResult(kind: 'ok' | 'failed' | 'info', text: string): void {\n const el = this.root?.querySelector('#vulnSrcConnResult');\n if (!el) return;\n el.hidden = false;\n el.className = `vuln-src-conn-result alert ${kind === 'ok' ? 'alert-success' : kind === 'failed' ? 'alert-danger' : 'alert-info'}`;\n el.textContent = text;\n }\n\n private async connectorAction(action: string, id: number): Promise {\n const conn = this.connectors.find(c => c.id === id);\n // While a reload for a newly picked tenant is in flight the previous\n // tenant's rows are still on screen: never pair their ids with the\n // new tenantId.\n const tenantId = this.loadedTenantId;\n if (!conn || !tenantId || tenantId !== this.tenantId) return;\n try {\n switch (action) {\n case 'test': {\n this.connResult('info', '\u2026');\n const r = await this.store.testVulnConnector(tenantId, id);\n if (r.ok) this.connResult('ok', t2('body.vulnSrcConnTestOk', { message: r.message ?? '' }));\n else this.connResult('failed', t2('body.vulnSrcConnTestFailed', { error: r.error ?? '' }));\n return;\n }\n case 'run':\n await this.store.runVulnConnector(tenantId, id);\n this.lastResult = t2('body.vulnSrcConnRunQueued');\n await this.reload();\n return;\n case 'toggle':\n await this.store.setVulnConnectorEnabled(tenantId, id, !conn.enabled);\n await this.reload();\n return;\n case 'delete':\n showConfirmModal({\n title: t2('body.vulnSrcConnDelete'),\n messageHtml: esc(t2('body.vulnSrcConnDeleteConfirm', { label: conn.label })),\n confirmLabel: t2('body.vulnSrcConnDelete'),\n confirmClassName: 'btn btn-danger',\n onConfirm: async () => {\n await this.store.deleteVulnConnector(tenantId, id);\n await this.reload();\n },\n });\n return;\n }\n } catch (err) {\n this.onError((err as Error).message);\n }\n }\n\n private openConnectorDrawer(existing: VulnConnector | null): void {\n if (!this.tenantId) return;\n // An existing connector belongs to the loaded tenant (see connectorAction).\n if (existing && this.loadedTenantId !== this.tenantId) return;\n VulnConnectorDrawer.open({\n store: this.store,\n tenantId: this.tenantId,\n vendors: this.vendors,\n existing,\n onSaved: () => {\n this.lastResult = t2('body.vulnSrcConnSaved');\n void this.reload();\n },\n });\n }\n\n private initSourcesTable(): void {\n this.sourcesDt = ($(`#${SOURCES_TABLE_ID}`) as any).DataTable({\n data: this.overview.sources,\n dom: DOM_STRING,\n order: [[3, 'desc']],\n pageLength: 25,\n columns: [\n { data: 'source', title: t2('body.vulnSrcColSource'), render: (v: string, type: string) => type === 'display' ? `${esc(v)}` : v },\n { data: 'files', title: t2('body.vulnSrcColFiles') },\n { data: 'findings', title: t2('body.vulnSrcColFindings') },\n { data: 'lastReceived', title: t2('body.vulnSrcColLastReceived'), render: (v: string, type: string) => type === 'display' ? fmtDate(v) : v },\n ],\n columnDefs: [\n { targets: [0], columnControl: CC_LIST },\n { targets: [1, 2, 3], columnControl: CC_ORDER },\n ],\n });\n setupDataTableUI(SOURCES_TABLE_ID, this.sourcesDt as DataTables.Api, CC_UI);\n }\n\n private initFilesTable(): void {\n this.filesDt = ($(`#${FILES_TABLE_ID}`) as any).DataTable({\n data: this.files,\n dom: DOM_STRING,\n order: [[6, 'desc']],\n pageLength: 25,\n columns: [\n { data: 'fileName', title: t2('body.vulnSrcColFileName'), render: (v: string, type: string) => type === 'display' ? `${esc(v)}` : v },\n { data: 'source', title: t2('body.vulnSrcColSource'), defaultContent: '', render: (v: string, type: string) => type === 'display' ? esc(v ?? '') : (v ?? '') },\n { data: 'format', title: t2('body.vulnSrcColFormat'), defaultContent: '', render: (v: string, type: string) => type === 'display' ? esc(v ?? '') : (v ?? '') },\n { data: 'route', title: t2('body.vulnSrcColRoute'), defaultContent: '', render: (v: string, type: string) => type === 'display' ? esc(v ?? '') : (v ?? '') },\n { data: 'nodeId', title: t2('body.vulnSrcColNode'), defaultContent: '', render: (v: string, type: string) => type === 'display' ? shortId(v) : (v ?? '') },\n { data: 'sizeBytes', title: t2('body.vulnSrcColSize'), render: (v: number, type: string) => type === 'display' ? fmtBytes(v) : v },\n { data: 'receivedAt', title: t2('body.vulnSrcColReceived'), render: (v: string, type: string) => type === 'display' ? fmtDate(v) : v },\n {\n data: 'uploadedBy', title: t2('body.vulnSrcColBy'), defaultContent: '',\n // A connector's files carry connector:; show the\n // connector's label instead (the id stays in the title).\n render: (v: string, type: string) => {\n const label = this.uploaderLabel(v);\n return type === 'display' ? `${esc(label)}` : label;\n },\n },\n {\n data: 'parseStatus', title: t2('body.vulnSrcColStatus'),\n render: (v: string, type: string, row: VulnSourceFile) => {\n if (type !== 'display') return v;\n const cls = v === 'ok' ? 'vuln-src-ok' : v === 'failed' ? 'vuln-src-failed' : 'vuln-src-muted';\n const err = row.parseError ? ` ${esc(row.parseError)}` : '';\n return `${esc(v)}${err}`;\n },\n },\n { data: 'findingCount', title: t2('body.vulnSrcColFindings') },\n ],\n columnDefs: [\n // File name and node id are free text; source, format, route,\n // uploader and parse status are closed sets to pick from.\n { targets: [0, 4], columnControl: CC_TEXT },\n { targets: [1, 2, 3, 7, 8], columnControl: CC_LIST },\n { targets: [5, 6, 9], columnControl: CC_ORDER },\n ],\n });\n setupDataTableUI(FILES_TABLE_ID, this.filesDt as DataTables.Api, CC_UI);\n }\n\n private initNodesTable(): void {\n this.nodesDt = ($(`#${NODES_TABLE_ID}`) as any).DataTable({\n data: this.nodes,\n dom: DOM_STRING,\n order: [[2, 'desc']],\n pageLength: 25,\n columns: [\n // Node ID and Hostname as on the Nodes page: the short-short\n // id (the hostname is what a reader scans by), full values on\n // the copy button that appears on hover, a searchList on each.\n {\n data: 'nodeId', title: t2('col.nodeId'),\n render: (v: string, type: string) => type === 'display' ? esc(shortShortId(v)) : v,\n createdCell: (td: HTMLElement, v: string) => {\n if (!v) return;\n addCopyButton(td, v, t2('col.nodeId'), 'node_id');\n td.classList.add('nh-copy-cell');\n },\n },\n {\n data: 'hostname', title: t2('col.hostname'), defaultContent: '',\n render: (v: string, type: string) => type === 'display' ? esc(v ?? '') : (v ?? ''),\n createdCell: (td: HTMLElement, v: string) => {\n if (!v) return;\n addCopyButton(td, v, t2('col.hostname'), 'hostname');\n td.classList.add('nh-copy-cell');\n },\n },\n { data: 'reportedAt', title: t2('body.vulnSrcColReported'), render: (v: string, type: string) => type === 'display' ? fmtDate(v) : v },\n { data: 'osBuild', title: t2('body.vulnSrcColBuild'), render: (_v: number, type: string, row: VulnSourceNode) => type === 'display' ? `${row.osBuild}.${row.osUbr}` : row.osBuild },\n { data: 'installedCount', title: t2('body.vulnSrcColInstalled') },\n { data: 'missingCount', title: t2('body.vulnSrcColMissing') },\n { data: 'missingSource', title: t2('body.vulnSrcColMissingSource'), defaultContent: '', render: (v: string, type: string) => type === 'display' ? esc(v ?? '') : (v ?? '') },\n {\n data: 'rebootPending', title: t2('body.vulnSrcColReboot'),\n // 'filter' wants the rendered label \u2014 that is what the\n // dropdown lists and matches against; sort keeps the boolean.\n render: (v: boolean, type: string) => (type === 'display' || type === 'filter') ? (v ? t2('common.yes') : t2('common.no')) : (v ? 1 : 0),\n },\n {\n data: 'complete', title: t2('body.vulnSrcColSnapshot'),\n render: (v: boolean, type: string, row: VulnSourceNode) => (type === 'display' || type === 'filter')\n ? (v ? t2('body.vulnSrcSnapshotComplete') : t2('body.vulnSrcSnapshotPartial', { n: row.chunkCount }))\n : (v ? 1 : 0),\n },\n { data: 'lastHeartbeatAt', title: t2('body.vulnSrcColHeartbeat'), defaultContent: '', render: (v: string, type: string) => type === 'display' ? fmtDate(v) : (v ?? '') },\n ],\n columnDefs: [\n { targets: [0], columnControl: CC_LIST_FULL },\n { targets: [1, 6, 7, 8], columnControl: CC_LIST },\n { targets: [2, 3, 4, 5, 9], columnControl: CC_ORDER },\n ],\n });\n setupDataTableUI(NODES_TABLE_ID, this.nodesDt as DataTables.Api, CC_UI);\n }\n\n // The import modal: the existing chunked uploader with the tenant (and,\n // when the file name does not carry them, source/format) as headers.\n private openImport(): void {\n if (!this.tenantId) return;\n const sourceSel = this.root?.querySelector('#vulnSrcSource');\n const formatSel = this.root?.querySelector('#vulnSrcFormat');\n const headers: Record = { 'X-Tenant-Id': this.tenantId };\n if (sourceSel?.value) headers['X-Source'] = sourceSel.value;\n if (formatSel?.value) headers['X-Format'] = formatSel.value;\n\n UploadModal.ensure().open({\n title: t2('body.vulnSrcImportTitle'),\n folder: 'vuln-sources',\n type: 'vuln-findings',\n accept: ['.csv', '.json', '.xml', '.nessus'],\n extraHeaders: headers,\n onComplete: async (result) => {\n const outcome = vulnImportOutcome(result);\n if (outcome === 'alreadySeen') {\n this.onError(t2('body.vulnSrcAlreadySeen'));\n } else if (outcome === 'superseded') {\n this.onError(t2('body.vulnSrcSuperseded'));\n } else {\n this.lastResult = t2('body.vulnSrcImportResult', {\n findings: result.findings ?? 0,\n matched: result.matched ?? 0,\n method: result.matchMethod ?? '',\n });\n }\n await this.reload();\n },\n onError: (err) => this.onError(err.message || t2('body.vulnSrcImportFailed')),\n });\n }\n\n private lastResult = '';\n\n private ensureStyles(): void {\n if (document.getElementById('vuln-src-styles')) return;\n const style = document.createElement('style');\n style.id = 'vuln-src-styles';\n style.textContent = `\n .vuln-src-head { align-items: flex-start; }\n .vuln-src-actions { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; }\n .vuln-src-tenant-label { margin: 0; white-space: nowrap; }\n .vuln-src-picker { position: relative; display: flex; align-items: center; gap: 6px; }\n .vuln-src-picker-current { font-weight: 600; white-space: nowrap; max-width: 260px; overflow: hidden; text-overflow: ellipsis; }\n /* Both controls take the DataTables \"entries per page\" recipe\n (select.dt-style-select / input.dt-style-input in tables.css)\n so they sit level with the tables below; only widths here. */\n .vuln-src-picker-env { max-width: 150px; }\n .vuln-src-picker-search { width: 220px; }\n .vuln-src-picker-list { position: absolute; top: 100%; left: 0; right: 0; z-index: 1050; margin-top: 4px;\n max-height: 320px; overflow-y: auto; background: var(--card, #fff); border: 1px solid var(--border, #e4e3df);\n border-radius: 6px; box-shadow: 0 6px 18px rgba(0,0,0,.12); }\n .vuln-src-tenant-item { display: flex; align-items: center; justify-content: space-between; gap: 8px;\n padding: 6px 10px; cursor: pointer; font-size: 13px; }\n .vuln-src-tenant-item:hover, .vuln-src-tenant-item.is-current { background: var(--surface-2, #f3f2ef); }\n .vuln-src-tenant-item.is-current .vuln-src-tenant-name { font-weight: 600; }\n .vuln-src-tenant-empty { padding: 8px 10px; color: var(--text-3, #8a8984); font-size: 13px; }\n .vuln-src-env-badge { display: inline-block; font-size: 11px; line-height: 1; padding: 3px 6px; border-radius: 10px;\n background: var(--border, #e4e3df); color: var(--text-2, #52514e); white-space: nowrap; }\n .vuln-src-env-prod { background: #dff2df; color: #006300; }\n .vuln-src-env-none { background: transparent; border: 1px dashed var(--border, #e4e3df); }\n .vuln-src-refresh { padding: 2px 6px; line-height: 1; }\n .vuln-src-ok { color: var(--good-text, #006300); font-weight: 600; }\n .vuln-src-failed { color: var(--critical, #d03b3b); font-weight: 600; }\n .vuln-src-muted { color: var(--text-3, #8a8984); }\n .vuln-src-err { color: var(--text-2, #52514e); font-size: 12px; margin-left: 6px; }\n .vuln-src-result { margin: 8px 0 0; padding: 8px 12px; }\n /* Section cards: the Missing Patches table cards' recipe\n (.sc-chart-card / .sc-chart-title in ad-usn-change.css),\n restated here because the Admin page does not load that sheet. */\n .vuln-src-card { border: 1px solid #eef0f2; border-radius: 6px; padding: 10px 12px; background: #fff; margin-top: 14px; }\n .vuln-src-card-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; min-height: 33px; }\n .vuln-src-card-title { font-size: 12px; font-weight: 700; color: #374151; text-transform: uppercase; letter-spacing: .02em; }\n .vuln-src-card-desc { margin: 4px 0 8px; }\n /* The card-header button is the Import file button's recipe minus\n the shared class's fixed width: the label sizes the box. */\n .vuln-src-card-head .connector-allow-btn { width: auto; min-width: 0; white-space: nowrap; flex: 0 0 auto; }\n .vuln-src-conn-result { margin: 0 0 8px; padding: 8px 12px; }\n .vuln-src-col0 { width: 36px; text-align: center; }\n .vuln-src-conn-row { cursor: pointer; }\n\n `;\n document.head.appendChild(style);\n }\n}\n\nfunction esc(s: string): string {\n return String(s ?? '').replace(/[&<>\"']/g, c =>\n ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[c]!));\n}\n\nfunction fmtDate(v: string | undefined | null): string {\n if (!v) return '';\n const d = new Date(v);\n return isNaN(d.getTime()) ? esc(v) : d.toLocaleString();\n}\n\nfunction fmtBytes(n: number): string {\n if (!n && n !== 0) return '';\n if (n < 1024) return `${n} B`;\n if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;\n return `${(n / (1024 * 1024)).toFixed(1)} MB`;\n}\n\nfunction shortId(v: string): string {\n if (!v) return '';\n return `${esc(v.slice(0, 8))}\u2026`;\n}\n", "/** 0197ab9d-\u2026c05a for UUIDs; abcdef01\u20266789 for hashes; leave short strings as-is */\nexport function shortId(id: string, tail = 4): string {\n if (!id) return '\u2014';\n const s = id.trim();\n const dash = s.indexOf('-');\n if (dash >= 0) {\n // UUID-style: split at first dash\n if (s.length <= dash + tail + 2) return s;\n return `${s.slice(0, dash + 1)}\u2026${s.slice(-tail)}`;\n }\n // Non-UUID (SHA256 etc): first 8 + last tail\n if (s.length <= 8 + tail + 1) return s;\n return `${s.slice(0, 8)}\u2026${s.slice(-tail)}`;\n}\n\n/** Even shorter than shortId: first `head` + \u2026 + last `tail` chars\n * (e.g. \"019b\u20263fb8\"). For tight columns where shortId is still too wide. */\nexport function shortShortId(id: string, head = 4, tail = 4): string {\n if (!id) return '\u2014';\n const s = id.trim();\n if (s.length <= head + tail + 1) return s;\n return `${s.slice(0, head)}\u2026${s.slice(-tail)}`;\n}\n\n/** Tiniest form: just `\u2026xxxx` (the last `tail` chars). For very tight\n * columns where even shortShortId is too wide \u2014 the leading prefix on\n * UUIDs and hashes is mostly noise, so dropping it claws back ~5 chars\n * while staying unique enough for visual scanning. Hover the cell to\n * copy the full ID. */\nexport function shortShortShortId(id: string, tail = 4): string {\n if (!id) return '\u2014';\n const s = id.trim();\n if (s.length <= tail + 1) return s;\n return `\u2026${s.slice(-tail)}`;\n}", "// static/ts/utils/UploadModal.ts\n// -----------------------------------------------------------------------------\n// Reusable Upload Modal (single-file)\n// \u2013 Builds its own Bootstrap modal DOM if missing\n// \u2013 Uses UploadChunkedFileStore with X-Upload-Session (sessionId)\n// \u2013 Shows progress + compact status log\n// \u2013 Calls onComplete(payload) when server finishes (no navigation)\n// \u2013 Adds an OK button (80px, disabled until success, dismisses modal)\n// \u2013 Canonicalizes filename -> rawdump_YYYYMMDD_HHMMSS.{snappy|json}\n// \u2013 Enforces single-file selection/drag-and-drop\n// \u2013 Keeps modal comfortably tall to avoid \u201Cjump\u201D when history shows\n// -----------------------------------------------------------------------------\nimport { t as t2 } from '../i18n/I18n';\nimport { UploadChunkedFileStore, UploadCompletePayload } from '../stores/UploadChunkedFileStore';\nimport { uploadProgressPct } from './UploadChunks';\nimport { formatElapsed, formatElapsedDuration, escapeHtml } from '../utils/FormatUtils';\n\nexport interface UploadModalOptions {\n title?: string;\n /** required: server folder label sent as X-Folder */\n folder: string;\n /** required: arbitrary label sent as X-Input-Type (UI decides) */\n type: string;\n /** required: allowed extensions/MIME (UI hint only). [] means \u201Callow anything\u201D. */\n accept: string[];\n\n makeUniqueName?: boolean; // still unused, harmless\n chunkSizeBytes?: number; // default 100 MB\n autoUploadDefault?: boolean; // default: true\n sessionId?: string;\n /** Extra request headers sent on every chunk and on completion (X-Tenant-Id \u2026). */\n extraHeaders?: Record;\n /** A destination's own chunk and completion routes, and its transport\n * (UploadOptions in stores/UploadChunkedFileStore). */\n chunkUrl?: string;\n completeUrl?: string;\n gzipAlways?: boolean;\n /** Called with the file when its upload starts, before its first chunk.\n * A returned text vetoes the upload: nothing is sent and the dialog shows\n * the text as the failure. */\n onStart?: (file: File) => string | void;\n /** The text a failure shows, in place of the error's own message. */\n describeError?: (err: Error) => string;\n\n onComplete?: (\n result: UploadCompletePayload & {\n shortId?: string;\n sessionId?: string;\n filename?: string; // server-provided if any\n originalFilename?: string; // client convenience only\n }\n ) => Promise | void;\n onError?: (err: Error) => void;\n onClose?: () => void;\n}\n\nexport class UploadModal {\n private static _instance: UploadModal | null = null;\n\n private modalId = 'upload-modal';\n private uploadStore = new UploadChunkedFileStore();\n private bsModalInstance: any | null = null;\n\n private options: Required> & {\n extraHeaders?: Record;\n chunkUrl?: string;\n completeUrl?: string;\n gzipAlways?: boolean;\n onStart?: UploadModalOptions['onStart'];\n describeError?: UploadModalOptions['describeError'];\n // title is the only optional we still default internally\n title: string;\n onComplete?: UploadModalOptions['onComplete'];\n onError?: UploadModalOptions['onError'];\n onClose?: UploadModalOptions['onClose'];\n sessionId?: string;\n };\n\n // DOM refs\n private modalEl!: HTMLElement;\n private titleEl!: HTMLElement;\n private bodyEl!: HTMLElement;\n private footerEl!: HTMLElement;\n\n private dropzoneForm!: HTMLElement;\n private uploadBtn!: HTMLButtonElement;\n private okBtn!: HTMLButtonElement;\n private cancelBtn!: HTMLButtonElement;\n private uploadLoadingIndicator!: HTMLElement;\n private progressBarContainer!: HTMLElement;\n private progressBar!: HTMLElement;\n private messageArea!: HTMLElement;\n private statusTable!: HTMLElement;\n private onCmdEnter?: (e: KeyboardEvent) => void;\n\n // State\n private selectedFile: File | null = null;\n private isUploading = false;\n private isUploaded = false;\n private startTime: number | null = null;\n private timerInterval: number | null = null;\n private baseSpan: HTMLSpanElement = document.createElement('span');\n private timerSpan: HTMLSpanElement = document.createElement('span');\n private runningBaseMessage = '';\n /** Counts open() calls: a close asked for while the dialog fades in\n * applies to that open only. */\n private openGen = 0;\n /** The open whose show has finished (Bootstrap's shown event, or at once\n * without its fade). */\n private shownGen = 0;\n /** The open to close as soon as it is shown: closeIdle during the fade,\n * which Bootstrap's hide ignores while it transitions. */\n private closeOnShownGen = 0;\n private onEscKey = (e: KeyboardEvent) => {\n if (e.key === 'Escape') {\n e.preventDefault();\n this.hideModal();\n }\n };\n\n private statusHistory: Array<{\n time: string;\n status: string; // info | running | failed | completed | passed\n message: string;\n progress: number; // 0..100\n elapsedTime: number;\n bold?: boolean;\n }> = [];\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 API \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n static ensure(): UploadModal {\n if (!this._instance) this._instance = new UploadModal();\n this._instance.ensureDom();\n return this._instance;\n }\n\n private showModal(): void {\n const anyWin = window as any;\n\n // Prefer Bootstrap 5 API\n if (anyWin.bootstrap && typeof anyWin.bootstrap.Modal === 'function') {\n try {\n if (this.bsModalInstance?.hide) this.bsModalInstance.hide();\n this.bsModalInstance = new anyWin.bootstrap.Modal(this.modalEl, {\n backdrop: 'static',\n keyboard: false,\n });\n this.bsModalInstance.show();\n return;\n } catch { }\n }\n\n // Fallback to jQuery (BS4)\n const jq: any = (window as any).$ || (window as any).jQuery;\n if (jq?.fn?.modal) {\n jq(this.modalEl).modal({ show: true, backdrop: 'static', keyboard: false });\n return;\n }\n\n // Manual fallback (no bootstrap js)\n this.modalEl.classList.add('show');\n this.modalEl.style.display = 'block';\n this.modalEl.removeAttribute('aria-hidden');\n this.modalEl.setAttribute('aria-modal', 'true');\n document.body.classList.add('modal-open');\n if (!document.getElementById('upload-modal-backdrop')) {\n const backdrop = document.createElement('div');\n backdrop.id = 'upload-modal-backdrop';\n backdrop.className = 'modal-backdrop fade show';\n document.body.appendChild(backdrop);\n }\n // No transition here, and no shown event.\n this.markShown(this.openGen);\n }\n\n public open(opts: UploadModalOptions) {\n this.options = {\n title: opts.title ?? t2('common.upload'),\n folder: opts.folder,\n type: opts.type,\n accept: opts.accept,\n extraHeaders: opts.extraHeaders,\n chunkUrl: opts.chunkUrl,\n completeUrl: opts.completeUrl,\n gzipAlways: opts.gzipAlways,\n onStart: opts.onStart,\n describeError: opts.describeError,\n makeUniqueName: true,\n chunkSizeBytes: opts.chunkSizeBytes ?? 1024 * 1024 * 100,\n autoUploadDefault: opts.autoUploadDefault ?? true,\n onComplete: opts.onComplete,\n onError: opts.onError,\n onClose: opts.onClose,\n sessionId: opts.sessionId,\n };\n\n this.grabRefs();\n this.resetUiState();\n this.titleEl.textContent = this.options.title;\n\n this.wireDropzone();\n\n this.uploadBtn.onclick = (e) => {\n e.preventDefault();\n this.startUpload().catch(() => {/* handled */ });\n };\n\n this.okBtn.onclick = (e) => {\n e.preventDefault();\n if (this.okBtn.disabled) return;\n this.hideModal();\n };\n\n this.cancelBtn.onclick = (e) => {\n e.preventDefault();\n this.hideModal(); // same behavior as the header \u201CX\u201D\n };\n\n const gen = ++this.openGen;\n this.closeOnShownGen = 0;\n this.bindShownHandler(gen);\n this.showModal();\n this.bindHiddenHandler();\n // Allow Esc to dismiss (same as Cancel / header X)\n document.addEventListener('keydown', this.onEscKey);\n\n // \u2318/Ctrl+Enter triggers OK if enabled\n this.onCmdEnter = (e: KeyboardEvent) => {\n if (!(e.metaKey || e.ctrlKey) || e.key !== 'Enter' || e.altKey || e.shiftKey) return;\n const t = e.target as HTMLElement | null;\n if (t && (t.closest('textarea, [contenteditable=\"true\"]'))) return;\n if (!this.okBtn || this.okBtn.disabled) return;\n e.preventDefault();\n this.okBtn.click();\n };\n document.addEventListener('keydown', this.onCmdEnter, true);\n }\n\n /** Closes the dialog unless an upload is running (which cannot be\n * abandoned: see hideModal); true when it is closed or was not open. A\n * page that opened it calls this as it goes, so an idle dialog does not\n * outlive the page and upload for it. */\n public closeIdle(): boolean {\n if (this.isUploading) return false;\n if (this.openGen !== 0 && this.shownGen !== this.openGen) {\n // Still fading in: Bootstrap ignores a hide until it is shown.\n this.closeOnShownGen = this.openGen;\n return true;\n }\n if (this.modalEl?.classList.contains('show') || this.modalEl?.style.display === 'block') this.hideModal();\n return true;\n }\n\n /** Records that open gen finished showing, and closes it if closeIdle\n * asked for that during its fade. */\n private markShown(gen: number): void {\n if (gen !== this.openGen) return;\n this.shownGen = gen;\n if (this.closeOnShownGen === gen) {\n this.closeOnShownGen = 0;\n this.hideModal();\n }\n }\n\n private bindShownHandler(gen: number): void {\n const onShown = () => this.markShown(gen);\n this.modalEl.addEventListener('shown.bs.modal', onShown, { once: true });\n const jq: any = (window as any).$ || (window as any).jQuery;\n if (jq?.fn?.modal) jq(this.modalEl).one('shown.bs.modal', onShown);\n }\n\n private hideModal(): void {\n // An upload can't be abandoned: the chunked store has no abort, so\n // the file would still land server-side after the modal closed.\n // Cancel and the header X are disabled while it runs (setDismissable);\n // this also holds Esc and any other path here.\n if (this.isUploading) return;\n // Always clean up the Esc handler\n document.removeEventListener('keydown', this.onEscKey);\n const anyWin = window as any;\n const jq: any = (window as any).$ || (window as any).jQuery;\n\n // cleanup shortcut handler\n if (this.onCmdEnter) {\n document.removeEventListener('keydown', this.onCmdEnter, true);\n this.onCmdEnter = undefined;\n }\n\n if (anyWin.bootstrap && this.bsModalInstance?.hide) {\n this.bsModalInstance.hide();\n } else if (jq?.fn?.modal) {\n jq(this.modalEl).modal('hide');\n } else {\n // Manual hide\n this.modalEl.classList.remove('show');\n this.modalEl.style.display = 'none';\n this.modalEl.setAttribute('aria-hidden', 'true');\n document.getElementById('upload-modal-backdrop')?.remove();\n document.body.classList.remove('modal-open');\n // fire close hook\n this.options.onClose?.();\n }\n }\n\n private bindHiddenHandler(): void {\n const anyWin = window as any;\n const onHidden = () => {\n // Clean up Esc handler even if Bootstrap closed it\n document.removeEventListener('keydown', this.onEscKey);\n // safety: ensure shortcut is removed even if hidden externally\n if (this.onCmdEnter) {\n document.removeEventListener('keydown', this.onCmdEnter, true);\n this.onCmdEnter = undefined;\n }\n this.stopTimer();\n this.shownGen = this.openGen;\n this.closeOnShownGen = 0;\n this.options.onClose?.();\n };\n\n // BS5 & BS4 event\n this.modalEl.removeEventListener('hidden.bs.modal', onHidden as any);\n this.modalEl.addEventListener('hidden.bs.modal', onHidden as any);\n\n const jq: any = (window as any).$ || (window as any).jQuery;\n if (jq?.fn?.modal) {\n jq(this.modalEl).off('hidden.bs.modal').on('hidden.bs.modal', onHidden);\n }\n\n // Header \"X\" button fallback\n const closeBtn = this.modalEl.querySelector('[data-dismiss=\"modal\"], [data-bs-dismiss=\"modal\"]') as HTMLElement;\n if (closeBtn) closeBtn.onclick = () => this.hideModal();\n }\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 DOM build \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n private ensureDom(): void {\n this.modalEl = document.getElementById(this.modalId)!;\n if (this.modalEl) return;\n\n const wrapper = document.createElement('div');\n wrapper.innerHTML = `\n
\n
\n
\n
\n
${t2('common.upload')}
\n \n
\n\n
\n
\n
\n
\n
${t2('body.fileDropZone')}
\n
\n
\n
\n
\n upload_file\n

${t2('body.dropYourFileHereOrClick')}

\n
\n
\n
\n
\n\n \n\n
\n
\n ${t2('body.uploading')}\n
\n

${t2('body.uploadingFiles')}

\n
\n\n
\n
0%
\n
\n\n
\n
\n
\n
${t2('body.statusHistory')}
\n
\n
\n
\n
\n
\n\n
\n
\n
\n\n
\n \n \n
\n
\n
\n
`;\n document.body.appendChild(wrapper.firstElementChild!);\n\n // Ensure our width/height rules win over upstream caps\n if (!document.getElementById('upload-modal-style')) {\n const style = document.createElement('style');\n style.id = 'upload-modal-style';\n style.textContent = `\n #upload-modal .modal-dialog,\n #upload-modal > .modal-dialog {\n width: clamp(900px, 80vw, 1200px) !important;\n max-width: none !important;\n margin-left: auto !important;\n margin-right: auto !important;\n }\n #upload-modal .modal-content { width: 100% !important; max-width: none !important; }\n #upload-modal .modal-body { min-height: 45vh; } /* taller by default, avoids jump */\n #upload-modal .modal-body > * { width: 100% !important; max-width: none !important; }\n #upload-modal .btn-primary { background-color: var(--brand-accent, #d4b25a); color: #fff; }\n #upload-modal .btn-secondary { background-color: #5a6672; color: #fff; }\n #okUploadButton[disabled] { opacity: .6; cursor: not-allowed; }\n `;\n document.head.appendChild(style);\n }\n }\n\n private grabRefs() {\n this.modalEl = document.getElementById(this.modalId)!;\n this.titleEl = this.modalEl.querySelector('.modal-title')!;\n this.bodyEl = this.modalEl.querySelector('.modal-body')!;\n this.footerEl = this.modalEl.querySelector('.modal-footer')!;\n\n this.dropzoneForm = this.modalEl.querySelector('#folderDropzone') as HTMLElement;\n this.uploadBtn = this.modalEl.querySelector('#startUploadButton') as HTMLButtonElement;\n this.okBtn = this.modalEl.querySelector('#okUploadButton') as HTMLButtonElement;\n this.cancelBtn = this.modalEl.querySelector('#cancelUploadButton') as HTMLButtonElement;\n\n this.uploadLoadingIndicator = this.modalEl.querySelector('#uploadLoadingIndicator') as HTMLElement;\n this.progressBarContainer = this.modalEl.querySelector('#progressBarContainer') as HTMLElement;\n this.progressBar = this.modalEl.querySelector('#progressBar') as HTMLElement;\n this.messageArea = this.modalEl.querySelector('#messageArea') as HTMLElement;\n this.statusTable = this.modalEl.querySelector('#statusTable') as HTMLElement;\n }\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 Wiring \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n private wireDropzone() {\n // wipe old handlers (in case of re-open)\n const dz = this.dropzoneForm;\n dz.replaceWith(dz.cloneNode(true));\n this.dropzoneForm = this.modalEl.querySelector('#folderDropzone') as HTMLElement;\n\n // Drag over\n this.dropzoneForm.addEventListener('dragover', (e) => {\n e.preventDefault();\n this.dropzoneForm.style.backgroundColor = '#e9ecef';\n });\n\n this.dropzoneForm.addEventListener('dragleave', () => {\n this.dropzoneForm.style.backgroundColor = '';\n });\n\n // Click -> file input (single file only)\n this.dropzoneForm.addEventListener('click', () => {\n const input = document.createElement('input');\n input.type = 'file';\n input.accept = this.options.accept.join(',');\n input.multiple = false; // enforce single\n input.onchange = () => {\n const files = input.files;\n if (!files || files.length === 0) return;\n if (files.length > 1) {\n this.showMessage('failed', t2('body.pleaseChooseOnlyOneFile'));\n return;\n }\n this.onFilePicked(files[0]);\n };\n input.click();\n });\n\n // Drop handler (works in Safari too)\n this.dropzoneForm.addEventListener('drop', (e: DragEvent) => this.onDrop(e));\n }\n\n\n private async onDrop(e: DragEvent) {\n e.preventDefault();\n this.dropzoneForm.style.backgroundColor = '';\n\n const files = e.dataTransfer?.files;\n if (files && files.length > 0) {\n if (files.length > 1) {\n this.showMessage('failed', t2('body.pleaseDropOnlyOneFile'));\n return;\n }\n this.onFilePicked(files[0]);\n return;\n }\n\n const items = e.dataTransfer?.items;\n if (!items || items.length === 0) return;\n if (items.length > 1) {\n this.showMessage('failed', t2('body.pleaseDropOnlyOneFile'));\n return;\n }\n\n const item = items[0];\n const entry = (item as any).webkitGetAsEntry?.();\n if (entry && entry.isDirectory) {\n this.showMessage('failed', t2('body.pleaseDropASingleFileNotFolder'));\n return;\n }\n\n const file = item.getAsFile();\n if (!file) {\n this.showMessage('failed', t2('body.noFileFoundInTheDrop'));\n return;\n }\n this.onFilePicked(file);\n }\n\n private onFilePicked(file: File) {\n // Generic hint only if patterns are set and don\u2019t match\n if (!this.extAllowed(file.name) && this.options.accept.length) {\n this.showMessage(\n 'running',\n t2('body.pickedFileAllowed', { name: escapeHtml(file.name), allowed: escapeHtml(this.options.accept.join(', ')) })\n );\n }\n\n this.selectedFile = file;\n this.renderPickedFile(file);\n this.updateUploadButtonVisibility();\n\n if (!this.isUploaded) {\n void this.startUpload();\n }\n }\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 Uploading \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n /** Brand color helper: read from CSS var, fallback to hex */\n private getBrandGold(): string {\n const val = getComputedStyle(document.documentElement)\n .getPropertyValue('--mimic-gold')\n .trim();\n return val || '#D4AF37'; // fallback gold\n }\n\n // setDismissable enables or disables the modal's Cancel and header X\n // (the header X is a BS4 data-dismiss button, which Bootstrap closes on\n // its own unless it is disabled).\n private setDismissable(on: boolean): void {\n if (this.cancelBtn) this.cancelBtn.disabled = !on;\n const closeBtn = this.modalEl?.querySelector('[data-dismiss=\"modal\"], [data-bs-dismiss=\"modal\"]') as HTMLButtonElement | null;\n if (closeBtn) closeBtn.disabled = !on;\n }\n\n private async startUpload(): Promise {\n if (!this.selectedFile) {\n this.showMessage('failed', t2('body.pleaseSelectOrDropAFile'));\n return;\n }\n if (this.isUploading) return;\n\n // This upload's callbacks: a later open() replaces this.options.\n const opts = this.options;\n const veto = opts.onStart?.(this.selectedFile);\n if (typeof veto === 'string') {\n this.showMessage('failed', escapeHtml(veto));\n return;\n }\n this.isUploading = true;\n this.setDismissable(false);\n this.okBtn.disabled = true;\n this.updateUploadButtonVisibility();\n\n const headIdx = this.addLog({\n status: 'info',\n message: t2('body.readyToUpload', { name: escapeHtml(this.selectedFile.name) }),\n progress: 0,\n });\n this.updateProgress(1);\n\n let savingTicker: number | null = null;\n const stopSavingTicker = () => {\n if (savingTicker !== null) {\n window.clearInterval(savingTicker);\n savingTicker = null;\n }\n };\n\n try {\n const file = this.selectedFile;\n const total = file.size;\n let uploadedSoFar = 0;\n let saveIdx = -1; // row index for \"Saving\u2026\" in the history table\n let savingAnnounced = false; // we announce saving as soon as last chunk lands\n\n // Upload chunks (store handles headers, MD5, etc.)\n const result: UploadCompletePayload = await this.uploadStore.uploadFileChunked({\n sessionId: opts.sessionId,\n folder: opts.folder,\n localFile: file,\n type: opts.type,\n chunkSize: opts.chunkSizeBytes,\n extraHeaders: opts.extraHeaders,\n chunkUrl: opts.chunkUrl,\n completeUrl: opts.completeUrl,\n gzipAlways: opts.gzipAlways,\n onProgressByteDelta: (delta: number) => {\n uploadedSoFar += delta;\n // Map upload progress into 0..90% (reserve 10% for the saving phase)\n const { raw: rawPct, display: displayPct } = uploadProgressPct(uploadedSoFar, total);\n this.updateProgress(displayPct);\n // Keep message clear, but don't ever show 100% in the progress column here\n this.updateLogProgress(headIdx, displayPct, t2('body.uploadingFilePct', { name: escapeHtml(file.name), pct: rawPct }));\n\n // When the last chunk lands, immediately announce the \"Saving\u2026\" phase\n if (!savingAnnounced && uploadedSoFar >= total) {\n savingAnnounced = true;\n this.updateProgress(90);\n this.updateLogStatus(headIdx, 'passed', t2('body.uploadedFile100', { name: escapeHtml(file.name) }));\n\n // spinner, Mimic Gold\n const GOLD = this.getBrandGold();\n const preMsg =\n `` +\n t2('body.savingFile');\n this.showMessage('running', preMsg);\n saveIdx = this.addLog({ status: 'running', message: preMsg, progress: 90 });\n\n // Gentle pulse 90\u219295% while saving\n let shown = 90;\n savingTicker = window.setInterval(() => {\n if (shown < 95) {\n shown += 1;\n this.updateProgress(shown);\n if (saveIdx >= 0) this.updateLogProgress(saveIdx, shown);\n } else {\n stopSavingTicker();\n }\n }, 350);\n }\n },\n });\n\n try {\n // Let caller react; we pass through server filename (if any) and original file name\n if (opts.onComplete) {\n const enriched = { ...result, originalFilename: file.name, size: file.size };\n const maybe = opts.onComplete(enriched);\n if (maybe instanceof Promise) await maybe;\n }\n\n stopSavingTicker();\n this.updateProgress(100);\n if (saveIdx >= 0) {\n // Now the entire workflow is complete; reflect 100% on the saving row\n this.updateLogProgress(saveIdx, 100);\n this.updateLogStatus(saveIdx, 'passed', t2('body.saved'));\n }\n this.showMessage('completed', t2('body.uploadCompletedFor', { name: escapeHtml(file.name) }));\n this.okBtn.disabled = false;\n\n } catch (postErr: any) {\n stopSavingTicker();\n this.updateProgress(95);\n if (saveIdx >= 0) this.updateLogStatus(saveIdx, 'failed', t2('body.saveFailed'));\n else this.addLog({ status: 'failed', message: t2('body.saveFailed'), progress: 95 });\n this.showMessage('failed', escapeHtml(this.errorText(opts, postErr, t2('body.saveFailedDot'))));\n this.okBtn.disabled = true;\n opts.onError?.(postErr);\n }\n\n } catch (err: any) {\n this.showMessage('failed', escapeHtml(this.errorText(opts, err, t2('body.uploadFailedDot'))));\n this.updateLogStatus(headIdx, 'failed', t2('body.uploadFailedDot'));\n this.okBtn.disabled = true;\n opts.onError?.(err);\n } finally {\n this.isUploading = false;\n this.setDismissable(true);\n this.updateUploadButtonVisibility();\n this.stopTimer();\n }\n }\n\n\n /** A failure's text: the caller's wording when it gave one, else the\n * error's message, else fallback. */\n private errorText(opts: UploadModal['options'], err: any, fallback: string): string {\n if (opts.describeError && err instanceof Error) return opts.describeError(err);\n return err?.message || fallback;\n }\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 UI helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n private renderPickedFile(file: File) {\n this.clearUI();\n this.startTimer();\n\n this.addLog({ status: 'info', message: t2('body.readyToUploadBold', { name: escapeHtml(file.name) }), progress: 0 });\n\n this.dropzoneForm.innerHTML = `\n
\n description\n

${escapeHtml(file.name)}

\n
`;\n }\n\n private resetUiState() {\n this.selectedFile = null;\n this.isUploading = false;\n this.isUploaded = false;\n this.setDismissable(true);\n\n this.okBtn.disabled = true; // OK only becomes enabled when upload completes\n\n // reset visuals\n this.clearUI();\n this.dropzoneForm.innerHTML = `\n
\n upload_file\n

${t2('body.dropYourFileHereOrClick')}

\n
`;\n this.updateUploadButtonVisibility();\n }\n\n private updateUploadButtonVisibility(): void {\n if (!this.uploadBtn) return;\n if (this.isUploading || this.isUploaded) {\n this.uploadBtn.style.display = 'none';\n this.uploadBtn.disabled = true;\n return;\n }\n const ready = !!this.selectedFile;\n\n const autoOff = false\n if (ready && autoOff) {\n this.uploadBtn.style.display = 'inline-flex';\n this.uploadBtn.disabled = false;\n } else {\n this.uploadBtn.style.display = 'none';\n this.uploadBtn.disabled = true;\n }\n }\n\n private clearUI(): void {\n if (this.uploadLoadingIndicator) this.uploadLoadingIndicator.style.display = 'none';\n if (this.progressBarContainer) this.progressBarContainer.style.display = 'none';\n if (this.messageArea) {\n this.messageArea.style.display = 'none';\n this.messageArea.className = 'alert';\n this.messageArea.textContent = '';\n }\n const hist = this.modalEl.querySelector('#statusHistoryContainer') as HTMLElement;\n if (hist) hist.style.display = 'none';\n this.statusHistory = [];\n if (this.statusTable) this.statusTable.innerHTML = '';\n }\n\n private showMessage(\n status: 'failed' | 'completed' | 'running',\n message: string,\n progress?: number\n ): void {\n let alertClass = 'alert-secondary';\n if (status === 'completed') alertClass = 'alert-success';\n if (status === 'failed') alertClass = 'alert-danger';\n if (status === 'running') alertClass = 'alert-info';\n\n if (status === 'running') {\n this.messageArea.className = `alert ${alertClass}`;\n this.messageArea.style.display = 'block';\n if (message.trim().length > 0) this.runningBaseMessage = message;\n\n this.baseSpan.innerHTML = this.runningBaseMessage;\n this.baseSpan.querySelectorAll('i').forEach((icon) => (icon as HTMLElement).style.marginLeft = '0px');\n\n this.messageArea.innerHTML = '';\n this.messageArea.appendChild(this.baseSpan);\n this.messageArea.appendChild(this.timerSpan);\n } else {\n this.messageArea.className = `alert ${alertClass}`;\n this.messageArea.innerHTML = message;\n this.messageArea.style.display = message ? 'block' : 'none';\n this.messageArea.querySelectorAll('i').forEach((icon) => (icon as HTMLElement).style.marginLeft = '0px');\n }\n\n if (progress !== undefined) this.updateProgress(progress);\n }\n\n private updateProgress(progress: number): void {\n if (this.progressBarContainer) this.progressBarContainer.style.display = 'block';\n if (this.progressBar) {\n this.progressBar.style.width = `${progress}%`;\n this.progressBar.textContent = `${progress}%`;\n this.progressBar.setAttribute('aria-valuenow', String(progress));\n }\n // ensure status block visible once uploading starts\n const container = this.modalEl.querySelector('#statusHistoryContainer') as HTMLElement;\n if (container) container.style.display = 'block';\n }\n\n private addLog(entry: { status: string; message: string; progress: number }): number {\n const row = {\n time: new Date().toLocaleTimeString(),\n status: entry.status,\n message: entry.message,\n progress: entry.progress,\n elapsedTime: (Date.now() - (this.startTime ?? Date.now())),\n bold: true,\n };\n this.statusHistory.push(row);\n this.updateStatusTable();\n return this.statusHistory.length - 1;\n }\n\n private updateLogProgress(index: number, progress: number, message?: string) {\n const row = this.statusHistory[index];\n row.progress = progress;\n if (message) row.message = message;\n row.elapsedTime = Date.now() - (this.startTime ?? Date.now());\n this.updateStatusTable();\n }\n\n private updateLogStatus(index: number, status: string, message?: string) {\n const row = this.statusHistory[index];\n row.status = status;\n if (message) row.message = message;\n row.bold = false;\n row.elapsedTime = Date.now() - (this.startTime ?? Date.now());\n this.updateStatusTable();\n }\n\n private updateStatusTable(): void {\n if (!this.statusTable) return;\n this.statusTable.innerHTML = '';\n\n const headerRow = document.createElement('tr');\n headerRow.innerHTML = `\n ${t2('col.time')}\n ${t2('col.status')}\n ${t2('common.message')}\n ${t2('body.progress')}\n ${t2('body.elapsed')}`;\n this.statusTable.appendChild(headerRow);\n\n if (!this.statusHistory.length) return;\n\n this.statusHistory.forEach((entry) => {\n const row = document.createElement('tr');\n\n const timeTd = document.createElement('td'); timeTd.textContent = entry.time;\n const statusTd = document.createElement('td'); statusTd.textContent = statusLabel(entry.status);\n const messageTd = document.createElement('td'); messageTd.innerHTML = entry.message;\n const progressTd = document.createElement('td'); progressTd.className = 'text-right'; progressTd.textContent = `${entry.progress}%`;\n const elapsedTd = document.createElement('td'); elapsedTd.className = 'text-right'; elapsedTd.textContent = formatElapsedDuration(entry.elapsedTime);\n\n if (entry.bold) row.style.fontWeight = 'bold';\n\n row.appendChild(timeTd);\n row.appendChild(statusTd);\n row.appendChild(messageTd);\n row.appendChild(progressTd);\n row.appendChild(elapsedTd);\n\n this.statusTable.appendChild(row);\n });\n }\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 Timer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n private startTimer(): void {\n this.startTime = Date.now();\n this.updateTimerMessage();\n this.timerInterval = window.setInterval(() => this.updateTimerMessage(), 1000);\n }\n\n private stopTimer(): void {\n if (this.timerInterval) {\n clearInterval(this.timerInterval);\n this.timerInterval = null;\n }\n this.startTime = null;\n }\n\n private updateTimerMessage(): void {\n if (!this.startTime) return;\n const elapsedMs = Date.now() - this.startTime;\n const formatted = formatElapsed(elapsedMs);\n this.timerSpan.textContent = ` (${formatted})`;\n }\n\n /* \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 Utilities \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\n private extAllowed(name: string): boolean {\n if (!this.options.accept || this.options.accept.length === 0) return true;\n const lower = name.toLowerCase();\n return this.options.accept.some(ext => lower.endsWith(ext.toLowerCase()));\n }\n}\n\n/** Localized display label for an internal status-log token. */\nfunction statusLabel(status: string): string {\n switch (status) {\n case 'info': return t2('body.infoLower');\n case 'running': return t2('body.running2');\n case 'failed': return t2('body.failedLower');\n case 'completed': return t2('body.completedLower');\n case 'passed': return t2('body.passedLower');\n default: return status;\n }\n}\n\n", "// VulnConnectorDrawer \u2014 slide-out form for a vendor connector on Admin \u2192\n// Vulnerability Sources (ENG-4346). Same Drawer base as the Connector\n// Redirects form and the Notifications rule form, wide enough that every\n// label sits on one line with its help text beneath. On edit the\n// connector's run history sits below the form, so one drawer shows\n// everything about a connector. The client secret is write-only: the form\n// never receives it, only whether one is stored.\n\nimport { Drawer } from './Drawer';\nimport { t as t2 } from '../i18n/I18n';\nimport { escapeHtml } from './FormatUtils';\nimport { FetchGeneration } from './FetchGeneration';\nimport type AdminStore from '../stores/AdminStore';\nimport type { VulnConnector, VulnConnectorInput, VulnConnectorRun, VulnConnectorVendor } from '../stores/AdminStore';\n\ninterface OpenArgs {\n store: AdminStore;\n tenantId: string;\n vendors: VulnConnectorVendor[];\n /** null = add */\n existing: VulnConnector | null;\n /** Called after a successful save so the tab can reload. */\n onSaved: () => void;\n}\n\nconst PREFIX = 'vuln-connector-drawer';\nconst ID = 'vuln-connector-drawer';\nconst STYLE_ID = 'vuln-connector-drawer-styles';\n\nlet drawer: Drawer | null = null;\n// Bumped by every open() and by the drawer's close: a save that resolves\n// after either must not close (or report into) the form shown by then.\nconst openGen = new FetchGeneration();\n\n/**\n * True when an edit moves the API URL or token URL of a connector whose\n * secret is stored, without entering a new secret: keeping the stored one\n * would send it to the newly typed host on the next Test or Run.\n */\nexport function secretNeededForUrlChange(\n loaded: { apiUrl: string; authUrl: string },\n now: { apiUrl: string; authUrl: string },\n hasSecret: boolean,\n secret: string,\n): boolean {\n if (!hasSecret || secret !== '') return false;\n return now.apiUrl !== loaded.apiUrl || now.authUrl !== loaded.authUrl;\n}\n\n/** The vendor knobs the form owns in a connector's `extra`. */\nexport interface ConnectorFormKnobs {\n asset_types?: string[];\n name_filter?: boolean;\n match_only?: boolean;\n}\n\n/**\n * The knobs a save sends: every one on create (`loaded` null), otherwise\n * only those the user changed since the form loaded. The server merges what\n * is sent onto the stored extra and keeps every other key, so a knob left as\n * loaded keeps whatever value was saved elsewhere meanwhile, not this form's\n * stale copy.\n */\nexport function connectorKnobsToSend(loaded: ConnectorFormKnobs | null, now: ConnectorFormKnobs): ConnectorFormKnobs {\n if (!loaded) return { ...now };\n const out: ConnectorFormKnobs = {};\n if (now.asset_types !== undefined && (loaded.asset_types ?? []).join(',') !== now.asset_types.join(',')) out.asset_types = now.asset_types;\n if (now.name_filter !== undefined && now.name_filter !== loaded.name_filter) out.name_filter = now.name_filter;\n if (now.match_only !== undefined && now.match_only !== loaded.match_only) out.match_only = now.match_only;\n return out;\n}\n\nfunction ensureStyles(): void {\n if (document.getElementById(STYLE_ID)) return;\n const style = document.createElement('style');\n style.id = STYLE_ID;\n style.textContent = `\n .${PREFIX}__title { font-weight: 600; font-size: 15px; color: #111; }\n .${PREFIX}__subtitle { font-size: 12px; color: #666; margin-top: 2px; max-width: 640px; }\n .${PREFIX}__form { display: flex; flex-direction: column; gap: 14px; max-width: 720px; }\n .${PREFIX}__field { display: flex; flex-direction: column; gap: 4px; }\n .${PREFIX}__field > label { font-size: 12px; color: #666; white-space: nowrap; }\n .${PREFIX}__field input, .${PREFIX}__field select { font-family: inherit; width: 100%; }\n .${PREFIX}__field input.mono { font-family: var(--font-mono, monospace); font-size: 13px; }\n .${PREFIX}__help { font-size: 12px; color: #8a8984; }\n .${PREFIX}__check { display: flex; align-items: center; gap: 8px; font-size: 13px; }\n .${PREFIX}__error { color: #b3261e; font-size: 13px; min-height: 18px; }\n .${PREFIX}__actions { display: flex; gap: 8px; margin-top: 6px; }\n .${PREFIX}__cancel { margin-right: auto; width: 80px; text-align: center; background: #5a6672; color: #fff; border: none; }\n .${PREFIX}__cancel:hover { background: #4a5560; color: #fff; }\n .${PREFIX}__save { min-width: 80px; text-align: center; background: var(--brand-accent, #d4b25a); color: var(--brand-accent-fg, #1a1a1a); border: none; font-weight: 500; }\n .${PREFIX}__save:hover { filter: brightness(0.94); }\n .${PREFIX}__save:disabled { opacity: 0.6; cursor: default; }\n .${PREFIX}__runs { margin-top: 28px; }\n .${PREFIX}__runs h6 { font-weight: 600; margin: 0 0 8px; }\n .${PREFIX}__runs table { width: 100%; font-size: 13px; }\n .${PREFIX}__runs th { white-space: nowrap; }\n .${PREFIX}__runs td.num { text-align: right; }\n .${PREFIX}__runs .ok { color: #006300; font-weight: 600; }\n .${PREFIX}__runs .failed { color: #d03b3b; font-weight: 600; }\n .${PREFIX}__runs .muted { color: #8a8984; }\n .${PREFIX}__runs .err { color: #52514e; font-size: 12px; word-break: break-word; }\n `;\n document.head.appendChild(style);\n}\n\n/** A list knob from a connector's extra JSON, joined for the text field; `fallback` when unset. */\nfunction extraList(extra: Record | undefined, key: string, fallback: string): string {\n const v = extra?.[key];\n if (Array.isArray(v)) return v.map(String).join(', ');\n if (typeof v === 'string' && v.trim()) return v;\n return fallback;\n}\n\n/** A boolean knob from a connector's extra JSON; absent means on. */\nfunction extraFlag(extra: Record | undefined, key: string): boolean {\n const v = extra?.[key];\n return typeof v === 'boolean' ? v : true;\n}\n\nfunction fmtDate(v: string | undefined | null): string {\n if (!v) return '';\n const d = new Date(v);\n return isNaN(d.getTime()) ? escapeHtml(v) : d.toLocaleString();\n}\n\nexport const VulnConnectorDrawer = {\n open({ store, tenantId, vendors, existing, onSaved }: OpenArgs): void {\n ensureStyles();\n if (!drawer) {\n drawer = new Drawer({\n id: ID,\n classPrefix: PREFIX,\n ariaLabel: t2('body.vulnSrcConnTitle'),\n width: 'min(820px, 92vw)',\n keepWidthWhenStacked: true,\n });\n drawer.onClose(() => openGen.invalidate());\n }\n const gen = openGen.begin();\n\n const vendorList: VulnConnectorVendor[] = vendors.length ? vendors : [{ id: 'wiz', name: 'Wiz', authUrl: '', apiUrl: '', apiUrlHint: '', needsApiUrl: true, auth: 'client_credentials', assetScoped: true, assetTypes: true }];\n const vendorName = (id: string) => vendorList.find(v => v.id === id)?.name ?? id;\n const v0 = existing ? vendorList.find(v => v.id === existing.vendor) : vendorList[0];\n\n drawer.headerEl.innerHTML = `\n
${escapeHtml(t2('body.vulnSrcConnTitle'))} \u00B7 ${escapeHtml(existing ? existing.label : t2('body.vulnSrcConnAdd'))}
\n
${escapeHtml(t2('body.vulnSrcConnHint'))}
\n `;\n\n const field = (id: string, label: string, input: string, help = '') =>\n `
${input}${help ? `
${help}
` : ''}
`;\n const vendorInput = existing\n ? ``\n : ``;\n\n drawer.bodyEl.innerHTML = `\n
\n ${field('vcd-vendor', t2('body.vulnSrcConnVendor'), vendorInput, `${escapeHtml(t2('body.vulnSrcConnFeedHint'))}`)}\n ${field('vcd-label', t2('body.vulnSrcConnLabel'), ``, escapeHtml(t2('body.vulnSrcConnLabelHelp')))}\n ${field('vcd-api-url', t2('body.vulnSrcConnApiUrl'), ``, escapeHtml(t2('body.vulnSrcConnApiUrlHelp', { hint: v0?.apiUrlHint ?? '' })))}\n ${field('vcd-auth-url', t2('body.vulnSrcConnAuthUrl'), ``)}\n ${field('vcd-client-id', t2('body.vulnSrcConnClientId'), ``)}\n ${field('vcd-secret', t2('body.vulnSrcConnSecret'), ``, existing?.hasSecret ? escapeHtml(t2('body.vulnSrcConnSecretSet')) : '')}\n
\n ${field('vcd-asset-types', t2('body.vulnSrcConnAssetTypes'), ``, escapeHtml(t2('body.vulnSrcConnAssetTypesHelp')))}\n \n
${escapeHtml(t2('body.vulnSrcConnNameFilterHelp', { vendor: v0?.name ?? '' }))}
\n \n
${escapeHtml(t2('body.vulnSrcConnMatchOnlyHelp'))}
\n
\n \n
\n
\n \n \n
\n
\n ${existing ? `
${escapeHtml(t2('body.vulnSrcConnRuns'))}
${escapeHtml(t2('dt.loading'))}
` : ''}\n `;\n\n const $id = (s: string) => drawer!.bodyEl.querySelector(s)!;\n const val = (s: string) => ($id(s) as HTMLInputElement).value.trim();\n const errorEl = $id('#vcd-error');\n const saveBtn = $id('#vcd-save') as HTMLButtonElement;\n\n // The vendor decides which fields exist: a fixed endpoint hides the\n // API URL; token auth hides the token URL and client id and calls the\n // secret a token; key-and-secret auth hides the token URL and calls\n // the other two the API key and API secret; a knowledge feed (no\n // assets of its own) hides the asset knobs and says where its\n // findings land instead; only a vendor with an asset-type enum (Wiz)\n // shows the asset-types field.\n const fieldOf = (id: string) => $id(`#${id}`).closest(`.${PREFIX}__field`);\n const applyVendor = (v: VulnConnectorVendor) => {\n const apiField = fieldOf('vcd-api-url');\n if (apiField) apiField.hidden = !v.needsApiUrl;\n if (!v.needsApiUrl && !val('#vcd-api-url')) ($id('#vcd-api-url') as HTMLInputElement).value = v.apiUrl;\n const token = v.auth === 'api_token';\n const keySecret = v.auth === 'api_key_secret';\n const authField = fieldOf('vcd-auth-url');\n if (authField) authField.hidden = token || keySecret;\n const clientField = fieldOf('vcd-client-id');\n if (clientField) clientField.hidden = token;\n const clientLabel = drawer!.bodyEl.querySelector('label[for=\"vcd-client-id\"]');\n if (clientLabel) clientLabel.textContent = keySecret ? t2('body.vulnSrcConnApiKey') : t2('body.vulnSrcConnClientId');\n const secretLabel = drawer!.bodyEl.querySelector('label[for=\"vcd-secret\"]');\n if (secretLabel) secretLabel.textContent = token ? t2('body.vulnSrcConnApiToken') : keySecret ? t2('body.vulnSrcConnApiSecret') : t2('body.vulnSrcConnSecret');\n $id('#vcd-asset-knobs').hidden = !v.assetScoped;\n const typesField = fieldOf('vcd-asset-types');\n if (typesField) typesField.hidden = !v.assetTypes;\n $id('#vcd-name-filter-label').textContent = t2('body.vulnSrcConnNameFilter', { vendor: v.name });\n $id('#vcd-name-filter-help').textContent = t2('body.vulnSrcConnNameFilterHelp', { vendor: v.name });\n $id('#vcd-feed-hint').hidden = v.assetScoped;\n const help = drawer!.bodyEl.querySelector('#vcd-api-url-help');\n if (help) help.textContent = t2('body.vulnSrcConnApiUrlHelp', { hint: v.apiUrlHint });\n };\n if (v0) applyVendor(v0);\n\n // The stored secret belongs to the URLs it was entered for. Once an\n // edit moves either URL the \"leave blank to keep it\" hint no longer\n // holds, and the secret must be entered again.\n const loadedUrls = { apiUrl: val('#vcd-api-url'), authUrl: val('#vcd-auth-url') };\n // The vendor knobs as the form shows them. A vendor without assets of\n // its own has no asset knobs; one without asset types has no type list.\n const readKnobs = (): ConnectorFormKnobs => {\n const vk = vendorList.find(x => x.id === (existing ? existing.vendor : val('#vcd-vendor')));\n if (vk && !vk.assetScoped) return {};\n const assetTypes = val('#vcd-asset-types').split(',').map(t => t.trim().toUpperCase()).filter(Boolean);\n return {\n ...(vk && !vk.assetTypes ? {} : { asset_types: assetTypes.length ? assetTypes : ['VIRTUAL_MACHINE'] }),\n name_filter: ($id('#vcd-name-filter') as HTMLInputElement).checked,\n match_only: ($id('#vcd-match-only') as HTMLInputElement).checked,\n };\n };\n const loadedKnobs = existing ? readKnobs() : null;\n const secretInput = $id('#vcd-secret') as HTMLInputElement;\n const secretHelp = drawer.bodyEl.querySelector('#vcd-secret-help');\n // Returns true while the save must wait for a new secret.\n const syncSecretRequired = (): boolean => {\n const urlMoved = secretNeededForUrlChange(\n loadedUrls,\n { apiUrl: val('#vcd-api-url'), authUrl: val('#vcd-auth-url') },\n !!existing?.hasSecret,\n '',\n );\n secretInput.required = urlMoved;\n if (secretHelp) secretHelp.hidden = urlMoved;\n return urlMoved && val('#vcd-secret') === '';\n };\n for (const sel of ['#vcd-api-url', '#vcd-auth-url', '#vcd-secret']) {\n $id(sel).addEventListener('input', () => { syncSecretRequired(); });\n }\n\n // Vendor change re-fills the token URL default and re-shapes the form.\n const vendorSel = drawer.bodyEl.querySelector('select#vcd-vendor');\n vendorSel?.addEventListener('change', () => {\n const v = vendorList.find(x => x.id === vendorSel.value);\n if (!v) return;\n ($id('#vcd-auth-url') as HTMLInputElement).value = v.authUrl;\n ($id('#vcd-api-url') as HTMLInputElement).value = v.needsApiUrl ? '' : v.apiUrl;\n applyVendor(v);\n });\n\n $id('#vcd-cancel').addEventListener('click', () => drawer!.close());\n\n // Enter in a field calls save() too, so the button's disabled state\n // alone does not stop a second create while the first is in flight.\n let inFlight = false;\n const save = async () => {\n if (inFlight) return;\n if (syncSecretRequired()) {\n // The browser's own \"fill out this field\" prompt, localized.\n secretInput.reportValidity();\n return;\n }\n const vendorId = existing ? existing.vendor : val('#vcd-vendor');\n const input: VulnConnectorInput = {\n vendor: vendorId,\n label: val('#vcd-label'),\n apiUrl: val('#vcd-api-url'),\n authUrl: val('#vcd-auth-url'),\n clientId: val('#vcd-client-id'),\n // The vendor-specific knobs ride `extra`: on create every knob\n // this form owns, on update only those the user changed here.\n // The server merges them onto the stored extra, so keys it does\n // not own (a raw filterBy or aql, project ids) and knobs left as\n // loaded keep their current values rather than this form's copy.\n extra: { ...connectorKnobsToSend(loadedKnobs, readKnobs()) },\n };\n // `enabled` rides only when the box was toggled (or on create): the\n // server keeps the stored value otherwise, so saving a stale form\n // does not send back the value it loaded over an enable or disable\n // made elsewhere since it opened.\n const enabledNow = ($id('#vcd-enabled') as HTMLInputElement).checked;\n if (!existing || enabledNow !== existing.enabled) input.enabled = enabledNow;\n const secret = val('#vcd-secret');\n if (secret) input.secret = secret;\n errorEl.textContent = '';\n saveBtn.disabled = true;\n inFlight = true;\n try {\n if (existing) await store.updateVulnConnector(tenantId, existing.id, input);\n else await store.createVulnConnector(tenantId, input);\n // The drawer is shared: close it only while it still shows\n // this form. The list reloads either way.\n if (openGen.isCurrent(gen)) drawer!.close();\n onSaved();\n } catch (err) {\n if (openGen.isCurrent(gen)) errorEl.textContent = (err as Error).message;\n } finally {\n inFlight = false;\n saveBtn.disabled = false;\n }\n };\n saveBtn.addEventListener('click', () => void save());\n for (const input of drawer.bodyEl.querySelectorAll(`.${PREFIX}__form input[type=text], .${PREFIX}__form input[type=url], .${PREFIX}__form input[type=password]`)) {\n input.addEventListener('keydown', (e) => { if (e.key === 'Enter') void save(); });\n }\n\n drawer.open();\n ($id('#vcd-label') as HTMLInputElement).focus();\n\n if (existing) void renderRuns(store, tenantId, existing);\n },\n};\n\nasync function renderRuns(store: AdminStore, tenantId: string, conn: VulnConnector): Promise {\n const host = drawer?.bodyEl.querySelector('#vcd-runs');\n if (!host) return;\n let runs: VulnConnectorRun[];\n try {\n runs = await store.listVulnConnectorRuns(tenantId, conn.id);\n } catch (err) {\n host.innerHTML = `
${escapeHtml(t2('body.vulnSrcConnRuns'))}
${escapeHtml((err as Error).message)}`;\n return;\n }\n const rows = runs.map(r => `\n \n ${escapeHtml(r.status)}\n ${fmtDate(r.startedAt)}\n ${fmtDate(r.finishedAt)}\n ${r.findings}\n ${r.assets}\n ${r.retired}\n ${escapeHtml(r.triggeredBy)}\n ${escapeHtml(r.error)}\n `).join('');\n host.innerHTML = `\n
${escapeHtml(t2('body.vulnSrcConnRuns'))}
\n ${runs.length === 0 ? `${escapeHtml(t2('body.vulnSrcConnNoRuns'))}` : `\n \n \n \n \n \n \n \n \n \n \n \n ${rows}\n
${escapeHtml(t2('body.vulnSrcColStatus'))}${escapeHtml(t2('body.vulnSrcConnColStarted'))}${escapeHtml(t2('body.vulnSrcConnColFinished'))}${escapeHtml(t2('body.vulnSrcColFindings'))}${escapeHtml(t2('body.vulnSrcConnColAssets'))}${escapeHtml(t2('body.vulnSrcConnColRetired'))}${escapeHtml(t2('body.vulnSrcConnColTriggeredBy'))}${escapeHtml(t2('body.vulnSrcConnColError'))}
`}`;\n}\n", "// What the Vulnerability Sources import modal says about a finished import.\n//\n// A superseded file (a newer file of the same name came in while this one was\n// parsed) answers 200 with no findings applied; it must not read as an\n// import of an empty file, so it is told apart from one, as an identical\n// file already imported is.\n\nexport interface VulnImportResult {\n alreadySeen?: boolean;\n superseded?: boolean;\n}\n\nexport type VulnImportOutcome = \"alreadySeen\" | \"superseded\" | \"imported\";\n\nexport function vulnImportOutcome(r: VulnImportResult): VulnImportOutcome {\n if (r.alreadySeen) return \"alreadySeen\";\n if (r.superseded) return \"superseded\";\n return \"imported\";\n}\n", "// TenantProfilingEconomicsUI \u2014 the finance \"economics\" report tabs on the\n// Tenant Profiling page (ENG-3213 follow-up). Reproduces, in-app, the three\n// report sheets a finance analyst built from the include-history CSV export:\n//\n// \u2022 Monthly Summary \u2014 fleet node-days / avg nodes / event volume /\n// events-per-node-day, per calendar month.\n// \u2022 Monthly by Tenant \u2014 the same three measures broken out per tenant/month.\n// \u2022 Tenant Totals \u2014 full-period totals per tenant.\n//\n// All aggregation happens here, client-side, from the per-(tenant, day) rows\n// the /economics endpoint returns \u2014 the same rows the history CSV emits \u2014 so\n// the on-screen numbers match the workbook cell-for-cell. Charts use the\n// globally-loaded Chart.js (see utils/PieChart.ts).\n//\n// Fully localized: every label goes through t(); numbers via formatNumber and\n// month labels via getLocale(), so a finance user on any of the shipped\n// locales gets native number grouping + month names.\n//\n// \"Average nodes\" for a month = node-days \u00F7 the days elapsed that month (a\n// monthly average), matching the workbook's Avg Nodes formula.\n// \"Node-days\" sums the daily node_total (the backfilled provisioned count).\n\nimport { formatNumber, escapeHtml } from '../utils/FormatUtils';\nimport { SC_PALETTE } from '../utils/SummaryCharts';\nimport { t, getLocale } from '../i18n/I18n';\nimport { installCsvActionBar, setupDataTableUI } from '../utils/DataTables';\nimport { displayTenantNameText } from '../utils/TenantNameDisplay';\nimport {\n TenantEconomicsResponse,\n TenantEconomicsRow,\n TenantEconomicsTenant,\n} from '../stores/TenantProfilingStore';\nimport { csvCell, csvFormulaSafe } from '../utils/CsvFormula';\n\ndeclare const Chart: any;\n\n// A tenant's label in every table, chart, echo and CSV of these tabs: it\n// follows the Show Tenant Names setting as the Current tab does (masked: the\n// initial and dots). The CSV mirrors the table, so a masked view never\n// writes the names to a file either.\nfunction tenantLabel(x: TenantEconomicsTenant): string {\n return displayTenantNameText(x.tenantName || x.tenantId, { tenantId: x.tenantId });\n}\n\n// Trigger a CSV file download from a header + rows (already-formatted strings,\n// but we strip grouping separators from numeric-looking cells so the CSV is\n// machine-parseable). Cells are quoted (CR included) and formula-safe, as the\n// other CSVs are: tenant labels are text a spreadsheet must not evaluate.\nexport function economicsCsvLines(header: string[], rows: string[][]): string[] {\n // Only a cell that is a grouped number (\"1,234\", \"-1,234.5\") loses its\n // separators: a tenant named \"Acme,123 Ltd\" keeps its comma.\n const clean = (c: string) => (/^-?\\d{1,3}(,\\d{3})+(\\.\\d+)?$/.test(c) ? c.replace(/,/g, '') : c);\n const cell = (c: string) => csvCell(csvFormulaSafe(c));\n const lines = [header.map(cell).join(',')];\n for (const r of rows) lines.push(r.map(c => cell(clean(c))).join(','));\n return lines;\n}\n\nfunction downloadCsv(filename: string, header: string[], rows: string[][]): void {\n const lines = economicsCsvLines(header, rows);\n const blob = new Blob([lines.join('\\n')], { type: 'text/csv;charset=utf-8' });\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = url; a.download = filename;\n document.body.appendChild(a); a.click(); a.remove();\n URL.revokeObjectURL(url);\n}\n\nexport type EconomicsTab = 'summary' | 'byTenant' | 'totals';\n\n// Fleet series colour (node-days / totals bars) and the events accent.\nconst NODE_COLOR = '#1f6feb';\nconst EVENT_COLOR = '#2b8a3e';\n// Activity series. Profiling keeps the established fleet blue (it is the bulk\n// of the estate); protecting takes the warmer amber so the smaller, more\n// expensive slice reads clearly against it. Unattributed is deliberately a\n// flat grey \u2014 it is an absence of information, not a third activity.\nconst PROFILING_COLOR = '#1f6feb';\nconst PROTECTING_COLOR = '#e8590c';\nconst NOT_CONFIGURED_COLOR = '#9c36b5';\nconst UNKNOWN_COLOR = '#868e96';\n\n// L \u2014 translate + HTML-escape, for building innerHTML.\nfunction L(key: string): string {\n return escapeHtml(t(key));\n}\n\n// infoIcon \u2014 a material info glyph whose data-tooltip is picked up by the global\n// CustomTooltips widget (see Main.ts). Explains a non-obvious metric inline.\nfunction infoIcon(label: string, tip: string): string {\n return ` info_outline`;\n}\n\n// envLabel \u2014 localized display label for a customer_environment token (the raw\n// token stays the filter/match value). Shared by the Current-tab Env filter,\n// the Env column pill, and the economics read-only echo.\nexport function envLabel(token: string): string {\n switch (token) {\n case 'prod': return t('body.envProd');\n case 'qa': return t('body.envQa');\n case 'dev': return t('body.envDev');\n case 'demo': return t('body.envDemo');\n case 'partner': return t('body.envPartner');\n case 'other': return t('body.envOther');\n case '(none)': return t('body.envNone');\n default: return token;\n }\n}\n\n// labelToEnvToken \u2014 reverse of envLabel, for reading a localized searchList\n// option label back to its raw env token. Falls back to the label itself.\nexport function labelToEnvToken(label: string): string {\n for (const tok of ['prod', 'qa', 'dev', 'demo', 'partner', 'other', '(none)']) {\n if (envLabel(tok) === label) return tok;\n }\n return label;\n}\n\n// monthKey \u2192 \"YYYY-MM\".\nfunction monthKey(date: string): string {\n return date.slice(0, 7);\n}\n// Localized month label from a \"YYYY-MM\" key, e.g. \"Jul 2025\" / \"2025\u5E747\u6708\".\nfunction monthStartLabel(key: string): string {\n const [y, m] = key.split('-').map(Number);\n const d = new Date(Date.UTC(y, m - 1, 1));\n return d.toLocaleDateString(getLocale(), { year: 'numeric', month: 'short', timeZone: 'UTC' });\n}\nfunction calendarDaysInMonth(key: string): number {\n const [y, m] = key.split('-').map(Number);\n return new Date(Date.UTC(y, m, 0)).getUTCDate();\n}\n// Days to average a month's node-days over: the full calendar month once it's\n// complete, but only the days elapsed so far for the in-progress month (keyed\n// off `asOf`, the latest date with data) \u2014 otherwise the current month reads\n// artificially low from dividing partial node-days by the whole month.\nfunction billableDaysInMonth(key: string, asOf: string): number {\n const cal = calendarDaysInMonth(key);\n const asOfMonth = asOf.slice(0, 7);\n if (!asOf || key !== asOfMonth) return cal; // completed (or, defensively, future) month\n const asOfDay = Number(asOf.slice(8, 10));\n return Math.min(cal, Math.max(1, asOfDay)); // current month \u2192 days so far\n}\n\ninterface MonthTenantAgg {\n nodeDays: number; // \u03A3 daily node_total\n eventVolume: number; // \u03A3 daily event_volume\n // Node-days split by what each node was ACTUALLY doing that day (replayed\n // from its classification history, not its class today). The four buckets\n // partition nodeDays exactly.\n ndProfiling: number;\n ndProtecting: number;\n ndNotConfigured: number;\n ndUnknown: number;\n // Ingest attributed the same way \u2014 the signal that answers whether\n // protecting costs more than profiling, since volume drives spend.\n evProfiling: number;\n evProtecting: number;\n}\n\nfunction emptyAgg(): MonthTenantAgg {\n return {\n nodeDays: 0, eventVolume: 0,\n ndProfiling: 0, ndProtecting: 0, ndNotConfigured: 0, ndUnknown: 0,\n evProfiling: 0, evProtecting: 0,\n };\n}\n\n// Fold one (tenant, day) row into a month bucket.\nfunction addRow(a: MonthTenantAgg, r: TenantEconomicsRow): MonthTenantAgg {\n a.nodeDays += r.nodeTotal;\n a.eventVolume += r.eventVolume;\n a.ndProfiling += r.nodeProfiling ?? 0;\n a.ndProtecting += r.nodeProtecting ?? 0;\n a.ndNotConfigured += r.nodeNotConfigured ?? 0;\n a.ndUnknown += r.nodeUnknown ?? 0;\n a.evProfiling += r.eventVolumeProfiling ?? 0;\n a.evProtecting += r.eventVolumeProtecting ?? 0;\n return a;\n}\n\n// Events per node-day for one class \u2014 the per-activity unit cost. Zero\n// node-days means the activity wasn't running, so the rate is 0, not NaN.\nfunction ratePerNodeDay(events: number, nodeDays: number): number {\n return nodeDays > 0 ? events / nodeDays : 0;\n}\n\n// Fold one aggregate into another. Used for running totals so every field is\n// carried by construction; a hand-written subset silently drops whichever\n// bucket the author forgot.\nfunction addAgg(dst: MonthTenantAgg, src: MonthTenantAgg): MonthTenantAgg {\n dst.nodeDays += src.nodeDays;\n dst.eventVolume += src.eventVolume;\n dst.ndProfiling += src.ndProfiling;\n dst.ndProtecting += src.ndProtecting;\n dst.ndNotConfigured += src.ndNotConfigured;\n dst.ndUnknown += src.ndUnknown;\n dst.evProfiling += src.evProfiling;\n dst.evProtecting += src.evProtecting;\n return dst;\n}\n\n// Node-days the total claims but no activity bucket accounts for.\n//\n// DERIVED from the shortfall, never read from the unknown bucket. Those are not\n// the same thing, and assuming they were is what hid a real gap: `unknown`\n// counts active nodes whose class couldn't be established, but a tenant-day can\n// also carry a total with NO class rows at all (e.g. a stale node_count_\n// provisioned left by an earlier backfill whose source events have since aged\n// out). That case lands in no bucket, so a check on `unknown` cannot see it,\n// and the table silently showed columns that didn't add up.\n//\n// Computing the residual catches every cause, including ones not yet known.\nfunction unattributed(a: MonthTenantAgg): number {\n return Math.max(0, a.nodeDays - a.ndProfiling - a.ndProtecting - a.ndNotConfigured - a.ndUnknown);\n}\n\nexport class TenantProfilingEconomicsUI {\n private data: TenantEconomicsResponse | null = null;\n // Environment filter \u2014 NOT owned here. It's driven by the Current-snapshot\n // tab's Env multi-select (single source of truth) via setEnvFilter (always\n // called before renderTab), and shown read-only above the report.\n private envFilter: Set = new Set();\n // Tenant subset from the Current tab \u2014 the tenants its table still shows\n // when a filter beyond Env (Role, Profile, Tenant, Support, search) has\n // narrowed it. null = no narrowing, so tenants that only exist in history\n // are not dropped.\n private tenantFilter: Set | null = null;\n // Role / Profile selection labels, echoed read-only (the data itself\n // arrives already re-counted for the matching nodes).\n private facetLabels: { roles: string[] | null; profiles: string[] | null } = { roles: null, profiles: null };\n private charts: any[] = [];\n private reportTables: any[] = [];\n // Tenant Totals drill-down: a tenant row (or bar) click hands the tenant\n // id to the owner, which opens the events-by-operation drawer.\n private onTenantClick: ((tenantId: string) => void) | null = null;\n\n public setOnTenantClick(fn: (tenantId: string) => void): void {\n this.onTenantClick = fn;\n }\n\n public setData(data: TenantEconomicsResponse): void {\n this.data = data;\n }\n\n /** Adopt the env selection from the Current tab (the single source of truth). */\n public setEnvFilter(envFilter: Set): void {\n this.envFilter = new Set(envFilter);\n }\n\n /** Adopt the Current tab's Role / Profile selection labels for the echo bar. */\n public setFacetLabels(roles: string[] | null, profiles: string[] | null): void {\n this.facetLabels = { roles, profiles };\n }\n\n /** Adopt the Current tab's narrowed tenant set (null = not narrowed). */\n public setTenantFilter(tenantIds: Set | null): void {\n this.tenantFilter = tenantIds ? new Set(tenantIds) : null;\n }\n\n public destroy(): void {\n for (const ch of this.charts) {\n try { ch.destroy(); } catch { /* swallow */ }\n }\n this.charts = [];\n for (const dt of this.reportTables) {\n try { dt.destroy(true); } catch { /* swallow */ }\n }\n this.reportTables = [];\n }\n\n // \u2500\u2500 Report tables \u2014 real DataTables, professional styling, per-table CSV \u2500\u2500\n\n // Render one report table (styled DataTable + a CSV button positioned above\n // it, between the charts and the grid). Column 0 is a text label; the rest\n // are numeric (right-aligned, numeric-sorted via data-order). An optional\n // total/fleet row is pinned in the footer so sorting never moves it.\n private mountReportTable(\n slotId: string, tableId: string, csvName: string,\n head: string[], rows: (string | number)[][], total: (string | number)[] | null,\n rowIds: string[] | null = null,\n ): void {\n const slot = document.getElementById(slotId);\n if (!slot) return;\n const cls = (i: number) => (i === 0 ? 'tpe-th-label' : 'tpe-num');\n const numCell = (c: string | number, i: number) => {\n if (i === 0) return `${escapeHtml(String(c))}`;\n const n = Number(c);\n return `${escapeHtml(formatNumber(n))}`;\n };\n const thead = `${head.map((h, i) => `${escapeHtml(h)}`).join('')}`;\n // rowIds make rows clickable (drill-down); the id rides data-row-id.\n const trOpen = (i: number) => (rowIds && this.onTenantClick\n ? ``\n : '');\n const tbody = `${rows.map((r, i) => `${trOpen(i)}${r.map(numCell).join('')}`).join('')}`;\n const tfoot = total\n ? `${total.map((c, i) => i === 0\n ? `${escapeHtml(String(c))}`\n : `${c === '' ? '' : escapeHtml(formatNumber(Number(c)))}`).join('')}`\n : '';\n slot.innerHTML = `${thead}${tbody}${tfoot}
`;\n\n const dt = ($(`#${tableId}`) as any).DataTable({\n paging: false, searching: false, info: false, autoWidth: false,\n ordering: true, order: [], dom: 't',\n });\n this.reportTables.push(dt);\n if (rowIds && this.onTenantClick) {\n const open = this.onTenantClick;\n $(`#${tableId} tbody`).off('click.tpeRow').on('click.tpeRow', 'tr.tpe-row-link', function (this: HTMLElement) {\n const id = this.getAttribute('data-row-id');\n if (id) open(id);\n });\n }\n // Shared DataTables chrome \u2014 here that is the column layout (drag to\n // resize, Columns menu); the toolbar bits are inert with `dom: 't'`.\n setupDataTableUI(tableId, dt);\n\n // CSV mirrors the table (+ total row), with raw numbers.\n const csvRows = rows.map(r => r.map(String));\n if (total) csvRows.push(total.map(c => (c === '' ? '' : String(c))));\n installCsvActionBar(tableId, () => downloadCsv(csvName, head, csvRows), { buttonId: `${tableId}-csv` });\n\n // Monthly-by-Tenant tables scroll horizontally (many tenant columns).\n // installCsvActionBar drops its bar INSIDE the DataTables wrapper, which\n // is the scroller here \u2014 so the bar would slide off with the columns.\n // Lift it out to the slot (above the wrapper, which owns the overflow),\n // so the CSV button stays pinned over the sticky Total column. Only the\n // `.tpe-view-table` slots (this tab) scroll; Summary/Totals are untouched.\n if (slot.classList.contains('tpe-view-table')) {\n const wrapper = document.getElementById(`${tableId}_wrapper`);\n const bar = wrapper?.querySelector('.cx-csv-action-bar');\n if (wrapper && bar) slot.insertBefore(bar, wrapper);\n }\n }\n\n // \u2500\u2500 Environment filter helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n // '(none)' is a display token for the \"no environment set\" bucket; it is a\n // sentinel, not user prose, so it isn't translated (it also can't collide\n // with a real customerEnvironment enum value).\n private static readonly ENV_NONE = '(none)';\n\n private envOf(t: TenantEconomicsTenant): string {\n return t.customerEnvironment && t.customerEnvironment.trim() !== ''\n ? t.customerEnvironment\n : TenantProfilingEconomicsUI.ENV_NONE;\n }\n\n private selectedTenants(): TenantEconomicsTenant[] {\n return (this.data?.tenants ?? [])\n .filter((t) => this.envFilter.has(this.envOf(t)))\n .filter((t) => !this.tenantFilter || this.tenantFilter.has(t.tenantId))\n .slice()\n .sort((a, b) => (a.tenantName || a.tenantId).localeCompare(b.tenantName || b.tenantId, getLocale()));\n }\n\n private selectedRows(): TenantEconomicsRow[] {\n const ids = new Set(this.selectedTenants().map((t) => t.tenantId));\n return (this.data?.rows ?? []).filter((r) => ids.has(r.tenantId));\n }\n\n // \u2500\u2500 Public render entry \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n public renderTab(tab: EconomicsTab, root: HTMLElement): void {\n this.destroy();\n if (!this.data) {\n root.innerHTML = `
${L('body.tpeLoading')}
`;\n return;\n }\n // Read-only echo of the Current-tab Env selection (the single source\n // of truth \u2014 no filter input lives here). Shows the localized labels.\n const selected = Array.from(this.envFilter);\n const envText = selected.length\n ? selected.map(envLabel).join(', ')\n : t('body.tpeEnvAll');\n const bar = document.createElement('div');\n bar.className = 'tpe-envbar tpe-envbar--readonly';\n bar.innerHTML = `${escapeHtml(t('body.tpeEnvironments'))}: `\n + `${escapeHtml(envText)}`;\n // Echo the tenant narrowing too, so a filtered report can't be read as\n // the whole environment.\n const echo = (key: string, values: string[] | null) => {\n if (!values) return;\n bar.innerHTML += `  \u00B7  ${escapeHtml(t(key))}: `\n + `${escapeHtml(values.join(', '))}`;\n };\n echo('col.role', this.facetLabels.roles);\n echo('col.profile', this.facetLabels.profiles);\n if (this.tenantFilter) {\n const names = this.selectedTenants().map(tenantLabel).join(', ');\n bar.innerHTML += `  \u00B7  ${escapeHtml(t('body.tenants'))}: `\n + `${escapeHtml(names)}`;\n }\n const body = document.createElement('div');\n body.className = 'tpe-body';\n root.innerHTML = '';\n root.appendChild(bar);\n root.appendChild(body);\n\n if (this.selectedTenants().length === 0) {\n body.innerHTML = `
${L('body.tpeNoTenantsForEnv')}
`;\n return;\n }\n switch (tab) {\n case 'summary': this.renderMonthlySummary(body); break;\n case 'byTenant': this.renderMonthlyByTenant(body); break;\n case 'totals': this.renderTenantTotals(body); break;\n }\n }\n\n // \u2500\u2500 Aggregations \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n private months(rows: TenantEconomicsRow[]): string[] {\n const set = new Set();\n for (const r of rows) set.add(monthKey(r.date));\n return Array.from(set).sort();\n }\n\n // Latest date with data across all tenants (YYYY-MM-DD) \u2014 the \"as-of\" point\n // used to average the in-progress month over days elapsed, not the full month.\n private asOf(): string {\n let max = '';\n for (const r of this.data?.rows ?? []) if (r.date > max) max = r.date;\n return max || (this.data?.generatedAt ?? '').slice(0, 10);\n }\n\n private fleetByMonth(rows: TenantEconomicsRow[]): Map {\n const out = new Map();\n for (const r of rows) {\n const k = monthKey(r.date);\n out.set(k, addRow(out.get(k) ?? emptyAgg(), r));\n }\n return out;\n }\n\n private byTenantMonth(rows: TenantEconomicsRow[]): Map> {\n const out = new Map>();\n for (const r of rows) {\n const k = monthKey(r.date);\n let m = out.get(r.tenantId);\n if (!m) { m = new Map(); out.set(r.tenantId, m); }\n m.set(k, addRow(m.get(k) ?? emptyAgg(), r));\n }\n return out;\n }\n\n // True when any selected month has node-days we could not attribute to a\n // class. Drives whether the \"Unattributed\" series/column appears at all \u2014\n // a clean fleet shouldn't carry a permanently-zero column, but a gap must\n // never be hidden either.\n private hasUnattributed(fleet: Map): boolean {\n for (const a of fleet.values()) if (unattributed(a) > 0) return true;\n return false;\n }\n\n private hasNotConfigured(fleet: Map): boolean {\n for (const a of fleet.values()) if (a.ndNotConfigured > 0) return true;\n return false;\n }\n\n // \u2500\u2500 Tab 1: Monthly Summary (fleet) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n private renderMonthlySummary(host: HTMLElement): void {\n const rows = this.selectedRows();\n const months = this.months(rows);\n const fleet = this.fleetByMonth(rows);\n\n // Every bucket that carries node-days gets a column, so Node-Days is\n // always visibly the sum of the columns beside it. Buckets that are\n // empty across the whole selection are dropped to keep the table narrow\n // \u2014 but a bucket is only hidden when it is genuinely zero, never when\n // it merely happens to be inconvenient.\n const showNotConfigured = this.hasNotConfigured(fleet);\n const showUnattributed = this.hasUnattributed(fleet);\n const head = [\n t('body.tpeColMonth'), t('body.tpeColNodeDays'),\n t('body.tpeColNodeDaysProfiling'), t('body.tpeColNodeDaysProtecting'),\n ...(showNotConfigured ? [t('body.tpeColNodeDaysNotConfigured')] : []),\n ...(showUnattributed ? [t('body.tpeColNodeDaysUnattributed')] : []),\n t('body.tpeColAvgNodes'),\n t('body.tpeColEventVolume'), t('body.tpeColEventsPerNodeDay'),\n t('body.tpeColEpndProfiling'), t('body.tpeColEpndProtecting'),\n ];\n const asOf = this.asOf();\n const tot = emptyAgg();\n const bodyRows: (string | number)[][] = months.map((k) => {\n const a = fleet.get(k) ?? emptyAgg();\n const avgNodes = a.nodeDays / billableDaysInMonth(k, asOf);\n // Accumulate via the shared fold so a newly-added bucket can't be\n // silently omitted here \u2014 leaving ndNotConfigured out of a\n // hand-written running total is exactly how the footer's residual\n // came to over-count.\n addAgg(tot, a);\n return [\n monthStartLabel(k), Math.round(a.nodeDays),\n Math.round(a.ndProfiling), Math.round(a.ndProtecting),\n ...(showNotConfigured ? [Math.round(a.ndNotConfigured)] : []),\n ...(showUnattributed ? [Math.round(unattributed(a))] : []),\n Math.round(avgNodes),\n a.eventVolume, Math.round(ratePerNodeDay(a.eventVolume, a.nodeDays)),\n Math.round(ratePerNodeDay(a.evProfiling, a.ndProfiling)),\n Math.round(ratePerNodeDay(a.evProtecting, a.ndProtecting)),\n ];\n });\n const totalRow: (string | number)[] = [\n t('body.tpeColTotal'), Math.round(tot.nodeDays),\n Math.round(tot.ndProfiling), Math.round(tot.ndProtecting),\n ...(showNotConfigured ? [Math.round(tot.ndNotConfigured)] : []),\n ...(showUnattributed ? [Math.round(unattributed(tot))] : []),\n // Avg Nodes has no meaningful full-period total (it's a per-month average).\n '',\n tot.eventVolume, Math.round(ratePerNodeDay(tot.eventVolume, tot.nodeDays)),\n Math.round(ratePerNodeDay(tot.evProfiling, tot.ndProfiling)),\n Math.round(ratePerNodeDay(tot.evProtecting, tot.ndProtecting)),\n ];\n\n host.innerHTML = `\n ${this.cardOpen(t('body.tpeCardFleetSummaryTitle'), t('body.tpeCardFleetSummarySub'))}\n
\n ${this.chartCard(L('body.tpeChartNodeDaysByActivity'), 'tpe-sum-combo')}\n ${this.chartCard(L('body.tpeChartCostPerActivity'), 'tpe-sum-costmix')}\n ${this.chartCard(L('body.tpeChartMonthlyEventVolumeFleet'), 'tpe-sum-events')}\n
\n
\n ${this.cardClose()}\n `;\n this.mountReportTable('tpe-sum-slot', 'tpe-sum-table', 'tenant-economics-monthly-summary.csv',\n head, bodyRows, totalRow);\n\n const labels = months.map(monthStartLabel);\n // Node-days stack by activity rather than one undifferentiated bar, so\n // the profiling/protecting mix over time is the thing you see first.\n const stack = (labelKey: string, pick: (a: MonthTenantAgg) => number, color: string) => ({\n type: 'bar' as const, label: t(labelKey), yAxisID: 'nodes', order: 2, stack: 'nd',\n data: months.map((k) => Math.round(pick(fleet.get(k) ?? emptyAgg()))),\n backgroundColor: color + 'cc', borderColor: color, borderWidth: 1,\n });\n this.mountChart('tpe-sum-combo', {\n type: 'bar',\n data: {\n labels,\n datasets: [\n // Stack order mirrors the columns, and every bucket that\n // holds node-days is present \u2014 so the bar height IS the\n // Node-Days figure. A stack that silently omits a bucket\n // reads as a smaller fleet rather than as missing data.\n stack('body.tpeSeriesProfiling', (a) => a.ndProfiling, PROFILING_COLOR),\n stack('body.tpeSeriesProtecting', (a) => a.ndProtecting, PROTECTING_COLOR),\n ...(showNotConfigured\n ? [stack('body.tpeSeriesNotConfigured', (a) => a.ndNotConfigured, NOT_CONFIGURED_COLOR)]\n : []),\n ...(showUnattributed\n ? [stack('body.tpeSeriesUnattributed', unattributed, UNKNOWN_COLOR)]\n : []),\n {\n type: 'line', label: t('body.tpeColEventsPerNodeDay'), yAxisID: 'intensity', order: 1,\n data: months.map((k) => {\n const a = fleet.get(k); return a ? ratePerNodeDay(a.eventVolume, a.nodeDays) : 0;\n }),\n borderColor: EVENT_COLOR, backgroundColor: EVENT_COLOR, borderWidth: 2,\n tension: 0.2, pointRadius: 2,\n },\n ],\n },\n options: this.stackedComboOptions(t('body.tpeAxisNodeDays'), t('body.tpeAxisEventsPerNodeDay')),\n });\n // Unit cost per activity: events per node-day, profiling vs protecting.\n // Node-days alone can't answer \"is protecting more expensive\" \u2014 this can.\n this.mountChart('tpe-sum-costmix', {\n type: 'line',\n data: {\n labels,\n datasets: [\n {\n label: t('body.tpeSeriesProfiling'),\n data: months.map((k) => {\n const a = fleet.get(k); return a ? Math.round(ratePerNodeDay(a.evProfiling, a.ndProfiling)) : 0;\n }),\n borderColor: PROFILING_COLOR, backgroundColor: PROFILING_COLOR + '22',\n borderWidth: 2, tension: 0.2, pointRadius: 2,\n },\n {\n label: t('body.tpeSeriesProtecting'),\n data: months.map((k) => {\n const a = fleet.get(k); return a ? Math.round(ratePerNodeDay(a.evProtecting, a.ndProtecting)) : 0;\n }),\n borderColor: PROTECTING_COLOR, backgroundColor: PROTECTING_COLOR + '22',\n borderWidth: 2, tension: 0.2, pointRadius: 2,\n },\n ],\n },\n options: this.singleAxisOptions(t('body.tpeAxisEventsPerNodeDay')),\n });\n this.mountChart('tpe-sum-events', {\n type: 'line',\n data: {\n labels,\n datasets: [{\n label: t('body.tpeColEventVolume'),\n data: months.map((k) => fleet.get(k)?.eventVolume ?? 0),\n borderColor: EVENT_COLOR, backgroundColor: EVENT_COLOR + '22',\n borderWidth: 2, fill: true, tension: 0.2, pointRadius: 2,\n }],\n },\n options: this.singleAxisOptions(t('body.tpeAxisEvents')),\n });\n }\n\n // \u2500\u2500 Tab 2: Monthly by Tenant \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n private renderMonthlyByTenant(host: HTMLElement): void {\n const rows = this.selectedRows();\n const months = this.months(rows);\n const tenants = this.selectedTenants();\n const agg = this.byTenantMonth(rows);\n const fleet = this.fleetByMonth(rows);\n const labels = months.map(monthStartLabel);\n const ids = tenants.map((t) => t.tenantId);\n const tName = (id: string) => {\n const t = tenants.find((x) => x.tenantId === id);\n return t ? tenantLabel(t) : displayTenantNameText(id, { tenantId: id });\n };\n\n const asOf = this.asOf();\n const avgNodes = (id: string, k: string) => {\n const a = agg.get(id)?.get(k); return a ? a.nodeDays / billableDaysInMonth(k, asOf) : 0;\n };\n const eventVol = (id: string, k: string) => agg.get(id)?.get(k)?.eventVolume ?? 0;\n const epnd = (id: string, k: string) => {\n const a = agg.get(id)?.get(k); return a && a.nodeDays > 0 ? a.eventVolume / a.nodeDays : 0;\n };\n\n // Each of the three views renders table-left + chart-right, side by\n // side (mirrors the finance workbook's \"Monthly Detail by Tenant\"),\n // so the tab reads as a tight report rather than a tall stack.\n const view = (sub: string, chartTitleKey: string, canvasId: string, slotId: string, tipKey?: string) => `\n
${L(sub)}${tipKey ? infoIcon(t(sub), t(tipKey)) : ''}
\n
\n
\n ${this.chartCard(L(chartTitleKey), canvasId)}\n
`;\n host.innerHTML = `\n ${this.cardOpen(t('body.tpeCardByTenantTitle'), t('body.tpeCardByTenantSub'))}\n ${view('body.tpeSubAvgBilledNodes', 'body.tpeChartAvgBilledNodesByTenant', 'tpe-bt-nodes', 'tpe-bt-nodes-slot', 'body.tpeTipAvgBilledNodes')}\n ${view('body.tpeSubEventVolume', 'body.tpeChartEventVolumeStacked', 'tpe-bt-events', 'tpe-bt-events-slot')}\n ${view('body.tpeSubEventsPerNodeDay', 'body.tpeChartEpndByTenant', 'tpe-bt-epnd', 'tpe-bt-epnd-slot', 'body.tpeTipEventsPerNodeDay')}\n ${this.cardClose()}\n `;\n // Footer \"Total\" row \u2014 ONLY on Event Volume, where a column sum is a\n // real total. Average Nodes and Events-per-Node-Day get no footer: a\n // \"total\" of a per-month average or ratio is meaningless (it isn't a\n // total, and reads as a much smaller number than the latest month).\n const tEvents = (id: string) => months.reduce((s, k) => s + (agg.get(id)?.get(k)?.eventVolume ?? 0), 0);\n const fEvents = months.reduce((s, k) => s + (fleet.get(k)?.eventVolume ?? 0), 0);\n const evFooter: (string | number)[] = [t('body.tpeColTotal'), ...ids.map(tEvents), fEvents];\n\n const nodesData = this.perTenantData(months, labels, ids, tName, (id, k) => Math.round(avgNodes(id, k)));\n this.mountReportTable('tpe-bt-nodes-slot', 'tpe-bt-nodes-table', 'tenant-economics-avg-nodes.csv',\n nodesData.head, nodesData.rows, null);\n const evData = this.perTenantData(months, labels, ids, tName, (id, k) => eventVol(id, k));\n this.mountReportTable('tpe-bt-events-slot', 'tpe-bt-events-table', 'tenant-economics-event-volume.csv',\n evData.head, evData.rows, evFooter);\n // Fleet events-per-node-day is a ratio of fleet totals, NOT the sum of\n // per-tenant rates \u2014 summing those would be nonsense.\n const epndData = this.perTenantData(months, labels, ids, tName, (id, k) => Math.round(epnd(id, k)),\n (k) => { const a = fleet.get(k); return a && a.nodeDays > 0 ? Math.round(a.eventVolume / a.nodeDays) : 0; });\n this.mountReportTable('tpe-bt-epnd-slot', 'tpe-bt-epnd-table', 'tenant-economics-events-per-node-day.csv',\n epndData.head, epndData.rows, null);\n\n // Flush layout before creating these charts. They sit in a flex row\n // (align-items:stretch) beside the just-mounted tables, so their card\n // height isn't final until the browser has computed the stretch. If we\n // create the chart first, Chart.js reads a stale (min-)height, starts\n // the entrance animation, and the ResizeObserver's snap to the real\n // height then cancels it \u2014 which is why only this tab appeared not to\n // animate (Summary/Totals charts sit in a grid, no sibling table). One\n // forced reflow makes the cards their final size up front.\n void host.offsetHeight;\n\n const color = (i: number) => SC_PALETTE[i % SC_PALETTE.length];\n this.mountChart('tpe-bt-nodes', {\n type: 'line',\n data: {\n labels,\n datasets: tenants.map((t, i) => ({\n label: tName(t.tenantId),\n data: months.map((k) => avgNodes(t.tenantId, k)),\n borderColor: color(i), backgroundColor: color(i) + '22',\n borderWidth: 2, tension: 0.2, pointRadius: 1, fill: false,\n })),\n },\n options: this.singleAxisOptions(t('body.tpeAxisAvgNodes'), false, 'right'),\n });\n this.mountChart('tpe-bt-events', {\n type: 'bar',\n data: {\n labels,\n datasets: tenants.map((t, i) => ({\n label: tName(t.tenantId),\n data: months.map((k) => eventVol(t.tenantId, k)),\n backgroundColor: color(i),\n })),\n },\n options: this.stackedBarOptions(t('body.tpeAxisEvents'), 'right'),\n });\n this.mountChart('tpe-bt-epnd', {\n type: 'line',\n data: {\n labels,\n datasets: tenants.map((t, i) => ({\n label: tName(t.tenantId),\n data: months.map((k) => epnd(t.tenantId, k)),\n borderColor: color(i), backgroundColor: color(i) + '22',\n borderWidth: 2, tension: 0.2, pointRadius: 1, fill: false,\n })),\n },\n options: this.singleAxisOptions(t('body.tpeAxisEventsPerNodeDay'), false, 'right'),\n });\n\n // These three charts share a flex row (align-items:stretch) with their\n // tables, so their final height isn't known until the browser lays the\n // row out \u2014 and that post-create resize swallows the entrance animation\n // (unlike the grid-based Summary/Totals charts). Once layout has settled\n // (rAF), replay the entrance: reset() rewinds each chart to its animation\n // start, update() animates it back to the data at the now-correct size.\n requestAnimationFrame(() => {\n for (const id of ['tpe-bt-nodes', 'tpe-bt-events', 'tpe-bt-epnd']) {\n const cv = document.getElementById(id);\n const ch = cv ? Chart.getChart(cv) : null;\n if (ch) { ch.reset(); ch.update(); }\n }\n });\n }\n\n // \u2500\u2500 Tab 3: Tenant Totals (full period) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n private renderTenantTotals(host: HTMLElement): void {\n const rows = this.selectedRows();\n const tenants = this.selectedTenants();\n\n interface Tot { days: number; peak: number; nodeDays: number; events: number; ndProfiling: number; ndProtecting: number; }\n const byTenant = new Map();\n // The fleet's peak is the busiest day's node count across the\n // tenants, not the sum of each tenant's own peak (those fall on\n // different days).\n const fleetByDay = new Map();\n for (const t of tenants) {\n byTenant.set(t.tenantId, { days: 0, peak: 0, nodeDays: 0, events: 0, ndProfiling: 0, ndProtecting: 0 });\n }\n for (const r of rows) {\n const tot = byTenant.get(r.tenantId);\n if (!tot) continue;\n tot.days += 1;\n tot.peak = Math.max(tot.peak, r.nodeTotal);\n fleetByDay.set(r.date, (fleetByDay.get(r.date) ?? 0) + r.nodeTotal);\n tot.nodeDays += r.nodeTotal;\n tot.events += r.eventVolume;\n tot.ndProfiling += r.nodeProfiling ?? 0;\n tot.ndProtecting += r.nodeProtecting ?? 0;\n }\n const tName = (id: string) => {\n const t = tenants.find((x) => x.tenantId === id); return t ? tenantLabel(t) : displayTenantNameText(id, { tenantId: id });\n };\n const ids = tenants.map((t) => t.tenantId);\n\n const head = [\n t('col.tenant'), t('body.tpeColDaysReported'), t('body.tpeColPeakNodes'),\n t('body.tpeColNodeDays'),\n t('body.tpeColNodeDaysProfiling'), t('body.tpeColNodeDaysProtecting'),\n t('body.tpeColTotalEvents'), t('body.tpeColEventsPerNodeDay'),\n ];\n let fDays = 0, fNodeDays = 0, fEvents = 0, fProf = 0, fProt = 0;\n let fPeak = 0;\n for (const n of fleetByDay.values()) fPeak = Math.max(fPeak, n);\n const bodyRows: (string | number)[][] = ids.map((id) => {\n const tt = byTenant.get(id)!;\n fDays = Math.max(fDays, tt.days); fNodeDays += tt.nodeDays; fEvents += tt.events;\n fProf += tt.ndProfiling; fProt += tt.ndProtecting;\n return [\n tName(id), tt.days, tt.peak, tt.nodeDays,\n tt.ndProfiling, tt.ndProtecting,\n tt.events, Math.round(ratePerNodeDay(tt.events, tt.nodeDays)),\n ];\n });\n const totalRow: (string | number)[] = [\n t('body.tpeColFleet'), fDays, fPeak, fNodeDays, fProf, fProt, fEvents,\n Math.round(ratePerNodeDay(fEvents, fNodeDays)),\n ];\n\n host.innerHTML = `\n ${this.cardOpen(t('body.tpeCardTotalsTitle'), t('body.tpeCardTotalsSub'))}\n
\n ${this.chartCard(L('body.tpeChartNodeDaysByTenant'), 'tpe-tot-nodedays')}\n ${this.chartCard(L('body.tpeChartEpndByTenantFull'), 'tpe-tot-epnd')}\n
\n ${this.onTenantClick ? `
touch_app${escapeHtml(t('body.tpeTotalsDrillHint'))}
` : ''}\n
\n ${this.cardClose()}\n `;\n this.mountReportTable('tpe-tot-slot', 'tpe-tot-table', 'tenant-economics-tenant-totals.csv',\n head, bodyRows, totalRow, ids);\n\n const labels = ids.map(tName);\n // Clicking a tenant's bar drills down like clicking its row.\n const drill = this.onTenantClick\n ? { onClick: (_e: unknown, els: { index: number }[]) => { if (els[0]) this.onTenantClick?.(ids[els[0].index]); } }\n : {};\n this.mountChart('tpe-tot-nodedays', {\n type: 'bar',\n data: {\n labels,\n datasets: [{\n label: t('body.tpeColNodeDays'),\n data: ids.map((id) => byTenant.get(id)!.nodeDays),\n backgroundColor: NODE_COLOR + 'cc', borderColor: NODE_COLOR, borderWidth: 1,\n }],\n },\n options: { ...this.singleAxisOptions(t('body.tpeAxisNodeDays'), true), ...drill },\n });\n this.mountChart('tpe-tot-epnd', {\n type: 'bar',\n data: {\n labels,\n datasets: [{\n label: t('body.tpeColEventsPerNodeDay'),\n data: ids.map((id) => {\n const tt = byTenant.get(id)!; return tt.nodeDays > 0 ? tt.events / tt.nodeDays : 0;\n }),\n backgroundColor: EVENT_COLOR + 'cc', borderColor: EVENT_COLOR, borderWidth: 1,\n }],\n },\n options: { ...this.singleAxisOptions(t('body.tpeAxisEventsPerNodeDay'), true), ...drill },\n });\n }\n\n // \u2500\u2500 Shared rendering helpers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n private cardOpen(title: string, sub: string): string {\n return `\n
\n
\n
${escapeHtml(title)}
\n
${escapeHtml(sub)}
\n
`;\n }\n private cardClose(): string { return `
`; }\n\n private chartCard(titleHtml: string, canvasId: string): string {\n return `\n
\n
${titleHtml}
\n
\n
`;\n }\n\n // Per-tenant month table data: Month | tenant\u2026 | Total. Additive measures\n // (nodes, events) sum across tenants for the Total (fleet) column; a ratio\n // measure (events/node-day) supplies its own fleet total via fleetOverride.\n private perTenantData(\n months: string[], labels: string[], ids: string[],\n tName: (id: string) => string, val: (id: string, k: string) => number,\n fleetOverride?: (k: string) => number,\n ): { head: string[]; rows: (string | number)[][] } {\n const head = [t('body.tpeColMonth'), ...ids.map(tName), t('body.tpeColTotal')];\n const rows: (string | number)[][] = months.map((k, mi) => {\n let fleet = 0;\n const cells = ids.map((id) => { const v = val(id, k); fleet += v; return v; });\n return [labels[mi], ...cells, fleetOverride ? fleetOverride(k) : fleet];\n });\n return { head, rows };\n }\n\n private mountChart(canvasId: string, config: any): void {\n const canvas = document.getElementById(canvasId) as HTMLCanvasElement | null;\n if (!canvas) return;\n this.charts.push(new Chart(canvas.getContext('2d'), config));\n }\n\n // \u2500\u2500 Chart option builders \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n // Bold, compact tick/title font shared by all axes (matches the workbook).\n private static readonly AXIS_FONT = { size: 10, weight: 'bold' as const };\n\n private baseOptions(legendPos: 'bottom' | 'right' = 'bottom'): any {\n return {\n responsive: true,\n maintainAspectRatio: false,\n // Charts are rebuilt (new Chart) each time a tab is shown or the\n // data refreshes, so every mount is a \"first render\" \u2014 a short\n // grow/draw-in animation gives each tab a bit of life without\n // re-animating on hover (interaction updates don't rebuild).\n animation: { duration: 650, easing: 'easeOutQuart' },\n interaction: { mode: 'index', intersect: false },\n // Breathing room between the plot and the card edges (Excel-like).\n layout: { padding: { top: 8, right: 12, bottom: 4, left: 4 } },\n plugins: {\n datalabels: { display: false },\n legend: {\n display: true, position: legendPos,\n labels: {\n // Solid square swatches for every series (lines included),\n // like the workbook legend \u2014 Chart.js's default line marker\n // is a hollow rect, so fill it with the series' solid color.\n usePointStyle: true, pointStyle: 'rect',\n boxWidth: 10, boxHeight: 10, font: { size: 10 },\n padding: legendPos === 'right' ? 10 : 6,\n generateLabels: (chart: any) => chart.data.datasets.map((ds: any, i: number) => {\n const color = ds.borderColor ?? ds.backgroundColor;\n return {\n text: ds.label,\n fillStyle: color,\n strokeStyle: color,\n lineWidth: 0,\n pointStyle: 'rect',\n hidden: !chart.isDatasetVisible(i),\n datasetIndex: i,\n };\n }),\n },\n },\n tooltip: {\n callbacks: {\n label: (ctx: any) => `${ctx.dataset.label}: ${formatNumber(Math.round(ctx.parsed.y))}`,\n },\n },\n },\n };\n }\n\n private singleAxisOptions(yTitle: string, hideLegend = false, legendPos: 'bottom' | 'right' = 'bottom'): any {\n const o = this.baseOptions(legendPos);\n if (hideLegend) o.plugins.legend.display = false;\n const F = TenantProfilingEconomicsUI.AXIS_FONT;\n o.scales = {\n y: {\n beginAtZero: true,\n title: { display: true, text: yTitle, font: F },\n ticks: { callback: (v: number) => formatNumber(v), font: F },\n // Horizontal gridlines only.\n grid: { color: 'rgba(17,24,39,0.10)', drawTicks: false },\n border: { display: false },\n },\n x: {\n ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 12, font: F },\n grid: { display: false }, // no vertical lines\n },\n };\n return o;\n }\n\n private stackedBarOptions(yTitle: string, legendPos: 'bottom' | 'right' = 'bottom'): any {\n const o = this.singleAxisOptions(yTitle, false, legendPos);\n o.scales.x.stacked = true;\n o.scales.y.stacked = true;\n return o;\n }\n\n private comboOptions(leftTitle: string, rightTitle: string): any {\n const o = this.baseOptions();\n const F = TenantProfilingEconomicsUI.AXIS_FONT;\n o.scales = {\n nodes: {\n type: 'linear', position: 'left', beginAtZero: true,\n title: { display: true, text: leftTitle, font: F },\n ticks: { callback: (v: number) => formatNumber(v), font: F },\n grid: { color: 'rgba(17,24,39,0.10)', drawTicks: false },\n border: { display: false },\n },\n intensity: {\n type: 'linear', position: 'right', beginAtZero: true,\n title: { display: true, text: rightTitle, font: F },\n grid: { drawOnChartArea: false },\n ticks: { callback: (v: number) => formatNumber(v), font: F },\n border: { display: false },\n },\n x: {\n ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 12, font: F },\n grid: { display: false },\n },\n };\n return o;\n }\n\n // comboOptions with the bar axis stacked, for the node-days-by-activity\n // chart. The line series stays on its own unstacked right-hand axis \u2014 a\n // rate must never be added onto a count.\n private stackedComboOptions(leftTitle: string, rightTitle: string): any {\n const o = this.comboOptions(leftTitle, rightTitle);\n o.scales.nodes.stacked = true;\n o.scales.x.stacked = true;\n return o;\n }\n}\n", "// APIPerfAdminUI \u2014 the Admin page's Performance tab (DASH-2176 phase 2).\n//\n// The web and mobile apps' API latency, from api_perf_daily (every pod's\n// per-day latency histograms, DASH-2178), and the nightly regression check's\n// verdicts (api_perf_regressions, DASH-2179). Three cards: the regressions\n// the check found in the window, every route and request shape's latency,\n// and one build against another. Latency figures are of 2xx calls, as the\n// check reads them; call and error counts are of every status (the\n// regressions' day calls are the check's, 2xx only). Percentiles come from\n// histograms whose buckets are 25% apart, so each is the upper bound of the\n// bucket holding it; the Routes and Compare builds percentiles are capped at\n// the slowest call; one in the last bucket whose slowest call is past that\n// bucket's bound shows as at least the bucket's lower bound, with no change\n// ratio. Each card folds (its state remembered per card), and each table\n// exports a CSV of the rows its filters select, streamed by the server\n// (api/admin_api_perf_csv.go).\nimport { t as t2 } from '../i18n/I18n';\nimport AdminStore, {\n APIPerfCompareRow, APIPerfRegression, APIPerfReport, APIPerfRoute,\n} from '../stores/AdminStore';\nimport { clientTableFilterState, DOM_STRING, downloadCsvNative, installCsvActionBar, setupDataTableUI } from '../utils/DataTables';\nimport { escapeHtml, formatNumber, tipAttr } from '../utils/FormatUtils';\nimport { FetchGeneration } from '../utils/FetchGeneration';\nimport { isOpenEnded, OPEN_FROM_MS, p95Change, pickCompareBuilds } from '../utils/APIPerfRules';\nimport { displayTenantNameText, SHOW_TENANT_NAMES_EVENT } from '../utils/TenantNameDisplay';\nimport { Header } from '../utils/Header';\nimport { storage } from '../utils/Storage';\n\nconst REGRESSIONS_TABLE_ID = 'apiPerfRegressionsTable';\nconst ROUTES_TABLE_ID = 'apiPerfRoutesTable';\nconst COMPARE_TABLE_ID = 'apiPerfCompareTable';\n\n// The cards, by element id; their folded state is kept per id in\n// apiPerfCardCollapsed.\nconst REGRESSIONS_CARD_ID = 'apiPerfRegressionsCard';\nconst ROUTES_CARD_ID = 'apiPerfRoutesCard';\nconst COMPARE_CARD_ID = 'apiPerfCompareCard';\n\ntype CsvTable = 'regressions' | 'routes' | 'compare';\n\n// Each table's CSV columns, one per table column in order (the columns'\n// names are the server's layout, api/admin_api_perf_csv.go\n// apiPerfCSVLayouts): the column title's key, and the unit its cells are\n// written in.\ntype CsvUnit = '' | 'ms' | 'pct';\nconst CSV_COLUMNS: Record> = {\n regressions: [\n ['body.perfColDay', ''], ['col.tenant', ''], ['body.perfColMethod', ''], ['body.perfColRoute', ''],\n ['body.perfColShape', ''], ['body.perfColBaselineP95', 'ms'], ['body.perfColDayP95', 'ms'],\n ['body.perfColChange', ''], ['body.perfColDayCalls', ''], ['body.perfColPg', 'pct'], ['body.perfColFirstBadBuild', ''],\n ],\n routes: [\n ['body.perfColMethod', ''], ['body.perfColRoute', ''], ['body.perfColShape', ''], ['body.perfColCalls', ''],\n ['body.perfCol4xx', ''], ['body.perfCol5xx', ''], ['body.perfColP50', 'ms'], ['body.perfColP95', 'ms'],\n ['body.perfColP99', 'ms'], ['body.perfColAvg', 'ms'], ['body.perfColMax', 'ms'], ['body.perfColPg', 'pct'],\n ['body.perfColCh', 'pct'], ['body.tenants', ''],\n ],\n compare: [\n ['body.perfColMethod', ''], ['body.perfColRoute', ''], ['body.perfColShape', ''], ['body.perfColCallsA', ''],\n ['body.perfColP50A', 'ms'], ['body.perfColP95A', 'ms'], ['body.perfColCallsB', ''], ['body.perfColP50B', 'ms'],\n ['body.perfColP95B', 'ms'], ['body.perfColChange', ''],\n ],\n};\n\n// Column widths: DataTables sizes a column from its unwrapped text, so a\n// long route would take the room the request shape needs. The route wraps\n// at its slashes inside ROUTE_WIDTH; the shape gets the most room, so a row\n// takes as few lines as it can; a short header with an info icon and a\n// filter button needs TIP_COL_WIDTH to keep the two apart, and a share\n// column SHARE_WIDTH for its title on one line.\nconst ROUTE_WIDTH = '220px';\nconst SHAPE_WIDTH = '440px';\nconst TIP_COL_WIDTH = '84px';\nconst SHARE_WIDTH = '116px';\n\n// The windows the server accepts (api/admin_api_perf.go apiPerfWindowDays).\nconst WINDOW_DAYS = [1, 7, 14, 30];\n\n// The controls' source before any report has loaded.\nconst EMPTY_REPORT: APIPerfReport = { from: '', to: '', routes: [], builds: [], tenants: [], tenantNames: {}, regressions: [], lastRun: null };\n\n// caller_tenant_id of a call with no tenant (db.APIPerfNoTenant).\nconst NO_TENANT = '00000000-0000-0000-0000-000000000000';\n\n// ColumnControl, as on the Vulnerability Sources tab: closed sets get a\n// searchList, free text a searchText, numbers order only.\nconst CC_LIST = [['orderAsc', 'orderDesc', 'searchList']];\nconst CC_TEXT = [['orderAsc', 'orderDesc', 'searchText']];\nconst CC_ORDER = [['orderAsc', 'orderDesc']];\nconst CC_UI = { columnControl: { dropdownMaterialIcon: 'filter_list', fixHeaderLayout: true, cascadeSearchLists: true } };\n\n// Text columns sort as plain strings (lower-cased, by code unit) and number\n// columns as numbers, as the CSV export does (api/client_table_filter.go\n// strKey, numKey). Left to detection, a column holding a non-ASCII label (a\n// masked tenant name's dots) would sort by locale, a day column as dates,\n// and a share column whose values all read like versions (\"0.25\") by the\n// app's semver type, which puts 0.25 above 0.5.\nconst STRING_TYPE = { type: 'string' };\nconst NUM_TYPE = { type: 'num' };\n\nexport class APIPerfAdminUI {\n private root: HTMLElement | null = null;\n private days = 7;\n private tenantId = '';\n private build = '';\n private compareA = '';\n private compareB = '';\n // The current report's tenant names (the server sends only those).\n private tenantNames = new Map();\n private report: APIPerfReport | null = null;\n private compareRows: APIPerfCompareRow[] = [];\n private regressionsDt: DataTables.Api | null = null;\n private routesDt: DataTables.Api | null = null;\n private compareDt: DataTables.Api | null = null;\n // The root the listeners are on: a page navigation replaces the admin\n // page's DOM, so a new root needs its own (the old one went with it).\n private boundRoot: HTMLElement | null = null;\n private nameListenerWired = false;\n private unloadWired = false;\n // A control can change while a load is in flight: only the newest load\n // may draw, or a slow load for the last window would replace this one's.\n private readonly reloadGen = new FetchGeneration();\n private readonly compareGen = new FetchGeneration();\n // A superseded load is also aborted, so the server stops its queries\n // (a 30-day report reads every row of the window) instead of finishing\n // work nobody will see.\n private reportAbort: AbortController | null = null;\n // The last load's failure, shown in place of the report until a load\n // succeeds.\n private loadError: string | null = null;\n private compareAbort: AbortController | null = null;\n // The queries the drawn report and comparison were read with: an\n // export reads what the tables show, not a control changed since.\n private reportQuery = '';\n private compareQuery = '';\n // The last day of the window the comparison was read for: it loads on\n // its own (a build pick), so after midnight UTC it can differ from the\n // report's.\n private compareTo = '';\n\n constructor(private readonly store: AdminStore) { }\n\n async render(root: HTMLElement): Promise {\n this.root = root;\n this.bindListeners();\n if (!this.nameListenerWired) {\n // Show Tenant Names can flip elsewhere while the tab is open.\n window.addEventListener(SHOW_TENANT_NAMES_EVENT, () => this.redrawForNames()); // copilot-lint-ignore: added once (nameListenerWired); AdminUI keeps this instance for the session\n this.nameListenerWired = true;\n }\n if (!this.unloadWired) {\n window.addEventListener('dashcam:page-unload', () => this.leave()); // copilot-lint-ignore: added once (unloadWired); AdminUI keeps this instance for the session\n this.unloadWired = true;\n }\n await this.reload();\n }\n\n // Rewrites the root, keeping keyboard focus on the control that had it:\n // arrow keys on a focused select fire a change per step, and each change\n // redraws the controls.\n private setRoot(html: string): void {\n if (!this.root) return;\n const active = document.activeElement;\n const focusedId = active instanceof HTMLElement && this.root.contains(active) ? active.id : '';\n this.root.innerHTML = html;\n if (focusedId) this.root.querySelector(`#${CSS.escape(focusedId)}`)?.focus();\n }\n\n // Leaving the page: the loads in flight are aborted and can no longer\n // draw, and the tables go with the page.\n private leave(): void {\n this.reloadGen.invalidate();\n this.compareGen.invalidate();\n this.reportAbort?.abort();\n this.compareAbort?.abort();\n this.reportAbort = this.compareAbort = null;\n this.destroyTables();\n }\n\n private async reload(): Promise {\n if (!this.root) return;\n const gen = this.reloadGen.begin();\n // A compare still loading belongs to the report being replaced.\n this.compareGen.invalidate();\n this.compareAbort?.abort();\n this.reportAbort?.abort();\n const abort = new AbortController();\n this.reportAbort = abort;\n this.destroyTables();\n this.renderLoading();\n let report: APIPerfReport;\n try {\n report = await this.store.getAPIPerfReport({ days: this.days, tenantId: this.tenantId, build: this.build }, abort.signal);\n } catch (err) {\n if (!this.reloadGen.isCurrent(gen) || abort.signal.aborted) return;\n this.reportAbort = null;\n this.loadError = (err as Error).message;\n this.renderError();\n return;\n }\n if (!this.reloadGen.isCurrent(gen)) return;\n this.reportAbort = null;\n this.loadError = null;\n this.report = report;\n this.reportQuery = this.query({ days: this.days, tenantId: this.tenantId, build: this.build });\n this.tenantNames = new Map(Object.entries(report.tenantNames));\n ({ before: this.compareA, after: this.compareB } = pickCompareBuilds(report.builds.map(b => b.build), this.compareA, this.compareB));\n this.compareRows = [];\n this.renderBody();\n void this.loadCompare();\n }\n\n // The controls stay while a report loads, so a slow window can be swapped\n // for another (which aborts that load).\n private renderLoading(): void {\n this.setRoot(`\n
${this.renderControls(this.report ?? EMPTY_REPORT)}
\n ${escapeHtml(t2('dt.loading'))}`);\n }\n\n // The controls stay, so a failed or slow window can be retried or swapped\n // for a shorter one.\n private renderError(): void {\n if (!this.root || this.loadError === null) return;\n this.setRoot(`\n
${this.renderControls(this.report ?? EMPTY_REPORT)}
\n ${escapeHtml(t2('body.failedToLoad', { error: this.loadError }))}`);\n }\n\n private async loadCompare(): Promise {\n const gen = this.compareGen.begin();\n const holder = this.root?.querySelector('#apiPerfCompare');\n if (!holder) return;\n try { this.compareDt?.destroy(); } catch { /* the nodes are about to go anyway */ }\n this.compareDt = null;\n this.compareAbort?.abort();\n const builds = this.report?.builds.length ?? 0;\n if (builds < 2) {\n holder.innerHTML = `${escapeHtml(t2(builds === 0 ? 'body.perfNoCalls' : 'body.perfCompareNeedsTwo'))}`;\n return;\n }\n const abort = new AbortController();\n this.compareAbort = abort;\n holder.innerHTML = `${escapeHtml(t2('dt.loading'))}`;\n let rows: APIPerfCompareRow[];\n let to: string;\n try {\n ({ rows, to } = await this.store.compareAPIPerfBuilds({ days: this.days, tenantId: this.tenantId }, this.compareA, this.compareB, abort.signal));\n } catch (err) {\n if (!this.compareGen.isCurrent(gen) || abort.signal.aborted) return;\n holder.innerHTML = `${escapeHtml(t2('body.failedToLoad', { error: (err as Error).message }))}`;\n return;\n }\n if (!this.compareGen.isCurrent(gen)) return;\n this.compareRows = rows;\n this.compareTo = to;\n this.compareQuery = this.query({ days: this.days, tenantId: this.tenantId, a: this.compareA, b: this.compareB });\n if (rows.length === 0) {\n holder.innerHTML = `${escapeHtml(t2('body.perfNoCompareRows'))}`;\n return;\n }\n holder.innerHTML = `
`; // copilot-lint-ignore: COMPARE_TABLE_ID is a constant\n this.initCompareTable();\n }\n\n private bindListeners(): void {\n if (!this.root || this.boundRoot === this.root) return;\n this.boundRoot = this.root;\n this.root.addEventListener('change', (e) => {\n const el = e.target as HTMLSelectElement;\n switch (el.id) {\n case 'apiPerfWindow': this.days = Number(el.value) || 7; void this.reload(); break;\n case 'apiPerfTenant': this.tenantId = el.value; void this.reload(); break;\n case 'apiPerfBuild': this.build = el.value; void this.reload(); break;\n case 'apiPerfCompareA': this.compareA = el.value; void this.loadCompare(); break;\n case 'apiPerfCompareB': this.compareB = el.value; void this.loadCompare(); break;\n }\n });\n this.root.addEventListener('click', (e) => {\n const target = e.target as HTMLElement;\n if (target.closest('#apiPerfRefreshBtn')) {\n void this.reload();\n return;\n }\n const toggle = target.closest('.api-perf-card-toggle');\n const card = toggle?.closest('.api-perf-card');\n if (toggle && card) this.toggleCard(card, toggle);\n });\n }\n\n // Folds or opens a card and remembers it. A table drawn while its card\n // was folded measured its columns at no width, so opening the card\n // measures them again.\n private toggleCard(card: HTMLElement, toggle: HTMLButtonElement): void {\n const collapsed = !card.classList.contains('collapsed');\n card.classList.toggle('collapsed', collapsed);\n toggle.setAttribute('aria-expanded', String(!collapsed));\n storage.set('apiPerfCardCollapsed', { ...(storage.get('apiPerfCardCollapsed') ?? {}), [card.id]: collapsed });\n if (collapsed) return;\n const dt = { [REGRESSIONS_CARD_ID]: this.regressionsDt, [ROUTES_CARD_ID]: this.routesDt, [COMPARE_CARD_ID]: this.compareDt }[card.id];\n dt?.columns.adjust();\n }\n\n // A card's markup: a head whose title folds it (with the head's own\n // controls, which fold with it) over its body. Everything starts open;\n // a card the reader folded stays folded across reloads and visits.\n private card(id: string, titleKey: string, controls: string, body: string): string {\n const collapsed = (storage.get('apiPerfCardCollapsed') ?? {})[id] === true;\n return `\n
\n
\n \n ${controls}\n
\n
${body}
\n
`;\n }\n\n private destroyTables(): void {\n for (const dt of [this.regressionsDt, this.routesDt, this.compareDt]) {\n try { dt?.destroy(); } catch { /* the nodes are about to go anyway */ }\n }\n this.regressionsDt = this.routesDt = this.compareDt = null;\n }\n\n // With Show Tenant Names off a name reads as its initial and dots; an id\n // with no known name is shown as is (it is not a name).\n private tenantLabel(id: string): string {\n if (id === NO_TENANT) return t2('body.perfNoTenant');\n const name = this.tenantNames.get(id);\n // Every tenant's rows, not only the user's: the setting alone\n // decides, never the single-tenant rule (crossTenant).\n return name === undefined ? id : displayTenantNameText(name, { crossTenant: true });\n }\n\n // Redraws what the tab shows with the current Show Tenant Names setting:\n // the loading view (its request carries on), the error or the report. A\n // detached tab reads the setting on its next render.\n private redrawForNames(): void {\n if (!this.root?.isConnected) return;\n if (this.reportAbort) {\n this.renderLoading();\n return;\n }\n if (this.loadError !== null) {\n this.renderError();\n return;\n }\n if (!this.report) return;\n this.destroyTables();\n this.renderBody();\n void this.loadCompare();\n }\n\n private windowLabel(days: number): string {\n return days === 1 ? t2('body.perfWindowToday') : t2('body.perfWindowDays', { n: days });\n }\n\n private renderControls(report: APIPerfReport): string {\n const opt = (value: string, label: string, selected: boolean) =>\n ``;\n const windows = WINDOW_DAYS.map(d => opt(String(d), this.windowLabel(d), d === this.days)).join('');\n // The tenants and builds with calls in the window; a pick the window\n // no longer has stays listed so the control shows what is applied.\n const tenantIds = report.tenants.map(t => t.tenantId);\n if (this.tenantId && !tenantIds.includes(this.tenantId)) tenantIds.push(this.tenantId);\n const tenants = [opt('', t2('body.allTenantsLower'), this.tenantId === '')]\n .concat(tenantIds\n .map(id => ({ id, label: this.tenantLabel(id) }))\n .sort((a, b) => a.label.localeCompare(b.label))\n .map(t => opt(t.id, t.label, t.id === this.tenantId)))\n .join('');\n const buildNames = report.builds.map(b => b.build);\n if (this.build && !buildNames.includes(this.build)) buildNames.push(this.build);\n const builds = [opt('', t2('body.perfAllBuilds'), this.build === '')]\n .concat(buildNames.map(b => opt(b, b, b === this.build)))\n .join('');\n return `\n
\n \n \n \n \n \n \n \n
`;\n }\n\n private renderLastRun(report: APIPerfReport): string {\n const run = report.lastRun;\n if (!run) return escapeHtml(t2('body.perfNeverRun'));\n if (run.groups === undefined || run.regressions === undefined) {\n return escapeHtml(t2('body.perfLastRunDay', { day: run.day }));\n }\n return escapeHtml(t2('body.perfLastRun', {\n day: run.day,\n groups: formatNumber(run.groups),\n regressions: formatNumber(run.regressions),\n }));\n }\n\n private renderBody(): void {\n if (!this.root || !this.report) return;\n const report = this.report;\n const buildOptions = (selected: string) => report.builds\n .map(b => ``)\n .join('');\n const compareControls = report.builds.length < 2 ? '' : `\n
\n \n \n \n \n
`;\n this.setRoot(`\n
\n

${escapeHtml(t2('body.perfHint'))}

\n ${this.renderControls(report)}\n
\n ${this.card(REGRESSIONS_CARD_ID, 'body.perfRegressionsTitle', '', `\n

${escapeHtml(t2('body.perfRegressionsHint'))} ${this.renderLastRun(report)}

\n
${report.regressions.length === 0\n ? `${escapeHtml(t2('body.perfNoRegressions'))}`\n : `
`}
`)}\n ${this.card(ROUTES_CARD_ID, 'body.perfRoutesTitle', '', `\n
${report.routes.length === 0\n ? `${escapeHtml(t2('body.perfNoCalls'))}`\n : `
`}
`)}\n ${this.card(COMPARE_CARD_ID, 'body.perfCompareTitle', compareControls, `\n

${escapeHtml(t2('body.perfCompareHint'))}

\n
`)}`);\n this.ensureStyles();\n if (report.regressions.length > 0) this.initRegressionsTable(report.regressions);\n if (report.routes.length > 0) this.initRoutesTable(report.routes);\n }\n\n private initRegressionsTable(rows: APIPerfRegression[]): void {\n this.regressionsDt = ($(`#${REGRESSIONS_TABLE_ID}`) as any).DataTable({\n data: rows,\n dom: DOM_STRING,\n order: [[0, 'desc'], [7, 'desc']],\n pageLength: 25,\n columns: [\n { name: 'day', data: 'day', title: escapeHtml(t2('body.perfColDay')), render: textCell },\n { name: 'tenant', data: 'tenantId', title: escapeHtml(t2('col.tenant')), render: (v: string, type: string) => type === 'display' ? escapeHtml(this.tenantLabel(v)) : this.tenantLabel(v) },\n { name: 'method', data: 'method', title: escapeHtml(t2('body.perfColMethod')), render: textCell },\n { name: 'route', data: 'route', title: escapeHtml(t2('body.perfColRoute')), render: codeCell, width: ROUTE_WIDTH },\n { name: 'shape', data: 'shape', title: headerTip('body.perfColShape', 'body.perfColShapeTip'), render: shapeCell, width: SHAPE_WIDTH },\n { name: 'baselineP95', data: 'baselineP95Ms', title: escapeHtml(t2('body.perfColBaselineP95')), render: (v: number, type: string) => msCell(v, type, true) },\n { name: 'dayP95', data: 'currentP95Ms', title: escapeHtml(t2('body.perfColDayP95')), render: (v: number, type: string) => msCell(v, type, true) },\n {\n name: 'change', data: null, title: escapeHtml(t2('body.perfColChange')),\n render: (_v: unknown, type: string, r: APIPerfRegression) => ratioCell(r.baselineP95Ms, r.currentP95Ms, type),\n },\n { name: 'dayCalls', data: 'currentCalls', title: escapeHtml(t2('body.perfColDayCalls')), render: countCell },\n { name: 'pgShare', data: 'currentPgShare', title: headerTip('body.perfColPg', 'body.perfColPgTip'), render: shareCell, width: SHARE_WIDTH, className: 'dt-right' },\n { name: 'firstBadBuild', data: 'firstBadBuild', title: escapeHtml(t2('body.perfColFirstBadBuild')), render: (v: string, type: string) => type === 'display' ? escapeHtml(v || '\u2014') : v },\n ],\n columnDefs: [\n { targets: [0, 1, 2, 3, 4, 10], ...STRING_TYPE },\n { targets: [5, 6, 7, 8, 9], ...NUM_TYPE },\n { targets: [0, 1, 2, 10], columnControl: CC_LIST },\n { targets: [3, 4], columnControl: CC_TEXT },\n { targets: [5, 6, 7, 8, 9], columnControl: CC_ORDER },\n ],\n });\n setupDataTableUI(REGRESSIONS_TABLE_ID, this.regressionsDt as DataTables.Api, CC_UI);\n installCsvActionBar(REGRESSIONS_TABLE_ID, () => void this.exportCsv('regressions'));\n scrollSideways(REGRESSIONS_TABLE_ID);\n }\n\n private initRoutesTable(rows: APIPerfRoute[]): void {\n this.routesDt = ($(`#${ROUTES_TABLE_ID}`) as any).DataTable({\n data: rows,\n dom: DOM_STRING,\n order: [[7, 'desc']],\n pageLength: 25,\n columns: [\n { name: 'method', data: 'method', title: escapeHtml(t2('body.perfColMethod')), render: textCell },\n { name: 'route', data: 'route', title: escapeHtml(t2('body.perfColRoute')), render: codeCell, width: ROUTE_WIDTH },\n { name: 'shape', data: 'shape', title: headerTip('body.perfColShape', 'body.perfColShapeTip'), render: shapeCell, width: SHAPE_WIDTH },\n { name: 'calls', data: 'calls', title: escapeHtml(t2('body.perfColCalls')), render: countCell },\n { name: 'clientErrors', data: 'clientErrors', title: headerTip('body.perfCol4xx', 'body.perfCol4xxTip'), render: countCell, width: TIP_COL_WIDTH },\n { name: 'serverErrors', data: 'serverErrors', title: headerTip('body.perfCol5xx', 'body.perfCol5xxTip'), width: TIP_COL_WIDTH, render: (v: number, type: string) => type === 'display' && v > 0 ? `${formatNumber(v)}` : countCell(v, type) },\n { name: 'p50', data: 'p50Ms', title: escapeHtml(t2('body.perfColP50')), render: okPct },\n { name: 'p95', data: 'p95Ms', title: escapeHtml(t2('body.perfColP95')), render: okPct },\n { name: 'p99', data: 'p99Ms', title: escapeHtml(t2('body.perfColP99')), render: okPct },\n { name: 'avg', data: 'avgMs', title: escapeHtml(t2('body.perfColAvg')), render: okMs },\n { name: 'max', data: 'maxMs', title: escapeHtml(t2('body.perfColMax')), render: okMs },\n { name: 'pgShare', data: 'pgShare', title: headerTip('body.perfColPg', 'body.perfColPgTip'), render: shareCell, width: SHARE_WIDTH, className: 'dt-right' },\n { name: 'chShare', data: 'chShare', title: headerTip('body.perfColCh', 'body.perfColChTip'), render: shareCell, width: SHARE_WIDTH, className: 'dt-right' },\n { name: 'tenants', data: 'tenants', title: escapeHtml(t2('body.tenants')), render: countCell },\n ],\n columnDefs: [\n { targets: [0, 1, 2], ...STRING_TYPE },\n { targets: [0], columnControl: CC_LIST },\n { targets: [1, 2], columnControl: CC_TEXT },\n { targets: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], ...NUM_TYPE },\n { targets: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], columnControl: CC_ORDER },\n ],\n });\n setupDataTableUI(ROUTES_TABLE_ID, this.routesDt as DataTables.Api, CC_UI);\n installCsvActionBar(ROUTES_TABLE_ID, () => void this.exportCsv('routes'));\n scrollSideways(ROUTES_TABLE_ID);\n }\n\n private initCompareTable(): void {\n // A build with no 2xx calls for the row shows \"\u2014\" on its side.\n const side = (pick: (r: APIPerfCompareRow) => number | undefined, cell: (v: number, type: string) => string | number) =>\n (_v: unknown, type: string, r: APIPerfCompareRow) => {\n const v = pick(r);\n if (v === undefined) return type === 'display' ? '\u2014' : -1;\n return cell(v, type);\n };\n const sideMs = (v: number, type: string) => pctCell(v, type, true);\n this.compareDt = ($(`#${COMPARE_TABLE_ID}`) as any).DataTable({\n data: this.compareRows,\n dom: DOM_STRING,\n order: [[9, 'desc']],\n pageLength: 25,\n columns: [\n { name: 'method', data: 'method', title: escapeHtml(t2('body.perfColMethod')), render: textCell },\n { name: 'route', data: 'route', title: escapeHtml(t2('body.perfColRoute')), render: codeCell, width: ROUTE_WIDTH },\n { name: 'shape', data: 'shape', title: headerTip('body.perfColShape', 'body.perfColShapeTip'), render: shapeCell, width: SHAPE_WIDTH },\n { name: 'callsA', data: null, title: escapeHtml(t2('body.perfColCallsA')), render: side(r => r.a?.calls, countCell) },\n { name: 'p50A', data: null, title: escapeHtml(t2('body.perfColP50A')), render: side(r => r.a?.p50Ms, sideMs) },\n { name: 'p95A', data: null, title: escapeHtml(t2('body.perfColP95A')), render: side(r => r.a?.p95Ms, sideMs) },\n { name: 'callsB', data: null, title: escapeHtml(t2('body.perfColCallsB')), render: side(r => r.b?.calls, countCell) },\n { name: 'p50B', data: null, title: escapeHtml(t2('body.perfColP50B')), render: side(r => r.b?.p50Ms, sideMs) },\n { name: 'p95B', data: null, title: escapeHtml(t2('body.perfColP95B')), render: side(r => r.b?.p95Ms, sideMs) },\n {\n name: 'change', data: null, title: escapeHtml(t2('body.perfColChange')),\n render: (_v: unknown, type: string, r: APIPerfCompareRow) => r.a && r.b\n ? ratioCell(r.a.p95Ms, r.b.p95Ms, type)\n : (type === 'display' ? '\u2014' : -1),\n },\n ],\n columnDefs: [\n { targets: [0, 1, 2], ...STRING_TYPE },\n { targets: [0], columnControl: CC_LIST },\n { targets: [1, 2], columnControl: CC_TEXT },\n { targets: [3, 4, 5, 6, 7, 8, 9], ...NUM_TYPE },\n { targets: [3, 4, 5, 6, 7, 8, 9], columnControl: CC_ORDER },\n ],\n });\n setupDataTableUI(COMPARE_TABLE_ID, this.compareDt as DataTables.Api, CC_UI);\n installCsvActionBar(COMPARE_TABLE_ID, () => void this.exportCsv('compare'));\n scrollSideways(COMPARE_TABLE_ID);\n }\n\n private query(params: Record): string {\n const p = new URLSearchParams();\n for (const [k, v] of Object.entries(params)) if (v !== '') p.set(k, String(v));\n return p.toString();\n }\n\n // One table's CSV: the server reads the rows again with the query the\n // table was drawn from and streams those its filters select, in its\n // order. The page sends what only it knows: the column titles, and the\n // tenant labels as it shows them (Show Tenant Names applied).\n private async exportCsv(table: CsvTable): Promise {\n const dt = { regressions: this.regressionsDt, routes: this.routesDt, compare: this.compareDt }[table];\n if (!dt) return;\n const headers = CSV_COLUMNS[table].map(([key, unit]) => {\n const label = t2(key);\n return unit === 'ms' ? t2('body.perfCsvMs', { label }) : unit === 'pct' ? t2('body.perfCsvPct', { label }) : label;\n });\n const tenantLabels: Record = {};\n for (const id of this.tenantNames.keys()) tenantLabels[id] = this.tenantLabel(id);\n const q = table === 'compare' ? this.compareQuery : this.reportQuery;\n const header = new Header('none');\n try {\n await downloadCsvNative(`/api/api-perf/${table}/csv?${q}`, {\n method: 'POST',\n body: {\n table: clientTableFilterState(dt),\n // The window the table was drawn for; the server\n // refuses an export after it has moved.\n to: table === 'compare' ? this.compareTo : (this.report?.to ?? ''),\n // The columns the filter and sort indexes refer to; the\n // server refuses an export built for another layout.\n layout: ((dt.settings() as any)[0]?.aoColumns ?? []).map((c: any) => String(c.sName ?? '')),\n headers,\n noTenant: t2('body.perfNoTenant'),\n tenantLabels,\n },\n });\n header.showMessage(escapeHtml(t2('body.csvDownloadStarted')), 'info', 5);\n } catch (err) {\n header.showMessage(escapeHtml(t2('body.csvExportFailed', { error: (err as Error)?.message ?? String(err) })), 'error', 8);\n }\n }\n\n private ensureStyles(): void {\n if (document.getElementById('api-perf-styles')) return;\n const style = document.createElement('style');\n style.id = 'api-perf-styles';\n style.textContent = `\n /* #adminBody is a column that sizes each child to its content\n (align-items: flex-start), so a 14-column table widens the\n body and the page slides sideways. On this tab AdminUI adds\n admin-body-fill: the body and the pane take the page's width,\n and a table wider than its card scrolls inside it. */\n #adminBody.admin-body-fill { align-self: stretch; min-width: 0; }\n #adminBody.admin-body-fill > #adminPerformanceTabPane { align-self: stretch; min-width: 0; }\n /* The card never grows past the page (overflow other than\n visible stops the table's width reaching the body); inside\n it only the table scrolls sideways (scrollSideways), so the\n card's title and the table's controls stay put. */\n .api-perf-card { min-width: 0; overflow-x: hidden; }\n .api-perf-scroll { max-width: 100%; overflow-x: auto; }\n .api-perf-head { align-items: flex-start; flex-wrap: wrap; }\n .api-perf-head .blocked-domains-copy { flex: 1 1 420px; }\n .api-perf-controls { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; flex-wrap: wrap; }\n .api-perf-controls label { margin: 0; white-space: nowrap; }\n .api-perf-tenant { max-width: 260px; }\n .api-perf-build { max-width: 220px; }\n /* Icon only, no fill or border: the Partners tab's refresh. */\n .api-perf-refresh { background: none; border: none; color: #6c757d; cursor: pointer; line-height: 1; padding: 4px; }\n .api-perf-refresh:hover, .api-perf-refresh:focus-visible { color: var(--brand-accent-fg, #212529); outline: none; }\n .api-perf-refresh i { font-size: 22px; margin: 0; }\n .api-perf-bad { color: var(--critical, #d03b3b); font-weight: 600; }\n .api-perf-worse { color: var(--critical, #d03b3b); font-weight: 600; }\n .api-perf-better { color: var(--good-text, #006300); }\n .api-perf-muted { color: var(--text-3, #8a8984); }\n /* A route breaks after a / (), so the wide shape column\n can hold a shape on as few lines as it can. A shape breaks\n between its parameters ( after each &), and only\n mid-parameter when one alone is wider than the column. */\n .api-perf-route { display: inline-block; min-width: 24ch; max-width: 30ch; white-space: normal; overflow-wrap: anywhere; }\n .api-perf-shape { display: inline-block; min-width: 44ch; max-width: 72ch; white-space: normal; overflow-wrap: anywhere; }\n /* The header info icon: CVE Fleet's .cvf-col-info. */\n .api-perf-th { white-space: nowrap; }\n .api-perf-col-info { font-size: 14px; color: #888; margin-left: 4px; vertical-align: middle; cursor: help; line-height: 1; }\n .api-perf-col-info:hover { color: #1f6feb; }\n /* Section cards: the Vulnerability Sources tab's recipe. */\n .api-perf-card { border: 1px solid var(--border, #eef0f2); border-radius: 6px; padding: 10px 12px; background: var(--card, #fff); margin-top: 14px; }\n .api-perf-card-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; min-height: 33px; flex-wrap: wrap; }\n .api-perf-card-title { font-size: 12px; font-weight: 700; color: var(--text-1, #374151); text-transform: uppercase; letter-spacing: .02em; }\n .api-perf-card-desc { margin: 4px 0 8px; }\n /* The card's title folds it: Missing Patches' summary chevron. */\n .api-perf-card-toggle { display: flex; align-items: center; gap: 8px; background: none; border: 0; padding: 4px 2px; cursor: pointer; }\n .api-perf-card-toggle:focus { outline: none; }\n .api-perf-card-toggle:focus-visible .api-perf-card-title { text-decoration: underline; }\n .api-perf-chevron { transition: transform 0.15s ease; font-size: 13px; color: #9ca3af; }\n .api-perf-card.collapsed .api-perf-chevron { transform: rotate(-90deg); }\n .api-perf-card.collapsed .api-perf-card-body,\n .api-perf-card.collapsed .api-perf-card-head .api-perf-controls { display: none; }\n `;\n document.head.appendChild(style);\n }\n}\n\n// Wraps a drawn table in its own sideways scroller: DataTables puts the\n// table straight in its container beside the paging and search controls,\n// which would otherwise scroll away with it.\nfunction scrollSideways(tableId: string): void {\n const table = document.getElementById(tableId);\n if (!table || table.parentElement?.classList.contains('api-perf-scroll')) return;\n const wrap = document.createElement('div');\n wrap.className = 'api-perf-scroll';\n table.before(wrap);\n wrap.append(table);\n}\n\n// A column title with the app's info icon, whose tooltip explains it, on\n// one line.\nfunction headerTip(labelKey: string, tipKey: string): string {\n const tip = t2(tipKey);\n return `${escapeHtml(t2(labelKey))}info_outline`;\n}\n\nfunction textCell(v: string, type: string): string {\n return type === 'display' ? escapeHtml(v) : v;\n}\n\nfunction codeCell(v: string, type: string): string {\n if (type !== 'display') return v;\n return `${v.split('/').map(escapeHtml).join('/')}`;\n}\n\nfunction shapeCell(v: string, type: string): string {\n if (type !== 'display') return v;\n if (!v) return `${escapeHtml(t2('body.perfNoShape'))}`;\n return `${v.split('&').map(escapeHtml).join('&')}`;\n}\n\nfunction countCell(v: number, type: string): string | number {\n return type === 'display' ? formatNumber(v) : v;\n}\n\n// A latency in milliseconds: \"8.2 ms\" under 10ms, \"264 ms\" under a second,\n// \"1.6 s\" above; \"<1 ms\" for zero, as the capture records whole milliseconds.\nfunction msText(v: number): string {\n if (v < 1) return t2('body.perfUnderMs');\n if (v < 10) return t2('body.perfMs', { n: formatNumber(v, { maximumFractionDigits: 1 }) });\n if (v < 1000) return t2('body.perfMs', { n: formatNumber(Math.round(v)) });\n return t2('body.perfSec', { n: formatNumber(v / 1000, { maximumFractionDigits: 1 }) });\n}\n\n// msText for display; \"\u2014\" when there were no 2xx calls to time (hasCalls\n// false).\nfunction msCell(v: number, type: string, hasCalls: boolean): string | number {\n if (type !== 'display') return v;\n if (!hasCalls) return '\u2014';\n return escapeHtml(msText(v));\n}\n\n// A percentile: as msCell, but one in the last bucket whose slowest call is\n// past that bucket's bound is only known to be at least the bucket's lower\n// bound (\"\u2265 1,019.6 s\").\nfunction pctCell(v: number, type: string, hasCalls: boolean): string | number {\n if (type !== 'display' || !hasCalls || !isOpenEnded(v)) return msCell(v, type, hasCalls);\n return escapeHtml(t2('body.perfAtLeast', { value: msText(OPEN_FROM_MS) }));\n}\n\n// msCell for a Routes column, whose latency is of the row's 2xx calls.\nfunction okMs(v: number, type: string, r: APIPerfRoute): string | number {\n return msCell(v, type, r.ok > 0);\n}\n\n// pctCell for a Routes percentile column.\nfunction okPct(v: number, type: string, r: APIPerfRoute): string | number {\n return pctCell(v, type, r.ok > 0);\n}\n\nfunction shareCell(v: number | null, type: string): string | number {\n if (type !== 'display') return v ?? -1;\n return v === null || v === undefined ? '\u2014' : formatNumber(v, { style: 'percent', maximumFractionDigits: 0 });\n}\n\n// after / before, shown as \"2.4\u00D7\", red when slower by a bucket (25%) or\n// more, green when faster by as much (p95Change).\nfunction ratioCell(before: number, after: number, type: string): string | number {\n const change = p95Change(before, after);\n if (!change) return type === 'display' ? '\u2014' : -1;\n if (type !== 'display') return change.ratio;\n const text = escapeHtml(t2('body.perfTimes', { n: formatNumber(change.ratio, { maximumFractionDigits: 1 }) }));\n return change.tone ? `${text}` : text;\n}\n", "/**\n * The Admin Performance tab's two pure rules (APIPerfAdminUI).\n *\n * Dependency-free so the node test runner can load it from the tsc output.\n */\n\n/**\n * The builds to compare, from `builds` (newest first): the current picks\n * while the list still has them, else the newest build (\"after\") against\n * the newest other one (\"before\"). Both are \"\" with no builds; with one\n * build, both are that build.\n */\nexport function pickCompareBuilds(builds: string[], before: string, after: string): { before: string; after: string } {\n const b = builds.includes(after) ? after : (builds[0] ?? '');\n const a = builds.includes(before) ? before : (builds.find(n => n !== b) ?? b);\n return { before: a, after: b };\n}\n\n// The latency histogram (db.APIPerfBuckets, db.APIPerfBucketRatio): bucket i\n// holds calls up to RATIO^i ms, and the last bucket every longer call too.\nconst BUCKET_RATIO = 1.25;\nconst BUCKETS = 64;\n/** The last bucket's bound: only an open-ended percentile reads above it. */\nexport const LAST_BOUND_MS = BUCKET_RATIO ** (BUCKETS - 1);\n/** The open-ended bucket's lower bound: such a percentile is at least this. */\nexport const OPEN_FROM_MS = BUCKET_RATIO ** (BUCKETS - 2);\n\n/**\n * Whether a percentile reads past the last bound: it lies in the last bucket\n * and its slowest call is past that bound. The server then reports the\n * slowest call, the only way a value passes LAST_BOUND_MS (db apiPerfAtMost),\n * so the percentile is only known to be at least OPEN_FROM_MS.\n */\nexport function isOpenEnded(ms: number): boolean {\n return ms > LAST_BOUND_MS;\n}\n\n// Bucket bounds are floating point, so a one-bucket ratio can come out a\n// hair under 1.25 (or over 0.8).\nconst RATIO_TOLERANCE = 1e-9;\n\n/**\n * How a p95 change reads: 'worse' when after is at least one histogram\n * bucket (1.25\u00D7) above before, 'better' when at least one bucket below\n * (0.8\u00D7), '' otherwise; null when either side is missing, zero or\n * open-ended, which has no ratio.\n */\nexport function p95Change(before: number, after: number): { ratio: number; tone: 'worse' | 'better' | '' } | null {\n if (!(before > 0) || !(after > 0) || isOpenEnded(before) || isOpenEnded(after)) return null;\n const ratio = after / before;\n return {\n ratio,\n tone: ratio >= BUCKET_RATIO - RATIO_TOLERANCE ? 'worse' : ratio <= 1 / BUCKET_RATIO + RATIO_TOLERANCE ? 'better' : '',\n };\n}\n", "// TAMAdminUI \u2014 the Admin page's \"My Tenants\" tab. A user subscribes to the\n// tenants they want to hear about; the subscription set pre-filters the\n// web/mobile notification lists and narrows tenant-scoped mobile pushes to\n// those tenants (a TAM with zero subscriptions receives none \u2014 see\n// db.GetPushDevicesForNotification for the full zero-subscription semantics).\n//\n// Server surface: /api/users/me/tenant-subscriptions (RoleInternal-gated,\n// me-scoped \u2014 everyone manages only their own set). Replace semantics: every\n// toggle PUTs the full set, mirroring the MSSPs tab.\n//\n// The tenant roster comes from the caller's OWN tenants (their MPE entitlement\n// plus any MSSP grant), NOT from /api/tenants. That endpoint deliberately\n// returns the full environment roster via the server's M2M bearer because the\n// MSSPs tab needs it to map MSSPs onto arbitrary tenants; reusing it here would\n// show every customer name in the environment to every internal user, which is\n// a disclosure this tab has no reason to make. You can only subscribe to\n// tenants you can already see \u2014 and subscribing to one you cannot see would be\n// inert anyway, since the push audience filter is applied independently against\n// the device's accessible-tenant snapshot.\n//\n// That roster is narrowed once more, server-side, to the tenants whose MP\n// posture is managed or monitored \u2014 the only ones a notification is ever\n// produced for. A \"none\" tenant (or one nobody has classified yet) would be a\n// checkbox that can only stay silent, so the list omits it rather than inviting\n// a subscription that does nothing. The narrowing lives on the server because\n// the posture does: see api.subscribableTenants.\n\nimport { t as t2 } from '../i18n/I18n';\nimport StoreManager from '../managers/StoreManager';\nimport { TAMSubscriptionConflictError } from '../stores/UserProfileStore';\nimport type { SubscribableTenant } from '../stores/UserProfileStore';\nimport { escapeHtml } from '../utils/FormatUtils';\n\nexport class TAMAdminUI {\n private root: HTMLElement | null = null;\n private tenants: SubscribableTenant[] = [];\n private subscribed = new Set();\n private filter = \"\";\n // Whose subscriptions these are. Set from the server, which follows the\n // View-As target \u2014 the tab must never imply you are editing your own set\n // when you are editing someone else's.\n private subjectName = \"\";\n private onBehalfOf = false;\n // A PUT is in flight. Each PUT replaces the whole set, built from\n // `subscribed`, which only moves when the reply lands \u2014 so the boxes stay\n // disabled until then, or a second quick toggle would send a set without\n // the first and one of the two would be lost.\n private saving = false;\n\n constructor(\n private readonly onError: (message: string) => void,\n ) { }\n\n async render(root: HTMLElement): Promise {\n this.root = root;\n root.innerHTML = `${t2('dt.loading')}`;\n try {\n const profile = StoreManager.getInstance().userProfileStore;\n const { subscribed, tenants, subjectName, onBehalfOf } = await profile.getTAMSubscriptionRoster();\n this.tenants = tenants;\n this.subscribed = new Set(subscribed);\n this.subjectName = subjectName;\n this.onBehalfOf = onBehalfOf;\n } catch (err) {\n root.innerHTML = `${t2('body.failedToLoad', { error: escapeHtml((err as Error).message) })}`; // copilot-lint-ignore: the only param, err.message, is escapeHtml'd; the t2() string is ours\n return;\n }\n this.renderBody();\n }\n\n private renderBody(): void {\n if (!this.root) return;\n const banner = this.onBehalfOf\n ? `

${t2('body.tamSettingForUser', { user: esc(this.subjectName) })}

`\n : \"\";\n if (this.tenants.length === 0) {\n // No filter box, no count: there is nothing to filter or count.\n this.root.innerHTML = `\n

${t2('body.tamTabHint')}

\n ${banner}\n
${t2('body.tamNoSubscribableTenants')}
`;\n return;\n }\n const needle = this.filter.trim().toLowerCase();\n const visible = needle\n ? this.tenants.filter(tn => tn.name.toLowerCase().includes(needle) || tn.id.toLowerCase().includes(needle))\n : this.tenants;\n const rows = visible.map(tn => `\n `).join(\"\");\n this.root.innerHTML = `\n

${t2('body.tamTabHint')}

\n ${banner}\n
\n \n ${t2('body.tamSubscribedCount', {\n // Counted against the roster, which the server builds to\n // include every tenant already subscribed to \u2014 so a\n // subscription whose tenant has since gone unmanaged still\n // gets a checkbox and still gets counted, rather than\n // showing a total no box accounts for.\n count: String(this.tenants.filter(tn => this.subscribed.has(tn.id)).length),\n total: String(this.tenants.length),\n })}\n
\n
\n ${rows || `${t2('body.tamNoMatches')}`}\n
`;\n this.wire();\n }\n\n private wire(): void {\n const root = this.root;\n if (!root) return;\n\n const filter = root.querySelector(\"#tamTenantFilter\");\n filter?.addEventListener(\"input\", () => {\n this.filter = filter.value;\n const pos = filter.selectionStart;\n this.renderBody();\n const again = this.root?.querySelector(\"#tamTenantFilter\");\n again?.focus();\n if (pos !== null && again) again.setSelectionRange(pos, pos);\n });\n\n root.querySelectorAll(\".tam-tenant-check\").forEach(box => {\n box.addEventListener(\"change\", async () => {\n if (this.saving) {\n box.checked = !box.checked;\n return;\n }\n const id = box.dataset.tenant ?? \"\";\n const next = new Set(this.subscribed);\n if (box.checked) next.add(id); else next.delete(id);\n this.saving = true;\n root.querySelectorAll(\".tam-tenant-check\").forEach(b => { b.disabled = true; });\n try {\n await StoreManager.getInstance().userProfileStore.setTAMSubscriptions([...next]);\n this.subscribed = next;\n } catch (err) {\n this.saving = false;\n // The server's 409 text is English; show the translated\n // message and keep the server's for the console.\n if (err instanceof TAMSubscriptionConflictError) {\n console.warn('TAM subscriptions conflict:', err.message);\n this.onError(t2('body.tamSubscriptionsConflict'));\n } else {\n this.onError((err as Error).message);\n }\n if (err instanceof TAMSubscriptionConflictError) {\n // The subject or the stored set moved under this\n // tab: reload both, so the boxes show what is\n // stored and the next click edits that.\n await this.render(root);\n return;\n }\n }\n // Re-render either way: success updates the count line, and a\n // failed PUT snaps the checkbox back to the stored truth.\n this.saving = false;\n this.renderBody();\n });\n });\n }\n}\n\nfunction esc(s: string): string {\n return s.replace(/[&<>\"']/g, c =>\n ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" }[c]!)\n );\n}\n", "/**\n * What a secrets resync answer means for the admin: \"heard\" when the\n * server broadcast it (receivers counts the subscribed processes, the\n * serving pod included), else \"podOnly\": only the serving pod resynced, and\n * the other replicas keep their secrets until the deployment restarts.\n *\n * Dependency-free so the node test runner can load it from the tsc output.\n */\nexport function secretsResyncOutcome(body: unknown): { kind: 'heard' | 'podOnly'; receivers: number } {\n const b = (body && typeof body === 'object' ? body : {}) as { broadcast?: unknown; receivers?: unknown };\n if (b.broadcast === true) {\n const n = Number(b.receivers);\n return { kind: 'heard', receivers: Number.isFinite(n) && n > 0 ? n : 0 };\n }\n return { kind: 'podOnly', receivers: 0 };\n}\n", "import ArtifactStore from '../stores/ArtifactsStore';\nimport { t, t as t2 } from '../i18n/I18n';\nimport { NodeIncident } from '../models/NodeIncident';\nimport { Artifact, ArtifactCategory } from '../models/Artifact';\nimport { formatFileSize } from '../utils/FormatFileSize';\nimport StoreManager from '../managers/StoreManager';\nimport { Header } from '../utils/Header';\nimport { escapeHtml } from '../utils/FormatUtils';\nimport { Modal } from '../utils/Modal';\nimport { createNodeListItem } from '../utils/ElementUtils';\nimport { storage } from '../utils/Storage';\nimport { hasValidAccessToken, refreshAccessToken } from '../utils/Auth';\n\ndeclare global {\n interface JQuery {\n DataTable(options?: any): any;\n }\n}\n\nexport class ArtifactsUI {\n private artifactStore: ArtifactStore;\n private container: HTMLElement;\n private header = new Header(\"artifacts\");\n private jsonModal: Modal;\n // Bumped by each updateUI; a loop from an older call stops on mismatch.\n private generation = 0;\n\n constructor() {\n this.artifactStore = StoreManager.getInstance().artifactsStore;\n // Bind the message handler\n window.addEventListener(\"message\", this.handleIframeMessage.bind(this), false);\n }\n\n private handleIframeMessage(event: MessageEvent): void {\n // Validate the message origin for security\n const expectedOrigin = window.location.origin;\n if (event.origin !== expectedOrigin) {\n console.warn(`Unexpected origin: ${event.origin}`);\n return;\n }\n\n // If there's no `type`, or it's not one of our recognized ones, just return:\n // JSZip\u2019s internal messages don\u2019t have a type, so we ignore them\n if (!event.data || !event.data.type) {\n return;\n }\n\n // Handle the message types\n switch (event.data.type) {\n case \"hideLoadingIndicator\":\n $(\"#modalLoadingIndicator\").hide();\n break;\n\n case \"showLoadingIndicator\":\n $(\"#modalLoadingIndicator\").show();\n break;\n\n case \"dismissCsvModal\":\n $(\"#csvModal\").modal(\"hide\");\n break;\n\n case \"dataTableReady\":\n $(\"#modalLoadingIndicator\").hide();\n console.log(\"DataTables initialization and column resizing completed.\");\n break;\n\n default:\n console.warn(\"Unknown message type:\", event.data.type);\n }\n }\n\n public async updateUI(title: string, nodes: NodeIncident[]): Promise {\n this.container = document.getElementById(\"artifactsContainer\") as HTMLElement;\n\n if (!this.container) {\n console.error('Artifacts container not found in the DOM.');\n return;\n }\n\n this.header.set({\n title: t('nav.artifacts'),\n icon: {\n materialIcon: \"history\"\n }\n });\n\n // A newer updateUI (another incident) clears the container while this\n // loop awaits; the stale loop stops instead of appending its hosts.\n const generation = ++this.generation;\n\n this.container.innerHTML = '';\n this.jsonModal = new Modal(\"json-modal\")\n\n if (nodes.length === 0) {\n console.error(`No nodes found for: ${title}`);\n return;\n }\n\n // Loop in a deterministic sequence so they appear in order\n for (const node of nodes) {\n const spin = this.header.showLoading();\n let artifactCategories: ArtifactCategory[] | null = null;\n try {\n // Await the async version of get (null marks a failed load)\n artifactCategories = await this.artifactStore.getAsync(node.incidentId, node.id);\n } catch (error) {\n console.error('Error loading artifacts:', error, 'for', node.hostname);\n }\n // Released in the epoch it was taken in: a stale loop never takes the\n // count of a load started after a navigation.\n this.header.hideLoading(spin);\n if (generation !== this.generation) return;\n\n if (artifactCategories && artifactCategories.length > 0) {\n this.display(title, node, artifactCategories);\n } else {\n // A failed load (null) reads differently from an empty node.\n // Appended as text, so a hostname is never parsed as HTML and\n // earlier rows keep their View listeners (innerHTML += drops them).\n if (!artifactCategories) console.error('Failed to load artifacts info for', node.hostname);\n const msg = document.createElement('p');\n msg.textContent = artifactCategories\n ? t2('body.noArtifactsAvailableFor', { hostname: node.hostname })\n : t2('body.failedToLoadArtifactsFor', { hostname: node.hostname });\n this.container.appendChild(msg);\n }\n }\n }\n\n private display(title: string, nodeIncident: NodeIncident, categories: ArtifactCategory[]): void {\n this.updateHeader(title);\n\n const container = document.createElement('div');\n container.className = 'artifacts-container';\n\n const mainUl = document.createElement('ul');\n mainUl.className = 'no-bullets';\n mainUl.setAttribute('role', 'tree');\n mainUl.setAttribute('aria-labelledby', 'tree_label');\n\n const nodeLi = createNodeListItem(nodeIncident);\n\n // Create a UL to hold the children. This is what the toggle logic will hide/show.\n const childUl = document.createElement('ul');\n childUl.setAttribute('role', 'group');\n childUl.style.listStyleType = 'none';\n childUl.style.paddingLeft = '20px'; // Indent children\n childUl.style.display = 'block'; // Match the initial expanded state\n\n // Create and populate the div that holds the actual category content.\n const artifactsCategoryDiv = document.createElement('div');\n artifactsCategoryDiv.style.marginTop = '10px'; // Space out from the node\n artifactsCategoryDiv.style.marginLeft = '20px';\n\n categories.forEach(category => {\n this.displayCategory(nodeIncident, category, artifactsCategoryDiv);\n });\n\n // Structure the elements correctly.\n childUl.appendChild(artifactsCategoryDiv); // Categories go inside the child UL\n nodeLi.appendChild(childUl); // The child UL goes inside the Node LI\n mainUl.appendChild(nodeLi); // The Node LI goes inside the main UL\n\n container.appendChild(mainUl);\n this.container.appendChild(container);\n }\n\n /**\n * Renders a single category (title + each artifact row).\n */\n private displayCategory(node: NodeIncident, category: ArtifactCategory, container: HTMLElement): void {\n // Category header\n const categoryElement = document.createElement('h3');\n const tenantId = storage.get('selectedTenantId')\n categoryElement.className = 'page-sub-heading-small';\n categoryElement.style.marginTop = '20px';\n categoryElement.textContent = category.title;\n container.appendChild(categoryElement);\n\n // Artifacts table rows\n category.artifacts.forEach(artifact => {\n // Make a small table for each artifact row\n const artifactTable = document.createElement('table');\n artifactTable.style.borderCollapse = 'collapse';\n\n const row = document.createElement('tr');\n\n // Name/size cell\n const nameSizeCell = document.createElement('td');\n const name = artifact.name;\n const fileType = name.split('.').pop();\n const icon = this.getFileIcon(fileType);\n\n const nameSizeLi = document.createElement('li');\n nameSizeLi.style.listStyleType = 'none';\n nameSizeLi.style.width = '460px';\n nameSizeLi.style.overflow = 'hidden';\n nameSizeLi.style.textOverflow = 'ellipsis';\n nameSizeLi.style.whiteSpace = 'nowrap';\n nameSizeLi.style.display = 'flex';\n nameSizeLi.style.alignItems = 'center';\n\n nameSizeLi.appendChild(icon);\n\n const compressed = formatFileSize(artifact.compressedSize);\n const original = formatFileSize(artifact.size);\n\n const textNode = document.createTextNode(\n artifact.isCompressed\n ? ` ${t2('body.fileNameWithSizes', { name, compressed, original })}`\n : ` ${t2('body.fileNameWithSize', { name, size: original })}`\n );\n nameSizeLi.appendChild(textNode);\n\n nameSizeCell.appendChild(nameSizeLi);\n row.appendChild(nameSizeCell);\n\n // Download cell\n const downloadCell = document.createElement('td');\n const downloadLink = document.createElement('a');\n\n downloadLink.href = `/api/tenants/${tenantId}/incidents/${node.incidentId}/node-incidents/${node.id}/artifact-download/${node.hostname}/${artifact.filename}?artifactID=${artifact.id}`;\n downloadLink.textContent = t2('col.download');\n downloadLink.className = 'artifact-download-link';\n this.applyLinkStyle(downloadLink);\n downloadCell.appendChild(downloadLink);\n row.appendChild(downloadCell);\n\n // Show the \"View\" link if:\n // - The artifact is not compressed\n // OR\n // - The artifact is compressed AND the compressedSize < 20 MB\n console.debug('Artifact:', artifact);\n const showViewCell =\n !artifact.isCompressed ||\n (artifact.isCompressed && artifact.size < (20 << 20)); // 20 MB in bytes\n\n if (showViewCell) {\n const viewCell = document.createElement('td');\n let view: HTMLElement;\n const tenantId = storage.get('selectedTenantId')\n const viewUrl = `/api/tenants/${tenantId}/incidents/${node.incidentId}/node-incidents/${node.id}/artifact-view/${artifact.filename}?artifactID=${artifact.id}`;\n\n if (artifact.filename.endsWith('.json') || artifact.filename.endsWith('.json.gz')) {\n const viewButton = document.createElement(\"button\");\n viewButton.className = \"btn artifact-view-link\"\n this.applyLinkStyle(viewButton);\n viewButton.addEventListener('focus', () => {\n // force-override the focus style\n viewButton.style.setProperty('box-shadow', 'none', 'important');\n viewButton.style.setProperty('outline', 'none', 'important');\n })\n viewButton.addEventListener(\"click\", () => {\n this.viewJson(viewUrl, artifact);\n });\n view = viewButton;\n } else {\n const viewLink = document.createElement('a');\n // If CSV, load in modal. Otherwise open a new tab.\n if (artifact.filename.endsWith('.csv')) {\n viewLink.href = 'javascript:void(0);';\n viewLink.addEventListener('click', (event) => {\n event.preventDefault();\n this.loadCSVIntoModal(viewUrl, artifact.name, artifact.size);\n });\n } else {\n // Normal file, open new tab\n viewLink.href = viewUrl;\n }\n viewLink.target = '_blank';\n viewLink.className = 'artifact-view-link';\n this.applyLinkStyle(viewLink);\n view = viewLink;\n }\n\n view.textContent = t2('common.view');\n viewCell.appendChild(view);\n row.appendChild(viewCell);\n }\n\n artifactTable.appendChild(row);\n container.appendChild(artifactTable);\n });\n }\n\n private viewJson(url: string, artifact: Artifact) {\n const spin = this.header.showLoading();\n this.artifactStore.viewJson(url, (scan) => {\n if (scan === undefined) {\n this.jsonModal.setTitle(t2('common.error'));\n this.jsonModal.setBody(t2('body.thereWasAnErrorInGetting'));\n this.jsonModal.setButtons([]);\n } else {\n this.jsonModal.setTitle(artifact.name);\n const html = renderjson(scan);\n this.jsonModal.setBodyHtml(html);\n\n this.jsonModal.setButtons([{\n label: t2('body.viewInNewTab'),\n icon: {\n fontAwesome: \"fa-arrow-up-right-from-square\",\n after: true,\n },\n onClick: () => {\n window.open(url, \"_blank\");\n }\n }]);\n }\n\n this.header.hideLoading(spin);\n this.jsonModal.show();\n });\n }\n\n\n // Bumped on every CSV open; see loadCSVIntoModal.\n private csvLoadGen = 0;\n\n private loadCSVIntoModal(url: string, artifactName: string, fileSize: number): void {\n // Show the modal's loading indicator\n $(\"#csvModalBody .modal-loading-overlay\").show();\n\n // Set the modal title\n const csvModalLabelElement = document.getElementById(\"csvModalLabel\");\n if (csvModalLabelElement) {\n csvModalLabelElement.textContent = artifactName;\n } else {\n console.error(\"csvModalLabel element not found!\");\n }\n\n // Show the modal (Bootstrap)\n $(\"#csvModal\").modal(\"show\");\n\n // Clear previous iframe if any\n const $modalBody = $(\"#csvModalBody\");\n $modalBody.find(\"iframe\").remove();\n\n // Construct the iframe URL with `url` and optionally `size`\n let iframeUrl = `/static/html/csv-iframe.html?url=${encodeURIComponent(url)}`;\n if (fileSize !== undefined) {\n iframeUrl += `&size=${fileSize}`;\n }\n\n // Create and append the iframe\n const $iframe = $(`\n \n `);\n // The iframe sends the parent's stored bearer as is, so an expired\n // one is refreshed first (apiFetch's 401 refresh never runs there).\n // Only the newest open may append, should a slower refresh finish\n // after the user opened another artifact.\n const gen = ++this.csvLoadGen;\n const ready = hasValidAccessToken()\n ? Promise.resolve()\n : refreshAccessToken().then(() => undefined, () => undefined);\n void ready.then(() => {\n if (gen === this.csvLoadGen) $modalBody.append($iframe);\n });\n\n // Optionally, hide the spinner after a fallback delay\n setTimeout(() => { $(\"#csvModalBody .modal-loading-overlay\").hide(); }, 10000); // 10 seconds fallback\n }\n\n private applyLinkStyle(linkElement: HTMLElement) {\n linkElement.style.color = '#007bff'; // Blue text\n linkElement.style.textDecoration = 'underline'; // Underline text\n linkElement.style.cursor = 'pointer'; // Pointer cursor on hover\n linkElement.style.background = 'none'; // No background\n linkElement.style.border = 'none'; // No border\n linkElement.style.padding = '0px 10px'; // Padding for aesthetics\n linkElement.style.display = 'inline-block'; // Align correctly with text\n }\n\n private getFileIcon(fileType: string | undefined): HTMLElement {\n const icon = document.createElement('i');\n icon.style.fontSize = '16px'; // Set the icon size\n icon.style.paddingRight = '5px'; // Space between icon and text\n\n switch (fileType) {\n case 'json':\n icon.className = 'fas fa-file-code'; // Example: Font Awesome code file icon\n icon.style.color = 'green';\n break;\n case 'csv':\n icon.className = 'fas fa-file-csv'; // Example: Font Awesome CSV file icon\n icon.style.color = 'blue';\n break;\n case 'txt':\n icon.className = 'fas fa-file-alt'; // Example: Font Awesome text file icon\n icon.style.color = 'dodgerblue';\n break;\n case 'pml':\n icon.className = 'fas fa-file-medical'; // Example: Font Awesome medical file icon (just as an example)\n icon.style.color = 'red';\n break;\n case 'html':\n icon.className = 'fab fa-html5'; // HTML files, similar to JSON in this example\n icon.style.color = 'orange';\n break;\n case 'gz':\n case 'zip':\n case 'tar':\n icon.className = 'fas fa-file-archive';\n icon.style.color = '#d66'; // or some color you like\n break;\n default:\n icon.className = 'fas fa-file'; // Generic file icon for other file types\n icon.style.color = 'gray';\n }\n return icon;\n }\n\n\n private updateHeader(title: string): void {\n // setSubtitle writes HTML; a report's title is user text.\n this.header.setSubtitle(escapeHtml(title));\n }\n\n}\n", "import { getLocale } from '../i18n/I18n';\n\nexport function formatFileSize(size: number): string {\n const units = ['bytes', 'KB', 'MB', 'GB', 'TB'];\n let unitIndex = 0;\n let formattedSize = size;\n\n while (formattedSize >= 1024 && unitIndex < units.length - 1) {\n formattedSize /= 1024;\n unitIndex++;\n }\n\n // Locale-aware thousands separator + up to two decimal places (es uses \".\" as\n // the grouping separator, en/zh use \",\"). The unit labels stay English.\n return new Intl.NumberFormat(getLocale(), { maximumFractionDigits: 2 }).format(formattedSize) + ' ' + units[unitIndex];\n}\n", "// Helpers that render the `warnings` field attached to a NodeModel into a\n// consistent UI badge. Used by the Nodes-page row, the Directory Tree\n// header, and any other node-scoped view so the user sees the same\n// diagnostic (and tooltip) wherever the node appears. Server-side warning\n// computation lives in db/node_warnings.go; the FE just renders.\n\nimport type { Node as NodeModel, NodeWarning } from '../models/Node';\n\nfunction escapeHtml(s: string): string {\n return String(s ?? '')\n .replace(/&/g, '&')\n .replace(//g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n}\n\n/**\n * Build the rich HTML the warning badge's tooltip should display. Each\n * warning becomes its own `
  • ` with a bold title and the detail body\n * below, so a node with multiple warnings reads as a scannable list\n * instead of a wall of text. Exported so the Nodes-page render (which\n * interpolates into an HTML attribute) and any other call site stay\n * formatted identically. User-supplied text is HTML-escaped; the\n * markup wrapper is intentional and trusted.\n */\nexport function buildNodeWarningTooltipHtml(warnings: NodeWarning[]): string {\n if (!warnings.length) return '';\n const items = warnings\n .map(w => {\n const title = escapeHtml(w.title);\n const detail = escapeHtml(w.detail);\n // +
    + detail keeps each warning visually\n // grouped while letting the bullet alignment do the work\n // separating items.\n return `
  • ${title}
    ${detail}
  • `;\n })\n .join('');\n return `
      ${items}
    `;\n}\n\n/**\n * Build a construction-icon HTMLElement summarizing every warning on this\n * node. Returns null when the node is clean (no warnings) so callers can\n * trivially skip with `if (!badge) return;`. Severity sets the color\n * (amber for warning, red for error); kinds are stamped onto the element\n * so future styles (high_cpu, obsolete_release) can override per-kind.\n */\nexport function buildNodeWarningBadge(node: NodeModel | null | undefined): HTMLElement | null {\n if (!node || !node.hasWarnings?.()) return null;\n const warnings: NodeWarning[] = node.warnings ?? [];\n if (warnings.length === 0) return null;\n\n const sev = warnings.some(w => (w.severity || '').toLowerCase() === 'error')\n ? 'error'\n : 'warning';\n const kinds = Array.from(new Set(warnings.map(w => w.kind))).join(' ');\n\n const el = document.createElement('i');\n el.className = `material-icons-outlined nt-warning-badge nt-warning-badge_${sev}`;\n // Triangle-with-bang reads as \"this node is misconfigured\" at a\n // glance; `construction` (toolbox) read as \"work in progress\".\n el.textContent = 'warning';\n el.style.cursor = 'help';\n // CustomTooltips assigns innerHTML from data-tooltip, so the bulleted\n // list renders as a real
      . setAttribute handles attribute\n // escaping automatically \u2014 no need for a single-vs-double-quote dance.\n el.setAttribute('data-tooltip', buildNodeWarningTooltipHtml(warnings));\n el.setAttribute('data-kinds', kinds);\n el.setAttribute('aria-label', `Node has ${warnings.length} warning${warnings.length === 1 ? '' : 's'}`);\n return el;\n}\n\n/**\n * Append a node-warning badge to an existing node-header element when the\n * node has warnings. No-op for clean nodes. Used by NodeTreescanUI and\n * any other page that already renders a node chip via createNodeItem.\n */\nexport function injectNodeWarningBadge(host: HTMLElement, node: NodeModel | null | undefined): void {\n const badge = buildNodeWarningBadge(node);\n if (!badge) return;\n // Pad away from the hostname/copy buttons so the icon doesn't visually\n // collide with the trailing copy chip \u2014 same offset the Nodes-page\n // CSS rule uses.\n badge.style.marginLeft = '8px';\n badge.style.verticalAlign = 'middle';\n badge.style.fontSize = '18px';\n host.appendChild(badge);\n}\n\n/**\n * Inject the global styles for `.nt-warning-badge` once. Idempotent \u2014 keyed\n * off a stable